diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 0cb7a850..8cd9f770 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1769,6 +1769,7 @@ export async function getCategoryColumnsByCompany( let columnsResult; // 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회 + // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) if (companyCode === "*") { const columnsQuery = ` SELECT DISTINCT @@ -1788,15 +1789,15 @@ export async function getCategoryColumnsByCompany( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = '*' + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery); - logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", { + logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", { rowCount: columnsResult.rows.length }); } else { - // 일반 회사: 해당 회사의 카테고리 컬럼만 조회 const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1815,11 +1816,12 @@ export async function getCategoryColumnsByCompany( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = $1 + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery, [companyCode]); - logger.info("✅ 회사별 카테고리 컬럼 조회 완료", { + logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", { companyCode, rowCount: columnsResult.rows.length }); @@ -1880,13 +1882,10 @@ export async function getCategoryColumnsByMenu( const { getPool } = await import("../database/db"); const pool = getPool(); - // 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회 - // category_column_mapping 대신 table_type_columns 기준으로 조회 - logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); - + // table_type_columns에서 input_type = 'category' 컬럼 조회 + // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) let columnsResult; - // 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회 if (companyCode === "*") { const columnsQuery = ` SELECT DISTINCT @@ -1906,15 +1905,15 @@ export async function getCategoryColumnsByMenu( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = '*' + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery); - logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", { + logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", { rowCount: columnsResult.rows.length }); } else { - // 일반 회사: 해당 회사의 카테고리 컬럼만 조회 const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1933,11 +1932,12 @@ export async function getCategoryColumnsByMenu( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = $1 + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery, [companyCode]); - logger.info("✅ 회사별 카테고리 컬럼 조회 완료", { + logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", { companyCode, rowCount: columnsResult.rows.length }); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 96f97fac..791940ec 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -518,8 +518,8 @@ export class TableManagementService { table_name, column_name, column_label, input_type, detail_settings, code_category, code_value, reference_table, reference_column, display_column, display_order, is_visible, is_nullable, - company_code, created_date, updated_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW()) + company_code, category_ref, created_date, updated_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW()) ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), @@ -532,6 +532,7 @@ export class TableManagementService { display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column), display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), + category_ref = EXCLUDED.category_ref, updated_date = NOW()`, [ tableName, @@ -547,6 +548,7 @@ export class TableManagementService { settings.displayOrder || 0, settings.isVisible !== undefined ? settings.isVisible : true, companyCode, + settings.categoryRef || null, ] ); @@ -4553,7 +4555,8 @@ export class TableManagementService { END as "detailSettings", ttc.is_nullable as "isNullable", ic.data_type as "dataType", - ttc.company_code as "companyCode" + ttc.company_code as "companyCode", + ttc.category_ref as "categoryRef" FROM table_type_columns ttc LEFT JOIN information_schema.columns ic ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name @@ -4630,20 +4633,24 @@ export class TableManagementService { } const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => { - const baseInfo = { + const baseInfo: any = { tableName: tableName, columnName: col.columnName, displayName: col.displayName, dataType: col.dataType || "varchar", inputType: col.inputType, detailSettings: col.detailSettings, - description: "", // 필수 필드 추가 - isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환 + description: "", + isNullable: col.isNullable === "Y" ? "Y" : "N", isPrimaryKey: false, displayOrder: 0, isVisible: true, }; + if (col.categoryRef) { + baseInfo.categoryRef = col.categoryRef; + } + // 카테고리 타입인 경우 categoryMenus 추가 if ( col.inputType === "category" && diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index e1869351..d5c41e6a 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -73,9 +73,10 @@ interface ColumnTypeInfo { referenceTable?: string; referenceColumn?: string; displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 - categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열 - hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할 - numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID + categoryMenus?: number[]; + hierarchyRole?: "large" | "medium" | "small"; + numberingRuleId?: string; + categoryRef?: string | null; } interface SecondLevelMenu { @@ -388,6 +389,7 @@ export default function TableManagementPage() { numberingRuleId, categoryMenus: col.categoryMenus || [], hierarchyRole, + categoryRef: col.categoryRef || null, }; }); @@ -670,15 +672,16 @@ export default function TableManagementPage() { } const columnSetting = { - columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) - columnLabel: column.displayName, // 사용자가 입력한 표시명 + columnName: column.columnName, + columnLabel: column.displayName, inputType: column.inputType || "text", detailSettings: finalDetailSettings, codeCategory: column.codeCategory || "", codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", referenceColumn: column.referenceColumn || "", - displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명 + displayColumn: column.displayColumn || "", + categoryRef: column.categoryRef || null, }; // console.log("저장할 컬럼 설정:", columnSetting); @@ -705,9 +708,9 @@ export default function TableManagementPage() { length: column.categoryMenus?.length || 0, }); - if (column.inputType === "category") { - // 1. 먼저 기존 매핑 모두 삭제 - console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", { + if (column.inputType === "category" && !column.categoryRef) { + // 참조가 아닌 자체 카테고리만 메뉴 매핑 처리 + console.log("기존 카테고리 메뉴 매핑 삭제 시작:", { tableName: selectedTable, columnName: column.columnName, }); @@ -866,8 +869,8 @@ export default function TableManagementPage() { } return { - columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) - columnLabel: column.displayName, // 사용자가 입력한 표시명 + columnName: column.columnName, + columnLabel: column.displayName, inputType: column.inputType || "text", detailSettings: finalDetailSettings, description: column.description || "", @@ -875,7 +878,8 @@ export default function TableManagementPage() { codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", referenceColumn: column.referenceColumn || "", - displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명 + displayColumn: column.displayColumn || "", + categoryRef: column.categoryRef || null, }; }); @@ -888,8 +892,8 @@ export default function TableManagementPage() { ); if (response.data.success) { - // 🆕 Category 타입 컬럼들의 메뉴 매핑 처리 - const categoryColumns = columns.filter((col) => col.inputType === "category"); + // 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외) + const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef); console.log("📥 전체 저장: 카테고리 컬럼 확인", { totalColumns: columns.length, @@ -1691,7 +1695,30 @@ export default function TableManagementPage() { )} )} - {/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */} + {/* 카테고리 타입: 참조 설정 */} + {column.inputType === "category" && ( +
+ + { + const val = e.target.value || null; + setColumns((prev) => + prev.map((c) => + c.columnName === column.columnName + ? { ...c, categoryRef: val } + : c + ) + ); + }} + placeholder="테이블명.컬럼명" + className="h-8 text-xs" + /> +

