diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 2dc70d0c..eb230454 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -2371,8 +2371,7 @@ export class MenuCopyService { return { copiedCount, ruleIdMap }; } - // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회 - const ruleIds = allRulesResult.rows.map((r) => r.rule_id); + // 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크 필요) const existingRulesResult = await client.query( `SELECT rule_id FROM numbering_rules WHERE company_code = $1`, [targetCompanyCode] @@ -2389,28 +2388,49 @@ export class MenuCopyService { const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = []; for (const rule of allRulesResult.rows) { + // 새 rule_id 계산: 회사코드 접두사 제거 후 대상 회사코드 추가 + // 예: COMPANY_10_rule-123 -> rule-123 -> COMPANY_16_rule-123 + // 예: rule-123 -> rule-123 -> COMPANY_16_rule-123 + // 예: WACE_품목코드 -> 품목코드 -> COMPANY_16_품목코드 + let baseName = rule.rule_id; + + // 회사코드 접두사 패턴들을 순서대로 제거 시도 + // 1. COMPANY_숫자_ 패턴 (예: COMPANY_10_) + // 2. 일반 접두사_ 패턴 (예: WACE_) + if (baseName.match(/^COMPANY_\d+_/)) { + baseName = baseName.replace(/^COMPANY_\d+_/, ""); + } else if (baseName.includes("_")) { + baseName = baseName.replace(/^[^_]+_/, ""); + } + + const newRuleId = `${targetCompanyCode}_${baseName}`; + if (existingRuleIds.has(rule.rule_id)) { - // 기존 규칙은 동일한 ID로 매핑 + // 원본 ID가 이미 존재 (동일한 ID로 매핑) ruleIdMap.set(rule.rule_id, rule.rule_id); - // 새 메뉴 ID로 연결 업데이트 필요 const newMenuObjid = menuIdMap.get(rule.menu_objid); if (newMenuObjid) { rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid }); } + logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`); + } else if (existingRuleIds.has(newRuleId)) { + // 새로 생성될 ID가 이미 존재 (기존 규칙으로 매핑) + ruleIdMap.set(rule.rule_id, newRuleId); + + const newMenuObjid = menuIdMap.get(rule.menu_objid); + if (newMenuObjid) { + rulesToUpdate.push({ ruleId: newRuleId, newMenuObjid }); + } logger.info( - ` ♻️ 채번규칙 이미 존재 (메뉴 연결 갱신): ${rule.rule_id}` + ` ♻️ 채번규칙 이미 존재 (대상 ID): ${rule.rule_id} -> ${newRuleId}` ); } else { - // 새 rule_id 생성 - const originalSuffix = rule.rule_id.includes("_") - ? rule.rule_id.replace(/^[^_]*_/, "") - : rule.rule_id; - const newRuleId = `${targetCompanyCode}_${originalSuffix}`; - + // 새로 복사 필요 ruleIdMap.set(rule.rule_id, newRuleId); originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); rulesToCopy.push({ ...rule, newRuleId }); + logger.info(` 📋 채번규칙 복사 예정: ${rule.rule_id} -> ${newRuleId}`); } } @@ -2425,6 +2445,24 @@ export class MenuCopyService { const ruleParams = rulesToCopy.flatMap((r) => { const newMenuObjid = menuIdMap.get(r.menu_objid); + // scope_type = 'menu'인 경우 menu_objid가 반드시 필요함 (check 제약조건) + // menuIdMap에 없으면 원본 menu_objid가 복사된 메뉴 범위 밖이므로 + // scope_type을 'table'로 변경하거나, 매핑이 없으면 null 처리 + const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null; + // scope_type 결정 로직: + // 1. menu 스코프인데 menu_objid 매핑이 없는 경우 + // - table_name이 있으면 'table' 스코프로 변경 + // - table_name이 없으면 'global' 스코프로 변경 + // 2. 그 외에는 원본 scope_type 유지 + let finalScopeType = r.scope_type; + if (r.scope_type === "menu" && finalMenuObjid === null) { + if (r.table_name) { + finalScopeType = "table"; // table_name이 있으면 table 스코프 + } else { + finalScopeType = "global"; // table_name도 없으면 global 스코프 + } + } + return [ r.newRuleId, r.rule_name, @@ -2436,8 +2474,8 @@ export class MenuCopyService { r.column_name, targetCompanyCode, userId, - newMenuObjid, - r.scope_type, + finalMenuObjid, + finalScopeType, null, ]; }); @@ -2458,8 +2496,11 @@ export class MenuCopyService { // 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 if (rulesToUpdate.length > 0) { // CASE WHEN을 사용한 배치 업데이트 + // menu_objid는 numeric 타입이므로 ::numeric 캐스팅 필요 const caseWhen = rulesToUpdate - .map((_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}`) + .map( + (_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}::numeric` + ) .join(" "); const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId); const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]); diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 1b54d3b9..1969f562 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -976,6 +976,19 @@ export const EditModal: React.FC = ({ className }) => { const groupedDataProp = groupData.length > 0 ? groupData : undefined; + // 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용) + // 최상위 컴포넌트 또는 조건부 컨테이너 내부 화면에 universal-form-modal이 있는지 확인 + const hasUniversalFormModal = screenData.components.some( + (c) => { + // 최상위에 universal-form-modal이 있는 경우 + if (c.componentType === "universal-form-modal") return true; + // 조건부 컨테이너 내부에 universal-form-modal이 있는 경우 + // (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정) + if (c.componentType === "conditional-container") return true; + return false; + } + ); + // 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가 const enrichedFormData = { ...(groupData.length > 0 ? groupData[0] : formData), @@ -1024,7 +1037,9 @@ export const EditModal: React.FC = ({ className }) => { id: modalState.screenId!, tableName: screenData.screenInfo?.tableName, }} - onSave={handleSave} + // 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용) + // ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지) + onSave={hasUniversalFormModal ? undefined : handleSave} isInModal={true} // 🆕 그룹 데이터를 ModalRepeaterTable에 전달 groupedData={groupedDataProp} diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index d5686f6c..59c82421 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -150,46 +150,54 @@ export function ConditionalSectionViewer({ /* 실행 모드: 실제 화면 렌더링 */
{/* 화면 크기만큼의 절대 위치 캔버스 */} -
- {components.map((component) => { - const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; - - return ( -
- -
- ); - })} -
+ {/* UniversalFormModal이 있으면 onSave 전달하지 않음 (자체 저장 로직 사용) */} + {(() => { + const hasUniversalFormModal = components.some( + (c) => c.componentType === "universal-form-modal" + ); + return ( +
+ {components.map((component) => { + const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; + + return ( +
+ +
+ ); + })} +
+ ); + })()}
)} diff --git a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx index 456594c2..ad73c317 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Dialog, DialogContent, @@ -12,9 +12,11 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Search, Loader2 } from "lucide-react"; import { useEntitySearch } from "../entity-search-input/useEntitySearch"; -import { ItemSelectionModalProps } from "./types"; +import { ItemSelectionModalProps, ModalFilterConfig } from "./types"; +import { apiClient } from "@/lib/api/client"; export function ItemSelectionModal({ open, @@ -29,27 +31,134 @@ export function ItemSelectionModal({ uniqueField, onSelect, columnLabels = {}, + modalFilters = [], }: ItemSelectionModalProps) { const [localSearchText, setLocalSearchText] = useState(""); const [selectedItems, setSelectedItems] = useState([]); + + // 모달 필터 값 상태 + const [modalFilterValues, setModalFilterValues] = useState>({}); + + // 카테고리 옵션 상태 (categoryRef별로 로드된 옵션) + const [categoryOptions, setCategoryOptions] = useState>({}); + + // 모달 필터 값과 기본 filterCondition을 합친 최종 필터 조건 + const combinedFilterCondition = useMemo(() => { + const combined = { ...filterCondition }; + + // 모달 필터 값 추가 (빈 값은 제외) + for (const [key, value] of Object.entries(modalFilterValues)) { + if (value !== undefined && value !== null && value !== "") { + combined[key] = value; + } + } + + return combined; + }, [filterCondition, modalFilterValues]); const { results, loading, error, search, clearSearch } = useEntitySearch({ tableName: sourceTable, searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns, - filterCondition, + filterCondition: combinedFilterCondition, }); - // 모달 열릴 때 초기 검색 + // 필터 옵션 로드 - 소스 테이블 컬럼의 distinct 값 조회 + const loadFilterOptions = async (filter: ModalFilterConfig) => { + // 드롭다운 타입만 옵션 로드 필요 (select, category 지원) + const isDropdownType = filter.type === "select" || filter.type === "category"; + if (!isDropdownType) return; + + const cacheKey = `${sourceTable}.${filter.column}`; + + // 이미 로드된 경우 스킵 + if (categoryOptions[cacheKey]) return; + + try { + // 소스 테이블에서 해당 컬럼의 데이터 조회 (POST 메서드 사용) + // 백엔드는 'size' 파라미터를 사용함 + const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, { + page: 1, + size: 10000, // 모든 데이터 조회를 위해 큰 값 설정 + }); + + if (response.data?.success) { + // 응답 구조에 따라 rows 추출 + const rows = response.data.data?.rows || response.data.data?.data || response.data.data || []; + + if (Array.isArray(rows)) { + // 컬럼 값 중복 제거 + const uniqueValues = new Set(); + for (const row of rows) { + const val = row[filter.column]; + if (val !== null && val !== undefined && val !== "") { + uniqueValues.add(String(val)); + } + } + + // 정렬 후 옵션으로 변환 + const options = Array.from(uniqueValues) + .sort() + .map((val) => ({ + value: val, + label: val, + })); + + setCategoryOptions((prev) => ({ + ...prev, + [cacheKey]: options, + })); + } + } + } catch (error) { + console.error(`필터 옵션 로드 실패 (${cacheKey}):`, error); + setCategoryOptions((prev) => ({ + ...prev, + [cacheKey]: [], + })); + } + }; + + // 모달 열릴 때 초기 검색 및 필터 초기화 useEffect(() => { if (open) { + // 모달 필터 기본값 설정 & 옵션 로드 + const initialFilterValues: Record = {}; + for (const filter of modalFilters) { + if (filter.defaultValue !== undefined) { + initialFilterValues[filter.column] = filter.defaultValue; + } + // 드롭다운 타입이면 옵션 로드 (소스 테이블에서 distinct 값 조회) + const isDropdownType = filter.type === "select" || filter.type === "category"; + if (isDropdownType) { + loadFilterOptions(filter); + } + } + setModalFilterValues(initialFilterValues); + search("", 1); // 빈 검색어로 전체 목록 조회 setSelectedItems([]); } else { clearSearch(); setLocalSearchText(""); setSelectedItems([]); + setModalFilterValues({}); } }, [open]); + + // 모달 필터 값 변경 시 재검색 + useEffect(() => { + if (open) { + search(localSearchText, 1); + } + }, [modalFilterValues]); + + // 모달 필터 값 변경 핸들러 + const handleModalFilterChange = (column: string, value: any) => { + setModalFilterValues((prev) => ({ + ...prev, + [column]: value, + })); + }; const handleSearch = () => { search(localSearchText, 1); @@ -202,6 +311,51 @@ export function ItemSelectionModal({ + {/* 모달 필터 */} + {modalFilters.length > 0 && ( +
+ {modalFilters.map((filter) => { + // 소스 테이블의 해당 컬럼에서 로드된 옵션 + const options = categoryOptions[`${sourceTable}.${filter.column}`] || []; + + // 드롭다운 타입인지 확인 (select, category 모두 드롭다운으로 처리) + const isDropdownType = filter.type === "select" || filter.type === "category"; + + return ( +
+ {filter.label}: + {isDropdownType && ( + + )} + {filter.type === "text" && ( + handleModalFilterChange(filter.column, e.target.value)} + placeholder={filter.label} + className="h-7 text-xs w-[120px]" + /> + )} +
+ ); + })} +
+ )} + {/* 선택된 항목 수 */} {selectedItems.length > 0 && (
diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index c7d7c8b6..2caf1332 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -205,6 +205,9 @@ export function ModalRepeaterTableComponent({ const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true; const calculationRules = componentConfig?.calculationRules || propCalculationRules || []; + // 모달 필터 설정 + const modalFilters = componentConfig?.modalFilters || []; + // ✅ value는 formData[columnName] 우선, 없으면 prop 사용 const columnName = component?.columnName; const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || []; @@ -889,6 +892,7 @@ export function ModalRepeaterTableComponent({ uniqueField={uniqueField} onSelect={handleAddItems} columnLabels={columnLabels} + modalFilters={modalFilters} />
); diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx index 7a11bdb1..2e1cf659 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx @@ -9,7 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Plus, X, Check, ChevronsUpDown } from "lucide-react"; -import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types"; +import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep, ModalFilterConfig } from "./types"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { cn } from "@/lib/utils"; @@ -842,6 +842,97 @@ export function ModalRepeaterTableConfigPanel({ /> + + {/* 모달 필터 설정 */} +
+
+ + +
+

+ 모달에서 드롭다운으로 필터링할 컬럼을 설정합니다. 소스 테이블의 해당 컬럼에서 고유 값들이 자동으로 표시됩니다. +

+ {(localConfig.modalFilters || []).length > 0 && ( +
+ {(localConfig.modalFilters || []).map((filter, index) => ( +
+ + { + const filters = [...(localConfig.modalFilters || [])]; + filters[index] = { ...filters[index], label: e.target.value }; + updateConfig({ modalFilters: filters }); + }} + placeholder="라벨" + className="h-8 text-xs w-[100px]" + /> + + +
+ ))} +
+ )} +
{/* 반복 테이블 컬럼 관리 */} diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 9604e7d2..88da4aef 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -31,8 +31,8 @@ import { CSS } from "@dnd-kit/utilities"; // SortableRow 컴포넌트 - 드래그 가능한 테이블 행 interface SortableRowProps { id: string; - children: (props: { - attributes: React.HTMLAttributes; + children: (props: { + attributes: React.HTMLAttributes; listeners: React.HTMLAttributes | undefined; isDragging: boolean; }) => React.ReactNode; @@ -40,14 +40,7 @@ interface SortableRowProps { } function SortableRow({ id, children, className }: SortableRowProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), @@ -93,9 +86,9 @@ export function RepeaterTable({ }: RepeaterTableProps) { // 컨테이너 ref - 실제 너비 측정용 const containerRef = useRef(null); - - // 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤) - const [isEqualizedMode, setIsEqualizedMode] = useState(false); + + // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행) + const initializedRef = useRef(false); // DnD 센서 설정 const sensors = useSensors( @@ -106,7 +99,7 @@ export function RepeaterTable({ }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, - }) + }), ); // 드래그 종료 핸들러 @@ -140,15 +133,15 @@ export function RepeaterTable({ } } }; - + const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string; } | null>(null); - + // 동적 데이터 소스 Popover 열림 상태 const [openPopover, setOpenPopover] = useState(null); - + // 컬럼 너비 상태 관리 const [columnWidths, setColumnWidths] = useState>(() => { const widths: Record = {}; @@ -157,7 +150,7 @@ export function RepeaterTable({ }); return widths; }); - + // 기본 너비 저장 (리셋용) const defaultWidths = React.useMemo(() => { const widths: Record = {}; @@ -166,10 +159,10 @@ export function RepeaterTable({ }); return widths; }, [columns]); - + // 리사이즈 상태 const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null); - + // 리사이즈 핸들러 const handleMouseDown = (e: React.MouseEvent, field: string) => { e.preventDefault(); @@ -178,104 +171,171 @@ export function RepeaterTable({ startX: e.clientX, startWidth: columnWidths[field] || 120, }); - // 수동 조정 시 균등 분배 모드 해제 - setIsEqualizedMode(false); }; - - // 컬럼 확장 상태 추적 (토글용) - const [expandedColumns, setExpandedColumns] = useState>(new Set()); - // 데이터 기준 최적 너비 계산 - const calculateAutoFitWidth = (field: string): number => { - const column = columns.find(col => col.field === field); - if (!column) return 120; + // 컨테이너 가용 너비 계산 + const getAvailableWidth = (): number => { + if (!containerRef.current) return 800; + const containerWidth = containerRef.current.offsetWidth; + // 드래그 핸들(32px) + 체크박스 컬럼(40px) + border(2px) + return containerWidth - 74; + }; - // 헤더 텍스트 길이 (대략 8px per character + padding) - const headerWidth = (column.label?.length || field.length) * 8 + 40; + // 텍스트 너비 계산 (한글/영문/숫자 혼합 고려) + const measureTextWidth = (text: string): number => { + if (!text) return 0; + let width = 0; + for (const char of text) { + if (/[가-힣]/.test(char)) { + width += 15; // 한글 (text-xs 12px 기준) + } else if (/[a-zA-Z]/.test(char)) { + width += 9; // 영문 + } else if (/[0-9]/.test(char)) { + width += 8; // 숫자 + } else if (/[_\-.]/.test(char)) { + width += 6; // 특수문자 + } else if (/[\(\)]/.test(char)) { + width += 6; // 괄호 + } else { + width += 8; // 기타 + } + } + return width; + }; - // 데이터 중 가장 긴 텍스트 찾기 + // 해당 컬럼의 가장 긴 글자 너비 계산 + // equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용) + const calculateColumnContentWidth = (field: string, equalWidth: number): number => { + const column = columns.find((col) => col.field === field); + if (!column) return equalWidth; + + // 날짜 필드는 110px (yyyy-MM-dd) + if (column.type === "date") { + return 110; + } + + // 해당 컬럼에 값이 있는지 확인 + let hasValue = false; let maxDataWidth = 0; - data.forEach(row => { + + data.forEach((row) => { const value = row[field]; - if (value !== undefined && value !== null) { + if (value !== undefined && value !== null && value !== "") { + hasValue = true; let displayText = String(value); - - // 숫자는 천단위 구분자 포함 - if (typeof value === 'number') { + + if (typeof value === "number") { displayText = value.toLocaleString(); } - // 날짜는 yyyy-mm-dd 형식 - if (column.type === 'date' && displayText.includes('T')) { - displayText = displayText.split('T')[0]; - } - - // 대략적인 너비 계산 (8px per character + padding) - const textWidth = displayText.length * 8 + 32; + + const textWidth = measureTextWidth(displayText) + 20; // padding maxDataWidth = Math.max(maxDataWidth, textWidth); } }); - // 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px - const optimalWidth = Math.max(headerWidth, maxDataWidth); - return Math.min(Math.max(optimalWidth, 60), 400); - }; + // 값이 없으면 균등 분배 너비 사용 + if (!hasValue) { + return equalWidth; + } - // 더블클릭으로 auto-fit / 기본 너비 토글 - const handleDoubleClick = (field: string) => { - // 개별 컬럼 조정 시 균등 분배 모드 해제 - setIsEqualizedMode(false); - - setExpandedColumns(prev => { - const newSet = new Set(prev); - if (newSet.has(field)) { - // 확장 상태 → 기본 너비로 복구 - newSet.delete(field); - setColumnWidths(prevWidths => ({ - ...prevWidths, - [field]: defaultWidths[field] || 120, - })); - } else { - // 기본 상태 → 데이터 기준 auto-fit - newSet.add(field); - const autoWidth = calculateAutoFitWidth(field); - setColumnWidths(prevWidths => ({ - ...prevWidths, - [field]: autoWidth, - })); + // 헤더 텍스트 너비 (동적 데이터 소스가 있으면 headerLabel 사용) + let headerText = column.label || field; + if (column.dynamicDataSource?.enabled && column.dynamicDataSource.options.length > 0) { + const activeOptionId = activeDataSources[field] || column.dynamicDataSource.defaultOptionId; + const activeOption = column.dynamicDataSource.options.find((opt) => opt.id === activeOptionId) + || column.dynamicDataSource.options[0]; + if (activeOption?.headerLabel) { + headerText = activeOption.headerLabel; } - return newSet; - }); + } + const headerWidth = measureTextWidth(headerText) + 32; // padding + 드롭다운 아이콘 + + // 헤더와 데이터 중 큰 값 사용 + return Math.max(headerWidth, maxDataWidth); }; - // 균등 분배 트리거 감지 - useEffect(() => { - if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return; - if (!containerRef.current) return; - - // 실제 컨테이너 너비 측정 - const containerWidth = containerRef.current.offsetWidth; - - // 체크박스 컬럼 너비(40px) + 테이블 border(2px) 제외한 가용 너비 계산 - const checkboxColumnWidth = 40; - const borderWidth = 2; - const availableWidth = containerWidth - checkboxColumnWidth - borderWidth; - - // 컬럼 수로 나눠서 균등 분배 (최소 60px 보장) + // 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤 + const handleDoubleClick = (field: string) => { + const availableWidth = getAvailableWidth(); const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); - + const contentWidth = calculateColumnContentWidth(field, equalWidth); + setColumnWidths((prev) => ({ + ...prev, + [field]: contentWidth, + })); + }; + + // 균등 분배: 컬럼 수로 테이블 너비를 균등 분배 + const applyEqualizeWidths = () => { + const availableWidth = getAvailableWidth(); + const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); + const newWidths: Record = {}; columns.forEach((col) => { newWidths[col.field] = equalWidth; }); - + setColumnWidths(newWidths); - setExpandedColumns(new Set()); // 확장 상태 초기화 - setIsEqualizedMode(true); // 균등 분배 모드 활성화 - }, [equalizeWidthsTrigger, columns]); - + }; + + // 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배 + const applyAutoFitWidths = () => { + if (columns.length === 0) return; + + // 균등 분배 너비 계산 (값이 없는 컬럼의 최소값) + const availableWidth = getAvailableWidth(); + const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); + + // 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용) + const newWidths: Record = {}; + columns.forEach((col) => { + newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth); + }); + + // 2. 컨테이너 너비와 비교 + const totalContentWidth = Object.values(newWidths).reduce((sum, w) => sum + w, 0); + + // 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지) + if (totalContentWidth < availableWidth) { + const extraSpace = availableWidth - totalContentWidth; + const extraPerColumn = Math.floor(extraSpace / columns.length); + columns.forEach((col) => { + newWidths[col.field] += extraPerColumn; + }); + } + // 컨테이너보다 크면 그대로 (스크롤 생성됨) + + setColumnWidths(newWidths); + }; + + // 초기 마운트 시 균등 분배 적용 + useEffect(() => { + if (initializedRef.current) return; + if (!containerRef.current || columns.length === 0) return; + + const timer = setTimeout(() => { + applyEqualizeWidths(); + initializedRef.current = true; + }, 100); + + return () => clearTimeout(timer); + }, [columns]); + + // 트리거 감지: 1=균등분배, 2=자동맞춤 + useEffect(() => { + if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return; + + // 홀수면 자동맞춤, 짝수면 균등분배 (토글 방식) + if (equalizeWidthsTrigger % 2 === 1) { + applyAutoFitWidths(); + } else { + applyEqualizeWidths(); + } + }, [equalizeWidthsTrigger]); + useEffect(() => { if (!resizing) return; - + const handleMouseMove = (e: MouseEvent) => { if (!resizing) return; const diff = e.clientX - resizing.startX; @@ -285,14 +345,14 @@ export function RepeaterTable({ [resizing.field]: newWidth, })); }; - + const handleMouseUp = () => { setResizing(null); }; - + document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); - + return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); @@ -336,13 +396,8 @@ export function RepeaterTable({ const isAllSelected = data.length > 0 && selectedRows.size === data.length; const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length; - const renderCell = ( - row: any, - column: RepeaterColumnConfig, - rowIndex: number - ) => { - const isEditing = - editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; + const renderCell = (row: any, column: RepeaterColumnConfig, rowIndex: number) => { + const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const value = row[column.field]; // 계산 필드는 편집 불가 @@ -359,14 +414,8 @@ export function RepeaterTable({ return num.toLocaleString("ko-KR"); } }; - - return ( -
- {column.type === "number" - ? formatNumber(value) - : value || "-"} -
- ); + + return
{column.type === "number" ? formatNumber(value) : value || "-"}
; } // 편집 가능한 필드 @@ -377,22 +426,22 @@ export function RepeaterTable({ if (value === undefined || value === null || value === "") return ""; const num = typeof value === "number" ? value : parseFloat(value); if (isNaN(num)) return ""; - // 정수면 소수점 없이, 소수면 소수점 유지 - if (Number.isInteger(num)) { - return num.toString(); - } else { - return num.toString(); - } + return num.toString(); })(); - + return ( - handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0) - } - className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" + onChange={(e) => { + const val = e.target.value; + // 숫자와 소수점만 허용 + if (val === "" || /^-?\d*\.?\d*$/.test(val)) { + handleCellEdit(rowIndex, column.field, val === "" ? 0 : parseFloat(val) || 0); + } + }} + className="h-8 w-full min-w-0 rounded-none border-gray-200 text-right text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500" /> ); @@ -414,25 +463,21 @@ export function RepeaterTable({ } return String(val); }; - + return ( - handleCellEdit(rowIndex, column.field, e.target.value)} - className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" + onClick={(e) => (e.target as HTMLInputElement).showPicker?.()} + className="h-8 w-full min-w-0 cursor-pointer rounded-none border border-gray-200 bg-white px-2 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-inner-spin-button]:hidden" /> ); case "select": return ( - handleCellEdit(rowIndex, column.field, newValue)}> + @@ -451,7 +496,7 @@ export function RepeaterTable({ type="text" value={value || ""} onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)} - className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" + className="h-8 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500" /> ); } @@ -461,126 +506,124 @@ export function RepeaterTable({ const sortableItems = data.map((_, idx) => `row-${idx}`); return ( - +
-
- +
sum + w, 0) + 74}px)`, + }} > - + {/* 드래그 핸들 헤더 */} - {/* 체크박스 헤더 */} - - {columns.map((col) => { - const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; - const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; - const activeOption = hasDynamicSource - ? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0] - : null; - - const isExpanded = expandedColumns.has(col.field); - - return ( - - ); - })} + + ); + })} @@ -589,7 +632,7 @@ export function RepeaterTable({ @@ -600,19 +643,19 @@ export function RepeaterTable({ key={`row-${rowIndex}`} id={`row-${rowIndex}`} className={cn( - "hover:bg-blue-50/50 transition-colors", - selectedRows.has(rowIndex) && "bg-blue-50" + "transition-colors hover:bg-blue-50/50", + selectedRows.has(rowIndex) && "bg-blue-50", )} > {({ attributes, listeners, isDragging }) => ( <> {/* 드래그 핸들 */} - {/* 체크박스 */} - {/* 데이터 컬럼들 */} {columns.map((col) => ( - @@ -651,4 +697,3 @@ export function RepeaterTable({ ); } - diff --git a/frontend/lib/registry/components/modal-repeater-table/types.ts b/frontend/lib/registry/components/modal-repeater-table/types.ts index 6097aaf3..092c27c6 100644 --- a/frontend/lib/registry/components/modal-repeater-table/types.ts +++ b/frontend/lib/registry/components/modal-repeater-table/types.ts @@ -17,6 +17,7 @@ export interface ModalRepeaterTableProps { modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택") modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색") multiSelect?: boolean; // 다중 선택 허용 (기본: true) + modalFilters?: ModalFilterConfig[]; // 모달 내 필터 설정 // Repeater 테이블 설정 columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정 @@ -75,6 +76,7 @@ export interface DynamicDataSourceConfig { export interface DynamicDataSourceOption { id: string; label: string; // 표시 라벨 (예: "거래처별 단가") + headerLabel?: string; // 헤더에 표시될 전체 라벨 (예: "단가 - 거래처별 단가") // 조회 방식 sourceType: "table" | "multiTable" | "api"; @@ -175,6 +177,14 @@ export interface CalculationRule { dependencies: string[]; // 의존하는 필드들 } +// 모달 필터 설정 (간소화된 버전) +export interface ModalFilterConfig { + column: string; // 필터 대상 컬럼 (소스 테이블의 컬럼명) + label: string; // 필터 라벨 (UI에 표시될 이름) + type: "select" | "text"; // select: 드롭다운 (distinct 값), text: 텍스트 입력 + defaultValue?: string; // 기본값 +} + export interface ItemSelectionModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -188,4 +198,7 @@ export interface ItemSelectionModalProps { uniqueField?: string; onSelect: (items: Record[]) => void; columnLabels?: Record; // 컬럼명 -> 라벨명 매핑 + + // 모달 내부 필터 (사용자 선택 가능) + modalFilters?: ModalFilterConfig[]; } diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx new file mode 100644 index 00000000..224459f0 --- /dev/null +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -0,0 +1,884 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Plus, Columns, AlignJustify } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; + +// 기존 ModalRepeaterTable 컴포넌트 재사용 +import { RepeaterTable } from "../modal-repeater-table/RepeaterTable"; +import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal"; +import { RepeaterColumnConfig, CalculationRule, DynamicDataSourceOption } from "../modal-repeater-table/types"; + +// 타입 정의 +import { + TableSectionConfig, + TableColumnConfig, + ValueMappingConfig, + TableJoinCondition, + FormDataState, +} from "./types"; + +interface TableSectionRendererProps { + sectionId: string; + tableConfig: TableSectionConfig; + formData: FormDataState; + onFormDataChange: (field: string, value: any) => void; + onTableDataChange: (data: any[]) => void; + className?: string; +} + +/** + * TableColumnConfig를 RepeaterColumnConfig로 변환 + * columnModes 또는 lookup이 있으면 dynamicDataSource로 변환 + */ +function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig { + const baseColumn: RepeaterColumnConfig = { + field: col.field, + label: col.label, + type: col.type, + editable: col.editable ?? true, + calculated: col.calculated ?? false, + width: col.width || "150px", + required: col.required, + defaultValue: col.defaultValue, + selectOptions: col.selectOptions, + // valueMapping은 별도로 처리 + }; + + // lookup 설정을 dynamicDataSource로 변환 (새로운 조회 기능) + if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) { + baseColumn.dynamicDataSource = { + enabled: true, + options: col.lookup.options.map((option) => ({ + id: option.id, + // "컬럼명 - 옵션라벨" 형식으로 헤더에 표시 + label: option.displayLabel || option.label, + // 헤더에 표시될 전체 라벨 (컬럼명 - 옵션라벨) + headerLabel: `${col.label} - ${option.displayLabel || option.label}`, + sourceType: "table" as const, + tableConfig: { + tableName: option.tableName, + valueColumn: option.valueColumn, + joinConditions: option.conditions.map((cond) => ({ + sourceField: cond.sourceField, + targetField: cond.targetColumn, + // sourceType에 따른 데이터 출처 설정 + sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable" + fromFormData: cond.sourceType === "sectionField", + sectionId: cond.sectionId, + // 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우) + externalLookup: cond.externalLookup, + // 값 변환 설정 전달 (레거시 호환) + transform: cond.transform?.enabled ? { + tableName: cond.transform.tableName, + matchColumn: cond.transform.matchColumn, + resultColumn: cond.transform.resultColumn, + } : undefined, + })), + }, + // 조회 유형 정보 추가 + lookupType: option.type, + })), + defaultOptionId: col.lookup.options.find((o) => o.isDefault)?.id || col.lookup.options[0]?.id, + }; + } + // columnModes를 dynamicDataSource로 변환 (기존 로직 유지) + else if (col.columnModes && col.columnModes.length > 0) { + baseColumn.dynamicDataSource = { + enabled: true, + options: col.columnModes.map((mode) => ({ + id: mode.id, + label: mode.label, + sourceType: "table" as const, + // 실제 조회 로직은 TableSectionRenderer에서 처리 + tableConfig: { + tableName: mode.valueMapping?.externalRef?.tableName || "", + valueColumn: mode.valueMapping?.externalRef?.valueColumn || "", + joinConditions: (mode.valueMapping?.externalRef?.joinConditions || []).map((jc) => ({ + sourceField: jc.sourceField, + targetField: jc.targetColumn, + })), + }, + })), + defaultOptionId: col.columnModes.find((m) => m.isDefault)?.id || col.columnModes[0]?.id, + }; + } + + return baseColumn; +} + +/** + * TableCalculationRule을 CalculationRule로 변환 + */ +function convertToCalculationRule(calc: { resultField: string; formula: string; dependencies: string[] }): CalculationRule { + return { + result: calc.resultField, + formula: calc.formula, + dependencies: calc.dependencies, + }; +} + +/** + * 값 변환 함수: 중간 테이블을 통해 값을 변환 + * 예: 거래처 이름 "(무)테스트업체" → 거래처 코드 "CUST-0002" + */ +async function transformValue( + value: any, + transform: { tableName: string; matchColumn: string; resultColumn: string } +): Promise { + if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) { + return value; + } + + try { + // 정확히 일치하는 검색 + const response = await apiClient.post( + `/table-management/tables/${transform.tableName}/data`, + { + search: { + [transform.matchColumn]: { + value: value, + operator: "equals" + } + }, + size: 1, + page: 1 + } + ); + + if (response.data.success && response.data.data?.data?.length > 0) { + const transformedValue = response.data.data.data[0][transform.resultColumn]; + return transformedValue; + } + + console.warn(`변환 실패: ${transform.tableName}.${transform.matchColumn} = "${value}" 인 행을 찾을 수 없습니다.`); + return undefined; + } catch (error) { + console.error("값 변환 오류:", error); + return undefined; + } +} + +/** + * 외부 테이블에서 조건 값을 조회하는 함수 + * LookupCondition.sourceType이 "externalTable"인 경우 사용 + */ +async function fetchExternalLookupValue( + externalLookup: { + tableName: string; + matchColumn: string; + matchSourceType: "currentRow" | "sourceTable" | "sectionField"; + matchSourceField: string; + matchSectionId?: string; + resultColumn: string; + }, + rowData: any, + sourceData: any, + formData: FormDataState +): Promise { + // 1. 비교 값 가져오기 + let matchValue: any; + if (externalLookup.matchSourceType === "currentRow") { + matchValue = rowData[externalLookup.matchSourceField]; + } else if (externalLookup.matchSourceType === "sourceTable") { + matchValue = sourceData?.[externalLookup.matchSourceField]; + } else { + matchValue = formData[externalLookup.matchSourceField]; + } + + if (matchValue === undefined || matchValue === null || matchValue === "") { + console.warn(`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`); + return undefined; + } + + // 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색) + try { + const response = await apiClient.post( + `/table-management/tables/${externalLookup.tableName}/data`, + { + search: { + [externalLookup.matchColumn]: { + value: matchValue, + operator: "equals" + } + }, + size: 1, + page: 1 + } + ); + + if (response.data.success && response.data.data?.data?.length > 0) { + return response.data.data.data[0][externalLookup.resultColumn]; + } + + console.warn(`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`); + return undefined; + } catch (error) { + console.error("외부 테이블 조회 오류:", error); + return undefined; + } +} + +/** + * 외부 테이블에서 값을 조회하는 함수 + * + * @param tableName - 조회할 테이블명 + * @param valueColumn - 가져올 컬럼명 + * @param joinConditions - 조인 조건 목록 + * @param rowData - 현재 행 데이터 (설정된 컬럼 필드) + * @param sourceData - 원본 소스 데이터 (_sourceData) + * @param formData - 폼 데이터 (다른 섹션 필드) + */ +async function fetchExternalValue( + tableName: string, + valueColumn: string, + joinConditions: TableJoinCondition[], + rowData: any, + sourceData: any, + formData: FormDataState +): Promise { + if (joinConditions.length === 0) { + return undefined; + } + + try { + const whereConditions: Record = {}; + + for (const condition of joinConditions) { + let value: any; + + // 값 출처에 따라 가져오기 (4가지 소스 타입 지원) + if (condition.sourceType === "row") { + // 현재 행 데이터 (설정된 컬럼 필드) + value = rowData[condition.sourceField]; + } else if (condition.sourceType === "sourceData") { + // 원본 소스 테이블 데이터 (_sourceData) + value = sourceData?.[condition.sourceField]; + } else if (condition.sourceType === "formData") { + // formData에서 가져오기 (다른 섹션) + value = formData[condition.sourceField]; + } else if (condition.sourceType === "externalTable" && condition.externalLookup) { + // 외부 테이블에서 조회하여 가져오기 + value = await fetchExternalLookupValue(condition.externalLookup, rowData, sourceData, formData); + } + + if (value === undefined || value === null || value === "") { + return undefined; + } + + // 값 변환이 필요한 경우 (예: 이름 → 코드) - 레거시 호환 + if (condition.transform) { + value = await transformValue(value, condition.transform); + if (value === undefined) { + return undefined; + } + } + + // 숫자형 ID 변환 + let convertedValue = value; + if (condition.targetColumn.endsWith("_id") || condition.targetColumn === "id") { + const numValue = Number(value); + if (!isNaN(numValue)) { + convertedValue = numValue; + } + } + + // 정확히 일치하는 검색을 위해 operator: "equals" 사용 + whereConditions[condition.targetColumn] = { + value: convertedValue, + operator: "equals" + }; + } + + // API 호출 + const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { search: whereConditions, size: 1, page: 1 } + ); + + if (response.data.success && response.data.data?.data?.length > 0) { + return response.data.data.data[0][valueColumn]; + } + + return undefined; + } catch (error) { + console.error("외부 테이블 조회 오류:", error); + return undefined; + } +} + +/** + * 테이블 섹션 렌더러 + * UniversalFormModal 내에서 테이블 형식의 데이터를 표시하고 편집 + */ +export function TableSectionRenderer({ + sectionId, + tableConfig, + formData, + onFormDataChange, + onTableDataChange, + className, +}: TableSectionRendererProps) { + // 테이블 데이터 상태 + const [tableData, setTableData] = useState([]); + + // 모달 상태 + const [modalOpen, setModalOpen] = useState(false); + + // 체크박스 선택 상태 + const [selectedRows, setSelectedRows] = useState>(new Set()); + + // 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배) + const [widthTrigger, setWidthTrigger] = useState(0); + + // 동적 데이터 소스 활성화 상태 + const [activeDataSources, setActiveDataSources] = useState>({}); + + // 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용) + const [batchAppliedFields, setBatchAppliedFields] = useState>(new Set()); + + // 초기 데이터 로드 완료 플래그 (무한 루프 방지) + const initialDataLoadedRef = React.useRef(false); + + // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) + useEffect(() => { + // 이미 초기화되었으면 스킵 + if (initialDataLoadedRef.current) return; + + const tableSectionKey = `_tableSection_${sectionId}`; + const initialData = formData[tableSectionKey]; + + if (Array.isArray(initialData) && initialData.length > 0) { + console.log("[TableSectionRenderer] 초기 데이터 로드:", { + sectionId, + itemCount: initialData.length, + }); + setTableData(initialData); + initialDataLoadedRef.current = true; + } + }, [sectionId, formData]); + + // RepeaterColumnConfig로 변환 + const columns: RepeaterColumnConfig[] = (tableConfig.columns || []).map(convertToRepeaterColumn); + + // 계산 규칙 변환 + const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule); + + // 계산 로직 + const calculateRow = useCallback( + (row: any): any => { + if (calculationRules.length === 0) return row; + + const updatedRow = { ...row }; + + for (const rule of calculationRules) { + try { + let formula = rule.formula; + const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || []; + const dependencies = rule.dependencies.length > 0 ? rule.dependencies : fieldMatches; + + for (const dep of dependencies) { + if (dep === rule.result) continue; + const value = parseFloat(row[dep]) || 0; + formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString()); + } + + const result = new Function(`return ${formula}`)(); + updatedRow[rule.result] = result; + } catch (error) { + console.error(`계산 오류 (${rule.formula}):`, error); + updatedRow[rule.result] = 0; + } + } + + return updatedRow; + }, + [calculationRules] + ); + + const calculateAll = useCallback( + (data: any[]): any[] => { + return data.map((row) => calculateRow(row)); + }, + [calculateRow] + ); + + // 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함) + const handleDataChange = useCallback( + (newData: any[]) => { + let processedData = newData; + + // 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리 + const batchApplyColumns = tableConfig.columns.filter( + (col) => col.type === "date" && col.batchApply === true + ); + + for (const dateCol of batchApplyColumns) { + // 이미 일괄 적용된 컬럼은 건너뜀 + if (batchAppliedFields.has(dateCol.field)) continue; + + // 해당 컬럼에 값이 있는 행과 없는 행 분류 + const itemsWithDate = processedData.filter((item) => item[dateCol.field]); + const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]); + + // 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 + if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) { + const selectedDate = itemsWithDate[0][dateCol.field]; + + // 모든 행에 동일한 날짜 적용 + processedData = processedData.map((item) => ({ + ...item, + [dateCol.field]: selectedDate, + })); + + // 플래그 활성화 (이후 개별 수정 가능) + setBatchAppliedFields((prev) => new Set([...prev, dateCol.field])); + } + } + + setTableData(processedData); + onTableDataChange(processedData); + }, + [onTableDataChange, tableConfig.columns, batchAppliedFields] + ); + + // 행 변경 핸들러 + const handleRowChange = useCallback( + (index: number, newRow: any) => { + const calculatedRow = calculateRow(newRow); + const newData = [...tableData]; + newData[index] = calculatedRow; + handleDataChange(newData); + }, + [tableData, calculateRow, handleDataChange] + ); + + // 행 삭제 핸들러 + const handleRowDelete = useCallback( + (index: number) => { + const newData = tableData.filter((_, i) => i !== index); + handleDataChange(newData); + }, + [tableData, handleDataChange] + ); + + // 선택된 항목 일괄 삭제 + const handleBulkDelete = useCallback(() => { + if (selectedRows.size === 0) return; + const newData = tableData.filter((_, index) => !selectedRows.has(index)); + handleDataChange(newData); + setSelectedRows(new Set()); + + // 데이터가 모두 삭제되면 일괄 적용 플래그도 리셋 + if (newData.length === 0) { + setBatchAppliedFields(new Set()); + } + }, [tableData, selectedRows, handleDataChange]); + + // 아이템 추가 핸들러 (모달에서 선택) + const handleAddItems = useCallback( + async (items: any[]) => { + // 각 아이템에 대해 valueMapping 적용 + const mappedItems = await Promise.all( + items.map(async (sourceItem) => { + const newItem: any = {}; + + for (const col of tableConfig.columns) { + const mapping = col.valueMapping; + + // 0. lookup 설정이 있는 경우 (동적 조회) + if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) { + // 현재 활성화된 옵션 또는 기본 옵션 사용 + const activeOptionId = activeDataSources[col.field]; + const defaultOption = col.lookup.options.find((o) => o.isDefault) || col.lookup.options[0]; + const selectedOption = activeOptionId + ? col.lookup.options.find((o) => o.id === activeOptionId) || defaultOption + : defaultOption; + + if (selectedOption) { + // sameTable 타입: 소스 데이터에서 직접 값 복사 + if (selectedOption.type === "sameTable") { + const value = sourceItem[selectedOption.valueColumn]; + if (value !== undefined) { + newItem[col.field] = value; + } + // _sourceData에 원본 저장 (나중에 다른 옵션으로 전환 시 사용) + newItem._sourceData = sourceItem; + continue; + } + + // relatedTable, combinedLookup: 외부 테이블 조회 + // 조인 조건 구성 (4가지 소스 타입 지원) + const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => { + // sourceType 매핑 + let sourceType: "row" | "sourceData" | "formData" | "externalTable"; + if (cond.sourceType === "currentRow") { + sourceType = "row"; + } else if (cond.sourceType === "sourceTable") { + sourceType = "sourceData"; + } else if (cond.sourceType === "externalTable") { + sourceType = "externalTable"; + } else { + sourceType = "formData"; + } + + return { + sourceType, + sourceField: cond.sourceField, + targetColumn: cond.targetColumn, + // 외부 테이블 조회 설정 + externalLookup: cond.externalLookup, + // 값 변환 설정 전달 (레거시 호환) + transform: cond.transform?.enabled ? { + tableName: cond.transform.tableName, + matchColumn: cond.transform.matchColumn, + resultColumn: cond.transform.resultColumn, + } : undefined, + }; + }); + + // 외부 테이블에서 값 조회 (sourceItem이 _sourceData 역할) + const value = await fetchExternalValue( + selectedOption.tableName, + selectedOption.valueColumn, + joinConditions, + { ...sourceItem, ...newItem }, // rowData (현재 행) + sourceItem, // sourceData (소스 테이블 원본) + formData + ); + + if (value !== undefined) { + newItem[col.field] = value; + } + + // _sourceData에 원본 저장 + newItem._sourceData = sourceItem; + } + continue; + } + + // 1. 먼저 col.sourceField 확인 (간단 매핑) + if (!mapping && col.sourceField) { + // sourceField가 명시적으로 설정된 경우 + if (sourceItem[col.sourceField] !== undefined) { + newItem[col.field] = sourceItem[col.sourceField]; + } + continue; + } + + if (!mapping) { + // 매핑 없으면 소스에서 동일 필드명으로 복사 + if (sourceItem[col.field] !== undefined) { + newItem[col.field] = sourceItem[col.field]; + } + continue; + } + + // 2. valueMapping이 있는 경우 (고급 매핑) + switch (mapping.type) { + case "source": + // 소스 테이블에서 복사 + const srcField = mapping.sourceField || col.sourceField || col.field; + if (sourceItem[srcField] !== undefined) { + newItem[col.field] = sourceItem[srcField]; + } + break; + + case "manual": + // 사용자 입력 (빈 값 또는 기본값) + newItem[col.field] = col.defaultValue ?? undefined; + break; + + case "internal": + // formData에서 값 가져오기 + if (mapping.internalField) { + newItem[col.field] = formData[mapping.internalField]; + } + break; + + case "external": + // 외부 테이블에서 조회 + if (mapping.externalRef) { + const { tableName, valueColumn, joinConditions } = mapping.externalRef; + const value = await fetchExternalValue( + tableName, + valueColumn, + joinConditions, + { ...sourceItem, ...newItem }, // rowData + sourceItem, // sourceData + formData + ); + if (value !== undefined) { + newItem[col.field] = value; + } + } + break; + } + + // 기본값 적용 + if (col.defaultValue !== undefined && newItem[col.field] === undefined) { + newItem[col.field] = col.defaultValue; + } + } + + return newItem; + }) + ); + + // 계산 필드 업데이트 + const calculatedItems = calculateAll(mappedItems); + + // 기존 데이터에 추가 + const newData = [...tableData, ...calculatedItems]; + handleDataChange(newData); + }, + [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources] + ); + + // 컬럼 모드/조회 옵션 변경 핸들러 + const handleDataSourceChange = useCallback( + async (columnField: string, optionId: string) => { + setActiveDataSources((prev) => ({ + ...prev, + [columnField]: optionId, + })); + + // 해당 컬럼의 모든 행 데이터 재조회 + const column = tableConfig.columns.find((col) => col.field === columnField); + + // lookup 설정이 있는 경우 (새로운 조회 기능) + if (column?.lookup?.enabled && column.lookup.options) { + const selectedOption = column.lookup.options.find((opt) => opt.id === optionId); + if (!selectedOption) return; + + // sameTable 타입: 현재 행의 소스 데이터에서 값 복사 (외부 조회 필요 없음) + if (selectedOption.type === "sameTable") { + const updatedData = tableData.map((row) => { + // sourceField에서 값을 가져와 해당 컬럼에 복사 + // row에 _sourceData가 있으면 거기서, 없으면 row 자체에서 가져옴 + const sourceData = row._sourceData || row; + const newValue = sourceData[selectedOption.valueColumn] ?? row[columnField]; + return { ...row, [columnField]: newValue }; + }); + + const calculatedData = calculateAll(updatedData); + handleDataChange(calculatedData); + return; + } + + // 모든 행에 대해 새 값 조회 + const updatedData = await Promise.all( + tableData.map(async (row) => { + let newValue: any = row[columnField]; + + // 조인 조건 구성 (4가지 소스 타입 지원) + const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => { + // sourceType 매핑 + let sourceType: "row" | "sourceData" | "formData" | "externalTable"; + if (cond.sourceType === "currentRow") { + sourceType = "row"; + } else if (cond.sourceType === "sourceTable") { + sourceType = "sourceData"; + } else if (cond.sourceType === "externalTable") { + sourceType = "externalTable"; + } else { + sourceType = "formData"; + } + + return { + sourceType, + sourceField: cond.sourceField, + targetColumn: cond.targetColumn, + // 외부 테이블 조회 설정 + externalLookup: cond.externalLookup, + // 값 변환 설정 전달 (레거시 호환) + transform: cond.transform?.enabled ? { + tableName: cond.transform.tableName, + matchColumn: cond.transform.matchColumn, + resultColumn: cond.transform.resultColumn, + } : undefined, + }; + }); + + // 외부 테이블에서 값 조회 (_sourceData 전달) + const sourceData = row._sourceData || row; + const value = await fetchExternalValue( + selectedOption.tableName, + selectedOption.valueColumn, + joinConditions, + row, + sourceData, + formData + ); + + if (value !== undefined) { + newValue = value; + } + + return { ...row, [columnField]: newValue }; + }) + ); + + // 계산 필드 업데이트 + const calculatedData = calculateAll(updatedData); + handleDataChange(calculatedData); + return; + } + + // 기존 columnModes 처리 (레거시 호환) + if (!column?.columnModes) return; + + const selectedMode = column.columnModes.find((mode) => mode.id === optionId); + if (!selectedMode) return; + + // 모든 행에 대해 새 값 조회 + const updatedData = await Promise.all( + tableData.map(async (row) => { + const mapping = selectedMode.valueMapping; + let newValue: any = row[columnField]; + const sourceData = row._sourceData || row; + + if (mapping.type === "external" && mapping.externalRef) { + const { tableName, valueColumn, joinConditions } = mapping.externalRef; + const value = await fetchExternalValue(tableName, valueColumn, joinConditions, row, sourceData, formData); + if (value !== undefined) { + newValue = value; + } + } else if (mapping.type === "source" && mapping.sourceField) { + newValue = row[mapping.sourceField]; + } else if (mapping.type === "internal" && mapping.internalField) { + newValue = formData[mapping.internalField]; + } + + return { ...row, [columnField]: newValue }; + }) + ); + + // 계산 필드 업데이트 + const calculatedData = calculateAll(updatedData); + handleDataChange(calculatedData); + }, + [tableConfig.columns, tableData, formData, calculateAll, handleDataChange] + ); + + // 소스 테이블 정보 + const { source, filters, uiConfig } = tableConfig; + const sourceTable = source.tableName; + const sourceColumns = source.displayColumns; + const sourceSearchFields = source.searchColumns; + const columnLabels = source.columnLabels || {}; + const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택"; + const addButtonText = uiConfig?.addButtonText || "항목 검색"; + const multiSelect = uiConfig?.multiSelect ?? true; + + // 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리) + const baseFilterCondition: Record = {}; + if (filters?.preFilters) { + for (const filter of filters.preFilters) { + // 간단한 "=" 연산자만 처리 (확장 가능) + if (filter.operator === "=") { + baseFilterCondition[filter.column] = filter.value; + } + } + } + + // 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환 + const modalFiltersForModal = useMemo(() => { + if (!filters?.modalFilters) return []; + return filters.modalFilters.map((filter) => ({ + column: filter.column, + label: filter.label || filter.column, + // category 타입을 select로 변환 (ModalFilterConfig 호환) + type: filter.type === "category" ? "select" as const : filter.type as "text" | "select", + options: filter.options, + categoryRef: filter.categoryRef, + defaultValue: filter.defaultValue, + })); + }, [filters?.modalFilters]); + + return ( +
+ {/* 추가 버튼 영역 */} +
+
+ + {tableData.length > 0 && `${tableData.length}개 항목`} + {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} + + {columns.length > 0 && ( + + )} +
+
+ {selectedRows.size > 0 && ( + + )} + +
+
+ + {/* Repeater 테이블 */} + + + {/* 항목 선택 모달 */} + +
+ ); +} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 03c2efb8..bc217299 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -38,6 +38,7 @@ import { OptionalFieldGroupConfig, } from "./types"; import { defaultConfig, generateUniqueId } from "./config"; +import { TableSectionRenderer } from "./TableSectionRenderer"; /** * 🔗 연쇄 드롭다운 Select 필드 컴포넌트 @@ -194,6 +195,10 @@ export function UniversalFormModalComponent({ // 로딩 상태 const [saving, setSaving] = useState(false); + // 🆕 수정 모드: 원본 그룹 데이터 (INSERT/UPDATE/DELETE 추적용) + const [originalGroupedData, setOriginalGroupedData] = useState([]); + const groupedDataInitializedRef = useRef(false); + // 삭제 확인 다이얼로그 const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; @@ -303,6 +308,12 @@ export function UniversalFormModalComponent({ console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items); } } + + // 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용) + if (originalGroupedData.length > 0) { + event.detail.formData._originalGroupedData = originalGroupedData; + console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`); + } }; window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); @@ -310,7 +321,37 @@ export function UniversalFormModalComponent({ return () => { window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener); }; - }, [formData, repeatSections, config.sections]); + }, [formData, repeatSections, config.sections, originalGroupedData]); + + // 🆕 수정 모드: _groupedData가 있으면 테이블 섹션 초기화 + useEffect(() => { + if (!_groupedData || _groupedData.length === 0) return; + if (groupedDataInitializedRef.current) return; // 이미 초기화됨 + + // 테이블 타입 섹션 찾기 + const tableSection = config.sections.find((s) => s.type === "table"); + if (!tableSection) { + console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시"); + return; + } + + console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", { + sectionId: tableSection.id, + itemCount: _groupedData.length, + }); + + // 원본 데이터 저장 (수정/삭제 추적용) + setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData))); + + // 테이블 섹션 데이터 설정 + const tableSectionKey = `_tableSection_${tableSection.id}`; + setFormData((prev) => ({ + ...prev, + [tableSectionKey]: _groupedData, + })); + + groupedDataInitializedRef.current = true; + }, [_groupedData, config.sections]); // 필드 레벨 linkedFieldGroup 데이터 로드 useEffect(() => { @@ -372,9 +413,12 @@ export function UniversalFormModalComponent({ items.push(createRepeatItem(section, i)); } newRepeatSections[section.id] = items; + } else if (section.type === "table") { + // 테이블 섹션은 필드 초기화 스킵 (TableSectionRenderer에서 처리) + continue; } else { // 일반 섹션 필드 초기화 - for (const field of section.fields || []) { + for (const field of (section.fields || [])) { // 기본값 설정 let value = field.defaultValue ?? ""; @@ -448,7 +492,7 @@ export function UniversalFormModalComponent({ _index: index, }; - for (const field of section.fields || []) { + for (const field of (section.fields || [])) { item[field.columnName] = field.defaultValue ?? ""; } @@ -479,9 +523,9 @@ export function UniversalFormModalComponent({ let hasChanges = false; for (const section of config.sections) { - if (section.repeatable) continue; + if (section.repeatable || section.type === "table") continue; - for (const field of section.fields || []) { + for (const field of (section.fields || [])) { if ( field.numberingRule?.enabled && field.numberingRule?.generateOnOpen && @@ -781,9 +825,9 @@ export function UniversalFormModalComponent({ const missingFields: string[] = []; for (const section of config.sections) { - if (section.repeatable) continue; // 반복 섹션은 별도 검증 + if (section.repeatable || section.type === "table") continue; // 반복 섹션 및 테이블 섹션은 별도 검증 - for (const field of section.fields || []) { + 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 === "") { @@ -799,17 +843,28 @@ export function UniversalFormModalComponent({ // 단일 행 저장 const saveSingleRow = useCallback(async () => { const dataToSave = { ...formData }; + + // 테이블 섹션 데이터 추출 (별도 저장용) + const tableSectionData: Record = {}; // 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용) Object.keys(dataToSave).forEach((key) => { - if (key.startsWith("_") && !key.includes("_numberingRuleId")) { + if (key.startsWith("_tableSection_")) { + // 테이블 섹션 데이터는 별도로 저장 + const sectionId = key.replace("_tableSection_", ""); + tableSectionData[sectionId] = dataToSave[key] || []; + delete dataToSave[key]; + } else if (key.startsWith("_") && !key.includes("_numberingRuleId")) { delete dataToSave[key]; } }); // 저장 시점 채번규칙 처리 (generateOnSave만 처리) for (const section of config.sections) { - for (const field of section.fields || []) { + // 테이블 타입 섹션은 건너뛰기 + if (section.type === "table") continue; + + for (const field of (section.fields || [])) { if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) { const response = await allocateNumberingCode(field.numberingRule.ruleId); if (response.success && response.data?.generatedCode) { @@ -822,12 +877,140 @@ export function UniversalFormModalComponent({ } } + // 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장) + // targetTable이 없거나 메인 테이블과 같은 경우 + const tableSectionsForMainTable = config.sections.filter( + (s) => s.type === "table" && + (!s.tableConfig?.saveConfig?.targetTable || + s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName) + ); + + if (tableSectionsForMainTable.length > 0) { + // 공통 저장 필드 수집 (sectionSaveModes 설정에 따라) + const commonFieldsData: Record = {}; + const { sectionSaveModes } = config.saveConfig; + + // 필드 타입 섹션에서 공통 저장 필드 수집 + for (const section of config.sections) { + if (section.type === "table") continue; + + const sectionMode = sectionSaveModes?.find((s) => s.sectionId === section.id); + const defaultMode = "common"; // 필드 타입 섹션의 기본값은 공통 저장 + const sectionSaveMode = sectionMode?.saveMode || defaultMode; + + if (section.fields) { + for (const field of section.fields) { + const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName); + const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode; + + if (fieldSaveMode === "common" && dataToSave[field.columnName] !== undefined) { + commonFieldsData[field.columnName] = dataToSave[field.columnName]; + } + } + } + } + + // 각 테이블 섹션의 품목 데이터에 공통 필드 병합하여 저장 + for (const tableSection of tableSectionsForMainTable) { + const sectionData = tableSectionData[tableSection.id] || []; + + if (sectionData.length > 0) { + // 품목별로 행 저장 + for (const item of sectionData) { + const rowToSave = { ...commonFieldsData, ...item }; + + // _sourceData 등 내부 메타데이터 제거 + Object.keys(rowToSave).forEach((key) => { + if (key.startsWith("_")) { + delete rowToSave[key]; + } + }); + + const response = await apiClient.post( + `/table-management/tables/${config.saveConfig.tableName}/add`, + rowToSave + ); + + if (!response.data?.success) { + throw new Error(response.data?.message || "품목 저장 실패"); + } + } + + // 이미 저장했으므로 아래 로직에서 다시 저장하지 않도록 제거 + delete tableSectionData[tableSection.id]; + } + } + + // 품목이 없으면 공통 데이터만 저장하지 않음 (품목이 필요한 화면이므로) + // 다른 테이블 섹션이 있는 경우에만 메인 데이터 저장 + const hasOtherTableSections = Object.keys(tableSectionData).length > 0; + if (!hasOtherTableSections) { + return; // 메인 테이블에 저장할 품목이 없으면 종료 + } + } + + // 메인 데이터 저장 (테이블 섹션이 없거나 별도 테이블에 저장하는 경우) const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave); if (!response.data?.success) { throw new Error(response.data?.message || "저장 실패"); } - }, [config.sections, config.saveConfig.tableName, formData]); + + // 테이블 섹션 데이터 저장 (별도 테이블에) + for (const section of config.sections) { + if (section.type === "table" && section.tableConfig?.saveConfig?.targetTable) { + const sectionData = tableSectionData[section.id]; + if (sectionData && sectionData.length > 0) { + // 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기) + const mainRecordId = response.data?.data?.id; + + // 공통 저장 필드 수집 (sectionSaveModes 설정에 따라) + const commonFieldsData: Record = {}; + const { sectionSaveModes } = config.saveConfig; + + if (sectionSaveModes && sectionSaveModes.length > 0) { + // 다른 섹션에서 공통 저장으로 설정된 필드 값 수집 + for (const otherSection of config.sections) { + if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기 + + const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id); + const defaultMode = otherSection.type === "table" ? "individual" : "common"; + const sectionSaveMode = sectionMode?.saveMode || defaultMode; + + // 필드 타입 섹션의 필드들 처리 + if (otherSection.type !== "table" && otherSection.fields) { + for (const field of otherSection.fields) { + // 필드별 오버라이드 확인 + const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName); + const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode; + + // 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용 + if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) { + commonFieldsData[field.columnName] = formData[field.columnName]; + } + } + } + } + } + + for (const item of sectionData) { + // 공통 필드 병합 + 개별 품목 데이터 + const itemToSave = { ...commonFieldsData, ...item }; + + // 메인 레코드와 연결이 필요한 경우 + if (mainRecordId && config.saveConfig.primaryKeyColumn) { + itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId; + } + + await apiClient.post( + `/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`, + itemToSave + ); + } + } + } + } + }, [config.sections, config.saveConfig.tableName, config.saveConfig.primaryKeyColumn, config.saveConfig.sectionSaveModes, formData]); // 다중 행 저장 (겸직 등) const saveMultipleRows = useCallback(async () => { @@ -901,9 +1084,9 @@ export function UniversalFormModalComponent({ // 저장 시점 채번규칙 처리 (메인 행만) for (const section of config.sections) { - if (section.repeatable) continue; + if (section.repeatable || section.type === "table") continue; - for (const field of section.fields || []) { + for (const field of (section.fields || [])) { if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { // generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당 const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen; @@ -951,7 +1134,7 @@ export function UniversalFormModalComponent({ // 1. 메인 테이블 데이터 구성 const mainData: Record = {}; config.sections.forEach((section) => { - if (section.repeatable) return; // 반복 섹션은 제외 + if (section.repeatable || section.type === "table") return; // 반복 섹션 및 테이블 타입 제외 (section.fields || []).forEach((field) => { const value = formData[field.columnName]; if (value !== undefined && value !== null && value !== "") { @@ -962,9 +1145,9 @@ export function UniversalFormModalComponent({ // 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당) for (const section of config.sections) { - if (section.repeatable) continue; + if (section.repeatable || section.type === "table") continue; - for (const field of section.fields || []) { + for (const field of (section.fields || [])) { // 채번규칙이 활성화된 필드 처리 if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { // 신규 생성이거나 값이 없는 경우에만 채번 @@ -1054,7 +1237,7 @@ export function UniversalFormModalComponent({ // 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑 else { config.sections.forEach((section) => { - if (section.repeatable) return; + if (section.repeatable || section.type === "table") return; const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn); if (matchingField && mainData[matchingField.columnName] !== undefined) { mainFieldMappings!.push({ @@ -1535,10 +1718,36 @@ export function UniversalFormModalComponent({ const isCollapsed = collapsedSections.has(section.id); const sectionColumns = section.columns || 2; + // 반복 섹션 if (section.repeatable) { return renderRepeatableSection(section, isCollapsed); } + // 테이블 타입 섹션 + if (section.type === "table" && section.tableConfig) { + return ( + + + {section.title} + {section.description && {section.description}} + + + { + // 테이블 섹션 데이터를 formData에 저장 + handleFieldChange(`_tableSection_${section.id}`, data); + }} + /> + + + ); + } + + // 기본 필드 타입 섹션 return ( {section.collapsible ? ( diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 98cbc248..4ef28d6f 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -17,6 +17,7 @@ import { Settings, Database, Layout, + Table, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -27,9 +28,11 @@ import { FormSectionConfig, FormFieldConfig, MODAL_SIZE_OPTIONS, + SECTION_TYPE_OPTIONS, } from "./types"; import { defaultSectionConfig, + defaultTableSectionConfig, generateSectionId, } from "./config"; @@ -37,6 +40,7 @@ import { import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal"; import { SaveSettingsModal } from "./modals/SaveSettingsModal"; import { SectionLayoutModal } from "./modals/SectionLayoutModal"; +import { TableSectionSettingsModal } from "./modals/TableSectionSettingsModal"; // 도움말 텍스트 컴포넌트 const HelpText = ({ children }: { children: React.ReactNode }) => ( @@ -57,6 +61,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor const [saveSettingsModalOpen, setSaveSettingsModalOpen] = useState(false); const [sectionLayoutModalOpen, setSectionLayoutModalOpen] = useState(false); const [fieldDetailModalOpen, setFieldDetailModalOpen] = useState(false); + const [tableSectionSettingsModalOpen, setTableSectionSettingsModalOpen] = useState(false); const [selectedSection, setSelectedSection] = useState(null); const [selectedField, setSelectedField] = useState(null); @@ -95,23 +100,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - const data = response.data?.data; + // API 응답 구조: { success, data: { columns: [...], total, page, ... } } + const columns = response.data?.data?.columns; - if (response.data?.success && Array.isArray(data)) { + if (response.data?.success && Array.isArray(columns)) { setTableColumns((prev) => ({ ...prev, - [tableName]: data.map( + [tableName]: columns.map( (c: { columnName?: string; column_name?: string; dataType?: string; data_type?: string; + displayName?: string; columnComment?: string; column_comment?: string; }) => ({ name: c.columnName || c.column_name || "", type: c.dataType || c.data_type || "text", - label: c.columnComment || c.column_comment || c.columnName || c.column_name || "", + label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "", }), ), })); @@ -159,17 +166,55 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor ); // 섹션 관리 - const addSection = useCallback(() => { + const addSection = useCallback((type: "fields" | "table" = "fields") => { const newSection: FormSectionConfig = { ...defaultSectionConfig, id: generateSectionId(), - title: `섹션 ${config.sections.length + 1}`, + title: type === "table" ? `테이블 섹션 ${config.sections.length + 1}` : `섹션 ${config.sections.length + 1}`, + type, + fields: type === "fields" ? [] : undefined, + tableConfig: type === "table" ? { ...defaultTableSectionConfig } : undefined, }; onChange({ ...config, sections: [...config.sections, newSection], }); }, [config, onChange]); + + // 섹션 타입 변경 + const changeSectionType = useCallback( + (sectionId: string, newType: "fields" | "table") => { + onChange({ + ...config, + sections: config.sections.map((s) => { + if (s.id !== sectionId) return s; + + if (newType === "table") { + return { + ...s, + type: "table", + fields: undefined, + tableConfig: { ...defaultTableSectionConfig }, + }; + } else { + return { + ...s, + type: "fields", + fields: [], + tableConfig: undefined, + }; + } + }), + }); + }, + [config, onChange] + ); + + // 테이블 섹션 설정 모달 열기 + const handleOpenTableSectionSettings = (section: FormSectionConfig) => { + setSelectedSection(section); + setTableSectionSettingsModalOpen(true); + }; const updateSection = useCallback( (sectionId: string, updates: Partial) => { @@ -365,39 +410,56 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor - + {/* 섹션 추가 버튼들 */} +
+ +
+ 순서 + handleDoubleClick(col.field)} - title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"} - > -
-
- {hasDynamicSource ? ( - setOpenPopover(open ? col.field : null)} - > - - - - { + const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; + const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; + const activeOption = hasDynamicSource + ? col.dynamicDataSource!.options.find((opt) => opt.id === activeOptionId) || + col.dynamicDataSource!.options[0] + : null; + + return ( +
handleDoubleClick(col.field)} + title="더블클릭하여 글자 너비에 맞춤" + > +
+
+ {hasDynamicSource ? ( + setOpenPopover(open ? col.field : null)} > -
- 데이터 소스 선택 -
- {col.dynamicDataSource!.options.map((option) => ( + - ))} - -
- ) : ( - <> - {col.label} - {col.required && *} - - )} + + +
+ 데이터 소스 선택 +
+ {col.dynamicDataSource!.options.map((option) => ( + + ))} +
+ + ) : ( + <> + {col.label} + {col.required && *} + + )} +
+ {/* 리사이즈 핸들 */} +
handleMouseDown(e, col.field)} + title="드래그하여 너비 조정" + />
- {/* 리사이즈 핸들 */} -
handleMouseDown(e, col.field)} - title="드래그하여 너비 조정" - /> -
-
추가된 항목이 없습니다 + + handleRowSelect(rowIndex, !!checked)} @@ -630,10 +673,13 @@ export function RepeaterTable({ {renderCell(row, col, rowIndex)}
+ 테이블 섹션 + + - 폼을 여러 섹션으로 나누어 구성할 수 있습니다. + 필드 섹션: 일반 입력 필드들을 배치합니다.
- 예: 기본 정보, 배송 정보, 결제 정보 + 테이블 섹션: 품목 목록 등 반복 테이블 형식 데이터를 관리합니다.
{config.sections.length === 0 ? (

섹션이 없습니다

-

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

+

위 버튼으로 섹션을 추가하세요

) : (
{config.sections.map((section, index) => (
- {/* 헤더: 제목 + 삭제 */} + {/* 헤더: 제목 + 타입 배지 + 삭제 */}
{section.title} - {section.repeatable && ( + {section.type === "table" ? ( + + 테이블 + + ) : section.repeatable ? ( 반복 - )} + ) : null}
- - {section.fields.length}개 필드 - + {section.type === "table" ? ( + + {section.tableConfig?.source?.tableName || "(소스 미설정)"} + + ) : ( + + {(section.fields || []).length}개 필드 + + )}
- {/* 필드 목록 */} - {section.fields.length > 0 && ( + {/* 필드 목록 (필드 타입만) */} + {section.type !== "table" && (section.fields || []).length > 0 && (
- {section.fields.slice(0, 4).map((field) => ( + {(section.fields || []).slice(0, 4).map((field) => ( ))} - {section.fields.length > 4 && ( + {(section.fields || []).length > 4 && ( - +{section.fields.length - 4} + +{(section.fields || []).length - 4} + + )} +
+ )} + + {/* 테이블 컬럼 목록 (테이블 타입만) */} + {section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && ( +
+ {section.tableConfig.columns.slice(0, 4).map((col) => ( + + {col.label} + + ))} + {section.tableConfig.columns.length > 4 && ( + + +{section.tableConfig.columns.length - 4} )}
)} - {/* 레이아웃 설정 버튼 */} - + {/* 설정 버튼 (타입에 따라 다름) */} + {section.type === "table" ? ( +
+ 테이블 설정 + + ) : ( + + )} ))} @@ -530,7 +624,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor const updatedSection = { ...selectedSection, // 기본 필드 목록에서 업데이트 - fields: selectedSection.fields.map((f) => (f.id === updatedField.id ? updatedField : f)), + fields: (selectedSection.fields || []).map((f) => (f.id === updatedField.id ? updatedField : f)), // 옵셔널 필드 그룹 내 필드도 업데이트 optionalFieldGroups: selectedSection.optionalFieldGroups?.map((group) => ({ ...group, @@ -558,6 +652,46 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor onLoadTableColumns={loadTableColumns} /> )} + + {/* 테이블 섹션 설정 모달 */} + {selectedSection && selectedSection.type === "table" && ( + { + const updatedSection = { + ...selectedSection, + ...updates, + }; + + // config 업데이트 + onChange({ + ...config, + sections: config.sections.map((s) => + s.id === selectedSection.id ? updatedSection : s + ), + }); + + setSelectedSection(updatedSection); + setTableSectionSettingsModalOpen(false); + }} + tables={tables.map(t => ({ table_name: t.name, comment: t.label }))} + tableColumns={Object.fromEntries( + Object.entries(tableColumns).map(([tableName, cols]) => [ + tableName, + cols.map(c => ({ + column_name: c.name, + data_type: c.type, + is_nullable: "YES", + comment: c.label, + })), + ]) + )} + onLoadTableColumns={loadTableColumns} + allSections={config.sections as FormSectionConfig[]} + /> + )} ); } diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index 85a3e3d9..e8b239f6 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -2,7 +2,16 @@ * 범용 폼 모달 컴포넌트 기본 설정 */ -import { UniversalFormModalConfig } from "./types"; +import { + UniversalFormModalConfig, + TableSectionConfig, + TableColumnConfig, + ValueMappingConfig, + ColumnModeConfig, + TablePreFilter, + TableModalFilter, + TableCalculationRule, +} from "./types"; // 기본 설정값 export const defaultConfig: UniversalFormModalConfig = { @@ -77,6 +86,7 @@ export const defaultSectionConfig = { id: "", title: "새 섹션", description: "", + type: "fields" as const, collapsible: false, defaultCollapsed: false, columns: 2, @@ -95,6 +105,97 @@ export const defaultSectionConfig = { linkedFieldGroups: [], }; +// ============================================ +// 테이블 섹션 관련 기본값 +// ============================================ + +// 기본 테이블 섹션 설정 +export const defaultTableSectionConfig: TableSectionConfig = { + source: { + tableName: "", + displayColumns: [], + searchColumns: [], + columnLabels: {}, + }, + filters: { + preFilters: [], + modalFilters: [], + }, + columns: [], + calculations: [], + saveConfig: { + targetTable: undefined, + uniqueField: undefined, + }, + uiConfig: { + addButtonText: "항목 검색", + modalTitle: "항목 검색 및 선택", + multiSelect: true, + maxHeight: "400px", + }, +}; + +// 기본 테이블 컬럼 설정 +export const defaultTableColumnConfig: TableColumnConfig = { + field: "", + label: "", + type: "text", + editable: true, + calculated: false, + required: false, + width: "150px", + minWidth: "60px", + maxWidth: "400px", + defaultValue: undefined, + selectOptions: [], + valueMapping: undefined, + columnModes: [], +}; + +// 기본 값 매핑 설정 +export const defaultValueMappingConfig: ValueMappingConfig = { + type: "source", + sourceField: "", + externalRef: undefined, + internalField: undefined, +}; + +// 기본 컬럼 모드 설정 +export const defaultColumnModeConfig: ColumnModeConfig = { + id: "", + label: "", + isDefault: false, + valueMapping: { + type: "source", + sourceField: "", + }, +}; + +// 기본 사전 필터 설정 +export const defaultPreFilterConfig: TablePreFilter = { + column: "", + operator: "=", + value: "", +}; + +// 기본 모달 필터 설정 +export const defaultModalFilterConfig: TableModalFilter = { + column: "", + label: "", + type: "category", + categoryRef: undefined, + options: [], + optionsFromTable: undefined, + defaultValue: undefined, +}; + +// 기본 계산 규칙 설정 +export const defaultCalculationRuleConfig: TableCalculationRule = { + resultField: "", + formula: "", + dependencies: [], +}; + // 기본 옵셔널 필드 그룹 설정 export const defaultOptionalFieldGroupConfig = { id: "", @@ -184,3 +285,18 @@ export const generateFieldId = (): string => { export const generateLinkedFieldGroupId = (): string => { return generateUniqueId("linked"); }; + +// 유틸리티: 테이블 컬럼 ID 생성 +export const generateTableColumnId = (): string => { + return generateUniqueId("tcol"); +}; + +// 유틸리티: 컬럼 모드 ID 생성 +export const generateColumnModeId = (): string => { + return generateUniqueId("mode"); +}; + +// 유틸리티: 필터 ID 생성 +export const generateFilterId = (): string => { + return generateUniqueId("filter"); +}; diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx index 94bdf3af..2607cf83 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -11,9 +11,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; -import { Plus, Trash2, Database, Layers } from "lucide-react"; +import { Plus, Trash2, Database, Layers, Info } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig } from "../types"; +import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types"; // 도움말 텍스트 컴포넌트 const HelpText = ({ children }: { children: React.ReactNode }) => ( @@ -219,19 +220,112 @@ export function SaveSettingsModal({ const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => { const fields: { columnName: string; label: string; sectionTitle: string }[] = []; sections.forEach((section) => { - section.fields.forEach((field) => { - fields.push({ - columnName: field.columnName, - label: field.label, - sectionTitle: section.title, + // 필드 타입 섹션만 처리 (테이블 타입은 fields가 undefined) + if (section.fields && Array.isArray(section.fields)) { + section.fields.forEach((field) => { + fields.push({ + columnName: field.columnName, + label: field.label, + sectionTitle: section.title, + }); }); - }); + } }); return fields; }; const allFields = getAllFields(); + // 섹션별 저장 방식 조회 (없으면 기본값 반환) + const getSectionSaveMode = (sectionId: string, sectionType: "fields" | "table"): "common" | "individual" => { + const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId); + if (sectionMode) { + return sectionMode.saveMode; + } + // 기본값: fields 타입은 공통 저장, table 타입은 개별 저장 + return sectionType === "fields" ? "common" : "individual"; + }; + + // 필드별 저장 방식 조회 (오버라이드 확인) + const getFieldSaveMode = (sectionId: string, fieldName: string, sectionType: "fields" | "table"): "common" | "individual" => { + const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId); + if (sectionMode) { + // 필드별 오버라이드 확인 + const fieldOverride = sectionMode.fieldOverrides?.find((f) => f.fieldName === fieldName); + if (fieldOverride) { + return fieldOverride.saveMode; + } + return sectionMode.saveMode; + } + // 기본값 + return sectionType === "fields" ? "common" : "individual"; + }; + + // 섹션별 저장 방식 업데이트 + const updateSectionSaveMode = (sectionId: string, mode: "common" | "individual") => { + const currentModes = localSaveConfig.sectionSaveModes || []; + const existingIndex = currentModes.findIndex((s) => s.sectionId === sectionId); + + let newModes: SectionSaveMode[]; + if (existingIndex >= 0) { + newModes = [...currentModes]; + newModes[existingIndex] = { ...newModes[existingIndex], saveMode: mode }; + } else { + newModes = [...currentModes, { sectionId, saveMode: mode }]; + } + + updateSaveConfig({ sectionSaveModes: newModes }); + }; + + // 필드별 오버라이드 토글 + const toggleFieldOverride = (sectionId: string, fieldName: string, sectionType: "fields" | "table") => { + const currentModes = localSaveConfig.sectionSaveModes || []; + const sectionIndex = currentModes.findIndex((s) => s.sectionId === sectionId); + + // 섹션 설정이 없으면 먼저 생성 + let newModes = [...currentModes]; + if (sectionIndex < 0) { + const defaultMode = sectionType === "fields" ? "common" : "individual"; + newModes.push({ sectionId, saveMode: defaultMode, fieldOverrides: [] }); + } + + const targetIndex = newModes.findIndex((s) => s.sectionId === sectionId); + const sectionMode = newModes[targetIndex]; + const currentFieldOverrides = sectionMode.fieldOverrides || []; + const fieldOverrideIndex = currentFieldOverrides.findIndex((f) => f.fieldName === fieldName); + + let newFieldOverrides; + if (fieldOverrideIndex >= 0) { + // 이미 오버라이드가 있으면 제거 (섹션 기본값으로 돌아감) + newFieldOverrides = currentFieldOverrides.filter((f) => f.fieldName !== fieldName); + } else { + // 오버라이드 추가 (섹션 기본값의 반대) + const oppositeMode = sectionMode.saveMode === "common" ? "individual" : "common"; + newFieldOverrides = [...currentFieldOverrides, { fieldName, saveMode: oppositeMode }]; + } + + newModes[targetIndex] = { ...sectionMode, fieldOverrides: newFieldOverrides }; + updateSaveConfig({ sectionSaveModes: newModes }); + }; + + // 섹션의 필드 목록 가져오기 + const getSectionFields = (section: FormSectionConfig): { fieldName: string; label: string }[] => { + if (section.type === "table" && section.tableConfig) { + // 테이블 타입: tableConfig.columns에서 필드 목록 가져오기 + return (section.tableConfig.columns || []).map((col) => ({ + fieldName: col.field, + label: col.label, + })); + } else if (section.fields) { + // 필드 타입: fields에서 목록 가져오기 + return section.fields.map((field) => ({ + fieldName: field.columnName, + label: field.label, + })); + } + return []; + }; + return ( @@ -721,6 +815,150 @@ export function SaveSettingsModal({ )} + {/* 섹션별 저장 방식 */} +
+
+ +

섹션별 저장 방식

+
+ + {/* 설명 */} +
+
+ +
+

+ 공통 저장: 이 섹션의 필드 값이 모든 품목 행에 동일하게 저장됩니다 +
+ 예: 수주번호, 거래처, 수주일 - 품목이 3개면 3개 행 모두 같은 값 +

+

+ 개별 저장: 이 섹션의 필드 값이 각 품목마다 다르게 저장됩니다 +
+ 예: 품목코드, 수량, 단가 - 품목마다 다른 값 +

+
+
+
+ + {/* 섹션 목록 */} + {sections.length === 0 ? ( +
+

섹션이 없습니다

+
+ ) : ( + + {sections.map((section) => { + const sectionType = section.type || "fields"; + const currentMode = getSectionSaveMode(section.id, sectionType); + const sectionFields = getSectionFields(section); + + return ( + + +
+
+ {section.title} + + {sectionType === "table" ? "테이블" : "필드"} + +
+ + {currentMode === "common" ? "공통 저장" : "개별 저장"} + +
+
+ + {/* 저장 방식 선택 */} +
+ + updateSectionSaveMode(section.id, value as "common" | "individual")} + className="flex gap-4" + > +
+ + +
+
+ + +
+
+
+ + {/* 필드 목록 */} + {sectionFields.length > 0 && ( + <> + +
+ + 필드를 클릭하면 섹션 기본값과 다르게 설정할 수 있습니다 +
+ {sectionFields.map((field) => { + const fieldMode = getFieldSaveMode(section.id, field.fieldName, sectionType); + const isOverridden = fieldMode !== currentMode; + + return ( + + ); + })} +
+
+ + )} +
+
+ ); + })} +
+ )} +
+ {/* 저장 후 동작 */}

저장 후 동작

diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx index 057502c9..4a90a777 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx @@ -37,13 +37,19 @@ export function SectionLayoutModal({ onOpenFieldDetail, }: SectionLayoutModalProps) { - // 로컬 상태로 섹션 관리 - const [localSection, setLocalSection] = useState(section); + // 로컬 상태로 섹션 관리 (fields가 없으면 빈 배열로 초기화) + const [localSection, setLocalSection] = useState(() => ({ + ...section, + fields: section.fields || [], + })); // open이 변경될 때마다 데이터 동기화 useEffect(() => { if (open) { - setLocalSection(section); + setLocalSection({ + ...section, + fields: section.fields || [], + }); } }, [open, section]); @@ -59,42 +65,45 @@ export function SectionLayoutModal({ onOpenChange(false); }; + // fields 배열 (안전한 접근) + const fields = localSection.fields || []; + // 필드 추가 const addField = () => { const newField: FormFieldConfig = { ...defaultFieldConfig, id: generateFieldId(), - label: `새 필드 ${localSection.fields.length + 1}`, - columnName: `field_${localSection.fields.length + 1}`, + label: `새 필드 ${fields.length + 1}`, + columnName: `field_${fields.length + 1}`, }; updateSection({ - fields: [...localSection.fields, newField], + fields: [...fields, newField], }); }; // 필드 삭제 const removeField = (fieldId: string) => { updateSection({ - fields: localSection.fields.filter((f) => f.id !== fieldId), + fields: fields.filter((f) => f.id !== fieldId), }); }; // 필드 업데이트 const updateField = (fieldId: string, updates: Partial) => { updateSection({ - fields: localSection.fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)), + fields: fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)), }); }; // 필드 이동 const moveField = (fieldId: string, direction: "up" | "down") => { - const index = localSection.fields.findIndex((f) => f.id === fieldId); + const index = fields.findIndex((f) => f.id === fieldId); if (index === -1) return; if (direction === "up" && index === 0) return; - if (direction === "down" && index === localSection.fields.length - 1) return; + if (direction === "down" && index === fields.length - 1) return; - const newFields = [...localSection.fields]; + const newFields = [...fields]; const targetIndex = direction === "up" ? index - 1 : index + 1; [newFields[index], newFields[targetIndex]] = [newFields[targetIndex], newFields[index]]; @@ -317,7 +326,7 @@ export function SectionLayoutModal({

필드 목록

- {localSection.fields.length}개 + {fields.length}개
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx new file mode 100644 index 00000000..797bce55 --- /dev/null +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx @@ -0,0 +1,1247 @@ +"use client"; + +import React, { useState, useEffect, useMemo } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// 타입 import +import { + TableColumnConfig, + ValueMappingConfig, + ColumnModeConfig, + TableJoinCondition, + LookupConfig, + LookupOption, + LookupCondition, + VALUE_MAPPING_TYPE_OPTIONS, + JOIN_SOURCE_TYPE_OPTIONS, + TABLE_COLUMN_TYPE_OPTIONS, + LOOKUP_TYPE_OPTIONS, + LOOKUP_CONDITION_SOURCE_OPTIONS, +} from "../types"; + +import { + defaultValueMappingConfig, + defaultColumnModeConfig, + generateColumnModeId, +} from "../config"; + +// 도움말 텍스트 컴포넌트 +const HelpText = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +interface TableColumnSettingsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + column: TableColumnConfig; + sourceTableName: string; // 소스 테이블명 + sourceTableColumns: { column_name: string; data_type: string; comment?: string }[]; + formFields: { columnName: string; label: string; sectionId?: string; sectionTitle?: string }[]; // formData 필드 목록 (섹션 정보 포함) + sections: { id: string; title: string }[]; // 섹션 목록 + onSave: (updatedColumn: TableColumnConfig) => void; + tables: { table_name: string; comment?: string }[]; + tableColumns: Record; + onLoadTableColumns: (tableName: string) => void; +} + +export function TableColumnSettingsModal({ + open, + onOpenChange, + column, + sourceTableName, + sourceTableColumns, + formFields, + sections, + onSave, + tables, + tableColumns, + onLoadTableColumns, +}: TableColumnSettingsModalProps) { + // 로컬 상태 + const [localColumn, setLocalColumn] = useState({ ...column }); + + // 외부 테이블 검색 상태 + const [externalTableOpen, setExternalTableOpen] = useState(false); + + // 조회 테이블 검색 상태 (옵션별) + const [lookupTableOpenMap, setLookupTableOpenMap] = useState>({}); + + // 활성 탭 + const [activeTab, setActiveTab] = useState("basic"); + + // open이 변경될 때마다 데이터 동기화 + useEffect(() => { + if (open) { + setLocalColumn({ ...column }); + } + }, [open, column]); + + // 외부 테이블 컬럼 로드 + const externalTableName = localColumn.valueMapping?.externalRef?.tableName; + useEffect(() => { + if (externalTableName) { + onLoadTableColumns(externalTableName); + } + }, [externalTableName, onLoadTableColumns]); + + // 외부 테이블의 컬럼 목록 + const externalTableColumns = useMemo(() => { + if (!externalTableName) return []; + return tableColumns[externalTableName] || []; + }, [tableColumns, externalTableName]); + + // 컬럼 업데이트 함수 + const updateColumn = (updates: Partial) => { + setLocalColumn((prev) => ({ ...prev, ...updates })); + }; + + // 값 매핑 업데이트 + const updateValueMapping = (updates: Partial) => { + const current = localColumn.valueMapping || { ...defaultValueMappingConfig }; + updateColumn({ + valueMapping: { ...current, ...updates }, + }); + }; + + // 외부 참조 업데이트 + const updateExternalRef = (updates: Partial>) => { + const current = localColumn.valueMapping?.externalRef || { + tableName: "", + valueColumn: "", + joinConditions: [], + }; + updateValueMapping({ + externalRef: { ...current, ...updates }, + }); + }; + + // 조인 조건 추가 + const addJoinCondition = () => { + const current = localColumn.valueMapping?.externalRef?.joinConditions || []; + const newCondition: TableJoinCondition = { + sourceType: "row", + sourceField: "", + targetColumn: "", + operator: "=", + }; + updateExternalRef({ + joinConditions: [...current, newCondition], + }); + }; + + // 조인 조건 삭제 + const removeJoinCondition = (index: number) => { + const current = localColumn.valueMapping?.externalRef?.joinConditions || []; + updateExternalRef({ + joinConditions: current.filter((_, i) => i !== index), + }); + }; + + // 조인 조건 업데이트 + const updateJoinCondition = (index: number, updates: Partial) => { + const current = localColumn.valueMapping?.externalRef?.joinConditions || []; + updateExternalRef({ + joinConditions: current.map((c, i) => (i === index ? { ...c, ...updates } : c)), + }); + }; + + // 컬럼 모드 추가 + const addColumnMode = () => { + const newMode: ColumnModeConfig = { + ...defaultColumnModeConfig, + id: generateColumnModeId(), + label: `모드 ${(localColumn.columnModes || []).length + 1}`, + }; + updateColumn({ + columnModes: [...(localColumn.columnModes || []), newMode], + }); + }; + + // 컬럼 모드 삭제 + const removeColumnMode = (index: number) => { + updateColumn({ + columnModes: (localColumn.columnModes || []).filter((_, i) => i !== index), + }); + }; + + // 컬럼 모드 업데이트 + const updateColumnMode = (index: number, updates: Partial) => { + updateColumn({ + columnModes: (localColumn.columnModes || []).map((m, i) => + i === index ? { ...m, ...updates } : m + ), + }); + }; + + // ============================================ + // 조회(Lookup) 관련 함수들 + // ============================================ + + // 조회 설정 업데이트 + const updateLookup = (updates: Partial) => { + const current = localColumn.lookup || { enabled: false, options: [] }; + updateColumn({ + lookup: { ...current, ...updates }, + }); + }; + + // 조회 옵션 추가 + const addLookupOption = () => { + const newOption: LookupOption = { + id: `lookup_${Date.now()}`, + label: `조회 옵션 ${(localColumn.lookup?.options || []).length + 1}`, + type: "sameTable", + tableName: sourceTableName, // 기본값: 소스 테이블 + valueColumn: "", + conditions: [], + isDefault: (localColumn.lookup?.options || []).length === 0, // 첫 번째 옵션은 기본값 + }; + updateLookup({ + options: [...(localColumn.lookup?.options || []), newOption], + }); + }; + + // 조회 옵션 삭제 + const removeLookupOption = (index: number) => { + const newOptions = (localColumn.lookup?.options || []).filter((_, i) => i !== index); + // 삭제 후 기본 옵션이 없으면 첫 번째를 기본으로 + if (newOptions.length > 0 && !newOptions.some(opt => opt.isDefault)) { + newOptions[0].isDefault = true; + } + updateLookup({ options: newOptions }); + }; + + // 조회 옵션 업데이트 + const updateLookupOption = (index: number, updates: Partial) => { + updateLookup({ + options: (localColumn.lookup?.options || []).map((opt, i) => + i === index ? { ...opt, ...updates } : opt + ), + }); + }; + + // 조회 조건 추가 + const addLookupCondition = (optionIndex: number) => { + const option = localColumn.lookup?.options?.[optionIndex]; + if (!option) return; + + const newCondition: LookupCondition = { + sourceType: "currentRow", + sourceField: "", + targetColumn: "", + }; + updateLookupOption(optionIndex, { + conditions: [...(option.conditions || []), newCondition], + }); + }; + + // 조회 조건 삭제 + const removeLookupCondition = (optionIndex: number, conditionIndex: number) => { + const option = localColumn.lookup?.options?.[optionIndex]; + if (!option) return; + + updateLookupOption(optionIndex, { + conditions: option.conditions.filter((_, i) => i !== conditionIndex), + }); + }; + + // 조회 조건 업데이트 + const updateLookupCondition = (optionIndex: number, conditionIndex: number, updates: Partial) => { + const option = localColumn.lookup?.options?.[optionIndex]; + if (!option) return; + + updateLookupOption(optionIndex, { + conditions: option.conditions.map((c, i) => + i === conditionIndex ? { ...c, ...updates } : c + ), + }); + }; + + // 조회 옵션의 테이블 컬럼 로드 + useEffect(() => { + if (localColumn.lookup?.enabled) { + localColumn.lookup.options?.forEach(option => { + if (option.tableName) { + onLoadTableColumns(option.tableName); + } + }); + } + }, [localColumn.lookup?.enabled, localColumn.lookup?.options, onLoadTableColumns]); + + // 저장 함수 + const handleSave = () => { + onSave(localColumn); + onOpenChange(false); + }; + + // 값 매핑 타입에 따른 설정 UI 렌더링 + const renderValueMappingConfig = () => { + const mappingType = localColumn.valueMapping?.type || "source"; + + switch (mappingType) { + case "source": + return ( +
+
+ + + 소스 테이블에서 복사할 컬럼을 선택하세요. +
+
+ ); + + case "manual": + return ( +
+ 사용자가 직접 입력하는 필드입니다. +
+ 기본값을 설정하려면 "기본 설정" 탭에서 설정하세요. +
+ ); + + case "internal": + return ( +
+
+ + + 같은 모달의 다른 필드 값을 참조합니다. +
+
+ ); + + case "external": + return ( +
+ {/* 외부 테이블 선택 */} +
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + updateExternalRef({ tableName: table.table_name }); + setExternalTableOpen(false); + }} + className="text-xs" + > + + {table.table_name} + + ))} + + + + + +
+ + {/* 가져올 컬럼 선택 */} + {externalTableName && ( +
+ + +
+ )} + + {/* 조인 조건 */} + {externalTableName && ( +
+
+ + +
+ + {(localColumn.valueMapping?.externalRef?.joinConditions || []).map((condition, index) => ( +
+ {/* 소스 타입 */} + + + {/* 소스 필드 */} + + + + + {/* 타겟 컬럼 */} + + + +
+ ))} + + {(localColumn.valueMapping?.externalRef?.joinConditions || []).length === 0 && ( +

+ 조인 조건을 추가하세요. +

+ )} +
+ )} +
+ ); + + default: + return null; + } + }; + + return ( + + + + 컬럼 상세 설정 + + "{localColumn.label}" 컬럼의 상세 설정을 구성합니다. + + + +
+ +
+ + + 기본 설정 + 조회 설정 + 값 매핑 + 컬럼 모드 + + + {/* 기본 설정 탭 */} + +
+
+ + updateColumn({ field: e.target.value })} + placeholder="field_name" + className="h-8 text-xs mt-1" + /> + 데이터베이스에 저장될 컬럼명입니다. +
+
+ + updateColumn({ label: e.target.value })} + placeholder="표시 라벨" + className="h-8 text-xs mt-1" + /> +
+
+ +
+
+ + +
+
+ + updateColumn({ width: e.target.value })} + placeholder="150px" + className="h-8 text-xs mt-1" + /> +
+
+ + updateColumn({ defaultValue: e.target.value })} + placeholder="기본값" + className="h-8 text-xs mt-1" + /> +
+
+ + + +
+

옵션

+
+ + + +
+
+ + {/* Select 옵션 (타입이 select일 때) */} + {localColumn.type === "select" && ( + <> + +
+

Select 옵션

+
+ {(localColumn.selectOptions || []).map((opt, index) => ( +
+ { + const newOptions = [...(localColumn.selectOptions || [])]; + newOptions[index] = { ...newOptions[index], value: e.target.value }; + updateColumn({ selectOptions: newOptions }); + }} + placeholder="값" + className="h-8 text-xs flex-1" + /> + { + const newOptions = [...(localColumn.selectOptions || [])]; + newOptions[index] = { ...newOptions[index], label: e.target.value }; + updateColumn({ selectOptions: newOptions }); + }} + placeholder="라벨" + className="h-8 text-xs flex-1" + /> + +
+ ))} + +
+
+ + )} +
+ + {/* 조회 설정 탭 */} + + {/* 조회 여부 토글 */} +
+
+ +

