From 52a73e8cda349ab929aa3b1f88361b5087a5b6b5 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 12 Mar 2026 01:00:03 +0900 Subject: [PATCH] [agent-pipeline] pipe-20260311155325-udmh round-1 --- .../V2RackStructureConfigPanel.tsx | 240 ++++ .../V2RepeatContainerConfigPanel.tsx | 1137 +++++++++++++++++ .../components/v2-rack-structure/index.ts | 2 +- .../components/v2-repeat-container/index.ts | 2 +- 4 files changed, 1379 insertions(+), 2 deletions(-) create mode 100644 frontend/components/v2/config-panels/V2RackStructureConfigPanel.tsx create mode 100644 frontend/components/v2/config-panels/V2RepeatContainerConfigPanel.tsx diff --git a/frontend/components/v2/config-panels/V2RackStructureConfigPanel.tsx b/frontend/components/v2/config-panels/V2RackStructureConfigPanel.tsx new file mode 100644 index 00000000..842b503a --- /dev/null +++ b/frontend/components/v2/config-panels/V2RackStructureConfigPanel.tsx @@ -0,0 +1,240 @@ +"use client"; + +/** + * V2 렉 구조 설정 패널 + * 토스식 단계별 UX: 필드 매핑 -> 제한 설정 -> UI 설정(접힘) + */ + +import React, { useState, useEffect } from "react"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Settings, ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { RackStructureComponentConfig, FieldMapping } from "@/lib/registry/components/v2-rack-structure/types"; + +interface V2RackStructureConfigPanelProps { + config: RackStructureComponentConfig; + onChange: (config: RackStructureComponentConfig) => void; + tables?: Array<{ + tableName: string; + tableLabel?: string; + columns: Array<{ + columnName: string; + columnLabel?: string; + dataType?: string; + }>; + }>; +} + +export const V2RackStructureConfigPanel: React.FC = ({ + config, + onChange, + tables = [], +}) => { + const [availableColumns, setAvailableColumns] = useState< + Array<{ value: string; label: string }> + >([]); + const [advancedOpen, setAdvancedOpen] = useState(false); + + useEffect(() => { + const columns: Array<{ value: string; label: string }> = []; + tables.forEach((table) => { + table.columns.forEach((col) => { + columns.push({ + value: col.columnName, + label: col.columnLabel || col.columnName, + }); + }); + }); + setAvailableColumns(columns); + }, [tables]); + + const handleChange = (key: keyof RackStructureComponentConfig, value: any) => { + onChange({ ...config, [key]: value }); + }; + + const handleFieldMappingChange = (field: keyof FieldMapping, value: string) => { + const currentMapping = config.fieldMapping || {}; + onChange({ + ...config, + fieldMapping: { + ...currentMapping, + [field]: value === "__none__" ? undefined : value, + }, + }); + }; + + const fieldMapping = config.fieldMapping || {}; + + const fieldMappingItems: Array<{ + key: keyof FieldMapping; + label: string; + description: string; + }> = [ + { key: "warehouseCodeField", label: "창고 코드", description: "창고를 식별하는 코드 필드예요" }, + { key: "warehouseNameField", label: "창고명", description: "창고 이름을 표시하는 필드예요" }, + { key: "floorField", label: "층", description: "몇 층인지 나타내는 필드예요" }, + { key: "zoneField", label: "구역", description: "구역 정보를 가져올 필드예요" }, + { key: "locationTypeField", label: "위치 유형", description: "위치의 유형(선반, 바닥 등)을 나타내요" }, + { key: "statusField", label: "사용 여부", description: "사용/미사용 상태를 나타내는 필드예요" }, + ]; + + return ( +
+ {/* ─── 1단계: 필드 매핑 ─── */} +
+

필드 매핑

+

+ 상위 폼의 필드 중 렉 생성에 사용할 필드를 선택해요 +

+
+ +
+ {fieldMappingItems.map((item) => ( +
+
+ {item.label} +

{item.description}

+
+ +
+ ))} +
+ + {/* ─── 2단계: 제한 설정 ─── */} +
+

제한 설정

+

+ 렉 조건의 최대값을 설정해요 +

+
+ +
+
+ 최대 조건 수 + handleChange("maxConditions", parseInt(e.target.value) || 10)} + className="h-7 w-[100px] text-xs" + /> +
+ +
+ 최대 열 수 + handleChange("maxRows", parseInt(e.target.value) || 99)} + className="h-7 w-[100px] text-xs" + /> +
+ +
+ 최대 단 수 + handleChange("maxLevels", parseInt(e.target.value) || 20)} + className="h-7 w-[100px] text-xs" + /> +
+
+ + {/* ─── 3단계: 고급 설정 (Collapsible) ─── */} + + + + + +
+
+
+

