"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;