+ 다른 테이블에서 값을 조회하여 가져옵니다. +

+
+ { + if (checked) { + updateLookup({ enabled: true, options: [] }); + } else { + updateColumn({ lookup: undefined }); + } + }} + /> +
+ + {/* 조회 설정 (활성화 시) */} + {localColumn.lookup?.enabled && ( +
+ + +
+
+ +

+ 헤더에서 선택 가능한 조회 방식을 정의합니다. +

+
+ +
+ + {(localColumn.lookup?.options || []).length === 0 ? ( +
+

조회 옵션이 없습니다

+

+ "옵션 추가" 버튼을 클릭하여 조회 방식을 추가하세요. +

+
+ ) : ( +
+ {(localColumn.lookup?.options || []).map((option, optIndex) => ( +
+ {/* 옵션 헤더 */} +
+
+ {option.label || `옵션 ${optIndex + 1}`} + {option.isDefault && ( + 기본 + )} +
+ +
+ + {/* 기본 설정 */} +
+
+ + updateLookupOption(optIndex, { label: e.target.value })} + placeholder="예: 기준단가" + className="h-8 text-xs mt-1" + /> +
+
+ + +
+
+ + {/* 조회 테이블 선택 */} +
+
+ + {option.type === "sameTable" ? ( + + ) : ( + setLookupTableOpenMap(prev => ({ ...prev, [option.id]: open }))} + > + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + updateLookupOption(optIndex, { tableName: table.table_name }); + onLoadTableColumns(table.table_name); + setLookupTableOpenMap(prev => ({ ...prev, [option.id]: false })); + }} + className="text-xs" + > + + {table.table_name} + + ))} + + + + + + )} +
+
+ + +
+
+ + {/* 기본 옵션 체크박스 */} +
+ { + if (checked) { + // 기본 옵션은 하나만 + updateLookup({ + options: (localColumn.lookup?.options || []).map((opt, i) => ({ + ...opt, + isDefault: i === optIndex, + })), + }); + } else { + updateLookupOption(optIndex, { isDefault: false }); + } + }} + className="scale-75" + /> + 기본 옵션으로 설정 +
+ + + + {/* 조회 조건 */} +
+
+ + +
+ + {(option.conditions || []).length === 0 ? ( +

+ 조회 조건을 추가하세요. +

+ ) : ( +
+ {option.conditions.map((condition, condIndex) => ( +
+ {/* 소스 타입 */} + + + {/* 섹션 선택 (sectionField일 때) */} + {condition.sourceType === "sectionField" && ( + + )} + + {/* 소스 필드 */} + + + = + + {/* 타겟 컬럼 */} + + + +
+ ))} +
+ )} + + {/* 조회 유형별 설명 */} +
+ {option.type === "sameTable" && ( + <> + 동일 테이블 조회: 검색 모달에서 선택한 행의 다른 컬럼 값을 가져옵니다. +
예: 품목 선택 시 → 품목 테이블의 기준단가 + + )} + {option.type === "relatedTable" && ( + <> + 연관 테이블 조회: 현재 행 데이터를 기준으로 다른 테이블에서 값을 조회합니다. +
예: 품목코드로 → 품목별단가 테이블에서 단가 조회 + + )} + {option.type === "combinedLookup" && ( + <> + 복합 조건 조회: 다른 섹션 필드와 현재 행을 조합하여 조회합니다. +
예: 거래처(섹션1) + 품목(현재행) → 거래처별단가 테이블 + + )} +
+
+
+ ))} +
+ )} +
+ )} +
+ + {/* 값 매핑 탭 */} + +
+ + + 이 컬럼의 값을 어디서 가져올지 설정합니다. +
+ + + + {renderValueMappingConfig()} +
+ + {/* 컬럼 모드 탭 */} + +
+
+ +

