From 6a4ebf362cbf0586fdc3bfed16af090d5ecc67f5 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 22 Dec 2025 14:36:13 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(UniversalFormModal):=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=ED=91=9C=EC=8B=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80=20ConfigPan?= =?UTF-8?q?el=EC=97=90=20showSaveButton=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EC=B2=B4=ED=81=AC=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EB=AA=A8=EB=8B=AC=20=ED=95=98=EB=8B=A8?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20=EA=B0=80=EB=8A=A5=20SaveSettingsModal=20SelectItem=20key=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=95=B4=EA=B2=B0=20=EC=84=9C=EB=B8=8C=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=82=AD=EC=A0=9C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UniversalFormModalConfigPanel.tsx | 16 ++++++++ .../modals/SaveSettingsModal.tsx | 38 +++++++++---------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 4ef28d6f..aa2386be 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; @@ -334,6 +335,21 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor 모달 창의 크기를 선택하세요 + {/* 저장 버튼 표시 설정 */} +
+
+ updateModalConfig({ showSaveButton: checked === true })} + /> + +
+ 체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다 +
+
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 2607cf83..c9976ed8 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -217,8 +217,8 @@ export function SaveSettingsModal({ const repeatSections = sections.filter((s) => s.repeatable); // 모든 필드 목록 (반복 섹션 포함) - const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => { - const fields: { columnName: string; label: string; sectionTitle: string }[] = []; + const getAllFields = (): { columnName: string; label: string; sectionTitle: string; sectionId: string }[] => { + const fields: { columnName: string; label: string; sectionTitle: string; sectionId: string }[] = []; sections.forEach((section) => { // 필드 타입 섹션만 처리 (테이블 타입은 fields가 undefined) if (section.fields && Array.isArray(section.fields)) { @@ -227,6 +227,7 @@ export function SaveSettingsModal({ columnName: field.columnName, label: field.label, sectionTitle: section.title, + sectionId: section.id, }); }); } @@ -550,8 +551,8 @@ export function SaveSettingsModal({ return ( - -
+
+
서브 테이블 {subIndex + 1}: {subTable.tableName || "(미설정)"} @@ -560,19 +561,18 @@ export function SaveSettingsModal({ ({subTable.fieldMappings?.length || 0}개 매핑)
- -
- + + +
@@ -755,8 +755,8 @@ export function SaveSettingsModal({ - {allFields.map((field) => ( - + {allFields.map((field, fieldIndex) => ( + {field.label} ({field.sectionTitle}) ))} From 533eaf5c9ff14bcb99965668b0e8e4b5d0ff7c5d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 23 Dec 2025 09:24:59 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(TableSection):=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=BB=AC=EB=9F=BC=20=EB=B6=80=EB=AA=A8=EA=B0=92=20?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?TableColumnConfig=EC=97=90=20receiveFromParent,=20parentFieldNa?= =?UTF-8?q?me=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80=20allComponents?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=80=EB=AA=A8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=9E=90=EB=8F=99=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=84=A4=EC=A0=95=EC=97=90=20"=EB=B6=80?= =?UTF-8?q?=EB=AA=A8=EA=B0=92"=20=EC=8A=A4=EC=9C=84=EC=B9=98=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B6=80=EB=AA=A8=20=ED=95=84=EB=93=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20UI=20=EC=B6=94=EA=B0=80=20handleAddItems()=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B6=80=EB=AA=A8=EA=B0=92=20=EC=9E=90=EB=8F=99=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableSectionRenderer.tsx | 8 + .../UniversalFormModalConfigPanel.tsx | 206 ++++++++++++- .../modals/FieldDetailSettingsModal.tsx | 57 ++++ .../modals/SaveSettingsModal.tsx | 276 +++++++++++++----- .../modals/SectionLayoutModal.tsx | 226 ++++++++++++-- .../modals/TableSectionSettingsModal.tsx | 120 ++++++++ .../components/universal-form-modal/types.ts | 6 + 7 files changed, 805 insertions(+), 94 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 224459f0..047849b6 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -621,6 +621,14 @@ export function TableSectionRenderer({ if (col.defaultValue !== undefined && newItem[col.field] === undefined) { newItem[col.field] = col.defaultValue; } + + // 부모에서 값 받기 (receiveFromParent) + if (col.receiveFromParent) { + const parentField = col.parentFieldName || col.field; + if (formData[parentField] !== undefined) { + newItem[col.field] = formData[parentField]; + } + } } return newItem; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index aa2386be..b2d5a9da 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -48,13 +48,24 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); -export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFormModalConfigPanelProps) { +// 부모 화면에서 전달 가능한 필드 타입 +interface AvailableParentField { + name: string; // 필드명 (columnName) + label: string; // 표시 라벨 + sourceComponent?: string; // 출처 컴포넌트 (예: "TableList", "SplitPanelLayout2") + sourceTable?: string; // 출처 테이블명 +} + +export function UniversalFormModalConfigPanel({ config, onChange, allComponents = [] }: UniversalFormModalConfigPanelProps) { // 테이블 목록 const [tables, setTables] = useState<{ name: string; label: string }[]>([]); const [tableColumns, setTableColumns] = useState<{ [tableName: string]: { name: string; type: string; label: string }[]; }>({}); + // 부모 화면에서 전달 가능한 필드 목록 + const [availableParentFields, setAvailableParentFields] = useState([]); + // 채번규칙 목록 const [numberingRules, setNumberingRules] = useState<{ id: string; name: string }[]>([]); @@ -72,6 +83,186 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor loadNumberingRules(); }, []); + // allComponents에서 부모 화면에서 전달 가능한 필드 추출 + useEffect(() => { + const extractParentFields = async () => { + if (!allComponents || allComponents.length === 0) { + setAvailableParentFields([]); + return; + } + + const fields: AvailableParentField[] = []; + + for (const comp of allComponents) { + // 컴포넌트 타입 추출 (여러 위치에서 확인) + const compType = comp.componentId || comp.componentConfig?.type || comp.componentConfig?.id || comp.type; + const compConfig = comp.componentConfig || {}; + + // 1. TableList / InteractiveDataTable - 테이블 컬럼 추출 + if (compType === "table-list" || compType === "interactive-data-table") { + const tableName = compConfig.selectedTable || compConfig.tableName; + if (tableName) { + // 테이블 컬럼 로드 + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + fields.push({ + name: colName, + label: colLabel, + sourceComponent: "TableList", + sourceTable: tableName, + }); + }); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${tableName}):`, error); + } + } + } + + // 2. SplitPanelLayout2 - 데이터 전달 필드 및 소스 테이블 컬럼 추출 + if (compType === "split-panel-layout2") { + // dataTransferFields 추출 + const transferFields = compConfig.dataTransferFields; + if (transferFields && Array.isArray(transferFields)) { + transferFields.forEach((field: any) => { + if (field.targetColumn) { + fields.push({ + name: field.targetColumn, + label: field.targetColumn, + sourceComponent: "SplitPanelLayout2", + sourceTable: compConfig.leftPanel?.tableName, + }); + } + }); + } + + // 좌측 패널 테이블 컬럼도 추출 + const leftTableName = compConfig.leftPanel?.tableName; + if (leftTableName) { + try { + const response = await apiClient.get(`/table-management/tables/${leftTableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + // 중복 방지 + if (!fields.some(f => f.name === colName && f.sourceTable === leftTableName)) { + fields.push({ + name: colName, + label: colLabel, + sourceComponent: "SplitPanelLayout2 (좌측)", + sourceTable: leftTableName, + }); + } + }); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${leftTableName}):`, error); + } + } + } + + // 3. 기타 테이블 관련 컴포넌트 + if (compType === "card-display" || compType === "simple-repeater-table") { + const tableName = compConfig.tableName || compConfig.initialDataConfig?.sourceTable; + if (tableName) { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + if (!fields.some(f => f.name === colName && f.sourceTable === tableName)) { + fields.push({ + name: colName, + label: colLabel, + sourceComponent: compType, + sourceTable: tableName, + }); + } + }); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${tableName}):`, error); + } + } + } + + // 4. 버튼 컴포넌트 - openModalWithData의 fieldMappings/dataMapping에서 소스 컬럼 추출 + if (compType === "button-primary" || compType === "button" || compType === "button-secondary") { + const action = compConfig.action || {}; + + // fieldMappings에서 소스 컬럼 추출 + const fieldMappings = action.fieldMappings || []; + fieldMappings.forEach((mapping: any) => { + if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) { + fields.push({ + name: mapping.sourceColumn, + label: mapping.sourceColumn, + sourceComponent: "Button (fieldMappings)", + sourceTable: action.sourceTableName, + }); + } + }); + + // dataMapping에서 소스 컬럼 추출 + const dataMapping = action.dataMapping || []; + dataMapping.forEach((mapping: any) => { + if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) { + fields.push({ + name: mapping.sourceColumn, + label: mapping.sourceColumn, + sourceComponent: "Button (dataMapping)", + sourceTable: action.sourceTableName, + }); + } + }); + } + } + + // 5. 현재 모달의 저장 테이블 컬럼도 추가 (부모에서 전달받을 수 있는 값들) + const currentTableName = config.saveConfig?.tableName; + if (currentTableName) { + try { + const response = await apiClient.get(`/table-management/tables/${currentTableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + if (!fields.some(f => f.name === colName)) { + fields.push({ + name: colName, + label: colLabel, + sourceComponent: "현재 폼 테이블", + sourceTable: currentTableName, + }); + } + }); + } + } catch (error) { + console.error(`현재 테이블 컬럼 로드 실패 (${currentTableName}):`, error); + } + } + + // 중복 제거 (같은 name이면 첫 번째만 유지) + const uniqueFields = fields.filter((field, index, self) => + index === self.findIndex(f => f.name === field.name) + ); + + setAvailableParentFields(uniqueFields); + }; + + extractParentFields(); + }, [allComponents, config.saveConfig?.tableName]); + // 저장 테이블 변경 시 컬럼 로드 useEffect(() => { if (config.saveConfig.tableName) { @@ -85,9 +276,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor const data = response.data?.data; if (response.data?.success && Array.isArray(data)) { setTables( - data.map((t: { tableName?: string; table_name?: string; tableLabel?: string; table_label?: string }) => ({ + data.map((t: { tableName?: string; table_name?: string; displayName?: string; tableLabel?: string; table_label?: string }) => ({ name: t.tableName || t.table_name || "", - label: t.tableLabel || t.table_label || t.tableName || t.table_name || "", + // displayName 우선, 없으면 tableLabel, 그것도 없으면 테이블명 + label: t.displayName || t.tableLabel || t.table_label || "", })), ); } @@ -620,6 +812,12 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor setSelectedField(field); setFieldDetailModalOpen(true); }} + tableName={config.saveConfig.tableName} + tableColumns={tableColumns[config.saveConfig.tableName || ""]?.map(col => ({ + name: col.name, + type: col.type, + label: col.label || col.name + })) || []} /> )} @@ -666,6 +864,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor tableColumns={tableColumns} numberingRules={numberingRules} onLoadTableColumns={loadTableColumns} + availableParentFields={availableParentFields} /> )} @@ -706,6 +905,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor )} onLoadTableColumns={loadTableColumns} allSections={config.sections as FormSectionConfig[]} + availableParentFields={availableParentFields} /> )}
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index d53d6e00..c8584a5f 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -36,6 +36,17 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); +/** + * 부모 화면에서 전달 가능한 필드 타입 + * 유니버셜 폼 모달에서 "부모에서 값 받기" 설정 시 선택 가능한 필드 목록 + */ +export interface AvailableParentField { + name: string; // 필드명 (columnName) + label: string; // 표시 라벨 + sourceComponent?: string; // 출처 컴포넌트 (예: "TableList", "SplitPanelLayout2") + sourceTable?: string; // 출처 테이블명 +} + interface FieldDetailSettingsModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -45,6 +56,8 @@ interface FieldDetailSettingsModalProps { tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] }; numberingRules: { id: string; name: string }[]; onLoadTableColumns: (tableName: string) => void; + // 부모 화면에서 전달 가능한 필드 목록 (선택사항) + availableParentFields?: AvailableParentField[]; } export function FieldDetailSettingsModal({ @@ -56,6 +69,7 @@ export function FieldDetailSettingsModal({ tableColumns, numberingRules, onLoadTableColumns, + availableParentFields = [], }: FieldDetailSettingsModalProps) { // 로컬 상태로 필드 설정 관리 const [localField, setLocalField] = useState(field); @@ -293,6 +307,49 @@ export function FieldDetailSettingsModal({ />
부모 화면에서 전달받은 값으로 자동 채워집니다 + + {/* 부모에서 값 받기 활성화 시 필드 선택 */} + {localField.receiveFromParent && ( +
+ + {availableParentFields.length > 0 ? ( + + ) : ( +
+ updateField({ parentFieldName: e.target.value })} + placeholder={`예: ${localField.columnName || "parent_field_name"}`} + className="h-8 text-xs" + /> +

+ 부모 화면에서 전달받을 필드명을 입력하세요. 비워두면 "{localField.columnName}"을 사용합니다. +

+
+ )} +
+ )}
{/* Accordion으로 고급 설정 */} 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 c9976ed8..5be176dc 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -11,7 +11,9 @@ 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, Info } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Plus, Trash2, Database, Layers, Info, Check, ChevronsUpDown } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types"; @@ -50,6 +52,11 @@ export function SaveSettingsModal({ saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single" ); + // 테이블 검색 Popover 상태 + const [singleTableSearchOpen, setSingleTableSearchOpen] = useState(false); + const [mainTableSearchOpen, setMainTableSearchOpen] = useState(false); + const [subTableSearchOpen, setSubTableSearchOpen] = useState>({}); + // open이 변경될 때마다 데이터 동기화 useEffect(() => { if (open) { @@ -376,24 +383,68 @@ export function SaveSettingsModal({
- + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateSaveConfig({ tableName: t.name }); + onLoadTableColumns(t.name); + setSingleTableSearchOpen(false); + }} + className="text-xs" + > + +
+ {t.name} + {t.label && ( + {t.label} + )} +
+
+ ))} +
+
+
+
+
폼 데이터를 저장할 테이블을 선택하세요
@@ -426,37 +477,81 @@ export function SaveSettingsModal({
- + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateSaveConfig({ + customApiSave: { + ...localSaveConfig.customApiSave, + apiType: "multi-table", + multiTable: { + ...localSaveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: { + ...localSaveConfig.customApiSave?.multiTable?.mainTable, + tableName: t.name, + }, + }, + }, + }); + onLoadTableColumns(t.name); + setMainTableSearchOpen(false); + }} + className="text-xs" + > + +
+ {t.name} + {t.label && ( + {t.label} + )} +
+
+ ))} +
+
+
+
+
주요 데이터를 저장할 메인 테이블 (예: orders, user_info)
@@ -576,24 +671,71 @@ export function SaveSettingsModal({
- + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateSubTable(subIndex, { tableName: t.name }); + onLoadTableColumns(t.name); + setSubTableSearchOpen(prev => ({ ...prev, [subIndex]: false })); + }} + className="text-xs" + > + +
+ {t.name} + {t.label && ( + {t.label} + )} +
+
+ ))} +
+
+
+
+ 반복 데이터를 저장할 서브 테이블
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 4a90a777..b47c2424 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx @@ -11,7 +11,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; -import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon, Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { FormSectionConfig, FormFieldConfig, OptionalFieldGroupConfig, FIELD_TYPE_OPTIONS } from "../types"; import { defaultFieldConfig, generateFieldId, generateUniqueId } from "../config"; @@ -21,12 +23,22 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); +// 테이블 컬럼 정보 타입 +interface TableColumnInfo { + name: string; + type: string; + label: string; +} + interface SectionLayoutModalProps { open: boolean; onOpenChange: (open: boolean) => void; section: FormSectionConfig; onSave: (updates: Partial) => void; onOpenFieldDetail: (field: FormFieldConfig) => void; + // 저장 테이블의 컬럼 정보 + tableName?: string; + tableColumns?: TableColumnInfo[]; } export function SectionLayoutModal({ @@ -35,8 +47,13 @@ export function SectionLayoutModal({ section, onSave, onOpenFieldDetail, + tableName = "", + tableColumns = [], }: SectionLayoutModalProps) { + // 컬럼 선택 Popover 상태 (필드별) + const [columnSearchOpen, setColumnSearchOpen] = useState>({}); + // 로컬 상태로 섹션 관리 (fields가 없으면 빈 배열로 초기화) const [localSection, setLocalSection] = useState(() => ({ ...section, @@ -443,11 +460,90 @@ export function SectionLayoutModal({
- updateField(field.id, { columnName: e.target.value })} - className="h-6 text-[9px] mt-0.5" - /> + {tableColumns.length > 0 ? ( + setColumnSearchOpen(prev => ({ ...prev, [field.id]: open }))} + > + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {tableColumns.map((col) => ( + { + updateField(field.id, { + columnName: col.name, + // 라벨이 기본값이면 컬럼 라벨로 자동 설정 + ...(field.label.startsWith("새 필드") || field.label.startsWith("field_") + ? { label: col.label || col.name } + : {}) + }); + setColumnSearchOpen(prev => ({ ...prev, [field.id]: false })); + }} + className="text-xs" + > + +
+
+ {col.name} + {col.label && col.label !== col.name && ( + ({col.label}) + )} +
+ {tableName && ( + {tableName} + )} +
+
+ ))} +
+
+
+
+
+ ) : ( + updateField(field.id, { columnName: e.target.value })} + className="h-6 text-[9px] mt-0.5" + placeholder="저장 테이블을 먼저 설정하세요" + /> + )}
@@ -821,24 +917,106 @@ export function SectionLayoutModal({ className="h-5 text-[8px]" placeholder="라벨" /> - { - const newGroups = localSection.optionalFieldGroups?.map((g) => - g.id === group.id - ? { - ...g, - fields: g.fields.map((f) => - f.id === field.id ? { ...f, columnName: e.target.value } : f - ), - } - : g - ); - updateSection({ optionalFieldGroups: newGroups }); - }} - className="h-5 text-[8px]" - placeholder="컬럼명" - /> + {tableColumns.length > 0 ? ( + setColumnSearchOpen(prev => ({ ...prev, [`opt-${field.id}`]: open }))} + > + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {tableColumns.map((col) => ( + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id + ? { + ...g, + fields: g.fields.map((f) => + f.id === field.id + ? { + ...f, + columnName: col.name, + ...(f.label.startsWith("필드 ") ? { label: col.label || col.name } : {}) + } + : f + ), + } + : g + ); + updateSection({ optionalFieldGroups: newGroups }); + setColumnSearchOpen(prev => ({ ...prev, [`opt-${field.id}`]: false })); + }} + className="text-[9px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + ({col.label}) + )} +
+
+ ))} +
+
+
+
+
+ ) : ( + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id + ? { + ...g, + fields: g.fields.map((f) => + f.id === field.id ? { ...f, columnName: e.target.value } : f + ), + } + : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="h-5 text-[8px]" + placeholder="컬럼명" + /> + )} onUpdate({ parentFieldName: e.target.value })} + placeholder={col.field} + className="h-8 text-xs" + /> +

+ 비워두면 "{col.field}"를 사용합니다. +

+ + )} + + )} + {/* 조회 설정 (조회 ON일 때만 표시) */} {col.lookup?.enabled && (
@@ -1119,6 +1235,8 @@ interface TableSectionSettingsModalProps { onLoadCategoryList?: () => void; // 전체 섹션 목록 (다른 섹션 필드 참조용) allSections?: FormSectionConfig[]; + // 부모 화면에서 전달 가능한 필드 목록 + availableParentFields?: AvailableParentField[]; } export function TableSectionSettingsModal({ @@ -1132,6 +1250,7 @@ export function TableSectionSettingsModal({ categoryList = [], onLoadCategoryList, allSections = [], + availableParentFields = [], }: TableSectionSettingsModalProps) { // 로컬 상태 const [title, setTitle] = useState(section.title); @@ -1693,6 +1812,7 @@ export function TableSectionSettingsModal({ sections={otherSections} formFields={otherSectionFields} tableConfig={tableConfig} + availableParentFields={availableParentFields} onLoadTableColumns={onLoadTableColumns} onUpdate={(updates) => updateColumn(index, updates)} onMoveUp={() => moveColumn(index, "up")} diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 4e25f7d7..935d46be 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -335,6 +335,10 @@ export interface TableColumnConfig { // 날짜 일괄 적용 (type이 "date"일 때만 사용) // 활성화 시 첫 번째 날짜 입력 시 모든 행에 동일한 날짜가 자동 적용됨 batchApply?: boolean; + + // 부모에서 값 받기 (모든 행에 동일한 값 적용) + receiveFromParent?: boolean; // 부모에서 값 받기 활성화 + parentFieldName?: string; // 부모 필드명 (미지정 시 field와 동일) } // ============================================ @@ -705,6 +709,8 @@ export interface UniversalFormModalComponentProps { export interface UniversalFormModalConfigPanelProps { config: UniversalFormModalConfig; onChange: (config: UniversalFormModalConfig) => void; + // 화면 설계 시 같은 화면의 다른 컴포넌트들 (부모 데이터 필드 추출용) + allComponents?: any[]; } // 필드 타입 옵션 From 3396834417d1be89006b1f7155dfabed5bde0cfb Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 24 Dec 2025 09:08:16 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(split-panel-layout2):=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=ED=95=91,=20=ED=83=AD=20=ED=95=84=ED=84=B0=EB=A7=81,?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: GroupingConfig, TabConfig, ColumnDisplayConfig 등 타입 확장 - Component: groupData, generateTabs, filterDataByTab 함수 추가 - ConfigPanel: SearchableColumnSelect, 설정 모달 상태 관리 추가 - 신규 모달: ActionButtonConfigModal, ColumnConfigModal, DataTransferConfigModal - UniversalFormModal: 연결필드 소스 테이블 Combobox로 변경 --- .../ActionButtonConfigModal.tsx | 674 +++++++ .../split-panel-layout2/ColumnConfigModal.tsx | 805 ++++++++ .../DataTransferConfigModal.tsx | 423 ++++ .../SplitPanelLayout2Component.tsx | 821 +++++++- .../SplitPanelLayout2ConfigPanel.tsx | 1734 +++++++++-------- .../components/SearchableColumnSelect.tsx | 163 ++ .../components/SortableColumnItem.tsx | 118 ++ .../components/split-panel-layout2/index.ts | 8 + .../components/split-panel-layout2/types.ts | 121 +- .../UniversalFormModalConfigPanel.tsx | 2 + .../modals/FieldDetailSettingsModal.tsx | 189 +- 11 files changed, 4189 insertions(+), 869 deletions(-) create mode 100644 frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/DataTransferConfigModal.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx diff --git a/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx b/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx new file mode 100644 index 00000000..aeff27c2 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx @@ -0,0 +1,674 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Plus, GripVertical, Settings, X, Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import type { ActionButtonConfig, ModalParamMapping, ColumnConfig } from "./types"; + +interface ScreenInfo { + screen_id: number; + screen_name: string; + screen_code: string; +} + +// 정렬 가능한 버튼 아이템 +const SortableButtonItem: React.FC<{ + id: string; + button: ActionButtonConfig; + index: number; + onSettingsClick: () => void; + onRemove: () => void; +}> = ({ id, button, index, onSettingsClick, onRemove }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const getVariantColor = (variant?: string) => { + switch (variant) { + case "destructive": + return "bg-destructive/10 text-destructive"; + case "outline": + return "bg-background border"; + case "ghost": + return "bg-muted/50"; + case "secondary": + return "bg-secondary text-secondary-foreground"; + default: + return "bg-primary/10 text-primary"; + } + }; + + const getActionLabel = (action?: string) => { + switch (action) { + case "add": + return "추가"; + case "edit": + return "수정"; + case "delete": + return "삭제"; + case "bulk-delete": + return "일괄삭제"; + case "api": + return "API"; + case "custom": + return "커스텀"; + default: + return "추가"; + } + }; + + return ( +
+ {/* 드래그 핸들 */} +
+ +
+ + {/* 버튼 정보 */} +
+
+ + {button.label || `버튼 ${index + 1}`} + +
+
+ + {getActionLabel(button.action)} + + {button.icon && ( + + {button.icon} + + )} + {button.showCondition && button.showCondition !== "always" && ( + + {button.showCondition === "selected" ? "선택시만" : "미선택시만"} + + )} +
+
+ + {/* 액션 버튼들 */} +
+ + +
+
+ ); +}; + +interface ActionButtonConfigModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + actionButtons: ActionButtonConfig[]; + displayColumns?: ColumnConfig[]; // 모달 파라미터 매핑용 + onSave: (buttons: ActionButtonConfig[]) => void; + side: "left" | "right"; +} + +export const ActionButtonConfigModal: React.FC = ({ + open, + onOpenChange, + actionButtons: initialButtons, + displayColumns = [], + onSave, + side, +}) => { + // 로컬 상태 + const [buttons, setButtons] = useState([]); + + // 버튼 세부설정 모달 + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [editingButtonIndex, setEditingButtonIndex] = useState(null); + const [editingButton, setEditingButton] = useState(null); + + // 화면 목록 + const [screens, setScreens] = useState([]); + const [screensLoading, setScreensLoading] = useState(false); + const [screenSelectOpen, setScreenSelectOpen] = useState(false); + + // 드래그 센서 + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // 초기값 설정 + useEffect(() => { + if (open) { + setButtons(initialButtons || []); + } + }, [open, initialButtons]); + + // 화면 목록 로드 + const loadScreens = useCallback(async () => { + setScreensLoading(true); + try { + const response = await apiClient.get("/screen-management/screens?size=1000"); + + let screenList: any[] = []; + if (response.data?.success && Array.isArray(response.data?.data)) { + screenList = response.data.data; + } else if (Array.isArray(response.data?.data)) { + screenList = response.data.data; + } + + const transformedScreens = screenList.map((s: any) => ({ + screen_id: s.screenId ?? s.screen_id ?? s.id, + screen_name: s.screenName ?? s.screen_name ?? s.name ?? `화면 ${s.screenId || s.screen_id || s.id}`, + screen_code: s.screenCode ?? s.screen_code ?? s.code ?? "", + })); + + setScreens(transformedScreens); + } catch (error) { + console.error("화면 목록 로드 실패:", error); + setScreens([]); + } finally { + setScreensLoading(false); + } + }, []); + + useEffect(() => { + if (open) { + loadScreens(); + } + }, [open, loadScreens]); + + // 드래그 종료 핸들러 + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = buttons.findIndex((btn) => btn.id === active.id); + const newIndex = buttons.findIndex((btn) => btn.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + setButtons(arrayMove(buttons, oldIndex, newIndex)); + } + } + }; + + // 버튼 추가 + const handleAddButton = () => { + const newButton: ActionButtonConfig = { + id: `btn-${Date.now()}`, + label: "새 버튼", + variant: "default", + action: "add", + showCondition: "always", + }; + setButtons([...buttons, newButton]); + }; + + // 버튼 삭제 + const handleRemoveButton = (index: number) => { + setButtons(buttons.filter((_, i) => i !== index)); + }; + + // 버튼 업데이트 + const handleUpdateButton = (index: number, updates: Partial) => { + const newButtons = [...buttons]; + newButtons[index] = { ...newButtons[index], ...updates }; + setButtons(newButtons); + }; + + // 버튼 세부설정 열기 + const handleOpenDetailSettings = (index: number) => { + setEditingButtonIndex(index); + setEditingButton({ ...buttons[index] }); + setDetailModalOpen(true); + }; + + // 버튼 세부설정 저장 + const handleSaveDetailSettings = () => { + if (editingButtonIndex !== null && editingButton) { + handleUpdateButton(editingButtonIndex, editingButton); + } + setDetailModalOpen(false); + setEditingButtonIndex(null); + setEditingButton(null); + }; + + // 저장 + const handleSave = () => { + onSave(buttons); + onOpenChange(false); + }; + + // 선택된 화면 정보 + const getScreenInfo = (screenId?: number) => { + return screens.find((s) => s.screen_id === screenId); + }; + + return ( + <> + + + + + {side === "left" ? "좌측" : "우측"} 패널 액션 버튼 설정 + + + 버튼을 추가하고 순서를 드래그로 변경할 수 있습니다. + + + +
+
+ + +
+ + + {buttons.length === 0 ? ( +
+

+ 액션 버튼이 없습니다 +

+ +
+ ) : ( + + btn.id)} + strategy={verticalListSortingStrategy} + > +
+ {buttons.map((btn, index) => ( + handleOpenDetailSettings(index)} + onRemove={() => handleRemoveButton(index)} + /> + ))} +
+
+
+ )} +
+
+ + + + + +
+
+ + {/* 버튼 세부설정 모달 */} + + + + 버튼 세부설정 + + {editingButton?.label || "버튼"}의 동작을 설정합니다. + + + + {editingButton && ( + +
+ {/* 기본 설정 */} +
+

기본 설정

+ +
+ + + setEditingButton({ ...editingButton, label: e.target.value }) + } + placeholder="버튼 라벨" + className="mt-1 h-9" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* 동작 설정 */} +
+