+ 다른 테이블의 카테고리 값 참조 시 입력 +

+
+ )} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {column.inputType === "entity" && ( <> diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 95305aaf..160883ad 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -87,10 +87,12 @@ function ScreenViewPage() { // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) const [conditionalContainerHeights, setConditionalContainerHeights] = useState>({}); - // 🆕 레이어 시스템 지원 + // 레이어 시스템 지원 const [conditionalLayers, setConditionalLayers] = useState([]); - // 🆕 조건부 영역(Zone) 목록 + // 조건부 영역(Zone) 목록 const [zones, setZones] = useState([]); + // 데이터 전달에 의해 강제 활성화된 레이어 ID 목록 + const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState([]); // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); @@ -378,11 +380,51 @@ function ScreenViewPage() { } }); - return newActiveIds; - }, [formData, conditionalLayers, layout]); + // 강제 활성화된 레이어 ID 병합 + for (const forcedId of forceActivatedLayerIds) { + if (!newActiveIds.includes(forcedId)) { + newActiveIds.push(forcedId); + } + } - // 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼) - // 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움 + return newActiveIds; + }, [formData, conditionalLayers, layout, forceActivatedLayerIds]); + + // 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너 + useEffect(() => { + const handleActivateLayer = (e: Event) => { + const { componentId, targetLayerId } = (e as CustomEvent).detail || {}; + if (!componentId && !targetLayerId) return; + + // targetLayerId가 직접 지정된 경우 + if (targetLayerId) { + setForceActivatedLayerIds((prev) => + prev.includes(targetLayerId) ? prev : [...prev, targetLayerId], + ); + console.log(`🔓 [레이어 강제 활성화] layerId: ${targetLayerId}`); + return; + } + + // componentId로 해당 컴포넌트가 속한 레이어를 찾아 활성화 + for (const layer of conditionalLayers) { + const found = layer.components.some((comp) => comp.id === componentId); + if (found) { + setForceActivatedLayerIds((prev) => + prev.includes(layer.id) ? prev : [...prev, layer.id], + ); + console.log(`🔓 [레이어 강제 활성화] componentId: ${componentId} → layerId: ${layer.id}`); + return; + } + } + }; + + window.addEventListener("activateLayerForComponent", handleActivateLayer); + return () => { + window.removeEventListener("activateLayerForComponent", handleActivateLayer); + }; + }, [conditionalLayers]); + + // 메인 테이블 데이터 자동 로드 (단일 레코드 폼) useEffect(() => { const loadMainTableData = async () => { if (!screen || !layout || !layout.components || !companyCode) { diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 16dd5afc..a79f26e3 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; interface ScreenModalState { isOpen: boolean; @@ -1025,6 +1026,10 @@ export const ScreenModal: React.FC = ({ className }) => { ) : screenData ? ( +
= ({ className }) => {
+
) : (

화면 데이터가 없습니다.

diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 8dad77db..49aed98b 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -17,6 +17,7 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm"; import { useAuth } from "@/hooks/useAuth"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; interface EditModalState { isOpen: boolean; @@ -1154,19 +1155,6 @@ export const EditModal: React.FC = ({ className }) => { if (response.success) { const masterRecordId = response.data?.id || formData.id; - // 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝) - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: masterRecordId, - masterRecordId, - mainFormData: formData, - tableName: screenData.screenInfo.tableName, - }, - }), - ); - console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName }); - toast.success("데이터가 생성되었습니다."); // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) @@ -1214,6 +1202,40 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } + // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) + try { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); + + console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", { + parentId: masterRecordId, + tableName: screenData.screenInfo.tableName, + }); + + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: masterRecordId, + tableName: screenData.screenInfo.tableName, + mainFormData: formData, + masterRecordId, + }, + }), + ); + + await repeaterSavePromise; + console.log("✅ [EditModal] INSERT 후 repeaterSave 완료"); + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } + handleClose(); } else { throw new Error(response.message || "생성에 실패했습니다."); @@ -1319,6 +1341,40 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } + // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) + try { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); + + console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", { + parentId: recordId, + tableName: screenData.screenInfo.tableName, + }); + + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: recordId, + tableName: screenData.screenInfo.tableName, + mainFormData: formData, + masterRecordId: recordId, + }, + }), + ); + + await repeaterSavePromise; + console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료"); + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } + handleClose(); } else { throw new Error(response.message || "수정에 실패했습니다."); @@ -1385,12 +1441,16 @@ export const EditModal: React.FC = ({ className }) => {
) : screenData ? ( +
{ const baseHeight = (screenDimensions?.height || 600) + 30; if (activeConditionalComponents.length > 0) { @@ -1546,6 +1606,7 @@ export const EditModal: React.FC = ({ className }) => { ); })}
+
) : (

화면 데이터가 없습니다.

diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index e2143e8e..05d228f4 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -571,8 +571,38 @@ export const InteractiveScreenViewerDynamic: React.FC { + const compType = c.componentType || c.overrides?.type; + if (compType !== "v2-repeater") return false; + const compConfig = c.componentConfig || c.overrides || {}; + return !compConfig.useCustomTable; + }); + + if (hasRepeaterOnSameTable) { + // 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장 + // 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리 + try { + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: null, + masterRecordId: null, + mainFormData: formData, + tableName: screenInfo.tableName, + }, + }), + ); + + toast.success("데이터가 성공적으로 저장되었습니다."); + } catch (error) { + toast.error("저장 중 오류가 발생했습니다."); + } + return; + } + try { - // 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) + // 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) // 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함 const masterFormData: Record = {}; @@ -591,11 +621,8 @@ export const InteractiveScreenViewerDynamic: React.FC { if (!Array.isArray(value)) { - // 배열이 아닌 값은 그대로 저장 masterFormData[key] = value; } else if (mediaColumnNames.has(key)) { - // v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응) - // 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용 masterFormData[key] = value.length > 0 ? value[0] : null; console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`); } else { @@ -608,7 +635,6 @@ export const InteractiveScreenViewerDynamic: React.FC(null); + // 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용) + const [otherLayerComponents, setOtherLayerComponents] = useState([]); + // 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기 useEffect(() => { if (activeLayerId <= 1 || !selectedScreen?.screenId) { @@ -578,6 +581,41 @@ export default function ScreenDesigner({ findZone(); }, [activeLayerId, selectedScreen?.screenId, zones]); + // 다른 레이어의 컴포넌트 메타 정보 로드 (데이터 전달 타겟 선택용) + useEffect(() => { + if (!selectedScreen?.screenId) return; + const loadOtherLayerComponents = async () => { + try { + const allLayers = await screenApi.getScreenLayers(selectedScreen.screenId); + const currentLayerId = activeLayerIdRef.current || 1; + const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0); + + const components: ComponentData[] = []; + for (const layerInfo of otherLayers) { + try { + const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerInfo.layer_id); + const rawComps = layerData?.components; + if (rawComps && Array.isArray(rawComps)) { + for (const comp of rawComps) { + components.push({ + ...comp, + _layerName: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`, + _layerId: String(layerInfo.layer_id), + } as any); + } + } + } catch { + // 개별 레이어 로드 실패 무시 + } + } + setOtherLayerComponents(components); + } catch { + setOtherLayerComponents([]); + } + }; + loadOtherLayerComponents(); + }, [selectedScreen?.screenId, activeLayerId]); + // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) const visibleComponents = useMemo(() => { return layout.components; @@ -6516,8 +6554,8 @@ export default function ScreenDesigner({ updateComponentProperty(selectedComponent.id, "style", style); } }} - allComponents={layout.components} // 🆕 플로우 위젯 감지용 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 + allComponents={[...layout.components, ...otherLayerComponents]} + menuObjid={menuObjid} /> )} diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index ea2febb1..4919ec33 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -92,13 +92,14 @@ export const ButtonConfigPanel: React.FC = ({ const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState>({}); // 블록별 테이블 Popover 열림 상태 const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState>({}); // 블록별 컬럼 Popover 열림 상태 - // 🆕 데이터 전달 필드 매핑용 상태 - const [mappingSourceColumns, setMappingSourceColumns] = useState>([]); + // 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원) + const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState>>({}); const [mappingTargetColumns, setMappingTargetColumns] = useState>([]); - const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); - const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); - const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); - const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); + const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); + const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); + const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0); // 🆕 openModalWithData 전용 필드 매핑 상태 const [modalSourceColumns, setModalSourceColumns] = useState>([]); @@ -295,57 +296,57 @@ export const ButtonConfigPanel: React.FC = ({ } }; - // 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드 - useEffect(() => { - const sourceTable = config.action?.dataTransfer?.sourceTable; - const targetTable = config.action?.dataTransfer?.targetTable; + // 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드 + const loadMappingColumns = useCallback(async (tableName: string): Promise> => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - const loadColumns = async () => { - if (sourceTable) { - try { - const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`); - if (response.data.success) { - let columnData = response.data.data; - if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; - if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - - if (Array.isArray(columnData)) { - const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, - })); - setMappingSourceColumns(columns); - } - } - } catch (error) { - console.error("소스 테이블 컬럼 로드 실패:", error); + if (Array.isArray(columnData)) { + return columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); } } + } catch (error) { + console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error); + } + return []; + }, []); - if (targetTable) { - try { - const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`); - if (response.data.success) { - let columnData = response.data.data; - if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; - if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + useEffect(() => { + const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || []; + const legacySourceTable = config.action?.dataTransfer?.sourceTable; + const targetTable = config.action?.dataTransfer?.targetTable; - if (Array.isArray(columnData)) { - const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, - })); - setMappingTargetColumns(columns); - } - } - } catch (error) { - console.error("타겟 테이블 컬럼 로드 실패:", error); + const loadAll = async () => { + const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean); + if (legacySourceTable && !sourceTableNames.includes(legacySourceTable)) { + sourceTableNames.push(legacySourceTable); + } + + const newMap: Record> = {}; + for (const tbl of sourceTableNames) { + if (!mappingSourceColumnsMap[tbl]) { + newMap[tbl] = await loadMappingColumns(tbl); } } + if (Object.keys(newMap).length > 0) { + setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap })); + } + + if (targetTable && mappingTargetColumns.length === 0) { + const cols = await loadMappingColumns(targetTable); + setMappingTargetColumns(cols); + } }; - loadColumns(); - }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); + loadAll(); + }, [config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns]); // 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드 useEffect(() => { @@ -2966,11 +2967,17 @@ export const ButtonConfigPanel: React.FC = ({ - {/* 데이터 제공 가능한 컴포넌트 필터링 */} + {/* 자동 탐색 옵션 (레이어별 테이블이 다를 때 유용) */} + +
+ 자동 탐색 (현재 활성 테이블) + (auto) +
+
+ {/* 데이터 제공 가능한 컴포넌트 필터링 (모든 레이어 포함) */} {allComponents .filter((comp: any) => { const type = comp.componentType || comp.type || ""; - // 데이터를 제공할 수 있는 컴포넌트 타입들 return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t), ); @@ -2978,11 +2985,17 @@ export const ButtonConfigPanel: React.FC = ({ .map((comp: any) => { const compType = comp.componentType || comp.type || "unknown"; const compLabel = comp.label || comp.componentConfig?.title || comp.id; + const layerName = comp._layerName; return (
{compLabel} ({compType}) + {layerName && ( + + {layerName} + + )}
); @@ -2999,7 +3012,9 @@ export const ButtonConfigPanel: React.FC = ({ )}
-

테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트

+

+ 레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다 +

@@ -3037,33 +3052,47 @@ export const ButtonConfigPanel: React.FC = ({ { - const currentSources = config.action?.dataTransfer?.additionalSources || []; - const newSources = [...currentSources]; - if (newSources.length === 0) { - newSources.push({ componentId: "", fieldName: e.target.value }); - } else { - newSources[0] = { ...newSources[0], fieldName: e.target.value }; - } - onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); - }} - className="h-8 text-xs" - /> -

타겟 테이블에 저장될 필드명

+ + + + + + + + + 컬럼을 찾을 수 없습니다. + + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: "" }); + } else { + newSources[0] = { ...newSources[0], fieldName: "" }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + 선택 안 함 (전체 데이터 병합) + + {(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => ( + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: col.name }); + } else { + newSources[0] = { ...newSources[0], fieldName: col.name }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + {col.label || col.name} + {col.label && col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +

추가 데이터가 저장될 타겟 테이블 컬럼

- {/* 필드 매핑 규칙 */} + {/* 멀티 테이블 필드 매핑 */}
- {/* 소스/타겟 테이블 선택 */} -
-
- - - - - - - - - - 테이블을 찾을 수 없습니다 - - {availableTables.map((table) => ( - { - onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name); - }} - className="text-xs" - > - - {table.label} - ({table.name}) - - ))} - - - - - -
- -
- - - - - - - - - - 테이블을 찾을 수 없습니다 - - {availableTables.map((table) => ( - { - onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); - }} - className="text-xs" - > - - {table.label} - ({table.name}) - - ))} - - - - - -
+ {/* 타겟 테이블 (공통) */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + +
- {/* 필드 매핑 규칙 */} + {/* 소스 테이블 매핑 그룹 */}
- +

- 소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다. + 여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.

- {!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? ( + {!config.action?.dataTransfer?.targetTable ? (
-

먼저 소스 테이블과 타겟 테이블을 선택하세요.

+

먼저 타겟 테이블을 선택하세요.

- ) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? ( + ) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (

- 매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다. + 매핑 그룹이 없습니다. 소스 테이블을 추가하세요.

) : (
- {(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => ( -
- {/* 소스 필드 선택 (Combobox) */} -
- setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} + {/* 소스 테이블 탭 */} +
+ {(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => ( +
+ - - - - - setMappingSourceSearch((prev) => ({ ...prev, [index]: value })) - } - /> - - - 컬럼을 찾을 수 없습니다 - - - {mappingSourceColumns.map((col) => ( - { - const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; - rules[index] = { ...rules[index], sourceField: col.name }; - onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); - setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false })); - }} - className="text-xs" - > - - {col.label} - {col.label !== col.name && ( - ({col.name}) - )} - - ))} - - - - - -
- - - - {/* 타겟 필드 선택 (Combobox) */} -
- setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} + {group.sourceTable + ? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable + : `그룹 ${gIdx + 1}`} + {group.mappingRules?.length > 0 && ( + + {group.mappingRules.length} + + )} + + - - - - - setMappingTargetSearch((prev) => ({ ...prev, [index]: value })) - } - /> - - - 컬럼을 찾을 수 없습니다 - - - {mappingTargetColumns.map((col) => ( - { - const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; - rules[index] = { ...rules[index], targetField: col.name }; - onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); - setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false })); - }} - className="text-xs" - > - - {col.label} - {col.label !== col.name && ( - ({col.name}) - )} - - ))} - - - - - + +
+ ))} +
- -
- ))} + {/* 활성 그룹 편집 영역 */} + {(() => { + const multiMappings = config.action?.dataTransfer?.multiTableMappings || []; + const activeGroup = multiMappings[activeMappingGroupIndex]; + if (!activeGroup) return null; + + const activeSourceTable = activeGroup.sourceTable || ""; + const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || []; + const activeRules: any[] = activeGroup.mappingRules || []; + + const updateGroupField = (field: string, value: any) => { + const mappings = [...multiMappings]; + mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value }; + onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings); + }; + + return ( +
+ {/* 소스 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + updateGroupField("sourceTable", table.name); + if (!mappingSourceColumnsMap[table.name]) { + const cols = await loadMappingColumns(table.name); + setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols })); + } + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ + {/* 매핑 규칙 목록 */} +
+
+ + +
+ + {!activeSourceTable ? ( +

소스 테이블을 먼저 선택하세요.

+ ) : activeRules.length === 0 ? ( +

매핑 없음 (동일 필드명 자동 매핑)

+ ) : ( + activeRules.map((rule: any, rIdx: number) => { + const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`; + const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`; + return ( +
+
+ + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open })) + } + > + + + + + + + + 컬럼 없음 + + {activeSourceColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ({col.name})} + + ))} + + + + + +
+ + + +
+ + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open })) + } + > + + + + + + + + 컬럼 없음 + + {mappingTargetColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], targetField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ({col.name})} + + ))} + + + + + +
+ + +
+ ); + }) + )} +
+
+ ); + })()}
)}
@@ -3567,9 +3712,9 @@ export const ButtonConfigPanel: React.FC = ({
1. 소스 컴포넌트에서 데이터를 선택합니다
- 2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드) + 2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
- 3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다 + 3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다

diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index 3be70840..d6ed8c62 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -72,9 +72,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, allColumns = response.data; } - // category 타입 컬럼만 필터링 + // category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외) const categoryColumns = allColumns.filter( - (col: any) => col.inputType === "category" || col.input_type === "category" + (col: any) => (col.inputType === "category" || col.input_type === "category") + && !col.categoryRef && !col.category_ref ); console.log("✅ 카테고리 컬럼 필터링 완료:", { diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index d2b288ff..1853ebe7 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -23,6 +23,9 @@ import { import { apiClient } from "@/lib/api/client"; import { allocateNumberingCode } from "@/lib/api/numberingRule"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { DataReceivable } from "@/types/data-transfer"; +import { toast } from "sonner"; // modal-repeater-table 컴포넌트 재사용 import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable"; @@ -38,6 +41,7 @@ declare global { export const V2Repeater: React.FC = ({ config: propConfig, + componentId, parentId, data: initialData, onDataChange, @@ -48,6 +52,12 @@ export const V2Repeater: React.FC = ({ }) => { // ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용) const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData; + + // componentId 결정: 직접 전달 또는 component 객체에서 추출 + const effectiveComponentId = componentId || (restProps as any).component?.id; + + // ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null) + const screenContext = useScreenContextOptional(); // 설정 병합 const config: V2RepeaterConfig = useMemo( () => ({ @@ -65,9 +75,119 @@ export const V2Repeater: React.FC = ({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [modalOpen, setModalOpen] = useState(false); + // 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + // 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용) + const loadedIdsRef = useRef>(new Set()); + // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 const [autoWidthTrigger, setAutoWidthTrigger] = useState(0); + // ScreenContext DataReceiver 등록 (데이터 전달 액션 수신) + const onDataChangeRef = useRef(onDataChange); + onDataChangeRef.current = onDataChange; + + const handleReceiveData = useCallback( + async (incomingData: any[], configOrMode?: any) => { + console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode }); + + if (!incomingData || incomingData.length === 0) { + toast.warning("전달할 데이터가 없습니다"); + return; + } + + // 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거 + const metaFieldsToStrip = new Set([ + "id", + "created_date", + "updated_date", + "created_by", + "updated_by", + "company_code", + ]); + const normalizedData = incomingData.map((item: any) => { + let raw = item; + if (item && typeof item === "object" && item[0] && typeof item[0] === "object") { + const { 0: originalData, ...additionalFields } = item; + raw = { ...originalData, ...additionalFields }; + } + const cleaned: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (!metaFieldsToStrip.has(key)) { + cleaned[key] = value; + } + } + return cleaned; + }); + + const mode = configOrMode?.mode || configOrMode || "append"; + + // 카테고리 코드 → 라벨 변환 + // allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환 + const codesToResolve = new Set(); + for (const item of normalizedData) { + for (const [key, val] of Object.entries(item)) { + if (key.startsWith("_")) continue; + if (typeof val === "string" && val && !categoryLabelMapRef.current[val]) { + codesToResolve.add(val as string); + } + } + } + + if (codesToResolve.size > 0) { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(codesToResolve), + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const item of normalizedData) { + for (const key of Object.keys(item)) { + if (key.startsWith("_")) continue; + const val = item[key]; + if (typeof val === "string" && labelData[val]) { + item[key] = labelData[val]; + } + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + + setData((prev) => { + const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData]; + onDataChangeRef.current?.(next); + return next; + }); + + toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`); + }, + [], + ); + + useEffect(() => { + if (screenContext && effectiveComponentId) { + const receiver: DataReceivable = { + componentId: effectiveComponentId, + componentType: "v2-repeater", + receiveData: handleReceiveData, + }; + console.log("📋 [V2Repeater] ScreenContext에 데이터 수신자 등록:", effectiveComponentId); + screenContext.registerDataReceiver(effectiveComponentId, receiver); + + return () => { + screenContext.unregisterDataReceiver(effectiveComponentId); + }; + } + }, [screenContext, effectiveComponentId, handleReceiveData]); + // 소스 테이블 컬럼 라벨 매핑 const [sourceColumnLabels, setSourceColumnLabels] = useState>({}); @@ -76,6 +196,10 @@ export const V2Repeater: React.FC = ({ // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) const [categoryLabelMap, setCategoryLabelMap] = useState>({}); + const categoryLabelMapRef = useRef>({}); + useEffect(() => { + categoryLabelMapRef.current = categoryLabelMap; + }, [categoryLabelMap]); // 현재 테이블 컬럼 정보 (inputType 매핑용) const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); @@ -109,35 +233,54 @@ export const V2Repeater: React.FC = ({ }; }, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]); - // 저장 이벤트 리스너 + // 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조) useEffect(() => { const handleSaveEvent = async (event: CustomEvent) => { - // 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용 - const tableName = - config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; - const eventParentId = event.detail?.parentId; - const mainFormData = event.detail?.mainFormData; + const currentData = dataRef.current; + const currentCategoryMap = categoryLabelMapRef.current; - // 🆕 마스터 테이블에서 생성된 ID (FK 연결용) + const configTableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + const tableName = configTableName || event.detail?.tableName; + const mainFormData = event.detail?.mainFormData; const masterRecordId = event.detail?.masterRecordId || mainFormData?.id; - if (!tableName || data.length === 0) { + console.log("🔵 [V2Repeater] repeaterSave 이벤트 수신:", { + configTableName, + tableName, + masterRecordId, + dataLength: currentData.length, + foreignKeyColumn: config.foreignKeyColumn, + foreignKeySourceColumn: config.foreignKeySourceColumn, + dataSnapshot: currentData.map((r: any) => ({ id: r.id, item_name: r.item_name })), + }); + toast.info(`[디버그] V2Repeater 이벤트 수신: ${currentData.length}건, table=${tableName}`); + + if (!tableName || currentData.length === 0) { + console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length }); + toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`); + window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); return; } - // V2Repeater 저장 시작 - const saveInfo = { + if (config.foreignKeyColumn) { + const sourceCol = config.foreignKeySourceColumn; + const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined; + if (!hasFkSource && !masterRecordId) { + console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵"); + window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); + return; + } + } + + console.log("V2Repeater 저장 시작", { tableName, - useCustomTable: config.useCustomTable, - mainTableName: config.mainTableName, foreignKeyColumn: config.foreignKeyColumn, masterRecordId, - dataLength: data.length, - }; - console.log("V2Repeater 저장 시작", saveInfo); + dataLength: currentData.length, + }); try { - // 테이블 유효 컬럼 조회 let validColumns: Set = new Set(); try { const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); @@ -148,13 +291,10 @@ export const V2Repeater: React.FC = ({ console.warn("테이블 컬럼 정보 조회 실패"); } - for (let i = 0; i < data.length; i++) { - const row = data[i]; - - // 내부 필드 제거 + for (let i = 0; i < currentData.length; i++) { + const row = currentData[i]; const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_"))); - // 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함) let mergedData: Record; if (config.useCustomTable && config.mainTableName) { mergedData = { ...cleanRow }; @@ -181,59 +321,83 @@ export const V2Repeater: React.FC = ({ }; } - // 유효하지 않은 컬럼 제거 const filteredData: Record = {}; for (const [key, value] of Object.entries(mergedData)) { if (validColumns.size === 0 || validColumns.has(key)) { - filteredData[key] = value; + if (typeof value === "string" && currentCategoryMap[value]) { + filteredData[key] = currentCategoryMap[value]; + } else { + filteredData[key] = value; + } } } - // 기존 행(id 존재)은 UPDATE, 새 행은 INSERT const rowId = row.id; + console.log(`🔧 [V2Repeater] 행 ${i} 저장:`, { + rowId, + isUpdate: rowId && typeof rowId === "string" && rowId.includes("-"), + filteredDataKeys: Object.keys(filteredData), + }); if (rowId && typeof rowId === "string" && rowId.includes("-")) { - // UUID 형태의 id가 있으면 기존 데이터 → UPDATE const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData; await apiClient.put(`/table-management/tables/${tableName}/edit`, { originalData: { id: rowId }, updatedData: updateFields, }); } else { - // 새 행 → INSERT await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); } } + // 삭제된 행 처리: 원본에는 있었지만 현재 data에 없는 ID를 DELETE + const currentIds = new Set(currentData.map((r) => r.id).filter(Boolean)); + const deletedIds = Array.from(loadedIdsRef.current).filter((id) => !currentIds.has(id)); + if (deletedIds.length > 0) { + console.log("🗑️ [V2Repeater] 삭제할 행:", deletedIds); + try { + await apiClient.delete(`/table-management/tables/${tableName}/delete`, { + data: deletedIds.map((id) => ({ id })), + }); + console.log(`✅ [V2Repeater] ${deletedIds.length}건 삭제 완료`); + } catch (deleteError) { + console.error("❌ [V2Repeater] 삭제 실패:", deleteError); + } + } + + // 저장 완료 후 loadedIdsRef 갱신 + loadedIdsRef.current = new Set(currentData.map((r) => r.id).filter(Boolean)); + + toast.success(`V2Repeater ${currentData.length}건 저장 완료`); } catch (error) { console.error("❌ V2Repeater 저장 실패:", error); - throw error; + toast.error(`V2Repeater 저장 실패: ${error}`); + } finally { + window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); } }; - // V2 EventBus 구독 const unsubscribe = v2EventBus.subscribe( V2_EVENTS.REPEATER_SAVE, async (payload) => { - const tableName = + const configTableName = config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; - if (payload.tableName === tableName) { + if (!configTableName || payload.tableName === configTableName) { await handleSaveEvent({ detail: payload } as CustomEvent); } }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` }, + { componentId: `v2-repeater-${config.dataSource?.tableName || "same-table"}` }, ); - // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) window.addEventListener("repeaterSave" as any, handleSaveEvent); return () => { unsubscribe(); window.removeEventListener("repeaterSave" as any, handleSaveEvent); }; }, [ - data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, + config.foreignKeySourceColumn, parentId, ]); @@ -301,7 +465,6 @@ export const V2Repeater: React.FC = ({ }); // 각 행에 소스 테이블의 표시 데이터 병합 - // RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함 rows.forEach((row: any) => { const sourceRecord = sourceMap.get(String(row[fkColumn])); if (sourceRecord) { @@ -319,12 +482,50 @@ export const V2Repeater: React.FC = ({ } } + // DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환 + const codesToResolve = new Set(); + for (const row of rows) { + for (const val of Object.values(row)) { + if (typeof val === "string" && val.startsWith("CATEGORY_")) { + codesToResolve.add(val); + } + } + } + + if (codesToResolve.size > 0) { + try { + const labelResp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(codesToResolve), + }); + if (labelResp.data?.success && labelResp.data.data) { + const labelData = labelResp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const row of rows) { + for (const key of Object.keys(row)) { + if (key.startsWith("_")) continue; + const val = row[key]; + if (typeof val === "string" && labelData[val]) { + row[key] = labelData[val]; + } + } + } + } + } catch { + // 라벨 변환 실패 시 코드 유지 + } + } + + // 원본 ID 목록 기록 (삭제 추적용) + const ids = rows.map((r: any) => r.id).filter(Boolean); + loadedIdsRef.current = new Set(ids); + console.log("📋 [V2Repeater] 원본 ID 기록:", ids); + setData(rows); dataLoadedRef.current = true; if (onDataChange) onDataChange(rows); } } catch (error) { - console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error); + console.error("[V2Repeater] 기존 데이터 로드 실패:", error); } }; @@ -346,16 +547,28 @@ export const V2Repeater: React.FC = ({ if (!tableName) return; try { - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - const columns = response.data?.data?.columns || response.data?.columns || response.data || []; + const [colResponse, typeResponse] = await Promise.all([ + apiClient.get(`/table-management/tables/${tableName}/columns`), + apiClient.get(`/table-management/tables/${tableName}/web-types`), + ]); + const columns = colResponse.data?.data?.columns || colResponse.data?.columns || colResponse.data || []; + const inputTypes = typeResponse.data?.data || []; + + // inputType/categoryRef 매핑 생성 + const typeMap: Record = {}; + inputTypes.forEach((t: any) => { + typeMap[t.columnName] = t; + }); const columnMap: Record = {}; columns.forEach((col: any) => { const name = col.columnName || col.column_name || col.name; + const typeInfo = typeMap[name]; columnMap[name] = { - inputType: col.inputType || col.input_type || col.webType || "text", + inputType: typeInfo?.inputType || col.inputType || col.input_type || col.webType || "text", displayName: col.displayName || col.display_name || col.label || name, detailSettings: col.detailSettings || col.detail_settings, + categoryRef: typeInfo?.categoryRef || null, }; }); setCurrentTableColumnInfo(columnMap); @@ -487,14 +700,18 @@ export const V2Repeater: React.FC = ({ else if (inputType === "code") type = "select"; else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 - // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식) - // category 타입인 경우 현재 테이블명과 컬럼명을 조합 + // 카테고리 참조 ID 결정 + // DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용 let categoryRef: string | undefined; if (inputType === "category") { - // 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용 - const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; - if (tableName) { - categoryRef = `${tableName}.${col.key}`; + const dbCategoryRef = colInfo?.detailSettings?.categoryRef || colInfo?.categoryRef; + if (dbCategoryRef) { + categoryRef = dbCategoryRef; + } else { + const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; + if (tableName) { + categoryRef = `${tableName}.${col.key}`; + } } } @@ -512,55 +729,79 @@ export const V2Repeater: React.FC = ({ }); }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); - // 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용) + // 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지 + // repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영) + const allCategoryColumns = useMemo(() => { + const fromRepeater = repeaterColumns + .filter((col) => col.type === "category") + .map((col) => col.field.replace(/^_display_/, "")); + const merged = new Set([...sourceCategoryColumns, ...fromRepeater]); + return Array.from(merged); + }, [sourceCategoryColumns, repeaterColumns]); + + // CATEGORY_ 코드 배열을 받아 라벨을 일괄 조회하는 함수 + const fetchCategoryLabels = useCallback(async (codes: string[]) => { + if (codes.length === 0) return; + try { + const response = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: codes, + }); + if (response.data?.success && response.data.data) { + setCategoryLabelMap((prev) => ({ ...prev, ...response.data.data })); + } + } catch (error) { + console.error("카테고리 라벨 조회 실패:", error); + } + }, []); + + // parentFormData(마스터 행)에서 카테고리 코드를 미리 로드 + // fromMainForm autoFill에서 참조할 마스터 필드의 라벨을 사전에 확보 useEffect(() => { - const loadCategoryLabels = async () => { - if (sourceCategoryColumns.length === 0 || data.length === 0) { - return; - } + if (!parentFormData) return; + const codes: string[] = []; - // 데이터에서 카테고리 컬럼의 모든 고유 코드 수집 - 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); - } - } - } + // fromMainForm autoFill의 sourceField 값 중 카테고리 컬럼에 해당하는 것만 수집 + for (const col of config.columns) { + if (col.autoFill?.type === "fromMainForm" && col.autoFill.sourceField) { + const val = parentFormData[col.autoFill.sourceField]; + if (typeof val === "string" && val && !categoryLabelMap[val]) { + codes.push(val); } } - - 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, - })); + // receiveFromParent 패턴 + if ((col as any).receiveFromParent) { + const parentField = (col as any).parentFieldName || col.key; + const val = parentFormData[parentField]; + if (typeof val === "string" && val && !categoryLabelMap[val]) { + codes.push(val); } - } catch (error) { - console.error("카테고리 라벨 조회 실패:", error); } - }; + } - loadCategoryLabels(); - }, [data, sourceCategoryColumns]); + if (codes.length > 0) { + fetchCategoryLabels(codes); + } + }, [parentFormData, config.columns, fetchCategoryLabels]); + + // 데이터 변경 시 카테고리 라벨 로드 + useEffect(() => { + if (data.length === 0) return; + + const allCodes = new Set(); + + for (const row of data) { + for (const col of allCategoryColumns) { + const val = row[`_display_${col}`] || row[col]; + if (val && typeof val === "string") { + val.split(",").map((c: string) => c.trim()).filter(Boolean).forEach((code: string) => { + if (!categoryLabelMap[code]) allCodes.add(code); + }); + } + } + } + + fetchCategoryLabels(Array.from(allCodes)); + }, [data, allCategoryColumns, fetchCategoryLabels]); // 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능) const applyCalculationRules = useCallback( @@ -677,7 +918,12 @@ export const V2Repeater: React.FC = ({ case "fromMainForm": if (col.autoFill.sourceField && mainFormData) { - return mainFormData[col.autoFill.sourceField]; + const rawValue = mainFormData[col.autoFill.sourceField]; + // categoryLabelMap에 매핑이 있으면 라벨로 변환 (접두사 무관) + if (typeof rawValue === "string" && categoryLabelMap[rawValue]) { + return categoryLabelMap[rawValue]; + } + return rawValue; } return ""; @@ -697,7 +943,7 @@ export const V2Repeater: React.FC = ({ return undefined; } }, - [], + [categoryLabelMap], ); // 🆕 채번 API 호출 (비동기) @@ -731,7 +977,12 @@ export const V2Repeater: React.FC = ({ const row: any = { _id: `grouped_${Date.now()}_${index}` }; for (const col of config.columns) { - const sourceValue = item[(col as any).sourceKey || col.key]; + let sourceValue = item[(col as any).sourceKey || col.key]; + + // 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반) + if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) { + sourceValue = categoryLabelMap[sourceValue]; + } if (col.isSourceDisplay) { row[col.key] = sourceValue ?? ""; @@ -752,6 +1003,48 @@ export const V2Repeater: React.FC = ({ return row; }); + // 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관) + const categoryColSet = new Set(allCategoryColumns); + const codesToResolve = new Set(); + for (const row of newRows) { + for (const col of config.columns) { + const val = row[col.key] || row[`_display_${col.key}`]; + if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) { + if (!categoryLabelMap[val]) { + codesToResolve.add(val); + } + } + } + } + + if (codesToResolve.size > 0) { + apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(codesToResolve), + }).then((resp) => { + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + const convertedRows = newRows.map((row) => { + const updated = { ...row }; + for (const col of config.columns) { + const val = updated[col.key]; + if (typeof val === "string" && labelData[val]) { + updated[col.key] = labelData[val]; + } + const dispKey = `_display_${col.key}`; + const dispVal = updated[dispKey]; + if (typeof dispVal === "string" && labelData[dispVal]) { + updated[dispKey] = labelData[dispVal]; + } + } + return updated; + }); + setData(convertedRows); + onDataChange?.(convertedRows); + } + }).catch(() => {}); + } + setData(newRows); onDataChange?.(newRows); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -786,7 +1079,7 @@ export const V2Repeater: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [parentFormData, config.columns, generateAutoFillValueSync]); - // 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 + // 행 추가 (inline 모드 또는 모달 열기) const handleAddRow = useCallback(async () => { if (isModalMode) { setModalOpen(true); @@ -794,11 +1087,10 @@ export const V2Repeater: React.FC = ({ const newRow: any = { _id: `new_${Date.now()}` }; const currentRowCount = data.length; - // 먼저 동기적 자동 입력 값 적용 + // 동기적 자동 입력 값 적용 for (const col of config.columns) { const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData); if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) { - // 채번 규칙: 즉시 API 호출 newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId); } else if (autoValue !== undefined) { newRow[col.key] = autoValue; @@ -807,10 +1099,51 @@ export const V2Repeater: React.FC = ({ } } + // fromMainForm 등으로 넘어온 카테고리 코드 → 라벨 변환 + // allCategoryColumns에 해당하는 컬럼이거나 categoryLabelMap에 매핑이 있으면 변환 + const categoryColSet = new Set(allCategoryColumns); + const unresolvedCodes: string[] = []; + for (const col of config.columns) { + const val = newRow[col.key]; + if (typeof val !== "string" || !val) continue; + + // 이 컬럼이 카테고리 타입이거나, fromMainForm으로 가져온 값인 경우 + const isCategoryCol = categoryColSet.has(col.key); + const isFromMainForm = col.autoFill?.type === "fromMainForm"; + + if (isCategoryCol || isFromMainForm) { + if (categoryLabelMap[val]) { + newRow[col.key] = categoryLabelMap[val]; + } else { + unresolvedCodes.push(val); + } + } + } + + if (unresolvedCodes.length > 0) { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: unresolvedCodes, + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const col of config.columns) { + const val = newRow[col.key]; + if (typeof val === "string" && labelData[val]) { + newRow[col.key] = labelData[val]; + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + const newData = [...data, newRow]; handleDataChange(newData); } - }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData]); + }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]); // 모달에서 항목 선택 - 비동기로 변경 const handleSelectItems = useCallback( @@ -835,8 +1168,12 @@ export const V2Repeater: React.FC = ({ // 모든 컬럼 처리 (순서대로) for (const col of config.columns) { if (col.isSourceDisplay) { - // 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용) - row[`_display_${col.key}`] = item[col.key] || ""; + let displayVal = item[col.key] || ""; + // 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관) + if (typeof displayVal === "string" && categoryLabelMap[displayVal]) { + displayVal = categoryLabelMap[displayVal]; + } + row[`_display_${col.key}`] = displayVal; } else { // 자동 입력 값 적용 const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData); @@ -856,6 +1193,43 @@ export const V2Repeater: React.FC = ({ }), ); + // 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환 + const categoryColSet = new Set(allCategoryColumns); + const unresolvedCodes = new Set(); + for (const row of newRows) { + for (const col of config.columns) { + const val = row[col.key]; + if (typeof val !== "string" || !val) continue; + const isCategoryCol = categoryColSet.has(col.key); + const isFromMainForm = col.autoFill?.type === "fromMainForm"; + if ((isCategoryCol || isFromMainForm) && !categoryLabelMap[val]) { + unresolvedCodes.add(val); + } + } + } + + if (unresolvedCodes.size > 0) { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(unresolvedCodes), + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const row of newRows) { + for (const col of config.columns) { + const val = row[col.key]; + if (typeof val === "string" && labelData[val]) { + row[col.key] = labelData[val]; + } + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + const newData = [...data, ...newRows]; handleDataChange(newData); setModalOpen(false); @@ -869,6 +1243,8 @@ export const V2Repeater: React.FC = ({ generateAutoFillValueSync, generateNumberingCode, parentFormData, + categoryLabelMap, + allCategoryColumns, ], ); @@ -881,9 +1257,6 @@ export const V2Repeater: React.FC = ({ }, [config.columns]); // 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환 - const dataRef = useRef(data); - dataRef.current = data; - useEffect(() => { const handleBeforeFormSave = async (event: Event) => { const customEvent = event as CustomEvent; @@ -1112,7 +1485,7 @@ export const V2Repeater: React.FC = ({ selectedRows={selectedRows} onSelectionChange={setSelectedRows} equalizeWidthsTrigger={autoWidthTrigger} - categoryColumns={sourceCategoryColumns} + categoryColumns={allCategoryColumns} categoryLabelMap={categoryLabelMap} />
diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 5b5b5fc2..1f89ae12 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -1214,13 +1214,21 @@ export const V2RepeaterConfigPanel: React.FC = ({ )} - {/* 편집 가능 체크박스 */} + {/* 편집 가능 토글 */} {!col.isSourceDisplay && ( - updateColumnProp(col.key, "editable", !!checked)} - title="편집 가능" - /> + )}