diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 7ba5c47e..4a9b53a4 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -898,9 +898,10 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { - // 순번 (현재 순번으로 미리보기, 증가 안 함) + // 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시) const length = autoConfig.sequenceLength || 3; - return String(rule.currentSequence || 1).padStart(length, "0"); + const nextSequence = (rule.currentSequence || 0) + 1; + return String(nextSequence).padStart(length, "0"); } case "number": { diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 0a87db3f..e955fddc 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -176,7 +176,7 @@ export const EditModal: React.FC = ({ className }) => { loadGroupData(); } } - }, [modalState.isOpen, modalState.screenId]); + }, [modalState.isOpen, modalState.screenId, modalState.groupByColumns, modalState.tableName]); // 🆕 그룹 데이터 조회 함수 const loadGroupData = async () => { @@ -225,7 +225,7 @@ export const EditModal: React.FC = ({ className }) => { const dataArray = Array.isArray(response) ? response : response?.data || []; if (dataArray.length > 0) { - console.log("✅ 그룹 데이터 조회 성공:", dataArray); + console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건"); setGroupData(dataArray); setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`); @@ -751,15 +751,8 @@ export const EditModal: React.FC = ({ className }) => { }, }; - // 🔍 디버깅: 컴포넌트 렌더링 시점의 groupData 확인 - if (component.id === screenData.components[0]?.id) { - console.log("🔍 [EditModal] InteractiveScreenViewerDynamic props:", { - componentId: component.id, - groupDataLength: groupData.length, - groupData: groupData, - formData: groupData.length > 0 ? groupData[0] : formData, - }); - } + + const groupedDataProp = groupData.length > 0 ? groupData : undefined; // 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가 const enrichedFormData = { @@ -811,7 +804,7 @@ export const EditModal: React.FC = ({ className }) => { onSave={handleSave} isInModal={true} // 🆕 그룹 데이터를 ModalRepeaterTable에 전달 - groupedData={groupData.length > 0 ? groupData : undefined} + groupedData={groupedDataProp} // 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처) disabledFields={["order_no", "partner_id"]} /> diff --git a/frontend/components/screen/FloatingPanel.tsx b/frontend/components/screen/FloatingPanel.tsx index cddb053b..d6efd8b1 100644 --- a/frontend/components/screen/FloatingPanel.tsx +++ b/frontend/components/screen/FloatingPanel.tsx @@ -267,7 +267,7 @@ export const FloatingPanel: React.FC = ({ {/* 컨텐츠 */}
= ({ }; return ( -
+

{definition.name} 설정

@@ -998,7 +998,7 @@ export const DetailSettingsPanel: React.FC = ({
{/* 설정 패널 영역 */} -
{renderComponentConfigPanel()}
+
{renderComponentConfigPanel()}
); } @@ -1156,8 +1156,8 @@ export const DetailSettingsPanel: React.FC = ({
{/* 컴포넌트 설정 패널 */} -
-
+
+
{/* DynamicComponentConfigPanel */} = ({
{/* 상세 설정 영역 */} -
-
+
+
{console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)} {/* 🆕 자동 입력 섹션 */}
diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index b039ac38..4d12309b 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -317,7 +317,7 @@ export const DynamicComponentRenderer: React.FC = // modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화 let currentValue; - if (componentType === "modal-repeater-table") { + if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") { // EditModal에서 전달된 groupedData가 있으면 우선 사용 currentValue = props.groupedData || formData?.[fieldName] || []; } else { @@ -449,7 +449,8 @@ export const DynamicComponentRenderer: React.FC = // 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable) // Note: 이 props들은 DOM 요소에 전달되면 안 됨 // 각 컴포넌트에서 명시적으로 destructure하여 사용해야 함 - _groupedData: props.groupedData, + groupedData: props.groupedData, // ✅ 언더스코어 제거하여 직접 전달 + _groupedData: props.groupedData, // 하위 호환성 유지 // 🆕 UniversalFormModal용 initialData 전달 // originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨) _initialData: originalData || formData, diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 410fd9a6..56e9a321 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -36,6 +36,71 @@ export function RepeaterTable({ // 동적 데이터 소스 Popover 열림 상태 const [openPopover, setOpenPopover] = useState(null); + + // 컬럼 너비 상태 관리 + const [columnWidths, setColumnWidths] = useState>(() => { + const widths: Record = {}; + columns.forEach((col) => { + widths[col.field] = col.width ? parseInt(col.width) : 120; + }); + return widths; + }); + + // 기본 너비 저장 (리셋용) + const defaultWidths = React.useMemo(() => { + const widths: Record = {}; + columns.forEach((col) => { + widths[col.field] = col.width ? parseInt(col.width) : 120; + }); + return widths; + }, [columns]); + + // 리사이즈 상태 + const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null); + + // 리사이즈 핸들러 + const handleMouseDown = (e: React.MouseEvent, field: string) => { + e.preventDefault(); + setResizing({ + field, + startX: e.clientX, + startWidth: columnWidths[field] || 120, + }); + }; + + // 더블클릭으로 기본 너비로 리셋 + const handleDoubleClick = (field: string) => { + setColumnWidths((prev) => ({ + ...prev, + [field]: defaultWidths[field] || 120, + })); + }; + + useEffect(() => { + if (!resizing) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!resizing) return; + const diff = e.clientX - resizing.startX; + const newWidth = Math.max(60, resizing.startWidth + diff); + setColumnWidths((prev) => ({ + ...prev, + [resizing.field]: newWidth, + })); + }; + + const handleMouseUp = () => { + setResizing(null); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [resizing, columns, data]); // 데이터 변경 감지 (필요시 활성화) // useEffect(() => { @@ -79,7 +144,7 @@ export function RepeaterTable({ onChange={(e) => handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0) } - className="h-7 text-xs" + className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none" /> ); @@ -107,7 +172,7 @@ export function RepeaterTable({ type="date" value={formatDateValue(value)} onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)} - className="h-7 text-xs" + className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none" /> ); @@ -119,7 +184,7 @@ export function RepeaterTable({ handleCellEdit(rowIndex, column.field, newValue) } > - + @@ -138,19 +203,19 @@ export function RepeaterTable({ type="text" value={value || ""} onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)} - className="h-7 text-xs" + className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none" /> ); } }; return ( -
-
- - +
+
+
+ - {columns.map((col) => { @@ -163,101 +228,113 @@ export function RepeaterTable({ return ( + "inline-flex items-center gap-1 hover:text-blue-600 transition-colors", + "focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded px-1 -mx-1" + )} + > + {col.label} + + + + +
+ 데이터 소스 선택 +
+ {col.dynamicDataSource!.options.map((option) => ( + + ))} +
+ + ) : ( + <> + {col.label} + {col.required && *} + + )} + + {/* 리사이즈 핸들 */} +
handleMouseDown(e, col.field)} + title="드래그하여 너비 조정" + /> +
+ ); })} -
- + {data.length === 0 ? ( ) : ( data.map((row, rowIndex) => ( - - + {columns.map((col) => ( - ))} -
+ # handleDoubleClick(col.field)} + title="더블클릭하여 기본 너비로 되돌리기" > - {hasDynamicSource ? ( - setOpenPopover(open ? col.field : null)} - > - - - - -
- 데이터 소스 선택 -
- {col.dynamicDataSource!.options.map((option) => ( - - ))} -
-
- ) : ( - <> - {col.label} - {col.required && *} - - )} -
+ 삭제
추가된 항목이 없습니다
+
{rowIndex + 1} + {renderCell(row, col, rowIndex)} + diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index 6bca0181..0c2edc4e 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -2517,7 +2517,7 @@ function LayoutRowConfigModal({ {/* 외부 데이터 소스 설정 */} -
+
{row.tableDataSource?.enabled && ( -
+ <>
- + />
-
+ + {/* 조인 조건 설정 */} +
+
+ +

+ 두 테이블을 연결하는 키를 설정합니다 +

+
+ {(row.tableDataSource?.joinConditions || []).map((condition, conditionIndex) => ( +
+
+ 조인 {conditionIndex + 1} + +
+ +
+
+ + { + const newConditions = [...(row.tableDataSource?.joinConditions || [])]; + newConditions[conditionIndex] = { ...condition, sourceKey: value }; + onUpdateRow({ + tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions } + }); + }} + placeholder="예: sales_order_id" + /> +

+ 외부 테이블의 컬럼 +

+
+ +
+ + { + const newConditions = [...(row.tableDataSource?.joinConditions || [])]; + newConditions[conditionIndex] = { ...condition, referenceKey: value }; + onUpdateRow({ + tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions } + }); + }} + placeholder="예: id" + /> +

+ 메인 테이블의 컬럼 +

+
+
+ +
+

+ {row.tableDataSource?.sourceTable}.{condition.sourceKey} = {dataSourceTable}.{condition.referenceKey} +

+

+ 외부 테이블에서 메인 테이블의 값과 일치하는 데이터를 가져옵니다 +

+
+
+ ))} + + +
+ + {/* 필터 설정 */} +
+
+
+ +

+ 특정 조건으로 데이터를 제외합니다 +

+
+ { + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + enabled: checked, + filterField: "", + filterType: "notEquals", + referenceField: "", + referenceSource: "representativeData", + }, + }, + }); + }} + className="scale-75" + /> +
+ + {row.tableDataSource?.filterConfig?.enabled && ( +
+
+
+ + { + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + filterField: value, + }, + }, + }); + }} + placeholder="예: order_no" + /> +

