From 17498b1b2bf5fbc7c8a58f4ddf389d31f4e6659f Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 8 Jan 2026 10:04:05 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20UniversalFormModalComponent=20?= =?UTF-8?q?=EC=9E=90=EC=B2=B4=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20saveSingleRow,=20saveWithCustomApi,=20hand?= =?UTF-8?q?leSave,=20handleReset=20=ED=95=A8=EC=88=98=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20saving=20=EC=83=81=ED=83=9C=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?/=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B2=84=ED=8A=BC=20UI=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20UI=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20ModalConfig=20=ED=83=80=EC=9E=85=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EA=B4=80=EB=A0=A8=20=EC=86=8D=EC=84=B1?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=A0=80=EC=9E=A5=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20button-primary=20(action:=20save)=EB=A1=9C=20?= =?UTF-8?q?=EC=9C=84=EC=9E=84=20=EC=95=BD=20468=EC=A4=84=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UniversalFormModalComponent.tsx | 426 +----------------- .../UniversalFormModalConfigPanel.tsx | 34 -- .../components/universal-form-modal/config.ts | 5 - .../components/universal-form-modal/types.ts | 7 - 4 files changed, 4 insertions(+), 468 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 5da98365..4ee024a4 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -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"; @@ -190,9 +190,6 @@ export function UniversalFormModalComponent({ [tableKey: string]: Record[]; }>({}); - // 로딩 상태 - const [saving, setSaving] = useState(false); - // 채번규칙 원본 값 추적 (수동 모드 감지용) // key: columnName, value: 자동 생성된 원본 값 const [numberingOriginalValues, setNumberingOriginalValues] = useState>({}); @@ -610,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} 검사:`, { @@ -1240,266 +1238,6 @@ export function UniversalFormModalComponent({ return { valid: missingFields.length === 0, missingFields }; }, [config.sections, formData]); - // 단일 행 저장 - const saveSingleRow = useCallback(async () => { - const dataToSave = { ...formData }; - - // 테이블 섹션 데이터 추출 (별도 저장용) - const tableSectionData: Record = {}; - - // 메타데이터 필드 제거 (채번 규칙 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 = {}; - 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 = {}; - 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 saveWithMultiTable = useCallback(async () => { const { customApiSave } = config.saveConfig; @@ -1682,130 +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 { customApiSave } = config.saveConfig; - - // 커스텀 API 저장 모드 (다중 테이블) - if (customApiSave?.enabled) { - await saveWithCustomApi(); - } else { - // 단일 테이블 저장 - await saveSingleRow(); - } - - // 저장 후 동작 - if (config.saveConfig.afterSave?.showToast) { - toast.success("저장되었습니다."); - } - - if (config.saveConfig.afterSave?.refreshParent) { - window.dispatchEvent(new CustomEvent("refreshParentData")); - } - - // onSave 콜백은 저장 완료 알림용으로만 사용 - // 실제 저장은 이미 위에서 완료됨 - // 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, - saveWithCustomApi, - ]); - - // 폼 초기화 - const handleReset = useCallback(() => { - initializeForm(); - toast.info("폼이 초기화되었습니다."); - }, [initializeForm]); - // 필드 요소 렌더링 (입력 컴포넌트만) // repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달 const renderFieldElement = ( @@ -2544,38 +2158,6 @@ export function UniversalFormModalComponent({ {/* 섹션들 */}
{config.sections.map((section) => renderSection(section))}
- {/* 버튼 영역 - 저장 버튼이 표시될 때만 렌더링 */} - {config.modal.showSaveButton !== false && ( -
- {config.modal.showResetButton && ( - - )} - -
- )} - {/* 삭제 확인 다이얼로그 */} 모달 창의 크기를 선택하세요 - - {/* 저장 버튼 표시 설정 */} -
-
- updateModalConfig({ showSaveButton: checked === true })} - /> - -
- 체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다 -
- -
-
- - updateModalConfig({ saveButtonText: e.target.value })} - className="h-9 w-full max-w-full text-sm" - /> -
-
- - updateModalConfig({ cancelButtonText: e.target.value })} - className="h-9 w-full max-w-full text-sm" - /> -
-
diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index 78b1583e..08baf766 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -23,11 +23,6 @@ export const defaultConfig: UniversalFormModalConfig = { size: "lg", closeOnOutsideClick: false, showCloseButton: true, - showSaveButton: true, - saveButtonText: "저장", - cancelButtonText: "취소", - showResetButton: false, - resetButtonText: "초기화", }, sections: [ { diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index db4d5503..25d04ea8 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -786,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; // 초기화 버튼 텍스트 } // 전체 설정