feat(universal-form-modal): 범용 다중 테이블 저장 기능 추가

This commit is contained in:
SeongHyun Kim 2025-12-08 17:54:11 +09:00
parent 3dc67dd60a
commit a278ceca3f
7 changed files with 1482 additions and 547 deletions

View File

@ -1811,3 +1811,299 @@ export async function getCategoryColumnsByMenu(
});
}
}
/**
* API
*
* () .
*
* :
* {
* mainTable: { tableName: string, primaryKeyColumn: string },
* mainData: Record<string, any>,
* subTables: Array<{
* tableName: string,
* linkColumn: { mainField: string, subColumn: string },
* items: Record<string, any>[],
* 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<void> {
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<string, any> = {
[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();
}
}

View File

@ -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;

View File

@ -448,6 +448,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false,
// 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable)
groupedData: props.groupedData,
// 🆕 UniversalFormModal용 initialData 전달
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
initialData: originalData || formData,
};
// 렌더러가 클래스인지 함수인지 확인

View File

@ -729,7 +729,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
const currentColumns = side === "left"
? config.leftPanel?.displayColumns || []
: config.rightPanel?.displayColumns || [];
// 기본 테이블 설정 (메인 테이블)
const defaultTable = side === "left"
? config.leftPanel?.tableName
@ -1109,18 +1109,18 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
};
return (
<div key={index} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => removeDisplayColumn("right", index)}
>
<X className="h-3 w-3" />
</Button>
</div>
<div key={index} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => removeDisplayColumn("right", index)}
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 테이블 선택 */}
<div>
@ -1156,7 +1156,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={col.name || ""}
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
@ -1189,33 +1189,33 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
</div>
{/* 표시 라벨 */}
<div>
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
value={col.label || ""}
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
placeholder="라벨명 (미입력 시 컬럼명 사용)"
className="h-8 text-xs"
/>
</div>
<Input
value={col.label || ""}
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
placeholder="라벨명 (미입력 시 컬럼명 사용)"
className="h-8 text-xs"
/>
</div>
{/* 표시 위치 */}
<div>
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={col.displayRow || "info"}
onValueChange={(value) => updateDisplayColumn("right", index, "displayRow", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name"> (Name Row)</SelectItem>
<SelectItem value="info"> (Info Row)</SelectItem>
</SelectContent>
</Select>
</div>
<Select
value={col.displayRow || "info"}
onValueChange={(value) => updateDisplayColumn("right", index, "displayRow", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name"> (Name Row)</SelectItem>
<SelectItem value="info"> (Info Row)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
})}
{(config.rightPanel?.displayColumns || []).length === 0 && (
@ -1273,14 +1273,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
const selectedTableLabel = tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName;
return (
<div key={index} className="flex items-center gap-2">
<div key={index} className="flex items-center gap-2">
<Select
value={searchCol.columnName || ""}
onValueChange={(value) => {
const current = [...(config.rightPanel?.searchColumns || [])];
current[index] = { ...current[index], columnName: value };
updateConfig("rightPanel.searchColumns", current);
}}
onValueChange={(value) => {
const current = [...(config.rightPanel?.searchColumns || [])];
current[index] = { ...current[index], columnName: value };
updateConfig("rightPanel.searchColumns", current);
}}
>
<SelectTrigger className="h-9 text-xs flex-1">
<SelectValue placeholder="컬럼 선택">
@ -1322,21 +1322,21 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
)}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 shrink-0 p-0"
onClick={() => {
const current = config.rightPanel?.searchColumns || [];
updateConfig(
"rightPanel.searchColumns",
current.filter((_, i) => i !== index)
);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 shrink-0 p-0"
onClick={() => {
const current = config.rightPanel?.searchColumns || [];
updateConfig(
"rightPanel.searchColumns",
current.filter((_, i) => i !== index)
);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
{(config.rightPanel?.displayColumns || []).length === 0 && (

View File

@ -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<Record<string, any> | 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<string>();
@ -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<string, any> = {};
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<string, any>[];
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<string, any>[] = [];
const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
// 반복 섹션 데이터를 필드 매핑에 따라 변환
for (const item of repeatData) {
const mappedItem: Record<string, any> = {};
// 연결 컬럼 값 설정
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<string, any> = {};
// 모든 필드에서 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("저장할 테이블이 설정되지 않았습니다.");

View File

@ -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; // 데이터 변환 함수명 (추후 확장)
}
// 모달 설정