Compare commits

...

5 Commits

Author SHA1 Message Date
SeongHyun Kim 7ac6bbc2c6 Merge remote-tracking branch 'origin/main' into ksh 2025-12-09 09:24:33 +09:00
SeongHyun Kim fa59235cd2 fix(split-panel-layout2): 좌측 패널 항목 선택 상태 비교 로직 개선
- idColumn 자동 감지 로직 추가 (id > dept_code > code 순 폴백)
- isSelected 비교 시 객체 동일성 및 undefined 체크 추가
- hierarchyConfig.idColumn 미설정 시에도 정상 동작
2025-12-09 09:22:10 +09:00
SeongHyun Kim d908de7f66 fix(numbering-rule): 채번규칙 저장 시 allocateNumberingCode로 실제 순번 할당
- generateNumberingCode를 allocateNumberingCode로 변경 (순번 실제 증가)

- saveSingleRow/saveMultipleRows/saveWithMultiTable 모두 적용

- NumberingRuleCard: 파트 타입 변경 시 defaultAutoConfig 적용

- NumberingRuleDesigner: 저장 시 partsWithDefaults로 기본값 병합

- sequenceLength/numberLength 기본값 4에서 3으로 변경

- 불필요한 console.log 제거
2025-12-08 19:10:07 +09:00
SeongHyun Kim b15b6e21ea fix(UniversalFormModal): 반복 섹션 linkedFieldGroup 매핑 및 서브 테이블 저장 로직 개선
- renderFieldWithColumns()에 repeatContext 파라미터 추가

- linkedFieldGroup 선택 시 repeatContext 유무에 따라 formData/repeatSections 분기 저장

- multiTableSave: UPSERT 대신 SELECT-UPDATE/INSERT 명시적 분기로 변경

- ON CONFLICT 조건 불일치 에러 방지

- 서브 테이블 저장 상세 로그 추가
2025-12-08 18:23:28 +09:00
SeongHyun Kim a278ceca3f feat(universal-form-modal): 범용 다중 테이블 저장 기능 추가 2025-12-08 17:54:11 +09:00
11 changed files with 1658 additions and 594 deletions

View File

@ -1811,3 +1811,333 @@ 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;
}
// 먼저 기존 데이터 존재 여부 확인 (user_id + is_primary 조합)
const checkQuery = `
SELECT * FROM "${tableName}"
WHERE "${linkColumn.subColumn}" = $1
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $2` : ""}
${companyCode !== "*" ? `AND company_code = $${options.mainMarkerColumn ? 3 : 2}` : ""}
LIMIT 1
`;
const checkParams: any[] = [savedPkValue];
if (options.mainMarkerColumn) {
checkParams.push(options.mainMarkerValue ?? true);
}
if (companyCode !== "*") {
checkParams.push(companyCode);
}
const existingResult = await client.query(checkQuery, checkParams);
if (existingResult.rows.length > 0) {
// UPDATE
const updateColumns = Object.keys(mainSubItem)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainSubItem)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map(col => mainSubItem[col]);
if (updateColumns) {
const updateQuery = `
UPDATE "${tableName}"
SET ${updateColumns}
WHERE "${linkColumn.subColumn}" = $${updateValues.length + 1}
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $${updateValues.length + 2}` : ""}
${companyCode !== "*" ? `AND company_code = $${updateValues.length + (options.mainMarkerColumn ? 3 : 2)}` : ""}
RETURNING *
`;
const updateParams = [...updateValues, savedPkValue];
if (options.mainMarkerColumn) {
updateParams.push(options.mainMarkerValue ?? true);
}
if (companyCode !== "*") {
updateParams.push(companyCode);
}
const updateResult = await client.query(updateQuery, updateParams);
subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] });
} else {
subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] });
}
} else {
// INSERT
const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", ");
const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", ");
const mainSubValues = Object.values(mainSubItem);
const insertQuery = `
INSERT INTO "${tableName}" (${mainSubColumns})
VALUES (${mainSubPlaceholders})
RETURNING *
`;
const insertResult = await client.query(insertQuery, mainSubValues);
subTableResults.push({ tableName, type: "main", data: insertResult.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

@ -897,13 +897,13 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
// 순번 (현재 순번으로 미리보기, 증가 안 함)
const length = autoConfig.sequenceLength || 4;
const length = autoConfig.sequenceLength || 3;
return String(rule.currentSequence || 1).padStart(length, "0");
}
case "number": {
// 숫자 (고정 자릿수)
const length = autoConfig.numberLength || 4;
const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0");
}
@ -957,13 +957,13 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
// 순번 (자동 증가 숫자)
const length = autoConfig.sequenceLength || 4;
const length = autoConfig.sequenceLength || 3;
return String(rule.currentSequence || 1).padStart(length, "0");
}
case "number": {
// 숫자 (고정 자릿수)
const length = autoConfig.numberLength || 4;
const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0");
}

