From 2513b89ca2c929b7f752beaeb4d078ee18cf0cf0 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 23 Dec 2025 14:45:19 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=98=EB=B3=B5=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/unified/UnifiedRepeater.tsx | 628 ++++++++++ .../UnifiedRepeaterConfigPanel.tsx | 1076 +++++++++++++++++ frontend/lib/registry/components/index.ts | 3 + .../UnifiedRepeaterRenderer.tsx | 105 ++ .../components/unified-repeater/index.ts | 98 ++ frontend/types/unified-repeater.ts | 230 ++++ 6 files changed, 2140 insertions(+) create mode 100644 frontend/components/unified/UnifiedRepeater.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx create mode 100644 frontend/lib/registry/components/unified-repeater/UnifiedRepeaterRenderer.tsx create mode 100644 frontend/lib/registry/components/unified-repeater/index.ts create mode 100644 frontend/types/unified-repeater.ts diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx new file mode 100644 index 00000000..81023c62 --- /dev/null +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -0,0 +1,628 @@ +"use client"; + +/** + * UnifiedRepeater 컴포넌트 + * + * 기존 컴포넌트 통합: + * - simple-repeater-table: 인라인 모드 + * - modal-repeater-table: 모달 모드 + * - repeat-screen-modal: 화면 기반 모달 모드 + * - related-data-buttons: 버튼 모드 + * + * 모든 하드코딩을 제거하고 설정 기반으로 동작합니다. + */ + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Plus, Trash2, Edit, Eye, GripVertical } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + UnifiedRepeaterConfig, + UnifiedRepeaterProps, + RepeaterButtonConfig, + ButtonActionType, + DEFAULT_REPEATER_CONFIG, +} from "@/types/unified-repeater"; +import { apiClient } from "@/lib/api/client"; +import { commonCodeApi } from "@/lib/api/commonCode"; + +// 모달 크기 매핑 +const MODAL_SIZE_MAP = { + sm: "max-w-md", + md: "max-w-lg", + lg: "max-w-2xl", + xl: "max-w-4xl", + full: "max-w-[95vw]", +}; + +export const UnifiedRepeater: React.FC = ({ + config: propConfig, + parentId, + data: initialData, + onDataChange, + onRowClick, + onButtonClick, + className, +}) => { + // 설정 병합 + const config: UnifiedRepeaterConfig = useMemo( + () => ({ + ...DEFAULT_REPEATER_CONFIG, + ...propConfig, + dataSource: { ...DEFAULT_REPEATER_CONFIG.dataSource, ...propConfig.dataSource }, + features: { ...DEFAULT_REPEATER_CONFIG.features, ...propConfig.features }, + modal: { ...DEFAULT_REPEATER_CONFIG.modal, ...propConfig.modal }, + button: { ...DEFAULT_REPEATER_CONFIG.button, ...propConfig.button }, + }), + [propConfig], + ); + + // 상태 + const [data, setData] = useState(initialData || []); + const [loading, setLoading] = useState(false); + const [selectedRows, setSelectedRows] = useState>(new Set()); + const [editingRow, setEditingRow] = useState(null); + const [editedData, setEditedData] = useState>({}); + const [modalOpen, setModalOpen] = useState(false); + const [modalRow, setModalRow] = useState(null); + const [codeButtons, setCodeButtons] = useState<{ label: string; value: string; variant?: string }[]>([]); + + // 데이터 로드 + const loadData = useCallback(async () => { + if (!config.dataSource?.tableName || !parentId) return; + + setLoading(true); + try { + const response = await apiClient.get(`/dynamic-form/${config.dataSource.tableName}`, { + params: { + [config.dataSource.foreignKey]: parentId, + }, + }); + + if (response.data?.success && response.data?.data) { + const items = Array.isArray(response.data.data) ? response.data.data : [response.data.data]; + setData(items); + onDataChange?.(items); + } + } catch (error) { + console.error("UnifiedRepeater 데이터 로드 실패:", error); + } finally { + setLoading(false); + } + }, [config.dataSource?.tableName, config.dataSource?.foreignKey, parentId, onDataChange]); + + // 공통코드 버튼 로드 + const loadCodeButtons = useCallback(async () => { + if (config.button?.sourceType !== "commonCode" || !config.button?.commonCode?.categoryCode) return; + + try { + const response = await commonCodeApi.codes.getList(config.button.commonCode.categoryCode); + if (response.success && response.data) { + const labelField = config.button.commonCode.labelField || "codeName"; + setCodeButtons( + response.data.map((code) => ({ + label: labelField === "codeName" ? code.codeName : code.codeValue, + value: code.codeValue, + variant: config.button?.commonCode?.variantMapping?.[code.codeValue], + })), + ); + } + } catch (error) { + console.error("공통코드 버튼 로드 실패:", error); + } + }, [config.button?.sourceType, config.button?.commonCode]); + + // 초기 로드 + useEffect(() => { + if (!initialData) { + loadData(); + } + }, [loadData, initialData]); + + useEffect(() => { + loadCodeButtons(); + }, [loadCodeButtons]); + + // 행 선택 토글 + const toggleRowSelection = (index: number) => { + if (!config.features?.selectable) return; + + setSelectedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + if (!config.features?.multiSelect) { + newSet.clear(); + } + newSet.add(index); + } + return newSet; + }); + }; + + // 행 추가 + const handleAddRow = async () => { + if (config.renderMode === "modal" || config.renderMode === "mixed") { + setModalRow(null); + setModalOpen(true); + } else { + // 인라인 추가 + const newRow: any = {}; + config.columns.forEach((col) => { + newRow[col.key] = ""; + }); + if (config.dataSource?.foreignKey && parentId) { + newRow[config.dataSource.foreignKey] = parentId; + } + + const newData = [...data, newRow]; + setData(newData); + onDataChange?.(newData); + setEditingRow(newData.length - 1); + } + }; + + // 행 삭제 + const handleDeleteRow = async (index: number) => { + const row = data[index]; + const rowId = row?.id || row?.objid; + + if (rowId && config.dataSource?.tableName) { + try { + await apiClient.delete(`/dynamic-form/${config.dataSource.tableName}/${rowId}`); + } catch (error) { + console.error("행 삭제 실패:", error); + return; + } + } + + const newData = data.filter((_, i) => i !== index); + setData(newData); + onDataChange?.(newData); + setSelectedRows((prev) => { + const newSet = new Set(prev); + newSet.delete(index); + return newSet; + }); + }; + + // 선택된 행 일괄 삭제 + const handleDeleteSelected = async () => { + if (selectedRows.size === 0) return; + + const indices = Array.from(selectedRows).sort((a, b) => b - a); // 역순 정렬 + for (const index of indices) { + await handleDeleteRow(index); + } + setSelectedRows(new Set()); + }; + + // 인라인 편집 시작 + const handleEditRow = (index: number) => { + if (!config.features?.inlineEdit) return; + setEditingRow(index); + setEditedData({ ...data[index] }); + }; + + // 인라인 편집 저장 + const handleSaveEdit = async () => { + if (editingRow === null) return; + + const rowId = editedData?.id || editedData?.objid; + + try { + if (rowId && config.dataSource?.tableName) { + await apiClient.put(`/dynamic-form/${config.dataSource.tableName}/${rowId}`, editedData); + } else if (config.dataSource?.tableName) { + const response = await apiClient.post(`/dynamic-form/${config.dataSource.tableName}`, editedData); + if (response.data?.data?.id) { + editedData.id = response.data.data.id; + } + } + + const newData = [...data]; + newData[editingRow] = editedData; + setData(newData); + onDataChange?.(newData); + } catch (error) { + console.error("저장 실패:", error); + } + + setEditingRow(null); + setEditedData({}); + }; + + // 인라인 편집 취소 + const handleCancelEdit = () => { + setEditingRow(null); + setEditedData({}); + }; + + // 행 클릭 + const handleRowClick = (row: any, index: number) => { + if (config.features?.selectable) { + toggleRowSelection(index); + } + onRowClick?.(row); + + if (config.renderMode === "modal" || config.renderMode === "mixed") { + setModalRow(row); + setModalOpen(true); + } + }; + + // 버튼 클릭 핸들러 + const handleButtonAction = (action: ButtonActionType, row?: any, buttonConfig?: RepeaterButtonConfig) => { + onButtonClick?.(action, row, buttonConfig); + + if (action === "view" && row) { + setModalRow(row); + setModalOpen(true); + } + }; + + // 공통코드 버튼 클릭 + const handleCodeButtonClick = async (codeValue: string, row: any, index: number) => { + const valueField = config.button?.commonCode?.valueField; + if (!valueField) return; + + const updatedRow = { ...row, [valueField]: codeValue }; + const rowId = row?.id || row?.objid; + + try { + if (rowId && config.dataSource?.tableName) { + await apiClient.put(`/dynamic-form/${config.dataSource.tableName}/${rowId}`, { [valueField]: codeValue }); + } + + const newData = [...data]; + newData[index] = updatedRow; + setData(newData); + onDataChange?.(newData); + } catch (error) { + console.error("상태 변경 실패:", error); + } + }; + + // 모달 제목 생성 + const getModalTitle = (row?: any) => { + const template = config.modal?.titleTemplate; + if (!template) return row ? "상세 보기" : "새 항목"; + + let title = template.prefix || ""; + if (template.columnKey && row?.[template.columnKey]) { + title += row[template.columnKey]; + } + title += template.suffix || ""; + + return title || (row ? "상세 보기" : "새 항목"); + }; + + // 버튼 렌더링 + const renderButtons = (row: any, index: number) => { + const isVertical = config.button?.layout === "vertical"; + const buttonStyle = config.button?.style || "outline"; + + if (config.button?.sourceType === "commonCode") { + return ( +
+ {codeButtons.map((btn) => ( + + ))} +
+ ); + } + + // 수동 버튼 + return ( +
+ {(config.button?.manualButtons || []).map((btn) => ( + + ))} +
+ ); + }; + + // 테이블 렌더링 (inline, mixed 모드) + const renderTable = () => { + if (config.renderMode === "button") return null; + + return ( +
+ + + + {config.features?.dragSort && } + {config.features?.selectable && } + {config.features?.showRowNumber && #} + {config.columns + .filter((col) => col.visible !== false) + .map((col) => ( + + {col.title} + + ))} + {(config.features?.inlineEdit || config.features?.showDeleteButton || config.renderMode === "mixed") && ( + 액션 + )} + + + + {data.length === 0 ? ( + + + 데이터가 없습니다 + + + ) : ( + data.map((row, index) => ( + handleRowClick(row, index)} + > + {config.features?.dragSort && ( + + + + )} + {config.features?.selectable && ( + + toggleRowSelection(index)} + onClick={(e) => e.stopPropagation()} + /> + + )} + {config.features?.showRowNumber && ( + {index + 1} + )} + {config.columns + .filter((col) => col.visible !== false) + .map((col) => ( + + {editingRow === index ? ( + + setEditedData((prev) => ({ + ...prev, + [col.key]: e.target.value, + })) + } + className="h-7 text-xs" + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {row[col.key]} + )} + + ))} + +
+ {editingRow === index ? ( + <> + + + + ) : ( + <> + {config.features?.inlineEdit && ( + + )} + {config.renderMode === "mixed" && ( + + )} + {config.features?.showDeleteButton && ( + + )} + + )} +
+
+
+ )) + )} +
+
+
+ ); + }; + + // 버튼 모드 렌더링 + const renderButtonMode = () => { + if (config.renderMode !== "button") return null; + + const isVertical = config.button?.layout === "vertical"; + + return ( +
+ {data.map((row, index) => ( +
+ {renderButtons(row, index)} +
+ ))} +
+ ); + }; + + return ( +
+ {/* 헤더 (추가/삭제 버튼) */} + {(config.features?.showAddButton || (config.features?.selectable && selectedRows.size > 0)) && ( +
+
+ {config.features?.showAddButton && ( + + )} + {config.features?.selectable && selectedRows.size > 0 && config.features?.showDeleteButton && ( + + )} +
+ 총 {data.length}건 +
+ )} + + {/* 로딩 */} + {loading &&
로딩 중...
} + + {/* 메인 컨텐츠 */} + {!loading && ( + <> + {renderTable()} + {renderButtonMode()} + + {/* mixed 모드에서 버튼도 표시 */} + {config.renderMode === "mixed" && data.length > 0 && ( +
+ {data.map((row, index) => ( +
+ {renderButtons(row, index)} +
+ ))} +
+ )} + + )} + + {/* 모달 */} + + + + {getModalTitle(modalRow)} + +
+ {config.modal?.screenId ? ( + // 화면 기반 모달 - 동적 화면 로드 +
+ 화면 ID: {config.modal.screenId} + {/* TODO: DynamicScreen 컴포넌트로 교체 */} +
+ ) : ( + // 기본 폼 표시 +
+ {config.columns.map((col) => ( +
+ + + setModalRow((prev: any) => ({ + ...prev, + [col.key]: e.target.value, + })) + } + className="h-9" + /> +
+ ))} +
+ )} +
+
+
+
+ ); +}; + +UnifiedRepeater.displayName = "UnifiedRepeater"; + +export default UnifiedRepeater; + diff --git a/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx b/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx new file mode 100644 index 00000000..c812feee --- /dev/null +++ b/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx @@ -0,0 +1,1076 @@ +"use client"; + +/** + * UnifiedRepeater 설정 패널 + * + * 모든 설정을 콤보박스 중심으로 구현하여 직접 입력을 최소화합니다. + * - 테이블/컬럼: API에서 동적으로 로드 + * - 화면 목록: API에서 동적으로 로드 + * - 공통코드 카테고리: API에서 동적으로 로드 + * - 기타 옵션: 고정 옵션에서 선택 + */ + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Database, + Link2, + ChevronDown, + ChevronRight, + Plus, + Trash2, + GripVertical, + Monitor, + Settings2, +} from "lucide-react"; +import { tableTypeApi, screenApi } from "@/lib/api/screen"; +import { commonCodeApi } from "@/lib/api/commonCode"; +import { cn } from "@/lib/utils"; +import { + UnifiedRepeaterConfig, + RepeaterColumnConfig, + RepeaterButtonConfig, + DEFAULT_REPEATER_CONFIG, + RENDER_MODE_OPTIONS, + MODAL_SIZE_OPTIONS, + COLUMN_WIDTH_OPTIONS, + BUTTON_ACTION_OPTIONS, + BUTTON_VARIANT_OPTIONS, + BUTTON_LAYOUT_OPTIONS, + BUTTON_SOURCE_OPTIONS, + LABEL_FIELD_OPTIONS, + ButtonActionType, + ButtonVariant, + ColumnWidthOption, +} from "@/types/unified-repeater"; + +interface UnifiedRepeaterConfigPanelProps { + config: UnifiedRepeaterConfig; + onChange: (config: UnifiedRepeaterConfig) => void; + currentTableName?: string; +} + +interface TableOption { + tableName: string; + tableLabel: string; +} + +interface ColumnOption { + columnName: string; + displayName: string; + isJoinColumn?: boolean; + sourceTable?: string; + inputType?: string; +} + +interface ScreenOption { + screenId: number; + screenName: string; + screenCode: string; +} + +interface CategoryOption { + categoryCode: string; + categoryName: string; +} + +export const UnifiedRepeaterConfigPanel: React.FC = ({ + config, + onChange, + currentTableName, +}) => { + // 상태 관리 + const [tables, setTables] = useState([]); + const [dataTableColumns, setDataTableColumns] = useState([]); + const [currentTableColumns, setCurrentTableColumns] = useState([]); + const [screens, setScreens] = useState([]); + const [categories, setCategories] = useState([]); + const [expandedJoinSections, setExpandedJoinSections] = useState>(new Set()); + + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingScreens, setLoadingScreens] = useState(false); + const [loadingCategories, setLoadingCategories] = useState(false); + + // 설정 업데이트 헬퍼 + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange], + ); + + const updateDataSource = useCallback( + (field: string, value: any) => { + updateConfig({ + dataSource: { ...config.dataSource, [field]: value }, + }); + }, + [config.dataSource, updateConfig], + ); + + const updateModal = useCallback( + (field: string, value: any) => { + updateConfig({ + modal: { ...config.modal, [field]: value }, + }); + }, + [config.modal, updateConfig], + ); + + const updateButton = useCallback( + (field: string, value: any) => { + updateConfig({ + button: { ...config.button, [field]: value }, + }); + }, + [config.button, updateConfig], + ); + + const updateFeatures = useCallback( + (field: string, value: boolean) => { + updateConfig({ + features: { ...config.features, [field]: value }, + }); + }, + [config.features, updateConfig], + ); + + // 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setTables( + response.data.map((t: any) => ({ + tableName: t.tableName || t.table_name, + tableLabel: t.tableLabel || t.table_label || t.tableName || t.table_name, + })), + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // 데이터 테이블 컬럼 로드 + useEffect(() => { + const loadDataTableColumns = async () => { + const tableName = config.dataSource?.tableName; + if (!tableName) { + setDataTableColumns([]); + return; + } + + setLoadingColumns(true); + try { + const columnData = await tableTypeApi.getColumns(tableName); + const cols: ColumnOption[] = columnData.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + })); + setDataTableColumns(cols); + } catch (error) { + console.error("데이터 테이블 컬럼 로드 실패:", error); + setDataTableColumns([]); + } finally { + setLoadingColumns(false); + } + }; + loadDataTableColumns(); + }, [config.dataSource?.tableName]); + + // 현재 화면 테이블 컬럼 로드 + useEffect(() => { + const loadCurrentTableColumns = async () => { + if (!currentTableName) { + setCurrentTableColumns([]); + return; + } + + try { + const columnData = await tableTypeApi.getColumns(currentTableName); + const cols: ColumnOption[] = columnData.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + })); + setCurrentTableColumns(cols); + } catch (error) { + console.error("현재 테이블 컬럼 로드 실패:", error); + setCurrentTableColumns([]); + } + }; + loadCurrentTableColumns(); + }, [currentTableName]); + + // 화면 목록 로드 (모달 모드일 때) + useEffect(() => { + const needScreens = config.renderMode === "modal" || config.renderMode === "mixed"; + if (!needScreens) return; + + const loadScreens = async () => { + setLoadingScreens(true); + try { + const response = await screenApi.getScreens({ size: 1000 }); + setScreens( + response.data.map((s) => ({ + screenId: s.screenId!, + screenName: s.screenName, + screenCode: s.screenCode, + })), + ); + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setLoadingScreens(false); + } + }; + loadScreens(); + }, [config.renderMode]); + + // 공통코드 카테고리 로드 (버튼 모드 + 공통코드 소스일 때) + useEffect(() => { + const needCategories = + (config.renderMode === "button" || config.renderMode === "mixed") && + config.button?.sourceType === "commonCode"; + if (!needCategories) return; + + const loadCategories = async () => { + setLoadingCategories(true); + try { + const response = await commonCodeApi.categories.getList(); + if (response.success && response.data) { + setCategories( + response.data.map((c) => ({ + categoryCode: c.categoryCode, + categoryName: c.categoryName, + })), + ); + } + } catch (error) { + console.error("공통코드 카테고리 로드 실패:", error); + } finally { + setLoadingCategories(false); + } + }; + loadCategories(); + }, [config.renderMode, config.button?.sourceType]); + + // 컬럼 토글 + const toggleColumn = (column: ColumnOption) => { + const existingIndex = config.columns.findIndex((c) => c.key === column.columnName); + if (existingIndex >= 0) { + // 제거 + const newColumns = config.columns.filter((c) => c.key !== column.columnName); + updateConfig({ columns: newColumns }); + } else { + // 추가 + const newColumn: RepeaterColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + isJoinColumn: column.isJoinColumn || false, + sourceTable: column.sourceTable, + }; + updateConfig({ columns: [...config.columns, newColumn] }); + } + }; + + const isColumnAdded = (columnName: string) => { + return config.columns.some((c) => c.key === columnName); + }; + + // 컬럼 속성 업데이트 + const updateColumnProp = (key: string, field: keyof RepeaterColumnConfig, value: any) => { + const newColumns = config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col)); + updateConfig({ columns: newColumns }); + }; + + // 수동 버튼 추가 + const addManualButton = () => { + const newButton: RepeaterButtonConfig = { + id: `btn_${Date.now()}`, + label: "새 버튼", + action: "create", + variant: "outline", + }; + const currentButtons = config.button?.manualButtons || []; + updateButton("manualButtons", [...currentButtons, newButton]); + }; + + // 수동 버튼 삭제 + const removeManualButton = (id: string) => { + const currentButtons = config.button?.manualButtons || []; + updateButton( + "manualButtons", + currentButtons.filter((b) => b.id !== id), + ); + }; + + // 수동 버튼 속성 업데이트 + const updateManualButton = (id: string, field: keyof RepeaterButtonConfig, value: any) => { + const currentButtons = config.button?.manualButtons || []; + const updated = currentButtons.map((b) => (b.id === id ? { ...b, [field]: value } : b)); + updateButton("manualButtons", updated); + }; + + // 조인 섹션 토글 + const toggleJoinSection = (tableName: string) => { + setExpandedJoinSections((prev) => { + const newSet = new Set(prev); + if (newSet.has(tableName)) { + newSet.delete(tableName); + } else { + newSet.add(tableName); + } + return newSet; + }); + }; + + // 엔티티 조인 컬럼 그룹화 + const joinColumnsByTable = useMemo(() => { + const grouped: Record = {}; + dataTableColumns + .filter((col) => col.isJoinColumn) + .forEach((col) => { + const table = col.sourceTable || "unknown"; + if (!grouped[table]) grouped[table] = []; + grouped[table].push(col); + }); + return grouped; + }, [dataTableColumns]); + + const baseColumns = useMemo(() => dataTableColumns.filter((col) => !col.isJoinColumn), [dataTableColumns]); + + // 모달/버튼 모드 여부 + const showModalSettings = config.renderMode === "modal" || config.renderMode === "mixed"; + const showButtonSettings = config.renderMode === "button" || config.renderMode === "mixed"; + + return ( +
+ + + + 기본 + + + 컬럼 + + + 모달 + + + 버튼 + + + + {/* 기본 설정 탭 */} + + {/* 렌더링 모드 */} +
+ + +
+ + + + {/* 데이터 소스 설정 */} +
+ + + {/* 데이터 테이블 선택 */} +
+ + +
+ + {/* 연결 키 (FK) - 데이터 테이블 컬럼 */} +
+ + +

데이터 테이블에서 부모를 참조하는 컬럼

+
+ + {/* 상위 키 - 현재 화면 테이블 컬럼 */} +
+ + {currentTableName ? ( + + ) : ( +

화면에 테이블이 설정되지 않았습니다

+ )} +

현재 화면 테이블의 PK/ID 컬럼

+
+
+ + + + {/* 기능 옵션 */} +
+ + +
+
+ updateFeatures("showAddButton", !!checked)} + /> + +
+ +
+ updateFeatures("showDeleteButton", !!checked)} + /> + +
+ +
+ updateFeatures("inlineEdit", !!checked)} + /> + +
+ +
+ updateFeatures("dragSort", !!checked)} + /> + +
+ +
+ updateFeatures("showRowNumber", !!checked)} + /> + +
+ +
+ updateFeatures("selectable", !!checked)} + /> + +
+
+
+
+ + {/* 컬럼 설정 탭 */} + +
+ + + {loadingColumns ? ( +

컬럼 로딩 중...

+ ) : !config.dataSource?.tableName ? ( +

먼저 데이터 테이블을 선택해주세요

+ ) : ( +
+ {baseColumns.map((column) => ( +
toggleColumn(column)} + > + toggleColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} +
+ ))} + + {/* 조인 컬럼 */} + {Object.keys(joinColumnsByTable).length > 0 && ( +
+
+ + 엔티티 조인 컬럼 +
+ {Object.entries(joinColumnsByTable).map(([refTable, refColumns]) => ( +
+
toggleJoinSection(refTable)} + > + {expandedJoinSections.has(refTable) ? ( + + ) : ( + + )} + {refTable} +
+ + {expandedJoinSections.has(refTable) && ( +
+ {refColumns.map((column) => ( +
toggleColumn(column)} + > + toggleColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + {column.displayName} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+ )} +
+ + {/* 선택된 컬럼 상세 설정 */} + {config.columns.length > 0 && ( + <> + +
+ +
+ {config.columns.map((col) => ( +
+ + {col.isJoinColumn ? ( + + ) : ( + + )} + updateColumnProp(col.key, "title", e.target.value)} + placeholder="제목" + className="h-6 flex-1 text-xs" + /> + {/* 너비: 콤보박스 */} + + +
+ ))} +
+
+ + )} +
+ + {/* 모달 설정 탭 */} + + {showModalSettings ? ( + <> + {/* 모달 화면 선택 */} +
+ + +
+ + {/* 모달 크기 */} +
+ + +
+ + + + {/* 모달 제목 템플릿 */} +
+ + +
+
+ + + updateModal("titleTemplate", { + ...config.modal?.titleTemplate, + prefix: e.target.value, + }) + } + placeholder="예: 수정 -" + className="h-7 text-xs" + /> +
+ +
+ + +
+ +
+ + + updateModal("titleTemplate", { + ...config.modal?.titleTemplate, + suffix: e.target.value, + }) + } + placeholder="예: 상세" + className="h-7 text-xs" + /> +
+
+
+ + ) : ( +

+ 모달 또는 혼합 모드에서만 설정할 수 있습니다 +

+ )} +
+ + {/* 버튼 설정 탭 */} + + {showButtonSettings ? ( + <> + {/* 버튼 소스 선택 */} +
+ + updateButton("sourceType", value)} + className="flex gap-4" + > + {BUTTON_SOURCE_OPTIONS.map((opt) => ( +
+ + +
+ ))} +
+
+ + + + {/* 공통코드 모드 */} + {config.button?.sourceType === "commonCode" && ( +
+ + + {/* 카테고리 선택 */} +
+ + +
+ + {/* 라벨 필드 */} +
+ + +
+ + {/* 값 컬럼 */} +
+ + +

버튼 클릭 시 값이 저장될 컬럼

+
+
+ )} + + {/* 수동 설정 모드 */} + {config.button?.sourceType === "manual" && ( +
+
+ + +
+ +
+ {(config.button?.manualButtons || []).map((btn) => ( +
+
+ updateManualButton(btn.id, "label", e.target.value)} + placeholder="버튼 라벨" + className="h-7 flex-1 text-xs" + /> + +
+
+ {/* 액션 */} + + + {/* 스타일 */} + +
+ + {/* 네비게이트 액션일 때 화면 선택 */} + {btn.action === "navigate" && ( + + )} +
+ ))} + + {(config.button?.manualButtons || []).length === 0 && ( +

버튼을 추가해주세요

+ )} +
+
+ )} + + + + {/* 버튼 레이아웃 */} +
+
+ + +
+ +
+ + +
+
+ + ) : ( +

+ 버튼 또는 혼합 모드에서만 설정할 수 있습니다 +

+ )} +
+
+
+ ); +}; + +UnifiedRepeaterConfigPanel.displayName = "UnifiedRepeaterConfigPanel"; + +export default UnifiedRepeaterConfigPanel; + diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index e28e1755..212e99f5 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인 // 🆕 연관 데이터 버튼 컴포넌트 import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시 +// 🆕 통합 반복 데이터 컴포넌트 (Unified) +import "./unified-repeater/UnifiedRepeaterRenderer"; // 인라인/모달/버튼 모드 통합 + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/unified-repeater/UnifiedRepeaterRenderer.tsx b/frontend/lib/registry/components/unified-repeater/UnifiedRepeaterRenderer.tsx new file mode 100644 index 00000000..9c4af25e --- /dev/null +++ b/frontend/lib/registry/components/unified-repeater/UnifiedRepeaterRenderer.tsx @@ -0,0 +1,105 @@ +"use client"; + +/** + * UnifiedRepeater 렌더러 + * 컴포넌트 레지스트리에 등록하기 위한 래퍼 + */ + +import React from "react"; +import { ComponentRegistry } from "../../ComponentRegistry"; +import { UnifiedRepeater } from "@/components/unified/UnifiedRepeater"; +import { UnifiedRepeaterDefinition } from "./index"; +import { UnifiedRepeaterConfig, DEFAULT_REPEATER_CONFIG } from "@/types/unified-repeater"; + +interface UnifiedRepeaterRendererProps { + component: any; + data?: any; + mode?: "view" | "edit"; + isPreview?: boolean; + onDataChange?: (data: any[]) => void; + onRowClick?: (row: any) => void; + onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void; + parentId?: string | number; +} + +const UnifiedRepeaterRenderer: React.FC = ({ + component, + data, + mode, + isPreview, + onDataChange, + onRowClick, + onButtonClick, + parentId, +}) => { + // component.config에서 UnifiedRepeaterConfig 추출 + const config: UnifiedRepeaterConfig = React.useMemo(() => { + const componentConfig = component?.config || component?.props?.config || {}; + return { + ...DEFAULT_REPEATER_CONFIG, + ...componentConfig, + dataSource: { + ...DEFAULT_REPEATER_CONFIG.dataSource, + ...componentConfig.dataSource, + }, + columns: componentConfig.columns || [], + features: { + ...DEFAULT_REPEATER_CONFIG.features, + ...componentConfig.features, + }, + modal: { + ...DEFAULT_REPEATER_CONFIG.modal, + ...componentConfig.modal, + }, + button: { + ...DEFAULT_REPEATER_CONFIG.button, + ...componentConfig.button, + }, + }; + }, [component]); + + // parentId 결정: props에서 전달받거나 data에서 추출 + const resolvedParentId = React.useMemo(() => { + if (parentId) return parentId; + if (data && config.dataSource?.referenceKey) { + return data[config.dataSource.referenceKey]; + } + return undefined; + }, [parentId, data, config.dataSource?.referenceKey]); + + // 미리보기 모드에서는 샘플 데이터 표시 + if (isPreview) { + return ( +
+
+ 통합 반복 데이터 +
+ + 모드: {config.renderMode} | 테이블: {config.dataSource?.tableName || "미설정"} + +
+
+ ); + } + + return ( + + ); +}; + +// 컴포넌트 레지스트리에 등록 +ComponentRegistry.registerComponent({ + ...UnifiedRepeaterDefinition, + render: (props: any) => , +}); + +export default UnifiedRepeaterRenderer; + diff --git a/frontend/lib/registry/components/unified-repeater/index.ts b/frontend/lib/registry/components/unified-repeater/index.ts new file mode 100644 index 00000000..24d5b5d2 --- /dev/null +++ b/frontend/lib/registry/components/unified-repeater/index.ts @@ -0,0 +1,98 @@ +/** + * UnifiedRepeater 컴포넌트 정의 + * + * 반복 데이터 관리를 위한 통합 컴포넌트 + * 기존 simple-repeater-table, modal-repeater-table, repeat-screen-modal, related-data-buttons 통합 + */ + +import { ComponentCategory } from "@/types/component"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { UnifiedRepeaterConfigPanel } from "@/components/unified/config-panels/UnifiedRepeaterConfigPanel"; +import { UnifiedRepeater } from "@/components/unified/UnifiedRepeater"; + +export const UnifiedRepeaterDefinition = createComponentDefinition({ + id: "unified-repeater", + name: "통합 반복 데이터", + description: "반복 데이터 관리 (인라인/모달/버튼 모드)", + category: ComponentCategory.UNIFIED, + webType: "entity", // 반복 데이터는 엔티티 참조 타입 + version: "1.0.0", + component: UnifiedRepeater, // React 컴포넌트 (필수) + + // 기본 속성 + defaultProps: { + config: { + renderMode: "inline", + dataSource: { + tableName: "", + foreignKey: "", + referenceKey: "", + }, + columns: [], + modal: { + size: "md", + }, + button: { + sourceType: "manual", + manualButtons: [], + layout: "horizontal", + style: "outline", + }, + features: { + showAddButton: true, + showDeleteButton: true, + inlineEdit: false, + dragSort: false, + showRowNumber: false, + selectable: false, + multiSelect: false, + }, + }, + }, + + // 설정 스키마 + configSchema: { + renderMode: { + type: "select", + label: "렌더링 모드", + options: [ + { value: "inline", label: "인라인 (테이블)" }, + { value: "modal", label: "모달" }, + { value: "button", label: "버튼" }, + { value: "mixed", label: "혼합 (테이블 + 버튼)" }, + ], + }, + "dataSource.tableName": { + type: "tableSelect", + label: "데이터 테이블", + description: "반복 데이터가 저장된 테이블", + }, + "dataSource.foreignKey": { + type: "columnSelect", + label: "연결 키 (FK)", + description: "부모 레코드를 참조하는 컬럼", + dependsOn: "dataSource.tableName", + }, + "dataSource.referenceKey": { + type: "columnSelect", + label: "상위 키", + description: "현재 화면 테이블의 PK 컬럼", + useCurrentTable: true, + }, + }, + + // 이벤트 + events: ["onDataChange", "onRowClick", "onButtonClick"], + + // 아이콘 + icon: "Repeat", + + // 태그 + tags: ["data", "repeater", "table", "modal", "button", "unified"], + + // 설정 패널 + configPanel: UnifiedRepeaterConfigPanel, +}); + +export default UnifiedRepeaterDefinition; + diff --git a/frontend/types/unified-repeater.ts b/frontend/types/unified-repeater.ts new file mode 100644 index 00000000..c1f32000 --- /dev/null +++ b/frontend/types/unified-repeater.ts @@ -0,0 +1,230 @@ +/** + * UnifiedRepeater 컴포넌트 타입 정의 + * + * 기존 컴포넌트 통합: + * - simple-repeater-table: 인라인 모드 + * - modal-repeater-table: 모달 모드 + * - repeat-screen-modal: 화면 기반 모달 모드 + * - related-data-buttons: 버튼 모드 + */ + +// 렌더링 모드 +export type RepeaterRenderMode = "inline" | "modal" | "button" | "mixed"; + +// 버튼 소스 타입 +export type ButtonSourceType = "commonCode" | "manual"; + +// 버튼 액션 타입 +export type ButtonActionType = "create" | "update" | "delete" | "view" | "navigate" | "custom"; + +// 버튼 색상/스타일 +export type ButtonVariant = "default" | "primary" | "secondary" | "destructive" | "outline" | "ghost"; + +// 버튼 레이아웃 +export type ButtonLayout = "horizontal" | "vertical"; + +// 모달 크기 +export type ModalSize = "sm" | "md" | "lg" | "xl" | "full"; + +// 컬럼 너비 옵션 +export type ColumnWidthOption = "auto" | "60px" | "80px" | "100px" | "120px" | "150px" | "200px" | "250px" | "300px"; + +// 컬럼 설정 +export interface RepeaterColumnConfig { + key: string; + title: string; + width: ColumnWidthOption; + visible: boolean; + isJoinColumn?: boolean; + sourceTable?: string; +} + +// 버튼 설정 (수동 모드) +export interface RepeaterButtonConfig { + id: string; + label: string; + action: ButtonActionType; + variant: ButtonVariant; + icon?: string; + confirmMessage?: string; + // 네비게이트 액션용 + navigateScreen?: number; + navigateParams?: Record; + // 커스텀 액션용 + customHandler?: string; +} + +// 공통코드 버튼 설정 +export interface CommonCodeButtonConfig { + categoryCode: string; + labelField: "codeValue" | "codeName"; + valueField: string; // 버튼 클릭 시 전달할 값의 컬럼 + variantMapping?: Record; // 코드값별 색상 매핑 +} + +// 모달 설정 +export interface RepeaterModalConfig { + screenId?: number; + size: ModalSize; + titleTemplate?: { + prefix?: string; + columnKey?: string; + suffix?: string; + }; +} + +// 기능 옵션 +export interface RepeaterFeatureOptions { + showAddButton: boolean; + showDeleteButton: boolean; + inlineEdit: boolean; + dragSort: boolean; + showRowNumber: boolean; + selectable: boolean; + multiSelect: boolean; +} + +// 메인 설정 타입 +export interface UnifiedRepeaterConfig { + // 렌더링 모드 + renderMode: RepeaterRenderMode; + + // 데이터 소스 설정 + dataSource: { + tableName: string; // 데이터 테이블 + foreignKey: string; // 연결 키 (FK) - 데이터 테이블의 컬럼 + referenceKey: string; // 상위 키 - 현재 화면 테이블의 컬럼 (부모 ID) + filter?: { // 추가 필터 조건 + column: string; + value: string; + }; + }; + + // 컬럼 설정 + columns: RepeaterColumnConfig[]; + + // 모달 설정 (modal, mixed 모드) + modal?: RepeaterModalConfig; + + // 버튼 설정 (button, mixed 모드) + button?: { + sourceType: ButtonSourceType; + commonCode?: CommonCodeButtonConfig; + manualButtons?: RepeaterButtonConfig[]; + layout: ButtonLayout; + style: ButtonVariant; + }; + + // 기능 옵션 + features: RepeaterFeatureOptions; + + // 스타일 + style?: { + maxHeight?: string; + minHeight?: string; + borderless?: boolean; + compact?: boolean; + }; +} + +// 컴포넌트 Props +export interface UnifiedRepeaterProps { + config: UnifiedRepeaterConfig; + parentId?: string | number; // 부모 레코드 ID + data?: any[]; // 초기 데이터 (없으면 API로 로드) + onDataChange?: (data: any[]) => void; + onRowClick?: (row: any) => void; + onButtonClick?: (action: ButtonActionType, row?: any, buttonConfig?: RepeaterButtonConfig) => void; + className?: string; +} + +// 기본 설정값 +export const DEFAULT_REPEATER_CONFIG: UnifiedRepeaterConfig = { + renderMode: "inline", + dataSource: { + tableName: "", + foreignKey: "", + referenceKey: "", + }, + columns: [], + modal: { + size: "md", + }, + button: { + sourceType: "manual", + manualButtons: [], + layout: "horizontal", + style: "outline", + }, + features: { + showAddButton: true, + showDeleteButton: true, + inlineEdit: false, + dragSort: false, + showRowNumber: false, + selectable: false, + multiSelect: false, + }, +}; + +// 고정 옵션들 (콤보박스용) +export const RENDER_MODE_OPTIONS = [ + { value: "inline", label: "인라인 (테이블)" }, + { value: "modal", label: "모달" }, + { value: "button", label: "버튼" }, + { value: "mixed", label: "혼합 (테이블 + 버튼)" }, +] as const; + +export const MODAL_SIZE_OPTIONS = [ + { value: "sm", label: "작게 (sm)" }, + { value: "md", label: "중간 (md)" }, + { value: "lg", label: "크게 (lg)" }, + { value: "xl", label: "매우 크게 (xl)" }, + { value: "full", label: "전체 화면" }, +] as const; + +export const COLUMN_WIDTH_OPTIONS = [ + { value: "auto", label: "자동" }, + { value: "60px", label: "60px" }, + { value: "80px", label: "80px" }, + { value: "100px", label: "100px" }, + { value: "120px", label: "120px" }, + { value: "150px", label: "150px" }, + { value: "200px", label: "200px" }, + { value: "250px", label: "250px" }, + { value: "300px", label: "300px" }, +] as const; + +export const BUTTON_ACTION_OPTIONS = [ + { value: "create", label: "생성" }, + { value: "update", label: "수정" }, + { value: "delete", label: "삭제" }, + { value: "view", label: "보기" }, + { value: "navigate", label: "화면 이동" }, + { value: "custom", label: "커스텀" }, +] as const; + +export const BUTTON_VARIANT_OPTIONS = [ + { value: "default", label: "기본" }, + { value: "primary", label: "Primary" }, + { value: "secondary", label: "Secondary" }, + { value: "destructive", label: "삭제 (빨강)" }, + { value: "outline", label: "Outline" }, + { value: "ghost", label: "Ghost" }, +] as const; + +export const BUTTON_LAYOUT_OPTIONS = [ + { value: "horizontal", label: "가로 배치" }, + { value: "vertical", label: "세로 배치" }, +] as const; + +export const BUTTON_SOURCE_OPTIONS = [ + { value: "commonCode", label: "공통코드 사용" }, + { value: "manual", label: "수동 설정" }, +] as const; + +export const LABEL_FIELD_OPTIONS = [ + { value: "codeName", label: "코드명" }, + { value: "codeValue", label: "코드값" }, +] as const; +