Compare commits
5 Commits
531ba3ffdb
...
7ac6bbc2c6
| Author | SHA1 | Date |
|---|---|---|
|
|
7ac6bbc2c6 | |
|
|
fa59235cd2 | |
|
|
d908de7f66 | |
|
|
b15b6e21ea | |
|
|
a278ceca3f |
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (필터링 기준)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
// 렌더러가 클래스인지 함수인지 확인
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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; // 데이터 변환 함수명 (추후 확장)
|
||||
}
|
||||
|
||||
// 모달 설정
|
||||
|
|
|
|||
Loading…
Reference in New Issue