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

@@ -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("저장할 테이블이 설정되지 않았습니다.");