+ 하나의 컬럼에서 여러 데이터 소스를 전환하여 사용할 수 있습니다. +

+
+ +
+ + {(localColumn.columnModes || []).length === 0 ? ( +
+

컬럼 모드가 없습니다

+

+ 예: 기준 단가 / 거래처별 단가를 전환하여 표시 +

+
+ ) : ( +
+ {(localColumn.columnModes || []).map((mode, index) => ( +
+
+
+ {mode.label || `모드 ${index + 1}`} + {mode.isDefault && ( + 기본 + )} +
+ +
+ +
+
+ + updateColumnMode(index, { label: e.target.value })} + placeholder="예: 기준 단가" + className="h-8 text-xs mt-1" + /> +
+
+ +
+
+ +
+ + +
+
+ ))} +
+ )} +
+
+
+
+
+ + + + + +
+
+ ); +} + diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx new file mode 100644 index 00000000..5a845db0 --- /dev/null +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -0,0 +1,2154 @@ +"use client"; + +import React, { useState, useEffect, useMemo } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings, Check, ChevronsUpDown, Filter, Table as TableIcon, Search } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// 타입 import +import { + FormSectionConfig, + TableSectionConfig, + TableColumnConfig, + TablePreFilter, + TableModalFilter, + TableCalculationRule, + LookupOption, + ExternalTableLookup, + TABLE_COLUMN_TYPE_OPTIONS, + FILTER_OPERATOR_OPTIONS, + MODAL_FILTER_TYPE_OPTIONS, + LOOKUP_TYPE_OPTIONS, + LOOKUP_CONDITION_SOURCE_OPTIONS, +} from "../types"; + +import { + defaultTableSectionConfig, + defaultTableColumnConfig, + defaultPreFilterConfig, + defaultModalFilterConfig, + defaultCalculationRuleConfig, + generateTableColumnId, + generateFilterId, +} from "../config"; + +// 도움말 텍스트 컴포넌트 +const HelpText = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +// 컬럼 설정 아이템 컴포넌트 +interface ColumnSettingItemProps { + col: TableColumnConfig; + index: number; + totalCount: number; + saveTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; + displayColumns: string[]; // 검색 설정에서 선택한 표시 컬럼 목록 + sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 소스 테이블 컬럼 + sourceTableName: string; // 소스 테이블명 + tables: { table_name: string; comment?: string }[]; // 전체 테이블 목록 + tableColumns: Record; // 테이블별 컬럼 + sections: { id: string; title: string }[]; // 섹션 목록 + formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록 + tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용 + onLoadTableColumns: (tableName: string) => void; + onUpdate: (updates: Partial) => void; + onMoveUp: () => void; + onMoveDown: () => void; + onRemove: () => void; +} + +function ColumnSettingItem({ + col, + index, + totalCount, + saveTableColumns, + displayColumns, + sourceTableColumns, + sourceTableName, + tables, + tableColumns, + sections, + formFields, + tableConfig, + onLoadTableColumns, + onUpdate, + onMoveUp, + onMoveDown, + onRemove, +}: ColumnSettingItemProps) { + const [fieldSearchOpen, setFieldSearchOpen] = useState(false); + const [sourceFieldSearchOpen, setSourceFieldSearchOpen] = useState(false); + const [lookupTableOpenMap, setLookupTableOpenMap] = useState>({}); + + // 조회 옵션 추가 + const addLookupOption = () => { + const newOption: LookupOption = { + id: `lookup_${Date.now()}`, + label: `조회 옵션 ${(col.lookup?.options || []).length + 1}`, + type: "sameTable", + tableName: sourceTableName, + valueColumn: "", + conditions: [], + isDefault: (col.lookup?.options || []).length === 0, + }; + onUpdate({ + lookup: { + enabled: true, + options: [...(col.lookup?.options || []), newOption], + }, + }); + }; + + // 조회 옵션 삭제 + const removeLookupOption = (optIndex: number) => { + const newOptions = (col.lookup?.options || []).filter((_, i) => i !== optIndex); + if (newOptions.length > 0 && !newOptions.some((opt) => opt.isDefault)) { + newOptions[0].isDefault = true; + } + onUpdate({ + lookup: { + enabled: col.lookup?.enabled ?? false, + options: newOptions, + }, + }); + }; + + // 조회 옵션 업데이트 + const updateLookupOption = (optIndex: number, updates: Partial) => { + onUpdate({ + lookup: { + enabled: col.lookup?.enabled ?? false, + options: (col.lookup?.options || []).map((opt, i) => + i === optIndex ? { ...opt, ...updates } : opt + ), + }, + }); + }; + + // 조회 조건 추가 + const addLookupCondition = (optIndex: number) => { + const option = col.lookup?.options?.[optIndex]; + if (!option) return; + const newCondition: LookupCondition = { + sourceType: "currentRow", + sourceField: "", + targetColumn: "", + }; + updateLookupOption(optIndex, { + conditions: [...(option.conditions || []), newCondition], + }); + }; + + // 조회 조건 삭제 + const removeLookupCondition = (optIndex: number, condIndex: number) => { + const option = col.lookup?.options?.[optIndex]; + if (!option) return; + updateLookupOption(optIndex, { + conditions: option.conditions.filter((_, i) => i !== condIndex), + }); + }; + + // 조회 조건 업데이트 + const updateLookupCondition = (optIndex: number, condIndex: number, updates: Partial) => { + const option = col.lookup?.options?.[optIndex]; + if (!option) return; + updateLookupOption(optIndex, { + conditions: option.conditions.map((c, i) => + i === condIndex ? { ...c, ...updates } : c + ), + }); + }; + + return ( +
+
+
+ + {col.label || col.field || `컬럼 ${index + 1}`} + + {TABLE_COLUMN_TYPE_OPTIONS.find((t) => t.value === col.type)?.label || col.type} + + {col.calculated && 계산} +
+
+ + + +
+
+ +
+ {/* 필드명 - Combobox (저장할 컬럼) */} +
+ + + + + + + + + + + 필드를 찾을 수 없습니다. + + + {saveTableColumns.map((column) => ( + { + onUpdate({ + field: column.column_name, + // 라벨이 비어있으면 comment로 자동 설정 + ...((!col.label || col.label.startsWith("컬럼 ")) && column.comment ? { label: column.comment } : {}) + }); + setFieldSearchOpen(false); + }} + className="text-xs" + > + +
+ {column.column_name} + + {column.comment || column.data_type} + +
+
+ ))} +
+
+
+
+
+
+ + {/* 소스 필드 - Combobox (검색 모달에서 가져올 컬럼) */} +
+ + + + + + + + + + + 소스 필드를 찾을 수 없습니다. + + + {/* 필드명과 동일 옵션 */} + { + onUpdate({ sourceField: undefined }); + setSourceFieldSearchOpen(false); + }} + className="text-xs" + > + + (필드명과 동일) + + {/* 표시 컬럼 목록 */} + {displayColumns.map((colName) => { + const colInfo = sourceTableColumns.find((c) => c.column_name === colName); + return ( + { + onUpdate({ sourceField: colName }); + setSourceFieldSearchOpen(false); + }} + className="text-xs" + > + +
+ {colName} + {colInfo?.comment && ( + + {colInfo.comment} + + )} +
+
+ ); + })} +
+
+
+
+
+
+ + {/* 라벨 */} +
+ + onUpdate({ label: e.target.value })} + placeholder="표시 라벨" + className="h-8 text-xs mt-1" + /> +
+ + {/* 타입 */} +
+ + +
+ + {/* 너비 */} +
+ + onUpdate({ width: e.target.value })} + placeholder="150px" + className="h-8 text-xs mt-1" + /> +
+
+ + {/* 옵션 스위치 */} +
+ + + + + {/* 날짜 타입일 때만 일괄 적용 옵션 표시 */} + {col.type === "date" && ( + + )} +
+ + {/* 조회 설정 (조회 ON일 때만 표시) */} + {col.lookup?.enabled && ( +
+
+ + +
+ + {(col.lookup?.options || []).length === 0 ? ( +

+ "옵션 추가" 버튼을 클릭하여 조회 방식을 추가하세요. +

+ ) : ( +
+ {(col.lookup?.options || []).map((option, optIndex) => ( +
+ {/* 옵션 헤더 */} +
+
+ {option.label || `옵션 ${optIndex + 1}`} + {option.isDefault && ( + 기본 + )} +
+ +
+ + {/* 기본 설정 - 첫 번째 줄: 옵션명, 표시 라벨 */} +
+
+ + updateLookupOption(optIndex, { label: e.target.value })} + placeholder="예: 기준단가" + className="h-7 text-xs mt-0.5" + /> +
+
+ + updateLookupOption(optIndex, { displayLabel: e.target.value })} + placeholder={`예: 단가 (${option.label || "옵션명"})`} + className="h-7 text-xs mt-0.5" + /> +

+ 비워두면 옵션명만 표시 +

+
+
+ + {/* 기본 설정 - 두 번째 줄: 조회 유형, 테이블, 가져올 컬럼 */} +
+
+ + +
+
+ + {option.type === "sameTable" ? ( + + ) : ( + setLookupTableOpenMap((prev) => ({ ...prev, [option.id]: open }))} + > + + + + + + + + 없음 + + {tables.map((table) => ( + { + updateLookupOption(optIndex, { tableName: table.table_name }); + onLoadTableColumns(table.table_name); + setLookupTableOpenMap((prev) => ({ ...prev, [option.id]: false })); + }} + className="text-xs" + > + + {table.table_name} + + ))} + + + + + + )} +
+
+ + +
+
+ + {/* 기본 옵션 & 조회 조건 */} +
+ + +
+ + {/* 조회 조건 목록 */} + {(option.conditions || []).length > 0 && ( +
+ {option.conditions.map((cond, condIndex) => ( +
+ {/* 기본 조건 행 */} +
+ + + {/* 다른 섹션 선택 시 - 섹션 드롭다운 */} + {cond.sourceType === "sectionField" && ( + + )} + + {/* 현재 행 / 소스 테이블 / 다른 섹션 - 필드 선택 */} + {cond.sourceType !== "externalTable" && ( +
+ + {cond.sourceField && ( +

+ {cond.sourceType === "currentRow" + ? `rowData.${cond.sourceField}` + : cond.sourceType === "sourceTable" + ? `${sourceTableName}.${cond.sourceField}` + : `formData.${cond.sourceField}` + } +

+ )} +
+ )} + + {/* 현재 행 / 소스 테이블 / 다른 섹션일 때 = 기호와 조회 컬럼 */} + {cond.sourceType !== "externalTable" && ( + <> + = + +
+ + {cond.targetColumn && option.tableName && ( +

+ {option.tableName}.{cond.targetColumn} +

+ )} +
+ + )} + + +
+ + {/* 외부 테이블 조회 설정 */} + {cond.sourceType === "externalTable" && cond.externalLookup && ( +
+

외부 테이블에서 조건 값 조회

+ + {/* 1행: 조회 테이블 선택 */} +
+
+

조회 테이블

+ setLookupTableOpenMap(prev => ({ ...prev, [`ext_${optIndex}_${condIndex}`]: open }))} + > + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + { + onLoadTableColumns(table.table_name); + updateLookupCondition(optIndex, condIndex, { + externalLookup: { ...cond.externalLookup!, tableName: table.table_name, matchColumn: "", resultColumn: "" } + }); + setLookupTableOpenMap(prev => ({ ...prev, [`ext_${optIndex}_${condIndex}`]: false })); + }} + className="text-xs" + > + + {table.table_name} + + ))} + + + + + +
+ +
+

찾을 컬럼

+ +
+ +
+

가져올 컬럼

+ +
+
+ + {/* 2행: 비교 값 출처 */} +
+

비교 값 출처 (찾을 때 사용할 값)

+
+ + + {cond.externalLookup.matchSourceType === "sectionField" && ( + + )} + + +
+
+ + {/* 3행: 최종 조회 컬럼 */} +
+ 조회된 값 (비교할 컬럼) + = + +
+ + {/* 설명 텍스트 */} + {cond.externalLookup.tableName && cond.externalLookup.matchColumn && cond.externalLookup.resultColumn && cond.targetColumn && ( +

+ {cond.externalLookup.tableName}에서 {cond.externalLookup.matchColumn} = 입력값(비교 값 출처)인 행의{" "} + {cond.externalLookup.resultColumn} 값을 가져와 {option.tableName}.{cond.targetColumn}와 비교 +

+ )} +
+ )} + + {/* 값 변환 설정 (다른 섹션일 때만 표시) */} + {cond.sourceType === "sectionField" && ( +
+ + + {cond.transform?.enabled && ( +
+
+
+ + + + + + + + + + 없음 + + {tables.map((table) => ( + { + updateLookupCondition(optIndex, condIndex, { + transform: { ...cond.transform!, tableName: table.table_name, matchColumn: "", resultColumn: "" } + }); + onLoadTableColumns(table.table_name); + }} + className="text-xs" + > + + {table.table_name} + + ))} + + + + + +
+
+ + +
+
+ + +
+
+ {cond.transform.tableName && cond.transform.matchColumn && cond.transform.resultColumn && ( +

+ {cond.transform.tableName}에서 {cond.transform.matchColumn} = 입력값 인 행의 {cond.transform.resultColumn} 값으로 변환 +

+ )} +
+ )} +
+ )} +
+ ))} +
+ )} + + {/* 조회 유형 설명 */} +