+ 외부 테이블에서 비교할 컬럼 +

+
+ +
+ + { + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + referenceField: value, + }, + }, + }); + }} + placeholder="예: order_no" + /> +

+ 현재 선택한 행의 컬럼 +

+
+
+ +
+ + +
+ +
+

+ {row.tableDataSource?.sourceTable}.{row.tableDataSource?.filterConfig?.filterField} != 현재행.{row.tableDataSource?.filterConfig?.referenceField} +

+

+ {row.tableDataSource?.filterConfig?.filterType === "notEquals" + ? "현재 선택한 행과 다른 데이터만 표시합니다" + : "현재 선택한 행과 같은 데이터만 표시합니다"} +

+
+
+ )} +
+ )}
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 89838c67..a78d2e95 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -216,14 +216,24 @@ export function UniversalFormModalComponent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행 - // config 변경 시에만 재초기화 (initialData 변경은 무시) + // config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외 useEffect(() => { if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵 - initializeForm(); + console.log('[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)'); + // initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지) // eslint-disable-next-line react-hooks/exhaustive-deps }, [config]); + // 컴포넌트 unmount 시 채번 플래그 초기화 + useEffect(() => { + return () => { + console.log('[채번] 컴포넌트 unmount - 플래그 초기화'); + numberingGeneratedRef.current = false; + isGeneratingRef.current = false; + }; + }, []); + // 🆕 beforeFormSave 이벤트 리스너 - ButtonPrimary 저장 시 formData를 전달 // 설정된 필드(columnName)만 병합하여 의도치 않은 덮어쓰기 방지 useEffect(() => { @@ -301,6 +311,8 @@ export function UniversalFormModalComponent({ // 폼 초기화 const initializeForm = useCallback(async () => { + console.log('[initializeForm] 시작'); + // 캡처된 initialData 사용 (props로 전달된 initialData가 아닌) const effectiveInitialData = capturedInitialData.current || initialData; @@ -351,7 +363,9 @@ export function UniversalFormModalComponent({ setOriginalData(effectiveInitialData || {}); // 채번규칙 자동 생성 + console.log('[initializeForm] generateNumberingValues 호출'); await generateNumberingValues(newFormData); + console.log('[initializeForm] 완료'); // eslint-disable-next-line react-hooks/exhaustive-deps }, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용) @@ -369,9 +383,26 @@ export function UniversalFormModalComponent({ return item; }; - // 채번규칙 자동 생성 + // 채번규칙 자동 생성 (중복 호출 방지) + const numberingGeneratedRef = useRef(false); + const isGeneratingRef = useRef(false); // 진행 중 플래그 추가 + const generateNumberingValues = useCallback( async (currentFormData: FormDataState) => { + // 이미 생성되었거나 진행 중이면 스킵 + if (numberingGeneratedRef.current) { + console.log('[채번] 이미 생성됨 - 스킵'); + return; + } + + if (isGeneratingRef.current) { + console.log('[채번] 생성 진행 중 - 스킵'); + return; + } + + isGeneratingRef.current = true; // 진행 중 표시 + console.log('[채번] 생성 시작'); + const updatedData = { ...currentFormData }; let hasChanges = false; @@ -386,10 +417,14 @@ export function UniversalFormModalComponent({ !updatedData[field.columnName] ) { try { - const response = await generateNumberingCode(field.numberingRule.ruleId); + console.log(`[채번 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`); + // generateOnOpen: 모달 열 때 실제 순번 할당 (DB 시퀀스 즉시 증가) + const response = await allocateNumberingCode(field.numberingRule.ruleId); if (response.success && response.data?.generatedCode) { updatedData[field.columnName] = response.data.generatedCode; hasChanges = true; + numberingGeneratedRef.current = true; // 생성 완료 표시 + console.log(`[채번 완료] ${field.columnName} = ${response.data.generatedCode}`); } } catch (error) { console.error(`채번규칙 생성 실패 (${field.columnName}):`, error); @@ -398,6 +433,8 @@ export function UniversalFormModalComponent({ } } + isGeneratingRef.current = false; // 진행 완료 + if (hasChanges) { setFormData(updatedData); } @@ -629,23 +666,16 @@ export function UniversalFormModalComponent({ } }); - // 저장 시점 채번규칙 처리 (allocateNumberingCode로 실제 순번 증가) + // 저장 시점 채번규칙 처리 (generateOnSave만 처리) 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; - } + if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) { + const response = await allocateNumberingCode(field.numberingRule.ruleId); + if (response.success && response.data?.generatedCode) { + dataToSave[field.columnName] = response.data.generatedCode; + console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode}`); + } else { + console.error(`[채번 실패] ${field.columnName}:`, response.error); } } } diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 48542342..656f3f1a 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -4,13 +4,9 @@ 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 { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Plus, @@ -21,37 +17,27 @@ import { 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"; +// 모달 import +import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal"; +import { SaveSettingsModal } from "./modals/SaveSettingsModal"; +import { SectionLayoutModal } from "./modals/SectionLayoutModal"; + // 도움말 텍스트 컴포넌트 const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

