From 1a6d78df43ab75f1c545060df84aa2c93405ab03 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 13:30:57 +0900 Subject: [PATCH 1/6] refactor: Improve existing item ID handling in BomItemEditorComponent - Updated the logic for tracking existing item IDs to prevent duplicates during item addition, ensuring that sibling items are checked for duplicates at the same level while allowing duplicates in child levels. - Enhanced the existingItemIds calculation to differentiate between root level and child level additions, improving data integrity and user experience. - Refactored the useMemo hook to include addTargetParentId as a dependency, ensuring accurate updates when the target parent ID changes. --- .../BomItemEditorComponent.tsx | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index fcb7b710..bd5f3d92 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -937,19 +937,38 @@ export function BomItemEditorComponent({ setItemSearchOpen(true); }, []); - // 이미 추가된 품목 ID 목록 (중복 방지용) + // 같은 레벨(형제) 품목 ID 목록 (동일 레벨 중복 방지, 하위 레벨은 허용) const existingItemIds = useMemo(() => { const ids = new Set(); - const collect = (nodes: BomItemNode[]) => { - for (const n of nodes) { - const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; + const fkField = cfg.dataSource?.foreignKey || "child_item_id"; + + if (addTargetParentId === null) { + // 루트 레벨 추가: 루트 노드의 형제들만 체크 + for (const n of treeData) { + const fk = n.data[fkField]; if (fk) ids.add(fk); - collect(n.children); } - }; - collect(treeData); + } else { + // 하위 추가: 해당 부모의 직속 자식들만 체크 + const findParent = (nodes: BomItemNode[]): BomItemNode | null => { + for (const n of nodes) { + if (n.tempId === addTargetParentId) return n; + const found = findParent(n.children); + if (found) return found; + } + return null; + }; + const parent = findParent(treeData); + if (parent) { + for (const child of parent.children) { + const fk = child.data[fkField]; + if (fk) ids.add(fk); + } + } + } + return ids; - }, [treeData, cfg]); + }, [treeData, cfg, addTargetParentId]); // 루트 품목 추가 시작 const handleAddRoot = useCallback(() => { From 21c0c2b95c347e48870e43977f37d550bd43e3be Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 14:00:06 +0900 Subject: [PATCH 2/6] fix: Enhance layout loading logic in screen management - Updated the ScreenManagementService to allow SUPER_ADMIN or users with companyCode as "*" to load layouts based on the screen's company code. - Improved layout loading in ScreenViewPage and EditModal components by implementing fallback mechanisms to ensure a valid layout is always set. - Added console warnings for better debugging when layout loading fails, enhancing error visibility and user experience. - Refactored label display logic in various components to ensure consistent behavior across input types. --- .../src/services/screenManagementService.ts | 4 +- .../app/(main)/screens/[screenId]/page.tsx | 20 ++- frontend/components/screen/EditModal.tsx | 23 +++- .../screen/InteractiveScreenViewer.tsx | 7 +- .../screen/InteractiveScreenViewerDynamic.tsx | 2 +- frontend/components/v2/V2Date.tsx | 2 +- frontend/components/v2/V2Input.tsx | 2 +- frontend/components/v2/V2Select.tsx | 2 +- .../table-list/TableListConfigPanel.tsx | 124 +++++++++++++++++- .../v2-table-list/TableListConfigPanel.tsx | 124 ++++++++++++++++++ frontend/lib/utils/buttonActions.ts | 14 +- 11 files changed, 304 insertions(+), 20 deletions(-) diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6f412de5..74506a39 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5083,8 +5083,8 @@ export class ScreenManagementService { let layout: { layout_data: any } | null = null; // 🆕 기본 레이어(layer_id=1)를 우선 로드 - // SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 - if (isSuperAdmin) { + // SUPER_ADMIN이거나 companyCode가 "*"인 경우: 화면의 회사 코드로 레이아웃 조회 + if (isSuperAdmin || companyCode === "*") { // 1. 화면 정의의 회사 코드 + 기본 레이어 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 160883ad..d1e07abe 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -179,7 +179,25 @@ function ScreenViewPage() { } else { // V1 레이아웃 또는 빈 레이아웃 const layoutData = await screenApi.getLayout(screenId); - setLayout(layoutData); + if (layoutData?.components?.length > 0) { + setLayout(layoutData); + } else { + console.warn("[ScreenViewPage] getLayout 실패, getLayerLayout(1) fallback:", screenId); + const baseLayerData = await screenApi.getLayerLayout(screenId, 1); + if (baseLayerData && isValidV2Layout(baseLayerData)) { + const converted = convertV2ToLegacy(baseLayerData); + if (converted) { + setLayout({ + ...converted, + screenResolution: baseLayerData.screenResolution || converted.screenResolution, + } as LayoutData); + } else { + setLayout(layoutData); + } + } else { + setLayout(layoutData); + } + } } } catch (layoutError) { console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError); diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index fe6ba4fa..ec36096d 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -413,9 +413,28 @@ export const EditModal: React.FC = ({ className }) => { // V2 없으면 기존 API fallback if (!layoutData) { + console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId); layoutData = await screenApi.getLayout(screenId); } + // getLayout도 실패하면 기본 레이어(layer_id=1) 직접 로드 + if (!layoutData || !layoutData.components || layoutData.components.length === 0) { + console.warn("[EditModal] getLayout도 실패, getLayerLayout(1) 최종 fallback:", screenId); + try { + const baseLayerData = await screenApi.getLayerLayout(screenId, 1); + if (baseLayerData && isValidV2Layout(baseLayerData)) { + layoutData = convertV2ToLegacy(baseLayerData); + if (layoutData) { + layoutData.screenResolution = baseLayerData.screenResolution || layoutData.screenResolution; + } + } else if (baseLayerData?.components) { + layoutData = baseLayerData; + } + } catch (fallbackErr) { + console.error("[EditModal] getLayerLayout(1) fallback 실패:", fallbackErr); + } + } + if (screenInfo && layoutData) { const components = layoutData.components || []; @@ -1440,7 +1459,7 @@ export const EditModal: React.FC = ({ className }) => { -
+
{loading ? (
@@ -1455,7 +1474,7 @@ export const EditModal: React.FC = ({ className }) => { >
= ( // 라벨 표시 여부 계산 const shouldShowLabel = - !hideLabel && // hideLabel이 true면 라벨 숨김 - (component.style?.labelDisplay ?? true) && + !hideLabel && + (component.style?.labelDisplay ?? true) !== false && + component.style?.labelDisplay !== "false" && (component.label || component.style?.labelText) && - !templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함 + !templateTypes.includes(component.type); const labelText = component.style?.labelText || component.label || ""; diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index a35c5ed2..253c886d 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1109,7 +1109,7 @@ export const InteractiveScreenViewerDynamic: React.FC((props, ref) => { } }; - const showLabel = label && style?.labelDisplay !== false; + const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false"; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index d76802e8..219fa275 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -962,7 +962,7 @@ export const V2Input = forwardRef((props, ref) => }; const actualLabel = label || style?.labelText; - const showLabel = actualLabel && style?.labelDisplay === true; + const showLabel = actualLabel && style?.labelDisplay !== false && style?.labelDisplay !== "false"; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index e7dbfd86..690791d5 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -1135,7 +1135,7 @@ export const V2Select = forwardRef( } }; - const showLabel = label && style?.labelDisplay !== false; + const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false"; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 8cd8b0c5..f3a28c4c 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -10,7 +10,7 @@ import { TableListConfig, ColumnConfig } from "./types"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; -import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2 } from "lucide-react"; +import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, Pencil } 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"; @@ -1213,6 +1213,34 @@ export const TableListConfigPanel: React.FC = ({ )} + {/* 선택된 컬럼 순서 변경 */} + {config.columns && config.columns.length > 0 && ( +
+
+

컬럼 순서 / 설정

+

+ 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 +

+
+
+
+ {[...(config.columns || [])] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((column, idx) => ( + moveColumn(column.columnName, direction)} + onRemove={() => removeColumn(column.columnName)} + onUpdate={(updates) => updateColumn(column.columnName, updates)} + /> + ))} +
+
+ )} + {/* 🆕 데이터 필터링 설정 */}
@@ -1240,3 +1268,97 @@ export const TableListConfigPanel: React.FC = ({
); }; + +/** + * 선택된 컬럼 항목 컴포넌트 + * 순서 이동, 삭제, 표시명 수정 기능 제공 + */ +const SelectedColumnItem: React.FC<{ + column: ColumnConfig; + index: number; + total: number; + onMove: (direction: "up" | "down") => void; + onRemove: () => void; + onUpdate: (updates: Partial) => void; +}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(column.displayName || column.columnName); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== column.displayName) { + onUpdate({ displayName: trimmed }); + } + setIsEditing(false); + }; + + return ( +
+ + + {index + 1} + + {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") { + setEditValue(column.displayName || column.columnName); + setIsEditing(false); + } + }} + className="h-5 flex-1 px-1 text-xs" + autoFocus + /> + ) : ( + + )} + +
+ + + +
+
+ ); +}; diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index 35f15596..ad250a16 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -22,6 +22,8 @@ import { Database, Table2, Link2, + GripVertical, + Pencil, } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; @@ -1458,6 +1460,34 @@ export const TableListConfigPanel: React.FC = ({ )} + {/* 선택된 컬럼 순서 변경 */} + {config.columns && config.columns.length > 0 && ( +
+
+

컬럼 순서 / 설정

+

+ 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 +

+
+
+
+ {[...(config.columns || [])] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((column, idx) => ( + moveColumn(column.columnName, direction)} + onRemove={() => removeColumn(column.columnName)} + onUpdate={(updates) => updateColumn(column.columnName, updates)} + /> + ))} +
+
+ )} + {/* 🆕 데이터 필터링 설정 */}
@@ -1484,3 +1514,97 @@ export const TableListConfigPanel: React.FC = ({
); }; + +/** + * 선택된 컬럼 항목 컴포넌트 + * 순서 이동, 삭제, 표시명 수정 기능 제공 + */ +const SelectedColumnItem: React.FC<{ + column: ColumnConfig; + index: number; + total: number; + onMove: (direction: "up" | "down") => void; + onRemove: () => void; + onUpdate: (updates: Partial) => void; +}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(column.displayName || column.columnName); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== column.displayName) { + onUpdate({ displayName: trimmed }); + } + setIsEditing(false); + }; + + return ( +
+ + + {index + 1} + + {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") { + setEditValue(column.displayName || column.columnName); + setIsEditing(false); + } + }} + className="h-5 flex-1 px-1 text-xs" + autoFocus + /> + ) : ( + + )} + +
+ + + +
+
+ ); +}; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 054b257f..2ed4db87 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -3173,16 +3173,16 @@ export class ButtonActionExecutor { return false; } - // 1. 화면 설명 가져오기 - let description = config.modalDescription || ""; - if (!description) { + // 1. 화면 정보 가져오기 (제목/설명이 미설정 시 화면명에서 가져옴) + let screenInfo: any = null; + if (!config.modalTitle || !config.modalDescription) { try { - const screenInfo = await screenApi.getScreen(config.targetScreenId); - description = screenInfo?.description || ""; + screenInfo = await screenApi.getScreen(config.targetScreenId); } catch (error) { - console.warn("화면 설명을 가져오지 못했습니다:", error); + console.warn("화면 정보를 가져오지 못했습니다:", error); } } + let description = config.modalDescription || screenInfo?.description || ""; // 2. 데이터 소스 및 선택된 데이터 수집 let selectedData: any[] = []; @@ -3288,7 +3288,7 @@ export class ButtonActionExecutor { } // 3. 동적 모달 제목 생성 - let finalTitle = config.modalTitle || "화면"; + let finalTitle = config.modalTitle || screenInfo?.screenName || "데이터 등록"; // 블록 기반 제목 처리 if (config.modalTitleBlocks?.length) { From 026e99511cce366bb0e543868709792190db6996 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 14:30:31 +0900 Subject: [PATCH 3/6] refactor: Enhance label display and drag-and-drop functionality in table configuration - Updated the InteractiveScreenViewer and InteractiveScreenViewerDynamic components to include label positioning and size adjustments based on horizontal label settings. - Improved the DynamicComponentRenderer to handle label display logic more robustly, allowing for string values in addition to boolean. - Introduced drag-and-drop functionality in the TableListConfigPanel for reordering selected columns, enhancing user experience and flexibility in column management. - Refactored the display name resolution logic to prioritize available column labels, ensuring accurate representation in the UI. --- .../screen/InteractiveScreenViewer.tsx | 11 +- .../screen/InteractiveScreenViewerDynamic.tsx | 17 +- .../lib/registry/DynamicComponentRenderer.tsx | 4 +- .../table-list/TableListConfigPanel.tsx | 228 +++++++++-------- .../v2-table-list/TableListConfigPanel.tsx | 229 +++++++++--------- 5 files changed, 255 insertions(+), 234 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 1d64b597..7a9a3ff3 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -2233,8 +2233,17 @@ export const InteractiveScreenViewer: React.FC = ( ...component, style: { ...component.style, - labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김 + labelDisplay: false, + labelPosition: "top" as const, + ...(isHorizontalLabel ? { width: "100%", height: "100%" } : {}), }, + ...(isHorizontalLabel ? { + size: { + ...component.size, + width: undefined as unknown as number, + height: undefined as unknown as number, + }, + } : {}), } : component; diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 253c886d..bcf9959c 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1292,7 +1292,22 @@ export const InteractiveScreenViewerDynamic: React.FC = componentType === "modal-repeater-table" || componentType === "v2-input"; - // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시) + // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시) const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; - const effectiveLabel = labelDisplay === true + const effectiveLabel = (labelDisplay === true || labelDisplay === "true") ? (component.style?.labelText || (component as any).label || component.componentConfig?.label) : undefined; diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index f3a28c4c..8526b0c9 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -10,11 +10,74 @@ import { TableListConfig, ColumnConfig } from "./types"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; -import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, Pencil } from "lucide-react"; +import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, X } 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 { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; +import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +/** + * 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴) + */ +function SortableColumnRow({ + id, + col, + index, + isEntityJoin, + onLabelChange, + onWidthChange, + onRemove, +}: { + id: string; + col: ColumnConfig; + index: number; + isEntityJoin?: boolean; + onLabelChange: (value: string) => void; + onWidthChange: (value: number) => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style = { transform: CSS.Transform.toString(transform), transition }; + + return ( +
+
+ +
+ {isEntityJoin ? ( + + ) : ( + #{index + 1} + )} + onLabelChange(e.target.value)} + placeholder="표시명" + className="h-6 min-w-0 flex-1 text-xs" + /> + onWidthChange(parseInt(e.target.value) || 100)} + placeholder="너비" + className="h-6 w-14 shrink-0 text-xs" + /> + +
+ ); +} export interface TableListConfigPanelProps { config: TableListConfig; @@ -348,11 +411,11 @@ export const TableListConfigPanel: React.FC = ({ const existingColumn = config.columns?.find((col) => col.columnName === columnName); if (existingColumn) return; - // tableColumns에서 해당 컬럼의 라벨 정보 찾기 + // tableColumns → availableColumns 순서로 한국어 라벨 찾기 const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName); + const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName); - // 라벨명 우선 사용, 없으면 컬럼명 사용 - const displayName = columnInfo?.label || columnInfo?.displayName || columnName; + const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName; const newColumn: ColumnConfig = { columnName, @@ -1213,31 +1276,59 @@ export const TableListConfigPanel: React.FC = ({ )} - {/* 선택된 컬럼 순서 변경 */} + {/* 선택된 컬럼 순서 변경 (DnD) */} {config.columns && config.columns.length > 0 && (
-

컬럼 순서 / 설정

+

표시할 컬럼 ({config.columns.length}개 선택)

- 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 + 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다


-
- {[...(config.columns || [])] - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((column, idx) => ( - moveColumn(column.columnName, direction)} - onRemove={() => removeColumn(column.columnName)} - onUpdate={(updates) => updateColumn(column.columnName, updates)} - /> - ))} -
+ { + const { active, over } = event; + if (!over || active.id === over.id) return; + const columns = [...(config.columns || [])]; + const oldIndex = columns.findIndex((c) => c.columnName === active.id); + const newIndex = columns.findIndex((c) => c.columnName === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + const reordered = arrayMove(columns, oldIndex, newIndex); + reordered.forEach((col, idx) => { col.order = idx; }); + handleChange("columns", reordered); + } + }} + > + c.columnName)} + strategy={verticalListSortingStrategy} + > +
+ {(config.columns || []).map((column, idx) => { + const resolvedLabel = + column.displayName && column.displayName !== column.columnName + ? column.displayName + : availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName; + + const colWithLabel = { ...column, displayName: resolvedLabel }; + return ( + updateColumn(column.columnName, { displayName: value })} + onWidthChange={(value) => updateColumn(column.columnName, { width: value })} + onRemove={() => removeColumn(column.columnName)} + /> + ); + })} +
+
+
)} @@ -1269,96 +1360,3 @@ export const TableListConfigPanel: React.FC = ({ ); }; -/** - * 선택된 컬럼 항목 컴포넌트 - * 순서 이동, 삭제, 표시명 수정 기능 제공 - */ -const SelectedColumnItem: React.FC<{ - column: ColumnConfig; - index: number; - total: number; - onMove: (direction: "up" | "down") => void; - onRemove: () => void; - onUpdate: (updates: Partial) => void; -}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(column.displayName || column.columnName); - - const handleSave = () => { - const trimmed = editValue.trim(); - if (trimmed && trimmed !== column.displayName) { - onUpdate({ displayName: trimmed }); - } - setIsEditing(false); - }; - - return ( -
- - - {index + 1} - - {isEditing ? ( - setEditValue(e.target.value)} - onBlur={handleSave} - onKeyDown={(e) => { - if (e.key === "Enter") handleSave(); - if (e.key === "Escape") { - setEditValue(column.displayName || column.columnName); - setIsEditing(false); - } - }} - className="h-5 flex-1 px-1 text-xs" - autoFocus - /> - ) : ( - - )} - -
- - - -
-
- ); -}; diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index ad250a16..7de8a533 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -23,12 +23,75 @@ import { Table2, Link2, GripVertical, - Pencil, + X, } 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 { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; +import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +/** + * 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴) + */ +function SortableColumnRow({ + id, + col, + index, + isEntityJoin, + onLabelChange, + onWidthChange, + onRemove, +}: { + id: string; + col: ColumnConfig; + index: number; + isEntityJoin?: boolean; + onLabelChange: (value: string) => void; + onWidthChange: (value: number) => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style = { transform: CSS.Transform.toString(transform), transition }; + + return ( +
+
+ +
+ {isEntityJoin ? ( + + ) : ( + #{index + 1} + )} + onLabelChange(e.target.value)} + placeholder="표시명" + className="h-6 min-w-0 flex-1 text-xs" + /> + onWidthChange(parseInt(e.target.value) || 100)} + placeholder="너비" + className="h-6 w-14 shrink-0 text-xs" + /> + +
+ ); +} export interface TableListConfigPanelProps { config: TableListConfig; @@ -368,11 +431,11 @@ export const TableListConfigPanel: React.FC = ({ const existingColumn = config.columns?.find((col) => col.columnName === columnName); if (existingColumn) return; - // tableColumns에서 해당 컬럼의 라벨 정보 찾기 + // tableColumns → availableColumns 순서로 한국어 라벨 찾기 const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName); + const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName); - // 라벨명 우선 사용, 없으면 컬럼명 사용 - const displayName = columnInfo?.label || columnInfo?.displayName || columnName; + const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName; const newColumn: ColumnConfig = { columnName, @@ -1460,31 +1523,60 @@ export const TableListConfigPanel: React.FC = ({ )} - {/* 선택된 컬럼 순서 변경 */} + {/* 선택된 컬럼 순서 변경 (DnD) */} {config.columns && config.columns.length > 0 && (
-

컬럼 순서 / 설정

+

표시할 컬럼 ({config.columns.length}개 선택)

- 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 + 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다


-
- {[...(config.columns || [])] - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((column, idx) => ( - moveColumn(column.columnName, direction)} - onRemove={() => removeColumn(column.columnName)} - onUpdate={(updates) => updateColumn(column.columnName, updates)} - /> - ))} -
+ { + const { active, over } = event; + if (!over || active.id === over.id) return; + const columns = [...(config.columns || [])]; + const oldIndex = columns.findIndex((c) => c.columnName === active.id); + const newIndex = columns.findIndex((c) => c.columnName === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + const reordered = arrayMove(columns, oldIndex, newIndex); + reordered.forEach((col, idx) => { col.order = idx; }); + handleChange("columns", reordered); + } + }} + > + c.columnName)} + strategy={verticalListSortingStrategy} + > +
+ {(config.columns || []).map((column, idx) => { + // displayName이 columnName과 같으면 한국어 라벨 미설정 → availableColumns에서 찾기 + const resolvedLabel = + column.displayName && column.displayName !== column.columnName + ? column.displayName + : availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName; + + const colWithLabel = { ...column, displayName: resolvedLabel }; + return ( + updateColumn(column.columnName, { displayName: value })} + onWidthChange={(value) => updateColumn(column.columnName, { width: value })} + onRemove={() => removeColumn(column.columnName)} + /> + ); + })} +
+
+
)} @@ -1515,96 +1607,3 @@ export const TableListConfigPanel: React.FC = ({ ); }; -/** - * 선택된 컬럼 항목 컴포넌트 - * 순서 이동, 삭제, 표시명 수정 기능 제공 - */ -const SelectedColumnItem: React.FC<{ - column: ColumnConfig; - index: number; - total: number; - onMove: (direction: "up" | "down") => void; - onRemove: () => void; - onUpdate: (updates: Partial) => void; -}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(column.displayName || column.columnName); - - const handleSave = () => { - const trimmed = editValue.trim(); - if (trimmed && trimmed !== column.displayName) { - onUpdate({ displayName: trimmed }); - } - setIsEditing(false); - }; - - return ( -
- - - {index + 1} - - {isEditing ? ( - setEditValue(e.target.value)} - onBlur={handleSave} - onKeyDown={(e) => { - if (e.key === "Enter") handleSave(); - if (e.key === "Escape") { - setEditValue(column.displayName || column.columnName); - setIsEditing(false); - } - }} - className="h-5 flex-1 px-1 text-xs" - autoFocus - /> - ) : ( - - )} - -
- - - -
-
- ); -}; From a8ad26cf305b07bfed182107cd366b5cf7d3bce3 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 15:24:55 +0900 Subject: [PATCH 4/6] refactor: Enhance horizontal label handling in dynamic components - Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to improve horizontal label rendering and style management. - Refactored the DynamicComponentRenderer to support external horizontal labels, ensuring proper display and positioning based on component styles. - Cleaned up style handling by removing unnecessary border properties for horizontal labels, enhancing visual consistency. - Improved the logic for determining label display requirements, streamlining the rendering process for dynamic components. --- .../screen/InteractiveScreenViewerDynamic.tsx | 86 +++++++--- .../screen/RealtimePreviewDynamic.tsx | 15 +- .../lib/registry/DynamicComponentRenderer.tsx | 154 +++++++++++++++--- 3 files changed, 213 insertions(+), 42 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index bcf9959c..1bb04e97 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1119,6 +1119,12 @@ export const InteractiveScreenViewerDynamic: React.FC { const compType = (component as any).componentType || ""; const isSplitLine = type === "component" && compType === "v2-split-line"; @@ -1194,9 +1200,17 @@ export const InteractiveScreenViewerDynamic: React.FC { + const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize; + return rest; + })() + : safeStyleWithoutSize; + const componentStyle = { position: "absolute" as const, - ...safeStyleWithoutSize, + ...cleanedStyle, // left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게) left: adjustedX, top: position?.y || 0, @@ -1267,11 +1281,7 @@ export const InteractiveScreenViewerDynamic: React.FC
{needsExternalLabel ? ( -
- {externalLabelComponent} -
- {renderInteractiveWidget(componentToRender)} + isHorizLabel ? ( +
+ +
+ {renderInteractiveWidget(componentToRender)} +
-
+ ) : ( +
+ {externalLabelComponent} +
+ {renderInteractiveWidget(componentToRender)} +
+
+ ) ) : ( renderInteractiveWidget(componentToRender) )} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index b95506d9..dcca4d0d 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -548,10 +548,23 @@ const RealtimePreviewDynamicComponent: React.FC = ({ const origWidth = size?.width || 100; const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth; + // v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리) + const isV2HorizLabel = !!( + componentStyle && + (componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") && + (componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right") + ); + const safeComponentStyle = isV2HorizLabel + ? (() => { + const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any; + return rest; + })() + : componentStyle; + const baseStyle = { left: `${adjustedPositionX}px`, top: `${position.y}px`, - ...componentStyle, + ...safeComponentStyle, width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth, height: displayHeight, zIndex: component.type === "layout" ? 1 : position.z || 2, diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index ed98561c..50c4bee4 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -371,15 +371,18 @@ export const DynamicComponentRenderer: React.FC = try { const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer"); const fieldName = columnName || component.id; - const currentValue = props.formData?.[fieldName] || ""; - const handleChange = (value: any) => { - if (props.onFormDataChange) { - props.onFormDataChange(fieldName, value); - } - }; - - // V2SelectRenderer용 컴포넌트 데이터 구성 + // 수평 라벨 감지 + const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; + const catLabelPosition = component.style?.labelPosition; + const catLabelText = (catLabelDisplay === true || catLabelDisplay === "true") + ? (component.style?.labelText || (component as any).label || component.componentConfig?.label) + : undefined; + const catNeedsExternalHorizLabel = !!( + catLabelText && + (catLabelPosition === "left" || catLabelPosition === "right") + ); + const selectComponent = { ...component, componentConfig: { @@ -395,6 +398,24 @@ export const DynamicComponentRenderer: React.FC = webType: "category", }; + const catStyle = catNeedsExternalHorizLabel + ? { + ...(component as any).style, + labelDisplay: false, + labelPosition: "top" as const, + width: "100%", + height: "100%", + borderWidth: undefined, + borderColor: undefined, + borderStyle: undefined, + border: undefined, + borderRadius: undefined, + } + : (component as any).style; + const catSize = catNeedsExternalHorizLabel + ? { ...(component as any).size, width: undefined, height: undefined } + : (component as any).size; + const rendererProps = { component: selectComponent, formData: props.formData, @@ -402,12 +423,47 @@ export const DynamicComponentRenderer: React.FC = isDesignMode: props.isDesignMode, isInteractive: props.isInteractive ?? !props.isDesignMode, tableName, - style: (component as any).style, - size: (component as any).size, + style: catStyle, + size: catSize, }; const rendererInstance = new V2SelectRenderer(rendererProps); - return rendererInstance.render(); + const renderedCatSelect = rendererInstance.render(); + + if (catNeedsExternalHorizLabel) { + const labelGap = component.style?.labelGap || "8px"; + const labelFontSize = component.style?.labelFontSize || "14px"; + const labelColor = component.style?.labelColor || "#64748b"; + const labelFontWeight = component.style?.labelFontWeight || "500"; + const isRequired = component.required || (component as any).required; + const isLeft = catLabelPosition === "left"; + return ( +
+ +
+ {renderedCatSelect} +
+
+ ); + } + return renderedCatSelect; } catch (error) { console.error("❌ V2SelectRenderer 로드 실패:", error); } @@ -625,12 +681,33 @@ export const DynamicComponentRenderer: React.FC = ? (component.style?.labelText || (component as any).label || component.componentConfig?.label) : undefined; + // 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리 + const labelPosition = component.style?.labelPosition; + const isV2Component = componentType?.startsWith("v2-"); + const needsExternalHorizLabel = !!( + isV2Component && + effectiveLabel && + (labelPosition === "left" || labelPosition === "right") + ); + // 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀 const mergedStyle = { ...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저! // CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고) width: finalStyle.width, height: finalStyle.height, + // 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리) + ...(needsExternalHorizLabel ? { + labelDisplay: false, + labelPosition: "top" as const, + width: "100%", + height: "100%", + borderWidth: undefined, + borderColor: undefined, + borderStyle: undefined, + border: undefined, + borderRadius: undefined, + } : {}), }; // 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선) @@ -649,7 +726,9 @@ export const DynamicComponentRenderer: React.FC = onClick, onDragStart, onDragEnd, - size: component.size || newComponent.defaultSize, + size: needsExternalHorizLabel + ? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined } + : (component.size || newComponent.defaultSize), position: component.position, config: mergedComponentConfig, componentConfig: mergedComponentConfig, @@ -657,8 +736,8 @@ export const DynamicComponentRenderer: React.FC = ...(mergedComponentConfig || {}), // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) style: mergedStyle, - // 🆕 라벨 표시 (labelDisplay가 true일 때만) - label: effectiveLabel, + // 수평 라벨 → 외부에서 처리하므로 label 전달 안 함 + label: needsExternalHorizLabel ? undefined : effectiveLabel, // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선) inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType, columnName: (component as any).columnName || component.componentConfig?.columnName, @@ -759,16 +838,51 @@ export const DynamicComponentRenderer: React.FC = NewComponentRenderer.prototype && NewComponentRenderer.prototype.render; + let renderedElement: React.ReactElement; if (isClass) { - // 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속) const rendererInstance = new NewComponentRenderer(rendererProps); - return rendererInstance.render(); + renderedElement = rendererInstance.render(); } else { - // 함수형 컴포넌트 - // refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제 - - return ; + renderedElement = ; } + + // 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움 + if (needsExternalHorizLabel) { + const labelGap = component.style?.labelGap || "8px"; + const labelFontSize = component.style?.labelFontSize || "14px"; + const labelColor = component.style?.labelColor || "#64748b"; + const labelFontWeight = component.style?.labelFontWeight || "500"; + const isRequired = component.required || (component as any).required; + const isLeft = labelPosition === "left"; + + return ( +
+ +
+ {renderedElement} +
+
+ ); + } + + return renderedElement; } } catch (error) { console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error); From e16d76936b13dd5a5c3789a37485f8a3f8091760 Mon Sep 17 00:00:00 2001 From: kjs Date: Sat, 28 Feb 2026 14:33:18 +0900 Subject: [PATCH 5/6] feat: Enhance V2Repeater and configuration panel with source detail auto-fetching - Added support for automatic fetching of detail rows from the master data in the V2Repeater component, improving data management. - Introduced a new configuration option in the V2RepeaterConfigPanel to enable source detail auto-fetching, allowing users to specify detail table and foreign key settings. - Enhanced the V2Repeater component to handle entity joins for loading data, optimizing data retrieval processes. - Updated the V2RepeaterProps and V2RepeaterConfig interfaces to include new properties for grouped data and source detail configuration, ensuring type safety and clarity in component usage. - Improved logging for data loading processes to provide better insights during development and debugging. --- frontend/components/v2/V2Repeater.tsx | 256 +++++++++--- .../config-panels/V2RepeaterConfigPanel.tsx | 128 ++++++ .../modal-repeater-table/RepeaterTable.tsx | 10 +- .../SplitPanelLayout2Component.tsx | 139 ++++++- .../TableSectionRenderer.tsx | 375 +++++++++++++----- .../UniversalFormModalComponent.tsx | 85 ++-- .../UniversalFormModalConfigPanel.tsx | 20 +- .../modals/TableSectionSettingsModal.tsx | 9 +- .../components/DetailFormModal.tsx | 9 +- .../v2-repeater/V2RepeaterRenderer.tsx | 3 + frontend/types/v2-repeater.ts | 24 +- 11 files changed, 858 insertions(+), 200 deletions(-) diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index b60617e6..f6f1fc6b 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -48,6 +48,7 @@ export const V2Repeater: React.FC = ({ onRowClick, className, formData: parentFormData, + groupedData, ...restProps }) => { // componentId 결정: 직접 전달 또는 component 객체에서 추출 @@ -419,65 +420,113 @@ export const V2Repeater: React.FC = ({ fkValue, }); - const response = await apiClient.post( - `/table-management/tables/${config.mainTableName}/data`, - { + let rows: any[] = []; + const useEntityJoinForLoad = config.sourceDetailConfig?.useEntityJoin; + + if (useEntityJoinForLoad) { + // 엔티티 조인을 사용하여 데이터 로드 (part_code → item_info 자동 조인) + const searchParam = JSON.stringify({ [config.foreignKeyColumn!]: fkValue }); + const params: Record = { page: 1, size: 1000, - dataFilter: { - enabled: true, - filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], - }, - autoFilter: true, + search: searchParam, + enableEntityJoin: true, + autoFilter: JSON.stringify({ enabled: true }), + }; + const addJoinCols = config.sourceDetailConfig?.additionalJoinColumns; + if (addJoinCols && addJoinCols.length > 0) { + params.additionalJoinColumns = JSON.stringify(addJoinCols); } - ); + const response = await apiClient.get( + `/table-management/tables/${config.mainTableName}/data-with-joins`, + { params } + ); + const resultData = response.data?.data; + const rawRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + // 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어날 수 있으므로 id 기준 중복 제거 + const seenIds = new Set(); + rows = rawRows.filter((row: any) => { + if (!row.id || seenIds.has(row.id)) return false; + seenIds.add(row.id); + return true; + }); + } else { + const response = await apiClient.post( + `/table-management/tables/${config.mainTableName}/data`, + { + page: 1, + size: 1000, + dataFilter: { + enabled: true, + filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], + }, + autoFilter: true, + } + ); + rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; + } - const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; if (Array.isArray(rows) && rows.length > 0) { - console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`); + console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : ""); - // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 - const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); - const sourceTable = config.dataSource?.sourceTable; - const fkColumn = config.dataSource?.foreignKey; - const refKey = config.dataSource?.referenceKey || "id"; + // 엔티티 조인 사용 시: columnMapping으로 _display_ 필드 보강 + const columnMapping = config.sourceDetailConfig?.columnMapping; + if (useEntityJoinForLoad && columnMapping) { + const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); + rows.forEach((row: any) => { + sourceDisplayColumns.forEach((col) => { + const mappedKey = columnMapping[col.key]; + const value = mappedKey ? row[mappedKey] : row[col.key]; + row[`_display_${col.key}`] = value ?? ""; + }); + }); + console.log("✅ [V2Repeater] 엔티티 조인 표시 데이터 보강 완료"); + } - if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { - try { - const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); - const uniqueValues = [...new Set(fkValues)]; + // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시) + if (!useEntityJoinForLoad) { + const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); + const sourceTable = config.dataSource?.sourceTable; + const fkColumn = config.dataSource?.foreignKey; + const refKey = config.dataSource?.referenceKey || "id"; - if (uniqueValues.length > 0) { - // FK 값 기반으로 소스 테이블에서 해당 레코드만 조회 - const sourcePromises = uniqueValues.map((val) => - apiClient.post(`/table-management/tables/${sourceTable}/data`, { - page: 1, size: 1, - search: { [refKey]: val }, - autoFilter: true, - }).then((r) => r.data?.data?.data || r.data?.data?.rows || []) - .catch(() => []) - ); - const sourceResults = await Promise.all(sourcePromises); - const sourceMap = new Map(); - sourceResults.flat().forEach((sr: any) => { - if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr); - }); + if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { + try { + const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); + const uniqueValues = [...new Set(fkValues)]; - // 각 행에 소스 테이블의 표시 데이터 병합 - rows.forEach((row: any) => { - const sourceRecord = sourceMap.get(String(row[fkColumn])); - if (sourceRecord) { - sourceDisplayColumns.forEach((col) => { - const displayValue = sourceRecord[col.key] ?? null; - row[col.key] = displayValue; - row[`_display_${col.key}`] = displayValue; - }); - } - }); - console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); + if (uniqueValues.length > 0) { + const sourcePromises = uniqueValues.map((val) => + apiClient.post(`/table-management/tables/${sourceTable}/data`, { + page: 1, size: 1, + search: { [refKey]: val }, + autoFilter: true, + }).then((r) => r.data?.data?.data || r.data?.data?.rows || []) + .catch(() => []) + ); + const sourceResults = await Promise.all(sourcePromises); + const sourceMap = new Map(); + sourceResults.flat().forEach((sr: any) => { + if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr); + }); + + rows.forEach((row: any) => { + const sourceRecord = sourceMap.get(String(row[fkColumn])); + if (sourceRecord) { + sourceDisplayColumns.forEach((col) => { + const displayValue = sourceRecord[col.key] ?? null; + row[col.key] = displayValue; + row[`_display_${col.key}`] = displayValue; + }); + } + }); + console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); + } + } catch (sourceError) { + console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError); } - } catch (sourceError) { - console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError); } } @@ -964,8 +1013,113 @@ export const V2Repeater: React.FC = ({ [], ); - // V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용. - // EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음. + // sourceDetailConfig가 설정되고 groupedData(모달에서 전달된 마스터 데이터)가 있으면 + // 마스터의 키를 추출하여 디테일 테이블에서 행을 조회 → 리피터에 자동 세팅 + const sourceDetailLoadedRef = useRef(false); + useEffect(() => { + if (sourceDetailLoadedRef.current) return; + if (!groupedData || groupedData.length === 0) return; + if (!config.sourceDetailConfig) return; + + const { tableName, foreignKey, parentKey } = config.sourceDetailConfig; + if (!tableName || !foreignKey || !parentKey) return; + + const parentKeys = groupedData + .map((row) => row[parentKey]) + .filter((v) => v !== undefined && v !== null && v !== ""); + + if (parentKeys.length === 0) return; + + sourceDetailLoadedRef.current = true; + + const loadSourceDetails = async () => { + try { + const uniqueKeys = [...new Set(parentKeys)] as string[]; + const { useEntityJoin, columnMapping, additionalJoinColumns } = config.sourceDetailConfig!; + + let detailRows: any[] = []; + + if (useEntityJoin) { + // data-with-joins GET API 사용 (엔티티 조인 자동 적용) + const searchParam = JSON.stringify({ [foreignKey]: uniqueKeys.join("|") }); + const params: Record = { + page: 1, + size: 9999, + search: searchParam, + enableEntityJoin: true, + autoFilter: JSON.stringify({ enabled: true }), + }; + if (additionalJoinColumns && additionalJoinColumns.length > 0) { + params.additionalJoinColumns = JSON.stringify(additionalJoinColumns); + } + const resp = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params }); + const resultData = resp.data?.data; + const rawRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + // 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어나므로 id 기준 중복 제거 + const seenIds = new Set(); + detailRows = rawRows.filter((row: any) => { + if (!row.id || seenIds.has(row.id)) return false; + seenIds.add(row.id); + return true; + }); + } else { + // 기존 POST API 사용 + const resp = await apiClient.post(`/table-management/tables/${tableName}/data`, { + page: 1, + size: 9999, + search: { [foreignKey]: uniqueKeys }, + }); + const resultData = resp.data?.data; + detailRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + } + + if (detailRows.length === 0) { + console.warn("[V2Repeater] sourceDetail 조회 결과 없음:", { tableName, uniqueKeys }); + return; + } + + console.log("[V2Repeater] sourceDetail 조회 완료:", detailRows.length, "건", useEntityJoin ? "(엔티티 조인)" : ""); + + // 디테일 행을 리피터 컬럼에 매핑 + const newRows = detailRows.map((detail, index) => { + const row: any = { _id: `src_detail_${Date.now()}_${index}` }; + for (const col of config.columns) { + if (col.isSourceDisplay) { + // columnMapping이 있으면 조인 alias에서 값 가져오기 (표시용) + const mappedKey = columnMapping?.[col.key]; + const value = mappedKey ? detail[mappedKey] : detail[col.key]; + row[`_display_${col.key}`] = value ?? ""; + // 원본 값도 저장 (DB persist용 - _display_ 접두사 없이) + if (detail[col.key] !== undefined) { + row[col.key] = detail[col.key]; + } + } else if (col.autoFill) { + const autoValue = generateAutoFillValueSync(col, index, parentFormData); + row[col.key] = autoValue ?? ""; + } else if (col.sourceKey && detail[col.sourceKey] !== undefined) { + row[col.key] = detail[col.sourceKey]; + } else if (detail[col.key] !== undefined) { + row[col.key] = detail[col.key]; + } else { + row[col.key] = ""; + } + } + return row; + }); + + setData(newRows); + onDataChange?.(newRows); + } catch (error) { + console.error("[V2Repeater] sourceDetail 조회 실패:", error); + } + }; + + loadSourceDetails(); + }, [groupedData, config.sourceDetailConfig, config.columns, generateAutoFillValueSync, parentFormData, onDataChange]); // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 useEffect(() => { diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 1f89ae12..66f0f18b 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -31,6 +31,7 @@ import { Wand2, Check, ChevronsUpDown, + ListTree, } from "lucide-react"; import { Command, @@ -983,6 +984,133 @@ export const V2RepeaterConfigPanel: React.FC = ({ + {/* 소스 디테일 자동 조회 설정 */} +
+
+ { + if (checked) { + updateConfig({ + sourceDetailConfig: { + tableName: "", + foreignKey: "", + parentKey: "", + }, + }); + } else { + updateConfig({ sourceDetailConfig: undefined }); + } + }} + /> + +
+