+ {option.type === "sameTable" && "동일 테이블: 검색 모달에서 선택한 행의 다른 컬럼 값"} + {option.type === "relatedTable" && "연관 테이블: 현재 행 데이터로 다른 테이블 조회"} + {option.type === "combinedLookup" && "복합 조건: 다른 섹션 필드 + 현재 행 조합 조회"} +

+
+ ))} +
+ )} +
+ )} +
+ ); +} + +interface TableSectionSettingsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + section: FormSectionConfig; + onSave: (updates: Partial) => void; + tables: { table_name: string; comment?: string }[]; + tableColumns: Record; + onLoadTableColumns: (tableName: string) => void; + // 카테고리 목록 (table_column_category_values에서 가져옴) + categoryList?: { tableName: string; columnName: string; displayName?: string }[]; + onLoadCategoryList?: () => void; + // 전체 섹션 목록 (다른 섹션 필드 참조용) + allSections?: FormSectionConfig[]; +} + +export function TableSectionSettingsModal({ + open, + onOpenChange, + section, + onSave, + tables, + tableColumns, + onLoadTableColumns, + categoryList = [], + onLoadCategoryList, + allSections = [], +}: TableSectionSettingsModalProps) { + // 로컬 상태 + const [title, setTitle] = useState(section.title); + const [description, setDescription] = useState(section.description || ""); + const [tableConfig, setTableConfig] = useState( + section.tableConfig || { ...defaultTableSectionConfig } + ); + + // 테이블 검색 Combobox 상태 + const [tableSearchOpen, setTableSearchOpen] = useState(false); + const [saveTableSearchOpen, setSaveTableSearchOpen] = useState(false); + + // 활성 탭 + const [activeTab, setActiveTab] = useState("source"); + + // open이 변경될 때마다 데이터 동기화 + useEffect(() => { + if (open) { + setTitle(section.title); + setDescription(section.description || ""); + setTableConfig(section.tableConfig || { ...defaultTableSectionConfig }); + } + }, [open, section]); + + // 소스 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (tableConfig.source.tableName) { + onLoadTableColumns(tableConfig.source.tableName); + } + }, [tableConfig.source.tableName, onLoadTableColumns]); + + // 저장 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (tableConfig.saveConfig?.targetTable) { + onLoadTableColumns(tableConfig.saveConfig.targetTable); + } + }, [tableConfig.saveConfig?.targetTable, onLoadTableColumns]); + + // 조회 설정에 있는 테이블들의 컬럼 로드 (모달 열릴 때) + useEffect(() => { + if (open && tableConfig.columns) { + const tablesToLoad = new Set(); + + // 각 컬럼의 lookup 설정에서 테이블 수집 + tableConfig.columns.forEach((col) => { + if (col.lookup?.enabled && col.lookup.options) { + col.lookup.options.forEach((option) => { + // 조회 테이블 + if (option.tableName) { + tablesToLoad.add(option.tableName); + } + // 변환 테이블 + option.conditions?.forEach((cond) => { + if (cond.transform?.enabled && cond.transform.tableName) { + tablesToLoad.add(cond.transform.tableName); + } + }); + }); + } + }); + + // 수집된 테이블들의 컬럼 로드 + tablesToLoad.forEach((tableName) => { + if (!tableColumns[tableName]) { + onLoadTableColumns(tableName); + } + }); + } + }, [open, tableConfig.columns, tableColumns, onLoadTableColumns]); + + // 소스 테이블의 컬럼 목록 + const sourceTableColumns = useMemo(() => { + return tableColumns[tableConfig.source.tableName] || []; + }, [tableColumns, tableConfig.source.tableName]); + + // 저장 테이블의 컬럼 목록 + const saveTableColumns = useMemo(() => { + // 저장 테이블이 지정되어 있으면 해당 테이블의 컬럼, 아니면 소스 테이블의 컬럼 사용 + const targetTable = tableConfig.saveConfig?.targetTable; + if (targetTable) { + return tableColumns[targetTable] || []; + } + return sourceTableColumns; + }, [tableColumns, tableConfig.saveConfig?.targetTable, sourceTableColumns]); + + // 다른 섹션 목록 (현재 섹션 제외, 테이블 타입이 아닌 섹션만) + const otherSections = useMemo(() => { + return allSections + .filter((s) => s.id !== section.id && s.type !== "table") + .map((s) => ({ id: s.id, title: s.title })); + }, [allSections, section.id]); + + // 다른 섹션의 필드 목록 + const otherSectionFields = useMemo(() => { + const fields: { columnName: string; label: string; sectionId: string }[] = []; + allSections + .filter((s) => s.id !== section.id && s.type !== "table") + .forEach((s) => { + (s.fields || []).forEach((f) => { + fields.push({ + columnName: f.columnName, + label: f.label, + sectionId: s.id, + }); + }); + }); + return fields; + }, [allSections, section.id]); + + // 설정 업데이트 함수 + const updateTableConfig = (updates: Partial) => { + setTableConfig((prev) => ({ ...prev, ...updates })); + }; + + const updateSource = (updates: Partial) => { + updateTableConfig({ + source: { ...tableConfig.source, ...updates }, + }); + }; + + const updateFilters = (updates: Partial) => { + updateTableConfig({ + filters: { ...tableConfig.filters, ...updates }, + }); + }; + + const updateUiConfig = (updates: Partial>) => { + updateTableConfig({ + uiConfig: { ...tableConfig.uiConfig, ...updates }, + }); + }; + + const updateSaveConfig = (updates: Partial>) => { + updateTableConfig({ + saveConfig: { ...tableConfig.saveConfig, ...updates }, + }); + }; + + // 저장 함수 + const handleSave = () => { + onSave({ + title, + description, + tableConfig, + }); + onOpenChange(false); + }; + + // 컬럼 추가 + const addColumn = () => { + const newColumn: TableColumnConfig = { + ...defaultTableColumnConfig, + field: `column_${(tableConfig.columns || []).length + 1}`, + label: `컬럼 ${(tableConfig.columns || []).length + 1}`, + }; + updateTableConfig({ + columns: [...(tableConfig.columns || []), newColumn], + }); + }; + + // 컬럼 삭제 + const removeColumn = (index: number) => { + updateTableConfig({ + columns: (tableConfig.columns || []).filter((_, i) => i !== index), + }); + }; + + // 컬럼 업데이트 + const updateColumn = (index: number, updates: Partial) => { + updateTableConfig({ + columns: (tableConfig.columns || []).map((col, i) => + i === index ? { ...col, ...updates } : col + ), + }); + }; + + // 컬럼 이동 + const moveColumn = (index: number, direction: "up" | "down") => { + const columns = [...(tableConfig.columns || [])]; + if (direction === "up" && index > 0) { + [columns[index - 1], columns[index]] = [columns[index], columns[index - 1]]; + } else if (direction === "down" && index < columns.length - 1) { + [columns[index], columns[index + 1]] = [columns[index + 1], columns[index]]; + } + updateTableConfig({ columns }); + }; + + // 사전 필터 추가 + const addPreFilter = () => { + const newFilter: TablePreFilter = { ...defaultPreFilterConfig }; + updateFilters({ + preFilters: [...(tableConfig.filters?.preFilters || []), newFilter], + }); + }; + + // 사전 필터 삭제 + const removePreFilter = (index: number) => { + updateFilters({ + preFilters: (tableConfig.filters?.preFilters || []).filter((_, i) => i !== index), + }); + }; + + // 사전 필터 업데이트 + const updatePreFilter = (index: number, updates: Partial) => { + updateFilters({ + preFilters: (tableConfig.filters?.preFilters || []).map((f, i) => + i === index ? { ...f, ...updates } : f + ), + }); + }; + + // 모달 필터 추가 + const addModalFilter = () => { + const newFilter: TableModalFilter = { ...defaultModalFilterConfig }; + updateFilters({ + modalFilters: [...(tableConfig.filters?.modalFilters || []), newFilter], + }); + }; + + // 모달 필터 삭제 + const removeModalFilter = (index: number) => { + updateFilters({ + modalFilters: (tableConfig.filters?.modalFilters || []).filter((_, i) => i !== index), + }); + }; + + // 모달 필터 업데이트 + const updateModalFilter = (index: number, updates: Partial) => { + updateFilters({ + modalFilters: (tableConfig.filters?.modalFilters || []).map((f, i) => + i === index ? { ...f, ...updates } : f + ), + }); + }; + + // 계산 규칙 추가 + const addCalculation = () => { + const newCalc: TableCalculationRule = { ...defaultCalculationRuleConfig }; + updateTableConfig({ + calculations: [...(tableConfig.calculations || []), newCalc], + }); + }; + + // 계산 규칙 삭제 + const removeCalculation = (index: number) => { + updateTableConfig({ + calculations: (tableConfig.calculations || []).filter((_, i) => i !== index), + }); + }; + + // 계산 규칙 업데이트 + const updateCalculation = (index: number, updates: Partial) => { + updateTableConfig({ + calculations: (tableConfig.calculations || []).map((c, i) => + i === index ? { ...c, ...updates } : c + ), + }); + }; + + // 표시 컬럼 토글 + const toggleDisplayColumn = (columnName: string) => { + const current = tableConfig.source.displayColumns || []; + if (current.includes(columnName)) { + updateSource({ + displayColumns: current.filter((c) => c !== columnName), + }); + } else { + updateSource({ + displayColumns: [...current, columnName], + }); + } + }; + + // 검색 컬럼 토글 + const toggleSearchColumn = (columnName: string) => { + const current = tableConfig.source.searchColumns || []; + if (current.includes(columnName)) { + updateSource({ + searchColumns: current.filter((c) => c !== columnName), + }); + } else { + updateSource({ + searchColumns: [...current, columnName], + }); + } + }; + + // 표시 컬럼 순서 변경 + const moveDisplayColumn = (index: number, direction: "up" | "down") => { + const columns = [...(tableConfig.source.displayColumns || [])]; + if (direction === "up" && index > 0) { + [columns[index - 1], columns[index]] = [columns[index], columns[index - 1]]; + } else if (direction === "down" && index < columns.length - 1) { + [columns[index], columns[index + 1]] = [columns[index + 1], columns[index]]; + } + updateSource({ displayColumns: columns }); + }; + + // 표시 컬럼 삭제 (순서 편집 영역에서) + const removeDisplayColumn = (columnName: string) => { + updateSource({ + displayColumns: (tableConfig.source.displayColumns || []).filter((c) => c !== columnName), + }); + }; + + return ( + + + + 테이블 섹션 설정 + + 테이블 형식의 데이터를 표시하고 편집하는 섹션을 설정합니다. + + + +
+ +
+ {/* 기본 정보 */} +
+

