diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 04fa1add..7c84898b 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1973,15 +1973,21 @@ export async function multiTableSave( for (const subTableConfig of subTables || []) { const { tableName, linkColumn, items, options } = subTableConfig; - if (!tableName || !items || items.length === 0) { - logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`); + // saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함 + const hasSaveMainAsFirst = options?.saveMainAsFirst && + options?.mainFieldMappings && + options.mainFieldMappings.length > 0; + + if (!tableName || (!items?.length && !hasSaveMainAsFirst)) { + logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`); continue; } logger.info(`서브 테이블 ${tableName} 저장 시작:`, { - itemsCount: items.length, + itemsCount: items?.length || 0, linkColumn, options, + hasSaveMainAsFirst, }); // 기존 데이터 삭제 옵션 @@ -1999,7 +2005,15 @@ export async function multiTableSave( } // 메인 데이터도 서브 테이블에 저장 (옵션) - if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) { + // mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지) + logger.info(`saveMainAsFirst 옵션 확인:`, { + saveMainAsFirst: options?.saveMainAsFirst, + mainFieldMappings: options?.mainFieldMappings, + mainFieldMappingsLength: options?.mainFieldMappings?.length, + linkColumn, + mainDataKeys: Object.keys(mainData), + }); + if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) { const mainSubItem: Record = { [linkColumn.subColumn]: savedPkValue, }; diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 075a8229..a163f30c 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -2201,15 +2201,20 @@ export class MenuCopyService { "system", ]); - await client.query( + const result = await client.query( `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, is_active, created_by - ) VALUES ${assignmentValues}`, + ) VALUES ${assignmentValues} + ON CONFLICT (screen_id, menu_objid, company_code) DO NOTHING`, assignmentParams ); - } - logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`); + logger.info( + `✅ 화면-메뉴 할당 완료: ${result.rowCount}개 삽입 (${validAssignments.length - (result.rowCount || 0)}개 중복 무시)` + ); + } else { + logger.info(`📭 화면-메뉴 할당할 항목 없음`); + } } /** 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/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 1969f562..58149088 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -996,14 +996,6 @@ export const EditModal: React.FC = ({ className }) => { screenId: modalState.screenId, // 화면 ID 추가 }; - // 🔍 디버깅: enrichedFormData 확인 - console.log("🔑 [EditModal] enrichedFormData 생성:", { - "screenData.screenInfo": screenData.screenInfo, - "screenData.screenInfo?.tableName": screenData.screenInfo?.tableName, - "enrichedFormData.tableName": enrichedFormData.tableName, - "enrichedFormData.id": enrichedFormData.id, - }); - return ( = groupedData: props.groupedData, // ✅ 언더스코어 제거하여 직접 전달 _groupedData: props.groupedData, // 하위 호환성 유지 // 🆕 UniversalFormModal용 initialData 전달 - // originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨) - _initialData: originalData || formData, + // originalData가 비어있지 않으면 originalData 사용, 아니면 formData 사용 + // 생성 모드에서는 originalData가 빈 객체이므로 formData를 사용해야 함 + _initialData: (originalData && Object.keys(originalData).length > 0) ? originalData : formData, _originalData: originalData, // 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용) parentTabId: props.parentTabId, 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/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx index 2e1cf659..f22eb497 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx @@ -1636,7 +1636,7 @@ export function ModalRepeaterTableConfigPanel({ - {(localConfig.columns || []).map((col) => ( + {(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => ( {col.label} ({col.field}) @@ -1900,7 +1900,7 @@ export function ModalRepeaterTableConfigPanel({ - {(localConfig.columns || []).map((col) => ( + {(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => ( {col.label} @@ -2056,7 +2056,7 @@ export function ModalRepeaterTableConfigPanel({ - {(localConfig.columns || []).map((col) => ( + {(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => ( {col.label} ({col.field}) @@ -2303,7 +2303,7 @@ export function ModalRepeaterTableConfigPanel({ - {(localConfig.columns || []).map((col) => ( + {(localConfig.columns || []).filter((col) => col.field && col.field !== "").map((col) => ( {col.label} diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 88da4aef..70b15e7d 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -481,7 +481,7 @@ export function RepeaterTable({ - {column.selectOptions?.map((option) => ( + {column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => ( {option.label} diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx index 6303cdee..add34d5f 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx @@ -561,7 +561,7 @@ export function SimpleRepeaterTableComponent({ - {column.selectOptions?.map((option) => ( + {column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => ( {option.label} diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx index 41e70e08..4ef47e39 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx @@ -1539,7 +1539,7 @@ export function SimpleRepeaterTableConfigPanel({ - {(localConfig.columns || []).filter(c => c.type === "number").map((col) => ( + {(localConfig.columns || []).filter(c => c.type === "number" && c.field && c.field !== "").map((col) => ( {col.label} 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..5efd59b8 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx @@ -0,0 +1,675 @@ +"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..ae6c4093 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/ColumnConfigModal.tsx @@ -0,0 +1,806 @@ +"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/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/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 679a64c9..6e38e86e 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 { @@ -85,6 +86,178 @@ export const SplitPanelLayout2Component: React.FC(null); const [isBulkDelete, setIsBulkDelete] = useState(false); + const [deleteTargetPanel, setDeleteTargetPanel] = useState<"left" | "right">("right"); + + // 탭 상태 (좌측/우측 각각) + 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 () => { @@ -115,6 +288,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 +371,7 @@ export const SplitPanelLayout2Component: React.FC { return config.rightPanel?.primaryKeyColumn || "id"; }, [config.rightPanel?.primaryKeyColumn]); + // 기본키 컬럼명 가져오기 (좌측 패널) + const getLeftPrimaryKeyColumn = useCallback(() => { + return config.leftPanel?.primaryKeyColumn || config.leftPanel?.hierarchyConfig?.idColumn || "id"; + }, [config.leftPanel?.primaryKeyColumn, config.leftPanel?.hierarchyConfig?.idColumn]); + // 우측 패널 수정 버튼 클릭 const handleEditItem = useCallback( - (item: any) => { + async (item: any) => { // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; @@ -435,6 +684,67 @@ export const SplitPanelLayout2Component: React.FC { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData); + }, + [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, selectedLeftItem, loadRightData], + ); + + // 좌측 패널 수정 버튼 클릭 + const handleLeftEditItem = useCallback( + (item: any) => { + // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) + const modalScreenId = config.leftPanel?.editModalScreenId || config.leftPanel?.addModalScreenId; + + if (!modalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + // EditModal 열기 이벤트 발생 (수정 모드) const event = new CustomEvent("openEditModal", { detail: { @@ -444,22 +754,29 @@ export const SplitPanelLayout2Component: React.FC { - if (selectedLeftItem) { - loadRightData(selectedLeftItem); - } + loadLeftData(); }, }, }); window.dispatchEvent(event); - console.log("[SplitPanelLayout2] 수정 모달 열기:", item); + console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", item); }, - [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData], + [config.leftPanel?.editModalScreenId, config.leftPanel?.addModalScreenId, loadLeftData], ); // 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시) const handleDeleteClick = useCallback((item: any) => { setItemToDelete(item); setIsBulkDelete(false); + setDeleteTargetPanel("right"); + setDeleteDialogOpen(true); + }, []); + + // 좌측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시) + const handleLeftDeleteClick = useCallback((item: any) => { + setItemToDelete(item); + setIsBulkDelete(false); + setDeleteTargetPanel("left"); setDeleteDialogOpen(true); }, []); @@ -470,41 +787,54 @@ export const SplitPanelLayout2Component: React.FC { - if (!config.rightPanel?.tableName) { + // 대상 패널에 따라 테이블명과 기본키 컬럼 결정 + const tableName = deleteTargetPanel === "left" + ? config.leftPanel?.tableName + : config.rightPanel?.tableName; + const pkColumn = deleteTargetPanel === "left" + ? getLeftPrimaryKeyColumn() + : getPrimaryKeyColumn(); + + if (!tableName) { toast.error("테이블 설정이 없습니다."); return; } - const pkColumn = getPrimaryKeyColumn(); - try { if (isBulkDelete) { - // 일괄 삭제 - const idsToDelete = Array.from(selectedRightItems); - console.log("[SplitPanelLayout2] 일괄 삭제:", idsToDelete); + // 일괄 삭제 - 선택된 항목들의 데이터를 body로 전달 + const itemsToDelete = rightData.filter((item) => selectedRightItems.has(item[pkColumn] as string | number)); + console.log("[SplitPanelLayout2] 일괄 삭제:", itemsToDelete); - for (const id of idsToDelete) { - await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${id}`); - } + // 백엔드 API는 body로 삭제할 데이터를 받음 + await apiClient.delete(`/table-management/tables/${tableName}/delete`, { + data: itemsToDelete, + }); - toast.success(`${idsToDelete.length}개 항목이 삭제되었습니다.`); - setSelectedRightItems(new Set()); + toast.success(`${itemsToDelete.length}개 항목이 삭제되었습니다.`); + setSelectedRightItems(new Set()); } else if (itemToDelete) { - // 단일 삭제 - const itemId = itemToDelete[pkColumn]; - console.log("[SplitPanelLayout2] 단일 삭제:", itemId); + // 단일 삭제 - 해당 항목 데이터를 배열로 감싸서 body로 전달 (백엔드가 배열을 기대함) + console.log(`[SplitPanelLayout2] ${deleteTargetPanel === "left" ? "좌측" : "우측"} 단일 삭제:`, itemToDelete); - await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${itemId}`); + await apiClient.delete(`/table-management/tables/${tableName}/delete`, { + data: [itemToDelete], + }); toast.success("항목이 삭제되었습니다."); } // 데이터 새로고침 - if (selectedLeftItem) { + if (deleteTargetPanel === "left") { + loadLeftData(); + setSelectedLeftItem(null); // 좌측 선택 초기화 + setRightData([]); // 우측 데이터도 초기화 + } else if (selectedLeftItem) { loadRightData(selectedLeftItem); } } catch (error: any) { @@ -516,13 +846,18 @@ export const SplitPanelLayout2Component: React.FC d[pkColumn] === selectedId); if (item) { - handleEditItem(item); + // 액션 버튼에 모달 화면이 설정되어 있으면 해당 화면 사용 + const modalScreenId = btn.modalScreenId || config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; + + if (!modalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + const event = new CustomEvent("openEditModal", { + detail: { + screenId: modalScreenId, + title: btn.label || "수정", + modalSize: "lg", + editData: item, + isCreateMode: false, + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(event); } } else if (selectedRightItems.size > 1) { toast.error("수정할 항목을 1개만 선택해주세요."); @@ -614,6 +971,57 @@ export const SplitPanelLayout2Component: React.FC { + switch (btn.action) { + case "add": + // 액션 버튼에 설정된 modalScreenId 우선 사용 + const modalScreenId = btn.modalScreenId || config.leftPanel?.addModalScreenId; + + if (!modalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId: modalScreenId, + title: btn.label || "추가", + modalSize: "lg", + editData: {}, + isCreateMode: true, + onSave: () => { + loadLeftData(); + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId); + break; + + case "edit": + // 좌측 패널에서 수정 (필요시 구현) + console.log("[SplitPanelLayout2] 좌측 수정 액션:", btn); + break; + + case "delete": + // 좌측 패널에서 삭제 (필요시 구현) + console.log("[SplitPanelLayout2] 좌측 삭제 액션:", btn); + break; + + case "custom": + console.log("[SplitPanelLayout2] 좌측 커스텀 액션:", btn); + break; + + default: + break; + } + }, + [config.leftPanel?.addModalScreenId, loadLeftData], + ); + // 컬럼 라벨 로드 const loadColumnLabels = useCallback( async (tableName: string, setLabels: (labels: Record) => void) => { @@ -700,16 +1108,20 @@ 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,37 +1143,41 @@ 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( (checked: boolean) => { if (checked) { const pkColumn = getPrimaryKeyColumn(); - const allIds = new Set(filteredRightData.map((item) => item[pkColumn])); + const allIds = new Set(filteredRightData.map((item) => item[pkColumn] as string | number)); setSelectedRightItems(allIds); } else { - setSelectedRightItems(new Set()); + setSelectedRightItems(new Set()); } }, [filteredRightData, getPrimaryKeyColumn], @@ -835,7 +1251,7 @@ export const SplitPanelLayout2Component: React.FC { // col.name이 "테이블명.컬럼명" 형식인 경우 처리 @@ -843,28 +1259,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 +1423,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 +1465,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( | @@ -1008,6 +1510,27 @@ export const SplitPanelLayout2Component: React.FC )}
+ + {/* 좌측 패널 수정/삭제 버튼 */} + {(config.leftPanel?.showEditButton || config.leftPanel?.showDeleteButton) && ( +
e.stopPropagation()}> + {config.leftPanel?.showEditButton && ( + + )} + {config.leftPanel?.showDeleteButton && ( + + )} +
+ )}
{/* 자식 항목 */} @@ -1020,6 +1543,90 @@ export const SplitPanelLayout2Component: React.FC { + 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 || []; @@ -1157,8 +1764,8 @@ export const SplitPanelLayout2Component: React.FC 0 && filteredRightData.every((item) => selectedRightItems.has(item[pkColumn])); - const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn])); + filteredRightData.length > 0 && filteredRightData.every((item) => selectedRightItems.has(item[pkColumn] as string | number)); + const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn] as string | number)); return (
@@ -1204,7 +1811,7 @@ export const SplitPanelLayout2Component: React.FC ) : ( filteredRightData.map((item, index) => { - const itemId = item[pkColumn]; + const itemId = item[pkColumn] as string | number; return ( {showCheckbox && ( @@ -1285,6 +1892,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 +2129,32 @@ 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 +2171,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 +2247,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 +2277,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..8ff83b6f 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,236 @@ export const SplitPanelLayout2ConfigPanel: React.FC
- {/* 표시 컬럼 */} + {/* 표시 모드 설정 */}
-
- -
+ + {/* 컬럼 설정 버튼 */} +
+
+
+ +

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

+
+
-
- {(config.leftPanel?.displayColumns || []).map((col, index) => ( -
-
- 컬럼 {index + 1} - -
+
+ + {/* 액션 버튼 설정 */} +
+
+
+ +

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

+
+ +
+
+ + {/* 개별 수정/삭제 버튼 (좌측) */} +
+ +

각 행에 표시되는 수정/삭제 버튼

+
+
+ + updateConfig("leftPanel.showEditButton", checked)} + /> +
+
+ + updateConfig("leftPanel.showDeleteButton", checked)} + /> +
+ {(config.leftPanel?.showEditButton || config.leftPanel?.showDeleteButton) && ( +
+ updateDisplayColumn("left", index, "name", value)} - placeholder="컬럼 선택" + value={config.leftPanel?.primaryKeyColumn || ""} + onValueChange={(value) => updateConfig("leftPanel.primaryKeyColumn", value)} + placeholder="기본키 컬럼 선택 (기본: id)" /> -
- - 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 && ( -
- 검색할 컬럼을 추가하세요 + {/* 탭 설정 (좌측) */} +
+
+ + { + 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)} + /> +
+ )}
-
- )} - -
- - updateConfig("leftPanel.showAddButton", checked)} - /> + )}
- {config.leftPanel?.showAddButton && ( - <> -
- - updateConfig("leftPanel.addButtonLabel", e.target.value)} - placeholder="추가" - className="h-9 text-sm" - /> + {/* 추가 조인 테이블 설정 (좌측) */} +
+
+ + +
+

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

+ {(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), + ); + }} + /> + ))}
-
- - updateConfig("leftPanel.addModalScreenId", value)} - placeholder="모달 화면 선택" - open={leftModalOpen} - onOpenChange={setLeftModalOpen} - /> -
- - )} + )} +
@@ -981,6 +1191,23 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {/* 표시 모드 설정 */} +
+ + +
+ {/* 추가 조인 테이블 설정 */}
@@ -1041,572 +1268,219 @@ 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)} - />
- {/* 수정/삭제 버튼 */} + {/* 액션 버튼 설정 */} +
+
+
+ +

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

