"use client"; 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"; import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; 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, allocateNumberingCode } from "@/lib/api/numberingRule"; import { UniversalFormModalComponentProps, UniversalFormModalConfig, FormSectionConfig, FormFieldConfig, FormDataState, RepeatSectionItem, SelectOptionConfig, } from "./types"; import { defaultConfig, generateUniqueId } from "./config"; /** * 범용 폼 모달 컴포넌트 * * 섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원합니다. */ export function UniversalFormModalComponent({ component, config: propConfig, isDesignMode = false, isSelected = false, className, style, initialData, onSave, onCancel, onChange, }: UniversalFormModalComponentProps) { // 설정 병합 const config: UniversalFormModalConfig = useMemo(() => { const componentConfig = component?.config || {}; return { ...defaultConfig, ...propConfig, ...componentConfig, modal: { ...defaultConfig.modal, ...propConfig?.modal, ...componentConfig.modal, }, saveConfig: { ...defaultConfig.saveConfig, ...propConfig?.saveConfig, ...componentConfig.saveConfig, multiRowSave: { ...defaultConfig.saveConfig.multiRowSave, ...propConfig?.saveConfig?.multiRowSave, ...componentConfig.saveConfig?.multiRowSave, }, afterSave: { ...defaultConfig.saveConfig.afterSave, ...propConfig?.saveConfig?.afterSave, ...componentConfig.saveConfig?.afterSave, }, }, }; }, [component?.config, propConfig]); // 폼 데이터 상태 const [formData, setFormData] = useState({}); const [, setOriginalData] = useState>({}); // 반복 섹션 데이터 const [repeatSections, setRepeatSections] = useState<{ [sectionId: string]: RepeatSectionItem[]; }>({}); // 섹션 접힘 상태 const [collapsedSections, setCollapsedSections] = useState>(new Set()); // Select 옵션 캐시 const [selectOptionsCache, setSelectOptionsCache] = useState<{ [key: string]: { value: string; label: string }[]; }>({}); // 연동 필드 그룹 데이터 캐시 (테이블별 데이터) const [linkedFieldDataCache, setLinkedFieldDataCache] = useState<{ [tableKey: string]: Record[]; }>({}); // 로딩 상태 const [saving, setSaving] = useState(false); // 삭제 확인 다이얼로그 const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; sectionId: string; itemId: string; }>({ open: false, sectionId: "", itemId: "" }); // 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시) const capturedInitialData = useRef | 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(); // 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(() => { const loadData = async () => { const tablesToLoad = new Set(); // 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집 config.sections.forEach((section) => { section.fields.forEach((field) => { if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) { tablesToLoad.add(field.linkedFieldGroup.sourceTable); } }); }); // 각 테이블 데이터 로드 for (const tableName of tablesToLoad) { if (!linkedFieldDataCache[tableName]) { await loadLinkedFieldData(tableName); } } }; loadData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.sections]); // 폼 초기화 const initializeForm = useCallback(async () => { // 캡처된 initialData 사용 (props로 전달된 initialData가 아닌) const effectiveInitialData = capturedInitialData.current || initialData; const newFormData: FormDataState = {}; const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {}; const newCollapsed = new Set(); // 섹션별 초기화 for (const section of config.sections) { // 접힘 상태 초기화 if (section.defaultCollapsed) { newCollapsed.add(section.id); } if (section.repeatable) { // 반복 섹션 초기화 const minItems = section.repeatConfig?.minItems || 0; const items: RepeatSectionItem[] = []; for (let i = 0; i < minItems; i++) { items.push(createRepeatItem(section, i)); } newRepeatSections[section.id] = items; } else { // 일반 섹션 필드 초기화 for (const field of section.fields) { // 기본값 설정 let value = field.defaultValue ?? ""; // 부모에서 전달받은 값 적용 (receiveFromParent 또는 effectiveInitialData에 해당 값이 있으면) if (effectiveInitialData) { const parentField = field.parentFieldName || field.columnName; if (effectiveInitialData[parentField] !== undefined) { // receiveFromParent가 true이거나, effectiveInitialData에 값이 있으면 적용 if (field.receiveFromParent || value === "" || value === undefined) { value = effectiveInitialData[parentField]; } } } newFormData[field.columnName] = value; } } } setFormData(newFormData); setRepeatSections(newRepeatSections); setCollapsedSections(newCollapsed); setOriginalData(effectiveInitialData || {}); // 채번규칙 자동 생성 await generateNumberingValues(newFormData); // eslint-disable-next-line react-hooks/exhaustive-deps }, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용) // 반복 섹션 아이템 생성 const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => { const item: RepeatSectionItem = { _id: generateUniqueId("repeat"), _index: index, }; for (const field of section.fields) { item[field.columnName] = field.defaultValue ?? ""; } return item; }; // 채번규칙 자동 생성 const generateNumberingValues = useCallback( async (currentFormData: FormDataState) => { const updatedData = { ...currentFormData }; let hasChanges = false; for (const section of config.sections) { if (section.repeatable) continue; for (const field of section.fields) { if ( field.numberingRule?.enabled && field.numberingRule?.generateOnOpen && field.numberingRule?.ruleId && !updatedData[field.columnName] ) { try { const response = await generateNumberingCode(field.numberingRule.ruleId); if (response.success && response.data?.generatedCode) { updatedData[field.columnName] = response.data.generatedCode; hasChanges = true; } } catch (error) { console.error(`채번규칙 생성 실패 (${field.columnName}):`, error); } } } } if (hasChanges) { setFormData(updatedData); } }, [config], ); // 필드 값 변경 핸들러 const handleFieldChange = useCallback( (columnName: string, value: any) => { setFormData((prev) => { const newData = { ...prev, [columnName]: value }; // onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용) if (onChange) { setTimeout(() => onChange(newData), 0); } return newData; }); }, [onChange], ); // 반복 섹션 필드 값 변경 핸들러 const handleRepeatFieldChange = useCallback((sectionId: string, itemId: string, columnName: string, value: any) => { setRepeatSections((prev) => { const items = prev[sectionId] || []; const newItems = items.map((item) => (item._id === itemId ? { ...item, [columnName]: value } : item)); return { ...prev, [sectionId]: newItems }; }); }, []); // 반복 섹션 아이템 추가 const handleAddRepeatItem = useCallback( (sectionId: string) => { const section = config.sections.find((s) => s.id === sectionId); if (!section) return; const maxItems = section.repeatConfig?.maxItems || 10; setRepeatSections((prev) => { const items = prev[sectionId] || []; if (items.length >= maxItems) { toast.error(`최대 ${maxItems}개까지만 추가할 수 있습니다.`); return prev; } const newItem = createRepeatItem(section, items.length); return { ...prev, [sectionId]: [...items, newItem] }; }); }, [config], ); // 반복 섹션 아이템 삭제 const handleRemoveRepeatItem = useCallback( (sectionId: string, itemId: string) => { const section = config.sections.find((s) => s.id === sectionId); if (!section) return; const minItems = section.repeatConfig?.minItems || 0; setRepeatSections((prev) => { const items = prev[sectionId] || []; if (items.length <= minItems) { toast.error(`최소 ${minItems}개는 유지해야 합니다.`); return prev; } const newItems = items.filter((item) => item._id !== itemId).map((item, index) => ({ ...item, _index: index })); return { ...prev, [sectionId]: newItems }; }); setDeleteDialog({ open: false, sectionId: "", itemId: "" }); }, [config], ); // 섹션 접힘 토글 const toggleSectionCollapse = useCallback((sectionId: string) => { setCollapsedSections((prev) => { const newSet = new Set(prev); if (newSet.has(sectionId)) { newSet.delete(sectionId); } else { newSet.add(sectionId); } return newSet; }); }, []); // Select 옵션 로드 const loadSelectOptions = useCallback( async (fieldId: string, optionConfig: SelectOptionConfig): Promise<{ value: string; label: string }[]> => { // 캐시 확인 if (selectOptionsCache[fieldId]) { return selectOptionsCache[fieldId]; } let options: { value: string; label: string }[] = []; try { if (optionConfig.type === "static") { options = optionConfig.staticOptions || []; } else if (optionConfig.type === "table" && optionConfig.tableName) { // POST 방식으로 테이블 데이터 조회 (autoFilter 포함) const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, { page: 1, size: 1000, autoFilter: { enabled: true, filterColumn: "company_code" }, }); // 응답 데이터 파싱 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) { options = response.data.data.map((code: any) => ({ value: code.code_value || code.codeValue, label: code.code_name || code.codeName, })); } } // 캐시 저장 setSelectOptionsCache((prev) => ({ ...prev, [fieldId]: options })); } catch (error) { console.error(`Select 옵션 로드 실패 (${fieldId}):`, error); } return options; }, [selectOptionsCache], ); // 연동 필드 그룹 데이터 로드 const loadLinkedFieldData = useCallback( async (sourceTable: string): Promise[]> => { // 캐시 확인 - 이미 배열로 캐시되어 있으면 반환 if (Array.isArray(linkedFieldDataCache[sourceTable]) && linkedFieldDataCache[sourceTable].length > 0) { return linkedFieldDataCache[sourceTable]; } let data: Record[] = []; try { console.log(`[연동필드] ${sourceTable} 데이터 로드 시작`); // 현재 회사 기준으로 데이터 조회 (POST 메서드, autoFilter 사용) const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, { page: 1, size: 1000, autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링 }); console.log(`[연동필드] ${sourceTable} API 응답:`, response.data); if (response.data?.success) { // data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] } const responseData = response.data?.data; if (Array.isArray(responseData)) { // 직접 배열인 경우 data = responseData; } else if (responseData?.data && Array.isArray(responseData.data)) { // { data: [...], total: ... } 형태 (tableManagementService 응답) data = responseData.data; } else if (responseData?.rows && Array.isArray(responseData.rows)) { // { rows: [...], total: ... } 형태 (다른 API 응답) data = responseData.rows; } console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3)); } // 캐시 저장 (빈 배열이라도 저장하여 중복 요청 방지) setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: data })); } catch (error) { console.error(`연동 필드 데이터 로드 실패 (${sourceTable}):`, error); // 실패해도 빈 배열로 캐시하여 무한 요청 방지 setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: [] })); } return data; }, [linkedFieldDataCache], ); // 필수 필드 검증 const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { const missingFields: string[] = []; for (const section of config.sections) { if (section.repeatable) continue; // 반복 섹션은 별도 검증 for (const field of section.fields) { if (field.required && !field.hidden && !field.numberingRule?.hidden) { const value = formData[field.columnName]; if (value === undefined || value === null || value === "") { missingFields.push(field.label || field.columnName); } } } } return { valid: missingFields.length === 0, missingFields }; }, [config.sections, formData]); // 단일 행 저장 const saveSingleRow = useCallback(async () => { const dataToSave = { ...formData }; // 메타데이터 필드 제거 Object.keys(dataToSave).forEach((key) => { if (key.startsWith("_")) { delete dataToSave[key]; } }); // 저장 시점 채번규칙 처리 (allocateNumberingCode로 실제 순번 증가) for (const section of config.sections) { for (const field of section.fields) { if ( field.numberingRule?.enabled && field.numberingRule?.ruleId ) { // 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; } } } } } const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave); if (!response.data?.success) { throw new Error(response.data?.message || "저장 실패"); } }, [config.sections, config.saveConfig.tableName, 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) 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; if (!customApiSave?.multiTable) return; const { multiTable } = customApiSave; // 1. 메인 테이블 데이터 구성 const mainData: Record = {}; 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; } }); }); // 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당) for (const section of config.sections) { if (section.repeatable) continue; 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[]; 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[] = []; const repeatData = repeatSections[subTableConfig.repeatSectionId] || []; // 반복 섹션 데이터를 필드 매핑에 따라 변환 for (const item of repeatData) { const mappedItem: Record = {}; // 연결 컬럼 값 설정 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); } } // 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 ); } subTablesData.push({ tableName: subTableConfig.tableName, linkColumn: subTableConfig.linkColumn, items: subItems, options: { ...subTableConfig.options, mainFieldMappings, // 메인 데이터 매핑 추가 }, }); } // 3. 범용 다중 테이블 저장 API 호출 const response = await apiClient.post("/table-management/multi-table-save", { mainTable: multiTable.mainTable, mainData, subTables: subTablesData, isUpdate: !!initialData?.[multiTable.mainTable.primaryKeyColumn], }); 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) { 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 }); } } 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 = ( field: FormFieldConfig, value: any, onChangeHandler: (value: any) => void, fieldKey: string, isDisabled: boolean, repeatContext?: { sectionId: string; itemId: string }, ) => { return (() => { switch (field.fieldType) { case "textarea": return (