기본 정보

+
+
+ + setTitle(e.target.value)} + placeholder="예: 품목 목록" + className="h-9 text-sm" + /> +
+
+ + setDescription(e.target.value)} + placeholder="섹션에 대한 설명" + className="h-9 text-sm" + /> +
+
+
+ + {/* 탭 구성 */} + + + 테이블 설정 + 컬럼 설정 + 검색 설정 + 고급 설정 + + + {/* 테이블 설정 탭 */} + + {/* 소스 테이블 설정 */} +
+

검색용 소스 테이블

+

검색 모달에서 데이터를 가져올 테이블입니다.

+ +
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + updateSource({ tableName: table.table_name }); + setTableSearchOpen(false); + }} + className="text-sm" + > + +
+ {table.table_name} + {table.comment && ( + {table.comment} + )} +
+
+ ))} +
+
+
+
+
+
+
+ + {/* 저장 테이블 설정 */} +
+

저장용 테이블

+

테이블 섹션 데이터를 저장할 테이블입니다. 미설정 시 메인 테이블에 저장됩니다.

+ +
+
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + { + updateSaveConfig({ targetTable: undefined }); + setSaveTableSearchOpen(false); + }} + className="text-sm" + > + + (메인 테이블과 동일) + + {tables.map((table) => ( + { + updateSaveConfig({ targetTable: table.table_name }); + // 선택 즉시 컬럼 로드 요청 + onLoadTableColumns(table.table_name); + setSaveTableSearchOpen(false); + }} + className="text-sm" + > + +
+ {table.table_name} + {table.comment && ( + {table.comment} + )} +
+
+ ))} +
+
+
+
+
+
+
+ + updateSaveConfig({ uniqueField: e.target.value || undefined })} + placeholder="예: item_id" + className="h-9 text-sm mt-1" + /> + 동일 값이 있으면 추가하지 않습니다. +
+
+
+
+ + {/* 컬럼 설정 탭 */} + + {/* 안내 메시지 */} + {saveTableColumns.length === 0 && !tableConfig.saveConfig?.targetTable && !tableConfig.source.tableName && ( +
+

+ "테이블 설정" 탭에서 저장 테이블을 먼저 선택해주세요. 선택한 테이블의 컬럼을 여기서 설정할 수 있습니다. +

+
+ )} + + {/* 테이블은 선택했지만 컬럼이 아직 로드되지 않은 경우 */} + {saveTableColumns.length === 0 && (tableConfig.saveConfig?.targetTable || tableConfig.source.tableName) && ( +
+

+ 테이블 "{tableConfig.saveConfig?.targetTable || tableConfig.source.tableName}" 의 컬럼을 불러오는 중입니다... +

+
+ )} + +
+
+ + {saveTableColumns.length > 0 && ( +

+ 사용 가능한 컬럼: {saveTableColumns.length}개 ({tableConfig.saveConfig?.targetTable || tableConfig.source.tableName || "테이블 미선택"}) +

+ )} +
+ +
+ + {(tableConfig.columns || []).length === 0 ? ( +
+ +

컬럼이 없습니다

+

"컬럼 추가" 버튼으로 추가하세요

+
+ ) : ( +
+ {(tableConfig.columns || []).map((col, index) => ( + updateColumn(index, updates)} + onMoveUp={() => moveColumn(index, "up")} + onMoveDown={() => moveColumn(index, "down")} + onRemove={() => removeColumn(index)} + /> + ))} +
+ )} +
+ + {/* 검색 설정 탭 */} + + {/* 표시 컬럼 / 검색 컬럼 설정 */} +
+