동작 설정

+ +
+ + +
+ + {/* 모달 설정 (add, edit 액션) */} + {(editingButton.action === "add" || editingButton.action === "edit") && ( +
+ + + + + + + + + + 검색 결과가 없습니다 + + {screens.map((screen) => ( + { + setEditingButton({ + ...editingButton, + modalScreenId: screen.screen_id, + }); + setScreenSelectOpen(false); + }} + > + + + {screen.screen_name} + + {screen.screen_code} + + + + ))} + + + + + +
+ )} + + {/* API 설정 */} + {editingButton.action === "api" && ( + <> +
+ + + setEditingButton({ + ...editingButton, + apiEndpoint: e.target.value, + }) + } + placeholder="/api/example" + className="mt-1 h-9" + /> +
+
+ + +
+ + )} + + {/* 확인 메시지 (삭제 계열) */} + {(editingButton.action === "delete" || + editingButton.action === "bulk-delete" || + (editingButton.action === "api" && editingButton.apiMethod === "DELETE")) && ( +
+ + + setEditingButton({ + ...editingButton, + confirmMessage: e.target.value, + }) + } + placeholder="정말 삭제하시겠습니까?" + className="mt-1 h-9" + /> +
+ )} + + {/* 커스텀 액션 ID */} + {editingButton.action === "custom" && ( +
+ + + setEditingButton({ + ...editingButton, + customActionId: e.target.value, + }) + } + placeholder="customAction1" + className="mt-1 h-9" + /> +

+ 커스텀 이벤트 핸들러에서 이 ID로 버튼을 구분합니다 +

+
+ )} +
+ +
+
+ )} + + + + + +
+
+ + ); +}; + +export default ActionButtonConfigModal; + diff --git a/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx b/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx new file mode 100644 index 00000000..89866651 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx @@ -0,0 +1,805 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { Plus, Settings2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { Checkbox } from "@/components/ui/checkbox"; +import type { ColumnConfig, SearchColumnConfig, GroupingConfig, ColumnDisplayConfig, EntityReferenceConfig } from "./types"; +import { SortableColumnItem } from "./components/SortableColumnItem"; +import { SearchableColumnSelect } from "./components/SearchableColumnSelect"; + +interface ColumnInfo { + column_name: string; + data_type: string; + column_comment?: string; + input_type?: string; + web_type?: string; + reference_table?: string; + reference_column?: string; +} + +// 참조 테이블 컬럼 정보 +interface ReferenceColumnInfo { + columnName: string; + displayName: string; + dataType: string; +} + +interface ColumnConfigModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tableName: string; + displayColumns: ColumnConfig[]; + searchColumns?: SearchColumnConfig[]; + grouping?: GroupingConfig; + showSearch?: boolean; + onSave: (config: { + displayColumns: ColumnConfig[]; + searchColumns: SearchColumnConfig[]; + grouping: GroupingConfig; + showSearch: boolean; + }) => void; + side: "left" | "right"; // 좌측/우측 패널 구분 +} + +export const ColumnConfigModal: React.FC = ({ + open, + onOpenChange, + tableName, + displayColumns: initialDisplayColumns, + searchColumns: initialSearchColumns, + grouping: initialGrouping, + showSearch: initialShowSearch, + onSave, + side, +}) => { + // 로컬 상태 (모달 내에서만 사용, 저장 시 부모로 전달) + const [displayColumns, setDisplayColumns] = useState([]); + const [searchColumns, setSearchColumns] = useState([]); + const [grouping, setGrouping] = useState({ enabled: false, groupByColumn: "" }); + const [showSearch, setShowSearch] = useState(false); + + // 컬럼 세부설정 모달 + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [editingColumnIndex, setEditingColumnIndex] = useState(null); + const [editingColumn, setEditingColumn] = useState(null); + + // 테이블 컬럼 목록 + const [columns, setColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + // 엔티티 참조 관련 상태 + const [entityReferenceColumns, setEntityReferenceColumns] = useState>(new Map()); + const [loadingEntityColumns, setLoadingEntityColumns] = useState>(new Set()); + + // 드래그 센서 + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // 초기값 설정 + useEffect(() => { + if (open) { + setDisplayColumns(initialDisplayColumns || []); + setSearchColumns(initialSearchColumns || []); + setGrouping(initialGrouping || { enabled: false, groupByColumn: "" }); + setShowSearch(initialShowSearch || false); + } + }, [open, initialDisplayColumns, initialSearchColumns, initialGrouping, initialShowSearch]); + + // 테이블 컬럼 로드 (entity 타입 정보 포함) + const loadColumns = useCallback(async () => { + if (!tableName) { + setColumns([]); + return; + } + + setColumnsLoading(true); + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=200`); + + let columnList: any[] = []; + if (response.data?.success && response.data?.data?.columns) { + columnList = response.data.data.columns; + } else if (Array.isArray(response.data?.data?.columns)) { + columnList = response.data.data.columns; + } else if (Array.isArray(response.data?.data)) { + columnList = response.data.data; + } + + // entity 타입 정보를 포함하여 변환 + const transformedColumns = columnList.map((c: any) => ({ + column_name: c.columnName ?? c.column_name ?? c.name ?? "", + data_type: c.dataType ?? c.data_type ?? c.type ?? "", + column_comment: c.displayName ?? c.column_comment ?? c.label ?? "", + input_type: c.inputType ?? c.input_type ?? "", + web_type: c.webType ?? c.web_type ?? "", + reference_table: c.referenceTable ?? c.reference_table ?? "", + reference_column: c.referenceColumn ?? c.reference_column ?? "", + })); + + setColumns(transformedColumns); + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + setColumns([]); + } finally { + setColumnsLoading(false); + } + }, [tableName]); + + // 엔티티 참조 테이블의 컬럼 목록 로드 + const loadEntityReferenceColumns = useCallback(async (columnName: string, referenceTable: string) => { + if (!referenceTable || entityReferenceColumns.has(columnName)) { + return; + } + + setLoadingEntityColumns(prev => new Set(prev).add(columnName)); + try { + const result = await entityJoinApi.getReferenceTableColumns(referenceTable); + if (result?.columns) { + setEntityReferenceColumns(prev => { + const newMap = new Map(prev); + newMap.set(columnName, result.columns); + return newMap; + }); + } + } catch (error) { + console.error(`엔티티 참조 컬럼 로드 실패 (${referenceTable}):`, error); + } finally { + setLoadingEntityColumns(prev => { + const newSet = new Set(prev); + newSet.delete(columnName); + return newSet; + }); + } + }, [entityReferenceColumns]); + + useEffect(() => { + if (open && tableName) { + loadColumns(); + } + }, [open, tableName, loadColumns]); + + // 드래그 종료 핸들러 + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = displayColumns.findIndex((col, idx) => `col-${idx}` === active.id); + const newIndex = displayColumns.findIndex((col, idx) => `col-${idx}` === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + setDisplayColumns(arrayMove(displayColumns, oldIndex, newIndex)); + } + } + }; + + // 컬럼 추가 + const handleAddColumn = () => { + setDisplayColumns([ + ...displayColumns, + { + name: "", + label: "", + displayRow: side === "left" ? "name" : "info", + sourceTable: tableName, + }, + ]); + }; + + // 컬럼 삭제 + const handleRemoveColumn = (index: number) => { + setDisplayColumns(displayColumns.filter((_, i) => i !== index)); + }; + + // 컬럼 업데이트 (entity 타입이면 참조 테이블 컬럼도 로드) + const handleUpdateColumn = (index: number, updates: Partial) => { + const newColumns = [...displayColumns]; + newColumns[index] = { ...newColumns[index], ...updates }; + setDisplayColumns(newColumns); + + // 컬럼명이 변경된 경우 entity 타입인지 확인하고 참조 테이블 컬럼 로드 + if (updates.name) { + const columnInfo = columns.find(c => c.column_name === updates.name); + if (columnInfo && (columnInfo.input_type === 'entity' || columnInfo.web_type === 'entity')) { + if (columnInfo.reference_table) { + loadEntityReferenceColumns(updates.name, columnInfo.reference_table); + } + } + } + }; + + // 컬럼 세부설정 열기 (entity 타입이면 참조 테이블 컬럼도 로드) + const handleOpenDetailSettings = (index: number) => { + const column = displayColumns[index]; + setEditingColumnIndex(index); + setEditingColumn({ ...column }); + setDetailModalOpen(true); + + // entity 타입인지 확인하고 참조 테이블 컬럼 로드 + if (column.name) { + const columnInfo = columns.find(c => c.column_name === column.name); + if (columnInfo && (columnInfo.input_type === 'entity' || columnInfo.web_type === 'entity')) { + if (columnInfo.reference_table) { + loadEntityReferenceColumns(column.name, columnInfo.reference_table); + } + } + } + }; + + // 컬럼 세부설정 저장 + const handleSaveDetailSettings = () => { + if (editingColumnIndex !== null && editingColumn) { + handleUpdateColumn(editingColumnIndex, editingColumn); + } + setDetailModalOpen(false); + setEditingColumnIndex(null); + setEditingColumn(null); + }; + + // 검색 컬럼 추가 + const handleAddSearchColumn = () => { + setSearchColumns([...searchColumns, { columnName: "", label: "" }]); + }; + + // 검색 컬럼 삭제 + const handleRemoveSearchColumn = (index: number) => { + setSearchColumns(searchColumns.filter((_, i) => i !== index)); + }; + + // 검색 컬럼 업데이트 + const handleUpdateSearchColumn = (index: number, columnName: string) => { + const newColumns = [...searchColumns]; + newColumns[index] = { ...newColumns[index], columnName }; + setSearchColumns(newColumns); + }; + + // 저장 + const handleSave = () => { + onSave({ + displayColumns, + searchColumns, + grouping, + showSearch, + }); + onOpenChange(false); + }; + + // 엔티티 표시 컬럼 토글 + const toggleEntityDisplayColumn = (selectedColumn: string) => { + if (!editingColumn) return; + + const currentDisplayColumns = editingColumn.entityReference?.displayColumns || []; + const newDisplayColumns = currentDisplayColumns.includes(selectedColumn) + ? currentDisplayColumns.filter(col => col !== selectedColumn) + : [...currentDisplayColumns, selectedColumn]; + + setEditingColumn({ + ...editingColumn, + entityReference: { + ...editingColumn.entityReference, + displayColumns: newDisplayColumns, + } as EntityReferenceConfig, + }); + }; + + // 현재 편집 중인 컬럼이 entity 타입인지 확인 + const getEditingColumnEntityInfo = useCallback(() => { + if (!editingColumn?.name) return null; + const columnInfo = columns.find(c => c.column_name === editingColumn.name); + if (!columnInfo) return null; + if (columnInfo.input_type !== 'entity' && columnInfo.web_type !== 'entity') return null; + return { + referenceTable: columnInfo.reference_table || '', + referenceColumns: entityReferenceColumns.get(editingColumn.name) || [], + isLoading: loadingEntityColumns.has(editingColumn.name), + }; + }, [editingColumn, columns, entityReferenceColumns, loadingEntityColumns]); + + // 이미 선택된 컬럼명 목록 (중복 선택 방지용) + const selectedColumnNames = displayColumns.map((col) => col.name).filter(Boolean); + + return ( + <> + + + + + + {side === "left" ? "좌측" : "우측"} 패널 컬럼 설정 + + + 표시할 컬럼을 추가하고 순서를 드래그로 변경할 수 있습니다. + + + + + + 표시 컬럼 + + 그룹핑 + + 검색 + + + {/* 표시 컬럼 탭 */} + +
+ + +
+ + +
+ {displayColumns.length === 0 ? ( +
+

+ 표시할 컬럼이 없습니다 +

+ +
+ ) : ( + + `col-${idx}`)} + strategy={verticalListSortingStrategy} + > +
+ {displayColumns.map((col, index) => ( +
+ handleOpenDetailSettings(index)} + onRemove={() => handleRemoveColumn(index)} + showGroupingSettings={grouping.enabled} + /> + {/* 컬럼 빠른 선택 (인라인) */} + {!col.name && ( +
+ { + const colInfo = columns.find((c) => c.column_name === value); + handleUpdateColumn(index, { + name: value, + label: colInfo?.column_comment || "", + }); + }} + excludeColumns={selectedColumnNames} + placeholder="컬럼을 선택하세요" + /> +
+ )} +
+ ))} +
+
+
+ )} +
+
+
+ + {/* 그룹핑 탭 (좌측 패널만) */} + + +
+
+
+ +

+ 동일한 값을 가진 행들을 하나로 그룹화합니다 +

+
+ + setGrouping({ ...grouping, enabled: checked }) + } + /> +
+ + {grouping.enabled && ( +
+
+ + + setGrouping({ ...grouping, groupByColumn: value }) + } + placeholder="그룹 기준 컬럼 선택" + className="mt-1" + /> +

+ 예: item_id로 그룹핑하면 같은 품목의 데이터를 하나로 표시합니다 +

+
+
+ )} +
+
+
+ + {/* 검색 탭 */} + + +
+
+
+ +

+ 검색 입력창을 표시합니다 +

+
+ +
+ + {showSearch && ( +
+
+ + +
+ + {searchColumns.length === 0 ? ( +
+ 검색할 컬럼을 추가하세요 +
+ ) : ( +
+ {searchColumns.map((searchCol, index) => ( +
+ handleUpdateSearchColumn(index, value)} + placeholder="검색 컬럼 선택" + className="flex-1" + /> + +
+ ))} +
+ )} +
+ )} +
+
+
+
+ + + + + +
+
+ + {/* 컬럼 세부설정 모달 */} + + + + 컬럼 세부설정 + + {editingColumn?.label || editingColumn?.name || "컬럼"}의 표시 설정을 변경합니다. + + + + {editingColumn && ( +
+ {/* 기본 설정 */} +
+

기본 설정

+ +
+ + { + const colInfo = columns.find((c) => c.column_name === value); + setEditingColumn({ + ...editingColumn, + name: value, + label: colInfo?.column_comment || editingColumn.label, + }); + }} + className="mt-1" + /> +
+ +
+ + + setEditingColumn({ ...editingColumn, label: e.target.value }) + } + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="mt-1 h-9" + /> +
+ +
+ + +
+ +
+ + + setEditingColumn({ + ...editingColumn, + width: e.target.value ? parseInt(e.target.value) : undefined, + }) + } + placeholder="자동" + className="mt-1 h-9" + /> +
+
+ + {/* 그룹핑/집계 설정 (그룹핑 활성화 시만) */} + {grouping.enabled && ( +
+

그룹핑/집계 설정

+ +
+ + +

+ 배지는 여러 값을 태그 형태로 나란히 표시합니다 +

+
+ +
+
+ +

+ 그룹핑 시 값을 집계합니다 +

+
+ + setEditingColumn({ + ...editingColumn, + displayConfig: { + displayType: editingColumn.displayConfig?.displayType || "text", + aggregate: { + enabled: checked, + function: editingColumn.displayConfig?.aggregate?.function || "DISTINCT", + }, + }, + }) + } + /> +
+ + {editingColumn.displayConfig?.aggregate?.enabled && ( +
+ + +
+ )} +
+ )} + + {/* 엔티티 참조 설정 (entity 타입 컬럼일 때만 표시) */} + {(() => { + const entityInfo = getEditingColumnEntityInfo(); + if (!entityInfo) return null; + + return ( +
+

엔티티 표시 컬럼

+

+ 참조 테이블: {entityInfo.referenceTable} +

+ + {entityInfo.isLoading ? ( +
+ 컬럼 정보 로딩 중... +
+ ) : entityInfo.referenceColumns.length === 0 ? ( +
+ 참조 테이블의 컬럼 정보를 불러올 수 없습니다 +
+ ) : ( + +
+ {entityInfo.referenceColumns.map((col) => { + const isSelected = (editingColumn.entityReference?.displayColumns || []).includes(col.columnName); + return ( +
toggleEntityDisplayColumn(col.columnName)} + > + toggleEntityDisplayColumn(col.columnName)} + /> +
+ + {col.displayName || col.columnName} + + + {col.columnName} ({col.dataType}) + +
+
+ ); + })} +
+
+ )} + + {(editingColumn.entityReference?.displayColumns || []).length > 0 && ( +
+

+ 선택됨: {(editingColumn.entityReference?.displayColumns || []).length}개 +

+
+ {(editingColumn.entityReference?.displayColumns || []).map((colName) => { + const colInfo = entityInfo.referenceColumns.find(c => c.columnName === colName); + return ( + + {colInfo?.displayName || colName} + + ); + })} +
+
+ )} +
+ ); + })()} +
+ )} + + + + + +
+
+ + ); +}; + +export default ColumnConfigModal; + diff --git a/frontend/lib/registry/components/split-panel-layout2/DataTransferConfigModal.tsx b/frontend/lib/registry/components/split-panel-layout2/DataTransferConfigModal.tsx new file mode 100644 index 00000000..36ce4a96 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/DataTransferConfigModal.tsx @@ -0,0 +1,423 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Plus, X, Settings, ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import type { DataTransferField } from "./types"; + +interface ColumnInfo { + column_name: string; + column_comment?: string; + data_type?: string; +} + +interface DataTransferConfigModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + dataTransferFields: DataTransferField[]; + onChange: (fields: DataTransferField[]) => void; + leftColumns: ColumnInfo[]; + rightColumns: ColumnInfo[]; + leftTableName?: string; + rightTableName?: string; +} + +// 컬럼 선택 컴포넌트 +const ColumnSelect: React.FC<{ + columns: ColumnInfo[]; + value: string; + onValueChange: (value: string) => void; + placeholder: string; + disabled?: boolean; +}> = ({ columns, value, onValueChange, placeholder, disabled = false }) => { + return ( + + ); +}; + +// 개별 필드 편집 모달 +const FieldEditModal: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; + field: DataTransferField | null; + onSave: (field: DataTransferField) => void; + leftColumns: ColumnInfo[]; + rightColumns: ColumnInfo[]; + leftTableName?: string; + rightTableName?: string; + isNew?: boolean; +}> = ({ + open, + onOpenChange, + field, + onSave, + leftColumns, + rightColumns, + leftTableName, + rightTableName, + isNew = false, +}) => { + const [editingField, setEditingField] = useState({ + id: "", + panel: "left", + sourceColumn: "", + targetColumn: "", + label: "", + description: "", + }); + + useEffect(() => { + if (field) { + setEditingField({ ...field }); + } else { + setEditingField({ + id: `field_${Date.now()}`, + panel: "left", + sourceColumn: "", + targetColumn: "", + label: "", + description: "", + }); + } + }, [field, open]); + + const handleSave = () => { + if (!editingField.sourceColumn || !editingField.targetColumn) { + return; + } + onSave(editingField); + onOpenChange(false); + }; + + const currentColumns = editingField.panel === "left" ? leftColumns : rightColumns; + const currentTableName = editingField.panel === "left" ? leftTableName : rightTableName; + + return ( + + + + {isNew ? "데이터 전달 필드 추가" : "데이터 전달 필드 편집"} + + 선택한 항목의 데이터를 모달에 자동으로 전달합니다. + + + +
+ {/* 패널 선택 */} +
+ + +

데이터를 가져올 패널을 선택합니다.