+
+ +
+
+ + {/* 개별 수정/삭제 버튼 */}
-
+

각 행에 표시되는 수정/삭제 버튼

+
- + 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?.mainTableForEdit && ( +
+
+ + 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" + /> +
+
)}
- ))} - {(config.rightPanel?.actionButtons || []).length === 0 && ( -
- 액션 버튼을 추가하세요 (선택사항) -
)}
+ + {/* 탭 설정 (우측) */} +
+
+ + { + 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)} + /> +
+
+
@@ -1723,64 +1597,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 +1805,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/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 new file mode 100644 index 00000000..da160f3e --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/components/SearchableColumnSelect.tsx @@ -0,0 +1,164 @@ +"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..4a188983 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/components/SortableColumnItem.tsx @@ -0,0 +1,119 @@ +"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..ae8c71ed 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,27 @@ export interface LeftPanelConfig { searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성) searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수) showSearch?: boolean; // 검색 표시 여부 - showAddButton?: boolean; // 추가 버튼 표시 - addButtonLabel?: string; // 추가 버튼 라벨 - addModalScreenId?: number; // 추가 모달 화면 ID + showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성) + addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성) + addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성) + showEditButton?: boolean; // 수정 버튼 표시 + showDeleteButton?: boolean; // 삭제 버튼 표시 + editModalScreenId?: 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 +211,22 @@ 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; } /** @@ -157,9 +278,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/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 224459f0..ba03d2b9 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,413 @@ 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; + if (!tableConfig.source?.tableName) return; + + // 조건 변경 시 캐시 리셋하고 즉시 다시 로드 + sourceDataLoadedRef.current = false; + setSourceDataCache([]); + + // 즉시 데이터 다시 로드 (기존 useEffect에 의존하지 않고 직접 호출) + const loadSourceData = async () => { + try { + const filterCondition: Record = {}; + 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(); + }, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled, conditionalConfig?.sourceFilter?.filterColumn, tableConfig.source?.tableName]); + + // 컬럼별 동적 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 handleDataChange = useCallback( + (newData: any[]) => { + let processedData = newData; + + // 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리 + const batchApplyColumns = tableConfig.columns.filter( + (col) => col.type === "date" && col.batchApply === true + ); + + for (const dateCol of batchApplyColumns) { + // 이미 일괄 적용된 컬럼은 건너뜀 + if (batchAppliedFields.has(dateCol.field)) continue; + + // 해당 컬럼에 값이 있는 행과 없는 행 분류 + const itemsWithDate = processedData.filter((item) => item[dateCol.field]); + const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]); + + // 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 + if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) { + const selectedDate = itemsWithDate[0][dateCol.field]; + + // 모든 행에 동일한 날짜 적용 + processedData = processedData.map((item) => ({ + ...item, + [dateCol.field]: selectedDate, + })); + + // 플래그 활성화 (이후 개별 수정 가능) + setBatchAppliedFields((prev) => new Set([...prev, dateCol.field])); + } + } + + setTableData(processedData); + onTableDataChange(processedData); + }, + [onTableDataChange, tableConfig.columns, batchAppliedFields] + ); + + // 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움 + const 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] + ); + + // 참조 컬럼 값 조회 함수 (saveToTarget: false인 컬럼에 대해 소스 테이블 조회) + const loadReferenceColumnValues = useCallback(async (data: any[]) => { + // saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기 + const referenceColumns = (tableConfig.columns || []).filter( + (col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay + ); + + if (referenceColumns.length === 0) return; + + const sourceTableName = tableConfig.source?.tableName; + if (!sourceTableName) { + console.warn("[TableSectionRenderer] 참조 조회를 위한 소스 테이블이 설정되지 않았습니다."); + return; + } + + // 참조 ID들 수집 (중복 제거) + const referenceIdSet = new Set(); + + for (const col of referenceColumns) { + const refDisplay = col.saveConfig!.referenceDisplay!; + + for (const row of data) { + const refId = row[refDisplay.referenceIdField]; + if (refId !== undefined && refId !== null && refId !== "") { + referenceIdSet.add(String(refId)); + } + } + } + + if (referenceIdSet.size === 0) return; + + try { + // 소스 테이블에서 참조 ID에 해당하는 데이터 조회 + const response = await apiClient.post( + `/table-management/tables/${sourceTableName}/data`, + { + search: { id: Array.from(referenceIdSet) }, // ID 배열로 조회 + size: 1000, + page: 1, + } + ); + + if (!response.data?.success || !response.data?.data?.data) { + console.warn("[TableSectionRenderer] 참조 데이터 조회 실패"); + return; + } + + const sourceData: any[] = response.data.data.data; + + // ID를 키로 하는 맵 생성 + const sourceDataMap: Record = {}; + for (const sourceRow of sourceData) { + sourceDataMap[String(sourceRow.id)] = sourceRow; + } + + // 각 행에 참조 컬럼 값 채우기 + const updatedData = data.map((row) => { + const newRow = { ...row }; + + for (const col of referenceColumns) { + const refDisplay = col.saveConfig!.referenceDisplay!; + const refId = row[refDisplay.referenceIdField]; + + if (refId !== undefined && refId !== null && refId !== "") { + const sourceRow = sourceDataMap[String(refId)]; + if (sourceRow) { + newRow[col.field] = sourceRow[refDisplay.sourceColumn]; + } + } + } + + return newRow; + }); + + console.log("[TableSectionRenderer] 참조 컬럼 값 조회 완료:", { + referenceColumns: referenceColumns.map((c) => c.field), + updatedRowCount: updatedData.length, + }); + + setTableData(updatedData); + } catch (error) { + console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error); + } + }, [tableConfig.columns, tableConfig.source?.tableName]); // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) useEffect(() => { @@ -357,11 +788,25 @@ export function TableSectionRenderer({ }); setTableData(initialData); initialDataLoadedRef.current = true; + + // 참조 컬럼 값 조회 (saveToTarget: false인 컬럼) + loadReferenceColumnValues(initialData); } - }, [sectionId, formData]); + }, [sectionId, formData, loadReferenceColumnValues]); - // 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); @@ -405,54 +850,47 @@ export function TableSectionRenderer({ [calculateRow] ); - // 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함) - const handleDataChange = useCallback( - (newData: any[]) => { - let processedData = newData; + // 행 변경 핸들러 (동적 Select 행 선택 모드 지원) + const handleRowChange = useCallback( + (index: number, newRow: any, conditionValue?: string) => { + const oldRow = conditionValue && isConditionalMode + ? (conditionalTableData[conditionValue]?.[index] || {}) + : (tableData[index] || {}); - // 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리 - const batchApplyColumns = tableConfig.columns.filter( - (col) => col.type === "date" && col.batchApply === true - ); - - for (const dateCol of batchApplyColumns) { - // 이미 일괄 적용된 컬럼은 건너뜀 - if (batchAppliedFields.has(dateCol.field)) continue; - - // 해당 컬럼에 값이 있는 행과 없는 행 분류 - const itemsWithDate = processedData.filter((item) => item[dateCol.field]); - const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]); - - // 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 - if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) { - const selectedDate = itemsWithDate[0][dateCol.field]; - - // 모든 행에 동일한 날짜 적용 - processedData = processedData.map((item) => ({ - ...item, - [dateCol.field]: selectedDate, - })); - - // 플래그 활성화 (이후 개별 수정 가능) - setBatchAppliedFields((prev) => new Set([...prev, dateCol.field])); + // 변경된 필드 찾기 + const changedFields: string[] = []; + for (const key of Object.keys(newRow)) { + if (oldRow[key] !== newRow[key]) { + changedFields.push(key); } } - setTableData(processedData); - onTableDataChange(processedData); - }, - [onTableDataChange, tableConfig.columns, batchAppliedFields] - ); - - // 행 변경 핸들러 - const handleRowChange = useCallback( - (index: number, newRow: any) => { + // 동적 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] ); // 행 삭제 핸들러 @@ -621,6 +1059,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; @@ -770,19 +1216,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(() => { @@ -798,6 +1260,554 @@ 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; + + // 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용) + // 빈 value를 가진 옵션은 제외 (Select.Item은 빈 문자열 value를 허용하지 않음) + const effectiveOptions = (conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0 + ? dynamicOptions + : conditionalConfig.options || []).filter(opt => opt.value && opt.value.trim() !== ""); + + // 로딩 중이면 로딩 표시 + 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 (
{/* 추가 버튼 영역 */} @@ -840,10 +1850,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 bc217299..b4921a51 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -102,11 +102,13 @@ const CascadingSelectField: React.FC = ({ : config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
) : ( - options.map((option) => ( - - {option.label} - - )) + options + .filter((option) => option.value && option.value !== "") + .map((option) => ( + + {option.label} + + )) )} @@ -212,15 +214,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 +255,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 +488,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); @@ -997,6 +1083,14 @@ export function UniversalFormModalComponent({ // 공통 필드 병합 + 개별 품목 데이터 const itemToSave = { ...commonFieldsData, ...item }; + // saveToTarget: false인 컬럼은 저장에서 제외 + const columns = section.tableConfig?.columns || []; + for (const col of columns) { + if (col.saveConfig?.saveToTarget === false && col.field in itemToSave) { + delete itemToSave[col.field]; + } + } + // 메인 레코드와 연결이 필요한 경우 if (mainRecordId && config.saveConfig.primaryKeyColumn) { itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId; @@ -1142,6 +1236,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 +1293,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); + } } } @@ -1223,12 +1337,12 @@ export function UniversalFormModalComponent({ if (subTableConfig.options?.saveMainAsFirst) { mainFieldMappings = []; - // 메인 섹션(비반복)의 필드들을 서브 테이블에 매핑 - // 서브 테이블의 fieldMappings에서 targetColumn을 찾아서 매핑 + // fieldMappings에 정의된 targetColumn만 매핑 (서브 테이블에 존재하는 컬럼만) for (const mapping of subTableConfig.fieldMappings || []) { if (mapping.targetColumn) { - // 메인 데이터에서 동일한 컬럼명이 있으면 매핑 - if (mainData[mapping.targetColumn] !== undefined) { + // formData에서 동일한 컬럼명이 있으면 매핑 (receiveFromParent 필드 포함) + const formValue = formData[mapping.targetColumn]; + if (formValue !== undefined && formValue !== null && formValue !== "") { mainFieldMappings.push({ formField: mapping.targetColumn, targetColumn: mapping.targetColumn, @@ -1239,11 +1353,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) { - 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, + }); + } } }); } @@ -1256,15 +1373,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 호출 @@ -1374,6 +1494,11 @@ export function UniversalFormModalComponent({ if (onSave) { onSave({ ...formData, _saveCompleted: true }); } + + // 저장 완료 후 모달 닫기 이벤트 발생 + if (config.saveConfig.afterSave?.closeModal !== false) { + window.dispatchEvent(new CustomEvent("closeEditModal")); + } } catch (error: any) { console.error("저장 실패:", error); // axios 에러의 경우 서버 응답 메시지 추출 @@ -1485,16 +1610,39 @@ 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) { + let result = lfg.customDisplayFormat; + // {컬럼명} 패턴을 찾아서 실제 값으로 치환 + const matches = result.match(/\{([^}]+)\}/g); + if (matches) { + matches.forEach((match) => { + const columnName = match.slice(1, -1); // { } 제거 + const columnValue = row[columnName]; + result = result.replace(match, columnValue !== undefined && columnValue !== null ? String(columnValue) : ""); + }); + } + return result; + } + return String(mainDisplayVal); case "name_only": default: - return String(displayVal); + return String(mainDisplayVal); } }; @@ -1542,11 +1690,13 @@ export function UniversalFormModalComponent({ {sourceData.length > 0 ? ( - sourceData.map((row, index) => ( - - {getDisplayText(row)} - - )) + sourceData + .filter((row) => row[valueColumn] !== null && row[valueColumn] !== undefined && String(row[valueColumn]) !== "") + .map((row, index) => ( + + {getDisplayText(row)} + + )) ) : ( {cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"} @@ -2207,11 +2357,13 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa - {options.map((option) => ( - - {option.label} - - ))} + {options + .filter((option) => option.value && option.value !== "") + .map((option) => ( + + {option.label} + + ))} ); diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 4ef28d6f..27af68f1 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"; @@ -47,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 }[]>([]); @@ -71,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) { @@ -84,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 || "", })), ); } @@ -334,6 +527,21 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor 모달 창의 크기를 선택하세요
+ {/* 저장 버튼 표시 설정 */} +
+
+ updateModalConfig({ showSaveButton: checked === true })} + /> + +
+ 체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다 +
+
@@ -520,13 +728,13 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {/* 테이블 컬럼 목록 (테이블 타입만) */} {section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
- {section.tableConfig.columns.slice(0, 4).map((col) => ( + {section.tableConfig.columns.slice(0, 4).map((col, idx) => ( - {col.label} + {col.label || col.field || `컬럼 ${idx + 1}`} ))} {section.tableConfig.columns.length > 4 && ( @@ -604,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 + })) || []} /> )} @@ -650,6 +864,9 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor tableColumns={tableColumns} numberingRules={numberingRules} onLoadTableColumns={loadTableColumns} + availableParentFields={availableParentFields} + targetTableName={config.saveConfig?.tableName} + targetTableColumns={config.saveConfig?.tableName ? tableColumns[config.saveConfig.tableName] || [] : []} /> )} @@ -690,6 +907,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/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 d53d6e00..8882d9bc 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, @@ -36,6 +45,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 +65,11 @@ interface FieldDetailSettingsModalProps { tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] }; numberingRules: { id: string; name: string }[]; onLoadTableColumns: (tableName: string) => void; + // 부모 화면에서 전달 가능한 필드 목록 (선택사항) + availableParentFields?: AvailableParentField[]; + // 저장 테이블 정보 (타겟 컬럼 선택용) + targetTableName?: string; + targetTableColumns?: { name: string; type: string; label: string }[]; } export function FieldDetailSettingsModal({ @@ -56,13 +81,26 @@ export function FieldDetailSettingsModal({ tableColumns, 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>({}); + const [displayColumnOpen, setDisplayColumnOpen] = useState(false); + const [subDisplayColumnOpen, setSubDisplayColumnOpen] = useState(false); // 서브 표시 컬럼 Popover 상태 + const [sourceColumnOpenMap, setSourceColumnOpenMap] = useState>({}); // open이 변경될 때마다 필드 데이터 동기화 useEffect(() => { @@ -70,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(() => { @@ -293,6 +341,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으로 고급 설정 */} @@ -472,12 +563,12 @@ export function FieldDetailSettingsModal({ {selectTableColumns.length > 0 ? ( { + value={localField.linkedFieldGroup?.displayFormat || "name_only"} + onValueChange={(value) => updateField({ linkedFieldGroup: { ...localField.linkedFieldGroup, - sourceTable: value, + displayFormat: value as "name_only" | "code_name" | "name_code", + // name_only 선택 시 서브 컬럼 초기화 + ...(value === "name_only" ? { subDisplayColumn: undefined } : {}), }, - }); - onLoadTableColumns(value); - }} + }) + } > - + - {tables.map((t) => ( - - {t.label || t.name} + {LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => ( + +
+ {opt.label} + + {opt.value === "name_only" && "메인 컬럼만 표시"} + {opt.value === "code_name" && "서브 - 메인 형식"} + {opt.value === "name_code" && "메인 (서브) 형식"} + +
))}
- 값을 가져올 소스 테이블 (예: 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}