@@ -67,16 +53,15 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor // 채번규칙 목록 const [numberingRules, setNumberingRules] = useState<{ id: string; name: string }[]>([]); - // 선택된 섹션/필드 - const [selectedSectionId, setSelectedSectionId] = useState(null); - const [selectedFieldId, setSelectedFieldId] = useState(null); - - // 테이블 선택 Combobox 상태 - const [tableSelectOpen, setTableSelectOpen] = useState(false); + // 모달 상태 + const [saveSettingsModalOpen, setSaveSettingsModalOpen] = useState(false); + const [sectionLayoutModalOpen, setSectionLayoutModalOpen] = useState(false); + const [fieldDetailModalOpen, setFieldDetailModalOpen] = useState(false); + const [selectedSection, setSelectedSection] = useState(null); + const [selectedField, setSelectedField] = useState(null); // 테이블 목록 로드 useEffect(() => { - console.log("[UniversalFormModal ConfigPanel] 초기화 - 테이블 및 채번규칙 로드"); loadTables(); loadNumberingRules(); }, []); @@ -86,47 +71,8 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor 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"); @@ -145,18 +91,13 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor }; 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; + const data = response.data?.data; if (response.data?.success && Array.isArray(data)) { - console.log(`[UniversalFormModal] 파싱된 컬럼 ${data.length}개:`, data); setTableColumns((prev) => ({ ...prev, [tableName]: data.map( @@ -165,36 +106,27 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor column_name?: string; dataType?: string; data_type?: string; - columnLabel?: string; - column_label?: string; - name?: string; + columnComment?: string; + column_comment?: 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 || "", + name: c.columnName || c.column_name || "", + type: c.dataType || c.data_type || "text", + label: c.columnComment || c.column_comment || c.columnName || c.column_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); + const data = response?.data; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data: any = response.data; - - if (response.success && Array.isArray(data) && data.length > 0) { + if (response?.success && Array.isArray(data)) { const rules = data.map( (r: { id?: string | number; @@ -208,13 +140,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor 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); + console.error("채번규칙 목록 로드 실패:", error); } }; @@ -229,16 +158,6 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor [config, onChange], ); - const updateSaveConfig = useCallback( - (updates: Partial) => { - onChange({ - ...config, - saveConfig: { ...config.saveConfig, ...updates }, - }); - }, - [config, onChange], - ); - // 섹션 관리 const addSection = useCallback(() => { const newSection: FormSectionConfig = { @@ -250,7 +169,6 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor ...config, sections: [...config.sections, newSection], }); - setSelectedSectionId(newSection.id); }, [config, onChange]); const updateSection = useCallback( @@ -269,12 +187,8 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor ...config, sections: config.sections.filter((s) => s.id !== sectionId), }); - if (selectedSectionId === sectionId) { - setSelectedSectionId(null); - setSelectedFieldId(null); - } }, - [config, onChange, selectedSectionId], + [config, onChange], ); const moveSectionUp = useCallback( @@ -297,99 +211,71 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor [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 getFieldTypeColor = (fieldType: FormFieldConfig["fieldType"]): string => { + switch (fieldType) { + case "text": + case "email": + case "password": + case "tel": + return "text-blue-600 bg-blue-50 border-blue-200"; + case "number": + return "text-cyan-600 bg-cyan-50 border-cyan-200"; + case "date": + case "datetime": + return "text-purple-600 bg-purple-50 border-purple-200"; + case "select": + return "text-green-600 bg-green-50 border-green-200"; + case "checkbox": + return "text-pink-600 bg-pink-50 border-pink-200"; + case "textarea": + return "text-orange-600 bg-orange-50 border-orange-200"; + default: + return "text-gray-600 bg-gray-50 border-gray-200"; + } + }; - 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 handleOpenSectionLayout = (section: FormSectionConfig) => { + setSelectedSection(section); + setSectionLayoutModalOpen(true); + }; - 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] || []; + // 필드 상세 설정 모달 열기 + const handleOpenFieldDetail = (section: FormSectionConfig, field: FormFieldConfig) => { + setSelectedSection(section); + setSelectedField(field); + setFieldDetailModalOpen(true); + }; return ( - -
+
+
+
{/* 모달 기본 설정 */} - - - -
- - 모달 기본 설정 + + + +
+ + 모달 기본 설정
- -
- + +
+ updateModalConfig({ title: e.target.value })} - placeholder="모달 제목 입력" - className="h-7 text-xs mt-1" + className="h-9 text-sm w-full max-w-full" /> + 모달 상단에 표시될 제목입니다
-
- - updateModalConfig({ size: value })}> + @@ -400,1906 +286,263 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor ))} + 모달 창의 크기를 선택하세요
-
-
- 저장 버튼 표시 - updateModalConfig({ showSaveButton: checked })} +
+
+ + updateModalConfig({ saveButtonText: e.target.value })} + className="h-9 text-sm w-full max-w-full" + /> +
+
+ + updateModalConfig({ cancelButtonText: e.target.value })} + className="h-9 text-sm w-full max-w-full" />
- 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}개 로드됨 + +