검색 모달 컬럼 설정

+

검색 모달에서 보여줄 컬럼과 검색 대상 컬럼을 설정합니다.

+ + {/* 소스 테이블 미선택 시 안내 */} + {!tableConfig.source.tableName && ( +
+

+ "테이블 설정" 탭에서 검색용 소스 테이블을 먼저 선택해주세요. +

+
+ )} + + {/* 표시 컬럼 선택 */} + {sourceTableColumns.length > 0 && ( +
+ +
+
+ {sourceTableColumns.map((col) => ( + + ))} +
+
+ 선택된 컬럼: {(tableConfig.source.displayColumns || []).length}개 + + {/* 선택된 컬럼 순서 편집 */} + {(tableConfig.source.displayColumns || []).length > 0 && ( +
+ +
+ {(tableConfig.source.displayColumns || []).map((colName, index) => { + const colInfo = sourceTableColumns.find((c) => c.column_name === colName); + return ( +
+ + {colName} + {colInfo?.comment && ( + + {colInfo.comment} + + )} +
+ + + +
+
+ ); + })} +
+
+ )} +
+ )} + + {/* 검색 컬럼 선택 */} + {sourceTableColumns.length > 0 && ( +
+ +
+
+ {sourceTableColumns.map((col) => ( + + ))} +
+
+ 검색 컬럼: {(tableConfig.source.searchColumns || []).length}개 +
+ )} +
+ + + + {/* 사전 필터 */} +
+
+
+ +

