From 9fb94da493a7f6f5fd299580d9c418321095a454 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 19 Dec 2025 14:53:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(UniversalFormModal):=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=EB=B3=84=20=EC=A0=80=EC=9E=A5=20=EB=B0=A9=EC=8B=9D=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20SectionSa?= =?UTF-8?q?veMode=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80=20(=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=A0=80=EC=9E=A5/=EA=B0=9C=EB=B3=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5)=20SaveSettingsModal=EC=97=90=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=EB=B3=84/=ED=95=84=EB=93=9C=EB=B3=84=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=84=A4=EC=A0=95=20UI=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20saveSingleRow()=EC=97=90=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20+=20=ED=92=88=EB=AA=A9=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20buttonActions.ts=EC=97=90=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=EC=9A=A9=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=EC=A0=80=EC=9E=A5=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UniversalFormModalComponent.tsx | 109 +++++++- .../modals/SaveSettingsModal.tsx | 239 +++++++++++++++++- .../components/universal-form-modal/types.ts | 18 ++ frontend/lib/utils/buttonActions.ts | 124 +++++++++ 4 files changed, 485 insertions(+), 5 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 288ea9f6..76f28d3f 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -837,7 +837,79 @@ export function UniversalFormModalComponent({ } } - // 메인 데이터 저장 + // 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장) + // targetTable이 없거나 메인 테이블과 같은 경우 + const tableSectionsForMainTable = config.sections.filter( + (s) => s.type === "table" && + (!s.tableConfig?.saveConfig?.targetTable || + s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName) + ); + + if (tableSectionsForMainTable.length > 0) { + // 공통 저장 필드 수집 (sectionSaveModes 설정에 따라) + const commonFieldsData: Record = {}; + 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) { @@ -852,8 +924,39 @@ export function UniversalFormModalComponent({ // 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기) const mainRecordId = response.data?.data?.id; + // 공통 저장 필드 수집 (sectionSaveModes 설정에 따라) + const commonFieldsData: Record = {}; + const { sectionSaveModes } = config.saveConfig; + + if (sectionSaveModes && sectionSaveModes.length > 0) { + // 다른 섹션에서 공통 저장으로 설정된 필드 값 수집 + for (const otherSection of config.sections) { + if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기 + + const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id); + 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]; + } + } + } + } + } + for (const item of sectionData) { - const itemToSave = { ...item }; + // 공통 필드 병합 + 개별 품목 데이터 + const itemToSave = { ...commonFieldsData, ...item }; + // 메인 레코드와 연결이 필요한 경우 if (mainRecordId && config.saveConfig.primaryKeyColumn) { itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId; @@ -867,7 +970,7 @@ export function UniversalFormModalComponent({ } } } - }, [config.sections, config.saveConfig.tableName, config.saveConfig.primaryKeyColumn, formData]); + }, [config.sections, config.saveConfig.tableName, config.saveConfig.primaryKeyColumn, config.saveConfig.sectionSaveModes, formData]); // 다중 행 저장 (겸직 등) const saveMultipleRows = useCallback(async () => { diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx index 7d716b2e..2607cf83 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -11,9 +11,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; -import { Plus, Trash2, Database, Layers } from "lucide-react"; +import { Plus, Trash2, Database, Layers, Info } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig } from "../types"; +import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types"; // 도움말 텍스트 컴포넌트 const HelpText = ({ children }: { children: React.ReactNode }) => ( @@ -235,6 +236,96 @@ export function SaveSettingsModal({ const allFields = getAllFields(); + // 섹션별 저장 방식 조회 (없으면 기본값 반환) + const getSectionSaveMode = (sectionId: string, sectionType: "fields" | "table"): "common" | "individual" => { + const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId); + if (sectionMode) { + return sectionMode.saveMode; + } + // 기본값: fields 타입은 공통 저장, table 타입은 개별 저장 + return sectionType === "fields" ? "common" : "individual"; + }; + + // 필드별 저장 방식 조회 (오버라이드 확인) + const getFieldSaveMode = (sectionId: string, fieldName: string, sectionType: "fields" | "table"): "common" | "individual" => { + const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId); + if (sectionMode) { + // 필드별 오버라이드 확인 + const fieldOverride = sectionMode.fieldOverrides?.find((f) => f.fieldName === fieldName); + if (fieldOverride) { + return fieldOverride.saveMode; + } + return sectionMode.saveMode; + } + // 기본값 + return sectionType === "fields" ? "common" : "individual"; + }; + + // 섹션별 저장 방식 업데이트 + const updateSectionSaveMode = (sectionId: string, mode: "common" | "individual") => { + const currentModes = localSaveConfig.sectionSaveModes || []; + const existingIndex = currentModes.findIndex((s) => s.sectionId === sectionId); + + let newModes: SectionSaveMode[]; + if (existingIndex >= 0) { + newModes = [...currentModes]; + newModes[existingIndex] = { ...newModes[existingIndex], saveMode: mode }; + } else { + newModes = [...currentModes, { sectionId, saveMode: mode }]; + } + + updateSaveConfig({ sectionSaveModes: newModes }); + }; + + // 필드별 오버라이드 토글 + const toggleFieldOverride = (sectionId: string, fieldName: string, sectionType: "fields" | "table") => { + const currentModes = localSaveConfig.sectionSaveModes || []; + const sectionIndex = currentModes.findIndex((s) => s.sectionId === sectionId); + + // 섹션 설정이 없으면 먼저 생성 + let newModes = [...currentModes]; + if (sectionIndex < 0) { + const defaultMode = sectionType === "fields" ? "common" : "individual"; + newModes.push({ sectionId, saveMode: defaultMode, fieldOverrides: [] }); + } + + const targetIndex = newModes.findIndex((s) => s.sectionId === sectionId); + const sectionMode = newModes[targetIndex]; + const currentFieldOverrides = sectionMode.fieldOverrides || []; + const fieldOverrideIndex = currentFieldOverrides.findIndex((f) => f.fieldName === fieldName); + + let newFieldOverrides; + if (fieldOverrideIndex >= 0) { + // 이미 오버라이드가 있으면 제거 (섹션 기본값으로 돌아감) + newFieldOverrides = currentFieldOverrides.filter((f) => f.fieldName !== fieldName); + } else { + // 오버라이드 추가 (섹션 기본값의 반대) + const oppositeMode = sectionMode.saveMode === "common" ? "individual" : "common"; + newFieldOverrides = [...currentFieldOverrides, { fieldName, saveMode: oppositeMode }]; + } + + newModes[targetIndex] = { ...sectionMode, fieldOverrides: newFieldOverrides }; + updateSaveConfig({ sectionSaveModes: newModes }); + }; + + // 섹션의 필드 목록 가져오기 + const getSectionFields = (section: FormSectionConfig): { fieldName: string; label: string }[] => { + if (section.type === "table" && section.tableConfig) { + // 테이블 타입: tableConfig.columns에서 필드 목록 가져오기 + return (section.tableConfig.columns || []).map((col) => ({ + fieldName: col.field, + label: col.label, + })); + } else if (section.fields) { + // 필드 타입: fields에서 목록 가져오기 + return section.fields.map((field) => ({ + fieldName: field.columnName, + label: field.label, + })); + } + return []; + }; + return ( @@ -724,6 +815,150 @@ export function SaveSettingsModal({ )} + {/* 섹션별 저장 방식 */} +
+
+ +