+
+ +

+ {config.saveConfig.tableName || "(미설정)"}

- )} - - )} -
- - {/* 다중 행 저장 설정 - 전용 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.saveConfig.customApiSave?.enabled && config.saveConfig.customApiSave?.multiTable?.enabled && ( + + 다중 테이블 모드 + + )} +
+
+ + 데이터를 저장할 테이블과 방식을 설정합니다. +
+ "저장 설정 열기"를 클릭하여 상세 설정을 변경하세요. +
- {/* 섹션 관리 */} - - - -
- - 섹션 관리 ({config.sections.length}) + {/* 섹션 구성 */} + + + +
+ + 섹션 구성 + + {config.sections.length}개 +
- - + + 폼을 여러 섹션으로 나누어 구성할 수 있습니다. +
+ 예: 기본 정보, 배송 정보, 결제 정보 +
- {config.sections.map((section, sectionIndex) => ( - { - setSelectedSectionId(section.id); - setSelectedFieldId(null); - }} - > - -
-
- - {section.title} - {section.repeatable && ( - 반복 - )} -
-
+ {config.sections.length === 0 ? ( +
+

섹션이 없습니다

+

"섹션 추가" 버튼으로 폼 섹션을 만드세요

+
+ ) : ( +
+ {config.sections.map((section, index) => ( +
+ {/* 헤더: 제목 + 삭제 */} +
+
+
+ {section.title} + {section.repeatable && ( + + 반복 + + )} +
+ + {section.fields.length}개 필드 + +
+ - -
+ + {/* 순서 조정 버튼 */} +
+ +
+ + +
+
+ + {/* 필드 목록 */} + {section.fields.length > 0 && ( +
+ {section.fields.slice(0, 4).map((field) => ( + + {field.label} + + ))} + {section.fields.length > 4 && ( + + +{section.fields.length - 4} + + )} +
+ )} + + {/* 레이아웃 설정 버튼 */} +
- 필드 {section.fields.length}개 - - - ))} + ))} +
+ )} - - {/* 선택된 섹션 설정 */} - {selectedSection && ( - - - -
- - 섹션: {selectedSection.title} -
-
- -
- - updateSection(selectedSection.id, { title: e.target.value })} - className="h-7 text-xs mt-1" - /> -
- -
- -