From c3adb4216faa824ce3f600e9f9722f33f0c32645 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 15 Jan 2026 12:22:45 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A1=B0?= =?UTF-8?q?=EC=9D=B8=20=EC=84=A4=EC=A0=95=EC=97=90=EC=84=9C=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EC=9D=84=20=EC=A0=9C=EC=99=B8=ED=95=98=EA=B3=A0,=20Re?= =?UTF-8?q?peaterTable=EC=97=90=EC=84=9C=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=ED=83=80=EC=9E=85=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EA=B4=80=EB=A0=A8=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EB=A1=9C=EB=93=9C=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.=20?= =?UTF-8?q?=EB=98=90=ED=95=9C,=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=B4=20=EA=B0=80=EB=8F=85=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=ED=96=A5=EC=83=81=EC=8B=9C=EC=BC=B0=EC=8A=B5?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/entityJoinController.ts | 8 +- .../src/services/entityJoinService.ts | 4 +- frontend/components/screen/ScreenDesigner.tsx | 4 +- .../components/screen/panels/TablesPanel.tsx | 4 +- .../components/unified/UnifiedFormContext.tsx | 2 +- .../components/unified/UnifiedRepeater.tsx | 450 ++++++++++++------ .../UnifiedRepeaterConfigPanel.tsx | 266 ++++------- .../ItemSelectionModal.tsx | 6 +- .../modal-repeater-table/RepeaterTable.tsx | 198 +++++++- .../components/modal-repeater-table/types.ts | 15 +- frontend/types/unified-repeater.ts | 26 +- 11 files changed, 609 insertions(+), 374 deletions(-) diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index f722d469..d42029d1 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -447,7 +447,13 @@ export class EntityJoinController { logger.info(`Entity 조인 컬럼 조회: ${tableName}`); // 1. 현재 테이블의 Entity 조인 설정 조회 - const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName); + + // 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외 + // 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨 + const joinConfigs = allJoinConfigs.filter( + (config) => config.referenceTable !== "table_column_category_values" + ); if (joinConfigs.length === 0) { res.status(200).json({ diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 574f7190..96f005a0 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -750,9 +750,9 @@ export class EntityJoinService { return columns.map((col) => { const labelInfo = labelMap.get(col.column_name); return { - columnName: col.column_name, + columnName: col.column_name, displayName: labelInfo?.label || col.column_name, - dataType: col.data_type, + dataType: col.data_type, inputType: labelInfo?.inputType || "text", }; }); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 58236d8c..4847e339 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -2752,7 +2752,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD isEntityJoin: true, entityJoinTable: column.entityJoinTable, entityJoinColumn: column.entityJoinColumn, - }), + }), style: { labelDisplay: false, // 라벨 숨김 labelFontSize: "12px", @@ -2818,7 +2818,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD isEntityJoin: true, entityJoinTable: column.entityJoinTable, entityJoinColumn: column.entityJoinColumn, - }), + }), style: { labelDisplay: false, // 라벨 숨김 labelFontSize: "14px", diff --git a/frontend/components/screen/panels/TablesPanel.tsx b/frontend/components/screen/panels/TablesPanel.tsx index b5f432e9..1531c025 100644 --- a/frontend/components/screen/panels/TablesPanel.tsx +++ b/frontend/components/screen/panels/TablesPanel.tsx @@ -234,8 +234,8 @@ export const TablesPanel: React.FC = ({ draggable onDragStart={(e) => onDragStart(e, table, column)} > - {getWidgetIcon(column.widgetType)} -
+ {getWidgetIcon(column.widgetType)} +
>(initialValues); const [fieldStates, setFieldStates] = useState({}); - + // 새로운 상태 const [originalData, setOriginalData] = useState>(initialValues); const [status, setStatus] = useState(initialFormStatus); diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx index 71559fd7..faae28c1 100644 --- a/frontend/components/unified/UnifiedRepeater.tsx +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -6,13 +6,13 @@ * 렌더링 모드: * - inline: 현재 테이블 컬럼 직접 입력 * - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼 - * + * * RepeaterTable 및 ItemSelectionModal 재사용 */ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { Plus, Columns } from "lucide-react"; +import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { UnifiedRepeaterConfig, @@ -58,17 +58,29 @@ export const UnifiedRepeater: React.FC = ({ const [data, setData] = useState(initialData || []); const [selectedRows, setSelectedRows] = useState>(new Set()); const [modalOpen, setModalOpen] = useState(false); - const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0); - + + // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 + const [autoWidthTrigger, setAutoWidthTrigger] = useState(0); + // 소스 테이블 컬럼 라벨 매핑 const [sourceColumnLabels, setSourceColumnLabels] = useState>({}); - + + // 🆕 소스 테이블의 카테고리 타입 컬럼 목록 + const [sourceCategoryColumns, setSourceCategoryColumns] = useState([]); + + // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) + const [categoryLabelMap, setCategoryLabelMap] = useState>({}); + // 현재 테이블 컬럼 정보 (inputType 매핑용) const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); - + // 동적 데이터 소스 상태 const [activeDataSources, setActiveDataSources] = useState>({}); + // 🆕 최신 엔티티 참조 정보 (column_labels에서 조회) + const [resolvedSourceTable, setResolvedSourceTable] = useState(""); + const [resolvedReferenceKey, setResolvedReferenceKey] = useState("id"); + const isModalMode = config.renderMode === "modal"; // 전역 리피터 등록 @@ -94,7 +106,7 @@ export const UnifiedRepeater: React.FC = ({ const tableName = config.dataSource?.tableName; const eventParentId = event.detail?.parentId; const mainFormData = event.detail?.mainFormData; - + if (!tableName || data.length === 0) { return; } @@ -104,19 +116,18 @@ export const UnifiedRepeater: React.FC = ({ let validColumns: Set = new Set(); try { const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); - const columns = columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || []; + const columns = + columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || []; validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name)); } catch { console.warn("테이블 컬럼 정보 조회 실패"); } - + for (let i = 0; i < data.length; i++) { const row = data[i]; - + // 내부 필드 제거 - const cleanRow = Object.fromEntries( - Object.entries(row).filter(([key]) => !key.startsWith("_")) - ); + const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_"))); // 메인 폼 데이터 병합 const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; @@ -132,7 +143,7 @@ export const UnifiedRepeater: React.FC = ({ filteredData[key] = value; } } - + await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); } @@ -158,7 +169,7 @@ export const UnifiedRepeater: React.FC = ({ try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); const columns = response.data?.data?.columns || response.data?.columns || response.data || []; - + const columnMap: Record = {}; columns.forEach((col: any) => { const name = col.columnName || col.column_name || col.name; @@ -176,110 +187,253 @@ export const UnifiedRepeater: React.FC = ({ loadCurrentTableColumnInfo(); }, [config.dataSource?.tableName]); - // 소스 테이블 컬럼 라벨 로드 (modal 모드) + // 🆕 FK 컬럼 기반으로 최신 참조 테이블 정보 조회 (column_labels에서) useEffect(() => { - const loadSourceColumnLabels = async () => { - const sourceTable = config.dataSource?.sourceTable; - if (!isModalMode || !sourceTable) return; + const resolveEntityReference = async () => { + const tableName = config.dataSource?.tableName; + const foreignKey = config.dataSource?.foreignKey; + + if (!isModalMode || !tableName || !foreignKey) { + // config에 저장된 값을 기본값으로 사용 + setResolvedSourceTable(config.dataSource?.sourceTable || ""); + setResolvedReferenceKey(config.dataSource?.referenceKey || "id"); + return; + } try { - const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`); + // 현재 테이블의 컬럼 정보에서 FK 컬럼의 참조 테이블 조회 + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); const columns = response.data?.data?.columns || response.data?.columns || response.data || []; - + + const fkColumn = columns.find((col: any) => (col.columnName || col.column_name || col.name) === foreignKey); + + if (fkColumn) { + // column_labels의 reference_table 사용 (항상 최신값) + const refTable = + fkColumn.detailSettings?.referenceTable || + fkColumn.reference_table || + fkColumn.referenceTable || + config.dataSource?.sourceTable || + ""; + const refKey = + fkColumn.detailSettings?.referenceColumn || + fkColumn.reference_column || + fkColumn.referenceColumn || + config.dataSource?.referenceKey || + "id"; + + console.log("🔄 [UnifiedRepeater] 엔티티 참조 정보 조회:", { + foreignKey, + resolvedSourceTable: refTable, + resolvedReferenceKey: refKey, + configSourceTable: config.dataSource?.sourceTable, + }); + + setResolvedSourceTable(refTable); + setResolvedReferenceKey(refKey); + } else { + // FK 컬럼을 찾지 못한 경우 config 값 사용 + setResolvedSourceTable(config.dataSource?.sourceTable || ""); + setResolvedReferenceKey(config.dataSource?.referenceKey || "id"); + } + } catch (error) { + console.error("엔티티 참조 정보 조회 실패:", error); + // 오류 시 config 값 사용 + setResolvedSourceTable(config.dataSource?.sourceTable || ""); + setResolvedReferenceKey(config.dataSource?.referenceKey || "id"); + } + }; + + resolveEntityReference(); + }, [ + config.dataSource?.tableName, + config.dataSource?.foreignKey, + config.dataSource?.sourceTable, + config.dataSource?.referenceKey, + isModalMode, + ]); + + // 소스 테이블 컬럼 라벨 로드 (modal 모드) - resolvedSourceTable 사용 + // 🆕 카테고리 타입 컬럼도 함께 감지 + useEffect(() => { + const loadSourceColumnLabels = async () => { + if (!isModalMode || !resolvedSourceTable) return; + + try { + const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`); + const columns = response.data?.data?.columns || response.data?.columns || response.data || []; + const labels: Record = {}; + const categoryCols: string[] = []; + columns.forEach((col: any) => { const name = col.columnName || col.column_name || col.name; labels[name] = col.displayName || col.display_name || col.label || name; + + // 🆕 카테고리 타입 컬럼 감지 + const inputType = col.inputType || col.input_type || ""; + if (inputType === "category") { + categoryCols.push(name); + } }); + setSourceColumnLabels(labels); + setSourceCategoryColumns(categoryCols); } catch (error) { console.error("소스 컬럼 라벨 로드 실패:", error); } }; loadSourceColumnLabels(); - }, [config.dataSource?.sourceTable, isModalMode]); + }, [resolvedSourceTable, isModalMode]); // UnifiedColumnConfig → RepeaterColumnConfig 변환 + // 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분) const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => { - const displayColumns: RepeaterColumnConfig[] = []; - - // 모달 표시 컬럼 추가 (읽기 전용) - if (isModalMode && config.modal?.sourceDisplayColumns) { - config.modal.sourceDisplayColumns.forEach((col) => { - const key = typeof col === "string" ? col : col.key; - const label = typeof col === "string" ? sourceColumnLabels[col] || col : col.label || sourceColumnLabels[key] || key; - - if (key && key !== "none") { - displayColumns.push({ - field: `_display_${key}`, + return config.columns + .filter((col: UnifiedColumnConfig) => col.visible !== false) + .map((col: UnifiedColumnConfig): RepeaterColumnConfig => { + const colInfo = currentTableColumnInfo[col.key]; + const inputType = col.inputType || colInfo?.inputType || "text"; + + // 소스 표시 컬럼인 경우 (모달 모드에서 읽기 전용) + if (col.isSourceDisplay) { + const label = col.title || sourceColumnLabels[col.key] || col.key; + return { + field: `_display_${col.key}`, label, type: "text", editable: false, calculated: true, - }); + width: col.width === "auto" ? undefined : col.width, + }; } + + // 일반 입력 컬럼 + let type: "text" | "number" | "date" | "select" | "category" = "text"; + if (inputType === "number" || inputType === "decimal") type = "number"; + else if (inputType === "date" || inputType === "datetime") type = "date"; + else if (inputType === "code") type = "select"; + else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 + + // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식) + // category 타입인 경우 현재 테이블명과 컬럼명을 조합 + let categoryRef: string | undefined; + if (inputType === "category") { + // 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용 + const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; + if (tableName) { + categoryRef = `${tableName}.${col.key}`; + } + } + + return { + field: col.key, + label: col.title || colInfo?.displayName || col.key, + type, + editable: col.editable !== false, + width: col.width === "auto" ? undefined : col.width, + required: false, + categoryRef, // 🆕 카테고리 참조 ID 전달 + }; }); - } - - // 입력 컬럼 추가 - const inputColumns = config.columns.map((col: UnifiedColumnConfig): RepeaterColumnConfig => { - const colInfo = currentTableColumnInfo[col.key]; - const inputType = col.inputType || colInfo?.inputType || "text"; - - let type: "text" | "number" | "date" | "select" = "text"; - if (inputType === "number" || inputType === "decimal") type = "number"; - else if (inputType === "date" || inputType === "datetime") type = "date"; - else if (inputType === "code") type = "select"; - - return { - field: col.key, - label: col.title || colInfo?.displayName || col.key, - type, - editable: col.editable !== false, - width: col.width === "auto" ? undefined : col.width, - required: false, - }; - }); - - return [...displayColumns, ...inputColumns]; - }, [config.columns, config.modal?.sourceDisplayColumns, isModalMode, sourceColumnLabels, currentTableColumnInfo]); + }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); + + // 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용) + useEffect(() => { + const loadCategoryLabels = async () => { + if (sourceCategoryColumns.length === 0 || data.length === 0) { + return; + } + + // 데이터에서 카테고리 컬럼의 모든 고유 코드 수집 + const allCodes = new Set(); + for (const row of data) { + for (const col of sourceCategoryColumns) { + // _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인 + const val = row[`_display_${col}`] || row[col]; + if (val && typeof val === "string") { + const codes = val + .split(",") + .map((c: string) => c.trim()) + .filter(Boolean); + for (const code of codes) { + if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) { + allCodes.add(code); + } + } + } + } + } + + if (allCodes.size === 0) { + return; + } + + try { + const response = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(allCodes), + }); + + if (response.data?.success && response.data.data) { + setCategoryLabelMap((prev) => ({ + ...prev, + ...response.data.data, + })); + } + } catch (error) { + console.error("카테고리 라벨 조회 실패:", error); + } + }; + + loadCategoryLabels(); + }, [data, sourceCategoryColumns]); // 데이터 변경 핸들러 - const handleDataChange = useCallback((newData: any[]) => { - setData(newData); - onDataChange?.(newData); - }, [onDataChange]); + const handleDataChange = useCallback( + (newData: any[]) => { + setData(newData); + onDataChange?.(newData); + // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 + setAutoWidthTrigger((prev) => prev + 1); + }, + [onDataChange], + ); // 행 변경 핸들러 - const handleRowChange = useCallback((index: number, newRow: any) => { - const newData = [...data]; - newData[index] = newRow; - setData(newData); - onDataChange?.(newData); - }, [data, onDataChange]); + const handleRowChange = useCallback( + (index: number, newRow: any) => { + const newData = [...data]; + newData[index] = newRow; + // 🆕 handleDataChange 대신 직접 호출 (행 변경마다 너비 조정은 불필요) + setData(newData); + onDataChange?.(newData); + }, + [data, onDataChange], + ); // 행 삭제 핸들러 - const handleRowDelete = useCallback((index: number) => { - const newData = data.filter((_, i) => i !== index); - setData(newData); - onDataChange?.(newData); - - // 선택 상태 업데이트 - const newSelected = new Set(); - selectedRows.forEach((i) => { - if (i < index) newSelected.add(i); - else if (i > index) newSelected.add(i - 1); - }); - setSelectedRows(newSelected); - }, [data, selectedRows, onDataChange]); + const handleRowDelete = useCallback( + (index: number) => { + const newData = data.filter((_, i) => i !== index); + handleDataChange(newData); // 🆕 handleDataChange 사용 + + // 선택 상태 업데이트 + const newSelected = new Set(); + selectedRows.forEach((i) => { + if (i < index) newSelected.add(i); + else if (i > index) newSelected.add(i - 1); + }); + setSelectedRows(newSelected); + }, + [data, selectedRows, handleDataChange], + ); // 일괄 삭제 핸들러 const handleBulkDelete = useCallback(() => { const newData = data.filter((_, index) => !selectedRows.has(index)); - setData(newData); - onDataChange?.(newData); + handleDataChange(newData); // 🆕 handleDataChange 사용 setSelectedRows(new Set()); - }, [data, selectedRows, onDataChange]); + }, [data, selectedRows, handleDataChange]); // 행 추가 (inline 모드) const handleAddRow = useCallback(() => { @@ -291,96 +445,73 @@ export const UnifiedRepeater: React.FC = ({ newRow[col.key] = ""; }); const newData = [...data, newRow]; - setData(newData); - onDataChange?.(newData); + handleDataChange(newData); // 🆕 handleDataChange 사용 } - }, [isModalMode, config.columns, data, onDataChange]); + }, [isModalMode, config.columns, data, handleDataChange]); - // 모달에서 항목 선택 - const handleSelectItems = useCallback((items: Record[]) => { - const fkColumn = config.dataSource?.foreignKey; - const refKey = config.dataSource?.referenceKey || "id"; - - const newRows = items.map((item) => { - const row: any = { _id: `new_${Date.now()}_${Math.random()}` }; - - // FK 값 저장 - if (fkColumn && item[refKey]) { - row[fkColumn] = item[refKey]; - } - - // 표시용 데이터 저장 - if (config.modal?.sourceDisplayColumns) { - config.modal.sourceDisplayColumns.forEach((col) => { - const key = typeof col === "string" ? col : col.key; - if (key && key !== "none") { - row[`_display_${key}`] = item[key] || ""; + // 모달에서 항목 선택 - 🆕 columns 배열에서 isSourceDisplay 플래그로 구분 + const handleSelectItems = useCallback( + (items: Record[]) => { + const fkColumn = config.dataSource?.foreignKey; + + const newRows = items.map((item) => { + const row: any = { _id: `new_${Date.now()}_${Math.random()}` }; + + // FK 값 저장 (resolvedReferenceKey 사용) + if (fkColumn && item[resolvedReferenceKey]) { + row[fkColumn] = item[resolvedReferenceKey]; + } + + // 모든 컬럼 처리 (순서대로) + config.columns.forEach((col) => { + if (col.isSourceDisplay) { + // 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용) + row[`_display_${col.key}`] = item[col.key] || ""; + } else { + // 입력 컬럼: 빈 값으로 초기화 + if (row[col.key] === undefined) { + row[col.key] = ""; + } } }); - } - - // 입력 컬럼 초기화 - config.columns.forEach((col) => { - if (row[col.key] === undefined) { - row[col.key] = ""; - } - }); - - return row; - }); - - const newData = [...data, ...newRows]; - setData(newData); - onDataChange?.(newData); - setModalOpen(false); - }, [config.dataSource?.foreignKey, config.dataSource?.referenceKey, config.modal?.sourceDisplayColumns, config.columns, data, onDataChange]); - // 소스 컬럼 목록 (모달용) + return row; + }); + + const newData = [...data, ...newRows]; + handleDataChange(newData); // 🆕 handleDataChange 사용하여 autoWidthTrigger도 증가 + setModalOpen(false); + }, + [config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange], + ); + + // 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링 const sourceColumns = useMemo(() => { - if (!config.modal?.sourceDisplayColumns) return []; - return config.modal.sourceDisplayColumns - .map((col) => typeof col === "string" ? col : col.key) + return config.columns + .filter((col) => col.isSourceDisplay && col.visible !== false) + .map((col) => col.key) .filter((key) => key && key !== "none"); - }, [config.modal?.sourceDisplayColumns]); + }, [config.columns]); return (
{/* 헤더 영역 */} -
+
- + {data.length > 0 && `${data.length}개 항목`} {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} - {repeaterColumns.length > 0 && ( - - )}
{selectedRows.size > 0 && ( - )} -
@@ -398,23 +529,26 @@ export const UnifiedRepeater: React.FC = ({ }} selectedRows={selectedRows} onSelectionChange={setSelectedRows} - equalizeWidthsTrigger={equalizeWidthsTrigger} + equalizeWidthsTrigger={autoWidthTrigger} + categoryColumns={sourceCategoryColumns} + categoryLabelMap={categoryLabelMap} /> - {/* 항목 선택 모달 (modal 모드) */} + {/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */} {isModalMode && ( )}
diff --git a/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx b/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx index a4bb95ea..c5e2a628 100644 --- a/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx +++ b/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx @@ -33,8 +33,6 @@ import { DEFAULT_REPEATER_CONFIG, RENDER_MODE_OPTIONS, MODAL_SIZE_OPTIONS, - COLUMN_WIDTH_OPTIONS, - ColumnWidthOption, } from "@/types/unified-repeater"; interface UnifiedRepeaterConfigPanelProps { @@ -276,27 +274,33 @@ export const UnifiedRepeaterConfigPanel: React.FC { - const sourceDisplayColumns = config.modal?.sourceDisplayColumns || []; - const exists = sourceDisplayColumns.some(c => c.key === column.columnName); + const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay); if (exists) { - updateModal("sourceDisplayColumns", sourceDisplayColumns.filter(c => c.key !== column.columnName)); + // 제거 + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) }); } else { - updateModal("sourceDisplayColumns", [ - ...sourceDisplayColumns, - { key: column.columnName, label: column.displayName } - ]); + // 추가 (isSourceDisplay: true) + const newColumn: RepeaterColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + editable: false, // 소스 표시 컬럼은 편집 불가 + isSourceDisplay: true, + }; + updateConfig({ columns: [...config.columns, newColumn] }); } }; const isColumnAdded = (columnName: string) => { - return config.columns.some((c) => c.key === columnName); + return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay); }; const isSourceColumnSelected = (columnName: string) => { - return (config.modal?.sourceDisplayColumns || []).some(c => c.key === columnName); + return config.columns.some((c) => c.key === columnName && c.isSourceDisplay); }; // 컬럼 속성 업데이트 @@ -372,10 +376,9 @@ export const UnifiedRepeaterConfigPanel: React.FC - + 기본 컬럼 - 모달 {/* 기본 설정 탭 */} @@ -540,29 +543,37 @@ export const UnifiedRepeaterConfigPanel: React.FC - {/* 컬럼 설정 탭 */} + {/* 컬럼 설정 탭 - 🆕 통합 컬럼 선택 */} - {/* 모달 모드: 모달에 표시할 컬럼 */} - {isModalMode && config.dataSource?.sourceTable && ( - <> -
- -

- 검색 모달에서 보여줄 컬럼 (보기용) -

- + {/* 통합 컬럼 선택 */} +
+ +

+ {isModalMode + ? "표시할 컬럼과 입력 컬럼을 선택하세요. 아이콘으로 표시/입력 구분" + : "입력받을 컬럼을 선택하세요" + } +

+ + {/* 모달 모드: 소스 테이블 컬럼 (표시용) */} + {isModalMode && config.dataSource?.sourceTable && ( + <> +
+ + 소스 테이블 ({config.dataSource.sourceTable}) - 표시용 +
{loadingSourceColumns ? (

로딩 중...

) : sourceTableColumns.length === 0 ? (

컬럼 정보가 없습니다

) : ( -
+
{sourceTableColumns.map((column) => (
toggleSourceDisplayColumn(column)} > @@ -571,39 +582,32 @@ export const UnifiedRepeaterConfigPanel: React.FC toggleSourceDisplayColumn(column)} className="pointer-events-none h-3.5 w-3.5" /> + {column.displayName} + 표시
))}
)} -
- - - )} - - {/* 추가 입력 컬럼 (현재 테이블에서 FK 제외) */} -
- -

- {isModalMode - ? "엔티티 선택 후 추가로 입력받을 컬럼 (수량, 단가 등)" - : "직접 입력받을 컬럼을 선택하세요" - } -

+ + )} + {/* 현재 테이블 컬럼 (입력용) */} +
+ + 현재 테이블 ({currentTableName || "미선택"}) - 입력용 +
{loadingColumns ? (

로딩 중...

) : inputableColumns.length === 0 ? (

- {isModalMode ? "추가 입력 가능한 컬럼이 없습니다" : "컬럼 정보가 없습니다"} + 컬럼 정보가 없습니다

) : ( -
+
{inputableColumns.map((column) => (
toggleInputColumn(column)} className="pointer-events-none h-3.5 w-3.5" /> - + {column.displayName} {column.inputType}
@@ -624,48 +628,69 @@ export const UnifiedRepeaterConfigPanel: React.FC - {/* 선택된 컬럼 상세 설정 */} + {/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */} {config.columns.length > 0 && ( <>
- -
- {config.columns.map((col) => ( -
- - + +
+ {config.columns.map((col, index) => ( +
{ + e.dataTransfer.setData("columnIndex", String(index)); + }} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10); + if (fromIndex !== index) { + const newColumns = [...config.columns]; + const [movedCol] = newColumns.splice(fromIndex, 1); + newColumns.splice(index, 0, movedCol); + updateConfig({ columns: newColumns }); + } + }} + > + + {col.isSourceDisplay ? ( + + ) : ( + + )} updateColumnProp(col.key, "title", e.target.value)} placeholder="제목" className="h-6 flex-1 text-xs" /> - - updateColumnProp(col.key, "editable", !!checked)} - title="편집 가능" - /> + {!col.isSourceDisplay && ( + updateColumnProp(col.key, "editable", !!checked)} + title="편집 가능" + /> + )}
); diff --git a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx index 7bf7a81d..c101d286 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx @@ -505,10 +505,8 @@ export function ItemSelectionModal({ ) : ( filteredResults.map((item, index) => { const selected = isSelected(item); - const uniqueFieldValue = uniqueField ? item[uniqueField] : undefined; - const itemKey = (uniqueFieldValue !== undefined && uniqueFieldValue !== null) - ? uniqueFieldValue - : `item-${index}`; + // 🔧 index를 조합하여 항상 고유한 key 생성 (중복 데이터 대응) + const itemKey = `row-${index}`; return ( ) => void; // 선택 변경 콜백 // 균등 분배 트리거 equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행 + // 🆕 카테고리 라벨 변환용 + categoryColumns?: string[]; // 카테고리 타입 컬럼명 목록 + categoryLabelMap?: Record; // 카테고리 코드 → 라벨 매핑 } export function RepeaterTable({ @@ -83,10 +87,86 @@ export function RepeaterTable({ selectedRows, onSelectionChange, equalizeWidthsTrigger, + categoryColumns = [], + categoryLabelMap = {}, }: RepeaterTableProps) { // 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링 const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]); + // 🆕 카테고리 옵션 상태 (categoryRef별로 로드된 옵션) + const [categoryOptionsMap, setCategoryOptionsMap] = useState>({}); + + // 🆕 카테고리 옵션 로드 + // categoryRef 형식: "tableName.columnName" (예: "item_info.material") + useEffect(() => { + const loadCategoryOptions = async () => { + // category 타입이면서 categoryRef가 있는 컬럼들 찾기 + const categoryColumns = visibleColumns.filter((col) => col.type === "category"); + console.log( + "🔍 [RepeaterTable] 카테고리 컬럼 확인:", + categoryColumns.map((col) => ({ field: col.field, type: col.type, categoryRef: col.categoryRef })), + ); + + const categoryRefs = categoryColumns + .filter((col) => col.categoryRef) + .map((col) => col.categoryRef!) + .filter((ref, index, self) => self.indexOf(ref) === index); // 중복 제거 + + console.log("🔍 [RepeaterTable] categoryRefs:", categoryRefs); + + if (categoryRefs.length === 0) { + console.log("⚠️ [RepeaterTable] categoryRef가 있는 컬럼이 없음"); + return; + } + + for (const categoryRef of categoryRefs) { + if (categoryOptionsMap[categoryRef]) { + console.log(`⏭️ [RepeaterTable] ${categoryRef} 이미 로드됨`); + continue; + } + + try { + // categoryRef를 tableName.columnName 형식으로 파싱 + const parts = categoryRef.split("."); + let tableName: string; + let columnName: string; + + if (parts.length >= 2) { + // "tableName.columnName" 형식 + tableName = parts[0]; + columnName = parts.slice(1).join("."); // 컬럼명에 .이 포함될 수 있음 + } else { + // 단일 값인 경우 컬럼명만 있다고 가정 (테이블명 불명) + console.warn(`카테고리 참조 형식 오류 (${categoryRef}): tableName.columnName 형식이어야 합니다`); + continue; + } + + console.log(`🌐 [RepeaterTable] API 호출: /table-categories/${tableName}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + console.log("📥 [RepeaterTable] API 응답:", response.data); + + if (response.data?.success && response.data.data) { + const options = response.data.data.map((item: any) => ({ + value: item.valueCode || item.value_code, + label: item.valueLabel || item.value_label || item.displayValue || item.display_value || item.label, + })); + console.log(`✅ [RepeaterTable] ${categoryRef} 옵션 로드 성공:`, options); + setCategoryOptionsMap((prev) => ({ + ...prev, + [categoryRef]: options, + })); + } else { + console.warn(`⚠️ [RepeaterTable] ${categoryRef} API 응답이 success가 아니거나 data가 없음`); + } + } catch (error) { + console.error(`카테고리 옵션 로드 실패 (${categoryRef}):`, error); + } + } + }; + + loadCategoryOptions(); + }, [visibleColumns]); + // 컨테이너 ref - 실제 너비 측정용 const containerRef = useRef(null); @@ -148,9 +228,11 @@ export function RepeaterTable({ // 컬럼 너비 상태 관리 const [columnWidths, setColumnWidths] = useState>(() => { const widths: Record = {}; - columns.filter((col) => !col.hidden).forEach((col) => { - widths[col.field] = col.width ? parseInt(col.width) : 120; - }); + columns + .filter((col) => !col.hidden) + .forEach((col) => { + widths[col.field] = col.width ? parseInt(col.width) : 120; + }); return widths; }); @@ -245,8 +327,9 @@ export function RepeaterTable({ let headerText = column.label || field; if (column.dynamicDataSource?.enabled && column.dynamicDataSource.options.length > 0) { const activeOptionId = activeDataSources[field] || column.dynamicDataSource.defaultOptionId; - const activeOption = column.dynamicDataSource.options.find((opt) => opt.id === activeOptionId) - || column.dynamicDataSource.options[0]; + const activeOption = + column.dynamicDataSource.options.find((opt) => opt.id === activeOptionId) || + column.dynamicDataSource.options[0]; if (activeOption?.headerLabel) { headerText = activeOption.headerLabel; } @@ -324,17 +407,22 @@ export function RepeaterTable({ return () => clearTimeout(timer); }, [visibleColumns]); - // 트리거 감지: 1=균등분배, 2=자동맞춤 + // 🆕 트리거 변경 시 자동으로 컬럼 너비 조정 (데이터 기반) useEffect(() => { if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return; + if (!containerRef.current || visibleColumns.length === 0) return; - // 홀수면 자동맞춤, 짝수면 균등분배 (토글 방식) - if (equalizeWidthsTrigger % 2 === 1) { - applyAutoFitWidths(); - } else { - applyEqualizeWidths(); - } - }, [equalizeWidthsTrigger]); + // 데이터가 있으면 데이터 기반 자동 맞춤, 없으면 균등 분배 + const timer = setTimeout(() => { + if (data.length > 0) { + applyAutoFitWidths(); + } else { + applyEqualizeWidths(); + } + }, 50); + + return () => clearTimeout(timer); + }, [equalizeWidthsTrigger, data.length]); useEffect(() => { if (!resizing) return; @@ -403,6 +491,31 @@ export function RepeaterTable({ const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const value = row[column.field]; + // 🆕 카테고리 라벨 변환 함수 + const getCategoryDisplayValue = (val: any): string => { + if (!val || typeof val !== "string") return val || "-"; + + // 카테고리 컬럼이 아니면 그대로 반환 + const fieldName = column.field.replace(/^_display_/, ""); // _display_ 접두사 제거 + if (!categoryColumns.includes(fieldName)) return val; + + // 쉼표로 구분된 다중 값 처리 + const codes = val + .split(",") + .map((c: string) => c.trim()) + .filter(Boolean); + const labels = codes.map((code: string) => categoryLabelMap[code] || code); + return labels.join(", "); + }; + + // 🆕 40자 초과 시 ... 처리 및 툴팁 표시 함수 + const truncateText = (text: string, maxLength: number = 40): { truncated: string; isTruncated: boolean } => { + if (!text || text.length <= maxLength) { + return { truncated: text || "-", isTruncated: false }; + } + return { truncated: text.substring(0, maxLength) + "...", isTruncated: true }; + }; + // 계산 필드는 편집 불가 if (column.calculated || !column.editable) { // 숫자 포맷팅 함수: 정수/소수점 자동 구분 @@ -418,7 +531,17 @@ export function RepeaterTable({ } }; - return
{column.type === "number" ? formatNumber(value) : value || "-"}
; + // 🆕 카테고리 타입이면 라벨로 변환하여 표시 + const displayValue = column.type === "number" ? formatNumber(value) : getCategoryDisplayValue(value); + + // 🆕 40자 초과 시 ... 처리 및 툴팁 + const { truncated, isTruncated } = truncateText(String(displayValue)); + + return ( +
+ {truncated} +
+ ); } // 편집 가능한 필드 @@ -484,7 +607,27 @@ export function RepeaterTable({ - {column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => ( + {column.selectOptions + ?.filter((option) => option.value && option.value !== "") + .map((option) => ( + + {option.label} + + ))} + + + ); + + case "category": { + // 🆕 카테고리 타입: categoryRef로 로드된 옵션 사용 + const options = column.categoryRef ? categoryOptionsMap[column.categoryRef] || [] : []; + return ( + ); + } default: // text return ( @@ -567,7 +711,7 @@ export function RepeaterTable({ > {/* 컬럼명 - 선택된 옵션라벨 형식으로 표시 */} - {activeOption?.headerLabel || `${col.label} - ${activeOption?.label || ''}`} + {activeOption?.headerLabel || `${col.label} - ${activeOption?.label || ""}`} @@ -653,10 +797,12 @@ export function RepeaterTable({ {({ attributes, listeners, isDragging }) => ( <> {/* 드래그 핸들 - 좌측 고정 */} - +