+
+ + {/* 소스 컬럼 */} +
+ +
+ { + const col = currentColumns.find((c) => c.column_name === value); + setEditingField({ + ...editingField, + sourceColumn: value, + // 타겟 컬럼이 비어있으면 소스와 동일하게 설정 + targetColumn: editingField.targetColumn || value, + // 라벨이 비어있으면 컬럼 코멘트 사용 + label: editingField.label || col?.column_comment || "", + }); + }} + placeholder="컬럼 선택..." + disabled={currentColumns.length === 0} + /> +
+ {currentColumns.length === 0 && ( +

+ {currentTableName ? "테이블에 컬럼이 없습니다." : "테이블을 먼저 선택해주세요."} +

+ )} +
+ + {/* 타겟 컬럼 */} +
+ + setEditingField({ ...editingField, targetColumn: e.target.value })} + placeholder="모달에서 사용할 필드명" + className="mt-1 h-9 text-sm" + /> +

모달 폼에서 이 값을 받을 필드명입니다.

+
+ + {/* 라벨 (선택) */} +
+ + setEditingField({ ...editingField, label: e.target.value })} + placeholder="표시용 이름" + className="mt-1 h-9 text-sm" + /> +
+ + {/* 설명 (선택) */} +
+ + setEditingField({ ...editingField, description: e.target.value })} + placeholder="이 필드에 대한 설명" + className="mt-1 h-9 text-sm" + /> +
+ + {/* 미리보기 */} + {editingField.sourceColumn && editingField.targetColumn && ( +
+

미리보기

+
+ + {editingField.panel === "left" ? "좌측" : "우측"} + + {editingField.sourceColumn} + + {editingField.targetColumn} +
+
+ )} +
+ + + + + +
+
+ ); +}; + +// 메인 모달 컴포넌트 +const DataTransferConfigModal: React.FC = ({ + open, + onOpenChange, + dataTransferFields, + onChange, + leftColumns, + rightColumns, + leftTableName, + rightTableName, +}) => { + const [fields, setFields] = useState([]); + const [editModalOpen, setEditModalOpen] = useState(false); + const [editingField, setEditingField] = useState(null); + const [isNewField, setIsNewField] = useState(false); + + useEffect(() => { + if (open) { + // 기존 필드에 panel이 없으면 left로 기본 설정 (하위 호환성) + const normalizedFields = (dataTransferFields || []).map((field, idx) => ({ + ...field, + id: field.id || `field_${idx}`, + panel: field.panel || ("left" as const), + })); + setFields(normalizedFields); + } + }, [open, dataTransferFields]); + + const handleAddField = () => { + setEditingField(null); + setIsNewField(true); + setEditModalOpen(true); + }; + + const handleEditField = (field: DataTransferField) => { + setEditingField(field); + setIsNewField(false); + setEditModalOpen(true); + }; + + const handleSaveField = (field: DataTransferField) => { + if (isNewField) { + setFields([...fields, field]); + } else { + setFields(fields.map((f) => (f.id === field.id ? field : f))); + } + }; + + const handleRemoveField = (id: string) => { + setFields(fields.filter((f) => f.id !== id)); + }; + + const handleSave = () => { + onChange(fields); + onOpenChange(false); + }; + + const getColumnLabel = (panel: "left" | "right", columnName: string) => { + const columns = panel === "left" ? leftColumns : rightColumns; + const col = columns.find((c) => c.column_name === columnName); + return col?.column_comment || columnName; + }; + + return ( + <> + + + + 데이터 전달 설정 + + 버튼 클릭 시 모달에 자동으로 전달할 데이터를 설정합니다. + + + +
+
+ 전달 필드 ({fields.length}개) + +
+ + +
+ {fields.length === 0 ? ( +
+

전달할 필드가 없습니다

+ +
+ ) : ( + fields.map((field) => ( +
+ + {field.panel === "left" ? "좌측" : "우측"} + +
+
+ {getColumnLabel(field.panel, field.sourceColumn)} + + {field.targetColumn} +
+ {field.description && ( +

{field.description}

+ )} +
+
+ + +
+
+ )) + )} +
+
+
+ +
+

버튼별로 개별 데이터 전달 설정이 있으면 해당 설정이 우선 적용됩니다.

+
+ + + + + +
+
+ + {/* 필드 편집 모달 */} + + + ); +}; + +export default DataTransferConfigModal; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 679a64c9..ec52ba80 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -2,7 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; -import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig } from "./types"; +import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig, GroupingConfig, ColumnDisplayConfig, TabConfig } from "./types"; +import { Badge } from "@/components/ui/badge"; import { defaultConfig } from "./config"; import { cn } from "@/lib/utils"; import { @@ -86,6 +87,177 @@ export const SplitPanelLayout2Component: React.FC(null); const [isBulkDelete, setIsBulkDelete] = useState(false); + // 탭 상태 (좌측/우측 각각) + const [leftActiveTab, setLeftActiveTab] = useState(null); + const [rightActiveTab, setRightActiveTab] = useState(null); + + // 프론트엔드 그룹핑 함수 + const groupData = useCallback( + (data: Record[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record[] => { + if (!groupingConfig.enabled || !groupingConfig.groupByColumn) { + return data; + } + + const groupByColumn = groupingConfig.groupByColumn; + const groupMap = new Map>(); + + // 데이터를 그룹별로 수집 + data.forEach((item) => { + const groupKey = String(item[groupByColumn] ?? ""); + + if (!groupMap.has(groupKey)) { + // 첫 번째 항목을 기준으로 그룹 초기화 + const groupedItem: Record = { ...item }; + + // 각 컬럼의 displayConfig 확인하여 집계 준비 + columns.forEach((col) => { + if (col.displayConfig?.aggregate?.enabled) { + // 집계가 활성화된 컬럼은 배열로 초기화 + groupedItem[`__agg_${col.name}`] = [item[col.name]]; + } + }); + + groupMap.set(groupKey, groupedItem); + } else { + // 기존 그룹에 값 추가 + const existingGroup = groupMap.get(groupKey)!; + + columns.forEach((col) => { + if (col.displayConfig?.aggregate?.enabled) { + const aggKey = `__agg_${col.name}`; + if (!existingGroup[aggKey]) { + existingGroup[aggKey] = []; + } + existingGroup[aggKey].push(item[col.name]); + } + }); + } + }); + + // 집계 처리 및 결과 변환 + const result: Record[] = []; + + groupMap.forEach((groupedItem) => { + columns.forEach((col) => { + if (col.displayConfig?.aggregate?.enabled) { + const aggKey = `__agg_${col.name}`; + const values = groupedItem[aggKey] || []; + + if (col.displayConfig.aggregate.function === "DISTINCT") { + // 중복 제거 후 배열로 저장 + const uniqueValues = [...new Set(values.filter((v: any) => v !== null && v !== undefined))]; + groupedItem[col.name] = uniqueValues; + } else if (col.displayConfig.aggregate.function === "COUNT") { + // 개수를 숫자로 저장 + groupedItem[col.name] = values.filter((v: any) => v !== null && v !== undefined).length; + } + + // 임시 집계 키 제거 + delete groupedItem[aggKey]; + } + }); + + result.push(groupedItem); + }); + + console.log(`[SplitPanelLayout2] 그룹핑 완료: ${data.length}건 → ${result.length}개 그룹`); + return result; + }, + [], + ); + + // 탭 목록 생성 함수 (데이터에서 고유값 추출) + const generateTabs = useCallback( + (data: Record[], tabConfig: TabConfig | undefined): { id: string; label: string; count: number }[] => { + if (!tabConfig?.enabled || !tabConfig.tabSourceColumn) { + return []; + } + + const sourceColumn = tabConfig.tabSourceColumn; + + // 데이터에서 고유값 추출 및 개수 카운트 + const valueCount = new Map(); + data.forEach((item) => { + const value = String(item[sourceColumn] ?? ""); + if (value) { + valueCount.set(value, (valueCount.get(value) || 0) + 1); + } + }); + + // 탭 목록 생성 + const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({ + id: value, + label: value, + count: tabConfig.showCount ? count : 0, + })); + + console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`); + return tabs; + }, + [], + ); + + // 탭으로 필터링된 데이터 반환 + const filterDataByTab = useCallback( + (data: Record[], activeTab: string | null, tabConfig: TabConfig | undefined): Record[] => { + if (!tabConfig?.enabled || !activeTab || !tabConfig.tabSourceColumn) { + return data; + } + + const sourceColumn = tabConfig.tabSourceColumn; + return data.filter((item) => String(item[sourceColumn] ?? "") === activeTab); + }, + [], + ); + + // 좌측 패널 탭 목록 (메모이제이션) + const leftTabs = useMemo(() => { + if (!config.leftPanel?.tabConfig?.enabled || !config.leftPanel?.tabConfig?.tabSourceColumn) { + return []; + } + return generateTabs(leftData, config.leftPanel.tabConfig); + }, [leftData, config.leftPanel?.tabConfig, generateTabs]); + + // 우측 패널 탭 목록 (메모이제이션) + const rightTabs = useMemo(() => { + if (!config.rightPanel?.tabConfig?.enabled || !config.rightPanel?.tabConfig?.tabSourceColumn) { + return []; + } + return generateTabs(rightData, config.rightPanel.tabConfig); + }, [rightData, config.rightPanel?.tabConfig, generateTabs]); + + // 탭 기본값 설정 (탭 목록이 변경되면 기본 탭 선택) + useEffect(() => { + if (leftTabs.length > 0 && !leftActiveTab) { + const defaultTab = config.leftPanel?.tabConfig?.defaultTab; + if (defaultTab && leftTabs.some((t) => t.id === defaultTab)) { + setLeftActiveTab(defaultTab); + } else { + setLeftActiveTab(leftTabs[0].id); + } + } + }, [leftTabs, leftActiveTab, config.leftPanel?.tabConfig?.defaultTab]); + + useEffect(() => { + if (rightTabs.length > 0 && !rightActiveTab) { + const defaultTab = config.rightPanel?.tabConfig?.defaultTab; + if (defaultTab && rightTabs.some((t) => t.id === defaultTab)) { + setRightActiveTab(defaultTab); + } else { + setRightActiveTab(rightTabs[0].id); + } + } + }, [rightTabs, rightActiveTab, config.rightPanel?.tabConfig?.defaultTab]); + + // 탭 필터링된 데이터 (메모이제이션) + const filteredLeftDataByTab = useMemo(() => { + return filterDataByTab(leftData, leftActiveTab, config.leftPanel?.tabConfig); + }, [leftData, leftActiveTab, config.leftPanel?.tabConfig, filterDataByTab]); + + const filteredRightDataByTab = useMemo(() => { + return filterDataByTab(rightData, rightActiveTab, config.rightPanel?.tabConfig); + }, [rightData, rightActiveTab, config.rightPanel?.tabConfig, filterDataByTab]); + // 좌측 데이터 로드 const loadLeftData = useCallback(async () => { if (!config.leftPanel?.tableName || isDesignMode) return; @@ -115,6 +287,80 @@ export const SplitPanelLayout2Component: React.FC 0) { + for (const joinTableConfig of config.leftPanel.joinTables) { + if (!joinTableConfig.joinTable || !joinTableConfig.mainColumn || !joinTableConfig.joinColumn) { + continue; + } + // 메인 데이터에서 조인할 키 값들 추출 + const joinKeys = [ + ...new Set(data.map((item: Record) => item[joinTableConfig.mainColumn]).filter(Boolean)), + ]; + if (joinKeys.length === 0) continue; + + try { + const joinResponse = await apiClient.post(`/table-management/tables/${joinTableConfig.joinTable}/data`, { + page: 1, + size: 1000, + dataFilter: { + enabled: true, + matchType: "any", + filters: joinKeys.map((key, idx) => ({ + id: `join_key_${idx}`, + columnName: joinTableConfig.joinColumn, + operator: "equals", + value: String(key), + valueType: "static", + })), + }, + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + + if (joinResponse.data.success) { + const joinDataArray = joinResponse.data.data?.data || []; + const joinDataMap = new Map>(); + joinDataArray.forEach((item: Record) => { + const key = item[joinTableConfig.joinColumn]; + if (key) joinDataMap.set(String(key), item); + }); + + if (joinDataMap.size > 0) { + data = data.map((item: Record) => { + const joinKey = item[joinTableConfig.mainColumn]; + const joinData = joinDataMap.get(String(joinKey)); + if (joinData) { + const mergedData = { ...item }; + joinTableConfig.selectColumns.forEach((col) => { + // 테이블.컬럼명 형식으로 저장 + mergedData[`${joinTableConfig.joinTable}.${col}`] = joinData[col]; + // 컬럼명만으로도 저장 (기존 값이 없을 때) + if (!(col in mergedData)) { + mergedData[col] = joinData[col]; + } + }); + return mergedData; + } + return item; + }); + } + console.log(`[SplitPanelLayout2] 좌측 조인 테이블 로드: ${joinTableConfig.joinTable}, ${joinDataArray.length}건`); + } + } catch (error) { + console.error(`[SplitPanelLayout2] 좌측 조인 테이블 로드 실패 (${joinTableConfig.joinTable}):`, error); + } + } + } + + // 그룹핑 처리 + if (config.leftPanel.grouping?.enabled && config.leftPanel.grouping.groupByColumn) { + data = groupData(data, config.leftPanel.grouping, config.leftPanel.displayColumns || []); + } + setLeftData(data); console.log(`[SplitPanelLayout2] 좌측 데이터 로드: ${data.length}건 (company_code 필터 적용)`); } @@ -124,7 +370,7 @@ export const SplitPanelLayout2Component: React.FC { - if (!leftSearchTerm) return leftData; + // 1. 먼저 탭 필터링 적용 + const data = filteredLeftDataByTab; + + // 2. 검색어가 없으면 탭 필터링된 데이터 반환 + if (!leftSearchTerm) return data; // 복수 검색 컬럼 지원 (searchColumns 우선, 없으면 searchColumn 사용) const searchColumns = config.leftPanel?.searchColumns?.map((c) => c.columnName).filter(Boolean) || []; const legacyColumn = config.leftPanel?.searchColumn; const columnsToSearch = searchColumns.length > 0 ? searchColumns : legacyColumn ? [legacyColumn] : []; - if (columnsToSearch.length === 0) return leftData; + if (columnsToSearch.length === 0) return data; const filterRecursive = (items: any[]): any[] => { return items.filter((item) => { @@ -731,27 +981,31 @@ export const SplitPanelLayout2Component: React.FC { - if (!rightSearchTerm) return rightData; + // 1. 먼저 탭 필터링 적용 + const data = filteredRightDataByTab; + + // 2. 검색어가 없으면 탭 필터링된 데이터 반환 + if (!rightSearchTerm) return data; // 복수 검색 컬럼 지원 (searchColumns 우선, 없으면 searchColumn 사용) const searchColumns = config.rightPanel?.searchColumns?.map((c) => c.columnName).filter(Boolean) || []; const legacyColumn = config.rightPanel?.searchColumn; const columnsToSearch = searchColumns.length > 0 ? searchColumns : legacyColumn ? [legacyColumn] : []; - if (columnsToSearch.length === 0) return rightData; + if (columnsToSearch.length === 0) return data; - return rightData.filter((item) => { + return data.filter((item) => { // 여러 컬럼 중 하나라도 매칭되면 포함 return columnsToSearch.some((col) => { const value = String(item[col] || "").toLowerCase(); return value.includes(rightSearchTerm.toLowerCase()); }); }); - }, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]); + }, [filteredRightDataByTab, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]); // 체크박스 전체 선택/해제 (filteredRightData 정의 이후에 위치해야 함) const handleSelectAll = useCallback( @@ -835,7 +1089,7 @@ export const SplitPanelLayout2Component: React.FC { // col.name이 "테이블명.컬럼명" 형식인 경우 처리 @@ -843,28 +1097,66 @@ export const SplitPanelLayout2Component: React.FC jt.joinTable === effectiveSourceTable); - if (joinTable?.alias) { - const aliasKey = `${joinTable.alias}_${actualColName}`; - if (item[aliasKey] !== undefined) { - return item[aliasKey]; + baseValue = item[tableColumnKey]; + } else { + // 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도 + const joinTable = config.rightPanel?.joinTables?.find((jt) => jt.joinTable === effectiveSourceTable); + if (joinTable?.alias) { + const aliasKey = `${joinTable.alias}_${actualColName}`; + if (item[aliasKey] !== undefined) { + baseValue = item[aliasKey]; + } + } + // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) + if (baseValue === undefined && item[actualColName] !== undefined) { + baseValue = item[actualColName]; } } - // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) - if (item[actualColName] !== undefined) { - return item[actualColName]; + } else { + // 4. 기본: 컬럼명으로 직접 접근 + baseValue = item[actualColName]; + } + + // 엔티티 참조 설정이 있는 경우 - 선택된 컬럼들의 값을 결합 + if (col.entityReference?.displayColumns && col.entityReference.displayColumns.length > 0) { + // 엔티티 참조 컬럼들의 값을 수집 + // 백엔드에서 entity 조인을 통해 "컬럼명_참조테이블컬럼" 형태로 데이터가 들어옴 + const entityValues: string[] = []; + + for (const displayCol of col.entityReference.displayColumns) { + // 다양한 형식으로 값을 찾아봄 + // 1. 직접 컬럼명 (entity 조인 결과) + if (item[displayCol] !== undefined && item[displayCol] !== null) { + entityValues.push(String(item[displayCol])); + } + // 2. 컬럼명_참조컬럼 형식 + else if (item[`${actualColName}_${displayCol}`] !== undefined) { + entityValues.push(String(item[`${actualColName}_${displayCol}`])); + } + // 3. 참조테이블.컬럼 형식 + else if (col.entityReference.entityId) { + const refTableCol = `${col.entityReference.entityId}.${displayCol}`; + if (item[refTableCol] !== undefined && item[refTableCol] !== null) { + entityValues.push(String(item[refTableCol])); + } + } + } + + // 엔티티 값들이 있으면 결합하여 반환 + if (entityValues.length > 0) { + return entityValues.join(" - "); } } - // 4. 기본: 컬럼명으로 직접 접근 - return item[actualColName]; + + return baseValue; }, [config.rightPanel?.tableName, config.rightPanel?.joinTables], ); @@ -969,15 +1261,39 @@ export const SplitPanelLayout2Component: React.FC - {/* 내용 */} + {/* 내용 */}
{/* 이름 행 (Name Row) */} -
+
{primaryValue || "이름 없음"} - {/* 이름 행의 추가 컬럼들 (배지 스타일) */} + {/* 이름 행의 추가 컬럼들 */} {nameRowColumns.slice(1).map((col, idx) => { const value = item[col.name]; - if (!value) return null; + if (value === null || value === undefined) return null; + + // 배지 타입이고 배열인 경우 + if (col.displayConfig?.displayType === "badge" && Array.isArray(value)) { + return ( +
+ {value.map((v, vIdx) => ( + + {formatValue(v, col.format)} + + ))} +
+ ); + } + + // 배지 타입이지만 단일 값인 경우 + if (col.displayConfig?.displayType === "badge") { + return ( + + {formatValue(value, col.format)} + + ); + } + + // 기본 텍스트 스타일 return ( {formatValue(value, col.format)} @@ -987,16 +1303,40 @@ export const SplitPanelLayout2Component: React.FC {/* 정보 행 (Info Row) */} {infoRowColumns.length > 0 && ( -
+
{infoRowColumns .map((col, idx) => { const value = item[col.name]; - if (!value) return null; + if (value === null || value === undefined) return null; + + // 배지 타입이고 배열인 경우 + if (col.displayConfig?.displayType === "badge" && Array.isArray(value)) { + return ( +
+ {value.map((v, vIdx) => ( + + {formatValue(v, col.format)} + + ))} +
+ ); + } + + // 배지 타입이지만 단일 값인 경우 + if (col.displayConfig?.displayType === "badge") { + return ( + + {formatValue(value, col.format)} + + ); + } + + // 기본 텍스트 return {formatValue(value, col.format)}; }) .filter(Boolean) .reduce((acc: React.ReactNode[], curr, idx) => { - if (idx > 0) + if (idx > 0 && !React.isValidElement(curr)) acc.push( | @@ -1020,6 +1360,95 @@ export const SplitPanelLayout2Component: React.FC { + return config.leftPanel?.primaryKeyColumn || config.leftPanel?.hierarchyConfig?.idColumn || "id"; + }, [config.leftPanel?.primaryKeyColumn, config.leftPanel?.hierarchyConfig?.idColumn]); + + // 왼쪽 패널 테이블 렌더링 + const renderLeftTable = () => { + const displayColumns = config.leftPanel?.displayColumns || []; + const pkColumn = getLeftPrimaryKeyColumn(); + + // 값 렌더링 (배지 지원) + const renderCellValue = (item: any, col: ColumnConfig) => { + const value = item[col.name]; + if (value === null || value === undefined) return "-"; + + // 배지 타입이고 배열인 경우 + if (col.displayConfig?.displayType === "badge" && Array.isArray(value)) { + return ( +
+ {value.map((v, vIdx) => ( + + {formatValue(v, col.format)} + + ))} +
+ ); + } + + // 배지 타입이지만 단일 값인 경우 + if (col.displayConfig?.displayType === "badge") { + return ( + + {formatValue(value, col.format)} + + ); + } + + // 기본 텍스트 + return formatValue(value, col.format); + }; + + return ( +
+ + + + {displayColumns.map((col, idx) => ( + + {col.label || col.name} + + ))} + + + + {filteredLeftData.length === 0 ? ( + + + 데이터가 없습니다 + + + ) : ( + filteredLeftData.map((item, index) => { + const itemId = item[pkColumn]; + const isItemSelected = + selectedLeftItem && + (selectedLeftItem === item || + (item[pkColumn] !== undefined && + selectedLeftItem[pkColumn] !== undefined && + selectedLeftItem[pkColumn] === item[pkColumn])); + + return ( + handleLeftItemSelect(item)} + > + {displayColumns.map((col, colIdx) => ( + {renderCellValue(item, col)} + ))} + + ); + }) + )} + +
+
+ ); + }; + // 우측 패널 카드 렌더링 const renderRightCard = (item: any, index: number) => { const displayColumns = config.rightPanel?.displayColumns || []; @@ -1285,6 +1714,11 @@ export const SplitPanelLayout2Component: React.FC {/* 좌측 패널 미리보기 */} -
-
{config.leftPanel?.title || "좌측 패널"}
-
테이블: {config.leftPanel?.tableName || "미설정"}
-
좌측 목록 영역
+
+ {/* 헤더 */} +
+
+
{config.leftPanel?.title || "좌측 패널"}
+
+ {config.leftPanel?.tableName || "테이블 미설정"} +
+
+ {leftButtons.length > 0 && ( +
+ {leftButtons.slice(0, 2).map((btn) => ( +
+ {btn.label} +
+ ))} + {leftButtons.length > 2 && ( +
+{leftButtons.length - 2}
+ )} +
+ )} +
+ + {/* 검색 표시 */} + {config.leftPanel?.showSearch && ( +
+
+ 검색 +
+
+ )} + + {/* 컬럼 미리보기 */} +
+ {leftDisplayColumns.length > 0 ? ( +
+ {/* 샘플 카드 */} + {[1, 2, 3].map((i) => ( +
+
+ {leftDisplayColumns + .filter((col) => col.displayRow === "name" || !col.displayRow) + .slice(0, 2) + .map((col, idx) => ( +
+ {col.label || col.name} +
+ ))} +
+ {leftDisplayColumns.filter((col) => col.displayRow === "info").length > 0 && ( +
+ {leftDisplayColumns + .filter((col) => col.displayRow === "info") + .slice(0, 3) + .map((col) => ( + {col.label || col.name} + ))} +
+ )} +
+ ))} +
+ ) : ( +
+
컬럼 미설정
+
+ )} +
{/* 우측 패널 미리보기 */} -
-
{config.rightPanel?.title || "우측 패널"}
-
테이블: {config.rightPanel?.tableName || "미설정"}
-
우측 상세 영역
+
+ {/* 헤더 */} +
+
+
{config.rightPanel?.title || "우측 패널"}
+
+ {config.rightPanel?.tableName || "테이블 미설정"} +
+
+ {rightButtons.length > 0 && ( +
+ {rightButtons.slice(0, 2).map((btn) => ( +
+ {btn.label} +
+ ))} + {rightButtons.length > 2 && ( +
+{rightButtons.length - 2}
+ )} +
+ )} +
+ + {/* 검색 표시 */} + {config.rightPanel?.showSearch && ( +
+
+ 검색 +
+
+ )} + + {/* 컬럼 미리보기 */} +
+ {rightDisplayColumns.length > 0 ? ( + config.rightPanel?.displayMode === "table" ? ( + // 테이블 모드 미리보기 +
+
+ {config.rightPanel?.showCheckbox && ( +
+ )} + {rightDisplayColumns.slice(0, 4).map((col) => ( +
+ {col.label || col.name} +
+ ))} +
+ {[1, 2, 3].map((i) => ( +
+ {config.rightPanel?.showCheckbox && ( +
+
+
+ )} + {rightDisplayColumns.slice(0, 4).map((col) => ( +
+ --- +
+ ))} +
+ ))} +
+ ) : ( + // 카드 모드 미리보기 +
+ {[1, 2].map((i) => ( +
+
+ {rightDisplayColumns + .filter((col) => col.displayRow === "name" || !col.displayRow) + .slice(0, 2) + .map((col, idx) => ( +
+ {col.label || col.name} +
+ ))} +
+ {rightDisplayColumns.filter((col) => col.displayRow === "info").length > 0 && ( +
+ {rightDisplayColumns + .filter((col) => col.displayRow === "info") + .slice(0, 3) + .map((col) => ( + {col.label || col.name} + ))} +
+ )} +
+ ))} +
+ ) + ) : ( +
+
컬럼 미설정
+
+ )} +
+ + {/* 연결 설정 표시 */} + {(config.joinConfig?.leftColumn || config.joinConfig?.keys?.length) && ( +
+
+ 연결: {config.joinConfig?.leftColumn || config.joinConfig?.keys?.[0]?.leftColumn} →{" "} + {config.joinConfig?.rightColumn || config.joinConfig?.keys?.[0]?.rightColumn} +
+
+ )}
); @@ -1325,12 +1951,36 @@ export const SplitPanelLayout2Component: React.FC

