Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
@@ -365,7 +365,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
originalData={originalData || undefined}
|
||||
initialData={originalData || undefined} // 🆕 조건부 컨테이너 등에서 initialData로 전달
|
||||
initialData={(originalData && Object.keys(originalData).length > 0) ? originalData : formData} // 🆕 originalData가 있으면 사용, 없으면 formData 사용 (생성 모드에서 부모 데이터 전달)
|
||||
onFormDataChange={handleFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
|
||||
@@ -654,7 +654,6 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
console.log("[SplitPanelLayout2] 우측 추가 모달 열기");
|
||||
}, [
|
||||
config.rightPanel?.addModalScreenId,
|
||||
config.rightPanel?.addButtonLabel,
|
||||
|
||||
@@ -19,11 +19,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { generateNumberingCode, allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
|
||||
@@ -139,6 +139,7 @@ export function UniversalFormModalComponent({
|
||||
}: UniversalFormModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
|
||||
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
|
||||
const initialData = propInitialData || _initialData;
|
||||
|
||||
// 설정 병합
|
||||
const config: UniversalFormModalConfig = useMemo(() => {
|
||||
const componentConfig = component?.config || {};
|
||||
@@ -155,11 +156,6 @@ export function UniversalFormModalComponent({
|
||||
...defaultConfig.saveConfig,
|
||||
...propConfig?.saveConfig,
|
||||
...componentConfig.saveConfig,
|
||||
multiRowSave: {
|
||||
...defaultConfig.saveConfig.multiRowSave,
|
||||
...propConfig?.saveConfig?.multiRowSave,
|
||||
...componentConfig.saveConfig?.multiRowSave,
|
||||
},
|
||||
afterSave: {
|
||||
...defaultConfig.saveConfig.afterSave,
|
||||
...propConfig?.saveConfig?.afterSave,
|
||||
@@ -194,9 +190,6 @@ export function UniversalFormModalComponent({
|
||||
[tableKey: string]: Record<string, any>[];
|
||||
}>({});
|
||||
|
||||
// 로딩 상태
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 채번규칙 원본 값 추적 (수동 모드 감지용)
|
||||
// key: columnName, value: 자동 생성된 원본 값
|
||||
const [numberingOriginalValues, setNumberingOriginalValues] = useState<Record<string, string>>({});
|
||||
@@ -614,7 +607,8 @@ export function UniversalFormModalComponent({
|
||||
}
|
||||
|
||||
const tableConfig = section.tableConfig;
|
||||
const editConfig = tableConfig.editConfig;
|
||||
// editConfig는 타입에 정의되지 않았지만 런타임에 존재할 수 있음
|
||||
const editConfig = (tableConfig as any).editConfig;
|
||||
const saveConfig = tableConfig.saveConfig;
|
||||
|
||||
console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, {
|
||||
@@ -1244,378 +1238,6 @@ export function UniversalFormModalComponent({
|
||||
return { valid: missingFields.length === 0, missingFields };
|
||||
}, [config.sections, formData]);
|
||||
|
||||
// 단일 행 저장
|
||||
const saveSingleRow = useCallback(async () => {
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
// 테이블 섹션 데이터 추출 (별도 저장용)
|
||||
const tableSectionData: Record<string, any[]> = {};
|
||||
|
||||
// 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용)
|
||||
Object.keys(dataToSave).forEach((key) => {
|
||||
if (key.startsWith("_tableSection_")) {
|
||||
// 테이블 섹션 데이터는 별도로 저장
|
||||
const sectionId = key.replace("_tableSection_", "");
|
||||
tableSectionData[sectionId] = dataToSave[key] || [];
|
||||
delete dataToSave[key];
|
||||
} else if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
|
||||
delete dataToSave[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 저장 시점 채번규칙 처리
|
||||
for (const section of config.sections) {
|
||||
// 테이블 타입 섹션은 건너뛰기
|
||||
if (section.type === "table") continue;
|
||||
|
||||
for (const field of section.fields || []) {
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||
const ruleIdKey = `${field.columnName}_numberingRuleId`;
|
||||
const hasRuleId = dataToSave[ruleIdKey]; // 사용자가 수정하지 않았으면 ruleId 유지됨
|
||||
|
||||
// 채번 규칙 할당 조건
|
||||
const shouldAllocate =
|
||||
// 1. generateOnSave가 ON인 경우: 항상 저장 시점에 할당
|
||||
field.numberingRule.generateOnSave ||
|
||||
// 2. editable이 OFF인 경우: 사용자 입력 무시하고 채번 규칙으로 덮어씌움
|
||||
!field.numberingRule.editable ||
|
||||
// 3. editable이 ON이고 사용자가 수정하지 않은 경우 (ruleId 유지됨): 실제 번호 할당
|
||||
(field.numberingRule.editable && hasRuleId);
|
||||
|
||||
if (shouldAllocate) {
|
||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
dataToSave[field.columnName] = response.data.generatedCode;
|
||||
let reason = "(알 수 없음)";
|
||||
if (field.numberingRule.generateOnSave) {
|
||||
reason = "(generateOnSave)";
|
||||
} else if (!field.numberingRule.editable) {
|
||||
reason = "(editable=OFF, 강제 덮어씌움)";
|
||||
} else if (hasRuleId) {
|
||||
reason = "(editable=ON, 사용자 미수정)";
|
||||
}
|
||||
console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode} ${reason}`);
|
||||
} else {
|
||||
console.error(`[채번 실패] ${field.columnName}:`, response.error);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[채번 스킵] ${field.columnName}: 사용자가 직접 입력한 값 유지 = ${dataToSave[field.columnName]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 별도 테이블에 저장해야 하는 테이블 섹션 목록
|
||||
const tableSectionsForSeparateTable = config.sections.filter(
|
||||
(s) =>
|
||||
s.type === "table" &&
|
||||
s.tableConfig?.saveConfig?.targetTable &&
|
||||
s.tableConfig.saveConfig.targetTable !== config.saveConfig.tableName,
|
||||
);
|
||||
|
||||
// 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장)
|
||||
// targetTable이 없거나 메인 테이블과 같은 경우
|
||||
const tableSectionsForMainTable = config.sections.filter(
|
||||
(s) =>
|
||||
s.type === "table" &&
|
||||
(!s.tableConfig?.saveConfig?.targetTable ||
|
||||
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName),
|
||||
);
|
||||
|
||||
console.log("[saveSingleRow] 메인 테이블:", config.saveConfig.tableName);
|
||||
console.log(
|
||||
"[saveSingleRow] 메인 테이블에 저장할 테이블 섹션:",
|
||||
tableSectionsForMainTable.map((s) => s.id),
|
||||
);
|
||||
console.log(
|
||||
"[saveSingleRow] 별도 테이블에 저장할 테이블 섹션:",
|
||||
tableSectionsForSeparateTable.map((s) => s.id),
|
||||
);
|
||||
console.log("[saveSingleRow] 테이블 섹션 데이터 키:", Object.keys(tableSectionData));
|
||||
console.log("[saveSingleRow] dataToSave 키:", Object.keys(dataToSave));
|
||||
|
||||
if (tableSectionsForMainTable.length > 0) {
|
||||
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
|
||||
const commonFieldsData: Record<string, any> = {};
|
||||
const { sectionSaveModes } = config.saveConfig;
|
||||
|
||||
// 필드 타입 섹션에서 공통 저장 필드 수집
|
||||
for (const section of config.sections) {
|
||||
if (section.type === "table") continue;
|
||||
|
||||
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === section.id);
|
||||
const defaultMode = "common"; // 필드 타입 섹션의 기본값은 공통 저장
|
||||
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
|
||||
|
||||
if (section.fields) {
|
||||
for (const field of section.fields) {
|
||||
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
|
||||
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
|
||||
|
||||
if (fieldSaveMode === "common" && dataToSave[field.columnName] !== undefined) {
|
||||
commonFieldsData[field.columnName] = dataToSave[field.columnName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 각 테이블 섹션의 품목 데이터에 공통 필드 병합하여 저장
|
||||
for (const tableSection of tableSectionsForMainTable) {
|
||||
const sectionData = tableSectionData[tableSection.id] || [];
|
||||
|
||||
if (sectionData.length > 0) {
|
||||
// 품목별로 행 저장
|
||||
for (const item of sectionData) {
|
||||
const rowToSave = { ...commonFieldsData, ...item };
|
||||
|
||||
// _sourceData 등 내부 메타데이터 제거
|
||||
Object.keys(rowToSave).forEach((key) => {
|
||||
if (key.startsWith("_")) {
|
||||
delete rowToSave[key];
|
||||
}
|
||||
});
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${config.saveConfig.tableName}/add`,
|
||||
rowToSave,
|
||||
);
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || "품목 저장 실패");
|
||||
}
|
||||
}
|
||||
|
||||
// 이미 저장했으므로 아래 로직에서 다시 저장하지 않도록 제거
|
||||
delete tableSectionData[tableSection.id];
|
||||
}
|
||||
}
|
||||
|
||||
// 품목이 없으면 공통 데이터만 저장하지 않음 (품목이 필요한 화면이므로)
|
||||
// 다른 테이블 섹션이 있는 경우에만 메인 데이터 저장
|
||||
const hasOtherTableSections = Object.keys(tableSectionData).length > 0;
|
||||
if (!hasOtherTableSections) {
|
||||
return; // 메인 테이블에 저장할 품목이 없으면 종료
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 데이터 저장 (테이블 섹션이 없거나 별도 테이블에 저장하는 경우)
|
||||
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || "저장 실패");
|
||||
}
|
||||
|
||||
// 테이블 섹션 데이터 저장 (별도 테이블에)
|
||||
for (const section of config.sections) {
|
||||
if (section.type === "table" && section.tableConfig?.saveConfig?.targetTable) {
|
||||
const sectionData = tableSectionData[section.id];
|
||||
if (sectionData && sectionData.length > 0) {
|
||||
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
|
||||
const mainRecordId = response.data?.data?.id;
|
||||
|
||||
// 공통 저장 필드 수집: 다른 섹션(필드 타입)에서 공통 저장으로 설정된 필드 값
|
||||
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
|
||||
const commonFieldsData: Record<string, any> = {};
|
||||
const { sectionSaveModes } = config.saveConfig;
|
||||
|
||||
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
|
||||
for (const otherSection of config.sections) {
|
||||
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
|
||||
|
||||
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === otherSection.id);
|
||||
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
|
||||
const defaultMode = otherSection.type === "table" ? "individual" : "common";
|
||||
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
|
||||
|
||||
// 필드 타입 섹션의 필드들 처리
|
||||
if (otherSection.type !== "table" && otherSection.fields) {
|
||||
for (const field of otherSection.fields) {
|
||||
// 필드별 오버라이드 확인
|
||||
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
|
||||
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
|
||||
|
||||
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
|
||||
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
|
||||
commonFieldsData[field.columnName] = formData[field.columnName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 선택적 필드 그룹 (optionalFieldGroups)도 처리
|
||||
if (otherSection.optionalFieldGroups && otherSection.optionalFieldGroups.length > 0) {
|
||||
for (const optGroup of otherSection.optionalFieldGroups) {
|
||||
if (optGroup.fields) {
|
||||
for (const field of optGroup.fields) {
|
||||
// 선택적 필드 그룹은 기본적으로 common 저장
|
||||
if (formData[field.columnName] !== undefined) {
|
||||
commonFieldsData[field.columnName] = formData[field.columnName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[saveSingleRow] 별도 테이블 저장 - 공통 필드:", Object.keys(commonFieldsData));
|
||||
|
||||
for (const item of sectionData) {
|
||||
// 공통 필드 병합 + 개별 품목 데이터
|
||||
const itemToSave = { ...commonFieldsData, ...item };
|
||||
|
||||
// saveToTarget: false인 컬럼은 저장에서 제외
|
||||
const columns = section.tableConfig?.columns || [];
|
||||
for (const col of columns) {
|
||||
if (col.saveConfig?.saveToTarget === false && col.field in itemToSave) {
|
||||
delete itemToSave[col.field];
|
||||
}
|
||||
}
|
||||
|
||||
// _sourceData 등 내부 메타데이터 제거
|
||||
Object.keys(itemToSave).forEach((key) => {
|
||||
if (key.startsWith("_")) {
|
||||
delete itemToSave[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 메인 레코드와 연결이 필요한 경우
|
||||
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
|
||||
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
|
||||
}
|
||||
|
||||
const saveResponse = await apiClient.post(
|
||||
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
|
||||
itemToSave,
|
||||
);
|
||||
|
||||
if (!saveResponse.data?.success) {
|
||||
throw new Error(saveResponse.data?.message || `${section.title || "테이블 섹션"} 저장 실패`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
config.sections,
|
||||
config.saveConfig.tableName,
|
||||
config.saveConfig.primaryKeyColumn,
|
||||
config.saveConfig.sectionSaveModes,
|
||||
formData,
|
||||
]);
|
||||
|
||||
// 다중 행 저장 (겸직 등)
|
||||
const saveMultipleRows = useCallback(async () => {
|
||||
const { multiRowSave } = config.saveConfig;
|
||||
if (!multiRowSave) return;
|
||||
|
||||
let { commonFields = [], repeatSectionId = "" } = multiRowSave;
|
||||
const { typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = multiRowSave;
|
||||
|
||||
// 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
|
||||
if (commonFields.length === 0) {
|
||||
const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
|
||||
commonFields = nonRepeatableSections.flatMap((s) => (s.fields || []).map((f) => f.columnName));
|
||||
}
|
||||
|
||||
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
|
||||
if (!repeatSectionId) {
|
||||
const repeatableSection = config.sections.find((s) => s.repeatable);
|
||||
if (repeatableSection) {
|
||||
repeatSectionId = repeatableSection.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 반복 섹션 데이터
|
||||
const repeatItems = repeatSections[repeatSectionId] || [];
|
||||
|
||||
// 저장할 행들 생성
|
||||
const rowsToSave: any[] = [];
|
||||
|
||||
// 공통 데이터 (모든 행에 적용)
|
||||
const commonData: any = {};
|
||||
commonFields.forEach((fieldName) => {
|
||||
if (formData[fieldName] !== undefined) {
|
||||
commonData[fieldName] = formData[fieldName];
|
||||
}
|
||||
});
|
||||
|
||||
// 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등)
|
||||
const mainSectionData: any = {};
|
||||
mainSectionFields.forEach((fieldName) => {
|
||||
if (formData[fieldName] !== undefined) {
|
||||
mainSectionData[fieldName] = formData[fieldName];
|
||||
}
|
||||
});
|
||||
|
||||
// 메인 행 (공통 데이터 + 메인 섹션 필드)
|
||||
const mainRow: any = { ...commonData, ...mainSectionData };
|
||||
if (typeColumn) {
|
||||
mainRow[typeColumn] = mainTypeValue || "main";
|
||||
}
|
||||
rowsToSave.push(mainRow);
|
||||
|
||||
// 반복 섹션 행들 (공통 데이터 + 반복 섹션 필드)
|
||||
for (const item of repeatItems) {
|
||||
const subRow: any = { ...commonData };
|
||||
|
||||
// 반복 섹션의 필드 값 추가
|
||||
const repeatSection = config.sections.find((s) => s.id === repeatSectionId);
|
||||
(repeatSection?.fields || []).forEach((field) => {
|
||||
if (item[field.columnName] !== undefined) {
|
||||
subRow[field.columnName] = item[field.columnName];
|
||||
}
|
||||
});
|
||||
|
||||
if (typeColumn) {
|
||||
subRow[typeColumn] = subTypeValue || "concurrent";
|
||||
}
|
||||
|
||||
rowsToSave.push(subRow);
|
||||
}
|
||||
|
||||
// 저장 시점 채번규칙 처리 (메인 행만)
|
||||
for (const section of config.sections) {
|
||||
if (section.repeatable || section.type === "table") continue;
|
||||
|
||||
for (const field of section.fields || []) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 행 저장
|
||||
for (let i = 0; i < rowsToSave.length; i++) {
|
||||
const row = rowsToSave[i];
|
||||
|
||||
// 빈 객체 체크
|
||||
if (Object.keys(row).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, row);
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
|
||||
}
|
||||
}
|
||||
}, [config.sections, config.saveConfig, formData, repeatSections]);
|
||||
|
||||
// 다중 테이블 저장 (범용)
|
||||
const saveWithMultiTable = useCallback(async () => {
|
||||
const { customApiSave } = config.saveConfig;
|
||||
@@ -1798,134 +1420,6 @@ export function UniversalFormModalComponent({
|
||||
}
|
||||
}, [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) {
|
||||
throw new Error("커스텀 API 엔드포인트가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
// 메타데이터 필드 제거
|
||||
Object.keys(dataToSave).forEach((key) => {
|
||||
if (key.startsWith("_")) {
|
||||
delete dataToSave[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 반복 섹션 데이터 포함
|
||||
if (Object.keys(repeatSections).length > 0) {
|
||||
dataToSave._repeatSections = repeatSections;
|
||||
}
|
||||
|
||||
const method = customApiSave.customMethod || "POST";
|
||||
const response =
|
||||
method === "PUT"
|
||||
? await apiClient.put(customApiSave.customEndpoint, dataToSave)
|
||||
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || "저장 실패");
|
||||
}
|
||||
};
|
||||
|
||||
switch (customApiSave.apiType) {
|
||||
case "multi-table":
|
||||
await saveWithMultiTable();
|
||||
break;
|
||||
case "custom":
|
||||
await saveWithGenericCustomApi();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`);
|
||||
}
|
||||
}, [config.saveConfig, formData, repeatSections, saveWithMultiTable]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(async () => {
|
||||
// 커스텀 API 저장 모드가 아닌 경우에만 테이블명 체크
|
||||
if (!config.saveConfig.customApiSave?.enabled && !config.saveConfig.tableName) {
|
||||
toast.error("저장할 테이블이 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 필수 필드 검증
|
||||
const { valid, missingFields } = validateRequiredFields();
|
||||
if (!valid) {
|
||||
toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const { multiRowSave, customApiSave } = config.saveConfig;
|
||||
|
||||
// 커스텀 API 저장 모드
|
||||
if (customApiSave?.enabled) {
|
||||
await saveWithCustomApi();
|
||||
} else if (multiRowSave?.enabled) {
|
||||
// 다중 행 저장
|
||||
await saveMultipleRows();
|
||||
} else {
|
||||
// 단일 행 저장
|
||||
await saveSingleRow();
|
||||
}
|
||||
|
||||
// 저장 후 동작
|
||||
if (config.saveConfig.afterSave?.showToast) {
|
||||
toast.success("저장되었습니다.");
|
||||
}
|
||||
|
||||
if (config.saveConfig.afterSave?.refreshParent) {
|
||||
window.dispatchEvent(new CustomEvent("refreshParentData"));
|
||||
}
|
||||
|
||||
// onSave 콜백은 저장 완료 알림용으로만 사용
|
||||
// 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows)
|
||||
// EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록
|
||||
// _saveCompleted 플래그를 포함하여 전달
|
||||
if (onSave) {
|
||||
onSave({ ...formData, _saveCompleted: true });
|
||||
}
|
||||
|
||||
// 저장 완료 후 모달 닫기 이벤트 발생
|
||||
if (config.saveConfig.afterSave?.closeModal !== false) {
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("저장 실패:", error);
|
||||
// axios 에러의 경우 서버 응답 메시지 추출
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error?.details ||
|
||||
error.message ||
|
||||
"저장에 실패했습니다.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
config,
|
||||
formData,
|
||||
repeatSections,
|
||||
onSave,
|
||||
validateRequiredFields,
|
||||
saveSingleRow,
|
||||
saveMultipleRows,
|
||||
saveWithCustomApi,
|
||||
]);
|
||||
|
||||
// 폼 초기화
|
||||
const handleReset = useCallback(() => {
|
||||
initializeForm();
|
||||
toast.info("폼이 초기화되었습니다.");
|
||||
}, [initializeForm]);
|
||||
|
||||
// 필드 요소 렌더링 (입력 컴포넌트만)
|
||||
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
|
||||
const renderFieldElement = (
|
||||
@@ -2664,38 +2158,6 @@ export function UniversalFormModalComponent({
|
||||
{/* 섹션들 */}
|
||||
<div className="space-y-4">{config.sections.map((section) => renderSection(section))}</div>
|
||||
|
||||
{/* 버튼 영역 - 저장 버튼이 표시될 때만 렌더링 */}
|
||||
{config.modal.showSaveButton !== false && (
|
||||
<div className="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||
{config.modal.showResetButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleReset();
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-4 w-4" />
|
||||
{config.modal.resetButtonText || "초기화"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSave();
|
||||
}}
|
||||
disabled={saving || !config.saveConfig.tableName}
|
||||
>
|
||||
{saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog
|
||||
open={deleteDialog.open}
|
||||
|
||||
@@ -530,40 +530,6 @@ export function UniversalFormModalConfigPanel({
|
||||
</Select>
|
||||
<HelpText>모달 창의 크기를 선택하세요</HelpText>
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 표시 설정 */}
|
||||
<div className="w-full min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="show-save-button"
|
||||
checked={config.modal.showSaveButton !== false}
|
||||
onCheckedChange={(checked) => updateModalConfig({ showSaveButton: checked === true })}
|
||||
/>
|
||||
<Label htmlFor="show-save-button" className="cursor-pointer text-xs font-medium">
|
||||
저장 버튼 표시
|
||||
</Label>
|
||||
</div>
|
||||
<HelpText>체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="w-full min-w-0 space-y-3">
|
||||
<div className="w-full min-w-0">
|
||||
<Label className="mb-1.5 block text-xs font-medium">저장 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.modal.saveButtonText || "저장"}
|
||||
onChange={(e) => updateModalConfig({ saveButtonText: e.target.value })}
|
||||
className="h-9 w-full max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full min-w-0">
|
||||
<Label className="mb-1.5 block text-xs font-medium">취소 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.modal.cancelButtonText || "취소"}
|
||||
onChange={(e) => updateModalConfig({ cancelButtonText: e.target.value })}
|
||||
className="h-9 w-full max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
@@ -885,7 +851,6 @@ export function UniversalFormModalConfigPanel({
|
||||
tableColumns={tableColumns}
|
||||
numberingRules={numberingRules}
|
||||
onLoadTableColumns={loadTableColumns}
|
||||
availableParentFields={availableParentFields}
|
||||
targetTableName={config.saveConfig?.tableName}
|
||||
targetTableColumns={config.saveConfig?.tableName ? tableColumns[config.saveConfig.tableName] || [] : []}
|
||||
/>
|
||||
|
||||
@@ -23,11 +23,6 @@ export const defaultConfig: UniversalFormModalConfig = {
|
||||
size: "lg",
|
||||
closeOnOutsideClick: false,
|
||||
showCloseButton: true,
|
||||
showSaveButton: true,
|
||||
saveButtonText: "저장",
|
||||
cancelButtonText: "취소",
|
||||
showResetButton: false,
|
||||
resetButtonText: "초기화",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
@@ -45,15 +40,6 @@ export const defaultConfig: UniversalFormModalConfig = {
|
||||
saveConfig: {
|
||||
tableName: "",
|
||||
primaryKeyColumn: "id",
|
||||
multiRowSave: {
|
||||
enabled: false,
|
||||
commonFields: [],
|
||||
repeatSectionId: "",
|
||||
typeColumn: "",
|
||||
mainTypeValue: "main",
|
||||
subTypeValue: "concurrent",
|
||||
mainSectionFields: [],
|
||||
},
|
||||
afterSave: {
|
||||
closeModal: true,
|
||||
refreshParent: true,
|
||||
|
||||
@@ -9,14 +9,14 @@ import { defaultConfig } from "./config";
|
||||
/**
|
||||
* 범용 폼 모달 컴포넌트 정의
|
||||
*
|
||||
* 섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원하는
|
||||
* 섹션 기반 폼 레이아웃, 채번규칙을 지원하는
|
||||
* 범용 모달 컴포넌트입니다.
|
||||
*/
|
||||
export const UniversalFormModalDefinition = createComponentDefinition({
|
||||
id: "universal-form-modal",
|
||||
name: "범용 폼 모달",
|
||||
nameEng: "Universal Form Modal",
|
||||
description: "섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원하는 범용 모달 컴포넌트",
|
||||
description: "섹션 기반 폼 레이아웃, 채번규칙을 지원하는 범용 모달 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "form",
|
||||
component: UniversalFormModalComponent,
|
||||
@@ -28,7 +28,7 @@ export const UniversalFormModalDefinition = createComponentDefinition({
|
||||
},
|
||||
configPanel: UniversalFormModalConfigPanel,
|
||||
icon: "FormInput",
|
||||
tags: ["폼", "모달", "입력", "저장", "채번", "겸직", "다중행"],
|
||||
tags: ["폼", "모달", "입력", "저장", "채번"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: `
|
||||
@@ -36,22 +36,22 @@ export const UniversalFormModalDefinition = createComponentDefinition({
|
||||
|
||||
### 주요 기능
|
||||
- **섹션 기반 레이아웃**: 기본 정보, 추가 정보 등 섹션별로 폼 구성
|
||||
- **반복 섹션**: 겸직처럼 동일한 필드 그룹을 여러 개 추가 가능
|
||||
- **반복 섹션**: 동일한 필드 그룹을 여러 개 추가 가능
|
||||
- **채번규칙 연동**: 자동 코드 생성 (모달 열릴 때 또는 저장 시점)
|
||||
- **다중 행 저장**: 공통 필드 + 개별 필드 조합으로 여러 행 동시 저장
|
||||
- **단일/다중 테이블 저장**: 단일 테이블 또는 메인+서브 테이블에 저장
|
||||
- **외부 데이터 수신**: 부모 화면에서 전달받은 값 자동 채움
|
||||
|
||||
### 사용 예시
|
||||
1. 부서관리 사원 추가 + 겸직 등록
|
||||
2. 품목 등록 + 규격 옵션 추가
|
||||
3. 거래처 등록 + 담당자 정보 추가
|
||||
1. 사원 등록, 부서 등록, 거래처 등록 (단일 테이블)
|
||||
2. 주문 등록 + 주문 상세 (다중 테이블)
|
||||
3. 품목 등록 + 규격 옵션 추가
|
||||
|
||||
### 설정 방법
|
||||
1. 저장 테이블 선택
|
||||
2. 섹션 추가 (기본 정보, 겸직 정보 등)
|
||||
2. 섹션 추가 (기본 정보 등)
|
||||
3. 각 섹션에 필드 추가
|
||||
4. 반복 섹션 설정 (필요 시)
|
||||
5. 다중 행 저장 설정 (필요 시)
|
||||
5. 다중 테이블 저장 설정 (필요 시)
|
||||
6. 채번규칙 연동 (필요 시)
|
||||
`,
|
||||
});
|
||||
@@ -69,7 +69,6 @@ export type {
|
||||
FormSectionConfig,
|
||||
FormFieldConfig,
|
||||
SaveConfig,
|
||||
MultiRowSaveConfig,
|
||||
NumberingRuleConfig,
|
||||
SelectOptionConfig,
|
||||
FormDataState,
|
||||
|
||||
@@ -65,8 +65,6 @@ interface FieldDetailSettingsModalProps {
|
||||
tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] };
|
||||
numberingRules: { id: string; name: string }[];
|
||||
onLoadTableColumns: (tableName: string) => void;
|
||||
// 부모 화면에서 전달 가능한 필드 목록 (선택사항)
|
||||
availableParentFields?: AvailableParentField[];
|
||||
// 저장 테이블 정보 (타겟 컬럼 선택용)
|
||||
targetTableName?: string;
|
||||
targetTableColumns?: { name: string; type: string; label: string }[];
|
||||
@@ -81,7 +79,6 @@ export function FieldDetailSettingsModal({
|
||||
tableColumns,
|
||||
numberingRules,
|
||||
onLoadTableColumns,
|
||||
availableParentFields = [],
|
||||
// targetTableName은 타겟 컬럼 선택 시 참고용으로 전달됨 (현재 targetTableColumns만 사용)
|
||||
targetTableName: _targetTableName,
|
||||
targetTableColumns = [],
|
||||
@@ -330,60 +327,6 @@ export function FieldDetailSettingsModal({
|
||||
/>
|
||||
</div>
|
||||
<HelpText>화면에 표시하지 않지만 값은 저장됩니다</HelpText>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">부모에서 값 받기</span>
|
||||
<Switch
|
||||
checked={localField.receiveFromParent || false}
|
||||
onCheckedChange={(checked) => updateField({ receiveFromParent: checked })}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>부모 화면에서 전달받은 값으로 자동 채워집니다</HelpText>
|
||||
|
||||
{/* 부모에서 값 받기 활성화 시 필드 선택 */}
|
||||
{localField.receiveFromParent && (
|
||||
<div className="mt-3 space-y-2 p-3 rounded-md bg-blue-50 border border-blue-200">
|
||||
<Label className="text-xs font-medium text-blue-700">부모 필드명 선택</Label>
|
||||
{availableParentFields.length > 0 ? (
|
||||
<Select
|
||||
value={localField.parentFieldName || localField.columnName}
|
||||
onValueChange={(value) => updateField({ parentFieldName: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableParentFields.map((pf) => (
|
||||
<SelectItem key={pf.name} value={pf.name}>
|
||||
<div className="flex flex-col">
|
||||
<span>{pf.label || pf.name}</span>
|
||||
{pf.sourceComponent && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{pf.sourceComponent}{pf.sourceTable && ` (${pf.sourceTable})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={localField.parentFieldName || ""}
|
||||
onChange={(e) => updateField({ parentFieldName: e.target.value })}
|
||||
placeholder={`예: ${localField.columnName || "parent_field_name"}`}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
부모 화면에서 전달받을 필드명을 입력하세요. 비워두면 "{localField.columnName}"을 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Accordion으로 고급 설정 */}
|
||||
|
||||
@@ -378,7 +378,11 @@ export function SaveSettingsModal({
|
||||
단일 테이블 저장
|
||||
</Label>
|
||||
</div>
|
||||
<HelpText>모든 필드를 하나의 테이블에 저장합니다 (기본 방식)</HelpText>
|
||||
<HelpText>
|
||||
폼 데이터를 하나의 테이블에 1개 행으로 저장합니다.
|
||||
<br />
|
||||
예: 사원 등록, 부서 등록, 거래처 등록 등 단순 등록 화면
|
||||
</HelpText>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<RadioGroupItem value="multi" id="mode-multi" />
|
||||
@@ -387,9 +391,13 @@ export function SaveSettingsModal({
|
||||
</Label>
|
||||
</div>
|
||||
<HelpText>
|
||||
메인 테이블 + 서브 테이블에 트랜잭션으로 저장합니다
|
||||
하나의 폼으로 여러 테이블에 동시 저장합니다. (트랜잭션으로 묶임)
|
||||
<br />
|
||||
예: 주문(orders) + 주문상세(order_items), 사원(user_info) + 부서(user_dept)
|
||||
메인 테이블: 폼의 모든 필드 중 해당 테이블 컬럼과 일치하는 것 자동 저장
|
||||
<br />
|
||||
서브 테이블: 필드 매핑에서 지정한 필드만 저장 (메인 테이블의 키 값이 자동 연결됨)
|
||||
<br />
|
||||
예: 사원+부서배정(user_info+user_dept), 주문+주문상세(orders+order_items)
|
||||
</HelpText>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
@@ -691,9 +699,11 @@ export function SaveSettingsModal({
|
||||
</div>
|
||||
|
||||
<HelpText>
|
||||
반복 섹션 데이터를 별도 테이블에 저장합니다.
|
||||
폼에서 입력한 필드를 서브 테이블에 나눠서 저장합니다.
|
||||
<br />
|
||||
예: 주문상세(order_items), 겸직부서(user_dept)
|
||||
메인 테이블의 키 값(예: user_id)이 서브 테이블에 자동으로 연결됩니다.
|
||||
<br />
|
||||
필드 매핑에서 지정한 필드만 서브 테이블에 저장됩니다.
|
||||
</HelpText>
|
||||
|
||||
{(localSaveConfig.customApiSave?.multiTable?.subTables || []).length === 0 ? (
|
||||
@@ -802,13 +812,13 @@ export function SaveSettingsModal({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">연결할 반복 섹션</Label>
|
||||
<Label className="text-[10px]">연결할 반복 섹션 (선택사항)</Label>
|
||||
<Select
|
||||
value={subTable.repeatSectionId || ""}
|
||||
onValueChange={(value) => updateSubTable(subIndex, { repeatSectionId: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="섹션 선택" />
|
||||
<SelectValue placeholder="섹션 선택 (없으면 필드 매핑만 사용)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repeatSections.length === 0 ? (
|
||||
@@ -824,7 +834,13 @@ export function SaveSettingsModal({
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>이 서브 테이블에 저장할 반복 섹션을 선택하세요</HelpText>
|
||||
<HelpText>
|
||||
반복 섹션: 폼 안에서 동적으로 항목을 추가/삭제할 수 있는 섹션 (예: 주문 품목 목록)
|
||||
<br />
|
||||
반복 섹션이 있으면 해당 섹션의 각 항목이 서브 테이블에 여러 행으로 저장됩니다.
|
||||
<br />
|
||||
반복 섹션 없이 필드 매핑만 사용하면 1개 행만 저장됩니다.
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -648,19 +648,6 @@ export interface TableCalculationRule {
|
||||
conditionalCalculation?: ConditionalCalculationConfig;
|
||||
}
|
||||
|
||||
// 다중 행 저장 설정
|
||||
export interface MultiRowSaveConfig {
|
||||
enabled?: boolean; // 사용 여부 (기본: false)
|
||||
commonFields?: string[]; // 모든 행에 공통 저장할 필드 (columnName 기준)
|
||||
repeatSectionId?: string; // 반복 섹션 ID
|
||||
typeColumn?: string; // 구분 컬럼명 (예: "employment_type")
|
||||
mainTypeValue?: string; // 메인 행 값 (예: "main")
|
||||
subTypeValue?: string; // 서브 행 값 (예: "concurrent")
|
||||
|
||||
// 메인 섹션 필드 (반복 섹션이 아닌 곳의 부서/직급 등)
|
||||
mainSectionFields?: string[]; // 메인 행에만 저장할 필드
|
||||
}
|
||||
|
||||
/**
|
||||
* 섹션별 저장 방식 설정
|
||||
* 공통 저장: 해당 섹션의 필드 값이 모든 품목 행에 동일하게 저장됩니다 (예: 수주번호, 거래처)
|
||||
@@ -681,9 +668,6 @@ export interface SaveConfig {
|
||||
tableName: string;
|
||||
primaryKeyColumn?: string; // PK 컬럼 (수정 시 사용)
|
||||
|
||||
// 다중 행 저장 설정
|
||||
multiRowSave?: MultiRowSaveConfig;
|
||||
|
||||
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
|
||||
customApiSave?: CustomApiSaveConfig;
|
||||
|
||||
@@ -802,13 +786,6 @@ export interface ModalConfig {
|
||||
size: "sm" | "md" | "lg" | "xl" | "full";
|
||||
closeOnOutsideClick?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
|
||||
// 버튼 설정
|
||||
showSaveButton?: boolean; // 저장 버튼 표시 (기본: true)
|
||||
saveButtonText?: string; // 저장 버튼 텍스트 (기본: "저장")
|
||||
cancelButtonText?: string; // 취소 버튼 텍스트 (기본: "취소")
|
||||
showResetButton?: boolean; // 초기화 버튼 표시
|
||||
resetButtonText?: string; // 초기화 버튼 텍스트
|
||||
}
|
||||
|
||||
// 전체 설정
|
||||
|
||||
Reference in New Issue
Block a user