From 1c6eb2ae61046069eeb96892d79368e1d922dd35 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 18 Dec 2025 15:19:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(UniversalFormModal):=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=84=B9=EC=85=98=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormSectionConfig에 type("fields"|"table") 및 tableConfig 필드 추가 - TableSectionRenderer, TableSectionSettingsModal 신규 컴포넌트 생성 - ItemSelectionModal에 모달 필터 기능 추가 (소스 테이블 distinct 값 조회) - 설정 패널에서 테이블 섹션 추가/설정 UI 구현 --- .../ItemSelectionModal.tsx | 162 ++- .../ModalRepeaterTableComponent.tsx | 4 + .../ModalRepeaterTableConfigPanel.tsx | 93 +- .../components/modal-repeater-table/types.ts | 12 + .../TableSectionRenderer.tsx | 502 +++++++ .../UniversalFormModalComponent.tsx | 112 +- .../UniversalFormModalConfigPanel.tsx | 203 ++- .../components/universal-form-modal/config.ts | 118 +- .../modals/SaveSettingsModal.tsx | 15 +- .../modals/SectionLayoutModal.tsx | 41 +- .../modals/TableColumnSettingsModal.tsx | 769 ++++++++++ .../modals/TableSectionSettingsModal.tsx | 1276 +++++++++++++++++ .../components/universal-form-modal/types.ts | 235 ++- 13 files changed, 3455 insertions(+), 87 deletions(-) create mode 100644 frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx create mode 100644 frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx create mode 100644 frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx 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/types.ts b/frontend/lib/registry/components/modal-repeater-table/types.ts index 6097aaf3..3f5ae63b 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[]; // 테이블 컬럼 설정 @@ -175,6 +176,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 +197,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..54866fd0 --- /dev/null +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -0,0 +1,502 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Plus, Columns } 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가 있으면 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은 별도로 처리 + }; + + // columnModes를 dynamicDataSource로 변환 + 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 || "", + valueField: mode.valueMapping?.externalRef?.valueColumn || "", + joinConditions: (mode.valueMapping?.externalRef?.joinConditions || []).map((jc) => ({ + sourceTable: jc.sourceType === "row" ? "target" : "source", + sourceField: jc.sourceField, + targetField: jc.targetColumn, + operator: jc.operator || "=", + })), + }, + })), + 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, + }; +} + +/** + * 외부 테이블에서 값을 조회하는 함수 + */ +async function fetchExternalValue( + tableName: string, + valueColumn: string, + joinConditions: TableJoinCondition[], + rowData: any, + formData: FormDataState +): Promise { + if (joinConditions.length === 0) { + console.warn("조인 조건이 없습니다."); + return undefined; + } + + try { + const whereConditions: Record = {}; + + for (const condition of joinConditions) { + let value: any; + + // 값 출처에 따라 가져오기 + if (condition.sourceType === "row") { + // 현재 행에서 가져오기 + value = rowData[condition.sourceField]; + } else if (condition.sourceType === "formData") { + // formData에서 가져오기 (핵심 기능!) + value = formData[condition.sourceField]; + } + + if (value === undefined || value === null) { + console.warn(`조인 조건의 필드 "${condition.sourceField}" 값이 없습니다. (sourceType: ${condition.sourceType})`); + return undefined; + } + + // 숫자형 ID 변환 + let convertedValue = value; + if (condition.targetColumn.endsWith("_id") || condition.targetColumn === "id") { + const numValue = Number(value); + if (!isNaN(numValue)) { + convertedValue = numValue; + } + } + + whereConditions[condition.targetColumn] = convertedValue; + } + + // 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 [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0); + + // 동적 데이터 소스 활성화 상태 + const [activeDataSources, setActiveDataSources] = useState>({}); + + // 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[]) => { + setTableData(newData); + onTableDataChange(newData); + }, + [onTableDataChange] + ); + + // 행 변경 핸들러 + 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()); + }, [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; + + // 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 }, // 현재까지 빌드된 아이템 + 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] + ); + + // 컬럼 모드 변경 핸들러 + const handleDataSourceChange = useCallback( + async (columnField: string, optionId: string) => { + setActiveDataSources((prev) => ({ + ...prev, + [columnField]: optionId, + })); + + // 해당 컬럼의 모든 행 데이터 재조회 + const column = tableConfig.columns.find((col) => col.field === columnField); + 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]; + + if (mapping.type === "external" && mapping.externalRef) { + const { tableName, valueColumn, joinConditions } = mapping.externalRef; + const value = await fetchExternalValue(tableName, valueColumn, joinConditions, row, 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, + type: filter.type, + options: filter.options, + categoryRef: filter.categoryRef, + booleanRef: filter.booleanRef, + 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 68a1553c..288ea9f6 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 필드 컴포넌트 @@ -269,7 +270,7 @@ export function UniversalFormModalComponent({ // 설정에 정의된 필드 columnName 목록 수집 const configuredFields = new Set(); config.sections.forEach((section) => { - section.fields.forEach((field) => { + (section.fields || []).forEach((field) => { if (field.columnName) { configuredFields.add(field.columnName); } @@ -319,7 +320,7 @@ export function UniversalFormModalComponent({ // 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집 config.sections.forEach((section) => { - section.fields.forEach((field) => { + (section.fields || []).forEach((field) => { if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) { tablesToLoad.add(field.linkedFieldGroup.sourceTable); } @@ -372,9 +373,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 +452,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 +483,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 +785,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 +803,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 +837,37 @@ export function UniversalFormModalComponent({ } } + // 메인 데이터 저장 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; + + for (const item of sectionData) { + const itemToSave = { ...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, formData]); // 다중 행 저장 (겸직 등) const saveMultipleRows = useCallback(async () => { @@ -901,9 +941,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,8 +991,8 @@ export function UniversalFormModalComponent({ // 1. 메인 테이블 데이터 구성 const mainData: Record = {}; config.sections.forEach((section) => { - if (section.repeatable) return; // 반복 섹션은 제외 - section.fields.forEach((field) => { + if (section.repeatable || section.type === "table") return; // 반복 섹션 및 테이블 타입 제외 + (section.fields || []).forEach((field) => { const value = formData[field.columnName]; if (value !== undefined && value !== null && value !== "") { mainData[field.columnName] = value; @@ -962,9 +1002,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,8 +1094,8 @@ export function UniversalFormModalComponent({ // 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑 else { config.sections.forEach((section) => { - if (section.repeatable) return; - const matchingField = section.fields.find((f) => f.columnName === mapping.targetColumn); + 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({ formField: matchingField.columnName, @@ -1535,10 +1575,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 ? ( @@ -1560,7 +1626,7 @@ export function UniversalFormModalComponent({
{/* 일반 필드 렌더링 */} - {section.fields.map((field) => + {(section.fields || []).map((field) => renderFieldWithColumns( field, formData[field.columnName], @@ -1582,7 +1648,7 @@ export function UniversalFormModalComponent({
{/* 일반 필드 렌더링 */} - {section.fields.map((field) => + {(section.fields || []).map((field) => renderFieldWithColumns( field, formData[field.columnName], @@ -1819,7 +1885,7 @@ export function UniversalFormModalComponent({
{/* 일반 필드 렌더링 */} - {section.fields.map((field) => + {(section.fields || []).map((field) => renderFieldWithColumns( field, item[field.columnName], @@ -1898,7 +1964,7 @@ export function UniversalFormModalComponent({

{config.modal.title || "범용 폼 모달"}

- {config.sections.length}개 섹션 |{config.sections.reduce((acc, s) => acc + s.fields.length, 0)}개 필드 + {config.sections.length}개 섹션 |{config.sections.reduce((acc, s) => acc + (s.fields?.length || 0), 0)}개 필드

저장 테이블: {config.saveConfig.tableName || "(미설정)"}

diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 98cbc248..37c76407 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
- + {/* 섹션 추가 버튼들 */} +
+ +