From 1a6d78df43ab75f1c545060df84aa2c93405ab03 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 13:30:57 +0900 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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);