템플릿 기능

+

조건을 템플릿으로 저장/불러오기할 수 있어요

+
+ handleChange("showTemplates", checked)} + /> +
+ +
+
+

미리보기

+

생성될 위치를 미리 확인할 수 있어요

+
+ handleChange("showPreview", checked)} + /> +
+ +
+
+

통계 카드

+

총 위치 수 등 통계를 카드로 표시해요

+
+ handleChange("showStatistics", checked)} + /> +
+ +
+
+

읽기 전용

+

조건을 수정할 수 없게 해요

+
+ handleChange("readonly", checked)} + /> +
+
+
+
+
+ ); +}; + +V2RackStructureConfigPanel.displayName = "V2RackStructureConfigPanel"; + +export default V2RackStructureConfigPanel; diff --git a/frontend/components/v2/config-panels/V2RepeatContainerConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeatContainerConfigPanel.tsx new file mode 100644 index 00000000..d1e64897 --- /dev/null +++ b/frontend/components/v2/config-panels/V2RepeatContainerConfigPanel.tsx @@ -0,0 +1,1137 @@ +"use client"; + +/** + * V2 리피터 컨테이너 설정 패널 + * 토스식 단계별 UX: 데이터 소스 -> 레이아웃 -> 슬롯 필드 -> 고급 설정(접힘) + */ + +import React, { useState, useEffect, useMemo } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { + Database, + Table2, + ChevronsUpDown, + Check, + LayoutGrid, + LayoutList, + Rows3, + Plus, + X, + Type, + Settings2, + ChevronDown, + ChevronUp, + Settings, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { RepeatContainerConfig, SlotComponentConfig } from "@/lib/registry/components/v2-repeat-container/types"; +import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; + +interface V2RepeatContainerConfigPanelProps { + config: RepeatContainerConfig; + onChange: (config: Partial) => void; + screenTableName?: string; +} + +export const V2RepeatContainerConfigPanel: React.FC = ({ + config, + onChange, + screenTableName, +}) => { + const [availableTables, setAvailableTables] = useState>([]); + const [loadingTables, setLoadingTables] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + + const [availableColumns, setAvailableColumns] = useState>([]); + const [loadingColumns, setLoadingColumns] = useState(false); + + const [titleColumnOpen, setTitleColumnOpen] = useState(false); + const [descriptionColumnOpen, setDescriptionColumnOpen] = useState(false); + + const [styleOpen, setStyleOpen] = useState(false); + const [interactionOpen, setInteractionOpen] = useState(false); + + const targetTableName = useMemo(() => { + if (config.useCustomTable && config.customTableName) { + return config.customTableName; + } + return config.tableName || screenTableName; + }, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]); + + useEffect(() => { + if (screenTableName && !config.tableName && !config.customTableName) { + onChange({ tableName: screenTableName }); + } + }, [screenTableName, config.tableName, config.customTableName, onChange]); + + useEffect(() => { + const fetchTables = async () => { + setLoadingTables(true); + try { + const response = await tableTypeApi.getTables(); + setAvailableTables( + response.map((table: any) => ({ + tableName: table.tableName, + displayName: table.displayName || table.tableName, + })) + ); + } catch (error) { + console.error("테이블 목록 가져오기 실패:", error); + } finally { + setLoadingTables(false); + } + }; + fetchTables(); + }, []); + + useEffect(() => { + if (!targetTableName) { + setAvailableColumns([]); + return; + } + + const fetchColumns = async () => { + setLoadingColumns(true); + try { + const response = await tableManagementApi.getColumnList(targetTableName); + const columnsData = response.data?.columns || response.data; + if (response.success && columnsData && Array.isArray(columnsData)) { + const columns = columnsData.map((col: any) => ({ + columnName: col.columnName, + displayName: col.displayName || col.columnLabel || col.columnName, + })); + setAvailableColumns(columns); + } + } catch (error) { + console.error("컬럼 목록 가져오기 실패:", error); + setAvailableColumns([]); + } finally { + setLoadingColumns(false); + } + }; + fetchColumns(); + }, [targetTableName, config.tableName, screenTableName, config.useCustomTable, config.customTableName]); + + return ( +
+ {/* ─── 1단계: 데이터 소스 테이블 ─── */} +
+

데이터 소스

+

+ 반복 렌더링할 데이터의 테이블을 선택해요 +

+
+ +
+ {/* 현재 선택된 테이블 카드 */} +
+ +
+
+ {config.customTableName || config.tableName || screenTableName || "테이블 미선택"} +
+
+ {config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"} +
+
+
+ + {/* 테이블 변경 Combobox */} + + + + + + + + + 테이블을 찾을 수 없습니다 + + {screenTableName && ( + + { + onChange({ + useCustomTable: false, + customTableName: undefined, + tableName: screenTableName, + }); + setTableComboboxOpen(false); + }} + className="text-xs cursor-pointer" + > + + + {screenTableName} + + + )} + + + {availableTables + .filter((table) => table.tableName !== screenTableName) + .map((table) => ( + { + onChange({ + useCustomTable: true, + customTableName: table.tableName, + tableName: table.tableName, + }); + setTableComboboxOpen(false); + }} + className="text-xs cursor-pointer" + > + + + {table.displayName || table.tableName} + + ))} + + + + + + + {/* 데이터 수신 방식 */} +
+ 데이터 수신 방식 + +
+ + {config.dataSourceType === "table-list" && ( +
+
+ 연동 컴포넌트 ID +

비우면 테이블명으로 자동 매칭

+
+ onChange({ dataSourceComponentId: e.target.value })} + placeholder="자동 매칭" + className="h-7 w-[140px] text-xs" + /> +
+ )} +
+ + {/* ─── 2단계: 레이아웃 ─── */} +
+