항상 적용되는 필터 조건입니다.

+
+ +
+ + {(tableConfig.filters?.preFilters || []).map((filter, index) => ( +
+ + + + + updatePreFilter(index, { value: e.target.value })} + placeholder="값" + className="h-8 text-xs flex-1" + /> + + +
+ ))} +
+ + + + {/* 모달 필터 */} +
+
+
+ +

사용자가 선택할 수 있는 필터입니다.

+
+ +
+ + {(tableConfig.filters?.modalFilters || []).map((filter, index) => ( +
+
+ {/* 컬럼 선택 */} + + + {/* 라벨 */} + updateModalFilter(index, { label: e.target.value })} + placeholder="라벨" + className="h-8 text-xs w-[100px]" + /> + + {/* 타입 */} + + + {/* 카테고리 선택 (타입이 category일 때만 표시) */} + {filter.type === "category" && ( + + )} + + {/* 삭제 버튼 */} + +
+
+ ))} +
+
+ + {/* 고급 설정 탭 */} + + {/* UI 설정 */} +
+

UI 설정

+
+
+ + updateUiConfig({ addButtonText: e.target.value })} + placeholder="항목 검색" + className="h-8 text-xs mt-1" + /> +
+
+ + updateUiConfig({ modalTitle: e.target.value })} + placeholder="항목 검색 및 선택" + className="h-8 text-xs mt-1" + /> +
+
+ + updateUiConfig({ maxHeight: e.target.value })} + placeholder="400px" + className="h-8 text-xs mt-1" + /> +
+
+ +
+
+
+ + {/* 계산 규칙 */} +
+
+
+

계산 규칙

+

다른 컬럼 값을 기반으로 자동 계산합니다.

