diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 4a80b007..ce6a73b9 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1811,3 +1811,299 @@ export async function getCategoryColumnsByMenu( }); } } + +/** + * 범용 다중 테이블 저장 API + * + * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. + * + * 요청 본문: + * { + * mainTable: { tableName: string, primaryKeyColumn: string }, + * mainData: Record, + * subTables: Array<{ + * tableName: string, + * linkColumn: { mainField: string, subColumn: string }, + * items: Record[], + * options?: { + * saveMainAsFirst?: boolean, + * mainFieldMappings?: Array<{ formField: string, targetColumn: string }>, + * mainMarkerColumn?: string, + * mainMarkerValue?: any, + * subMarkerValue?: any, + * deleteExistingBefore?: boolean, + * } + * }>, + * isUpdate?: boolean + * } + */ +export async function multiTableSave( + req: AuthenticatedRequest, + res: Response +): Promise { + const pool = require("../database/db").getPool(); + const client = await pool.connect(); + + try { + const { mainTable, mainData, subTables, isUpdate } = req.body; + const companyCode = req.user?.companyCode || "*"; + + logger.info("=== 다중 테이블 저장 시작 ===", { + mainTable, + mainDataKeys: Object.keys(mainData || {}), + subTablesCount: subTables?.length || 0, + isUpdate, + companyCode, + }); + + // 유효성 검사 + if (!mainTable?.tableName || !mainTable?.primaryKeyColumn) { + res.status(400).json({ + success: false, + message: "메인 테이블 설정이 올바르지 않습니다.", + }); + return; + } + + if (!mainData || Object.keys(mainData).length === 0) { + res.status(400).json({ + success: false, + message: "저장할 메인 데이터가 없습니다.", + }); + return; + } + + await client.query("BEGIN"); + + // 1. 메인 테이블 저장 + const mainTableName = mainTable.tableName; + const pkColumn = mainTable.primaryKeyColumn; + const pkValue = mainData[pkColumn]; + + // company_code 자동 추가 (최고 관리자가 아닌 경우) + if (companyCode !== "*" && !mainData.company_code) { + mainData.company_code = companyCode; + } + + let mainResult: any; + + if (isUpdate && pkValue) { + // UPDATE + const updateColumns = Object.keys(mainData) + .filter(col => col !== pkColumn) + .map((col, idx) => `"${col}" = $${idx + 1}`) + .join(", "); + const updateValues = Object.keys(mainData) + .filter(col => col !== pkColumn) + .map(col => mainData[col]); + + // updated_at 컬럼 존재 여부 확인 + const hasUpdatedAt = await client.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'updated_at' + `, [mainTableName]); + const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; + + const updateQuery = ` + UPDATE "${mainTableName}" + SET ${updateColumns}${updatedAtClause} + WHERE "${pkColumn}" = $${updateValues.length + 1} + ${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""} + RETURNING * + `; + + const updateParams = companyCode !== "*" + ? [...updateValues, pkValue, companyCode] + : [...updateValues, pkValue]; + + logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length }); + mainResult = await client.query(updateQuery, updateParams); + } else { + // INSERT + const columns = Object.keys(mainData).map(col => `"${col}"`).join(", "); + const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", "); + const values = Object.values(mainData); + + // updated_at 컬럼 존재 여부 확인 + const hasUpdatedAt = await client.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'updated_at' + `, [mainTableName]); + const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; + + const updateSetClause = Object.keys(mainData) + .filter(col => col !== pkColumn) + .map(col => `"${col}" = EXCLUDED."${col}"`) + .join(", "); + + const insertQuery = ` + INSERT INTO "${mainTableName}" (${columns}) + VALUES (${placeholders}) + ON CONFLICT ("${pkColumn}") DO UPDATE SET + ${updateSetClause}${updatedAtClause} + RETURNING * + `; + + logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length }); + mainResult = await client.query(insertQuery, values); + } + + if (mainResult.rowCount === 0) { + throw new Error("메인 테이블 저장 실패"); + } + + const savedMainData = mainResult.rows[0]; + const savedPkValue = savedMainData[pkColumn]; + logger.info("메인 테이블 저장 완료:", { pkColumn, savedPkValue }); + + // 2. 서브 테이블 저장 + const subTableResults: any[] = []; + + for (const subTableConfig of subTables || []) { + const { tableName, linkColumn, items, options } = subTableConfig; + + if (!tableName || !items || items.length === 0) { + logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`); + continue; + } + + logger.info(`서브 테이블 ${tableName} 저장 시작:`, { + itemsCount: items.length, + linkColumn, + options, + }); + + // 기존 데이터 삭제 옵션 + if (options?.deleteExistingBefore && linkColumn?.subColumn) { + const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn + ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` + : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; + + const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn + ? [savedPkValue, options.subMarkerValue ?? false] + : [savedPkValue]; + + logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams }); + await client.query(deleteQuery, deleteParams); + } + + // 메인 데이터도 서브 테이블에 저장 (옵션) + if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) { + const mainSubItem: Record = { + [linkColumn.subColumn]: savedPkValue, + }; + + // 메인 필드 매핑 적용 + for (const mapping of options.mainFieldMappings) { + if (mapping.formField && mapping.targetColumn) { + mainSubItem[mapping.targetColumn] = mainData[mapping.formField]; + } + } + + // 메인 마커 설정 + if (options.mainMarkerColumn) { + mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true; + } + + // company_code 추가 + if (companyCode !== "*") { + mainSubItem.company_code = companyCode; + } + + const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", "); + const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", "); + const mainSubValues = Object.values(mainSubItem); + + // UPSERT 쿼리 (PK가 있다면) + const mainSubInsertQuery = ` + INSERT INTO "${tableName}" (${mainSubColumns}) + VALUES (${mainSubPlaceholders}) + ON CONFLICT ("${linkColumn.subColumn}"${options.mainMarkerColumn ? `, "${options.mainMarkerColumn}"` : ""}) + DO UPDATE SET + ${Object.keys(mainSubItem) + .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn) + .map(col => `"${col}" = EXCLUDED."${col}"`) + .join(", ") || "updated_at = NOW()"} + RETURNING * + `; + + try { + logger.info(`서브 테이블 ${tableName} 메인 데이터 저장:`, { mainSubInsertQuery, mainSubValues }); + const mainSubResult = await client.query(mainSubInsertQuery, mainSubValues); + subTableResults.push({ tableName, type: "main", data: mainSubResult.rows[0] }); + } catch (err: any) { + // ON CONFLICT 실패 시 일반 INSERT 시도 + logger.warn(`서브 테이블 ${tableName} UPSERT 실패, 일반 INSERT 시도:`, err.message); + const simpleInsertQuery = ` + INSERT INTO "${tableName}" (${mainSubColumns}) + VALUES (${mainSubPlaceholders}) + RETURNING * + `; + const simpleResult = await client.query(simpleInsertQuery, mainSubValues); + subTableResults.push({ tableName, type: "main", data: simpleResult.rows[0] }); + } + } + + // 서브 아이템들 저장 + for (const item of items) { + // 연결 컬럼 값 설정 + if (linkColumn?.subColumn) { + item[linkColumn.subColumn] = savedPkValue; + } + + // company_code 추가 + if (companyCode !== "*" && !item.company_code) { + item.company_code = companyCode; + } + + const subColumns = Object.keys(item).map(col => `"${col}"`).join(", "); + const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", "); + const subValues = Object.values(item); + + const subInsertQuery = ` + INSERT INTO "${tableName}" (${subColumns}) + VALUES (${subPlaceholders}) + RETURNING * + `; + + logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length }); + const subResult = await client.query(subInsertQuery, subValues); + subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] }); + } + + logger.info(`서브 테이블 ${tableName} 저장 완료`); + } + + await client.query("COMMIT"); + + logger.info("=== 다중 테이블 저장 완료 ===", { + mainTable: mainTableName, + mainPk: savedPkValue, + subTableResultsCount: subTableResults.length, + }); + + res.json({ + success: true, + message: "다중 테이블 저장이 완료되었습니다.", + data: { + main: savedMainData, + subTables: subTableResults, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + + logger.error("다중 테이블 저장 실패:", { + message: error.message, + stack: error.stack, + }); + + res.status(500).json({ + success: false, + message: error.message || "다중 테이블 저장에 실패했습니다.", + error: error.message, + }); + } finally { + client.release(); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 5ea98489..d0716d59 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -24,6 +24,7 @@ import { getLogData, toggleLogTable, getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 + multiTableSave, // 🆕 범용 다중 테이블 저장 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -198,4 +199,17 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable); */ router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu); +// ======================================== +// 범용 다중 테이블 저장 API +// ======================================== + +/** + * 다중 테이블 저장 (메인 + 서브 테이블) + * POST /api/table-management/multi-table-save + * + * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. + * 사원+부서, 주문+주문상세 등 1:N 관계 데이터 저장에 사용됩니다. + */ +router.post("/multi-table-save", multiTableSave); + export default router; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 8609623b..816483fc 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -448,6 +448,9 @@ export const DynamicComponentRenderer: React.FC = isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false, // 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable) groupedData: props.groupedData, + // 🆕 UniversalFormModal용 initialData 전달 + // originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨) + initialData: originalData || formData, }; // 렌더러가 클래스인지 함수인지 확인 diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index c875316a..37e16c6d 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -729,7 +729,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC -
- 컬럼 {index + 1} - -
+
+
+ 컬럼 {index + 1} + +
{/* 테이블 선택 */}
@@ -1156,7 +1156,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC컬럼 updateDisplayColumn("right", index, "label", e.target.value)} - placeholder="라벨명 (미입력 시 컬럼명 사용)" - className="h-8 text-xs" - /> -
+ updateDisplayColumn("right", index, "label", e.target.value)} + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="h-8 text-xs" + /> +
{/* 표시 위치 */} -
+
- -
+
+ ); })} {(config.rightPanel?.displayColumns || []).length === 0 && ( @@ -1273,14 +1273,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC t.table_name === selectedTableName)?.table_comment || selectedTableName; return ( -
+
- -
+ +
); })} {(config.rightPanel?.displayColumns || []).length === 0 && ( diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 3938645d..4eab9f72 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -115,10 +115,37 @@ export function UniversalFormModalComponent({ itemId: string; }>({ open: false, sectionId: "", itemId: "" }); - // 초기화 + // 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시) + const capturedInitialData = useRef | undefined>(undefined); + const hasInitialized = useRef(false); + + // 초기화 - 최초 마운트 시에만 실행 useEffect(() => { + // 이미 초기화되었으면 스킵 + if (hasInitialized.current) { + console.log("[UniversalFormModal] 이미 초기화됨, 스킵"); + return; + } + + // 최초 initialData 캡처 (이후 변경되어도 이 값 사용) + if (initialData && Object.keys(initialData).length > 0) { + capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사 + console.log("[UniversalFormModal] initialData 캡처:", capturedInitialData.current); + } + + hasInitialized.current = true; initializeForm(); - }, [config, initialData]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행 + + // config 변경 시에만 재초기화 (initialData 변경은 무시) + useEffect(() => { + if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵 + + console.log("[UniversalFormModal] config 변경 감지, 재초기화"); + initializeForm(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); // 필드 레벨 linkedFieldGroup 데이터 로드 useEffect(() => { @@ -149,6 +176,10 @@ export function UniversalFormModalComponent({ // 폼 초기화 const initializeForm = useCallback(async () => { + // 캡처된 initialData 사용 (props로 전달된 initialData가 아닌) + const effectiveInitialData = capturedInitialData.current || initialData; + console.log("[UniversalFormModal] 폼 초기화 시작, effectiveInitialData:", effectiveInitialData); + const newFormData: FormDataState = {}; const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {}; const newCollapsed = new Set(); @@ -174,11 +205,15 @@ export function UniversalFormModalComponent({ // 기본값 설정 let value = field.defaultValue ?? ""; - // 부모에서 전달받은 값 적용 - if (field.receiveFromParent && initialData) { + // 부모에서 전달받은 값 적용 (receiveFromParent 또는 effectiveInitialData에 해당 값이 있으면) + if (effectiveInitialData) { const parentField = field.parentFieldName || field.columnName; - if (initialData[parentField] !== undefined) { - value = initialData[parentField]; + if (effectiveInitialData[parentField] !== undefined) { + // receiveFromParent가 true이거나, effectiveInitialData에 값이 있으면 적용 + if (field.receiveFromParent || value === "" || value === undefined) { + value = effectiveInitialData[parentField]; + console.log(`[UniversalFormModal] 필드 ${field.columnName}: initialData에서 값 적용 = ${value}`); + } } } @@ -190,11 +225,12 @@ export function UniversalFormModalComponent({ setFormData(newFormData); setRepeatSections(newRepeatSections); setCollapsedSections(newCollapsed); - setOriginalData(initialData || {}); + setOriginalData(effectiveInitialData || {}); // 채번규칙 자동 생성 await generateNumberingValues(newFormData); - }, [config, initialData]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용) // 반복 섹션 아이템 생성 const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => { @@ -344,15 +380,30 @@ export function UniversalFormModalComponent({ if (optionConfig.type === "static") { options = optionConfig.staticOptions || []; } else if (optionConfig.type === "table" && optionConfig.tableName) { - const response = await apiClient.get(`/table-management/tables/${optionConfig.tableName}/data`, { - params: { limit: 1000 }, + // POST 방식으로 테이블 데이터 조회 (autoFilter 포함) + const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, { + page: 1, + size: 1000, + autoFilter: { enabled: true, filterColumn: "company_code" }, }); - if (response.data?.success && response.data?.data) { - options = response.data.data.map((row: any) => ({ - value: String(row[optionConfig.valueColumn || "id"]), - label: String(row[optionConfig.labelColumn || "name"]), - })); + + // 응답 데이터 파싱 + let dataArray: any[] = []; + if (response.data?.success) { + const responseData = response.data?.data; + if (responseData?.data && Array.isArray(responseData.data)) { + dataArray = responseData.data; + } else if (Array.isArray(responseData)) { + dataArray = responseData; + } else if (responseData?.rows && Array.isArray(responseData.rows)) { + dataArray = responseData.rows; + } } + + options = dataArray.map((row: any) => ({ + value: String(row[optionConfig.valueColumn || "id"]), + label: String(row[optionConfig.labelColumn || "name"]), + })); } else if (optionConfig.type === "code" && optionConfig.codeCategory) { const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`); if (response.data?.success && response.data?.data) { @@ -444,7 +495,7 @@ export function UniversalFormModalComponent({ return { valid: missingFields.length === 0, missingFields }; }, [config.sections, formData]); - // 단일 행 저장 + // 단일 행 저장 const saveSingleRow = useCallback(async () => { const dataToSave = { ...formData }; @@ -532,9 +583,9 @@ export function UniversalFormModalComponent({ // 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등) const mainSectionData: any = {}; mainSectionFields.forEach((fieldName) => { - if (formData[fieldName] !== undefined) { - mainSectionData[fieldName] = formData[fieldName]; - } + if (formData[fieldName] !== undefined) { + mainSectionData[fieldName] = formData[fieldName]; + } }); console.log("[UniversalFormModal] 공통 데이터:", commonData); @@ -612,84 +663,113 @@ export function UniversalFormModalComponent({ console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`); }, [config.sections, config.saveConfig, formData, repeatSections]); - // 커스텀 API 저장 (사원+부서 통합 저장 등) + // 다중 테이블 저장 (범용) + const saveWithMultiTable = useCallback(async () => { + const { customApiSave } = config.saveConfig; + if (!customApiSave?.multiTable) return; + + const { multiTable } = customApiSave; + console.log("[UniversalFormModal] 다중 테이블 저장 시작:", multiTable); + console.log("[UniversalFormModal] 현재 formData:", formData); + console.log("[UniversalFormModal] 현재 repeatSections:", repeatSections); + + // 1. 메인 테이블 데이터 구성 + const mainData: Record = {}; + config.sections.forEach((section) => { + if (section.repeatable) return; // 반복 섹션은 제외 + section.fields.forEach((field) => { + const value = formData[field.columnName]; + if (value !== undefined && value !== null && value !== "") { + mainData[field.columnName] = value; + } + }); + }); + + // 2. 서브 테이블 데이터 구성 + const subTablesData: Array<{ + tableName: string; + linkColumn: { mainField: string; subColumn: string }; + items: Record[]; + options?: { + saveMainAsFirst?: boolean; + mainFieldMappings?: Array<{ formField: string; targetColumn: string }>; + mainMarkerColumn?: string; + mainMarkerValue?: any; + subMarkerValue?: any; + deleteExistingBefore?: boolean; + }; + }> = []; + + for (const subTableConfig of multiTable.subTables || []) { + if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) { + continue; + } + + const subItems: Record[] = []; + const repeatData = repeatSections[subTableConfig.repeatSectionId] || []; + + // 반복 섹션 데이터를 필드 매핑에 따라 변환 + for (const item of repeatData) { + const mappedItem: Record = {}; + + // 연결 컬럼 값 설정 + if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) { + mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField]; + } + + // 필드 매핑에 따라 데이터 변환 + for (const mapping of subTableConfig.fieldMappings || []) { + if (mapping.formField && mapping.targetColumn) { + mappedItem[mapping.targetColumn] = item[mapping.formField]; + } + } + + // 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값) + if (subTableConfig.options?.mainMarkerColumn) { + mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false; + } + + if (Object.keys(mappedItem).length > 0) { + subItems.push(mappedItem); + } + } + + subTablesData.push({ + tableName: subTableConfig.tableName, + linkColumn: subTableConfig.linkColumn, + items: subItems, + options: subTableConfig.options, + }); + } + + // 3. 범용 다중 테이블 저장 API 호출 + console.log("[UniversalFormModal] 다중 테이블 저장 데이터:", { + mainTable: multiTable.mainTable, + mainData, + subTablesData, + }); + + const response = await apiClient.post("/table-management/multi-table-save", { + mainTable: multiTable.mainTable, + mainData, + subTables: subTablesData, + isUpdate: !!initialData?.[multiTable.mainTable.primaryKeyColumn], + }); + + if (!response.data?.success) { + throw new Error(response.data?.message || "다중 테이블 저장 실패"); + } + + console.log("[UniversalFormModal] 다중 테이블 저장 완료:", response.data); + }, [config.sections, config.saveConfig, formData, repeatSections, initialData]); + + // 커스텀 API 저장 const saveWithCustomApi = useCallback(async () => { const { customApiSave } = config.saveConfig; if (!customApiSave) return; console.log("[UniversalFormModal] 커스텀 API 저장 시작:", customApiSave.apiType); - const saveUserWithDeptApi = async () => { - const { mainDeptFields, subDeptSectionId, subDeptFields } = customApiSave; - - // 1. userInfo 데이터 구성 - const userInfo: Record = {}; - - // 모든 필드에서 user_info에 해당하는 데이터 추출 - config.sections.forEach((section) => { - if (section.repeatable) return; // 반복 섹션은 제외 - - section.fields.forEach((field) => { - const value = formData[field.columnName]; - if (value !== undefined && value !== null && value !== "") { - userInfo[field.columnName] = value; - } - }); - }); - - // 2. mainDept 데이터 구성 - let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined; - - if (mainDeptFields) { - const deptCode = formData[mainDeptFields.deptCodeField || "dept_code"]; - if (deptCode) { - mainDept = { - dept_code: deptCode, - dept_name: formData[mainDeptFields.deptNameField || "dept_name"], - position_name: formData[mainDeptFields.positionNameField || "position_name"], - }; - } - } - - // 3. subDepts 데이터 구성 (반복 섹션에서) - const subDepts: Array<{ dept_code: string; dept_name?: string; position_name?: string }> = []; - - if (subDeptSectionId && repeatSections[subDeptSectionId]) { - const subDeptItems = repeatSections[subDeptSectionId]; - const deptCodeField = subDeptFields?.deptCodeField || "dept_code"; - const deptNameField = subDeptFields?.deptNameField || "dept_name"; - const positionNameField = subDeptFields?.positionNameField || "position_name"; - - subDeptItems.forEach((item) => { - const deptCode = item[deptCodeField]; - if (deptCode) { - subDepts.push({ - dept_code: deptCode, - dept_name: item[deptNameField], - position_name: item[positionNameField], - }); - } - }); - } - - // 4. API 호출 - console.log("[UniversalFormModal] 사원+부서 저장 데이터:", { userInfo, mainDept, subDepts }); - - const { saveUserWithDept } = await import("@/lib/api/user"); - const response = await saveUserWithDept({ - userInfo: userInfo as any, - mainDept, - subDepts, - isUpdate: !!initialData?.user_id, // 초기 데이터가 있으면 수정 모드 - }); - - if (!response.success) { - throw new Error(response.message || "사원 저장 실패"); - } - - console.log("[UniversalFormModal] 사원+부서 저장 완료:", response.data); - }; - const saveWithGenericCustomApi = async () => { if (!customApiSave.customEndpoint) { throw new Error("커스텀 API 엔드포인트가 설정되지 않았습니다."); @@ -720,8 +800,8 @@ export function UniversalFormModalComponent({ }; switch (customApiSave.apiType) { - case "user-with-dept": - await saveUserWithDeptApi(); + case "multi-table": + await saveWithMultiTable(); break; case "custom": await saveWithGenericCustomApi(); @@ -729,10 +809,16 @@ export function UniversalFormModalComponent({ default: throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`); } - }, [config.sections, config.saveConfig, formData, repeatSections, initialData]); + }, [config.saveConfig, formData, repeatSections, saveWithMultiTable]); // 저장 처리 const handleSave = useCallback(async () => { + console.log("[UniversalFormModal] 저장 시작, saveConfig:", { + tableName: config.saveConfig.tableName, + customApiSave: config.saveConfig.customApiSave, + multiRowSave: config.saveConfig.multiRowSave, + }); + // 커스텀 API 저장 모드가 아닌 경우에만 테이블명 체크 if (!config.saveConfig.customApiSave?.enabled && !config.saveConfig.tableName) { toast.error("저장할 테이블이 설정되지 않았습니다."); diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 8552cd6f..eb52a10f 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -108,6 +108,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.sections]); + // 다중 테이블 저장 설정의 메인/서브 테이블 컬럼 로드 + useEffect(() => { + const customApiSave = config.saveConfig.customApiSave; + if (customApiSave?.enabled && customApiSave?.multiTable) { + // 메인 테이블 컬럼 로드 + const mainTableName = customApiSave.multiTable.mainTable?.tableName; + if (mainTableName && !tableColumns[mainTableName]) { + loadTableColumns(mainTableName); + } + // 서브 테이블들 컬럼 로드 + customApiSave.multiTable.subTables?.forEach((subTable) => { + if (subTable.tableName && !tableColumns[subTable.tableName]) { + loadTableColumns(subTable.tableName); + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.saveConfig.customApiSave]); + const loadTables = async () => { try { const response = await apiClient.get("/table-management/tables"); @@ -425,58 +444,58 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor ) : ( <> - - - - - - - - - 테이블을 찾을 수 없습니다 - - {tables.map((t) => ( - { - updateSaveConfig({ tableName: t.name }); - setTableSelectOpen(false); - }} - className="text-xs" - > - - {t.name} - {t.label !== t.name && ( - ({t.label}) - )} - - ))} - - - - - - {config.saveConfig.tableName && ( -

- 컬럼 {currentColumns.length}개 로드됨 -

+ + + + + + + + + 테이블을 찾을 수 없습니다 + + {tables.map((t) => ( + { + updateSaveConfig({ tableName: t.name }); + setTableSelectOpen(false); + }} + className="text-xs" + > + + {t.name} + {t.label !== t.name && ( + ({t.label}) + )} + + ))} + + + + + + {config.saveConfig.tableName && ( +

+ 컬럼 {currentColumns.length}개 로드됨 +

)} )} @@ -592,29 +611,41 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor )} - {/* 커스텀 API 저장 설정 */} -
-
- 전용 API 저장 + {/* 다중 테이블 저장 설정 (범용) */} +
+
+ 다중 테이블 저장 updateSaveConfig({ - customApiSave: { ...config.saveConfig.customApiSave, enabled: checked, apiType: "user-with-dept" }, + customApiSave: { + ...config.saveConfig.customApiSave, + enabled: checked, + apiType: "multi-table", + multiTable: checked ? { + enabled: true, + mainTable: { tableName: config.saveConfig.tableName || "", primaryKeyColumn: "" }, + subTables: [], + } : undefined, + }, }) } />
- 테이블 직접 저장 대신 전용 백엔드 API를 사용합니다. 복잡한 비즈니스 로직(다중 테이블, 트랜잭션)에 적합합니다. + + 메인 테이블 + 서브 테이블(반복 섹션)에 트랜잭션으로 저장합니다. +
예: 사원+부서, 주문+주문상세, 프로젝트+멤버 등 +
{config.saveConfig.customApiSave?.enabled && ( -
+
{/* API 타입 선택 */}
- +
- {/* 사원+부서 통합 저장 설정 */} - {config.saveConfig.customApiSave?.apiType === "user-with-dept" && ( -
-

- user_info와 user_dept 테이블에 트랜잭션으로 저장합니다. - 메인 부서 변경 시 기존 메인은 겸직으로 자동 전환됩니다. -

- - {/* 메인 부서 필드 매핑 */} -
- -
-
- 부서코드: - -
-
- 부서명: - -
-
- 직급: - -
+ {/* 다중 테이블 저장 설정 */} + {config.saveConfig.customApiSave?.apiType === "multi-table" && ( +
+ {/* 메인 테이블 설정 */} +
+ + 비반복 섹션의 데이터가 저장될 메인 테이블입니다. + +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {tables.map((table) => ( + { + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: { + ...config.saveConfig.customApiSave?.multiTable?.mainTable, + tableName: table.name, + }, + subTables: config.saveConfig.customApiSave?.multiTable?.subTables || [], + }, + }, + }); + // 테이블 컬럼 로드 + if (!tableColumns[table.name]) { + loadTableColumns(table.name); + } + }} + className="text-[10px]" + > + +
+ {table.label || table.name} + {table.label && {table.name}} +
+
+ ))} +
+
+
+
+
-
- - {/* 겸직 부서 반복 섹션 */} -
- - + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: { + ...config.saveConfig.customApiSave?.multiTable?.mainTable, + tableName: config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || "", + primaryKeyColumn: value === "_none_" ? "" : value, + }, + subTables: config.saveConfig.customApiSave?.multiTable?.subTables || [], + }, + }, + }) + } + > + + + + + 선택 안함 + {(tableColumns[config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""] || []).map((col) => ( + + {col.label || col.name} ))} - - + + + 서브 테이블과 연결할 때 사용할 PK 컬럼 +
- {/* 겸직 부서 필드 매핑 */} - {config.saveConfig.customApiSave?.subDeptSectionId && ( -
- -
-
- 부서코드: - { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { ...newSubTables[subIndex], repeatSectionId: value === "_none_" ? "" : value }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, + }, + }); + }} + > + + + 선택 안함 {config.sections - .find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId) - ?.fields.map((field) => ( - - {field.label} + .filter((s) => s.repeatable) + .map((section) => ( + + 반복 섹션: {section.title} ))}
-
- 부서명: - { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { + ...newSubTables[subIndex], + linkColumn: { ...newSubTables[subIndex].linkColumn, mainField: value === "_none_" ? "" : value }, + }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, }, - }, - }) - } - > - - - - - {config.sections - .find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId) - ?.fields.map((field) => ( - - {field.label} + }); + }} + > + + + + + 선택 + {/* 메인 테이블의 컬럼 목록에서 선택 */} + {(tableColumns[config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""] || []).map((col) => ( + + {col.label || col.name} ))} - - + + +
+ {/* 서브 테이블 컬럼 선택 (FK 컬럼) */} + +
+ 메인 테이블과 서브 테이블을 연결할 컬럼
-
- 직급: - { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + const newMappings = [...(newSubTables[subIndex].fieldMappings || [])]; + newMappings[mapIndex] = { ...newMappings[mapIndex], formField: value === "_none_" ? "" : value }; + newSubTables[subIndex] = { ...newSubTables[subIndex], fieldMappings: newMappings }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, + }, + }); + }} + > + + + + + 선택 + {sectionFields + .filter((f) => f.columnName && f.columnName.trim() !== "") + .map((field) => ( + + {field.label} + + ))} + + +
+ +
+ ); + })} +
+ )} + + {/* 추가 옵션 */} +
+ +
+ { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { + ...newSubTables[subIndex], + options: { ...newSubTables[subIndex].options, saveMainAsFirst: !!checked }, + }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, }, - }, - }) - } - > - - - - - {config.sections - .find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId) - ?.fields.map((field) => ( - - {field.label} - - ))} - - + }); + }} + className="shrink-0" + /> + +
+ + {subTable.options?.saveMainAsFirst && ( +
+ + + 메인/서브 구분용 컬럼 (예: is_primary) +
+ )} + +
+ { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { + ...newSubTables[subIndex], + options: { ...newSubTables[subIndex].options, deleteExistingBefore: !!checked }, + }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, + }, + }); + }} + className="shrink-0" + /> + +
-
- )} + ))} + + {(config.saveConfig.customApiSave?.multiTable?.subTables || []).length === 0 && ( +

+ 서브 테이블을 추가하세요 +

+ )} +
)} {/* 커스텀 API 설정 */} {config.saveConfig.customApiSave?.apiType === "custom" && (
-
+
- - updateSaveConfig({ + onChange={(e) => + updateSaveConfig({ customApiSave: { ...config.saveConfig.customApiSave, customEndpoint: e.target.value }, - }) - } + }) + } placeholder="/api/custom/endpoint" - className="h-6 text-[10px] mt-1" - /> -
-
+ className="h-6 text-[10px] mt-1" + /> +
+
-
+
)}
@@ -1571,9 +1983,9 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {/* 다중 컬럼 저장 (select 타입만) */} {selectedField.fieldType === "select" && ( -
-
- 다중 컬럼 저장 +
+
+ 다중 컬럼 저장 @@ -1592,10 +2004,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {selectedField.linkedFieldGroup?.enabled && ( -
+
{/* 소스 테이블 */} -
- +
+ { + const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) => + i === mappingIndex ? { ...m, sourceColumn: value } : m + ); + updateField(selectedSection.id, selectedField.id, { + linkedFieldGroup: { + ...selectedField.linkedFieldGroup, + mappings: updatedMappings, + }, + }); + }} + > + + + + + {(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => ( + + {col.label || col.name} + + ))} + + +
+
+ {/* 저장할 테이블 선택 */} +
+ + +
+ {/* 저장할 컬럼 선택 */} +
+ + +
-
- - -
-
- - -
-
- ))} + ); + })} {(selectedField.linkedFieldGroup?.mappings || []).length === 0 && (

diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 04f7df0e..75dcf4fd 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -108,6 +108,7 @@ export interface FormFieldConfig { // 연동 필드 매핑 설정 export interface LinkedFieldMapping { sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code") + targetTable?: string; // 저장할 테이블 (선택, 없으면 자동 결정) targetColumn: string; // 저장할 컬럼 (예: "position_code") } @@ -194,42 +195,92 @@ export interface SaveConfig { }; } +/** + * 서브 테이블 필드 매핑 + * 폼 필드(columnName)를 서브 테이블의 컬럼에 매핑합니다. + */ +export interface SubTableFieldMapping { + formField: string; // 폼 필드의 columnName + targetColumn: string; // 서브 테이블의 컬럼명 +} + +/** + * 서브 테이블 저장 설정 + * 반복 섹션의 데이터를 별도 테이블에 저장하는 설정입니다. + */ +export interface SubTableSaveConfig { + enabled: boolean; + tableName: string; // 서브 테이블명 (예: user_dept, order_items) + repeatSectionId: string; // 연결할 반복 섹션 ID + + // 연결 설정 (메인 테이블과 서브 테이블 연결) + linkColumn: { + mainField: string; // 메인 테이블의 연결 필드 (예: user_id) + subColumn: string; // 서브 테이블의 연결 컬럼 (예: user_id) + }; + + // 필드 매핑 (반복 섹션 필드 → 서브 테이블 컬럼) + fieldMappings: SubTableFieldMapping[]; + + // 추가 옵션 + options?: { + // 메인 데이터도 서브 테이블에 저장 (1:N에서 메인도 저장할 때) + saveMainAsFirst?: boolean; + mainFieldMappings?: SubTableFieldMapping[]; // 메인 데이터용 필드 매핑 + mainMarkerColumn?: string; // 메인 여부 표시 컬럼 (예: is_primary) + mainMarkerValue?: any; // 메인일 때 값 (예: true) + subMarkerValue?: any; // 서브일 때 값 (예: false) + + // 저장 전 기존 데이터 삭제 + deleteExistingBefore?: boolean; + deleteOnlySubItems?: boolean; // 메인 항목은 유지하고 서브만 삭제 + }; +} + +/** + * 다중 테이블 저장 설정 (범용) + * + * 메인 테이블 + 서브 테이블(들)에 트랜잭션으로 저장합니다. + * + * ## 사용 예시 + * + * ### 사원 + 부서 (user_info + user_dept) + * - 메인 테이블: user_info (사원 정보) + * - 서브 테이블: user_dept (부서 관계, 메인 부서 + 겸직 부서) + * + * ### 주문 + 주문상세 (orders + order_items) + * - 메인 테이블: orders (주문 정보) + * - 서브 테이블: order_items (주문 상품 목록) + */ +export interface MultiTableSaveConfig { + enabled: boolean; + + // 메인 테이블 설정 + mainTable: { + tableName: string; // 메인 테이블명 + primaryKeyColumn: string; // PK 컬럼명 + }; + + // 서브 테이블 설정 (여러 개 가능) + subTables: SubTableSaveConfig[]; +} + /** * 커스텀 API 저장 설정 * * 테이블 직접 저장 대신 전용 백엔드 API를 호출합니다. * 복잡한 비즈니스 로직(다중 테이블 저장, 트랜잭션 등)에 사용합니다. - * - * ## 지원하는 API 타입 - * - `user-with-dept`: 사원 + 부서 통합 저장 (/api/admin/users/with-dept) - * - * ## 데이터 매핑 설정 - * - `userInfoFields`: user_info 테이블에 저장할 필드 매핑 - * - `mainDeptFields`: 메인 부서 정보 필드 매핑 - * - `subDeptSectionId`: 겸직 부서 반복 섹션 ID */ export interface CustomApiSaveConfig { enabled: boolean; - apiType: "user-with-dept" | "custom"; // 확장 가능한 API 타입 + apiType: "multi-table" | "custom"; // API 타입 - // user-with-dept 전용 설정 - userInfoFields?: string[]; // user_info에 저장할 필드 목록 (columnName) - mainDeptFields?: { - deptCodeField?: string; // 메인 부서코드 필드명 - deptNameField?: string; // 메인 부서명 필드명 - positionNameField?: string; // 메인 직급 필드명 - }; - subDeptSectionId?: string; // 겸직 부서 반복 섹션 ID - subDeptFields?: { - deptCodeField?: string; // 겸직 부서코드 필드명 - deptNameField?: string; // 겸직 부서명 필드명 - positionNameField?: string; // 겸직 직급 필드명 - }; + // 다중 테이블 저장 설정 (범용) + multiTable?: MultiTableSaveConfig; // 커스텀 API 전용 설정 customEndpoint?: string; // 커스텀 API 엔드포인트 customMethod?: "POST" | "PUT"; // HTTP 메서드 - customDataTransform?: string; // 데이터 변환 함수명 (추후 확장) } // 모달 설정