diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 746e2c2d..2a5d45e4 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -74,6 +74,9 @@ import "./location-swap-selector/LocationSwapSelectorRenderer"; // ๐Ÿ†• ํ™”๋ฉด ์ž„๋ฒ ๋”ฉ ๋ฐ ๋ถ„ํ•  ํŒจ๋„ ์ปดํฌ๋„ŒํŠธ import "./screen-split-panel/ScreenSplitPanelRenderer"; // ํ™”๋ฉด ๋ถ„ํ•  ํŒจ๋„ (์ขŒ์šฐ ํ™”๋ฉด ์ž„๋ฒ ๋”ฉ + ๋ฐ์ดํ„ฐ ์ „๋‹ฌ) +// ๐Ÿ†• ๋ฒ”์šฉ ํผ ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ +import "./universal-form-modal/UniversalFormModalRenderer"; // ์„น์…˜ ๊ธฐ๋ฐ˜ ํผ, ์ฑ„๋ฒˆ๊ทœ์น™, ๋‹ค์ค‘ ํ–‰ ์ €์žฅ ์ง€์› + /** * ์ปดํฌ๋„ŒํŠธ ์ดˆ๊ธฐํ™” ํ•จ์ˆ˜ */ diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx new file mode 100644 index 00000000..c4501f6b --- /dev/null +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -0,0 +1,951 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } 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 } 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 [saving, setSaving] = useState(false); + + // ์‚ญ์ œ ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ + const [deleteDialog, setDeleteDialog] = useState<{ + open: boolean; + sectionId: string; + itemId: string; + }>({ open: false, sectionId: "", itemId: "" }); + + // ์ดˆ๊ธฐํ™” + useEffect(() => { + initializeForm(); + }, [config, initialData]); + + // ํผ ์ดˆ๊ธฐํ™” + const initializeForm = useCallback(async () => { + 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 ?? ""; + + // ๋ถ€๋ชจ์—์„œ ์ „๋‹ฌ๋ฐ›์€ ๊ฐ’ ์ ์šฉ + if (field.receiveFromParent && initialData) { + const parentField = field.parentFieldName || field.columnName; + if (initialData[parentField] !== undefined) { + value = initialData[parentField]; + } + } + + newFormData[field.columnName] = value; + } + } + } + + setFormData(newFormData); + setRepeatSections(newRepeatSections); + setCollapsedSections(newCollapsed); + setOriginalData(initialData || {}); + + // ์ฑ„๋ฒˆ๊ทœ์น™ ์ž๋™ ์ƒ์„ฑ + await generateNumberingValues(newFormData); + }, [config, initialData]); + + // ๋ฐ˜๋ณต ์„น์…˜ ์•„์ดํ…œ ์ƒ์„ฑ + 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?.(newData); + 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) { + const response = await apiClient.get(`/table-management/tables/${optionConfig.tableName}/data`, { + params: { limit: 1000 }, + }); + if (response.data?.success && response.data?.data) { + options = response.data.data.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 handleSave = useCallback(async () => { + if (!config.saveConfig.tableName) { + toast.error("์ €์žฅํ•  ํ…Œ์ด๋ธ”์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."); + return; + } + + setSaving(true); + + try { + const { multiRowSave } = config.saveConfig; + + 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?.(formData); + } catch (error: any) { + console.error("์ €์žฅ ์‹คํŒจ:", error); + toast.error(error.message || "์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); + } finally { + setSaving(false); + } + }, [config, formData, repeatSections, onSave]); + + // ๋‹จ์ผ ํ–‰ ์ €์žฅ + const saveSingleRow = async () => { + const dataToSave = { ...formData }; + + // ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํ•„๋“œ ์ œ๊ฑฐ + Object.keys(dataToSave).forEach((key) => { + if (key.startsWith("_")) { + delete dataToSave[key]; + } + }); + + // ์ €์žฅ ์‹œ์  ์ฑ„๋ฒˆ๊ทœ์น™ ์ฒ˜๋ฆฌ + for (const section of config.sections) { + for (const field of section.fields) { + if ( + field.numberingRule?.enabled && + field.numberingRule?.generateOnSave && + field.numberingRule?.ruleId && + !dataToSave[field.columnName] + ) { + const response = await generateNumberingCode(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 || "์ €์žฅ ์‹คํŒจ"); + } + }; + + // ๋‹ค์ค‘ ํ–‰ ์ €์žฅ (๊ฒธ์ง ๋“ฑ) + const saveMultipleRows = async () => { + const { multiRowSave } = config.saveConfig; + if (!multiRowSave) return; + + const { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields } = + multiRowSave; + + // ๊ณตํ†ต ํ•„๋“œ ๋ฐ์ดํ„ฐ ์ถ”์ถœ + const commonData: Record = {}; + for (const fieldName of commonFields) { + if (formData[fieldName] !== undefined) { + commonData[fieldName] = formData[fieldName]; + } + } + + // ๋ฉ”์ธ ์„น์…˜ ํ•„๋“œ ๋ฐ์ดํ„ฐ ์ถ”์ถœ + const mainSectionData: Record = {}; + if (mainSectionFields) { + for (const fieldName of mainSectionFields) { + if (formData[fieldName] !== undefined) { + mainSectionData[fieldName] = formData[fieldName]; + } + } + } + + // ์ €์žฅํ•  ํ–‰๋“ค ์ค€๋น„ + const rowsToSave: Record[] = []; + + // 1. ๋ฉ”์ธ ํ–‰ ์ƒ์„ฑ + const mainRow: Record = { + ...commonData, + ...mainSectionData, + }; + if (typeColumn) { + mainRow[typeColumn] = mainTypeValue || "main"; + } + rowsToSave.push(mainRow); + + // 2. ๋ฐ˜๋ณต ์„น์…˜ ํ–‰๋“ค ์ƒ์„ฑ (๊ฒธ์ง ๋“ฑ) + const repeatItems = repeatSections[repeatSectionId] || []; + for (const item of repeatItems) { + const subRow: Record = { ...commonData }; + + // ๋ฐ˜๋ณต ์„น์…˜ ํ•„๋“œ ๋ณต์‚ฌ + Object.keys(item).forEach((key) => { + if (!key.startsWith("_")) { + subRow[key] = item[key]; + } + }); + + 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?.generateOnSave && field.numberingRule?.ruleId) { + const response = await generateNumberingCode(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 (const row of rowsToSave) { + const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, row); + + if (!response.data?.success) { + throw new Error(response.data?.message || "์ €์žฅ ์‹คํŒจ"); + } + } + + console.log(`[UniversalFormModal] ${rowsToSave.length}๊ฐœ ํ–‰ ์ €์žฅ ์™„๋ฃŒ`); + }; + + // ํผ ์ดˆ๊ธฐํ™” + const handleReset = useCallback(() => { + initializeForm(); + toast.info("ํผ์ด ์ดˆ๊ธฐํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + }, [initializeForm]); + + // ํ•„๋“œ ๋ Œ๋”๋ง + const renderField = (field: FormFieldConfig, value: any, onChangeHandler: (value: any) => void, fieldKey: string) => { + const isDisabled = field.disabled || (field.numberingRule?.enabled && !field.numberingRule?.editable); + const isHidden = field.numberingRule?.hidden; + + if (isHidden) { + return null; + } + + const fieldElement = (() => { + switch (field.fieldType) { + case "textarea": + return ( +