"use client"; import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings, Database, Layout, Hash, Check, ChevronsUpDown, } from "lucide-react"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { apiClient } from "@/lib/api/client"; import { getNumberingRules } from "@/lib/api/numberingRule"; import { UniversalFormModalConfig, UniversalFormModalConfigPanelProps, FormSectionConfig, FormFieldConfig, LinkedFieldMapping, FIELD_TYPE_OPTIONS, MODAL_SIZE_OPTIONS, SELECT_OPTION_TYPE_OPTIONS, LINKED_FIELD_DISPLAY_FORMAT_OPTIONS, } from "./types"; import { defaultFieldConfig, defaultSectionConfig, defaultNumberingRuleConfig, defaultSelectOptionsConfig, generateSectionId, generateFieldId, } from "./config"; // 도움말 텍스트 컴포넌트 const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFormModalConfigPanelProps) { // 테이블 목록 const [tables, setTables] = useState<{ name: string; label: string }[]>([]); const [tableColumns, setTableColumns] = useState<{ [tableName: string]: { name: string; type: string; label: string }[]; }>({}); // 채번규칙 목록 const [numberingRules, setNumberingRules] = useState<{ id: string; name: string }[]>([]); // 선택된 섹션/필드 const [selectedSectionId, setSelectedSectionId] = useState(null); const [selectedFieldId, setSelectedFieldId] = useState(null); // 테이블 선택 Combobox 상태 const [tableSelectOpen, setTableSelectOpen] = useState(false); // 테이블 목록 로드 useEffect(() => { console.log("[UniversalFormModal ConfigPanel] 초기화 - 테이블 및 채번규칙 로드"); loadTables(); loadNumberingRules(); }, []); // 저장 테이블 변경 시 컬럼 로드 useEffect(() => { if (config.saveConfig.tableName) { loadTableColumns(config.saveConfig.tableName); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.saveConfig.tableName]); // 다중 컬럼 저장의 소스 테이블 컬럼 로드 useEffect(() => { const allSourceTables = new Set(); config.sections.forEach((section) => { // 필드 레벨의 linkedFieldGroup 확인 section.fields.forEach((field) => { if (field.linkedFieldGroup?.sourceTable) { allSourceTables.add(field.linkedFieldGroup.sourceTable); } }); }); allSourceTables.forEach((tableName) => { if (!tableColumns[tableName]) { loadTableColumns(tableName); } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.sections]); // 다중 테이블 저장 설정의 메인/서브 테이블 컬럼 로드 useEffect(() => { const customApiSave = config.saveConfig.customApiSave; if (customApiSave?.enabled && customApiSave?.multiTable) { // 메인 테이블 컬럼 로드 const mainTableName = customApiSave.multiTable.mainTable?.tableName; if (mainTableName && !tableColumns[mainTableName]) { loadTableColumns(mainTableName); } // 서브 테이블들 컬럼 로드 customApiSave.multiTable.subTables?.forEach((subTable) => { if (subTable.tableName && !tableColumns[subTable.tableName]) { loadTableColumns(subTable.tableName); } }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.saveConfig.customApiSave]); const loadTables = async () => { try { const response = await apiClient.get("/table-management/tables"); const data = response.data?.data; if (response.data?.success && Array.isArray(data)) { setTables( data.map((t: { tableName?: string; table_name?: string; tableLabel?: string; table_label?: string }) => ({ name: t.tableName || t.table_name || "", label: t.tableLabel || t.table_label || t.tableName || t.table_name || "", })), ); } } catch (error) { console.error("테이블 목록 로드 실패:", error); } }; const loadTableColumns = async (tableName: string) => { console.log(`[UniversalFormModal] 테이블 컬럼 로드 시도: ${tableName}`); if (!tableName || (tableColumns[tableName] && tableColumns[tableName].length > 0)) return; try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); console.log("[UniversalFormModal] 테이블 컬럼 응답:", response.data); // API 응답 구조: { success: true, data: { columns: [...] } } const data = response.data?.data?.columns || response.data?.data; if (response.data?.success && Array.isArray(data)) { console.log(`[UniversalFormModal] 파싱된 컬럼 ${data.length}개:`, data); setTableColumns((prev) => ({ ...prev, [tableName]: data.map( (c: { columnName?: string; column_name?: string; dataType?: string; data_type?: string; columnLabel?: string; column_label?: string; name?: string; }) => ({ name: c.columnName || c.column_name || c.name || "", type: c.dataType || c.data_type || "", label: c.columnLabel || c.column_label || c.columnName || c.column_name || c.name || "", }), ), })); } else { console.warn("[UniversalFormModal] 컬럼 데이터 없음 또는 형식 오류:", response.data); setTableColumns((prev) => ({ ...prev, [tableName]: [] })); } } catch (error) { console.error(`테이블 컬럼 로드 실패 (${tableName}):`, error); setTableColumns((prev) => ({ ...prev, [tableName]: [] })); } }; const loadNumberingRules = async () => { try { console.log("[UniversalFormModal] 채번규칙 로드 시도"); const response = await getNumberingRules(); console.log("[UniversalFormModal] 채번규칙 응답:", response); // eslint-disable-next-line @typescript-eslint/no-explicit-any const data: any = response.data; if (response.success && Array.isArray(data) && data.length > 0) { const rules = data.map( (r: { id?: string | number; ruleId?: string; rule_id?: string; name?: string; ruleName?: string; rule_name?: string; }) => ({ id: String(r.id || r.ruleId || r.rule_id || ""), name: r.name || r.ruleName || r.rule_name || "", }), ); console.log("[UniversalFormModal] 파싱된 채번규칙:", rules); setNumberingRules(rules); } else { console.warn("[UniversalFormModal] 채번규칙 데이터 없음:", data); } } catch (error) { console.error("[UniversalFormModal] 채번규칙 목록 로드 실패:", error); } }; // 설정 업데이트 헬퍼 const updateModalConfig = useCallback( (updates: Partial) => { onChange({ ...config, modal: { ...config.modal, ...updates }, }); }, [config, onChange], ); const updateSaveConfig = useCallback( (updates: Partial) => { onChange({ ...config, saveConfig: { ...config.saveConfig, ...updates }, }); }, [config, onChange], ); // 섹션 관리 const addSection = useCallback(() => { const newSection: FormSectionConfig = { ...defaultSectionConfig, id: generateSectionId(), title: `섹션 ${config.sections.length + 1}`, }; onChange({ ...config, sections: [...config.sections, newSection], }); setSelectedSectionId(newSection.id); }, [config, onChange]); const updateSection = useCallback( (sectionId: string, updates: Partial) => { onChange({ ...config, sections: config.sections.map((s) => (s.id === sectionId ? { ...s, ...updates } : s)), }); }, [config, onChange], ); const removeSection = useCallback( (sectionId: string) => { onChange({ ...config, sections: config.sections.filter((s) => s.id !== sectionId), }); if (selectedSectionId === sectionId) { setSelectedSectionId(null); setSelectedFieldId(null); } }, [config, onChange, selectedSectionId], ); const moveSectionUp = useCallback( (index: number) => { if (index <= 0) return; const newSections = [...config.sections]; [newSections[index - 1], newSections[index]] = [newSections[index], newSections[index - 1]]; onChange({ ...config, sections: newSections }); }, [config, onChange], ); const moveSectionDown = useCallback( (index: number) => { if (index >= config.sections.length - 1) return; const newSections = [...config.sections]; [newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]]; onChange({ ...config, sections: newSections }); }, [config, onChange], ); // 필드 관리 const addField = useCallback( (sectionId: string) => { const newField: FormFieldConfig = { ...defaultFieldConfig, id: generateFieldId(), label: "새 필드", numberingRule: { ...defaultNumberingRuleConfig }, selectOptions: { ...defaultSelectOptionsConfig }, }; onChange({ ...config, sections: config.sections.map((s) => s.id === sectionId ? { ...s, fields: [...s.fields, newField] } : s, ), }); setSelectedSectionId(sectionId); setSelectedFieldId(newField.id); }, [config, onChange], ); const updateField = useCallback( (sectionId: string, fieldId: string, updates: Partial) => { onChange({ ...config, sections: config.sections.map((s) => s.id === sectionId ? { ...s, fields: s.fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)), } : s, ), }); }, [config, onChange], ); const removeField = useCallback( (sectionId: string, fieldId: string) => { onChange({ ...config, sections: config.sections.map((s) => s.id === sectionId ? { ...s, fields: s.fields.filter((f) => f.id !== fieldId) } : s, ), }); if (selectedFieldId === fieldId) { setSelectedFieldId(null); } }, [config, onChange, selectedFieldId], ); // 선택된 섹션/필드 가져오기 const selectedSection = config.sections.find((s) => s.id === selectedSectionId); const selectedField = selectedSection?.fields.find((f) => f.id === selectedFieldId); // 현재 테이블의 컬럼 목록 const currentColumns = tableColumns[config.saveConfig.tableName] || []; return (
{/* 모달 기본 설정 */}
모달 기본 설정
updateModalConfig({ title: e.target.value })} placeholder="모달 제목 입력" className="h-7 text-xs mt-1" />
저장 버튼 표시 updateModalConfig({ showSaveButton: checked })} />
ButtonPrimary 컴포넌트로 저장 버튼을 별도 구성할 경우 끄세요 {config.modal.showSaveButton !== false && (
updateModalConfig({ saveButtonText: e.target.value })} className="h-7 text-xs mt-1" />
)}
{/* 저장 설정 */}
저장 설정
{/* 저장 테이블 - Combobox */}
{config.saveConfig.customApiSave?.enabled ? (
전용 API 저장 모드에서는 API가 테이블 저장을 처리합니다. {config.saveConfig.customApiSave?.apiType === "user-with-dept" && ( 대상 테이블: user_info + user_dept )}
) : ( <> 테이블을 찾을 수 없습니다 {tables.map((t) => ( { updateSaveConfig({ tableName: t.name }); setTableSelectOpen(false); }} className="text-xs" > {t.name} {t.label !== t.name && ( ({t.label}) )} ))} {config.saveConfig.tableName && (

컬럼 {currentColumns.length}개 로드됨

)} )}
{/* 다중 행 저장 설정 - 전용 API 모드에서는 숨김 */} {!config.saveConfig.customApiSave?.enabled && (
다중 행 저장 updateSaveConfig({ multiRowSave: { ...config.saveConfig.multiRowSave, enabled: checked }, }) } />
겸직처럼 하나의 폼에서 여러 행을 저장할 때 사용합니다. {config.saveConfig.multiRowSave?.enabled && (
{/* 공통 필드 선택 */}
{config.sections .filter((s) => !s.repeatable) .flatMap((s) => s.fields) .map((field) => ( ))}
메인 행과 겸직 행 모두에 저장될 필드
{/* 메인 섹션 필드 선택 */}
{config.sections .filter((s) => !s.repeatable) .flatMap((s) => s.fields) .filter((field) => !config.saveConfig.multiRowSave?.commonFields?.includes(field.columnName)) .map((field) => ( ))}
메인 행에만 저장될 필드 (공통 필드 제외)
{/* 반복 섹션 선택 */}
겸직 등 반복 데이터가 있는 섹션
)}
)} {/* 다중 테이블 저장 설정 (범용) */}
다중 테이블 저장 updateSaveConfig({ customApiSave: { ...config.saveConfig.customApiSave, enabled: checked, apiType: "multi-table", multiTable: checked ? { enabled: true, mainTable: { tableName: config.saveConfig.tableName || "", primaryKeyColumn: "" }, subTables: [], } : undefined, }, }) } />
메인 테이블 + 서브 테이블(반복 섹션)에 트랜잭션으로 저장합니다.
예: 사원+부서, 주문+주문상세, 프로젝트+멤버 등
{config.saveConfig.customApiSave?.enabled && (
{/* API 타입 선택 */}
{/* 다중 테이블 저장 설정 */} {config.saveConfig.customApiSave?.apiType === "multi-table" && (
{/* 메인 테이블 설정 */}
비반복 섹션의 데이터가 저장될 메인 테이블입니다.
테이블을 찾을 수 없습니다 {tables.map((table) => ( { updateSaveConfig({ customApiSave: { ...config.saveConfig.customApiSave, multiTable: { ...config.saveConfig.customApiSave?.multiTable, enabled: true, mainTable: { ...config.saveConfig.customApiSave?.multiTable?.mainTable, tableName: table.name, }, subTables: config.saveConfig.customApiSave?.multiTable?.subTables || [], }, }, }); // 테이블 컬럼 로드 if (!tableColumns[table.name]) { loadTableColumns(table.name); } }} className="text-[10px]" >
{table.label || table.name} {table.label && {table.name}}
))}
서브 테이블과 연결할 때 사용할 PK 컬럼
{/* 서브 테이블 설정 */}
반복 섹션의 데이터가 저장될 서브 테이블을 설정합니다. {(config.saveConfig.customApiSave?.multiTable?.subTables || []).map((subTable, subIndex) => (
서브 테이블 #{subIndex + 1}
{/* 서브 테이블명 */}
테이블을 찾을 수 없습니다 {tables.map((table) => ( { const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; newSubTables[subIndex] = { ...newSubTables[subIndex], tableName: table.name }; updateSaveConfig({ customApiSave: { ...config.saveConfig.customApiSave, multiTable: { ...config.saveConfig.customApiSave?.multiTable, enabled: true, mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, subTables: newSubTables, }, }, }); // 테이블 컬럼 로드 if (!tableColumns[table.name]) { loadTableColumns(table.name); } }} className="text-[9px]" >
{table.label || table.name} {table.label && {table.name}}
))}
{/* 반복 섹션 선택 */}
서브 테이블에 저장할 데이터가 있는 반복 섹션
{/* 연결 컬럼 설정 */}
메인 테이블의 PK와 서브 테이블의 FK를 연결
{/* 메인 테이블 컬럼 선택 (PK 컬럼 기준) */}
{/* 서브 테이블 컬럼 선택 (FK 컬럼) */}
메인 테이블과 서브 테이블을 연결할 컬럼
{/* 필드 매핑 */} {subTable.repeatSectionId && subTable.tableName && (
{(subTable.fieldMappings || []).map((mapping, mapIndex) => { const repeatSection = config.sections.find((s) => s.id === subTable.repeatSectionId); const sectionFields = repeatSection?.fields || []; return (
매핑 #{mapIndex + 1}
); })}
)} {/* 추가 옵션 */}
{ const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; newSubTables[subIndex] = { ...newSubTables[subIndex], options: { ...newSubTables[subIndex].options, saveMainAsFirst: !!checked }, }; updateSaveConfig({ customApiSave: { ...config.saveConfig.customApiSave, multiTable: { ...config.saveConfig.customApiSave?.multiTable, enabled: true, mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, subTables: newSubTables, }, }, }); }} className="shrink-0" />
{subTable.options?.saveMainAsFirst && (
메인/서브 구분용 컬럼 (예: is_primary)
)}
{ const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; newSubTables[subIndex] = { ...newSubTables[subIndex], options: { ...newSubTables[subIndex].options, deleteExistingBefore: !!checked }, }; updateSaveConfig({ customApiSave: { ...config.saveConfig.customApiSave, multiTable: { ...config.saveConfig.customApiSave?.multiTable, enabled: true, mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, subTables: newSubTables, }, }, }); }} className="shrink-0" />
))} {(config.saveConfig.customApiSave?.multiTable?.subTables || []).length === 0 && (

서브 테이블을 추가하세요

)}
)} {/* 커스텀 API 설정 */} {config.saveConfig.customApiSave?.apiType === "custom" && (
updateSaveConfig({ customApiSave: { ...config.saveConfig.customApiSave, customEndpoint: e.target.value }, }) } placeholder="/api/custom/endpoint" className="h-6 text-[10px] mt-1" />
)}
)}
{/* 저장 후 동작 */}
모달 닫기 updateSaveConfig({ afterSave: { ...config.saveConfig.afterSave, closeModal: checked }, }) } />
부모 화면 새로고침 updateSaveConfig({ afterSave: { ...config.saveConfig.afterSave, refreshParent: checked }, }) } />
토스트 메시지 표시 updateSaveConfig({ afterSave: { ...config.saveConfig.afterSave, showToast: checked }, }) } />
{/* 섹션 관리 */}
섹션 관리 ({config.sections.length})
{config.sections.map((section, sectionIndex) => ( { setSelectedSectionId(section.id); setSelectedFieldId(null); }} >
{section.title} {section.repeatable && ( 반복 )}
필드 {section.fields.length}개
))}
{/* 선택된 섹션 설정 */} {selectedSection && (
섹션: {selectedSection.title}
updateSection(selectedSection.id, { title: e.target.value })} className="h-7 text-xs mt-1" />