+ ); + })()} +
+ )} @@ -729,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}) + + ))} + + + + + ) : ( - - 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" + /> + )}
))} @@ -909,3 +1316,4 @@ export function FieldDetailSettingsModal({ + 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..11b3a8ae 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,13 +52,39 @@ 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>({}); + + // 컬럼 검색 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) => { @@ -217,8 +245,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 +255,7 @@ export function SaveSettingsModal({ columnName: field.columnName, label: field.label, sectionTitle: section.title, + sectionId: section.id, }); }); } @@ -375,24 +404,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} + )} +
+
+ ))} +
+
+
+
+
폼 데이터를 저장할 테이블을 선택하세요
@@ -425,72 +498,157 @@ 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)
{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} + )} +
+
+ ))} +
+
+
+
+
) : ( - -
+
+
서브 테이블 {subIndex + 1}: {subTable.tableName || "(미설정)"} @@ -560,40 +718,86 @@ export function SaveSettingsModal({ ({subTable.fieldMappings?.length || 0}개 매핑)
- -
- + + +
- + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {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} + )} +
+
+ ))} +
+
+
+
+ 반복 데이터를 저장할 서브 테이블
@@ -633,25 +837,70 @@ export function SaveSettingsModal({
{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} + )} +
+
+ ))} +
+
+
+
+ ) : ( - {allFields.map((field) => ( - + {allFields.map((field, fieldIndex) => ( + {field.label} ({field.sectionTitle}) ))} @@ -769,23 +1063,68 @@ export function SaveSettingsModal({
{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/SectionLayoutModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx index 4a90a777..0031e5d0 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="컬럼명" + /> + )} { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + sourceField: value, + }, + }); + }} + > + + + + + {sourceTableColumns.map((col) => ( + + {col.column_name} {col.comment && `(${col.comment})`} + + ))} + + + ) : ( + { + 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 5a845db0..ebd16c44 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,244 @@ 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) + label: string; // 표시 라벨 + sourceComponent?: string; // 출처 컴포넌트 + sourceTable?: string; // 출처 테이블명 +} + // 컬럼 설정 아이템 컴포넌트 interface ColumnSettingItemProps { col: TableColumnConfig; @@ -62,6 +304,7 @@ interface ColumnSettingItemProps { sections: { id: string; title: string }[]; // 섹션 목록 formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록 tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용 + availableParentFields?: AvailableParentField[]; // 부모 화면에서 전달 가능한 필드 목록 onLoadTableColumns: (tableName: string) => void; onUpdate: (updates: Partial) => void; onMoveUp: () => void; @@ -82,6 +325,7 @@ function ColumnSettingItem({ sections, formFields, tableConfig, + availableParentFields = [], onLoadTableColumns, onUpdate, onMoveUp, @@ -90,6 +334,7 @@ function ColumnSettingItem({ }: ColumnSettingItemProps) { const [fieldSearchOpen, setFieldSearchOpen] = useState(false); const [sourceFieldSearchOpen, setSourceFieldSearchOpen] = useState(false); + const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false); const [lookupTableOpenMap, setLookupTableOpenMap] = useState>({}); // 조회 옵션 추가 @@ -205,10 +450,13 @@ function ColumnSettingItem({ variant="outline" role="combobox" aria-expanded={fieldSearchOpen} - className="h-8 w-full justify-between text-xs mt-1" + className={cn( + "h-8 w-full justify-between text-xs mt-1", + !col.field && "text-muted-foreground" + )} > - {col.field || "필드 선택..."} + {col.field || "(저장 안 함)"} @@ -221,6 +469,25 @@ function ColumnSettingItem({ 필드를 찾을 수 없습니다. + {/* 선택 안 함 옵션 */} + { + onUpdate({ field: "" }); + setFieldSearchOpen(false); + }} + className="text-xs text-muted-foreground" + > + + (선택 안 함 - 저장하지 않음) + + {/* 실제 컬럼 목록 */} {saveTableColumns.map((column) => ( 필수 +
); } @@ -1119,6 +2004,8 @@ interface TableSectionSettingsModalProps { onLoadCategoryList?: () => void; // 전체 섹션 목록 (다른 섹션 필드 참조용) allSections?: FormSectionConfig[]; + // 부모 화면에서 전달 가능한 필드 목록 + availableParentFields?: AvailableParentField[]; } export function TableSectionSettingsModal({ @@ -1132,6 +2019,7 @@ export function TableSectionSettingsModal({ categoryList = [], onLoadCategoryList, allSections = [], + availableParentFields = [], }: TableSectionSettingsModalProps) { // 로컬 상태 const [title, setTitle] = useState(section.title); @@ -1693,6 +2581,7 @@ export function TableSectionSettingsModal({ sections={otherSections} formFields={otherSectionFields} tableConfig={tableConfig} + availableParentFields={availableParentFields} onLoadTableColumns={onLoadTableColumns} onUpdate={(updates) => updateColumn(index, updates)} onMoveUp={() => moveColumn(index, "up")} @@ -2040,12 +2929,37 @@ export function TableSectionSettingsModal({

UI 설정

+
+ + +
updateUiConfig({ addButtonText: e.target.value })} - placeholder="항목 검색" + placeholder={tableConfig.uiConfig?.addButtonType === "addRow" ? "항목 추가" : "항목 검색"} className="h-8 text-xs mt-1" />
@@ -2056,7 +2970,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" && ( +

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