View File

@ -48,7 +48,20 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={part.partType}
onValueChange={(value) => onUpdate({ partType: value as CodePartType })}
onValueChange={(value) => {
const newPartType = value as CodePartType;
// 타입 변경 시 해당 타입의 기본 autoConfig 설정
const defaultAutoConfig: Record<string, any> = {
sequence: { sequenceLength: 3, startFrom: 1 },
number: { numberLength: 4, numberValue: 1 },
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "CODE" },
};
onUpdate({
partType: newPartType,
autoConfig: defaultAutoConfig[newPartType] || {}
});
}}
disabled={isPreview}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">

View File

@ -196,10 +196,31 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
try {
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
// 파트별 기본 autoConfig 정의
const defaultAutoConfigs: Record<string, any> = {
sequence: { sequenceLength: 3, startFrom: 1 },
number: { numberLength: 4, numberValue: 1 },
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "" },
};
// 저장 전에 각 파트의 autoConfig에 기본값 채우기
const partsWithDefaults = currentRule.parts.map((part) => {
if (part.generationMethod === "auto") {
const defaults = defaultAutoConfigs[part.partType] || {};
return {
...part,
autoConfig: { ...defaults, ...part.autoConfig },
};
}
return part;
});
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
// 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링)
const ruleToSave = {
...currentRule,
parts: partsWithDefaults,
scopeType: "menu" as const, // 메뉴 기반 채번규칙
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)

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