섹션별 저장 방식

+
+ + {/* 설명 */} +
+
+ +
+

+ 공통 저장: 이 섹션의 필드 값이 모든 품목 행에 동일하게 저장됩니다 +
+ 예: 수주번호, 거래처, 수주일 - 품목이 3개면 3개 행 모두 같은 값 +

+

+ 개별 저장: 이 섹션의 필드 값이 각 품목마다 다르게 저장됩니다 +
+ 예: 품목코드, 수량, 단가 - 품목마다 다른 값 +

+
+
+
+ + {/* 섹션 목록 */} + {sections.length === 0 ? ( +
+

섹션이 없습니다

+
+ ) : ( + + {sections.map((section) => { + const sectionType = section.type || "fields"; + const currentMode = getSectionSaveMode(section.id, sectionType); + const sectionFields = getSectionFields(section); + + return ( + + +
+
+ {section.title} + + {sectionType === "table" ? "테이블" : "필드"} + +
+ + {currentMode === "common" ? "공통 저장" : "개별 저장"} + +
+
+ + {/* 저장 방식 선택 */} +
+ + updateSectionSaveMode(section.id, value as "common" | "individual")} + className="flex gap-4" + > +
+ + +
+
+ + +
+
+
+ + {/* 필드 목록 */} + {sectionFields.length > 0 && ( + <> + +
+ + 필드를 클릭하면 섹션 기본값과 다르게 설정할 수 있습니다 +
+ {sectionFields.map((field) => { + const fieldMode = getFieldSaveMode(section.id, field.fieldName, sectionType); + const isOverridden = fieldMode !== currentMode; + + return ( + + ); + })} +
+
+ + )} +
+
+ ); + })} +
+ )} +
+ {/* 저장 후 동작 */}

저장 후 동작

diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 3bfa771b..4e25f7d7 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -507,6 +507,21 @@ export interface MultiRowSaveConfig { mainSectionFields?: string[]; // 메인 행에만 저장할 필드 } +/** + * 섹션별 저장 방식 설정 + * 공통 저장: 해당 섹션의 필드 값이 모든 품목 행에 동일하게 저장됩니다 (예: 수주번호, 거래처) + * 개별 저장: 해당 섹션의 필드 값이 각 품목마다 다르게 저장됩니다 (예: 품목코드, 수량, 단가) + */ +export interface SectionSaveMode { + sectionId: string; + saveMode: "common" | "individual"; // 공통 저장 / 개별 저장 + // 필드별 세부 설정 (선택사항 - 섹션 기본값과 다르게 설정할 필드) + fieldOverrides?: { + fieldName: string; + saveMode: "common" | "individual"; + }[]; +} + // 저장 설정 export interface SaveConfig { tableName: string; @@ -518,6 +533,9 @@ export interface SaveConfig { // 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용) customApiSave?: CustomApiSaveConfig; + // 섹션별 저장 방식 설정 + sectionSaveModes?: SectionSaveMode[]; + // 저장 후 동작 (간편 설정) showToast?: boolean; // 토스트 메시지 (기본: true) refreshParent?: boolean; // 부모 새로고침 (기본: true) diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 19a41a52..d2adf4cd 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -675,6 +675,14 @@ export class ButtonActionExecutor { console.log("⚠️ [handleSave] formData 전체 내용:", context.formData); } + // 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리 + // 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장 + const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData); + if (universalFormModalResult.handled) { + console.log("✅ [handleSave] Universal Form Modal 테이블 섹션 저장 완료"); + return universalFormModalResult.success; + } + // 폼 유효성 검사 if (config.validateForm) { const validation = this.validateFormData(formData); @@ -1479,6 +1487,122 @@ export class ButtonActionExecutor { } } + /** + * 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리 + * 범용_폼_모달 내부의 공통 필드 + _tableSection_ 데이터를 병합하여 품목별로 저장 + */ + private static async handleUniversalFormModalTableSectionSave( + config: ButtonActionConfig, + context: ButtonActionContext, + formData: Record, + ): Promise<{ handled: boolean; success: boolean }> { + const { tableName, screenId } = context; + + // 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음) + const universalFormModalKey = Object.keys(formData).find((key) => { + const value = formData[key]; + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + // _tableSection_ 키가 있는지 확인 + return Object.keys(value).some((k) => k.startsWith("_tableSection_")); + }); + + if (!universalFormModalKey) { + return { handled: false, success: false }; + } + + console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey); + + const modalData = formData[universalFormModalKey]; + + // _tableSection_ 데이터 추출 + const tableSectionData: Record = {}; + const commonFieldsData: Record = {}; + + for (const [key, value] of Object.entries(modalData)) { + if (key.startsWith("_tableSection_")) { + const sectionId = key.replace("_tableSection_", ""); + tableSectionData[sectionId] = value as any[]; + } else if (!key.startsWith("_")) { + // _로 시작하지 않는 필드는 공통 필드로 처리 + commonFieldsData[key] = value; + } + } + + console.log("🎯 [handleUniversalFormModalTableSectionSave] 데이터 분리:", { + commonFields: Object.keys(commonFieldsData), + tableSections: Object.keys(tableSectionData), + tableSectionCounts: Object.entries(tableSectionData).map(([k, v]) => ({ [k]: v.length })), + }); + + // 테이블 섹션 데이터가 없으면 처리하지 않음 + const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0); + if (!hasTableSectionData) { + console.log("⚠️ [handleUniversalFormModalTableSectionSave] 테이블 섹션 데이터 없음 - 일반 저장으로 전환"); + return { handled: false, success: false }; + } + + try { + // 사용자 정보 추가 + if (!context.userId) { + throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요."); + } + + const userInfo = { + writer: context.userId, + created_by: context.userId, + updated_by: context.userId, + company_code: context.companyCode || "", + }; + + let totalSaved = 0; + + // 각 테이블 섹션의 품목별로 저장 + for (const [sectionId, items] of Object.entries(tableSectionData)) { + console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 저장 시작: ${items.length}개 품목`); + + for (const item of items) { + // 공통 필드 + 품목 데이터 병합 + const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; + + // 내부 메타데이터 제거 + Object.keys(rowToSave).forEach((key) => { + if (key.startsWith("_")) { + delete rowToSave[key]; + } + }); + + console.log("📝 [handleUniversalFormModalTableSectionSave] 저장할 행:", rowToSave); + + // INSERT 실행 + const saveResult = await DynamicFormApi.saveFormData({ + screenId: screenId!, + tableName: tableName!, + data: rowToSave, + }); + + if (!saveResult.success) { + throw new Error(saveResult.message || "품목 저장 실패"); + } + + totalSaved++; + } + } + + console.log(`✅ [handleUniversalFormModalTableSectionSave] 총 ${totalSaved}개 행 저장 완료`); + toast.success(`${totalSaved}개 항목이 저장되었습니다.`); + + // 저장 성공 이벤트 발생 + window.dispatchEvent(new CustomEvent("saveSuccess")); + window.dispatchEvent(new CustomEvent("refreshTable")); + + return { handled: true, success: true }; + } catch (error: any) { + console.error("❌ [handleUniversalFormModalTableSectionSave] 저장 오류:", error); + toast.error(error.message || "저장 중 오류가 발생했습니다."); + return { handled: true, success: false }; + } + } + /** * 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조) * ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장