+
+ +
+ + {(tableConfig.calculations || []).map((calc, index) => ( +
+ + = + updateCalculation(index, { formula: e.target.value })} + placeholder="수식 (예: quantity * unit_price)" + className="h-8 text-xs flex-1" + /> + +
+ ))} +
+
+
+
+
+
+ + + + + +
+
+ ); +} diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 3b5801c2..4e25f7d7 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -184,7 +184,12 @@ export interface FormSectionConfig { description?: string; collapsible?: boolean; // 접을 수 있는지 (기본: false) defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false) - fields: FormFieldConfig[]; + + // 섹션 타입: fields (기본) 또는 table (테이블 형식) + type?: "fields" | "table"; + + // type: "fields" 일 때 사용 + fields?: FormFieldConfig[]; // 반복 섹션 (겸직 등) repeatable?: boolean; @@ -199,6 +204,294 @@ export interface FormSectionConfig { // 섹션 레이아웃 columns?: number; // 필드 배치 컬럼 수 (기본: 2) gap?: string; // 필드 간 간격 + + // type: "table" 일 때 사용 + tableConfig?: TableSectionConfig; +} + +// ============================================ +// 테이블 섹션 관련 타입 정의 +// ============================================ + +/** + * 테이블 섹션 설정 + * 모달 내에서 테이블 형식으로 데이터를 표시하고 편집하는 섹션 + */ +export interface TableSectionConfig { + // 1. 소스 설정 (검색 모달에서 데이터를 가져올 테이블) + source: { + tableName: string; // 소스 테이블명 (예: item_info) + displayColumns: string[]; // 모달에 표시할 컬럼 + searchColumns: string[]; // 검색 가능한 컬럼 + columnLabels?: Record; // 컬럼 라벨 (컬럼명 -> 표시 라벨) + }; + + // 2. 필터 설정 + filters?: { + // 사전 필터 (항상 적용, 사용자에게 노출되지 않음) + preFilters?: TablePreFilter[]; + + // 모달 내 필터 UI (사용자가 선택 가능) + modalFilters?: TableModalFilter[]; + }; + + // 3. 테이블 컬럼 설정 + columns: TableColumnConfig[]; + + // 4. 계산 규칙 + calculations?: TableCalculationRule[]; + + // 5. 저장 설정 + saveConfig?: { + targetTable?: string; // 다른 테이블에 저장 시 (미지정 시 메인 테이블) + uniqueField?: string; // 중복 체크 필드 + }; + + // 6. UI 설정 + uiConfig?: { + addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색") + modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택") + multiSelect?: boolean; // 다중 선택 허용 (기본: true) + maxHeight?: string; // 테이블 최대 높이 (기본: "400px") + }; +} + +/** + * 사전 필터 조건 + * 검색 시 항상 적용되는 필터 조건 + */ +export interface TablePreFilter { + column: string; // 필터할 컬럼 + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like"; + value: any; // 필터 값 +} + +/** + * 모달 내 필터 설정 + * 사용자가 선택할 수 있는 필터 UI + */ +export interface TableModalFilter { + column: string; // 필터할 컬럼 + label: string; // 필터 라벨 + type: "category" | "text"; // 필터 타입 (category: 드롭다운, text: 텍스트 입력) + + // 카테고리 참조 (type: "category"일 때) - 테이블에서 컬럼의 distinct 값 조회 + categoryRef?: { + tableName: string; // 테이블명 (예: "item_info") + columnName: string; // 컬럼명 (예: "division") + }; + + // 정적 옵션 (직접 입력한 경우) + options?: { value: string; label: string }[]; + + // 테이블에서 동적 로드 (테이블 컬럼 조회) + optionsFromTable?: { + tableName: string; + valueColumn: string; + labelColumn: string; + distinct?: boolean; // 중복 제거 (기본: true) + }; + + // 기본값 + defaultValue?: any; +} + +/** + * 테이블 컬럼 설정 + */ +export interface TableColumnConfig { + field: string; // 필드명 (저장할 컬럼명) + label: string; // 컬럼 헤더 라벨 + type: "text" | "number" | "date" | "select"; // 입력 타입 + + // 소스 필드 매핑 (검색 모달에서 가져올 컬럼명) + sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일) + + // 편집 설정 + editable?: boolean; // 편집 가능 여부 (기본: true) + calculated?: boolean; // 계산 필드 여부 (자동 읽기전용) + required?: boolean; // 필수 입력 여부 + + // 너비 설정 + width?: string; // 기본 너비 (예: "150px") + minWidth?: string; // 최소 너비 + maxWidth?: string; // 최대 너비 + + // 기본값 + defaultValue?: any; + + // Select 옵션 (type이 "select"일 때) + selectOptions?: { value: string; label: string }[]; + + // 값 매핑 (핵심 기능) - 고급 설정용 + valueMapping?: ValueMappingConfig; + + // 컬럼 모드 전환 (동적 데이터 소스) + columnModes?: ColumnModeConfig[]; + + // 조회 설정 (동적 값 조회) + lookup?: LookupConfig; + + // 날짜 일괄 적용 (type이 "date"일 때만 사용) + // 활성화 시 첫 번째 날짜 입력 시 모든 행에 동일한 날짜가 자동 적용됨 + batchApply?: boolean; +} + +// ============================================ +// 조회(Lookup) 설정 관련 타입 정의 +// ============================================ + +/** + * 조회 유형 + * - sameTable: 동일 테이블 조회 (소스 테이블에서 다른 컬럼 값) + * - relatedTable: 연관 테이블 조회 (현재 행 기준으로 다른 테이블에서) + * - combinedLookup: 복합 조건 조회 (다른 섹션 필드 + 현재 행 조합) + */ +export type LookupType = "sameTable" | "relatedTable" | "combinedLookup"; + +/** + * 값 변환 설정 + * 예: 거래처 이름 → 거래처 코드로 변환 + */ +export interface LookupTransform { + enabled: boolean; // 변환 사용 여부 + tableName: string; // 변환 테이블 (예: customer_mng) + matchColumn: string; // 찾을 컬럼 (예: customer_name) + resultColumn: string; // 가져올 컬럼 (예: customer_code) +} + +/** + * 외부 테이블 조회 설정 + * 다른 테이블에서 조건 값을 조회하여 사용 (이름→코드 변환 등) + */ +export interface ExternalTableLookup { + tableName: string; // 조회할 테이블 + matchColumn: string; // 조회 조건 컬럼 (WHERE 절에서 비교할 컬럼) + matchSourceType: "currentRow" | "sourceTable" | "sectionField"; // 비교값 출처 + matchSourceField: string; // 비교값 필드명 + matchSectionId?: string; // sectionField인 경우 섹션 ID + resultColumn: string; // 가져올 컬럼 (SELECT 절) +} + +/** + * 조회 조건 설정 + * + * sourceType 설명: + * - "currentRow": 테이블에 설정된 컬럼 필드값 (rowData에서 가져옴, 예: part_code, quantity) + * - "sourceTable": 원본 소스 테이블의 컬럼값 (_sourceData에서 가져옴, 예: item_number, company_code) + * - "sectionField": 폼의 다른 섹션 필드값 (formData에서 가져옴, 예: partner_id) + * - "externalTable": 외부 테이블에서 조회한 값 (다른 테이블에서 값을 조회해서 조건으로 사용) + */ +export interface LookupCondition { + sourceType: "currentRow" | "sourceTable" | "sectionField" | "externalTable"; // 값 출처 + sourceField: string; // 출처의 필드명 (참조할 필드) + sectionId?: string; // sectionField인 경우 섹션 ID + targetColumn: string; // 조회 테이블의 컬럼 + + // 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우) + externalLookup?: ExternalTableLookup; + + // 값 변환 설정 (선택) - 이름→코드 등 변환이 필요할 때 (레거시 호환) + transform?: LookupTransform; +} + +/** + * 조회 옵션 설정 + * 하나의 컬럼에 여러 조회 방식을 정의하고 헤더에서 선택 가능 + */ +export interface LookupOption { + id: string; // 옵션 고유 ID + label: string; // 옵션 라벨 (예: "기준단가", "거래처별 단가") + displayLabel?: string; // 헤더 드롭다운에 표시될 텍스트 (예: "기준단가" → "단가 (기준단가)") + type: LookupType; // 조회 유형 + + // 조회 테이블 설정 + tableName: string; // 조회할 테이블 + valueColumn: string; // 가져올 컬럼 + + // 조회 조건 (여러 조건 AND로 결합) + conditions: LookupCondition[]; + + // 기본 옵션 여부 + isDefault?: boolean; +} + +/** + * 컬럼 조회 설정 + */ +export interface LookupConfig { + enabled: boolean; // 조회 사용 여부 + options: LookupOption[]; // 조회 옵션 목록 + defaultOptionId?: string; // 기본 선택 옵션 ID +} + +/** + * 값 매핑 설정 + * 컬럼 값을 어디서 가져올지 정의 + */ +export interface ValueMappingConfig { + type: "source" | "manual" | "external" | "internal"; + + // type: "source" - 소스 테이블에서 복사 + sourceField?: string; // 소스 테이블의 컬럼명 + + // type: "external" - 외부 테이블 조회 + externalRef?: { + tableName: string; // 조회할 테이블 + valueColumn: string; // 가져올 컬럼 + joinConditions: TableJoinCondition[]; + }; + + // type: "internal" - formData의 다른 필드 값 직접 사용 + internalField?: string; // formData의 필드명 +} + +/** + * 테이블 조인 조건 + * 외부 테이블 조회 시 사용하는 조인 조건 + * + * sourceType 설명: + * - "row": 현재 행의 설정된 컬럼 (rowData) + * - "sourceData": 원본 소스 테이블 데이터 (_sourceData) + * - "formData": 폼의 다른 섹션 필드 (formData) + * - "externalTable": 외부 테이블에서 조회한 값 + */ +export interface TableJoinCondition { + sourceType: "row" | "sourceData" | "formData" | "externalTable"; // 값 출처 + sourceField: string; // 출처의 필드명 + targetColumn: string; // 조회 테이블의 컬럼 + operator?: "=" | "!=" | ">" | "<" | ">=" | "<="; // 연산자 (기본: "=") + + // 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우) + externalLookup?: ExternalTableLookup; + + // 값 변환 설정 (선택) - 이름→코드 등 중간 변환이 필요할 때 (레거시 호환) + transform?: { + tableName: string; // 변환 테이블 (예: customer_mng) + matchColumn: string; // 찾을 컬럼 (예: customer_name) + resultColumn: string; // 가져올 컬럼 (예: customer_code) + }; +} + +/** + * 컬럼 모드 설정 + * 하나의 컬럼에서 여러 데이터 소스를 전환하여 사용 + */ +export interface ColumnModeConfig { + id: string; // 모드 고유 ID + label: string; // 모드 라벨 (예: "기준 단가", "거래처별 단가") + isDefault?: boolean; // 기본 모드 여부 + valueMapping: ValueMappingConfig; // 이 모드의 값 매핑 +} + +/** + * 테이블 계산 규칙 + * 다른 컬럼 값을 기반으로 자동 계산 + */ +export interface TableCalculationRule { + resultField: string; // 결과를 저장할 필드 + formula: string; // 계산 공식 (예: "quantity * unit_price") + dependencies: string[]; // 의존하는 필드들 } // 다중 행 저장 설정 @@ -214,6 +507,21 @@ export interface MultiRowSaveConfig { mainSectionFields?: string[]; // 메인 행에만 저장할 필드 } +/** + * 섹션별 저장 방식 설정 + * 공통 저장: 해당 섹션의 필드 값이 모든 품목 행에 동일하게 저장됩니다 (예: 수주번호, 거래처) + * 개별 저장: 해당 섹션의 필드 값이 각 품목마다 다르게 저장됩니다 (예: 품목코드, 수량, 단가) + */ +export interface SectionSaveMode { + sectionId: string; + saveMode: "common" | "individual"; // 공통 저장 / 개별 저장 + // 필드별 세부 설정 (선택사항 - 섹션 기본값과 다르게 설정할 필드) + fieldOverrides?: { + fieldName: string; + saveMode: "common" | "individual"; + }[]; +} + // 저장 설정 export interface SaveConfig { tableName: string; @@ -225,6 +533,9 @@ export interface SaveConfig { // 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용) customApiSave?: CustomApiSaveConfig; + // 섹션별 저장 방식 설정 + sectionSaveModes?: SectionSaveMode[]; + // 저장 후 동작 (간편 설정) showToast?: boolean; // 토스트 메시지 (기본: true) refreshParent?: boolean; // 부모 새로고침 (기본: true) @@ -432,3 +743,69 @@ export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [ { value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" }, { value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" }, ] as const; + +// ============================================ +// 테이블 섹션 관련 상수 +// ============================================ + +// 섹션 타입 옵션 +export const SECTION_TYPE_OPTIONS = [ + { value: "fields", label: "필드 타입" }, + { value: "table", label: "테이블 타입" }, +] as const; + +// 테이블 컬럼 타입 옵션 +export const TABLE_COLUMN_TYPE_OPTIONS = [ + { value: "text", label: "텍스트" }, + { value: "number", label: "숫자" }, + { value: "date", label: "날짜" }, + { value: "select", label: "선택(드롭다운)" }, +] as const; + +// 값 매핑 타입 옵션 +export const VALUE_MAPPING_TYPE_OPTIONS = [ + { value: "source", label: "소스 테이블에서 복사" }, + { value: "manual", label: "사용자 직접 입력" }, + { value: "external", label: "외부 테이블 조회" }, + { value: "internal", label: "폼 데이터 참조" }, +] as const; + +// 조인 조건 소스 타입 옵션 +export const JOIN_SOURCE_TYPE_OPTIONS = [ + { value: "row", label: "현재 행 데이터" }, + { value: "formData", label: "폼 필드 값" }, +] as const; + +// 필터 연산자 옵션 +export const FILTER_OPERATOR_OPTIONS = [ + { value: "=", label: "같음 (=)" }, + { value: "!=", label: "다름 (!=)" }, + { value: ">", label: "큼 (>)" }, + { value: "<", label: "작음 (<)" }, + { value: ">=", label: "크거나 같음 (>=)" }, + { value: "<=", label: "작거나 같음 (<=)" }, + { value: "in", label: "포함 (IN)" }, + { value: "notIn", label: "미포함 (NOT IN)" }, + { value: "like", label: "유사 (LIKE)" }, +] as const; + +// 모달 필터 타입 옵션 +export const MODAL_FILTER_TYPE_OPTIONS = [ + { value: "category", label: "테이블 조회" }, + { value: "text", label: "텍스트 입력" }, +] as const; + +// 조회 유형 옵션 +export const LOOKUP_TYPE_OPTIONS = [ + { value: "sameTable", label: "동일 테이블 조회" }, + { value: "relatedTable", label: "연관 테이블 조회" }, + { value: "combinedLookup", label: "복합 조건 조회" }, +] as const; + +// 조회 조건 소스 타입 옵션 +export const LOOKUP_CONDITION_SOURCE_OPTIONS = [ + { value: "currentRow", label: "현재 행" }, + { value: "sourceTable", label: "소스 테이블" }, + { value: "sectionField", label: "다른 섹션" }, + { value: "externalTable", label: "외부 테이블" }, +] as const; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 19a41a52..de98028a 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -675,6 +675,14 @@ export class ButtonActionExecutor { console.log("⚠️ [handleSave] formData 전체 내용:", context.formData); } + // 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리 + // 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장 + const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData); + if (universalFormModalResult.handled) { + console.log("✅ [handleSave] Universal Form Modal 테이블 섹션 저장 완료"); + return universalFormModalResult.success; + } + // 폼 유효성 검사 if (config.validateForm) { const validation = this.validateFormData(formData); @@ -1479,6 +1487,244 @@ export class ButtonActionExecutor { } } + /** + * 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리 + * 범용_폼_모달 내부의 공통 필드 + _tableSection_ 데이터를 병합하여 품목별로 저장 + * 수정 모드: INSERT/UPDATE/DELETE 지원 + */ + private static async handleUniversalFormModalTableSectionSave( + config: ButtonActionConfig, + context: ButtonActionContext, + formData: Record, + ): Promise<{ handled: boolean; success: boolean }> { + const { tableName, screenId } = context; + + // 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음) + const universalFormModalKey = Object.keys(formData).find((key) => { + const value = formData[key]; + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + // _tableSection_ 키가 있는지 확인 + return Object.keys(value).some((k) => k.startsWith("_tableSection_")); + }); + + if (!universalFormModalKey) { + return { handled: false, success: false }; + } + + console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey); + + const modalData = formData[universalFormModalKey]; + + // _tableSection_ 데이터 추출 + const tableSectionData: Record = {}; + const commonFieldsData: Record = {}; + + // 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용) + // modalData 내부 또는 최상위 formData에서 찾음 + const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || []; + + for (const [key, value] of Object.entries(modalData)) { + if (key.startsWith("_tableSection_")) { + const sectionId = key.replace("_tableSection_", ""); + tableSectionData[sectionId] = value as any[]; + } else if (!key.startsWith("_")) { + // _로 시작하지 않는 필드는 공통 필드로 처리 + commonFieldsData[key] = value; + } + } + + console.log("🎯 [handleUniversalFormModalTableSectionSave] 데이터 분리:", { + commonFields: Object.keys(commonFieldsData), + tableSections: Object.keys(tableSectionData), + tableSectionCounts: Object.entries(tableSectionData).map(([k, v]) => ({ [k]: v.length })), + originalGroupedDataCount: originalGroupedData.length, + isEditMode: originalGroupedData.length > 0, + }); + + // 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음 + const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0); + if (!hasTableSectionData && originalGroupedData.length === 0) { + console.log("⚠️ [handleUniversalFormModalTableSectionSave] 테이블 섹션 데이터 없음 - 일반 저장으로 전환"); + return { handled: false, success: false }; + } + + try { + // 사용자 정보 추가 + if (!context.userId) { + throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요."); + } + + const userInfo = { + writer: context.userId, + created_by: context.userId, + updated_by: context.userId, + company_code: context.companyCode || "", + }; + + let insertedCount = 0; + let updatedCount = 0; + let deletedCount = 0; + + // 각 테이블 섹션 처리 + for (const [sectionId, currentItems] of Object.entries(tableSectionData)) { + console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`); + + // 1️⃣ 신규 품목 INSERT (id가 없는 항목) + const newItems = currentItems.filter((item) => !item.id); + for (const item of newItems) { + const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; + + // 내부 메타데이터 제거 + Object.keys(rowToSave).forEach((key) => { + if (key.startsWith("_")) { + delete rowToSave[key]; + } + }); + + console.log("➕ [INSERT] 신규 품목:", rowToSave); + + const saveResult = await DynamicFormApi.saveFormData({ + screenId: screenId!, + tableName: tableName!, + data: rowToSave, + }); + + if (!saveResult.success) { + throw new Error(saveResult.message || "신규 품목 저장 실패"); + } + + insertedCount++; + } + + // 2️⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만) + const existingItems = currentItems.filter((item) => item.id); + for (const item of existingItems) { + const originalItem = originalGroupedData.find((orig) => orig.id === item.id); + + if (!originalItem) { + console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - INSERT로 처리: id=${item.id}`); + // 원본이 없으면 신규로 처리 + const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; + Object.keys(rowToSave).forEach((key) => { + if (key.startsWith("_")) { + delete rowToSave[key]; + } + }); + delete rowToSave.id; // id 제거하여 INSERT + + const saveResult = await DynamicFormApi.saveFormData({ + screenId: screenId!, + tableName: tableName!, + data: rowToSave, + }); + + if (!saveResult.success) { + throw new Error(saveResult.message || "품목 저장 실패"); + } + + insertedCount++; + continue; + } + + // 변경 사항 확인 (공통 필드 포함) + const currentDataWithCommon = { ...commonFieldsData, ...item }; + const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon); + + if (hasChanges) { + console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}`); + + // 변경된 필드만 추출하여 부분 업데이트 + const updateResult = await DynamicFormApi.updateFormDataPartial( + item.id, + originalItem, + currentDataWithCommon, + tableName!, + ); + + if (!updateResult.success) { + throw new Error(updateResult.message || "품목 수정 실패"); + } + + updatedCount++; + } else { + console.log(`⏭️ [SKIP] 변경 없음: id=${item.id}`); + } + } + + // 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목) + const currentIds = new Set(currentItems.map((item) => item.id).filter(Boolean)); + const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id)); + + for (const deletedItem of deletedItems) { + console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}`); + + const deleteResult = await DynamicFormApi.deleteFormDataFromTable(tableName!, deletedItem.id); + + if (!deleteResult.success) { + throw new Error(deleteResult.message || "품목 삭제 실패"); + } + + deletedCount++; + } + } + + // 결과 메시지 생성 + const resultParts: string[] = []; + if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`); + if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`); + if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`); + + const resultMessage = resultParts.length > 0 ? resultParts.join(", ") : "변경 사항 없음"; + + console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`); + toast.success(`저장 완료: ${resultMessage}`); + + // 저장 성공 이벤트 발생 + window.dispatchEvent(new CustomEvent("saveSuccess")); + window.dispatchEvent(new CustomEvent("refreshTable")); + // EditModal 닫기 이벤트 발생 + window.dispatchEvent(new CustomEvent("closeEditModal")); + + return { handled: true, success: true }; + } catch (error: any) { + console.error("❌ [handleUniversalFormModalTableSectionSave] 저장 오류:", error); + toast.error(error.message || "저장 중 오류가 발생했습니다."); + return { handled: true, success: false }; + } + } + + /** + * 두 객체 간 변경 사항 확인 + */ + private static checkForChanges(original: Record, current: Record): boolean { + // 비교할 필드 목록 (메타데이터 제외) + const fieldsToCompare = new Set([ + ...Object.keys(original).filter((k) => !k.startsWith("_")), + ...Object.keys(current).filter((k) => !k.startsWith("_")), + ]); + + for (const field of fieldsToCompare) { + // 시스템 필드는 비교에서 제외 + if (["created_date", "updated_date", "created_by", "updated_by", "writer"].includes(field)) { + continue; + } + + const originalValue = original[field]; + const currentValue = current[field]; + + // null/undefined 통일 처리 + const normalizedOriginal = originalValue === null || originalValue === undefined ? "" : String(originalValue); + const normalizedCurrent = currentValue === null || currentValue === undefined ? "" : String(currentValue); + + if (normalizedOriginal !== normalizedCurrent) { + console.log(` 📝 변경 감지: ${field} = "${normalizedOriginal}" → "${normalizedCurrent}"`); + return true; + } + } + + return false; + } + /** * 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조) * ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장