레이아웃

+

+ 아이템의 배치 방식과 간격을 설정해요 +

+
+ +
+ {/* 배치 방식 카드 선택 */} +
+ {[ + { value: "vertical", label: "세로", icon: Rows3 }, + { value: "horizontal", label: "가로", icon: LayoutList }, + { value: "grid", label: "그리드", icon: LayoutGrid }, + ].map(({ value, label, icon: Icon }) => ( + + ))} +
+ + {config.layout === "grid" && ( +
+ 그리드 컬럼 수 + +
+ )} + +
+ 아이템 간격 + onChange({ gap: e.target.value })} + placeholder="16px" + className="h-7 w-[100px] text-xs" + /> +
+
+ + {/* ─── 3단계: 반복 표시 필드 (슬롯) ─── */} + + + {/* ─── 4단계: 아이템 제목/설명 ─── */} +
+
+

아이템 제목/설명

+ onChange({ showItemTitle: checked })} + /> +
+

+ 각 아이템에 제목과 설명을 표시할 수 있어요 +

+
+ + {config.showItemTitle && ( +
+ {/* 제목 컬럼 Combobox */} +
+ 제목 컬럼 + + + + + + + + + 컬럼을 찾을 수 없습니다 + + { + onChange({ titleColumn: "" }); + setTitleColumnOpen(false); + }} + className="text-xs" + > + + 선택 안함 + + {availableColumns.map((col) => ( + { + onChange({ titleColumn: col.columnName }); + setTitleColumnOpen(false); + }} + className="text-xs" + > + +
+ {col.displayName || col.columnName} + {col.displayName && col.displayName !== col.columnName && ( + {col.columnName} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 설명 컬럼 Combobox */} +
+ 설명 컬럼 (선택) + + + + + + + + + 컬럼을 찾을 수 없습니다 + + { + onChange({ descriptionColumn: "" }); + setDescriptionColumnOpen(false); + }} + className="text-xs" + > + + 선택 안함 + + {availableColumns.map((col) => ( + { + onChange({ descriptionColumn: col.columnName }); + setDescriptionColumnOpen(false); + }} + className="text-xs" + > + +
+ {col.displayName || col.columnName} + {col.displayName && col.displayName !== col.columnName && ( + {col.columnName} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 제목 스타일 */} +
+ 제목 스타일 +
+
+ + +
+
+ + onChange({ titleColor: e.target.value })} + className="h-7" + /> +
+
+ + +
+
+
+ + {config.descriptionColumn && ( +
+ 설명 스타일 +
+
+ + +
+
+ + onChange({ descriptionColor: e.target.value })} + className="h-7" + /> +
+
+
+ )} +
+ )} + + {/* ─── 5단계: 카드 스타일 (Collapsible) ─── */} + + + + + +
+
+
+ 배경색 + onChange({ backgroundColor: e.target.value })} + className="h-7 w-[60px]" + /> +
+
+ 둥글기 + onChange({ borderRadius: e.target.value })} + className="h-7 w-[60px] text-xs" + /> +
+
+ +
+
+ 내부 패딩 + onChange({ padding: e.target.value })} + className="h-7 w-[60px] text-xs" + /> +
+
+ 아이템 높이 + onChange({ itemHeight: e.target.value })} + className="h-7 w-[60px] text-xs" + /> +
+
+ +
+
+