+ 모달에서 전달받은 마스터 데이터의 디테일 행을 자동으로 조회하여 리피터에 채웁니다. +

+ + {config.sourceDetailConfig && ( +
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {allTables.map((table) => ( + { + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + tableName: table.tableName, + }, + }); + }} + className="text-xs" + > + + {table.displayName} + + ))} + + + + + +
+ +
+
+ + + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + foreignKey: e.target.value, + }, + }) + } + placeholder="예: order_no" + className="h-7 text-xs" + /> +
+
+ + + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + parentKey: e.target.value, + }, + }) + } + placeholder="예: order_no" + className="h-7 text-xs" + /> +
+
+ +

+ 마스터에서 [{config.sourceDetailConfig.parentKey || "?"}] 추출 → + {" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"} 로 조회 +

+
+ )} +
+ + + {/* 기능 옵션 */}
diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index d57ae60b..532881b7 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -553,14 +553,20 @@ export function RepeaterTable({ const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const value = row[column.field]; - // 카테고리 라벨 변환 함수 + // 카테고리/셀렉트 라벨 변환 함수 const getCategoryDisplayValue = (val: any): string => { if (!val || typeof val !== "string") return val || "-"; + // select 타입 컬럼의 selectOptions에서 라벨 찾기 + if (column.selectOptions && column.selectOptions.length > 0) { + const matchedOption = column.selectOptions.find((opt) => opt.value === val); + if (matchedOption) return matchedOption.label; + } + const fieldName = column.field.replace(/^_display_/, ""); const isCategoryColumn = categoryColumns.includes(fieldName); - // categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관) + // categoryLabelMap에 직접 매핑이 있으면 바로 변환 if (categoryLabelMap[val]) return categoryLabelMap[val]; // 카테고리 컬럼이 아니면 원래 값 반환 diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index a06c046f..6c631d83 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { apiClient } from "@/lib/api/client"; +import { getCategoryValues } from "@/lib/api/tableCategoryValue"; export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { // 추가 props @@ -92,6 +93,9 @@ export const SplitPanelLayout2Component: React.FC(null); const [rightActiveTab, setRightActiveTab] = useState(null); + // 카테고리 코드→라벨 매핑 + const [categoryLabelMap, setCategoryLabelMap] = useState>({}); + // 프론트엔드 그룹핑 함수 const groupData = useCallback( (data: Record[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record[] => { @@ -185,17 +189,17 @@ export const SplitPanelLayout2Component: React.FC ({ id: value, - label: value, + label: categoryLabelMap[value] || value, count: tabConfig.showCount ? count : 0, })); console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`); return tabs; }, - [], + [categoryLabelMap], ); // 탭으로 필터링된 데이터 반환 @@ -1000,10 +1004,38 @@ export const SplitPanelLayout2Component: React.FC { + loadLeftData(); + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(editEvent); + console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", selectedLeftItem); break; + } case "delete": // 좌측 패널에서 삭제 (필요시 구현) @@ -1018,7 +1050,7 @@ export const SplitPanelLayout2Component: React.FC { + if (isDesignMode) return; + + const loadCategoryLabels = async () => { + const allColumns = new Set(); + const tableName = config.leftPanel?.tableName || config.rightPanel?.tableName; + if (!tableName) return; + + // 좌우 패널의 표시 컬럼에서 카테고리 후보 수집 + for (const col of config.leftPanel?.displayColumns || []) { + allColumns.add(col.name); + } + for (const col of config.rightPanel?.displayColumns || []) { + allColumns.add(col.name); + } + // 탭 소스 컬럼도 추가 + if (config.rightPanel?.tabConfig?.tabSourceColumn) { + allColumns.add(config.rightPanel.tabConfig.tabSourceColumn); + } + if (config.leftPanel?.tabConfig?.tabSourceColumn) { + allColumns.add(config.leftPanel.tabConfig.tabSourceColumn); + } + + const labelMap: Record = {}; + + for (const columnName of allColumns) { + try { + const result = await getCategoryValues(tableName, columnName); + if (result.success && Array.isArray(result.data) && result.data.length > 0) { + for (const item of result.data) { + if (item.valueCode && item.valueLabel) { + labelMap[item.valueCode] = item.valueLabel; + } + } + } + } catch { + // 카테고리가 아닌 컬럼은 무시 + } + } + + if (Object.keys(labelMap).length > 0) { + setCategoryLabelMap(labelMap); + } + }; + + loadCategoryLabels(); + }, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]); + // 컴포넌트 언마운트 시 DataProvider 해제 useEffect(() => { return () => { @@ -1250,6 +1331,23 @@ export const SplitPanelLayout2Component: React.FC { + if (value === null || value === undefined) return ""; + const strVal = String(value); + if (categoryLabelMap[strVal]) return categoryLabelMap[strVal]; + // 콤마 구분 다중 값 처리 + if (strVal.includes(",")) { + const codes = strVal.split(",").map((c) => c.trim()).filter(Boolean); + const labels = codes.map((code) => categoryLabelMap[code] || code); + return labels.join(", "); + } + return strVal; + }, + [categoryLabelMap], + ); + // 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려) const getColumnValue = useCallback( (item: any, col: ColumnConfig): any => { @@ -1547,7 +1645,7 @@ export const SplitPanelLayout2Component: React.FC { const value = item[col.name]; if (value === null || value === undefined) return "-"; @@ -1558,7 +1656,7 @@ export const SplitPanelLayout2Component: React.FC {value.map((v, vIdx) => ( - {formatValue(v, col.format)} + {resolveCategoryLabel(v) || formatValue(v, col.format)} ))}
@@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC - {formatValue(value, col.format)} + {label !== String(value) ? label : formatValue(value, col.format)} ); } - // 기본 텍스트 + // 카테고리 라벨 변환 시도 후 기본 텍스트 + const label = resolveCategoryLabel(value); + if (label !== String(value)) return label; return formatValue(value, col.format); }; @@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC )} - {displayColumns.map((col, colIdx) => ( - {formatValue(getColumnValue(item, col), col.format)} - ))} + {displayColumns.map((col, colIdx) => { + const rawVal = getColumnValue(item, col); + const resolved = resolveCategoryLabel(rawVal); + const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format); + return {display || "-"}; + })} {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
@@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC 0 && (
- {config.leftPanel.actionButtons.map((btn, idx) => ( + {config.leftPanel.actionButtons + .filter((btn) => { + if (btn.showCondition === "selected") return !!selectedLeftItem; + return true; + }) + .map((btn, idx) => (