{config.leftPanel?.title || "목록"}

- {config.leftPanel?.showAddButton && ( + {/* actionButtons 배열이 정의되어 있으면(빈 배열 포함) 해당 배열 사용, undefined면 기존 showAddButton 사용 (하위호환) */} + {config.leftPanel?.actionButtons !== undefined ? ( + // 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음) + config.leftPanel.actionButtons.length > 0 && ( +
+ {config.leftPanel.actionButtons.map((btn, idx) => ( + + ))} +
+ ) + ) : config.leftPanel?.showAddButton ? ( + // 기존 showAddButton 기반 렌더링 (하위호환 - actionButtons가 undefined일 때만) - )} + ) : null}
{/* 검색 */} @@ -1347,15 +1997,49 @@ export const SplitPanelLayout2Component: React.FC + {/* 좌측 패널 탭 */} + {config.leftPanel?.tabConfig?.enabled && leftTabs.length > 0 && ( +
+ {leftTabs.map((tab) => ( + + ))} +
+ )} + {/* 목록 */} -
+
{leftLoading ? (
로딩 중...
+ ) : (config.leftPanel?.displayMode || "card") === "table" ? ( + // 테이블 모드 + renderLeftTable() ) : filteredLeftData.length === 0 ? (
데이터가 없습니다
) : ( + // 카드 모드 (기본)
{filteredLeftData.map((item, index) => renderLeftItem(item, 0, index))}
)}
@@ -1389,15 +2073,18 @@ export const SplitPanelLayout2Component: React.FC
- {/* 복수 액션 버튼 (actionButtons 설정 시) */} - {selectedLeftItem && renderActionButtons()} - - {/* 기존 단일 추가 버튼 (하위 호환성) */} - {config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && ( - + {/* actionButtons 배열이 정의되어 있으면(빈 배열 포함) 해당 배열 사용, undefined면 기존 showAddButton 사용 (하위호환) */} + {selectedLeftItem && ( + config.rightPanel?.actionButtons !== undefined ? ( + // 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음) + config.rightPanel.actionButtons.length > 0 && renderActionButtons() + ) : config.rightPanel?.showAddButton ? ( + // 기존 showAddButton 기반 렌더링 (하위호환 - actionButtons가 undefined일 때만) + + ) : null )}
@@ -1416,6 +2103,36 @@ export const SplitPanelLayout2Component: React.FC + {/* 우측 패널 탭 */} + {config.rightPanel?.tabConfig?.enabled && rightTabs.length > 0 && selectedLeftItem && ( +
+ {rightTabs.map((tab) => ( + + ))} +
+ )} + {/* 내용 */}
{!selectedLeftItem ? ( diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 518f85e0..1c7b7c77 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -8,10 +8,21 @@ import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; +import { Check, ChevronsUpDown, Plus, X, Settings, Columns, MousePointerClick } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; -import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField, JoinTableConfig } from "./types"; +import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField, JoinTableConfig, ColumnDisplayConfig, ActionButtonConfig, SearchColumnConfig, GroupingConfig, TabConfig, ButtonDataTransferConfig } from "./types"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ColumnConfigModal } from "./ColumnConfigModal"; +import { ActionButtonConfigModal } from "./ActionButtonConfigModal"; // lodash set 대체 함수 const setPath = (obj: any, path: string, value: any): any => { @@ -77,6 +88,26 @@ export const SplitPanelLayout2ConfigPanel: React.FC(null); + const [editingColumnConfig, setEditingColumnConfig] = useState({ + displayType: "text", + aggregate: { enabled: false, function: "DISTINCT" }, + }); + + // 새로운 컬럼 설정 모달 상태 + const [leftColumnModalOpen, setLeftColumnModalOpen] = useState(false); + const [rightColumnModalOpen, setRightColumnModalOpen] = useState(false); + + // 액션 버튼 설정 모달 상태 + const [leftActionButtonModalOpen, setLeftActionButtonModalOpen] = useState(false); + const [rightActionButtonModalOpen, setRightActionButtonModalOpen] = useState(false); + + // 데이터 전달 설정 모달 상태 + const [dataTransferModalOpen, setDataTransferModalOpen] = useState(false); + const [editingTransferIndex, setEditingTransferIndex] = useState(null); + // 테이블 목록 로드 const loadTables = useCallback(async () => { setTablesLoading(true); @@ -337,9 +368,9 @@ export const SplitPanelLayout2ConfigPanel: React.FC ( { - onValueChange(selectedValue); + value={`${table.table_name} ${table.table_comment || ""}`} + onSelect={() => { + onValueChange(table.table_name); onOpenChange(false); }} > @@ -485,6 +516,74 @@ export const SplitPanelLayout2ConfigPanel: React.FC void; + placeholder: string; + disabled?: boolean; + }> = ({ columns, value, onValueChange, placeholder, disabled = false }) => { + const [open, setOpen] = useState(false); + + // 현재 선택된 값의 라벨 찾기 + const selectedColumn = columns.find((col) => col.column_name === value); + const displayValue = selectedColumn + ? `${selectedColumn.column_comment || selectedColumn.column_name} (${selectedColumn.column_name})` + : ""; + + return ( + + + + + + + + + 검색 결과가 없습니다 + + {columns.map((col, index) => ( + { + onValueChange(col.column_name); + setOpen(false); + }} + className="text-xs" + > + +
+ {col.column_comment || col.column_name} + {col.column_name} +
+
+ ))} +
+
+
+
+
+ ); + }; + // 조인 테이블 아이템 컴포넌트 const JoinTableItem: React.FC<{ index: number; @@ -743,28 +842,66 @@ export const SplitPanelLayout2ConfigPanel: React.FC { - const currentFields = config.dataTransferFields || []; - updateConfig("dataTransferFields", [...currentFields, { sourceColumn: "", targetColumn: "" }]); - }; + // 컬럼 세부설정 저장 + const handleSaveColumnConfig = () => { + if (editingColumnIndex === null) return; - // 데이터 전달 필드 삭제 - const removeDataTransferField = (index: number) => { - const currentFields = config.dataTransferFields || []; - updateConfig( - "dataTransferFields", - currentFields.filter((_, i) => i !== index), - ); - }; - - // 데이터 전달 필드 업데이트 - const updateDataTransferField = (index: number, field: keyof DataTransferField, value: string) => { - const currentFields = [...(config.dataTransferFields || [])]; - if (currentFields[index]) { - currentFields[index] = { ...currentFields[index], [field]: value }; - updateConfig("dataTransferFields", currentFields); + const currentColumns = [...(config.leftPanel?.displayColumns || [])]; + if (currentColumns[editingColumnIndex]) { + currentColumns[editingColumnIndex] = { + ...currentColumns[editingColumnIndex], + displayConfig: editingColumnConfig, + }; + updateConfig("leftPanel.displayColumns", currentColumns); } + + setColumnConfigModalOpen(false); + setEditingColumnIndex(null); + }; + + // 좌측 컬럼 설정 모달 저장 핸들러 + const handleLeftColumnConfigSave = (columnConfig: { + displayColumns: ColumnConfig[]; + searchColumns: SearchColumnConfig[]; + grouping: GroupingConfig; + showSearch: boolean; + }) => { + // 모든 변경사항을 한 번에 적용 (개별 updateConfig 호출 시 마지막 값만 적용되는 문제 방지) + const newLeftPanel = { + ...config.leftPanel, + displayColumns: columnConfig.displayColumns, + searchColumns: columnConfig.searchColumns, + grouping: columnConfig.grouping, + showSearch: columnConfig.showSearch, + }; + onChange({ ...config, leftPanel: newLeftPanel }); + }; + + // 좌측 액션 버튼 설정 모달 저장 핸들러 + const handleLeftActionButtonSave = (buttons: ActionButtonConfig[]) => { + updateConfig("leftPanel.actionButtons", buttons); + }; + + // 우측 컬럼 설정 모달 저장 핸들러 + const handleRightColumnConfigSave = (columnConfig: { + displayColumns: ColumnConfig[]; + searchColumns: SearchColumnConfig[]; + grouping: GroupingConfig; + showSearch: boolean; + }) => { + // 모든 변경사항을 한 번에 적용 (개별 updateConfig 호출 시 마지막 값만 적용되는 문제 방지) + const newRightPanel = { + ...config.rightPanel, + displayColumns: columnConfig.displayColumns, + searchColumns: columnConfig.searchColumns, + showSearch: columnConfig.showSearch, + }; + onChange({ ...config, rightPanel: newRightPanel }); + }; + + // 우측 액션 버튼 설정 모달 저장 핸들러 + const handleRightActionButtonSave = (buttons: ActionButtonConfig[]) => { + updateConfig("rightPanel.actionButtons", buttons); }; return ( @@ -795,163 +932,200 @@ export const SplitPanelLayout2ConfigPanel: React.FC
- {/* 표시 컬럼 */} + {/* 표시 모드 설정 */}
-
- -
+ + {/* 컬럼 설정 버튼 */} +
+
+
+ +

+ 표시 컬럼 {(config.leftPanel?.displayColumns || []).length}개 + {config.leftPanel?.grouping?.enabled && " | 그룹핑 사용"} + {config.leftPanel?.showSearch && " | 검색 사용"} +

+
+ +
+
+ + {/* 액션 버튼 설정 */} +
+
+
+ +

+ {(config.leftPanel?.actionButtons || []).length > 0 + ? `${(config.leftPanel?.actionButtons || []).length}개 버튼` + : config.leftPanel?.showAddButton + ? "기본 추가 버튼" + : "없음"} +

+
+ +
+
+ + {/* 탭 설정 (좌측) */} +
+
+ + { + if (checked) { + updateConfig("leftPanel.tabConfig", { + enabled: true, + mode: "manual", + showCount: true, + }); + } else { + updateConfig("leftPanel.tabConfig.enabled", false); + } + }} + /> +
+ {config.leftPanel?.tabConfig?.enabled && ( +
+ {/* 그룹핑이 설정되어 있지 않으면 안내 메시지 */} + {!config.leftPanel?.grouping?.enabled ? ( +
+

+ 탭 기능을 사용하려면 컬럼 설정에서 그룹핑을 먼저 활성화해주세요. +

+
+ ) : ( + <> +
+ + +

+ 그룹핑/집계된 컬럼 중에서 선택 +

+
+
+ + updateConfig("leftPanel.tabConfig.showCount", checked)} + /> +
+ + )} +
+ )} +
+ + {/* 추가 조인 테이블 설정 (좌측) */} +
+
+ +
-
- {(config.leftPanel?.displayColumns || []).map((col, index) => ( -
-
- 컬럼 {index + 1} - -
- updateDisplayColumn("left", index, "name", value)} - placeholder="컬럼 선택" +

+ 다른 테이블을 조인하면 표시할 컬럼에서 해당 테이블의 컬럼도 선택할 수 있습니다. +

+ {(config.leftPanel?.joinTables || []).length > 0 && ( +
+ {(config.leftPanel?.joinTables || []).map((joinTable, index) => ( + { + const current = [...(config.leftPanel?.joinTables || [])]; + if (typeof fieldOrPartial === "object") { + current[index] = { ...current[index], ...fieldOrPartial }; + } else { + current[index] = { ...current[index], [fieldOrPartial]: value }; + } + updateConfig("leftPanel.joinTables", current); + }} + onRemove={() => { + const current = config.leftPanel?.joinTables || []; + updateConfig( + "leftPanel.joinTables", + current.filter((_, i) => i !== index), + ); + }} /> -
- - updateDisplayColumn("left", index, "label", e.target.value)} - placeholder="라벨명 (미입력 시 컬럼명 사용)" - className="h-8 text-xs" - /> -
-
- - -
-
- ))} - {(config.leftPanel?.displayColumns || []).length === 0 && ( -
- 표시할 컬럼을 추가하세요 -
- )} -
-
- -
- - updateConfig("leftPanel.showSearch", checked)} - /> -
- - {config.leftPanel?.showSearch && ( -
-
- - -
-
- {(config.leftPanel?.searchColumns || []).map((searchCol, index) => ( -
- { - const current = [...(config.leftPanel?.searchColumns || [])]; - current[index] = { ...current[index], columnName: value }; - updateConfig("leftPanel.searchColumns", current); - }} - placeholder="컬럼 선택" - /> - -
))} - {(config.leftPanel?.searchColumns || []).length === 0 && ( -
- 검색할 컬럼을 추가하세요 -
- )}
-
- )} - -
- - updateConfig("leftPanel.showAddButton", checked)} - /> + )}
- - {config.leftPanel?.showAddButton && ( - <> -
- - updateConfig("leftPanel.addButtonLabel", e.target.value)} - placeholder="추가" - className="h-9 text-sm" - /> -
-
- - updateConfig("leftPanel.addModalScreenId", value)} - placeholder="모달 화면 선택" - open={leftModalOpen} - onOpenChange={setLeftModalOpen} - /> -
- - )}
@@ -981,6 +1155,23 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {/* 표시 모드 설정 */} +
+ + +
+ {/* 추가 조인 테이블 설정 */}
@@ -1041,572 +1232,157 @@ export const SplitPanelLayout2ConfigPanel: React.FC
- {/* 표시 컬럼 */} -
-
- - -
-

- 테이블을 선택한 후 해당 테이블의 컬럼을 선택하세요. -

-
- {(config.rightPanel?.displayColumns || []).map((col, index) => { - // 선택 가능한 테이블 목록: 메인 테이블 + 조인 테이블들 - const availableTables = [ - config.rightPanel?.tableName, - ...(config.rightPanel?.joinTables || []).map((jt) => jt.joinTable), - ].filter(Boolean) as string[]; - - // 선택된 테이블의 컬럼만 필터링 - const selectedSourceTable = col.sourceTable || config.rightPanel?.tableName; - const filteredColumns = rightColumns.filter((c) => { - // 조인 테이블 컬럼인지 확인 (column_name이 "테이블명.컬럼명" 형태) - const isJoinColumn = c.column_name.includes("."); - - if (selectedSourceTable === config.rightPanel?.tableName) { - // 메인 테이블 선택 시: 조인 컬럼 아닌 것만 - return !isJoinColumn; - } else { - // 조인 테이블 선택 시: 해당 테이블 컬럼만 (테이블명.컬럼명 형태) - return c.column_name.startsWith(`${selectedSourceTable}.`); - } - }); - - // 테이블 라벨 가져오기 - const getTableLabel = (tableName: string) => { - const table = tables.find((t) => t.table_name === tableName); - return table?.table_comment || tableName; - }; - - return ( -
-
- 컬럼 {index + 1} - -
- - {/* 테이블 선택 */} -
- - -
- - {/* 컬럼 선택 */} -
- - -
- - {/* 표시 라벨 */} -
- - updateDisplayColumn("right", index, "label", e.target.value)} - placeholder="라벨명 (미입력 시 컬럼명 사용)" - className="h-8 text-xs" - /> -
- - {/* 표시 위치 */} -
- - -
-
- ); - })} - {(config.rightPanel?.displayColumns || []).length === 0 && ( -
- 표시할 컬럼을 추가하세요 -
- )} -
-
- -
- - updateConfig("rightPanel.showSearch", checked)} - /> -
- - {config.rightPanel?.showSearch && ( -
-
- - -
-

표시할 컬럼 중 검색에 사용할 컬럼을 선택하세요.

-
- {(config.rightPanel?.searchColumns || []).map((searchCol, index) => { - // 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시 - const displayColumns = config.rightPanel?.displayColumns || []; - - // 유효한 컬럼만 필터링 (name이 있는 것만) - const validDisplayColumns = displayColumns.filter((dc) => dc.name && dc.name.trim() !== ""); - - // 현재 선택된 컬럼의 표시 정보 - const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName); - const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName); - const selectedLabel = - selectedDisplayCol?.label || - selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") || - searchCol.columnName; - const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || ""; - const selectedTableLabel = - tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName; - - return ( -
- - -
- ); - })} - {(config.rightPanel?.displayColumns || []).length === 0 && ( -
- 먼저 표시할 컬럼을 추가하세요 -
- )} - {(config.rightPanel?.displayColumns || []).length > 0 && - (config.rightPanel?.searchColumns || []).length === 0 && ( -
- 검색할 컬럼을 추가하세요 -
- )} -
-
- )} - -
- - updateConfig("rightPanel.showAddButton", checked)} - /> -
- - {config.rightPanel?.showAddButton && ( - <> -
- - updateConfig("rightPanel.addButtonLabel", e.target.value)} - placeholder="추가" - className="h-9 text-sm" - /> -
-
- - updateConfig("rightPanel.addModalScreenId", value)} - placeholder="모달 화면 선택" - open={rightModalOpen} - onOpenChange={setRightModalOpen} - /> -
- - )} - - {/* 표시 모드 설정 */} + {/* 컬럼 설정 버튼 */}
- - -

- 카드형: 카드 형태로 정보 표시, 테이블형: 표 형태로 정보 표시 -

-
- - {/* 카드 모드 전용 옵션 */} - {(config.rightPanel?.displayMode || "card") === "card" && (
- -

라벨: 값 형식으로 표시

+ +

+ 표시 컬럼 {(config.rightPanel?.displayColumns || []).length}개 + {config.rightPanel?.showSearch && " | 검색 사용"} +

- updateConfig("rightPanel.showLabels", checked)} - /> -
- )} - - {/* 체크박스 표시 */} -
-
- -

항목 선택 기능 활성화

-
- updateConfig("rightPanel.showCheckbox", checked)} - /> -
- - {/* 수정/삭제 버튼 */} -
- -
-
- - updateConfig("rightPanel.showEditButton", checked)} - /> -
-
- - updateConfig("rightPanel.showDeleteButton", checked)} - /> -
-
-
- - {/* 수정 모달 화면 (수정 버튼 활성화 시) */} - {config.rightPanel?.showEditButton && ( -
- - updateConfig("rightPanel.editModalScreenId", value)} - placeholder="수정 모달 화면 선택 (미선택 시 추가 모달 사용)" - open={false} - onOpenChange={() => {}} - /> -

미선택 시 추가 모달 화면을 수정용으로 사용

-
- )} - - {/* 기본키 컬럼 */} -
- - updateConfig("rightPanel.primaryKeyColumn", value)} - placeholder="기본키 컬럼 선택 (기본: id)" - /> -

- 수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용) -

-
- - {/* 복수 액션 버튼 설정 */} -
-
-
-

- 복수의 버튼을 추가하면 기존 단일 추가 버튼 대신 사용됩니다 -

-
- {(config.rightPanel?.actionButtons || []).map((btn, index) => ( -
-
- 버튼 {index + 1} - -
-
- - { - const current = [...(config.rightPanel?.actionButtons || [])]; - current[index] = { ...current[index], label: e.target.value }; - updateConfig("rightPanel.actionButtons", current); - }} - placeholder="버튼 라벨" - className="h-8 text-xs" - /> -
-
- - -
-
- - -
-
- - -
- {btn.action === "add" && ( -
- - { - const current = [...(config.rightPanel?.actionButtons || [])]; - current[index] = { ...current[index], modalScreenId: value }; - updateConfig("rightPanel.actionButtons", current); - }} - placeholder="모달 화면 선택" - open={false} - onOpenChange={() => {}} - /> -
- )} -
- ))} - {(config.rightPanel?.actionButtons || []).length === 0 && ( -
- 액션 버튼을 추가하세요 (선택사항) -
- )} +
+ + {/* 액션 버튼 설정 */} +
+
+
+ +

+ {(config.rightPanel?.actionButtons || []).length > 0 + ? `${(config.rightPanel?.actionButtons || []).length}개 버튼` + : config.rightPanel?.showAddButton + ? "기본 추가 버튼" + : "없음"} +

+
+
+ + {/* 탭 설정 (우측) */} +
+
+ + { + if (checked) { + updateConfig("rightPanel.tabConfig", { + enabled: true, + mode: "manual", + showCount: true, + }); + } else { + updateConfig("rightPanel.tabConfig.enabled", false); + } + }} + /> +
+ {config.rightPanel?.tabConfig?.enabled && ( +
+
+ + updateConfig("rightPanel.tabConfig.tabSourceColumn", value)} + placeholder="컬럼 선택..." + disabled={!config.rightPanel?.tableName} + /> +

+ 선택한 컬럼의 고유값으로 탭이 생성됩니다 +

+
+
+ + updateConfig("rightPanel.tabConfig.showCount", checked)} + /> +
+
+ )} +
+ + {/* 기타 옵션들 - 접힌 상태로 표시 */} +
+ 추가 옵션 +
+ {/* 카드 모드 전용 옵션 */} + {(config.rightPanel?.displayMode || "card") === "card" && ( +
+
+ +

라벨: 값 형식으로 표시

+
+ updateConfig("rightPanel.showLabels", checked)} + /> +
+ )} + + {/* 체크박스 표시 */} +
+
+ +

항목 선택 기능 활성화

+
+ updateConfig("rightPanel.showCheckbox", checked)} + /> +
+ + {/* 수정/삭제 버튼 */} +
+ +
+ + updateConfig("rightPanel.showEditButton", checked)} + /> +
+
+ + updateConfig("rightPanel.showDeleteButton", checked)} + /> +
+
+ + {/* 기본키 컬럼 */} +
+ + updateConfig("rightPanel.primaryKeyColumn", value)} + placeholder="기본키 컬럼 선택 (기본: id)" + /> +

+ 수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용) +

+
+
+
@@ -1723,64 +1499,180 @@ export const SplitPanelLayout2ConfigPanel: React.FC {/* 데이터 전달 설정 */} -
-
-

데이터 전달 설정

- -
+
+

데이터 전달 설정

- {/* 설명 */} -
-

우측 패널 추가 버튼 클릭 시 모달로 데이터 전달

-

좌측에서 선택한 항목의 값을 모달 폼에 자동으로 채워줍니다.

-

예: dept_code를 모달의 dept_code 필드에 자동 입력

-
+

+ 버튼 클릭 시 모달에 전달할 데이터를 설정합니다. +

-
- {(config.dataTransferFields || []).map((field, index) => ( -
-
- 필드 {index + 1} - -
-
- - updateDataTransferField(index, "sourceColumn", value)} - placeholder="소스 컬럼" - /> -
-
- - updateDataTransferField(index, "targetColumn", e.target.value)} - placeholder="모달에서 사용할 필드명" - className="h-9 text-sm" - /> -
+ {/* 좌측 패널 버튼 */} + {(config.leftPanel?.actionButtons || []).length > 0 && ( +
+ +
+ {(config.leftPanel?.actionButtons || []).map((btn) => { + // 이 버튼에 대한 데이터 전달 설정 찾기 + const transferConfig = (config.buttonDataTransfers || []).find( + (t) => t.targetPanel === "left" && t.targetButtonId === btn.id + ); + const transferIndex = transferConfig + ? (config.buttonDataTransfers || []).findIndex((t) => t.id === transferConfig.id) + : -1; + + return ( +
+
+
+ {btn.label} + + ({btn.action || "add"}) + +
+ {transferConfig && transferConfig.fields.length > 0 && ( +
+ {transferConfig.fields.length}개 필드: {transferConfig.fields.map((f) => f.sourceColumn).join(", ")} +
+ )} +
+ +
+ ); + })}
- ))} - {(config.dataTransferFields || []).length === 0 && ( +
+ )} + + {/* 우측 패널 버튼 */} + {(config.rightPanel?.actionButtons || []).length > 0 && ( +
+ +
+ {(config.rightPanel?.actionButtons || []).map((btn) => { + // 이 버튼에 대한 데이터 전달 설정 찾기 + const transferConfig = (config.buttonDataTransfers || []).find( + (t) => t.targetPanel === "right" && t.targetButtonId === btn.id + ); + const transferIndex = transferConfig + ? (config.buttonDataTransfers || []).findIndex((t) => t.id === transferConfig.id) + : -1; + + return ( +
+
+
+ {btn.label} + + ({btn.action || "add"}) + +
+ {transferConfig && transferConfig.fields.length > 0 && ( +
+ {transferConfig.fields.length}개 필드: {transferConfig.fields.map((f) => f.sourceColumn).join(", ")} +
+ )} +
+ +
+ ); + })} +
+
+ )} + + {/* 버튼이 없을 때 */} + {(config.leftPanel?.actionButtons || []).length === 0 && + (config.rightPanel?.actionButtons || []).length === 0 && (
- 전달할 필드를 추가하세요 + 설정된 버튼이 없습니다. 먼저 좌측/우측 패널에서 액션 버튼을 추가하세요.
)} -
+ {/* 데이터 전달 상세 설정 모달 */} + { + if (editingTransferIndex !== null) { + const current = [...(config.buttonDataTransfers || [])]; + current[editingTransferIndex] = { ...current[editingTransferIndex], fields }; + updateConfig("buttonDataTransfers", current); + } + setDataTransferModalOpen(false); + setEditingTransferIndex(null); + }} + /> + {/* 레이아웃 설정 */}