테두리 표시

+

각 아이템에 테두리를 표시해요

+
+ onChange({ showBorder: checked })} + /> +
+ +
+
+

그림자 표시

+

각 아이템에 그림자를 적용해요

+
+ onChange({ showShadow: checked })} + /> +
+
+
+
+ + {/* ─── 6단계: 상호작용 & 페이징 (Collapsible) ─── */} + + + + + +
+
+
+

클릭 가능

+

아이템을 클릭해서 선택할 수 있어요

+
+ onChange({ clickable: checked })} + /> +
+ + {config.clickable && ( +
+
+
+

선택 상태 표시

+

선택된 아이템을 시각적으로 구분해요

+
+ onChange({ showSelectedState: checked })} + /> +
+ +
+ 선택 모드 + +
+
+ )} + +
+
+

페이징 사용

+

많은 데이터를 페이지로 나눠 표시해요

+
+ onChange({ usePaging: checked })} + /> +
+ + {config.usePaging && ( +
+
+ 페이지당 아이템 수 + +
+
+ )} + +
+ 빈 상태 메시지 + onChange({ emptyMessage: e.target.value })} + placeholder="데이터가 없습니다" + className="h-7 w-[160px] text-xs" + /> +
+
+
+
+
+ ); +}; + +V2RepeatContainerConfigPanel.displayName = "V2RepeatContainerConfigPanel"; + +// ============================================================ +// 슬롯 자식 컴포넌트 관리 섹션 (기존 기능 100% 유지) +// ============================================================ + +interface SlotChildrenSectionProps { + config: RepeatContainerConfig; + onChange: (config: Partial) => void; + availableColumns: Array<{ columnName: string; displayName?: string }>; + loadingColumns: boolean; + screenTableName?: string; +} + +function SlotChildrenSection({ + config, + onChange, + availableColumns, + loadingColumns, + screenTableName, +}: SlotChildrenSectionProps) { + const [columnComboboxOpen, setColumnComboboxOpen] = useState(false); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const children = config.children || []; + + const toggleExpanded = (id: string) => { + setExpandedIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }; + + const addComponent = (columnName: string, displayName: string) => { + const newChild: SlotComponentConfig = { + id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + componentType: "text-display", + label: displayName, + fieldName: columnName, + position: { x: 0, y: children.length * 40 }, + size: { width: 200, height: 32 }, + componentConfig: {}, + style: {}, + }; + onChange({ children: [...children, newChild] }); + setColumnComboboxOpen(false); + }; + + const removeComponent = (id: string) => { + onChange({ children: children.filter((c) => c.id !== id) }); + setExpandedIds((prev) => { + const newSet = new Set(prev); + newSet.delete(id); + return newSet; + }); + }; + + const updateComponentLabel = (id: string, label: string) => { + onChange({ children: children.map((c) => (c.id === id ? { ...c, label } : c)) }); + }; + + const updateComponentStyle = (id: string, key: string, value: any) => { + onChange({ + children: children.map((c) => + c.id === id ? { ...c, style: { ...c.style, [key]: value } } : c + ), + }); + }; + + const updateComponentSize = (id: string, width: number | undefined, height: number | undefined) => { + onChange({ + children: children.map((c) => + c.id === id + ? { ...c, size: { width: width ?? c.size?.width ?? 200, height: height ?? c.size?.height ?? 32 } } + : c + ), + }); + }; + + return ( + <> +
+

반복 표시 필드

+

+ 데이터의 어떤 컬럼을 각 아이템에 표시할지 선택해요 +