@ -812,11 +812,22 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 좌측 패널 항목 렌더링
const renderLeftItem = (item: any, level: number = 0, index: number = 0) => {
const idColumn = config.leftPanel?.hierarchyConfig?.idColumn || "id";
// ID 컬럼 결정: 설정값 > 데이터에 존재하는 일반적인 ID 컬럼 > 폴백
const configIdColumn = config.leftPanel?.hierarchyConfig?.idColumn;
const idColumn = configIdColumn ||
(item["id"] !== undefined ? "id" :
item["dept_code"] !== undefined ? "dept_code" :
item["code"] !== undefined ? "code" : "id");
const itemId = item[idColumn] ?? `item-${level}-${index}`;
const hasChildren = item.children?.length > 0;
const isExpanded = expandedItems.has(String(itemId));
const isSelected = selectedLeftItem && selectedLeftItem[idColumn] === item[idColumn];
// 선택 상태 확인: 동일한 객체이거나 idColumn 값이 일치해야 함
const isSelected = selectedLeftItem && (
selectedLeftItem === item ||
(item[idColumn] !== undefined &&
selectedLeftItem[idColumn] !== undefined &&
selectedLeftItem[idColumn] === item[idColumn])
);
// displayRow 설정에 따라 컬럼 분류
const displayColumns = config.leftPanel?.displayColumns || [];

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";
@ -23,7 +23,7 @@ import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { generateNumberingCode } from "@/lib/api/numberingRule";
import { generateNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import {
UniversalFormModalComponentProps,
@ -115,10 +115,34 @@ 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) {
return;
}
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
if (initialData && Object.keys(initialData).length > 0) {
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
}
hasInitialized.current = true;
initializeForm();
}, [config, initialData]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행
// config 변경 시에만 재초기화 (initialData 변경은 무시)
useEffect(() => {
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
// 필드 레벨 linkedFieldGroup 데이터 로드
useEffect(() => {
@ -137,7 +161,6 @@ export function UniversalFormModalComponent({
// 각 테이블 데이터 로드
for (const tableName of tablesToLoad) {
if (!linkedFieldDataCache[tableName]) {
console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`);
await loadLinkedFieldData(tableName);
}
}
@ -149,6 +172,9 @@ export function UniversalFormModalComponent({
// 폼 초기화
const initializeForm = useCallback(async () => {
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
const effectiveInitialData = capturedInitialData.current || initialData;
const newFormData: FormDataState = {};
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
const newCollapsed = new Set<string>();
@ -174,11 +200,14 @@ 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];
}
}
}
@ -190,11 +219,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 +374,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 +489,7 @@ export function UniversalFormModalComponent({
return { valid: missingFields.length === 0, missingFields };
}, [config.sections, formData]);
// 단일 행 저장
// 단일 행 저장
const saveSingleRow = useCallback(async () => {
const dataToSave = { ...formData };
@ -455,18 +500,26 @@ export function UniversalFormModalComponent({
}
});
// 저장 시점 채번규칙 처리
// 저장 시점 채번규칙 처리 (allocateNumberingCode로 실제 순번 증가)
for (const section of config.sections) {
for (const field of section.fields) {
if (
field.numberingRule?.enabled &&
field.numberingRule?.generateOnSave &&
field.numberingRule?.ruleId &&
!dataToSave[field.columnName]
field.numberingRule?.ruleId
) {
const response = await generateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
dataToSave[field.columnName] = response.data.generatedCode;
// generateOnSave: 저장 시 새로 생성
// generateOnOpen: 열 때 미리보기로 표시했지만, 저장 시 실제 순번 할당 필요
if (field.numberingRule.generateOnSave && !dataToSave[field.columnName]) {
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
dataToSave[field.columnName] = response.data.generatedCode;
}
} else if (field.numberingRule.generateOnOpen && dataToSave[field.columnName]) {
// generateOnOpen인 경우, 미리보기 값이 있더라도 실제 순번 할당
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
dataToSave[field.columnName] = response.data.generatedCode;
}
}
}
}
@ -491,7 +544,6 @@ export function UniversalFormModalComponent({
if (commonFields.length === 0) {
const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName));
console.log("[UniversalFormModal] 공통 필드 자동 설정:", commonFields);
}
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
@ -499,22 +551,9 @@ export function UniversalFormModalComponent({
const repeatableSection = config.sections.find((s) => s.repeatable);
if (repeatableSection) {
repeatSectionId = repeatableSection.id;
console.log("[UniversalFormModal] 반복 섹션 자동 설정:", repeatSectionId);
}
}
// 디버깅: 설정 확인
console.log("[UniversalFormModal] 다중 행 저장 설정:", {
commonFields,
repeatSectionId,
mainSectionFields,
typeColumn,
mainTypeValue,
subTypeValue,
repeatSections,
formData,
});
// 반복 섹션 데이터
const repeatItems = repeatSections[repeatSectionId] || [];
@ -532,15 +571,11 @@ 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);
console.log("[UniversalFormModal] 메인 섹션 데이터:", mainSectionData);
console.log("[UniversalFormModal] 반복 항목:", repeatItems);
// 메인 행 (공통 데이터 + 메인 섹션 필드)
const mainRow: any = { ...commonData, ...mainSectionData };
if (typeColumn) {
@ -572,16 +607,20 @@ export function UniversalFormModalComponent({
if (section.repeatable) continue;
for (const field of section.fields) {
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
const response = await generateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
// 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
if (commonFields.includes(field.columnName)) {
rowsToSave.forEach((row) => {
row[field.columnName] = response.data?.generatedCode;
});
} else {
rowsToSave[0][field.columnName] = response.data?.generatedCode;
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
if (shouldAllocate) {
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
// 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
if (commonFields.includes(field.columnName)) {
rowsToSave.forEach((row) => {
row[field.columnName] = response.data?.generatedCode;
});
} else {
rowsToSave[0][field.columnName] = response.data?.generatedCode;
}
}
}
}
@ -589,16 +628,11 @@ export function UniversalFormModalComponent({
}
// 모든 행 저장
console.log("[UniversalFormModal] 저장할 행들:", rowsToSave);
console.log("[UniversalFormModal] 저장 테이블:", config.saveConfig.tableName);
for (let i = 0; i < rowsToSave.length; i++) {
const row = rowsToSave[i];
console.log(`[UniversalFormModal] ${i + 1}번째 행 저장 시도:`, row);
// 빈 객체 체크
if (Object.keys(row).length === 0) {
console.warn(`[UniversalFormModal] ${i + 1}번째 행이 비어있습니다. 건너뜁니다.`);
continue;
}
@ -608,87 +642,171 @@ export function UniversalFormModalComponent({
throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
}
}
console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`);
}, [config.sections, config.saveConfig, formData, repeatSections]);
// 커스텀 API 저장 (사원+부서 통합 저장 등)
const saveWithCustomApi = useCallback(async () => {
// 다중 테이블 저장 (범용)
const saveWithMultiTable = useCallback(async () => {
const { customApiSave } = config.saveConfig;
if (!customApiSave) return;
if (!customApiSave?.multiTable) return;
console.log("[UniversalFormModal] 커스텀 API 저장 시작:", customApiSave.apiType);
const { multiTable } = customApiSave;
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;
}
});
// 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. mainDept 데이터 구성
let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined;
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
for (const section of config.sections) {
if (section.repeatable) continue;
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"],
};
for (const field of section.fields) {
// 채번규칙이 활성화된 필드 처리
if (
field.numberingRule?.enabled &&
field.numberingRule?.ruleId
) {
// 신규 생성이거나 값이 없는 경우에만 채번
const isNewRecord = !initialData?.[multiTable.mainTable.primaryKeyColumn];
const hasNoValue = !mainData[field.columnName];
if (isNewRecord || hasNoValue) {
try {
// allocateNumberingCode로 실제 순번 증가
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
mainData[field.columnName] = response.data.generatedCode;
}
} catch (error) {
console.error(`채번규칙 할당 실패 (${field.columnName}):`, error);
}
}
}
}
}
// 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);
}
}
// 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],
});
// saveMainAsFirst가 활성화된 경우, 메인 데이터를 서브 테이블에 저장하기 위한 매핑 생성
let mainFieldMappings: Array<{ formField: string; targetColumn: string }> | undefined;
if (subTableConfig.options?.saveMainAsFirst) {
mainFieldMappings = [];
// 메인 섹션(비반복)의 필드들을 서브 테이블에 매핑
// 서브 테이블의 fieldMappings에서 targetColumn을 찾아서 매핑
for (const mapping of subTableConfig.fieldMappings || []) {
if (mapping.targetColumn) {
// 메인 데이터에서 동일한 컬럼명이 있으면 매핑
if (mainData[mapping.targetColumn] !== undefined) {
mainFieldMappings.push({
formField: mapping.targetColumn,
targetColumn: mapping.targetColumn,
});
}
// 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑
else {
config.sections.forEach((section) => {
if (section.repeatable) return;
const matchingField = section.fields.find(f => f.columnName === mapping.targetColumn);
if (matchingField && mainData[matchingField.columnName] !== undefined) {
mainFieldMappings!.push({
formField: matchingField.columnName,
targetColumn: mapping.targetColumn,
});
}
});
}
}
});
}
// 중복 제거
mainFieldMappings = mainFieldMappings.filter((m, idx, arr) =>
arr.findIndex(x => x.targetColumn === m.targetColumn) === idx
);
}
// 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, // 초기 데이터가 있으면 수정 모드
subTablesData.push({
tableName: subTableConfig.tableName,
linkColumn: subTableConfig.linkColumn,
items: subItems,
options: {
...subTableConfig.options,
mainFieldMappings, // 메인 데이터 매핑 추가
},
});
}
if (!response.success) {
throw new Error(response.message || "사원 저장 실패");
}
// 3. 범용 다중 테이블 저장 API 호출
const response = await apiClient.post("/table-management/multi-table-save", {
mainTable: multiTable.mainTable,
mainData,
subTables: subTablesData,
isUpdate: !!initialData?.[multiTable.mainTable.primaryKeyColumn],
});
console.log("[UniversalFormModal] 사원+부서 저장 완료:", response.data);
};
if (!response.data?.success) {
throw new Error(response.data?.message || "다중 테이블 저장 실패");
}
}, [config.sections, config.saveConfig, formData, repeatSections, initialData]);
// 커스텀 API 저장
const saveWithCustomApi = useCallback(async () => {
const { customApiSave } = config.saveConfig;
if (!customApiSave) return;
const saveWithGenericCustomApi = async () => {
if (!customApiSave.customEndpoint) {
@ -720,8 +838,8 @@ export function UniversalFormModalComponent({
};
switch (customApiSave.apiType) {
case "user-with-dept":
await saveUserWithDeptApi();
case "multi-table":
await saveWithMultiTable();
break;
case "custom":
await saveWithGenericCustomApi();
@ -729,7 +847,7 @@ 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 () => {
@ -799,12 +917,14 @@ export function UniversalFormModalComponent({
}, [initializeForm]);
// 필드 요소 렌더링 (입력 컴포넌트만)
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
const renderFieldElement = (
field: FormFieldConfig,
value: any,
onChangeHandler: (value: any) => void,
fieldKey: string,
isDisabled: boolean,
repeatContext?: { sectionId: string; itemId: string },
) => {
return (() => {
switch (field.fieldType) {
@ -883,11 +1003,24 @@ export function UniversalFormModalComponent({
lfg.mappings.forEach((mapping) => {
if (mapping.sourceColumn && mapping.targetColumn) {
const mappedValue = selectedRow[mapping.sourceColumn];
// formData에 직접 저장
setFormData((prev) => ({
...prev,
[mapping.targetColumn]: mappedValue,
}));
// 반복 섹션인 경우 repeatSections에 저장, 아니면 formData에 저장
if (repeatContext) {
setRepeatSections((prev) => {
const items = prev[repeatContext.sectionId] || [];
const newItems = items.map((item) =>
item._id === repeatContext.itemId
? { ...item, [mapping.targetColumn]: mappedValue }
: item
);
return { ...prev, [repeatContext.sectionId]: newItems };
});
} else {
setFormData((prev) => ({
...prev,
[mapping.targetColumn]: mappedValue,
}));
}
}
});
}
@ -1030,12 +1163,14 @@ export function UniversalFormModalComponent({
};
// 필드 렌더링 (섹션 열 수 적용)
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
const renderFieldWithColumns = (
field: FormFieldConfig,
value: any,
onChangeHandler: (value: any) => void,
fieldKey: string,
sectionColumns: number = 2,
repeatContext?: { sectionId: string; itemId: string },
) => {
// 섹션 열 수에 따른 기본 gridSpan 계산 (섹션 열 수가 우선)
const defaultSpan = getDefaultGridSpan(sectionColumns);
@ -1049,7 +1184,7 @@ export function UniversalFormModalComponent({
return null;
}
const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled);
const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled, repeatContext);
if (field.fieldType === "checkbox") {
return (
@ -1189,6 +1324,7 @@ export function UniversalFormModalComponent({
(value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value),
`${section.id}-${item._id}-${field.id}`,
sectionColumns,
{ sectionId: section.id, itemId: item._id }, // 반복 섹션 컨텍스트 전달
),
)}
</div>

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