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/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 5f13cb78..49aed98b 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -1155,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 콜백 실행 (테이블 새로고침) @@ -1215,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 || "생성에 실패했습니다."); @@ -1320,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 || "수정에 실패했습니다."); 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 = ({ 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(() => { @@ -3364,278 +3365,342 @@ export const ButtonConfigPanel: React.FC = ({ - {/* 필드 매핑 규칙 */} + {/* 멀티 테이블 필드 매핑 */}
- {/* 소스/타겟 테이블 선택 */} -
-
- - - - - - - - - - 테이블을 찾을 수 없습니다 - - {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})} + + ))} + + + + + +
+ + +
+ ); + }) + )} +
+
+ ); + })()}
)}
@@ -3647,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 bd50ffdb..1853ebe7 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -75,6 +75,15 @@ 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); @@ -91,17 +100,67 @@ export const V2Repeater: React.FC = ({ return; } - // 데이터 정규화: {0: {...}} 형태 처리 + // 데이터 정규화: {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; - return { ...originalData, ...additionalFields }; + raw = { ...originalData, ...additionalFields }; } - return item; + 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); @@ -137,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>({}); @@ -170,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`); @@ -209,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 }; @@ -242,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, ]); @@ -362,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) { @@ -380,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); } }; @@ -407,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); @@ -548,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}`; + } } } @@ -574,63 +730,78 @@ export const V2Repeater: React.FC = ({ }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); // 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지 + // repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영) const allCategoryColumns = useMemo(() => { - const fromConfig = config.columns - .filter((col) => col.inputType === "category") - .map((col) => col.key); - const merged = new Set([...sourceCategoryColumns, ...fromConfig]); + 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, config.columns]); + }, [sourceCategoryColumns, repeaterColumns]); - // 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용) + // 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 (allCategoryColumns.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 allCategoryColumns) { - // _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.length > 0) { - 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, allCategoryColumns]); + 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( @@ -747,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 ""; @@ -767,7 +943,7 @@ export const V2Repeater: React.FC = ({ return undefined; } }, - [], + [categoryLabelMap], ); // 🆕 채번 API 호출 (비동기) @@ -801,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 ?? ""; @@ -822,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 @@ -856,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); @@ -864,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; @@ -877,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( @@ -905,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); @@ -926,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); @@ -939,6 +1243,8 @@ export const V2Repeater: React.FC = ({ generateAutoFillValueSync, generateNumberingCode, parentFormData, + categoryLabelMap, + allCategoryColumns, ], ); @@ -951,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; diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 78969fd0..5ad6d0eb 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -480,15 +480,20 @@ 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 fieldName = column.field.replace(/^_display_/, ""); + const isCategoryColumn = categoryColumns.includes(fieldName); - // 쉼표로 구분된 다중 값 처리 + // categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관) + if (categoryLabelMap[val]) return categoryLabelMap[val]; + + // 카테고리 컬럼이 아니면 원래 값 반환 + if (!isCategoryColumn) return val; + + // 콤마 구분된 다중 값 처리 const codes = val .split(",") .map((c: string) => c.trim()) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 2e8ca106..aee70dd2 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -781,6 +781,7 @@ export const TableListComponent: React.FC = ({ const dataProvider: DataProvidable = { componentId: component.id, componentType: "table-list", + tableName: tableConfig.selectedTable, getSelectedData: () => { // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 120022a5..06226c9e 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -554,6 +554,69 @@ export function TableSectionRenderer({ loadCategoryOptions(); }, [tableConfig.source.tableName, tableConfig.columns]); + // receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드 + useEffect(() => { + if (!formData || Object.keys(formData).length === 0) return; + if (!tableConfig.columns) return; + + const codesToResolve: string[] = []; + for (const col of tableConfig.columns) { + // receiveFromParent 컬럼 + if ((col as any).receiveFromParent) { + const parentField = (col as any).parentFieldName || col.field; + const val = formData[parentField]; + if (typeof val === "string" && val) { + codesToResolve.push(val); + } + } + // internal 매핑 컬럼 + const mapping = (col as any).valueMapping; + if (mapping?.type === "internal" && mapping.internalField) { + const val = formData[mapping.internalField]; + if (typeof val === "string" && val) { + codesToResolve.push(val); + } + } + } + + if (codesToResolve.length === 0) return; + + const loadParentLabels = async () => { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: codesToResolve, + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + // categoryOptionsMap에 추가 (receiveFromParent 컬럼별로) + const newOptionsMap: Record = {}; + for (const col of tableConfig.columns) { + let val: string | undefined; + if ((col as any).receiveFromParent) { + const parentField = (col as any).parentFieldName || col.field; + val = formData[parentField] as string; + } + const mapping = (col as any).valueMapping; + if (mapping?.type === "internal" && mapping.internalField) { + val = formData[mapping.internalField] as string; + } + if (val && typeof val === "string" && labelData[val]) { + newOptionsMap[col.field] = [{ value: val, label: labelData[val] }]; + } + } + if (Object.keys(newOptionsMap).length > 0) { + setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap })); + } + } + } catch { + // 라벨 조회 실패 시 무시 + } + }; + + loadParentLabels(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formData, tableConfig.columns]); + // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우) useEffect(() => { if (!isConditionalMode) return; @@ -1005,6 +1068,23 @@ export function TableSectionRenderer({ }); }, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]); + // categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생 + const tableCategoryColumns = useMemo(() => { + return Object.keys(categoryOptionsMap); + }, [categoryOptionsMap]); + + const tableCategoryLabelMap = useMemo(() => { + const map: Record = {}; + for (const options of Object.values(categoryOptionsMap)) { + for (const opt of options) { + if (opt.value && opt.label) { + map[opt.value] = opt.label; + } + } + } + return map; + }, [categoryOptionsMap]); + // 원본 계산 규칙 (조건부 계산 포함) const originalCalculationRules: TableCalculationRule[] = useMemo( () => tableConfig.calculations || [], @@ -1312,6 +1392,67 @@ export function TableSectionRenderer({ }), ); + // 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용) + const categoryFields = (tableConfig.columns || []) + .filter((col) => col.type === "category" || col.type === "select") + .reduce>>((acc, col) => { + const options = categoryOptionsMap[col.field]; + if (options && options.length > 0) { + acc[col.field] = {}; + for (const opt of options) { + acc[col.field][opt.value] = opt.label; + } + } + return acc; + }, {}); + + // receiveFromParent / internal 매핑으로 넘어온 값도 포함하여 변환 + if (Object.keys(categoryFields).length > 0) { + for (const item of mappedItems) { + for (const [field, codeToLabel] of Object.entries(categoryFields)) { + const val = item[field]; + if (typeof val === "string" && codeToLabel[val]) { + item[field] = codeToLabel[val]; + } + } + } + } + + // categoryOptionsMap에 없는 경우 API fallback + const unresolvedCodes = new Set(); + const categoryColFields = new Set( + (tableConfig.columns || []).filter((col) => col.type === "category").map((col) => col.field), + ); + for (const item of mappedItems) { + for (const field of categoryColFields) { + const val = item[field]; + if (typeof val === "string" && val && !categoryFields[field]?.[val] && val !== item[field]) { + unresolvedCodes.add(val); + } + } + } + + if (unresolvedCodes.size > 0) { + try { + const labelResp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(unresolvedCodes), + }); + if (labelResp.data?.success && labelResp.data.data) { + const labelData = labelResp.data.data as Record; + for (const item of mappedItems) { + for (const field of categoryColFields) { + const val = item[field]; + if (typeof val === "string" && labelData[val]) { + item[field] = labelData[val]; + } + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + // 계산 필드 업데이트 const calculatedItems = calculateAll(mappedItems); @@ -1319,7 +1460,7 @@ export function TableSectionRenderer({ const newData = [...tableData, ...calculatedItems]; handleDataChange(newData); }, - [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources], + [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources, categoryOptionsMap], ); // 컬럼 모드/조회 옵션 변경 핸들러 @@ -1667,6 +1808,31 @@ export function TableSectionRenderer({ }), ); + // 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용) + const categoryFields = (tableConfig.columns || []) + .filter((col) => col.type === "category" || col.type === "select") + .reduce>>((acc, col) => { + const options = categoryOptionsMap[col.field]; + if (options && options.length > 0) { + acc[col.field] = {}; + for (const opt of options) { + acc[col.field][opt.value] = opt.label; + } + } + return acc; + }, {}); + + if (Object.keys(categoryFields).length > 0) { + for (const item of mappedItems) { + for (const [field, codeToLabel] of Object.entries(categoryFields)) { + const val = item[field]; + if (typeof val === "string" && codeToLabel[val]) { + item[field] = codeToLabel[val]; + } + } + } + } + // 현재 조건의 데이터에 추가 const currentData = conditionalTableData[modalCondition] || []; const newData = [...currentData, ...mappedItems]; @@ -1964,6 +2130,8 @@ export function TableSectionRenderer({ [conditionValue]: newSelected, })); }} + categoryColumns={tableCategoryColumns} + categoryLabelMap={tableCategoryLabelMap} equalizeWidthsTrigger={widthTrigger} /> @@ -2055,6 +2223,8 @@ export function TableSectionRenderer({ })); }} equalizeWidthsTrigger={widthTrigger} + categoryColumns={tableCategoryColumns} + categoryLabelMap={tableCategoryLabelMap} /> ); @@ -2185,6 +2355,8 @@ export function TableSectionRenderer({ selectedRows={selectedRows} onSelectionChange={setSelectedRows} equalizeWidthsTrigger={widthTrigger} + categoryColumns={tableCategoryColumns} + categoryLabelMap={tableCategoryLabelMap} /> {/* 항목 선택 모달 */} diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index a937f5b2..c6673d8d 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -393,7 +393,7 @@ export interface TableModalFilter { export interface TableColumnConfig { field: string; // 필드명 (저장할 컬럼명) label: string; // 컬럼 헤더 라벨 - type: "text" | "number" | "date" | "select"; // 입력 타입 + type: "text" | "number" | "date" | "select" | "category"; // 입력 타입 // 소스 필드 매핑 (검색 모달에서 가져올 컬럼명) sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일) diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 9505d3dd..371814b5 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -897,11 +897,30 @@ export const ButtonPrimaryComponent: React.FC = ({ } } - // 4. 매핑 규칙 적용 + 추가 데이터 병합 - const mappedData = sourceData.map((row) => { - const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []); + // 4. 매핑 규칙 결정: 멀티 테이블 매핑 또는 레거시 단일 매핑 + let effectiveMappingRules: any[] = dataTransferConfig.mappingRules || []; + + const sourceTableName = sourceProvider?.tableName; + const multiTableMappings: Array<{ sourceTable: string; mappingRules: any[] }> = + dataTransferConfig.multiTableMappings || []; + + if (multiTableMappings.length > 0 && sourceTableName) { + const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName); + if (matchedGroup) { + effectiveMappingRules = matchedGroup.mappingRules || []; + console.log(`✅ [ButtonPrimary] 멀티 테이블 매핑 적용: ${sourceTableName}`, effectiveMappingRules); + } else { + console.log(`⚠️ [ButtonPrimary] 소스 테이블 ${sourceTableName}에 대한 매핑 없음, 동일 필드명 자동 매핑`); + effectiveMappingRules = []; + } + } else if (multiTableMappings.length > 0 && !sourceTableName) { + console.log("⚠️ [ButtonPrimary] 소스 테이블 미감지, 첫 번째 매핑 그룹 사용"); + effectiveMappingRules = multiTableMappings[0]?.mappingRules || []; + } + + const mappedData = sourceData.map((row) => { + const mappedRow = applyMappingRules(row, effectiveMappingRules); - // 추가 데이터를 모든 행에 포함 return { ...mappedRow, ...additionalData, diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 22c2a6f4..1eaef469 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -654,7 +654,7 @@ export const TableListComponent: React.FC = ({ const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); const [displayColumns, setDisplayColumns] = useState([]); const [columnMeta, setColumnMeta] = useState< - Record + Record >({}); // 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType) const [joinedColumnMeta, setJoinedColumnMeta] = useState< @@ -865,6 +865,7 @@ export const TableListComponent: React.FC = ({ const dataProvider: DataProvidable = { componentId: component.id, componentType: "table-list", + tableName: tableConfig.selectedTable, getSelectedData: () => { // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) @@ -1233,13 +1234,16 @@ export const TableListComponent: React.FC = ({ const cached = tableColumnCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { const labels: Record = {}; - const meta: Record = {}; + const meta: Record = {}; - // 캐시된 inputTypes 맵 생성 const inputTypeMap: Record = {}; + const categoryRefMap: Record = {}; if (cached.inputTypes) { cached.inputTypes.forEach((col: any) => { inputTypeMap[col.columnName] = col.inputType; + if (col.categoryRef) { + categoryRefMap[col.columnName] = col.categoryRef; + } }); } @@ -1248,7 +1252,8 @@ export const TableListComponent: React.FC = ({ meta[col.columnName] = { webType: col.webType, codeCategory: col.codeCategory, - inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용! + inputType: inputTypeMap[col.columnName], + categoryRef: categoryRefMap[col.columnName], }; }); @@ -1259,11 +1264,14 @@ export const TableListComponent: React.FC = ({ const columns = await tableTypeApi.getColumns(tableConfig.selectedTable); - // 컬럼 입력 타입 정보 가져오기 const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); const inputTypeMap: Record = {}; + const categoryRefMap: Record = {}; inputTypes.forEach((col: any) => { inputTypeMap[col.columnName] = col.inputType; + if (col.categoryRef) { + categoryRefMap[col.columnName] = col.categoryRef; + } }); tableColumnCache.set(cacheKey, { @@ -1273,7 +1281,7 @@ export const TableListComponent: React.FC = ({ }); const labels: Record = {}; - const meta: Record = {}; + const meta: Record = {}; columns.forEach((col: any) => { labels[col.columnName] = col.displayName || col.comment || col.columnName; @@ -1281,6 +1289,7 @@ export const TableListComponent: React.FC = ({ webType: col.webType, codeCategory: col.codeCategory, inputType: inputTypeMap[col.columnName], + categoryRef: categoryRefMap[col.columnName], }; }); @@ -1355,14 +1364,22 @@ export const TableListComponent: React.FC = ({ for (const columnName of categoryColumns) { try { - // 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인 let targetTable = tableConfig.selectedTable; let targetColumn = columnName; - if (columnName.includes(".")) { + // category_ref가 있으면 참조 테이블.컬럼 기준으로 조회 + const meta = columnMeta[columnName]; + if (meta?.categoryRef) { + const refParts = meta.categoryRef.split("."); + if (refParts.length === 2) { + targetTable = refParts[0]; + targetColumn = refParts[1]; + } + } else if (columnName.includes(".")) { + // 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태 const parts = columnName.split("."); - targetTable = parts[0]; // 조인된 테이블명 (예: item_info) - targetColumn = parts[1]; // 실제 컬럼명 (예: material) + targetTable = parts[0]; + targetColumn = parts[1]; } const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); @@ -1563,7 +1580,8 @@ export const TableListComponent: React.FC = ({ categoryColumns.length, JSON.stringify(categoryColumns), JSON.stringify(tableConfig.columns), - ]); // 더 명확한 의존성 + columnMeta, + ]); // ======================================== // 데이터 가져오기 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 7f094517..7dc5e573 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -559,6 +559,7 @@ export class ButtonActionExecutor { } // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 + // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림 if (onSave) { try { await onSave(); @@ -626,6 +627,7 @@ export class ButtonActionExecutor { // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리) + // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림 if (onSave && !hasTableSectionData) { try { await onSave(); @@ -1494,13 +1496,24 @@ export class ButtonActionExecutor { // @ts-ignore - window에 동적 속성 사용 const v2RepeaterTables = Array.from(window.__v2RepeaterInstances || []); + // V2Repeater가 동일 테이블에 존재하는지 allComponents로 감지 + // (useCustomTable 미설정 = 화면 테이블에 직접 저장하는 리피터) + const hasRepeaterOnSameTable = context.allComponents?.some((c: any) => { + const compType = c.componentType || c.overrides?.type; + if (compType !== "v2-repeater") return false; + const compConfig = c.componentConfig || c.overrides || {}; + return !compConfig.useCustomTable; + }) || false; + // 메인 저장 건너뛰기 조건: // 1. RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리 // 2. V2Repeater가 같은 테이블에 존재 (리피터 데이터에 메인 폼 데이터 병합되어 저장됨) + // 3. allComponents에서 useCustomTable 미설정 V2Repeater 감지 (글로벌 등록 없는 경우) const shouldSkipMainSave = repeatScreenModalTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName) || - v2RepeaterTables.includes(tableName); + v2RepeaterTables.includes(tableName) || + hasRepeaterOnSameTable; if (shouldSkipMainSave) { saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal/V2Repeater에서 처리" }; @@ -1779,16 +1792,7 @@ export class ButtonActionExecutor { throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); } - // 테이블과 플로우 새로고침 (모달 닫기 전에 실행) - context.onRefresh?.(); - context.onFlowRefresh?.(); - - // 저장 성공 후 이벤트 발생 - window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기 - window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리 - - // V2Repeater 저장 이벤트 발생 (메인 폼 데이터 + 리피터 데이터 병합 저장) - // 🔧 formData를 리피터에 전달하여 각 행에 병합 저장 + // V2Repeater 저장 이벤트 발생 (모달 닫기 전에 실행해야 V2Repeater가 이벤트를 수신할 수 있음) const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id; // _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑) @@ -1866,17 +1870,45 @@ export class ButtonActionExecutor { } } + console.log("🟢 [buttonActions] repeaterSave 이벤트 발행:", { + parentId: savedId, + tableName: context.tableName, + masterRecordId: savedId, + mainFormDataKeys: Object.keys(mainFormData), + }); + + // V2Repeater 저장 완료를 기다리기 위한 Promise + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); + window.dispatchEvent( new CustomEvent("repeaterSave", { detail: { parentId: savedId, tableName: context.tableName, - mainFormData, // 🆕 메인 폼 데이터 전달 - masterRecordId: savedId, // 🆕 마스터 레코드 ID (FK 자동 연결용) + mainFormData, + masterRecordId: savedId, }, }), ); + await repeaterSavePromise; + + // 테이블과 플로우 새로고침 (모달 닫기 전에 실행) + context.onRefresh?.(); + context.onFlowRefresh?.(); + + // 저장 성공 후 모달 닫기 이벤트 발생 + window.dispatchEvent(new CustomEvent("closeEditModal")); + window.dispatchEvent(new CustomEvent("saveSuccessInModal")); + return true; } catch (error) { console.error("저장 오류:", error); @@ -1884,6 +1916,50 @@ export class ButtonActionExecutor { } } + /** + * V2Repeater 디테일 데이터 저장 이벤트 발행 (onSave 콜백 경로에서도 사용) + */ + private static async dispatchRepeaterSave(context: ButtonActionContext): Promise { + const formData = context.formData || {}; + const savedId = formData.id; + + if (!savedId) { + console.log("⚠️ [dispatchRepeaterSave] savedId(formData.id) 없음 - 스킵"); + return; + } + + console.log("🟢 [dispatchRepeaterSave] repeaterSave 이벤트 발행:", { + parentId: savedId, + tableName: context.tableName, + masterRecordId: savedId, + formDataKeys: Object.keys(formData), + }); + + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); + + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: savedId, + tableName: context.tableName, + mainFormData: formData, + masterRecordId: savedId, + }, + }), + ); + + await repeaterSavePromise; + console.log("✅ [dispatchRepeaterSave] repeaterSave 완료"); + } + /** * DB에서 조회한 실제 기본키로 formData에서 값 추출 * @param formData 폼 데이터 diff --git a/frontend/types/data-transfer.ts b/frontend/types/data-transfer.ts index cdb5f55f..61aad8db 100644 --- a/frontend/types/data-transfer.ts +++ b/frontend/types/data-transfer.ts @@ -57,6 +57,15 @@ export interface MappingRule { required?: boolean; // 필수 여부 } +/** + * 멀티 테이블 매핑 그룹 + * 소스 테이블별로 별도의 매핑 규칙을 정의 + */ +export interface MultiTableMappingGroup { + sourceTable: string; + mappingRules: MappingRule[]; +} + /** * 데이터 수신자 설정 * 데이터를 받을 타겟 컴포넌트의 설정 @@ -155,6 +164,7 @@ export interface DataReceivable { export interface DataProvidable { componentId: string; componentType: string; + tableName?: string; /** * 선택된 데이터를 가져오는 메서드