레이아웃 설정

@@ -1815,8 +1707,294 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ + {/* 컬럼 세부설정 모달 */} + + + + 컬럼 세부설정 + + {editingColumnIndex !== null && + config.leftPanel?.displayColumns?.[editingColumnIndex] && + `${config.leftPanel.displayColumns[editingColumnIndex].label || config.leftPanel.displayColumns[editingColumnIndex].name} 컬럼의 표시 방식을 설정합니다.`} + + + +
+ {/* 표시 방식 */} +
+ + +

+ 배지는 여러 값을 태그 형태로 나란히 표시합니다 +

+
+ + {/* 집계 설정 */} +
+
+
+ +

그룹핑 시 값을 집계합니다

+
+ { + setEditingColumnConfig((prev) => ({ + ...prev, + aggregate: { + enabled: checked, + function: prev.aggregate?.function || "DISTINCT", + }, + })); + }} + /> +
+ + {editingColumnConfig.aggregate?.enabled && ( +
+ + +

+ {editingColumnConfig.aggregate?.function === "DISTINCT" + ? "중복을 제거하고 고유한 값들만 배열로 표시합니다" + : "값의 개수를 숫자로 표시합니다"} +

+
+ )} +
+
+ + + + + +
+
+ + {/* 좌측 패널 컬럼 설정 모달 */} + + + {/* 좌측 패널 액션 버튼 설정 모달 */} + + + {/* 우측 패널 컬럼 설정 모달 */} + + + {/* 우측 패널 액션 버튼 설정 모달 */} +
); }; +// 데이터 전달 상세 설정 모달 컴포넌트 +interface DataTransferDetailModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + config: SplitPanelLayout2Config; + editingIndex: number | null; + leftColumns: ColumnInfo[]; + onSave: (fields: DataTransferField[]) => void; +} + +const DataTransferDetailModal: React.FC = ({ + open, + onOpenChange, + config, + editingIndex, + leftColumns, + onSave, +}) => { + const [fields, setFields] = useState([]); + + // 모달 열릴 때 현재 설정 로드 + useEffect(() => { + if (open && editingIndex !== null) { + const transfer = config.buttonDataTransfers?.[editingIndex]; + setFields(transfer?.fields || []); + } + }, [open, editingIndex, config.buttonDataTransfers]); + + const transfer = editingIndex !== null ? config.buttonDataTransfers?.[editingIndex] : null; + + // 소스 컬럼: 대상 패널에 따라 반대 패널의 컬럼 사용 + // left 패널 버튼이면 -> left 패널 데이터를 모달에 전달 (좌측 컬럼이 소스) + // right 패널 버튼이면 -> left 패널 데이터를 모달에 전달 (좌측 컬럼이 소스, 또는 right 컬럼) + const sourceColumns = transfer?.targetPanel === "right" ? leftColumns : leftColumns; + + const addField = () => { + setFields([...fields, { sourceColumn: "", targetColumn: "" }]); + }; + + const removeField = (index: number) => { + setFields(fields.filter((_, i) => i !== index)); + }; + + const updateField = (index: number, key: keyof DataTransferField, value: string) => { + const newFields = [...fields]; + newFields[index] = { ...newFields[index], [key]: value }; + setFields(newFields); + }; + + const targetButton = + transfer?.targetPanel === "left" + ? config.leftPanel?.actionButtons?.find((btn) => btn.id === transfer.targetButtonId) + : config.rightPanel?.actionButtons?.find((btn) => btn.id === transfer?.targetButtonId); + + return ( + + + + 데이터 전달 상세 설정 + + {transfer?.targetPanel === "left" ? "좌측" : "우측"} 패널의 "{targetButton?.label || "버튼"}" 클릭 시 모달에 + 전달할 데이터를 설정합니다. + + + + +
+
+ + +
+ +
+

소스 컬럼: 선택된 항목에서 가져올 컬럼

+

타겟 컬럼: 모달 폼에 전달할 필드명