+
+ + {children.length > 0 ? ( +
+ {children.map((child, index) => { + const isExpanded = expandedIds.has(child.id); + return ( +
+
+
+ {index + 1} +
+
+
+ {child.label || child.fieldName} +
+
+ 필드: {child.fieldName} +
+
+ + +
+ + {isExpanded && ( +
+ {hasComponentConfigPanel(child.componentType) ? ( + { + onChange({ + children: children.map((c) => + c.id === child.id + ? { ...c, componentConfig: { ...c.componentConfig, ...newConfig } } + : c + ), + }); + }} + onLabelChange={(label) => updateComponentLabel(child.id, label)} + /> + ) : ( + <> + {child.fieldName && ( +
+
+ + + 바인딩: {child.fieldName} + +
+

+ 각 아이템의 "{child.fieldName}" 값이 자동으로 표시돼요 +

+
+ )} + +
+ 표시 라벨 + updateComponentLabel(child.id, e.target.value)} + placeholder="표시할 라벨" + className="h-7 w-[140px] text-xs" + /> +
+ +
+
+ + + updateComponentSize(child.id, parseInt(e.target.value) || 200, undefined) + } + className="h-7 text-xs" + /> +
+
+ + + updateComponentSize(child.id, undefined, parseInt(e.target.value) || 32) + } + className="h-7 text-xs" + /> +
+
+ +
+ 스타일 +
+
+ + +
+
+ + updateComponentStyle(child.id, "color", e.target.value)} + className="h-7" + /> +
+
+
+ + )} +
+ )} +
+ ); + })} +
+ ) : ( +
+ +
표시할 필드가 없어요
+
+ 아래에서 컬럼을 선택하세요 +
+
+ )} + + {/* 컬럼 추가 Combobox */} + + + + + + + + + 컬럼을 찾을 수 없습니다 + + {availableColumns.map((col) => { + const isAdded = children.some((c) => c.fieldName === col.columnName); + return ( + { + if (!isAdded) { + addComponent(col.columnName, col.displayName || col.columnName); + } + }} + disabled={isAdded} + className={cn( + "text-xs cursor-pointer", + isAdded && "opacity-50 cursor-not-allowed" + )} + > + +
+
{col.displayName || col.columnName}
+
+ {col.columnName} +
+
+ {isAdded && ( + + )} +
+ ); + })} +
+
+
+
+
+ + ); +} + +// 슬롯 컴포넌트 상세 설정 패널 +interface SlotComponentDetailPanelProps { + child: SlotComponentConfig; + screenTableName?: string; + onConfigChange: (newConfig: Record) => void; + onLabelChange: (label: string) => void; +} + +function SlotComponentDetailPanel({ + child, + screenTableName, + onConfigChange, + onLabelChange, +}: SlotComponentDetailPanelProps) { + return ( +
+ {child.fieldName && ( +
+
+ + + 바인딩: {child.fieldName} + +
+

+ 각 아이템의 "{child.fieldName}" 값이 자동으로 표시돼요 +

+
+ )} + +
+ 표시 라벨 + onLabelChange(e.target.value)} + placeholder="표시할 라벨" + className="h-7 w-[140px] text-xs" + /> +
+ +
+
+ {child.componentType} 상세 설정 +
+ +
+
+ ); +} + +export default V2RepeatContainerConfigPanel; diff --git a/frontend/lib/registry/components/v2-rack-structure/index.ts b/frontend/lib/registry/components/v2-rack-structure/index.ts index f4ff2b7d..5fbbf54d 100644 --- a/frontend/lib/registry/components/v2-rack-structure/index.ts +++ b/frontend/lib/registry/components/v2-rack-structure/index.ts @@ -4,7 +4,7 @@ import React from "react"; import { createComponentDefinition } from "../../utils/createComponentDefinition"; import { ComponentCategory } from "@/types/component"; import { RackStructureWrapper } from "./RackStructureComponent"; -import { RackStructureConfigPanel } from "./RackStructureConfigPanel"; +import { V2RackStructureConfigPanel as RackStructureConfigPanel } from "@/components/v2/config-panels/V2RackStructureConfigPanel"; import { defaultConfig } from "./config"; /** diff --git a/frontend/lib/registry/components/v2-repeat-container/index.ts b/frontend/lib/registry/components/v2-repeat-container/index.ts index 890a0bf9..30c869c1 100644 --- a/frontend/lib/registry/components/v2-repeat-container/index.ts +++ b/frontend/lib/registry/components/v2-repeat-container/index.ts @@ -3,7 +3,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition"; import { ComponentCategory } from "@/types/component"; import { RepeatContainerWrapper } from "./RepeatContainerComponent"; -import { RepeatContainerConfigPanel } from "./RepeatContainerConfigPanel"; +import { V2RepeatContainerConfigPanel as RepeatContainerConfigPanel } from "@/components/v2/config-panels/V2RepeatContainerConfigPanel"; import type { RepeatContainerConfig } from "./types"; /**