+ )}
@@ -2073,6 +2991,7 @@ export function TableSectionSettingsModal({ checked={tableConfig.uiConfig?.multiSelect ?? true} onCheckedChange={(checked) => updateUiConfig({ multiSelect: checked })} className="scale-75" + disabled={tableConfig.uiConfig?.addButtonType === "addRow"} /> 다중 선택 허용 @@ -2108,11 +3027,13 @@ export function TableSectionSettingsModal({ 컬럼 설정에서 먼저 컬럼을 추가하세요 ) : ( - (tableConfig.columns || []).map((col) => ( - - {col.label || col.field} - - )) + (tableConfig.columns || []) + .filter((col) => col.field) // 빈 필드명 제외 + .map((col, idx) => ( + + {col.label || col.field} + + )) )} @@ -2134,6 +3055,391 @@ 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 = '입고검사' 조건 적용 +

+
+ )} + + {/* 사용 가이드 */} +
+

사용 가이드

+
+

1. 소스 테이블 필터링 활성화 후:

+
    +
  • 항목 검색: 검색 모달에서 필터링된 데이터만 표시
  • +
  • 빈 행 추가: 드롭다운 옵션이 필터링된 데이터로 제한
  • +
+

2. 컬럼 설정에서 추가 설정:

+
    +
  • 컬럼 타입을 "선택(드롭다운)"으로 변경
  • +
  • "동적 드롭다운 옵션" 섹션이 나타남
  • +
  • 소스 컬럼 선택 → 해당 컬럼 값이 드롭다운 옵션으로 표시
  • +
  • "행 선택 모드" 활성화 시 → 선택한 값의 같은 행 데이터를 다른 컬럼에 자동 채움
  • +
+

3. 예시 (품목검사정보):

+
    +
  • "입고검사" 체크박스 선택 → 테이블 탭 표시
  • +
  • "항목 추가" 클릭 → 빈 행 생성
  • +
  • "검사항목" 드롭다운 → 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 4e25f7d7..1f2015eb 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -80,8 +80,12 @@ export interface FormFieldConfig { linkedFieldGroup?: { enabled?: boolean; // 사용 여부 sourceTable?: string; // 소스 테이블 (예: dept_info) - displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트 - displayFormat?: "name_only" | "code_name" | "name_code"; // 표시 형식 + displayColumn?: string; // 메인 표시 컬럼 (예: item_name) - 드롭다운에 보여줄 메인 텍스트 + subDisplayColumn?: string; // 서브 표시 컬럼 (예: item_number) - 메인과 함께 표시될 서브 텍스트 + displayFormat?: "name_only" | "code_name" | "name_code" | "custom"; // 표시 형식 + // 커스텀 표시 형식 (displayFormat이 "custom"일 때 사용) + // 형식: {컬럼명} 으로 치환됨 (예: "{item_name} ({item_number})" → "철판 (ITEM-001)") + customDisplayFormat?: string; mappings?: LinkedFieldMapping[]; // 저장할 컬럼 매핑 (첫 번째 매핑의 sourceColumn이 드롭다운 값으로 사용됨) }; @@ -253,7 +257,66 @@ export interface TableSectionConfig { modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택") multiSelect?: boolean; // 다중 선택 허용 (기본: true) maxHeight?: string; // 테이블 최대 높이 (기본: "400px") + + // 추가 버튼 타입 + // - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택 + // - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력 + addButtonType?: "search" | "addRow"; }; + + // 7. 조건부 테이블 설정 (고급) + conditionalTable?: ConditionalTableConfig; +} + +/** + * 조건부 테이블 설정 + * 조건(검사유형 등)에 따라 다른 데이터를 표시하고 저장합니다. + * + * 사용 예시: + * - 품목검사정보: 검사유형(입고/공정/출고/재고/최종)별로 검사항목 관리 + * - BOM 관리: 품목유형별 자재 구성 + * - 공정 관리: 공정유형별 작업 항목 + */ +export interface ConditionalTableConfig { + enabled: boolean; + + // 트리거 UI 타입 + // - checkbox: 체크박스로 다중 선택 (선택된 조건들을 탭으로 표시) + // - dropdown: 드롭다운으로 단일 선택 + // - tabs: 모든 옵션을 탭으로 표시 + triggerType: "checkbox" | "dropdown" | "tabs"; + + // 조건 값을 저장할 컬럼 (예: inspection_type) + // 저장 시 각 행에 이 컬럼으로 조건 값이 자동 저장됨 + conditionColumn: string; + + // 조건 옵션 목록 + options: ConditionalTableOption[]; + + // 옵션을 테이블에서 동적으로 로드할 경우 + optionSource?: { + enabled: boolean; + tableName: string; // 예: inspection_type_code + valueColumn: string; // 예: type_code + labelColumn: string; // 예: type_name + filterCondition?: string; // 예: is_active = 'Y' + }; + + // 소스 테이블 필터링 설정 + // 조건 선택 시 소스 테이블(검사기준 등)에서 해당 조건으로 필터링 + sourceFilter?: { + enabled: boolean; + filterColumn: string; // 소스 테이블에서 필터링할 컬럼 (예: inspection_type) + }; +} + +/** + * 조건부 테이블 옵션 + */ +export interface ConditionalTableOption { + id: string; + value: string; // 저장될 값 (예: "입고검사") + label: string; // 표시 라벨 (예: "입고검사") } /** @@ -323,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; @@ -335,6 +422,35 @@ export interface TableColumnConfig { // 날짜 일괄 적용 (type이 "date"일 때만 사용) // 활성화 시 첫 번째 날짜 입력 시 모든 행에 동일한 날짜가 자동 적용됨 batchApply?: boolean; + + // 부모에서 값 받기 (모든 행에 동일한 값 적용) + receiveFromParent?: boolean; // 부모에서 값 받기 활성화 + parentFieldName?: string; // 부모 필드명 (미지정 시 field와 동일) + + // 저장 설정 (컬럼별 저장 여부 및 참조 표시) + saveConfig?: TableColumnSaveConfig; +} + +/** + * 테이블 컬럼 저장 설정 + * - 컬럼별로 저장 여부를 설정하고, 저장하지 않는 컬럼은 참조 ID로 조회하여 표시 + */ +export interface TableColumnSaveConfig { + // 저장 여부 (기본값: true) + // true: 사용자가 입력/선택한 값을 DB에 저장 + // false: 저장하지 않고, 참조 ID로 소스 테이블을 조회하여 표시만 함 + saveToTarget: boolean; + + // 참조 표시 설정 (saveToTarget이 false일 때 사용) + referenceDisplay?: { + // 참조할 ID 컬럼 (같은 테이블 내의 다른 컬럼) + // 예: "inspection_standard_id" + referenceIdField: string; + + // 소스 테이블에서 가져올 컬럼 + // 예: "inspection_item" → 소스 테이블의 inspection_item 값을 표시 + sourceColumn: string; + }; } // ============================================ @@ -588,6 +704,10 @@ export interface SubTableSaveConfig { // 저장 전 기존 데이터 삭제 deleteExistingBefore?: boolean; deleteOnlySubItems?: boolean; // 메인 항목은 유지하고 서브만 삭제 + + // 수정 모드에서 서브 테이블 데이터 로드 + loadOnEdit?: boolean; // 수정 시 서브 테이블 데이터 로드 여부 + loadOnlySubItems?: boolean; // 서브 항목만 로드 (메인 항목 제외) }; } @@ -646,6 +766,7 @@ export interface ModalConfig { showCloseButton?: boolean; // 버튼 설정 + showSaveButton?: boolean; // 저장 버튼 표시 (기본: true) saveButtonText?: string; // 저장 버튼 텍스트 (기본: "저장") cancelButtonText?: string; // 취소 버튼 텍스트 (기본: "취소") showResetButton?: boolean; // 초기화 버튼 표시 @@ -705,6 +826,8 @@ export interface UniversalFormModalComponentProps { export interface UniversalFormModalConfigPanelProps { config: UniversalFormModalConfig; onChange: (config: UniversalFormModalConfig) => void; + // 화면 설계 시 같은 화면의 다른 컴포넌트들 (부모 데이터 필드 추출용) + allComponents?: any[]; } // 필드 타입 옵션 @@ -742,6 +865,7 @@ export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [ { value: "name_only", label: "이름만 (예: 영업부)" }, { value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" }, { value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" }, + { value: "custom", label: "커스텀 형식 (직접 입력)" }, ] as const; // ============================================ @@ -809,3 +933,10 @@ export const LOOKUP_CONDITION_SOURCE_OPTIONS = [ { value: "sectionField", label: "다른 섹션" }, { value: "externalTable", label: "외부 테이블" }, ] as const; + +// 조건부 테이블 트리거 타입 옵션 +export const CONDITIONAL_TABLE_TRIGGER_OPTIONS = [ + { value: "checkbox", label: "체크박스 (다중 선택)" }, + { value: "dropdown", label: "드롭다운 (단일 선택)" }, + { value: "tabs", label: "탭 (전체 표시)" }, +] as const;