+
+ +
+ {fields.map((field, index) => ( +
+
+ +
+ -> +
+ updateField(index, "targetColumn", e.target.value)} + placeholder="타겟 컬럼" + className="h-8 text-xs" + /> +
+ +
+ ))} + + {fields.length === 0 && ( +
+ 전달할 필드가 없습니다. 추가 버튼을 클릭하세요. +
+ )} +
+
+
+ + + + + +
+
+ ); +}; + export default SplitPanelLayout2ConfigPanel; diff --git a/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx b/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx new file mode 100644 index 00000000..24602860 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx @@ -0,0 +1,163 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Check, ChevronsUpDown, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; + +export interface ColumnInfo { + column_name: string; + data_type: string; + column_comment?: string; +} + +interface SearchableColumnSelectProps { + tableName: string; + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + excludeColumns?: string[]; // 이미 선택된 컬럼 제외 + className?: string; +} + +export const SearchableColumnSelect: React.FC = ({ + tableName, + value, + onValueChange, + placeholder = "컬럼 선택", + disabled = false, + excludeColumns = [], + className, +}) => { + const [open, setOpen] = useState(false); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(false); + + // 컬럼 목록 로드 + const loadColumns = useCallback(async () => { + if (!tableName) { + setColumns([]); + return; + } + + setLoading(true); + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=200`); + + let columnList: any[] = []; + if (response.data?.success && response.data?.data?.columns) { + columnList = response.data.data.columns; + } else if (Array.isArray(response.data?.data?.columns)) { + columnList = response.data.data.columns; + } else if (Array.isArray(response.data?.data)) { + columnList = response.data.data; + } else if (Array.isArray(response.data)) { + columnList = response.data; + } + + const transformedColumns = columnList.map((c: any) => ({ + column_name: c.columnName ?? c.column_name ?? c.name ?? "", + data_type: c.dataType ?? c.data_type ?? c.type ?? "", + column_comment: c.displayName ?? c.column_comment ?? c.label ?? "", + })); + + setColumns(transformedColumns); + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + setColumns([]); + } finally { + setLoading(false); + } + }, [tableName]); + + useEffect(() => { + loadColumns(); + }, [loadColumns]); + + // 선택된 컬럼 정보 가져오기 + const selectedColumn = columns.find((col) => col.column_name === value); + const displayValue = selectedColumn + ? selectedColumn.column_comment || selectedColumn.column_name + : value || ""; + + // 필터링된 컬럼 목록 (이미 선택된 컬럼 제외) + const filteredColumns = columns.filter( + (col) => !excludeColumns.includes(col.column_name) || col.column_name === value + ); + + return ( + + + + + + + + + + {filteredColumns.length === 0 ? "선택 가능한 컬럼이 없습니다" : "검색 결과가 없습니다"} + + + {filteredColumns.map((col) => ( + { + onValueChange(col.column_name); + setOpen(false); + }} + > + +
+ + {col.column_comment || col.column_name} + + + {col.column_name} ({col.data_type}) + +
+
+ ))} +
+
+
+
+
+ ); +}; + +export default SearchableColumnSelect; + diff --git a/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx b/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx new file mode 100644 index 00000000..8e3cef91 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx @@ -0,0 +1,118 @@ +"use client"; + +import React from "react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical, Settings, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { ColumnConfig } from "../types"; + +interface SortableColumnItemProps { + id: string; + column: ColumnConfig; + index: number; + onSettingsClick: () => void; + onRemove: () => void; + showGroupingSettings?: boolean; +} + +export const SortableColumnItem: React.FC = ({ + id, + column, + index, + onSettingsClick, + onRemove, + showGroupingSettings = false, +}) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {/* 드래그 핸들 */} +
+ +
+ + {/* 컬럼 정보 */} +
+
+ + {column.label || column.name || `컬럼 ${index + 1}`} + + {column.name && column.label && ( + + ({column.name}) + + )} +
+ + {/* 설정 요약 뱃지 */} +
+ {column.displayRow && ( + + {column.displayRow === "name" ? "이름행" : "정보행"} + + )} + {showGroupingSettings && column.displayConfig?.displayType === "badge" && ( + + 배지 + + )} + {showGroupingSettings && column.displayConfig?.aggregate?.enabled && ( + + {column.displayConfig.aggregate.function === "DISTINCT" ? "중복제거" : "개수"} + + )} + {column.sourceTable && ( + + {column.sourceTable} + + )} +
+
+ + {/* 액션 버튼들 */} +
+ + +
+
+ ); +}; + +export default SortableColumnItem; + diff --git a/frontend/lib/registry/components/split-panel-layout2/index.ts b/frontend/lib/registry/components/split-panel-layout2/index.ts index 64a88b11..8810a518 100644 --- a/frontend/lib/registry/components/split-panel-layout2/index.ts +++ b/frontend/lib/registry/components/split-panel-layout2/index.ts @@ -37,5 +37,13 @@ export type { JoinConfig, DataTransferField, ColumnConfig, + ActionButtonConfig, + ValueSourceConfig, + EntityReferenceConfig, + ModalParamMapping, } from "./types"; +// 모달 컴포넌트 내보내기 (별도 사용 필요시) +export { ColumnConfigModal } from "./ColumnConfigModal"; +export { ActionButtonConfigModal } from "./ActionButtonConfigModal"; + diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index f5e5290c..00c468e1 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -3,6 +3,65 @@ * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) */ +// ============================================================================= +// 값 소스 및 연동 설정 +// ============================================================================= + +/** + * 값 소스 설정 (화면 내 필드/폼에서 값 가져오기) + */ +export interface ValueSourceConfig { + type: "none" | "field" | "dataForm" | "component"; // 소스 유형 + fieldId?: string; // 필드 컴포넌트 ID + formId?: string; // 데이터폼 ID + formFieldName?: string; // 데이터폼 내 필드명 + componentId?: string; // 다른 컴포넌트 ID + componentColumn?: string; // 컴포넌트에서 참조할 컬럼 +} + +/** + * 엔티티 참조 설정 (엔티티에서 표시할 값 선택) + */ +export interface EntityReferenceConfig { + entityId?: string; // 연결된 엔티티 ID + displayColumns?: string[]; // 표시할 엔티티 컬럼들 (체크박스 선택) + primaryDisplayColumn?: string; // 주 표시 컬럼 +} + +// ============================================================================= +// 컬럼 표시 설정 +// ============================================================================= + +/** + * 컬럼별 표시 설정 (그룹핑 시 사용) + */ +export interface ColumnDisplayConfig { + displayType: "text" | "badge"; // 표시 방식 (텍스트 또는 배지) + aggregate?: { + enabled: boolean; // 집계 사용 여부 + function: "DISTINCT" | "COUNT"; // 집계 방식 (중복제거 또는 개수) + }; +} + +/** + * 그룹핑 설정 (왼쪽 패널용) + */ +export interface GroupingConfig { + enabled: boolean; // 그룹핑 사용 여부 + groupByColumn: string; // 그룹 기준 컬럼 (예: item_id) +} + +/** + * 탭 설정 + */ +export interface TabConfig { + enabled: boolean; // 탭 사용 여부 + mode?: "auto" | "manual"; // 하위 호환성용 (실제로는 manual만 사용) + tabSourceColumn?: string; // 탭 생성 기준 컬럼 + showCount?: boolean; // 탭에 항목 개수 표시 여부 + defaultTab?: string; // 기본 선택 탭 (값 또는 ID) +} + /** * 컬럼 설정 */ @@ -13,6 +72,9 @@ export interface ColumnConfig { displayRow?: "name" | "info"; // 표시 위치 (name: 이름 행, info: 정보 행) width?: number; // 너비 (px) bold?: boolean; // 굵게 표시 + displayConfig?: ColumnDisplayConfig; // 컬럼별 표시 설정 (그룹핑 시) + entityReference?: EntityReferenceConfig; // 엔티티 참조 설정 + valueSource?: ValueSourceConfig; // 값 소스 설정 (화면 내 연동) format?: { type?: "text" | "number" | "currency" | "date"; thousandSeparator?: boolean; @@ -23,27 +85,58 @@ export interface ColumnConfig { }; } +/** + * 모달 파라미터 매핑 설정 + */ +export interface ModalParamMapping { + sourceColumn: string; // 선택된 항목에서 가져올 컬럼 + targetParam: string; // 모달에 전달할 파라미터명 +} + /** * 액션 버튼 설정 */ export interface ActionButtonConfig { id: string; // 고유 ID label: string; // 버튼 라벨 - variant?: "default" | "outline" | "destructive" | "ghost"; // 버튼 스타일 + variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; // 버튼 스타일 icon?: string; // lucide 아이콘명 (예: "Plus", "Edit", "Trash2") + showCondition?: "always" | "selected" | "notSelected"; // 표시 조건 + action?: "add" | "edit" | "delete" | "bulk-delete" | "api" | "custom"; // 버튼 동작 유형 + + // 모달 관련 modalScreenId?: number; // 연결할 모달 화면 ID - action?: "add" | "edit" | "delete" | "bulk-delete" | "custom"; // 버튼 동작 유형 + modalParams?: ModalParamMapping[]; // 모달에 전달할 파라미터 매핑 + + // API 호출 관련 + apiEndpoint?: string; // API 엔드포인트 + apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; // HTTP 메서드 + confirmMessage?: string; // 확인 메시지 (삭제 등) + + // 커스텀 액션 + customActionId?: string; // 커스텀 액션 식별자 } /** * 데이터 전달 필드 설정 */ export interface DataTransferField { - sourceColumn: string; // 좌측 패널의 컬럼명 + sourceColumn: string; // 소스 패널의 컬럼명 targetColumn: string; // 모달로 전달할 컬럼명 label?: string; // 표시용 라벨 } +/** + * 버튼별 데이터 전달 설정 + * 특정 패널의 특정 버튼에 어떤 데이터를 전달할지 설정 + */ +export interface ButtonDataTransferConfig { + id: string; // 고유 ID + targetPanel: "left" | "right"; // 대상 패널 + targetButtonId: string; // 대상 버튼 ID + fields: DataTransferField[]; // 전달할 필드 목록 +} + /** * 검색 컬럼 설정 */ @@ -62,15 +155,24 @@ export interface LeftPanelConfig { searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성) searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수) showSearch?: boolean; // 검색 표시 여부 - showAddButton?: boolean; // 추가 버튼 표시 - addButtonLabel?: string; // 추가 버튼 라벨 - addModalScreenId?: number; // 추가 모달 화면 ID + showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성) + addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성) + addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성) + actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열 + displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형) + primaryKeyColumn?: string; // 기본키 컬럼명 (선택용, 기본: id) // 계층 구조 설정 hierarchyConfig?: { enabled: boolean; parentColumn: string; // 부모 참조 컬럼 (예: parent_dept_code) idColumn: string; // ID 컬럼 (예: dept_code) }; + // 그룹핑 설정 + grouping?: GroupingConfig; + // 탭 설정 + tabConfig?: TabConfig; + // 추가 조인 테이블 설정 (다른 테이블 참조하여 컬럼 추가 표시) + joinTables?: JoinTableConfig[]; } /** @@ -106,6 +208,8 @@ export interface RightPanelConfig { * - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시 */ joinTables?: JoinTableConfig[]; + // 탭 설정 + tabConfig?: TabConfig; } /** @@ -157,9 +261,12 @@ export interface SplitPanelLayout2Config { // 조인 설정 joinConfig: JoinConfig; - // 데이터 전달 설정 (모달로 전달할 필드) + // 데이터 전달 설정 (하위 호환성 - 기본 설정) dataTransferFields?: DataTransferField[]; + // 버튼별 데이터 전달 설정 (신규) + buttonDataTransfers?: ButtonDataTransferConfig[]; + // 레이아웃 설정 splitRatio?: number; // 좌우 비율 (0-100, 기본 30) resizable?: boolean; // 크기 조절 가능 여부 diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index b2d5a9da..61ad2016 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -865,6 +865,8 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents numberingRules={numberingRules} onLoadTableColumns={loadTableColumns} availableParentFields={availableParentFields} + targetTableName={config.saveConfig?.tableName} + targetTableColumns={config.saveConfig?.tableName ? tableColumns[config.saveConfig.tableName] || [] : []} /> )} diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index c8584a5f..f33f5405 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -10,7 +10,16 @@ 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, Settings as SettingsIcon } from "lucide-react"; +import { Plus, Trash2, Settings as SettingsIcon, Check, ChevronsUpDown } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { cn } from "@/lib/utils"; import { FormFieldConfig, @@ -58,6 +67,9 @@ interface FieldDetailSettingsModalProps { onLoadTableColumns: (tableName: string) => void; // 부모 화면에서 전달 가능한 필드 목록 (선택사항) availableParentFields?: AvailableParentField[]; + // 저장 테이블 정보 (타겟 컬럼 선택용) + targetTableName?: string; + targetTableColumns?: { name: string; type: string; label: string }[]; } export function FieldDetailSettingsModal({ @@ -70,13 +82,22 @@ export function FieldDetailSettingsModal({ numberingRules, onLoadTableColumns, availableParentFields = [], + // targetTableName은 타겟 컬럼 선택 시 참고용으로 전달됨 (현재 targetTableColumns만 사용) + targetTableName: _targetTableName, + targetTableColumns = [], }: FieldDetailSettingsModalProps) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + void _targetTableName; // 향후 사용 가능성을 위해 유지 // 로컬 상태로 필드 설정 관리 const [localField, setLocalField] = useState(field); // 전체 카테고리 컬럼 목록 상태 const [categoryColumns, setCategoryColumns] = useState([]); const [loadingCategoryColumns, setLoadingCategoryColumns] = useState(false); + + // Combobox 열림 상태 + const [sourceTableOpen, setSourceTableOpen] = useState(false); + const [targetColumnOpenMap, setTargetColumnOpenMap] = useState>({}); // open이 변경될 때마다 필드 데이터 동기화 useEffect(() => { @@ -649,29 +670,68 @@ export function FieldDetailSettingsModal({
- + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateField({ + linkedFieldGroup: { + ...localField.linkedFieldGroup, + sourceTable: t.name, + }, + }); + onLoadTableColumns(t.name); + setSourceTableOpen(false); + }} + className="text-xs" + > + + {t.label || t.name} + ({t.name}) + + ))} + + + + + 값을 가져올 소스 테이블 (예: customer_mng)
@@ -820,14 +880,78 @@ export function FieldDetailSettingsModal({
- - updateLinkedFieldMapping(index, { targetColumn: e.target.value }) - } - placeholder="partner_id" - className="h-6 text-[9px] mt-0.5" - /> + {targetTableColumns.length > 0 ? ( + + setTargetColumnOpenMap((prev) => ({ ...prev, [index]: open })) + } + > + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {targetTableColumns.map((col) => ( + { + updateLinkedFieldMapping(index, { targetColumn: col.name }); + setTargetColumnOpenMap((prev) => ({ ...prev, [index]: false })); + }} + className="text-[9px]" + > + + {col.name} + ({col.label}) + + ))} + + + + + + ) : ( + + updateLinkedFieldMapping(index, { targetColumn: e.target.value }) + } + placeholder="partner_id" + className="h-6 text-[9px] mt-0.5" + /> + )}
))} @@ -966,3 +1090,4 @@ export function FieldDetailSettingsModal({ + From 9878f1f502bb3ce467f2525750cd5b9ef07a09a9 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 24 Dec 2025 09:24:56 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix(select):=20Radix=20UI=20Select=20v2.x?= =?UTF-8?q?=20value=3D""=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radix UI Select v2.0부터 빈 문자열 value=""가 금지됨 (placeholder 예약어) 수정 파일: - FieldDetailSettingsModal.tsx: saveColumn "__default__" - TableLogViewer.tsx: 전체 필터 "__all__" - FlowStepPanel.tsx: disabled placeholder "__placeholder__" - MapConfigPanel.tsx: 선택 안 함 "__none__" (2곳) - DataMappingSettings.tsx: disabled placeholder "__placeholder__" (2곳) - ScreenAssignmentTab.tsx: disabled placeholder "__placeholder__" - multilang/page.tsx: 전체 메뉴/타입 "__all__" (2곳) --- frontend/app/(main)/multilang/page.tsx | 16 ++++++++++++---- .../components/admin/ScreenAssignmentTab.tsx | 3 ++- frontend/components/admin/TableLogViewer.tsx | 8 ++++++-- .../external-call/DataMappingSettings.tsx | 5 +++-- frontend/components/flow/FlowStepPanel.tsx | 3 ++- .../registry/components/map/MapConfigPanel.tsx | 14 ++++++++------ .../modals/FieldDetailSettingsModal.tsx | 6 +++--- 7 files changed, 36 insertions(+), 19 deletions(-) diff --git a/frontend/app/(main)/multilang/page.tsx b/frontend/app/(main)/multilang/page.tsx index 8c54d26d..34a51ec0 100644 --- a/frontend/app/(main)/multilang/page.tsx +++ b/frontend/app/(main)/multilang/page.tsx @@ -317,12 +317,16 @@ export default function MultiLangPage() {
- setSelectedMenu(value === "__all__" ? "" : value)} + > - 전체 메뉴 + 전체 메뉴 {menus.map((menu) => ( {menu.name} @@ -334,12 +338,16 @@ export default function MultiLangPage() {
- setSelectedKeyType(value === "__all__" ? "" : value)} + > - 전체 타입 + 전체 타입 {keyTypes.map((type) => ( {type.name} diff --git a/frontend/components/admin/ScreenAssignmentTab.tsx b/frontend/components/admin/ScreenAssignmentTab.tsx index e6554908..8513e410 100644 --- a/frontend/components/admin/ScreenAssignmentTab.tsx +++ b/frontend/components/admin/ScreenAssignmentTab.tsx @@ -172,8 +172,9 @@ export const ScreenAssignmentTab: React.FC = ({ menus // }); if (!menuList || menuList.length === 0) { + // Radix UI Select v2.x: 빈 문자열 value="" 금지 → "__placeholder__" 사용 return [ - + 메뉴가 없습니다 , ]; diff --git a/frontend/components/admin/TableLogViewer.tsx b/frontend/components/admin/TableLogViewer.tsx index 147229df..9f0541b6 100644 --- a/frontend/components/admin/TableLogViewer.tsx +++ b/frontend/components/admin/TableLogViewer.tsx @@ -151,12 +151,16 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
- setOperationType(value === "__all__" ? "" : value)} + > - 전체 + 전체 추가 수정 삭제 diff --git a/frontend/components/dataflow/external-call/DataMappingSettings.tsx b/frontend/components/dataflow/external-call/DataMappingSettings.tsx index a4e1ea56..01103744 100644 --- a/frontend/components/dataflow/external-call/DataMappingSettings.tsx +++ b/frontend/components/dataflow/external-call/DataMappingSettings.tsx @@ -236,12 +236,13 @@ export const DataMappingSettings: React.FC = ({ + {/* Radix UI Select v2.x: 빈 문자열 value="" 금지 → "__placeholder__" 사용 */} {tablesLoading ? ( - + 테이블 목록 로딩 중... ) : availableTables.length === 0 ? ( - + 사용 가능한 테이블이 없습니다 ) : ( diff --git a/frontend/components/flow/FlowStepPanel.tsx b/frontend/components/flow/FlowStepPanel.tsx index 855596cb..d861f97b 100644 --- a/frontend/components/flow/FlowStepPanel.tsx +++ b/frontend/components/flow/FlowStepPanel.tsx @@ -1173,7 +1173,8 @@ export function FlowStepPanel({ 기본 REST API 연결 ) : ( - + // Radix UI Select v2.x: 빈 문자열 value="" 금지 → "__placeholder__" 사용 + 연결된 REST API가 없습니다 )} diff --git a/frontend/lib/registry/components/map/MapConfigPanel.tsx b/frontend/lib/registry/components/map/MapConfigPanel.tsx index 62489274..3f591efc 100644 --- a/frontend/lib/registry/components/map/MapConfigPanel.tsx +++ b/frontend/lib/registry/components/map/MapConfigPanel.tsx @@ -315,16 +315,17 @@ export default function MapConfigPanel({ config, onChange }: MapConfigPanelProps {/* 라벨 컬럼 (선택) */}
+ {/* Radix UI Select v2.x: 빈 문자열 value="" 금지 → "__none__" 사용 */} updateConfig("dataSource.statusColumn", value)} + value={config.dataSource?.statusColumn || "__none__"} + onValueChange={(value) => updateConfig("dataSource.statusColumn", value === "__none__" ? "" : value)} disabled={isLoadingColumns || !config.dataSource?.tableName} > - 선택 안 함 + 선택 안 함 {columns.map((col) => ( {col.column_name} ({col.data_type}) diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index f33f5405..2404cc4c 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -550,12 +550,12 @@ export function FieldDetailSettingsModal({ {selectTableColumns.length > 0 ? ( updateConfig("rightPanel.mainTableForEdit.tableName", e.target.value)} + placeholder="예: user_info" + className="h-7 text-xs mt-1" + /> +
+
+
+ + updateConfig("rightPanel.mainTableForEdit.linkColumn.mainColumn", e.target.value)} + placeholder="예: user_id" + className="h-7 text-xs mt-1" + /> +
+
+ + updateConfig("rightPanel.mainTableForEdit.linkColumn.subColumn", e.target.value)} + placeholder="예: user_id" + className="h-7 text-xs mt-1" + /> +
+
+
+ )} +
+ )}
diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index fbe8c912..ae8c71ed 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -211,6 +211,20 @@ export interface RightPanelConfig { * - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시 */ joinTables?: JoinTableConfig[]; + + /** + * 수정 시 메인 테이블 데이터 조회 설정 + * 우측 패널이 서브 테이블(예: user_dept)이고, 수정 모달이 메인 테이블(예: user_info) 기준일 때 + * 수정 버튼 클릭 시 메인 테이블 데이터를 함께 조회하여 모달에 전달합니다. + */ + mainTableForEdit?: { + tableName: string; // 메인 테이블명 (예: user_info) + linkColumn: { + mainColumn: string; // 메인 테이블의 연결 컬럼 (예: user_id) + subColumn: string; // 서브 테이블의 연결 컬럼 (예: user_id) + }; + }; + // 탭 설정 tabConfig?: TabConfig; } diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 047849b6..64418541 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -2,20 +2,23 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { Plus, Columns, AlignJustify } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Plus, Columns, AlignJustify, Trash2, Search } 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 { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types"; // 타입 정의 import { TableSectionConfig, TableColumnConfig, - ValueMappingConfig, TableJoinCondition, FormDataState, } from "./types"; @@ -26,9 +29,16 @@ interface TableSectionRendererProps { formData: FormDataState; onFormDataChange: (field: string, value: any) => void; onTableDataChange: (data: any[]) => void; + // 조건부 테이블용 콜백 (조건별 데이터 변경) + onConditionalTableDataChange?: (conditionValue: string, data: any[]) => void; className?: string; } +// 조건부 테이블 데이터 타입 +interface ConditionalTableData { + [conditionValue: string]: any[]; +} + /** * TableColumnConfig를 RepeaterColumnConfig로 변환 * columnModes 또는 lookup이 있으면 dynamicDataSource로 변환 @@ -319,16 +329,30 @@ export function TableSectionRenderer({ formData, onFormDataChange, onTableDataChange, + onConditionalTableDataChange, className, }: TableSectionRendererProps) { - // 테이블 데이터 상태 + // 테이블 데이터 상태 (일반 모드) const [tableData, setTableData] = useState([]); + // 조건부 테이블 데이터 상태 (조건별로 분리) + const [conditionalTableData, setConditionalTableData] = useState({}); + + // 조건부 테이블: 선택된 조건들 (체크박스 모드) + const [selectedConditions, setSelectedConditions] = useState([]); + + // 조건부 테이블: 현재 활성 탭 + const [activeConditionTab, setActiveConditionTab] = useState(""); + + // 조건부 테이블: 현재 모달이 열린 조건 (어떤 조건의 테이블에 추가할지) + const [modalCondition, setModalCondition] = useState(""); + // 모달 상태 const [modalOpen, setModalOpen] = useState(false); - // 체크박스 선택 상태 + // 체크박스 선택 상태 (조건별로 분리) const [selectedRows, setSelectedRows] = useState>(new Set()); + const [conditionalSelectedRows, setConditionalSelectedRows] = useState>>({}); // 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배) const [widthTrigger, setWidthTrigger] = useState(0); @@ -341,6 +365,257 @@ export function TableSectionRenderer({ // 초기 데이터 로드 완료 플래그 (무한 루프 방지) const initialDataLoadedRef = React.useRef(false); + + // 조건부 테이블 설정 + const conditionalConfig = tableConfig.conditionalTable; + const isConditionalMode = conditionalConfig?.enabled ?? false; + + // 조건부 테이블: 동적 옵션 로드 상태 + const [dynamicOptions, setDynamicOptions] = useState<{ id: string; value: string; label: string }[]>([]); + const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false); + const dynamicOptionsLoadedRef = React.useRef(false); + + // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우) + useEffect(() => { + if (!isConditionalMode) return; + if (!conditionalConfig?.optionSource?.enabled) return; + if (dynamicOptionsLoadedRef.current) return; + + const { tableName, valueColumn, labelColumn, filterCondition } = conditionalConfig.optionSource; + + if (!tableName || !valueColumn) return; + + const loadDynamicOptions = async () => { + setDynamicOptionsLoading(true); + try { + // DISTINCT 값을 가져오기 위한 API 호출 + const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { + search: filterCondition ? { _raw: filterCondition } : {}, + size: 1000, + page: 1, + } + ); + + if (response.data.success && response.data.data?.data) { + const rows = response.data.data.data; + + // 중복 제거하여 고유 값 추출 + const uniqueValues = new Map(); + for (const row of rows) { + const value = row[valueColumn]; + if (value && !uniqueValues.has(value)) { + const label = labelColumn ? (row[labelColumn] || value) : value; + uniqueValues.set(value, label); + } + } + + // 옵션 배열로 변환 + const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({ + id: `dynamic_${index}`, + value, + label, + })); + + console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", { + tableName, + valueColumn, + optionCount: options.length, + options, + }); + + setDynamicOptions(options); + dynamicOptionsLoadedRef.current = true; + } + } catch (error) { + console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error); + } finally { + setDynamicOptionsLoading(false); + } + }; + + loadDynamicOptions(); + }, [isConditionalMode, conditionalConfig?.optionSource]); + + // ============================================ + // 동적 Select 옵션 (소스 테이블에서 드롭다운 옵션 로드) + // ============================================ + + // 소스 테이블 데이터 캐시 (동적 Select 옵션용) + const [sourceDataCache, setSourceDataCache] = useState([]); + const sourceDataLoadedRef = React.useRef(false); + + // 동적 Select 옵션이 있는 컬럼 확인 + const hasDynamicSelectColumns = useMemo(() => { + return tableConfig.columns?.some(col => col.dynamicSelectOptions?.enabled); + }, [tableConfig.columns]); + + // 소스 테이블 데이터 로드 (동적 Select 옵션용) + useEffect(() => { + if (!hasDynamicSelectColumns) return; + if (sourceDataLoadedRef.current) return; + if (!tableConfig.source?.tableName) return; + + const loadSourceData = async () => { + try { + // 조건부 테이블 필터 조건 적용 + const filterCondition: Record = {}; + + // 소스 필터가 활성화되어 있고 조건이 선택되어 있으면 필터 적용 + if (conditionalConfig?.sourceFilter?.enabled && activeConditionTab) { + filterCondition[conditionalConfig.sourceFilter.filterColumn] = activeConditionTab; + } + + const response = await apiClient.post( + `/table-management/tables/${tableConfig.source.tableName}/data`, + { + search: filterCondition, + size: 1000, + page: 1, + } + ); + + if (response.data.success && response.data.data?.data) { + setSourceDataCache(response.data.data.data); + sourceDataLoadedRef.current = true; + console.log("[TableSectionRenderer] 소스 데이터 로드 완료:", { + tableName: tableConfig.source.tableName, + rowCount: response.data.data.data.length, + filter: filterCondition, + }); + } + } catch (error) { + console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error); + } + }; + + loadSourceData(); + }, [hasDynamicSelectColumns, tableConfig.source?.tableName, conditionalConfig?.sourceFilter, activeConditionTab]); + + // 조건 탭 변경 시 소스 데이터 다시 로드 + useEffect(() => { + if (!hasDynamicSelectColumns) return; + if (!conditionalConfig?.sourceFilter?.enabled) return; + if (!activeConditionTab) return; + + // 조건 변경 시 캐시 리셋하고 다시 로드 + sourceDataLoadedRef.current = false; + setSourceDataCache([]); + }, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled]); + + // 컬럼별 동적 Select 옵션 생성 + const dynamicSelectOptionsMap = useMemo(() => { + const optionsMap: Record = {}; + + if (!sourceDataCache.length) return optionsMap; + + for (const col of tableConfig.columns || []) { + if (!col.dynamicSelectOptions?.enabled) continue; + + const { sourceField, labelField, distinct = true } = col.dynamicSelectOptions; + + if (!sourceField) continue; + + // 소스 데이터에서 옵션 추출 + const seenValues = new Set(); + const options: { value: string; label: string }[] = []; + + for (const row of sourceDataCache) { + const value = row[sourceField]; + if (value === undefined || value === null || value === "") continue; + + const stringValue = String(value); + + if (distinct && seenValues.has(stringValue)) continue; + seenValues.add(stringValue); + + const label = labelField ? (row[labelField] || stringValue) : stringValue; + options.push({ value: stringValue, label: String(label) }); + } + + optionsMap[col.field] = options; + } + + return optionsMap; + }, [sourceDataCache, tableConfig.columns]); + + // 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움 + const handleDynamicSelectChange = useCallback( + (rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => { + const column = tableConfig.columns?.find(col => col.field === columnField); + if (!column?.dynamicSelectOptions?.rowSelectionMode?.enabled) { + // 행 선택 모드가 아니면 일반 값 변경만 + if (conditionValue && isConditionalMode) { + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData]; + newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue }; + setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + onConditionalTableDataChange?.(conditionValue, newData); + } else { + const newData = [...tableData]; + newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue }; + handleDataChange(newData); + } + return; + } + + // 행 선택 모드: 소스 데이터에서 해당 값을 가진 행 찾기 + const { sourceField } = column.dynamicSelectOptions; + const { autoFillColumns, sourceIdColumn, targetIdField } = column.dynamicSelectOptions.rowSelectionMode; + + const sourceRow = sourceDataCache.find(row => String(row[sourceField]) === selectedValue); + + if (!sourceRow) { + console.warn(`[TableSectionRenderer] 소스 행을 찾을 수 없음: ${sourceField} = ${selectedValue}`); + return; + } + + // 현재 행 데이터 가져오기 + let currentData: any[]; + if (conditionValue && isConditionalMode) { + currentData = conditionalTableData[conditionValue] || []; + } else { + currentData = tableData; + } + + const newData = [...currentData]; + const updatedRow = { ...newData[rowIndex], [columnField]: selectedValue }; + + // 자동 채움 매핑 적용 + if (autoFillColumns) { + for (const mapping of autoFillColumns) { + const sourceValue = sourceRow[mapping.sourceColumn]; + if (sourceValue !== undefined) { + updatedRow[mapping.targetField] = sourceValue; + } + } + } + + // 소스 ID 저장 + if (sourceIdColumn && targetIdField) { + updatedRow[targetIdField] = sourceRow[sourceIdColumn]; + } + + newData[rowIndex] = updatedRow; + + // 데이터 업데이트 + if (conditionValue && isConditionalMode) { + setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + onConditionalTableDataChange?.(conditionValue, newData); + } else { + handleDataChange(newData); + } + + console.log("[TableSectionRenderer] 행 선택 모드 자동 채움:", { + columnField, + selectedValue, + sourceRow, + updatedRow, + }); + }, + [tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange] + ); // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) useEffect(() => { @@ -360,8 +635,19 @@ export function TableSectionRenderer({ } }, [sectionId, formData]); - // RepeaterColumnConfig로 변환 - const columns: RepeaterColumnConfig[] = (tableConfig.columns || []).map(convertToRepeaterColumn); + // RepeaterColumnConfig로 변환 (동적 Select 옵션 반영) + const columns: RepeaterColumnConfig[] = useMemo(() => { + return (tableConfig.columns || []).map(col => { + const baseColumn = convertToRepeaterColumn(col); + + // 동적 Select 옵션이 있으면 적용 + if (col.dynamicSelectOptions?.enabled && dynamicSelectOptionsMap[col.field]) { + baseColumn.selectOptions = dynamicSelectOptionsMap[col.field]; + } + + return baseColumn; + }); + }, [tableConfig.columns, dynamicSelectOptionsMap]); // 계산 규칙 변환 const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule); @@ -444,15 +730,47 @@ export function TableSectionRenderer({ [onTableDataChange, tableConfig.columns, batchAppliedFields] ); - // 행 변경 핸들러 + // 행 변경 핸들러 (동적 Select 행 선택 모드 지원) const handleRowChange = useCallback( - (index: number, newRow: any) => { + (index: number, newRow: any, conditionValue?: string) => { + const oldRow = conditionValue && isConditionalMode + ? (conditionalTableData[conditionValue]?.[index] || {}) + : (tableData[index] || {}); + + // 변경된 필드 찾기 + const changedFields: string[] = []; + for (const key of Object.keys(newRow)) { + if (oldRow[key] !== newRow[key]) { + changedFields.push(key); + } + } + + // 동적 Select 컬럼의 행 선택 모드 확인 + for (const changedField of changedFields) { + const column = tableConfig.columns?.find(col => col.field === changedField); + if (column?.dynamicSelectOptions?.rowSelectionMode?.enabled) { + // 행 선택 모드 처리 (자동 채움) + handleDynamicSelectChange(index, changedField, newRow[changedField], conditionValue); + return; // 행 선택 모드에서 처리 완료 + } + } + + // 일반 행 변경 처리 const calculatedRow = calculateRow(newRow); - const newData = [...tableData]; - newData[index] = calculatedRow; - handleDataChange(newData); + + if (conditionValue && isConditionalMode) { + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData]; + newData[index] = calculatedRow; + setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + onConditionalTableDataChange?.(conditionValue, newData); + } else { + const newData = [...tableData]; + newData[index] = calculatedRow; + handleDataChange(newData); + } }, - [tableData, calculateRow, handleDataChange] + [tableData, conditionalTableData, isConditionalMode, tableConfig.columns, calculateRow, handleDataChange, handleDynamicSelectChange, onConditionalTableDataChange] ); // 행 삭제 핸들러 @@ -778,19 +1096,35 @@ export function TableSectionRenderer({ const sourceSearchFields = source.searchColumns; const columnLabels = source.columnLabels || {}; const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택"; - const addButtonText = uiConfig?.addButtonText || "항목 검색"; + const addButtonType = uiConfig?.addButtonType || "search"; + const addButtonText = uiConfig?.addButtonText || (addButtonType === "addRow" ? "항목 추가" : "항목 검색"); 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; + const baseFilterCondition: Record = useMemo(() => { + const condition: Record = {}; + if (filters?.preFilters) { + for (const filter of filters.preFilters) { + // 간단한 "=" 연산자만 처리 (확장 가능) + if (filter.operator === "=") { + condition[filter.column] = filter.value; + } } } - } + return condition; + }, [filters?.preFilters]); + + // 조건부 테이블용 필터 조건 생성 (선택된 조건값으로 소스 테이블 필터링) + const conditionalFilterCondition = useMemo(() => { + const filter = { ...baseFilterCondition }; + + // 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용 + if (conditionalConfig?.sourceFilter?.enabled && modalCondition) { + filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition; + } + + return filter; + }, [baseFilterCondition, conditionalConfig?.sourceFilter, modalCondition]); // 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환 const modalFiltersForModal = useMemo(() => { @@ -806,6 +1140,553 @@ export function TableSectionRenderer({ })); }, [filters?.modalFilters]); + // ============================================ + // 조건부 테이블 관련 핸들러 + // ============================================ + + // 조건부 테이블: 조건 체크박스 토글 + const handleConditionToggle = useCallback((conditionValue: string, checked: boolean) => { + setSelectedConditions((prev) => { + if (checked) { + const newConditions = [...prev, conditionValue]; + // 첫 번째 조건 선택 시 해당 탭 활성화 + if (prev.length === 0) { + setActiveConditionTab(conditionValue); + } + return newConditions; + } else { + const newConditions = prev.filter((c) => c !== conditionValue); + // 현재 활성 탭이 제거된 경우 다른 탭으로 전환 + if (activeConditionTab === conditionValue && newConditions.length > 0) { + setActiveConditionTab(newConditions[0]); + } + return newConditions; + } + }); + }, [activeConditionTab]); + + // 조건부 테이블: 조건별 데이터 변경 + const handleConditionalDataChange = useCallback((conditionValue: string, newData: any[]) => { + setConditionalTableData((prev) => ({ + ...prev, + [conditionValue]: newData, + })); + + // 부모에게 조건별 데이터 변경 알림 + if (onConditionalTableDataChange) { + onConditionalTableDataChange(conditionValue, newData); + } + + // 전체 데이터를 flat array로 변환하여 onTableDataChange 호출 + // (저장 시 조건 컬럼 값이 자동으로 추가됨) + const conditionColumn = conditionalConfig?.conditionColumn; + const allData: any[] = []; + + // 현재 변경된 조건의 데이터 업데이트 + const updatedConditionalData = { ...conditionalTableData, [conditionValue]: newData }; + + for (const [condition, data] of Object.entries(updatedConditionalData)) { + for (const row of data) { + allData.push({ + ...row, + ...(conditionColumn ? { [conditionColumn]: condition } : {}), + }); + } + } + + onTableDataChange(allData); + }, [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange]); + + // 조건부 테이블: 조건별 행 변경 + const handleConditionalRowChange = useCallback((conditionValue: string, index: number, newRow: any) => { + const calculatedRow = calculateRow(newRow); + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData]; + newData[index] = calculatedRow; + handleConditionalDataChange(conditionValue, newData); + }, [conditionalTableData, calculateRow, handleConditionalDataChange]); + + // 조건부 테이블: 조건별 행 삭제 + const handleConditionalRowDelete = useCallback((conditionValue: string, index: number) => { + const currentData = conditionalTableData[conditionValue] || []; + const newData = currentData.filter((_, i) => i !== index); + handleConditionalDataChange(conditionValue, newData); + }, [conditionalTableData, handleConditionalDataChange]); + + // 조건부 테이블: 조건별 선택 행 일괄 삭제 + const handleConditionalBulkDelete = useCallback((conditionValue: string) => { + const selected = conditionalSelectedRows[conditionValue] || new Set(); + if (selected.size === 0) return; + + const currentData = conditionalTableData[conditionValue] || []; + const newData = currentData.filter((_, index) => !selected.has(index)); + handleConditionalDataChange(conditionValue, newData); + + // 선택 상태 초기화 + setConditionalSelectedRows((prev) => ({ + ...prev, + [conditionValue]: new Set(), + })); + }, [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange]); + + // 조건부 테이블: 아이템 추가 (특정 조건에) + const handleConditionalAddItems = useCallback(async (items: any[]) => { + if (!modalCondition) return; + + // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성 + const mappedItems = await Promise.all( + items.map(async (sourceItem) => { + const newItem: any = {}; + + for (const col of tableConfig.columns) { + const mapping = col.valueMapping; + + // 소스 필드에서 값 복사 (기본) + if (!mapping) { + const sourceField = col.sourceField || col.field; + if (sourceItem[sourceField] !== undefined) { + newItem[col.field] = sourceItem[sourceField]; + } + continue; + } + + // valueMapping 처리 + if (mapping.type === "source" && mapping.sourceField) { + const value = sourceItem[mapping.sourceField]; + if (value !== undefined) { + newItem[col.field] = value; + } + } else if (mapping.type === "manual") { + newItem[col.field] = col.defaultValue || ""; + } else if (mapping.type === "internal" && mapping.internalField) { + newItem[col.field] = formData[mapping.internalField]; + } + } + + // 원본 소스 데이터 보존 + newItem._sourceData = sourceItem; + + return newItem; + }) + ); + + // 현재 조건의 데이터에 추가 + const currentData = conditionalTableData[modalCondition] || []; + const newData = [...currentData, ...mappedItems]; + handleConditionalDataChange(modalCondition, newData); + + setModalOpen(false); + }, [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange]); + + // 조건부 테이블: 모달 열기 (특정 조건에 대해) + const openConditionalModal = useCallback((conditionValue: string) => { + setModalCondition(conditionValue); + setModalOpen(true); + }, []); + + // 조건부 테이블: 빈 행 추가 (addRow 모드에서 사용) + const addEmptyRowToCondition = useCallback((conditionValue: string) => { + const newRow: Record = {}; + + // 각 컬럼의 기본값으로 빈 행 생성 + for (const col of tableConfig.columns) { + if (col.defaultValue !== undefined) { + newRow[col.field] = col.defaultValue; + } else if (col.type === "number") { + newRow[col.field] = 0; + } else if (col.type === "checkbox") { + newRow[col.field] = false; + } else { + newRow[col.field] = ""; + } + } + + // 조건 컬럼에 현재 조건 값 설정 + if (conditionalConfig?.conditionColumn) { + newRow[conditionalConfig.conditionColumn] = conditionValue; + } + + // 현재 조건의 데이터에 추가 + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData, newRow]; + handleConditionalDataChange(conditionValue, newData); + }, [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange]); + + // 버튼 클릭 핸들러 (addButtonType에 따라 다르게 동작) + const handleAddButtonClick = useCallback((conditionValue: string) => { + const addButtonType = tableConfig.uiConfig?.addButtonType || "search"; + + if (addButtonType === "addRow") { + // 빈 행 직접 추가 + addEmptyRowToCondition(conditionValue); + } else { + // 검색 모달 열기 + openConditionalModal(conditionValue); + } + }, [tableConfig.uiConfig?.addButtonType, addEmptyRowToCondition, openConditionalModal]); + + // 조건부 테이블: 초기 데이터 로드 (수정 모드) + useEffect(() => { + if (!isConditionalMode) return; + if (initialDataLoadedRef.current) return; + + const tableSectionKey = `_tableSection_${sectionId}`; + const initialData = formData[tableSectionKey]; + + if (Array.isArray(initialData) && initialData.length > 0) { + const conditionColumn = conditionalConfig?.conditionColumn; + + if (conditionColumn) { + // 조건별로 데이터 그룹핑 + const grouped: ConditionalTableData = {}; + const conditions = new Set(); + + for (const row of initialData) { + const conditionValue = row[conditionColumn] || ""; + if (conditionValue) { + if (!grouped[conditionValue]) { + grouped[conditionValue] = []; + } + grouped[conditionValue].push(row); + conditions.add(conditionValue); + } + } + + setConditionalTableData(grouped); + setSelectedConditions(Array.from(conditions)); + + // 첫 번째 조건을 활성 탭으로 설정 + if (conditions.size > 0) { + setActiveConditionTab(Array.from(conditions)[0]); + } + + initialDataLoadedRef.current = true; + } + } + }, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]); + + // 조건부 테이블: 전체 항목 수 계산 + const totalConditionalItems = useMemo(() => { + return Object.values(conditionalTableData).reduce((sum, data) => sum + data.length, 0); + }, [conditionalTableData]); + + // ============================================ + // 조건부 테이블 렌더링 + // ============================================ + if (isConditionalMode && conditionalConfig) { + const { triggerType } = conditionalConfig; + + // 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용) + const effectiveOptions = conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0 + ? dynamicOptions + : conditionalConfig.options || []; + + // 로딩 중이면 로딩 표시 + if (dynamicOptionsLoading) { + return ( +
+
+
+
+ 조건 옵션을 불러오는 중... +
+
+
+ ); + } + + return ( +
+ {/* 조건 선택 UI */} + {triggerType === "checkbox" && ( +
+
+ {effectiveOptions.map((option) => ( + + ))} +
+ + {selectedConditions.length > 0 && ( +
+ {selectedConditions.length}개 유형 선택됨, 총 {totalConditionalItems}개 항목 +
+ )} +
+ )} + + {triggerType === "dropdown" && ( +
+ 유형 선택: + +
+ )} + + {/* 선택된 조건들의 테이블 (탭 형태) */} + {selectedConditions.length > 0 && ( + + + {selectedConditions.map((conditionValue) => { + const option = effectiveOptions.find((o) => o.value === conditionValue); + const itemCount = conditionalTableData[conditionValue]?.length || 0; + return ( + + {option?.label || conditionValue} + {itemCount > 0 && ( + + {itemCount} + + )} + + ); + })} + + + {selectedConditions.map((conditionValue) => { + const data = conditionalTableData[conditionValue] || []; + const selected = conditionalSelectedRows[conditionValue] || new Set(); + + return ( + + {/* 테이블 상단 컨트롤 */} +
+
+ + {data.length > 0 && `${data.length}개 항목`} + {selected.size > 0 && ` (${selected.size}개 선택됨)`} + + {columns.length > 0 && ( + + )} +
+
+ {selected.size > 0 && ( + + )} + +
+
+ + {/* 테이블 */} + handleConditionalDataChange(conditionValue, newData)} + onRowChange={(index, newRow) => handleConditionalRowChange(conditionValue, index, newRow)} + onRowDelete={(index) => handleConditionalRowDelete(conditionValue, index)} + activeDataSources={activeDataSources} + onDataSourceChange={handleDataSourceChange} + selectedRows={selected} + onSelectionChange={(newSelected) => { + setConditionalSelectedRows((prev) => ({ + ...prev, + [conditionValue]: newSelected, + })); + }} + equalizeWidthsTrigger={widthTrigger} + /> +
+ ); + })} +
+ )} + + {/* tabs 모드: 모든 옵션을 탭으로 표시 (선택 UI 없음) */} + {triggerType === "tabs" && effectiveOptions.length > 0 && ( + + + {effectiveOptions.map((option) => { + const itemCount = conditionalTableData[option.value]?.length || 0; + return ( + + {option.label} + {itemCount > 0 && ( + + {itemCount} + + )} + + ); + })} + + + {effectiveOptions.map((option) => { + const data = conditionalTableData[option.value] || []; + const selected = conditionalSelectedRows[option.value] || new Set(); + + return ( + +
+
+ + {data.length > 0 && `${data.length}개 항목`} + {selected.size > 0 && ` (${selected.size}개 선택됨)`} + +
+
+ {selected.size > 0 && ( + + )} + +
+
+ + handleConditionalDataChange(option.value, newData)} + onRowChange={(index, newRow) => handleConditionalRowChange(option.value, index, newRow)} + onRowDelete={(index) => handleConditionalRowDelete(option.value, index)} + activeDataSources={activeDataSources} + onDataSourceChange={handleDataSourceChange} + selectedRows={selected} + onSelectionChange={(newSelected) => { + setConditionalSelectedRows((prev) => ({ + ...prev, + [option.value]: newSelected, + })); + }} + equalizeWidthsTrigger={widthTrigger} + /> +
+ ); + })} +
+ )} + + {/* 조건이 선택되지 않은 경우 안내 메시지 (checkbox/dropdown 모드에서만) */} + {selectedConditions.length === 0 && triggerType !== "tabs" && ( +
+

+ {triggerType === "checkbox" + ? "위에서 유형을 선택하여 검사항목을 추가하세요." + : "유형을 선택하세요."} +

+
+ )} + + {/* 옵션이 없는 경우 안내 메시지 */} + {effectiveOptions.length === 0 && ( +
+

+ 조건 옵션이 설정되지 않았습니다. +

+
+ )} + + {/* 항목 선택 모달 (조건부 테이블용) */} + o.value === modalCondition)?.label || modalCondition} - ${modalTitle}`} + alreadySelected={conditionalTableData[modalCondition] || []} + uniqueField={tableConfig.saveConfig?.uniqueField} + onSelect={handleConditionalAddItems} + columnLabels={columnLabels} + modalFilters={modalFiltersForModal} + /> +
+ ); + } + + // ============================================ + // 일반 테이블 렌더링 (기존 로직) + // ============================================ return (
{/* 추가 버튼 영역 */} @@ -848,10 +1729,34 @@ export function TableSectionRenderer({ )}
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 64c2f826..16778725 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -212,15 +212,23 @@ export function UniversalFormModalComponent({ // 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요) const lastInitializedId = useRef(undefined); - // 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행 + // 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행 useEffect(() => { // initialData에서 ID 값 추출 (id, ID, objid 등) const currentId = initialData?.id || initialData?.ID || initialData?.objid; const currentIdString = currentId !== undefined ? String(currentId) : undefined; + + // 생성 모드에서 부모로부터 전달받은 데이터 해시 (ID가 없을 때만) + const createModeDataHash = !currentIdString && initialData && Object.keys(initialData).length > 0 + ? JSON.stringify(initialData) + : undefined; - // 이미 초기화되었고, ID가 동일하면 스킵 + // 이미 초기화되었고, ID가 동일하고, 생성 모드 데이터도 동일하면 스킵 if (hasInitialized.current && lastInitializedId.current === currentIdString) { - return; + // 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요 + if (!createModeDataHash || capturedInitialData.current) { + return; + } } // 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화 @@ -245,7 +253,7 @@ export function UniversalFormModalComponent({ hasInitialized.current = true; initializeForm(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화 + }, [initialData]); // initialData 전체 변경 시 재초기화 // config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외 useEffect(() => { @@ -478,6 +486,82 @@ export function UniversalFormModalComponent({ setActivatedOptionalFieldGroups(newActivatedGroups); setOriginalData(effectiveInitialData || {}); + // 수정 모드에서 서브 테이블 데이터 로드 (겸직 등) + const multiTable = config.saveConfig?.customApiSave?.multiTable; + if (multiTable && effectiveInitialData) { + const pkColumn = multiTable.mainTable?.primaryKeyColumn; + const pkValue = effectiveInitialData[pkColumn]; + + // PK 값이 있으면 수정 모드로 판단 + if (pkValue) { + console.log("[initializeForm] 수정 모드 - 서브 테이블 데이터 로드 시작"); + + for (const subTableConfig of multiTable.subTables || []) { + // loadOnEdit 옵션이 활성화된 경우에만 로드 + if (!subTableConfig.enabled || !subTableConfig.options?.loadOnEdit) { + continue; + } + + const { tableName, linkColumn, repeatSectionId, fieldMappings, options } = subTableConfig; + if (!tableName || !linkColumn?.subColumn || !repeatSectionId) { + continue; + } + + try { + // 서브 테이블에서 데이터 조회 + const filters: Record = { + [linkColumn.subColumn]: pkValue, + }; + + // 서브 항목만 로드 (메인 항목 제외) + if (options?.loadOnlySubItems && options?.mainMarkerColumn) { + filters[options.mainMarkerColumn] = options.subMarkerValue ?? false; + } + + console.log(`[initializeForm] 서브 테이블 ${tableName} 조회:`, filters); + + const response = await apiClient.get(`/table-management/tables/${tableName}/data`, { + params: { + filters: JSON.stringify(filters), + page: 1, + pageSize: 100, + }, + }); + + if (response.data?.success && response.data?.data?.items) { + const subItems = response.data.data.items; + console.log(`[initializeForm] 서브 테이블 ${tableName} 데이터 ${subItems.length}건 로드됨`); + + // 역매핑: 서브 테이블 데이터 → 반복 섹션 데이터 + const repeatItems: RepeatSectionItem[] = subItems.map((item: any, index: number) => { + const repeatItem: RepeatSectionItem = { + _id: generateUniqueId("repeat"), + _index: index, + _originalData: item, // 원본 데이터 보관 (수정 시 필요) + }; + + // 필드 매핑 역변환 (targetColumn → formField) + for (const mapping of fieldMappings || []) { + if (mapping.formField && mapping.targetColumn) { + repeatItem[mapping.formField] = item[mapping.targetColumn]; + } + } + + return repeatItem; + }); + + // 반복 섹션에 데이터 설정 + newRepeatSections[repeatSectionId] = repeatItems; + setRepeatSections({ ...newRepeatSections }); + console.log(`[initializeForm] 반복 섹션 ${repeatSectionId}에 ${repeatItems.length}건 설정`); + } + } catch (error) { + console.error(`[initializeForm] 서브 테이블 ${tableName} 로드 실패:`, error); + } + } + } + } + // 채번규칙 자동 생성 console.log("[initializeForm] generateNumberingValues 호출"); await generateNumberingValues(newFormData); @@ -1142,6 +1226,20 @@ export function UniversalFormModalComponent({ } }); }); + + // 1-0. receiveFromParent 필드 값도 mainData에 추가 (서브 테이블 저장용) + // 이 필드들은 메인 테이블에는 저장되지 않지만, 서브 테이블 저장 시 필요할 수 있음 + config.sections.forEach((section) => { + if (section.repeatable || section.type === "table") return; + (section.fields || []).forEach((field) => { + if (field.receiveFromParent && !mainData[field.columnName]) { + const value = formData[field.columnName]; + if (value !== undefined && value !== null && value !== "") { + mainData[field.columnName] = value; + } + } + }); + }); // 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당) for (const section of config.sections) { @@ -1185,36 +1283,42 @@ export function UniversalFormModalComponent({ }> = []; for (const subTableConfig of multiTable.subTables || []) { - if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) { + // 서브 테이블이 활성화되어 있고 테이블명이 있어야 함 + // repeatSectionId는 선택사항 (saveMainAsFirst만 사용하는 경우 없을 수 있음) + if (!subTableConfig.enabled || !subTableConfig.tableName) { continue; } const subItems: Record[] = []; - const repeatData = repeatSections[subTableConfig.repeatSectionId] || []; + + // 반복 섹션이 있는 경우에만 반복 데이터 처리 + if (subTableConfig.repeatSectionId) { + const repeatData = repeatSections[subTableConfig.repeatSectionId] || []; - // 반복 섹션 데이터를 필드 매핑에 따라 변환 - for (const item of repeatData) { - const mappedItem: Record = {}; + // 반복 섹션 데이터를 필드 매핑에 따라 변환 + for (const item of repeatData) { + const mappedItem: Record = {}; - // 연결 컬럼 값 설정 - if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) { - mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField]; - } - - // 필드 매핑에 따라 데이터 변환 - for (const mapping of subTableConfig.fieldMappings || []) { - if (mapping.formField && mapping.targetColumn) { - mappedItem[mapping.targetColumn] = item[mapping.formField]; + // 연결 컬럼 값 설정 + if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) { + mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField]; } - } - // 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값) - if (subTableConfig.options?.mainMarkerColumn) { - mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false; - } + // 필드 매핑에 따라 데이터 변환 + for (const mapping of subTableConfig.fieldMappings || []) { + if (mapping.formField && mapping.targetColumn) { + mappedItem[mapping.targetColumn] = item[mapping.formField]; + } + } - if (Object.keys(mappedItem).length > 0) { - subItems.push(mappedItem); + // 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값) + if (subTableConfig.options?.mainMarkerColumn) { + mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false; + } + + if (Object.keys(mappedItem).length > 0) { + subItems.push(mappedItem); + } } } @@ -1226,8 +1330,9 @@ export function UniversalFormModalComponent({ // fieldMappings에 정의된 targetColumn만 매핑 (서브 테이블에 존재하는 컬럼만) for (const mapping of subTableConfig.fieldMappings || []) { if (mapping.targetColumn) { - // 메인 데이터에서 동일한 컬럼명이 있으면 매핑 - if (mainData[mapping.targetColumn] !== undefined && mainData[mapping.targetColumn] !== null && mainData[mapping.targetColumn] !== "") { + // formData에서 동일한 컬럼명이 있으면 매핑 (receiveFromParent 필드 포함) + const formValue = formData[mapping.targetColumn]; + if (formValue !== undefined && formValue !== null && formValue !== "") { mainFieldMappings.push({ formField: mapping.targetColumn, targetColumn: mapping.targetColumn, @@ -1238,11 +1343,14 @@ export function UniversalFormModalComponent({ config.sections.forEach((section) => { if (section.repeatable || section.type === "table") return; const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn); - if (matchingField && mainData[matchingField.columnName] !== undefined && mainData[matchingField.columnName] !== null && mainData[matchingField.columnName] !== "") { - mainFieldMappings!.push({ - formField: matchingField.columnName, - targetColumn: mapping.targetColumn, - }); + if (matchingField) { + const fieldValue = formData[matchingField.columnName]; + if (fieldValue !== undefined && fieldValue !== null && fieldValue !== "") { + mainFieldMappings!.push({ + formField: matchingField.columnName, + targetColumn: mapping.targetColumn, + }); + } } }); } @@ -1255,15 +1363,18 @@ export function UniversalFormModalComponent({ ); } - subTablesData.push({ - tableName: subTableConfig.tableName, - linkColumn: subTableConfig.linkColumn, - items: subItems, - options: { - ...subTableConfig.options, - mainFieldMappings, // 메인 데이터 매핑 추가 - }, - }); + // 서브 테이블 데이터 추가 (반복 데이터가 없어도 saveMainAsFirst가 있으면 추가) + if (subItems.length > 0 || subTableConfig.options?.saveMainAsFirst) { + subTablesData.push({ + tableName: subTableConfig.tableName, + linkColumn: subTableConfig.linkColumn, + items: subItems, + options: { + ...subTableConfig.options, + mainFieldMappings, // 메인 데이터 매핑 추가 + }, + }); + } } // 3. 범용 다중 테이블 저장 API 호출 @@ -1489,13 +1600,20 @@ export function UniversalFormModalComponent({ // 표시 텍스트 생성 함수 const getDisplayText = (row: Record): string => { - const displayVal = row[lfg.displayColumn || ""] || ""; - const valueVal = row[valueColumn] || ""; + // 메인 표시 컬럼 (displayColumn) + const mainDisplayVal = row[lfg.displayColumn || ""] || ""; + // 서브 표시 컬럼 (subDisplayColumn이 있으면 사용, 없으면 valueColumn 사용) + const subDisplayVal = lfg.subDisplayColumn + ? (row[lfg.subDisplayColumn] || "") + : (row[valueColumn] || ""); + switch (lfg.displayFormat) { case "code_name": - return `${valueVal} - ${displayVal}`; + // 서브 - 메인 형식 + return `${subDisplayVal} - ${mainDisplayVal}`; case "name_code": - return `${displayVal} (${valueVal})`; + // 메인 (서브) 형식 + return `${mainDisplayVal} (${subDisplayVal})`; case "custom": // 커스텀 형식: {컬럼명}을 실제 값으로 치환 if (lfg.customDisplayFormat) { @@ -1511,10 +1629,10 @@ export function UniversalFormModalComponent({ } return result; } - return String(displayVal); + return String(mainDisplayVal); case "name_only": default: - return String(displayVal); + return String(mainDisplayVal); } }; diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index e8b239f6..41c18043 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -11,6 +11,8 @@ import { TablePreFilter, TableModalFilter, TableCalculationRule, + ConditionalTableConfig, + ConditionalTableOption, } from "./types"; // 기본 설정값 @@ -133,6 +135,33 @@ export const defaultTableSectionConfig: TableSectionConfig = { multiSelect: true, maxHeight: "400px", }, + conditionalTable: undefined, +}; + +// 기본 조건부 테이블 설정 +export const defaultConditionalTableConfig: ConditionalTableConfig = { + enabled: false, + triggerType: "checkbox", + conditionColumn: "", + options: [], + optionSource: { + enabled: false, + tableName: "", + valueColumn: "", + labelColumn: "", + filterCondition: "", + }, + sourceFilter: { + enabled: false, + filterColumn: "", + }, +}; + +// 기본 조건부 테이블 옵션 설정 +export const defaultConditionalTableOptionConfig: ConditionalTableOption = { + id: "", + value: "", + label: "", }; // 기본 테이블 컬럼 설정 @@ -300,3 +329,8 @@ export const generateColumnModeId = (): string => { export const generateFilterId = (): string => { return generateUniqueId("filter"); }; + +// 유틸리티: 조건부 테이블 옵션 ID 생성 +export const generateConditionalOptionId = (): string => { + return generateUniqueId("cond"); +}; diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index 2404cc4c..8882d9bc 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -98,6 +98,9 @@ export function FieldDetailSettingsModal({ // Combobox 열림 상태 const [sourceTableOpen, setSourceTableOpen] = useState(false); const [targetColumnOpenMap, setTargetColumnOpenMap] = useState>({}); + const [displayColumnOpen, setDisplayColumnOpen] = useState(false); + const [subDisplayColumnOpen, setSubDisplayColumnOpen] = useState(false); // 서브 표시 컬럼 Popover 상태 + const [sourceColumnOpenMap, setSourceColumnOpenMap] = useState>({}); // open이 변경될 때마다 필드 데이터 동기화 useEffect(() => { @@ -105,6 +108,16 @@ export function FieldDetailSettingsModal({ setLocalField(field); } }, [open, field]); + + // 모달이 열릴 때 소스 테이블 컬럼 자동 로드 + useEffect(() => { + if (open && field.linkedFieldGroup?.sourceTable) { + // tableColumns에 해당 테이블 컬럼이 없으면 로드 + if (!tableColumns[field.linkedFieldGroup.sourceTable] || tableColumns[field.linkedFieldGroup.sourceTable].length === 0) { + onLoadTableColumns(field.linkedFieldGroup.sourceTable); + } + } + }, [open, field.linkedFieldGroup?.sourceTable, tableColumns, onLoadTableColumns]); // 모든 카테고리 컬럼 목록 로드 (모달 열릴 때) useEffect(() => { @@ -735,32 +748,108 @@ export function FieldDetailSettingsModal({ 값을 가져올 소스 테이블 (예: customer_mng)
+ {/* 표시 형식 선택 */}
- + + + 드롭다운에 표시할 형식을 선택합니다 +
+ + {/* 메인 표시 컬럼 */} +
+ {sourceTableColumns.length > 0 ? ( - + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {sourceTableColumns.map((col) => ( + { + updateField({ + linkedFieldGroup: { + ...localField.linkedFieldGroup, + displayColumn: col.name, + }, + }); + setDisplayColumnOpen(false); + }} + className="text-xs" + > + + {col.name} + ({col.label}) + + ))} + + + + + ) : ( )} - 드롭다운에 표시할 컬럼 (예: customer_name) + 드롭다운에 표시할 메인 컬럼 (예: item_name)
-
- - - 드롭다운에 표시될 형식을 선택하세요 -
+ {/* 서브 표시 컬럼 - 표시 형식이 name_only가 아닌 경우에만 표시 */} + {localField.linkedFieldGroup?.displayFormat && + localField.linkedFieldGroup.displayFormat !== "name_only" && ( +
+ + {sourceTableColumns.length > 0 ? ( + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {sourceTableColumns.map((col) => ( + { + updateField({ + linkedFieldGroup: { + ...localField.linkedFieldGroup, + subDisplayColumn: col.name, + }, + }); + setSubDisplayColumnOpen(false); + }} + className="text-xs" + > + + {col.name} + ({col.label}) + + ))} + + + + + + ) : ( + + updateField({ + linkedFieldGroup: { + ...localField.linkedFieldGroup, + subDisplayColumn: e.target.value, + }, + }) + } + placeholder="item_code" + className="h-7 text-xs mt-1" + /> + )} + + {localField.linkedFieldGroup?.displayFormat === "code_name" + ? "메인 앞에 표시될 서브 컬럼 (예: 서브 - 메인)" + : "메인 뒤에 표시될 서브 컬럼 (예: 메인 (서브))"} + +
+ )} + + {/* 미리보기 - 메인 컬럼이 선택된 경우에만 표시 */} + {localField.linkedFieldGroup?.displayColumn && ( +
+

미리보기:

+ {(() => { + const mainCol = localField.linkedFieldGroup?.displayColumn || ""; + const subCol = localField.linkedFieldGroup?.subDisplayColumn || ""; + const mainLabel = sourceTableColumns.find(c => c.name === mainCol)?.label || mainCol; + const subLabel = sourceTableColumns.find(c => c.name === subCol)?.label || subCol; + const format = localField.linkedFieldGroup?.displayFormat || "name_only"; + + let preview = ""; + if (format === "name_only") { + preview = mainLabel; + } else if (format === "code_name" && subCol) { + preview = `${subLabel} - ${mainLabel}`; + } else if (format === "name_code" && subCol) { + preview = `${mainLabel} (${subLabel})`; + } else if (format !== "name_only" && !subCol) { + preview = `${mainLabel} (서브 컬럼을 선택하세요)`; + } else { + preview = mainLabel; + } + + return ( +

{preview}

+ ); + })()} +
+ )} @@ -846,24 +1029,67 @@ export function FieldDetailSettingsModal({
{sourceTableColumns.length > 0 ? ( - + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {sourceTableColumns.map((col) => ( + { + updateLinkedFieldMapping(index, { sourceColumn: col.name }); + setSourceColumnOpenMap((prev) => ({ ...prev, [index]: false })); + }} + className="text-[9px]" + > + + {col.name} + ({col.label}) + + ))} + + + + + ) : ( >({}); + // 컬럼 검색 Popover 상태 + const [mainKeyColumnSearchOpen, setMainKeyColumnSearchOpen] = useState(false); + const [mainFieldSearchOpen, setMainFieldSearchOpen] = useState>({}); + const [subColumnSearchOpen, setSubColumnSearchOpen] = useState>({}); + const [subTableColumnSearchOpen, setSubTableColumnSearchOpen] = useState>({}); + const [markerColumnSearchOpen, setMarkerColumnSearchOpen] = useState>({}); + // open이 변경될 때마다 데이터 동기화 useEffect(() => { if (open) { setLocalSaveConfig(saveConfig); setSaveMode(saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single"); + + // 모달이 열릴 때 기존에 설정된 테이블들의 컬럼 정보 로드 + const mainTableName = saveConfig.customApiSave?.multiTable?.mainTable?.tableName; + if (mainTableName && !tableColumns[mainTableName]) { + onLoadTableColumns(mainTableName); + } + + // 서브 테이블들의 컬럼 정보도 로드 + const subTables = saveConfig.customApiSave?.multiTable?.subTables || []; + subTables.forEach((subTable) => { + if (subTable.tableName && !tableColumns[subTable.tableName]) { + onLoadTableColumns(subTable.tableName); + } + }); } - }, [open, saveConfig]); + }, [open, saveConfig, tableColumns, onLoadTableColumns]); // 저장 설정 업데이트 함수 const updateSaveConfig = (updates: Partial) => { @@ -558,35 +579,76 @@ export function SaveSettingsModal({
{mainTableColumns.length > 0 ? ( - + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {mainTableColumns.map((col) => ( + { + updateSaveConfig({ + customApiSave: { + ...localSaveConfig.customApiSave, + multiTable: { + ...localSaveConfig.customApiSave?.multiTable, + mainTable: { + ...localSaveConfig.customApiSave?.multiTable?.mainTable, + primaryKeyColumn: col.name, + }, + }, + }, + }); + setMainKeyColumnSearchOpen(false); + }} + className="text-xs" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + {col.label} + )} +
+
+ ))} +
+
+
+
+
) : ( {mainTableColumns.length > 0 ? ( - + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {mainTableColumns.map((col) => ( + { + updateSubTable(subIndex, { + linkColumn: { ...subTable.linkColumn, mainField: col.name }, + }); + setMainFieldSearchOpen(prev => ({ ...prev, [subIndex]: false })); + }} + className="text-[10px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + {col.label} + )} +
+
+ ))} +
+
+
+
+ ) : ( {subTableColumns.length > 0 ? ( - + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {subTableColumns.map((col) => ( + { + updateSubTable(subIndex, { + linkColumn: { ...subTable.linkColumn, subColumn: col.name }, + }); + setSubColumnSearchOpen(prev => ({ ...prev, [subIndex]: false })); + }} + className="text-[10px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + {col.label} + )} +
+
+ ))} +
+
+
+
+ ) : ( {subTableColumns.length > 0 ? ( - + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {subTableColumns.map((col) => ( + { + updateFieldMapping(subIndex, mapIndex, { targetColumn: col.name }); + setSubTableColumnSearchOpen(prev => ({ ...prev, [`${subIndex}-${mapIndex}`]: false })); + }} + className="text-[10px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + {col.label} + )} +
+
+ ))} +
+
+
+
+ ) : ( )}
+ + + + {/* 대표 데이터 구분 저장 옵션 */} +
+ {!subTable.options?.saveMainAsFirst ? ( + // 비활성화 상태: 추가 버튼 표시 +
+
+
+

대표/일반 구분 저장

+

+ 저장되는 데이터를 대표와 일반으로 구분합니다 +

+
+ +
+
+ ) : ( + // 활성화 상태: 설정 필드 표시 +
+
+
+

대표/일반 구분 저장

+

+ 저장되는 데이터를 대표와 일반으로 구분합니다 +

+
+ +
+ +
+
+ + {subTableColumns.length > 0 ? ( + setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: open }))} + > + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {subTableColumns.map((col) => ( + { + updateSubTable(subIndex, { + options: { + ...subTable.options, + mainMarkerColumn: col.name, + } + }); + setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: false })); + }} + className="text-[10px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + {col.label} + )} +
+
+ ))} +
+
+
+
+
+ ) : ( + updateSubTable(subIndex, { + options: { + ...subTable.options, + mainMarkerColumn: e.target.value, + } + })} + placeholder="is_primary" + className="h-6 text-[9px] mt-0.5" + /> + )} + 대표/일반을 구분하는 컬럼 +
+ +
+
+ + { + const val = e.target.value; + // true/false 문자열은 boolean으로 변환 + let parsedValue: any = val; + if (val === "true") parsedValue = true; + else if (val === "false") parsedValue = false; + else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val); + + updateSubTable(subIndex, { + options: { + ...subTable.options, + mainMarkerValue: parsedValue, + } + }); + }} + placeholder="true, Y, 1 등" + className="h-6 text-[9px] mt-0.5" + /> + 기본 정보와 함께 저장될 때 값 +
+
+ + { + const val = e.target.value; + let parsedValue: any = val; + if (val === "true") parsedValue = true; + else if (val === "false") parsedValue = false; + else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val); + + updateSubTable(subIndex, { + options: { + ...subTable.options, + subMarkerValue: parsedValue, + } + }); + }} + placeholder="false, N, 0 등" + className="h-6 text-[9px] mt-0.5" + /> + 겸직 추가 시 저장될 때 값 +
+
+
+
+ )} +
+ + + + {/* 수정 시 데이터 로드 옵션 */} +
+ {!subTable.options?.loadOnEdit ? ( + // 비활성화 상태: 추가 버튼 표시 +
+
+
+

수정 시 데이터 로드

+

+ 수정 모드에서 서브 테이블 데이터를 불러옵니다 +

+
+ +
+
+ ) : ( + // 활성화 상태: 설정 필드 표시 +
+
+
+

수정 시 데이터 로드

+

+ 수정 모드에서 서브 테이블 데이터를 불러옵니다 +

+
+ +
+ +
+ updateSubTable(subIndex, { + options: { + ...subTable.options, + loadOnlySubItems: checked, + } + })} + /> + +
+ + 활성화하면 겸직 데이터만 불러오고, 비활성화하면 모든 데이터를 불러옵니다 + +
+ )} +
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx index 797bce55..79a78d10 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx @@ -699,6 +699,357 @@ export function TableColumnSettingsModal({
+ + {/* 동적 Select 옵션 (소스 테이블에서 로드) */} + +
+
+
+

동적 옵션 (소스 테이블에서 로드)

+

+ 소스 테이블에서 옵션을 동적으로 가져옵니다. 조건부 테이블 필터가 자동 적용됩니다. +

+
+ { + updateColumn({ + dynamicSelectOptions: checked + ? { + enabled: true, + sourceField: "", + distinct: true, + } + : undefined, + }); + }} + /> +
+ + {localColumn.dynamicSelectOptions?.enabled && ( +
+ {/* 소스 필드 */} +
+ +

+ 소스 테이블에서 옵션 값을 가져올 컬럼 +

+ {sourceTableColumns.length > 0 ? ( + + ) : ( + { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + sourceField: e.target.value, + }, + }); + }} + placeholder="inspection_item" + className="h-8 text-xs" + /> + )} +
+ + {/* 라벨 필드 */} +
+ +

+ 표시할 라벨 컬럼 (없으면 소스 컬럼 값 사용) +

+ {sourceTableColumns.length > 0 ? ( + + ) : ( + { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + labelField: e.target.value || undefined, + }, + }); + }} + placeholder="(비워두면 소스 컬럼 사용)" + className="h-8 text-xs" + /> + )} +
+ + {/* 행 선택 모드 */} +
+
+ { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + rowSelectionMode: checked + ? { + enabled: true, + autoFillMappings: [], + } + : undefined, + }, + }); + }} + className="scale-75" + /> +
+ +

+ 이 컬럼 선택 시 같은 소스 행의 다른 컬럼들도 자동 채움 +

+
+
+ + {localColumn.dynamicSelectOptions.rowSelectionMode?.enabled && ( +
+ {/* 소스 ID 저장 설정 */} +
+
+ + {sourceTableColumns.length > 0 ? ( + + ) : ( + { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + rowSelectionMode: { + ...localColumn.dynamicSelectOptions!.rowSelectionMode!, + sourceIdColumn: e.target.value || undefined, + }, + }, + }); + }} + placeholder="id" + className="h-7 text-xs mt-1" + /> + )} +
+
+ + { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + rowSelectionMode: { + ...localColumn.dynamicSelectOptions!.rowSelectionMode!, + targetIdField: e.target.value || undefined, + }, + }, + }); + }} + placeholder="inspection_standard_id" + className="h-7 text-xs mt-1" + /> +
+
+ + {/* 자동 채움 매핑 */} +
+
+ + +
+
+ {(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).map((mapping, idx) => ( +
+ {sourceTableColumns.length > 0 ? ( + + ) : ( + { + const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])]; + newMappings[idx] = { ...newMappings[idx], sourceColumn: e.target.value }; + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + rowSelectionMode: { + ...localColumn.dynamicSelectOptions!.rowSelectionMode!, + autoFillMappings: newMappings, + }, + }, + }); + }} + placeholder="소스 컬럼" + className="h-7 text-xs flex-1" + /> + )} + + { + const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])]; + newMappings[idx] = { ...newMappings[idx], targetField: e.target.value }; + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + rowSelectionMode: { + ...localColumn.dynamicSelectOptions!.rowSelectionMode!, + autoFillMappings: newMappings, + }, + }, + }); + }} + placeholder="타겟 필드" + className="h-7 text-xs flex-1" + /> + +
+ ))} + {(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).length === 0 && ( +

+ 매핑을 추가하세요 (예: inspection_criteria → inspection_standard) +

+ )} +
+
+
+ )} +
+
+ )} +
)} diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx index effb7927..535be447 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -25,12 +25,14 @@ import { TableModalFilter, TableCalculationRule, LookupOption, - ExternalTableLookup, + LookupCondition, + ConditionalTableOption, TABLE_COLUMN_TYPE_OPTIONS, FILTER_OPERATOR_OPTIONS, MODAL_FILTER_TYPE_OPTIONS, LOOKUP_TYPE_OPTIONS, LOOKUP_CONDITION_SOURCE_OPTIONS, + CONDITIONAL_TABLE_TRIGGER_OPTIONS, } from "../types"; import { @@ -39,8 +41,10 @@ import { defaultPreFilterConfig, defaultModalFilterConfig, defaultCalculationRuleConfig, + defaultConditionalTableConfig, generateTableColumnId, generateFilterId, + generateConditionalOptionId, } from "../config"; // 도움말 텍스트 컴포넌트 @@ -48,6 +52,236 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); +// 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox) +interface OptionSourceConfigProps { + optionSource: { + enabled: boolean; + tableName: string; + valueColumn: string; + labelColumn: string; + filterCondition?: string; + }; + tables: { table_name: string; comment?: string }[]; + tableColumns: Record; + onUpdate: (updates: Partial) => void; +} + +const OptionSourceConfig: React.FC = ({ + optionSource, + tables, + tableColumns, + onUpdate, +}) => { + const [tableOpen, setTableOpen] = useState(false); + const [valueColumnOpen, setValueColumnOpen] = useState(false); + + // 선택된 테이블의 컬럼 목록 + const selectedTableColumns = useMemo(() => { + return tableColumns[optionSource.tableName] || []; + }, [tableColumns, optionSource.tableName]); + + return ( +
+ {/* 테이블 선택 Combobox */} +
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + onUpdate({ + tableName: table.table_name, + valueColumn: "", // 테이블 변경 시 컬럼 초기화 + labelColumn: "", + }); + setTableOpen(false); + }} + className="text-xs" + > + +
+ {table.table_name} + {table.comment && ( + {table.comment} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 참조할 값 컬럼 선택 Combobox */} +
+ + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {selectedTableColumns.map((column) => ( + { + onUpdate({ valueColumn: column.column_name }); + setValueColumnOpen(false); + }} + className="text-xs" + > + +
+ {column.column_name} + {column.comment && ( + {column.comment} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 출력할 값 컬럼 선택 Combobox */} +
+ + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {/* 값 컬럼 사용 옵션 */} + onUpdate({ labelColumn: "" })} + className="text-xs text-muted-foreground" + > + + (참조할 값과 동일) + + {selectedTableColumns.map((column) => ( + onUpdate({ labelColumn: column.column_name })} + className="text-xs" + > + +
+ {column.column_name} + {column.comment && ( + {column.comment} + )} +
+
+ ))} +
+
+
+
+
+

+ 비워두면 참조할 값을 그대로 표시 +

+
+
+ ); +}; + // 부모 화면에서 전달 가능한 필드 타입 interface AvailableParentField { name: string; // 필드명 (columnName) @@ -1218,6 +1452,340 @@ function ColumnSettingItem({ )}
)} + + {/* 동적 Select 옵션 (소스 테이블 필터링이 활성화되고, 타입이 select일 때만 표시) */} + {col.type === "select" && tableConfig.conditionalTable?.sourceFilter?.enabled && ( +
+
+
+ +

+ 소스 테이블에서 옵션을 동적으로 로드합니다. 조건부 테이블 필터가 자동 적용됩니다. +

+
+ { + onUpdate({ + dynamicSelectOptions: checked + ? { + enabled: true, + sourceField: "", + distinct: true, + } + : undefined, + }); + }} + className="scale-75" + /> +
+ + {col.dynamicSelectOptions?.enabled && ( +
+ {/* 소스 컬럼 선택 */} +
+
+ +

+ 드롭다운 옵션으로 사용할 컬럼 +

+ {sourceTableColumns.length > 0 ? ( + + ) : ( + { + onUpdate({ + dynamicSelectOptions: { + ...col.dynamicSelectOptions!, + sourceField: e.target.value, + }, + }); + }} + placeholder="inspection_item" + className="h-7 text-xs" + /> + )} +
+ +
+ +

+ 표시할 라벨 (비워두면 소스 컬럼과 동일) +

+ {sourceTableColumns.length > 0 ? ( + + ) : ( + { + onUpdate({ + dynamicSelectOptions: { + ...col.dynamicSelectOptions!, + labelField: e.target.value, + }, + }); + }} + placeholder="(비워두면 소스 컬럼과 동일)" + className="h-7 text-xs" + /> + )} +
+
+ + {/* 행 선택 모드 */} +
+ +

+ 이 컬럼 선택 시 같은 소스 행의 다른 컬럼 값을 자동으로 채웁니다. +

+ + {col.dynamicSelectOptions.rowSelectionMode?.enabled && ( +
+
+ + +
+ + {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).length === 0 ? ( +

+ "매핑 추가" 버튼을 클릭하여 자동 채움 매핑을 추가하세요. +

+ ) : ( +
+ {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).map((mapping, mappingIndex) => ( +
+ {/* 소스 컬럼 */} +
+ + {sourceTableColumns.length > 0 ? ( + + ) : ( + { + const newMappings = [...(col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || [])]; + newMappings[mappingIndex] = { ...newMappings[mappingIndex], sourceColumn: e.target.value }; + onUpdate({ + dynamicSelectOptions: { + ...col.dynamicSelectOptions!, + rowSelectionMode: { + ...col.dynamicSelectOptions!.rowSelectionMode!, + autoFillColumns: newMappings, + }, + }, + }); + }} + placeholder="소스 컬럼" + className="h-6 text-[10px]" + /> + )} +
+ + + + {/* 타겟 필드 */} +
+ + +
+ + {/* 삭제 버튼 */} + +
+ ))} +
+ )} + + {/* 매핑 설명 */} + {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).length > 0 && ( +
+ {col.label || col.field} 선택 시:{" "} + {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []) + .filter((m) => m.sourceColumn && m.targetField) + .map((m) => { + const targetCol = tableConfig.columns?.find((c) => c.field === m.targetField); + return `${m.sourceColumn} → ${targetCol?.label || m.targetField}`; + }) + .join(", ")} +
+ )} +
+ )} +
+ + {/* 설정 요약 */} + {col.dynamicSelectOptions.sourceField && ( +
+ {sourceTableName}.{col.dynamicSelectOptions.sourceField} + {tableConfig.conditionalTable?.sourceFilter?.filterColumn && ( + <> (조건: {tableConfig.conditionalTable.sourceFilter.filterColumn} = 선택된 검사유형) + )} +
+ )} +
+ )} +
+ )}
); } @@ -2160,12 +2728,37 @@ export function TableSectionSettingsModal({

UI 설정

+
+ + +
updateUiConfig({ addButtonText: e.target.value })} - placeholder="항목 검색" + placeholder={tableConfig.uiConfig?.addButtonType === "addRow" ? "항목 추가" : "항목 검색"} className="h-8 text-xs mt-1" />
@@ -2176,7 +2769,11 @@ export function TableSectionSettingsModal({ onChange={(e) => updateUiConfig({ modalTitle: e.target.value })} placeholder="항목 검색 및 선택" className="h-8 text-xs mt-1" + disabled={tableConfig.uiConfig?.addButtonType === "addRow"} /> + {tableConfig.uiConfig?.addButtonType === "addRow" && ( +

빈 행 추가 모드에서는 모달이 열리지 않습니다

+ )}
@@ -2193,6 +2790,7 @@ export function TableSectionSettingsModal({ checked={tableConfig.uiConfig?.multiSelect ?? true} onCheckedChange={(checked) => updateUiConfig({ multiSelect: checked })} className="scale-75" + disabled={tableConfig.uiConfig?.addButtonType === "addRow"} /> 다중 선택 허용 @@ -2254,6 +2852,365 @@ export function TableSectionSettingsModal({
))}
+ + {/* 조건부 테이블 설정 */} +
+
+
+

조건부 테이블

+

+ 조건(검사유형 등)에 따라 다른 데이터를 표시하고 저장합니다. +

+
+ { + setTableConfig({ + ...tableConfig, + conditionalTable: checked + ? { ...defaultConditionalTableConfig, enabled: true } + : { ...defaultConditionalTableConfig, enabled: false }, + }); + }} + className="scale-75" + /> +
+ + {tableConfig.conditionalTable?.enabled && ( +
+ {/* 트리거 유형 및 조건 컬럼 */} +
+
+ + + + 체크박스: 다중 선택 후 탭으로 표시 / 드롭다운: 단일 선택 / 탭: 모든 옵션 표시 + +
+
+ + + 저장 시 각 행에 조건 값이 이 컬럼에 자동 저장됩니다. +
+
+ + {/* 조건 옵션 목록 */} +
+
+ +
+ +
+
+ + {/* 옵션 목록 */} +
+ {(tableConfig.conditionalTable?.options || []).map((option, index) => ( +
+ { + const newOptions = [...(tableConfig.conditionalTable?.options || [])]; + newOptions[index] = { ...newOptions[index], value: e.target.value }; + // label이 비어있으면 value와 동일하게 설정 + if (!newOptions[index].label) { + newOptions[index].label = e.target.value; + } + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + options: newOptions, + }, + }); + }} + placeholder="저장 값 (예: 입고검사)" + className="h-8 text-xs flex-1" + /> + { + const newOptions = [...(tableConfig.conditionalTable?.options || [])]; + newOptions[index] = { ...newOptions[index], label: e.target.value }; + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + options: newOptions, + }, + }); + }} + placeholder="표시 라벨 (예: 입고검사)" + className="h-8 text-xs flex-1" + /> + +
+ ))} + + {(tableConfig.conditionalTable?.options || []).length === 0 && ( +
+ 조건 옵션을 추가하세요. (예: 입고검사, 공정검사, 출고검사 등) +
+ )} +
+
+ + {/* 테이블에서 옵션 로드 설정 */} +
+
+ { + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + optionSource: { + ...tableConfig.conditionalTable?.optionSource, + enabled: checked, + tableName: tableConfig.conditionalTable?.optionSource?.tableName || "", + valueColumn: tableConfig.conditionalTable?.optionSource?.valueColumn || "", + labelColumn: tableConfig.conditionalTable?.optionSource?.labelColumn || "", + }, + }, + }); + }} + className="scale-75" + /> + +
+ + {tableConfig.conditionalTable?.optionSource?.enabled && ( + { + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + optionSource: { + ...tableConfig.conditionalTable?.optionSource!, + ...updates, + }, + }, + }); + }} + /> + )} +
+ + {/* 소스 테이블 필터링 설정 */} +
+
+ { + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + sourceFilter: { + enabled: checked, + filterColumn: tableConfig.conditionalTable?.sourceFilter?.filterColumn || "", + }, + }, + }); + }} + className="scale-75" + /> +
+ +

+ 조건 선택 시 소스 테이블에서 해당 조건으로 필터링합니다 +

+
+
+ + {tableConfig.conditionalTable?.sourceFilter?.enabled && ( +
+ +

+ 소스 테이블({tableConfig.source?.tableName || "미설정"})에서 조건값으로 필터링할 컬럼 +

+ {sourceTableColumns.length > 0 ? ( + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {sourceTableColumns.map((col) => ( + { + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + sourceFilter: { + ...tableConfig.conditionalTable?.sourceFilter!, + filterColumn: col.column_name, + }, + }, + }); + }} + className="text-xs" + > + + {col.column_name} + {col.comment && ( + ({col.comment}) + )} + + ))} + + + + + + ) : ( + { + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + sourceFilter: { + ...tableConfig.conditionalTable?.sourceFilter!, + filterColumn: e.target.value, + }, + }, + }); + }} + placeholder="inspection_type" + className="h-7 text-xs" + /> + )} +

+ 예: 검사유형 "입고검사" 선택 시 → inspection_type = '입고검사' 조건 적용 +

+
+ )} +
+
+ )} +
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 43377764..31388e96 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -80,7 +80,8 @@ export interface FormFieldConfig { linkedFieldGroup?: { enabled?: boolean; // 사용 여부 sourceTable?: string; // 소스 테이블 (예: dept_info) - displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트 + displayColumn?: string; // 메인 표시 컬럼 (예: item_name) - 드롭다운에 보여줄 메인 텍스트 + subDisplayColumn?: string; // 서브 표시 컬럼 (예: item_number) - 메인과 함께 표시될 서브 텍스트 displayFormat?: "name_only" | "code_name" | "name_code" | "custom"; // 표시 형식 // 커스텀 표시 형식 (displayFormat이 "custom"일 때 사용) // 형식: {컬럼명} 으로 치환됨 (예: "{item_name} ({item_number})" → "철판 (ITEM-001)") @@ -256,6 +257,11 @@ export interface TableSectionConfig { modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택") multiSelect?: boolean; // 다중 선택 허용 (기본: true) maxHeight?: string; // 테이블 최대 높이 (기본: "400px") + + // 추가 버튼 타입 + // - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택 + // - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력 + addButtonType?: "search" | "addRow"; }; // 7. 조건부 테이블 설정 (고급) @@ -295,6 +301,13 @@ export interface ConditionalTableConfig { labelColumn: string; // 예: type_name filterCondition?: string; // 예: is_active = 'Y' }; + + // 소스 테이블 필터링 설정 + // 조건 선택 시 소스 테이블(검사기준 등)에서 해당 조건으로 필터링 + sourceFilter?: { + enabled: boolean; + filterColumn: string; // 소스 테이블에서 필터링할 컬럼 (예: inspection_type) + }; } /** @@ -373,6 +386,30 @@ export interface TableColumnConfig { // Select 옵션 (type이 "select"일 때) selectOptions?: { value: string; label: string }[]; + // 동적 Select 옵션 (소스 테이블에서 옵션 로드) + // 조건부 테이블의 sourceFilter가 활성화되어 있으면 자동으로 필터 적용 + dynamicSelectOptions?: { + enabled: boolean; + sourceField: string; // 소스 테이블에서 가져올 컬럼 (예: inspection_item) + labelField?: string; // 표시 라벨 컬럼 (없으면 sourceField 사용) + distinct?: boolean; // 중복 제거 (기본: true) + + // 행 선택 모드: 이 컬럼 선택 시 같은 소스 행의 다른 컬럼들도 자동 채움 + // 활성화하면 이 컬럼이 "대표 컬럼"이 되어 선택 시 연관 컬럼들이 자동으로 채워짐 + rowSelectionMode?: { + enabled: boolean; + // 자동 채움할 컬럼 매핑 (소스 컬럼 → 타겟 필드) + // 예: [{ sourceColumn: "inspection_criteria", targetField: "inspection_standard" }] + autoFillColumns?: { + sourceColumn: string; // 소스 테이블의 컬럼 + targetField: string; // 현재 테이블의 필드 + }[]; + // 소스 테이블의 ID 컬럼 (참조 ID 저장용) + sourceIdColumn?: string; // 예: "id" + targetIdField?: string; // 예: "inspection_standard_id" + }; + }; + // 값 매핑 (핵심 기능) - 고급 설정용 valueMapping?: ValueMappingConfig; @@ -642,6 +679,10 @@ export interface SubTableSaveConfig { // 저장 전 기존 데이터 삭제 deleteExistingBefore?: boolean; deleteOnlySubItems?: boolean; // 메인 항목은 유지하고 서브만 삭제 + + // 수정 모드에서 서브 테이블 데이터 로드 + loadOnEdit?: boolean; // 수정 시 서브 테이블 데이터 로드 여부 + loadOnlySubItems?: boolean; // 서브 항목만 로드 (메인 항목 제외) }; } From 00376202fdbc699f6e7ee5078b1559ec9da7cd7b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 29 Dec 2025 09:06:07 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat(universal-form-modal):=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94,=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20Select=20=EC=98=B5=EC=85=98,=20=EC=84=9C=EB=B8=8C?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=88=98=EC=A0=95=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조건부 테이블: 체크박스/탭으로 조건 선택 시 다른 테이블 데이터 관리 - 동적 Select 옵션: 소스 테이블에서 드롭다운 옵션 동적 로드 - 행 선택 모드: Select 값 변경 시 같은 소스 행의 연관 컬럼 자동 채움 - 수정 모드 서브 테이블 로드: loadOnEdit 옵션으로 반복 섹션 데이터 자동 로드 - SplitPanelLayout2 메인 테이블 병합: 서브 테이블 수정 시 메인 데이터 함께 조회 - 연결 필드 그룹 표시 형식: subDisplayColumn 추가로 메인/서브 컬럼 분리 설정 - UX 개선: 컬럼 선택 UI를 검색 가능한 Combobox로 전환 - saveMainAsFirst 로직 개선: items 없어도 메인 데이터 저장 가능 --- .../ActionButtonConfigModal.tsx | 1 + .../split-panel-layout2/ColumnConfigModal.tsx | 1 + .../components/split-panel-layout2/README.md | 1 + .../SplitPanelLayout2Renderer.tsx | 1 + .../components/SearchableColumnSelect.tsx | 1 + .../components/SortableColumnItem.tsx | 1 + .../modals/SectionLayoutModal.tsx | 1 + .../modals/TableSectionSettingsModal.tsx | 32 +++++++++++++++++-- 8 files changed, 36 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx b/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx index aeff27c2..5efd59b8 100644 --- a/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx @@ -672,3 +672,4 @@ export const ActionButtonConfigModal: React.FC = ( export default ActionButtonConfigModal; + diff --git a/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx b/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx index 89866651..ae6c4093 100644 --- a/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx @@ -803,3 +803,4 @@ export const ColumnConfigModal: React.FC = ({ export default ColumnConfigModal; + diff --git a/frontend/lib/registry/components/split-panel-layout2/README.md b/frontend/lib/registry/components/split-panel-layout2/README.md index 26d2a669..aafdc38d 100644 --- a/frontend/lib/registry/components/split-panel-layout2/README.md +++ b/frontend/lib/registry/components/split-panel-layout2/README.md @@ -102,3 +102,4 @@ + diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx index 642de9a2..57979869 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx @@ -42,3 +42,4 @@ SplitPanelLayout2Renderer.registerSelf(); + diff --git a/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx b/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx index 24602860..da160f3e 100644 --- a/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx @@ -161,3 +161,4 @@ export const SearchableColumnSelect: React.FC = ({ export default SearchableColumnSelect; + diff --git a/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx b/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx index 8e3cef91..4a188983 100644 --- a/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx @@ -116,3 +116,4 @@ export const SortableColumnItem: React.FC = ({ export default SortableColumnItem; + 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 b47c2424..0031e5d0 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx @@ -1125,3 +1125,4 @@ export function SectionLayoutModal({ } + diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx index 535be447..f8b77aba 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -1538,12 +1538,12 @@ function ColumnSettingItem({

{sourceTableColumns.length > 0 ? (