"use client"; /** * pop-dashboard 설정 패널 (디자이너용) * * 3개 탭: * [기본 설정] - 표시 모드, 간격, 인디케이터 * [아이템 관리] - 아이템 추가/삭제/순서변경, 데이터 소스 설정 * [페이지] - 페이지(슬라이드) 추가/삭제, 각 페이지 독립 그리드 레이아웃 */ import React, { useState, useEffect, useCallback } from "react"; import { Plus, Trash2, ChevronDown, ChevronUp, GripVertical, Check, ChevronsUpDown, Eye, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import type { PopDashboardConfig, DashboardItem, DashboardSubType, DashboardDisplayMode, DataSourceConfig, DataSourceFilter, FilterOperator, FormulaConfig, ItemVisibility, DashboardCell, DashboardPage, JoinConfig, JoinType, ItemStyleConfig, } from "../types"; import { TEXT_ALIGN_LABELS, } from "../types"; import { migrateConfig } from "./PopDashboardComponent"; import { fetchTableColumns, fetchTableList, type ColumnInfo, type TableInfo, } from "./utils/dataFetcher"; import { validateExpression } from "./utils/formula"; // ===== Props ===== interface ConfigPanelProps { config: PopDashboardConfig | undefined; onUpdate: (config: PopDashboardConfig) => void; /** 페이지 미리보기 요청 (-1이면 해제) */ onPreviewPage?: (pageIndex: number) => void; /** 현재 미리보기 중인 페이지 인덱스 */ previewPageIndex?: number; } // ===== 기본값 ===== const DEFAULT_CONFIG: PopDashboardConfig = { items: [], pages: [], displayMode: "arrows", autoSlideInterval: 5, autoSlideResumeDelay: 3, showIndicator: true, gap: 8, }; const DEFAULT_VISIBILITY: ItemVisibility = { showLabel: true, showValue: true, showUnit: true, showTrend: true, showSubLabel: false, showTarget: true, }; const DEFAULT_DATASOURCE: DataSourceConfig = { tableName: "", filters: [], sort: [], }; // ===== 라벨 상수 ===== const DISPLAY_MODE_LABELS: Record = { arrows: "좌우 버튼", "auto-slide": "자동 슬라이드", scroll: "스크롤", }; const SUBTYPE_LABELS: Record = { "kpi-card": "KPI 카드", chart: "차트", gauge: "게이지", "stat-card": "통계 카드", }; const JOIN_TYPE_LABELS: Record = { inner: "INNER JOIN", left: "LEFT JOIN", right: "RIGHT JOIN", }; const FILTER_OPERATOR_LABELS: Record = { "=": "같음 (=)", "!=": "다름 (!=)", ">": "초과 (>)", ">=": "이상 (>=)", "<": "미만 (<)", "<=": "이하 (<=)", like: "포함 (LIKE)", in: "목록 (IN)", between: "범위 (BETWEEN)", }; // ===== 데이터 소스 편집기 ===== function DataSourceEditor({ dataSource, onChange, }: { dataSource: DataSourceConfig; onChange: (ds: DataSourceConfig) => void; }) { // 테이블 목록 (Combobox용) const [tables, setTables] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [tableOpen, setTableOpen] = useState(false); // 컬럼 목록 (집계 대상 컬럼용) const [columns, setColumns] = useState([]); const [loadingCols, setLoadingCols] = useState(false); const [columnOpen, setColumnOpen] = useState(false); // 그룹핑 컬럼 (차트 X축용) const [groupByOpen, setGroupByOpen] = useState(false); // 마운트 시 테이블 목록 로드 useEffect(() => { setLoadingTables(true); fetchTableList() .then(setTables) .finally(() => setLoadingTables(false)); }, []); // 테이블 변경 시 컬럼 목록 조회 useEffect(() => { if (!dataSource.tableName) { setColumns([]); return; } setLoadingCols(true); fetchTableColumns(dataSource.tableName) .then(setColumns) .finally(() => setLoadingCols(false)); }, [dataSource.tableName]); return (
{/* 테이블 선택 (검색 가능한 Combobox) */}
테이블을 찾을 수 없습니다 {tables.map((table) => ( { const newVal = table.tableName === dataSource.tableName ? "" : table.tableName; onChange({ ...dataSource, tableName: newVal }); setTableOpen(false); }} className="text-xs" >
{table.displayName || table.tableName} {table.displayName && table.displayName !== table.tableName && ( {table.tableName} )}
))}
{/* 집계 함수 + 대상 컬럼 */}
{dataSource.aggregation && (
컬럼을 찾을 수 없습니다. {columns.map((col) => ( { onChange({ ...dataSource, aggregation: { ...dataSource.aggregation!, column: col.name, }, }); setColumnOpen(false); }} className="text-xs" > {col.name} ({col.type}) ))}
)}
{/* 그룹핑 (차트 X축 분류) */} {dataSource.aggregation && (
컬럼을 찾을 수 없습니다. {columns.map((col) => ( { const current = dataSource.aggregation?.groupBy ?? []; const isSelected = current.includes(col.name); const newGroupBy = isSelected ? current.filter((g) => g !== col.name) : [...current, col.name]; onChange({ ...dataSource, aggregation: { ...dataSource.aggregation!, groupBy: newGroupBy.length > 0 ? newGroupBy : undefined, }, }); setGroupByOpen(false); }} className="text-xs" > {col.name} ({col.type}) ))}

차트에서 X축 카테고리로 사용됩니다

)} {/* 자동 새로고침 (Switch + 주기 입력) */}
0} onCheckedChange={(checked) => onChange({ ...dataSource, refreshInterval: checked ? 30 : 0, }) } />
{(dataSource.refreshInterval ?? 0) > 0 && (
onChange({ ...dataSource, refreshInterval: Math.max( 5, parseInt(e.target.value) || 30 ), }) } className="h-7 text-xs" min={5} />
)}
{/* 조인 설정 */} onChange({ ...dataSource, joins })} /> {/* 필터 조건 */} onChange({ ...dataSource, filters })} />
); } // ===== 조인 편집기 ===== function JoinEditor({ joins, mainTable, onChange, }: { joins: JoinConfig[]; mainTable: string; onChange: (joins: JoinConfig[]) => void; }) { const [tables, setTables] = useState([]); // 테이블 목록 로드 useEffect(() => { fetchTableList().then(setTables); }, []); const addJoin = () => { onChange([ ...joins, { targetTable: "", joinType: "left", on: { sourceColumn: "", targetColumn: "" }, }, ]); }; const updateJoin = (index: number, partial: Partial) => { const newJoins = [...joins]; newJoins[index] = { ...newJoins[index], ...partial }; onChange(newJoins); }; const removeJoin = (index: number) => { onChange(joins.filter((_, i) => i !== index)); }; return (
{!mainTable && joins.length === 0 && (

먼저 메인 테이블을 선택하세요

)} {joins.map((join, index) => ( updateJoin(index, partial)} onRemove={() => removeJoin(index)} /> ))}
); } function JoinRow({ join, mainTable, tables, onUpdate, onRemove, }: { join: JoinConfig; mainTable: string; tables: TableInfo[]; onUpdate: (partial: Partial) => void; onRemove: () => void; }) { const [targetColumns, setTargetColumns] = useState([]); const [sourceColumns, setSourceColumns] = useState([]); const [targetTableOpen, setTargetTableOpen] = useState(false); // 메인 테이블 컬럼 로드 useEffect(() => { if (!mainTable) return; fetchTableColumns(mainTable).then(setSourceColumns); }, [mainTable]); // 조인 대상 테이블 컬럼 로드 useEffect(() => { if (!join.targetTable) return; fetchTableColumns(join.targetTable).then(setTargetColumns); }, [join.targetTable]); return (
{/* 조인 타입 */} {/* 조인 대상 테이블 (Combobox) */} 없음 {tables .filter((t) => t.tableName !== mainTable) .map((t) => ( { onUpdate({ targetTable: t.tableName }); setTargetTableOpen(false); }} className="text-xs" > {t.displayName || t.tableName} ))} {/* 삭제 */}
{/* 조인 조건 (ON 절) */} {join.targetTable && (
ON =
)}
); } // ===== 필터 편집기 ===== function FilterEditor({ filters, tableName, onChange, }: { filters: DataSourceFilter[]; tableName: string; onChange: (filters: DataSourceFilter[]) => void; }) { const [columns, setColumns] = useState([]); useEffect(() => { if (!tableName) return; fetchTableColumns(tableName).then(setColumns); }, [tableName]); const addFilter = () => { onChange([...filters, { column: "", operator: "=", value: "" }]); }; const updateFilter = ( index: number, partial: Partial ) => { const newFilters = [...filters]; newFilters[index] = { ...newFilters[index], ...partial }; // operator 변경 시 value 초기화 if (partial.operator) { if (partial.operator === "between") { newFilters[index].value = ["", ""]; } else if (partial.operator === "in") { newFilters[index].value = []; } else if ( typeof newFilters[index].value !== "string" && typeof newFilters[index].value !== "number" ) { newFilters[index].value = ""; } } onChange(newFilters); }; const removeFilter = (index: number) => { onChange(filters.filter((_, i) => i !== index)); }; return (
{filters.map((filter, index) => (
{/* 컬럼 선택 */} {/* 연산자 */} {/* 값 입력 (연산자에 따라 다른 UI) */}
{filter.operator === "between" ? (
{ const arr = Array.isArray(filter.value) ? [...filter.value] : ["", ""]; arr[0] = e.target.value; updateFilter(index, { value: arr }); }} placeholder="시작" className="h-7 text-[10px]" /> { const arr = Array.isArray(filter.value) ? [...filter.value] : ["", ""]; arr[1] = e.target.value; updateFilter(index, { value: arr }); }} placeholder="끝" className="h-7 text-[10px]" />
) : filter.operator === "in" ? ( { const vals = e.target.value .split(",") .map((v) => v.trim()) .filter(Boolean); updateFilter(index, { value: vals }); }} placeholder="값1, 값2, 값3" className="h-7 text-[10px]" /> ) : ( updateFilter(index, { value: e.target.value }) } placeholder="값" className="h-7 text-[10px]" /> )}
{/* 삭제 */}
))}
); } // ===== 수식 편집기 ===== function FormulaEditor({ formula, onChange, }: { formula: FormulaConfig; onChange: (f: FormulaConfig) => void; }) { const availableIds = formula.values.map((v) => v.id); const isValid = formula.expression ? validateExpression(formula.expression, availableIds) : true; return (

계산식 설정

{/* 값 목록 */} {formula.values.map((fv, index) => (
{fv.id} { const newValues = [...formula.values]; newValues[index] = { ...fv, label: e.target.value }; onChange({ ...formula, values: newValues }); }} placeholder="라벨 (예: 생산량)" className="h-7 flex-1 text-xs" /> {formula.values.length > 2 && ( )}
{ const newValues = [...formula.values]; newValues[index] = { ...fv, dataSource: ds }; onChange({ ...formula, values: newValues }); }} />
))} {/* 값 추가 */} {/* 수식 입력 */}
onChange({ ...formula, expression: e.target.value }) } placeholder="예: A / B * 100" className={`h-8 text-xs ${!isValid ? "border-destructive" : ""}`} /> {!isValid && (

수식에 정의되지 않은 변수가 있습니다

)}
{/* 표시 형태 */}
); } // ===== 아이템 편집기 ===== function ItemEditor({ item, index, onUpdate, onDelete, onMoveUp, onMoveDown, isFirst, isLast, }: { item: DashboardItem; index: number; onUpdate: (item: DashboardItem) => void; onDelete: () => void; onMoveUp: () => void; onMoveDown: () => void; isFirst: boolean; isLast: boolean; }) { const [expanded, setExpanded] = useState(false); const [dataMode, setDataMode] = useState<"single" | "formula">( item.formula?.enabled ? "formula" : "single" ); return (
{/* 헤더 */}
onUpdate({ ...item, label: e.target.value })} placeholder={`아이템 ${index + 1}`} className="h-6 min-w-0 flex-1 border-0 bg-transparent px-1 text-xs font-medium shadow-none focus-visible:ring-1" /> {SUBTYPE_LABELS[item.subType]} onUpdate({ ...item, visible: checked }) } className="scale-75" />
{/* 상세 설정 */} {expanded && (
{dataMode === "formula" && item.formula ? ( onUpdate({ ...item, formula: f })} /> ) : ( onUpdate({ ...item, dataSource: ds })} /> )} {/* 요소별 보이기/숨기기 */}
{( [ ["showLabel", "라벨"], ["showValue", "값"], ["showUnit", "단위"], ["showTrend", "증감율"], ["showSubLabel", "보조라벨"], ["showTarget", "목표값"], ] as const ).map(([key, label]) => ( ))}
{/* 서브타입별 추가 설정 */} {item.subType === "kpi-card" && (
onUpdate({ ...item, kpiConfig: { ...item.kpiConfig, unit: e.target.value }, }) } placeholder="EA, 톤, 원" className="h-8 text-xs" />
)} {item.subType === "chart" && (
{/* X축/Y축 자동 안내 */}

X축: 그룹핑(X축)에서 선택한 컬럼 자동 적용 / Y축: 집계 결과(value) 자동 적용

)} {item.subType === "gauge" && (
onUpdate({ ...item, gaugeConfig: { ...item.gaugeConfig, min: parseInt(e.target.value) || 0, max: item.gaugeConfig?.max ?? 100, }, }) } className="h-8 text-xs" />
onUpdate({ ...item, gaugeConfig: { ...item.gaugeConfig, min: item.gaugeConfig?.min ?? 0, max: parseInt(e.target.value) || 100, }, }) } className="h-8 text-xs" />
onUpdate({ ...item, gaugeConfig: { ...item.gaugeConfig, min: item.gaugeConfig?.min ?? 0, max: item.gaugeConfig?.max ?? 100, target: parseInt(e.target.value) || undefined, }, }) } className="h-8 text-xs" />
)} {/* 통계 카드 카테고리 설정 */} {item.subType === "stat-card" && (
{(item.statConfig?.categories ?? []).map((cat, catIdx) => (
{ const newCats = [...(item.statConfig?.categories ?? [])]; newCats[catIdx] = { ...cat, label: e.target.value }; onUpdate({ ...item, statConfig: { ...item.statConfig, categories: newCats }, }); }} placeholder="라벨 (예: 수주)" className="h-6 flex-1 text-xs" /> { const newCats = [...(item.statConfig?.categories ?? [])]; newCats[catIdx] = { ...cat, color: e.target.value || undefined }; onUpdate({ ...item, statConfig: { ...item.statConfig, categories: newCats }, }); }} placeholder="#색상코드" className="h-6 w-20 text-xs" />
{/* 필터 조건: 컬럼 / 연산자 / 값 */}
{ const newCats = [...(item.statConfig?.categories ?? [])]; newCats[catIdx] = { ...cat, filter: { ...cat.filter, column: e.target.value }, }; onUpdate({ ...item, statConfig: { ...item.statConfig, categories: newCats }, }); }} placeholder="컬럼" className="h-6 w-20 text-[10px]" /> { const newCats = [...(item.statConfig?.categories ?? [])]; newCats[catIdx] = { ...cat, filter: { ...cat.filter, value: e.target.value }, }; onUpdate({ ...item, statConfig: { ...item.statConfig, categories: newCats }, }); }} placeholder="값" className="h-6 flex-1 text-[10px]" />
))} {(item.statConfig?.categories ?? []).length === 0 && (

카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다

)}
)}
)}
); } // ===== 그리드 레이아웃 편집기 ===== /** 기본 셀 그리드 생성 헬퍼 */ function generateDefaultCells( cols: number, rows: number ): DashboardCell[] { const cells: DashboardCell[] = []; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { cells.push({ id: `cell-${r}-${c}`, gridColumn: `${c + 1} / ${c + 2}`, gridRow: `${r + 1} / ${r + 2}`, itemId: null, }); } } return cells; } // ===================================================== // 아이템 스타일 에디터 (접기/펼치기 지원) // ===================================================== function ItemStyleEditor({ item, onUpdate, }: { item: DashboardItem; onUpdate: (updatedItem: DashboardItem) => void; }) { const [expanded, setExpanded] = useState(false); const updateStyle = (partial: Partial) => { const updatedItem = { ...item, itemStyle: { ...item.itemStyle, ...partial }, }; onUpdate(updatedItem); }; return (
{/* 헤더 - 클릭으로 접기/펼치기 */} {/* 내용 - 접기/펼치기 */} {expanded && (
{/* 라벨 정렬 */}
라벨 정렬
{(["left", "center", "right"] as const).map((align) => ( ))}
)}
); } function GridLayoutEditor({ cells, gridColumns, gridRows, items, onChange, onUpdateItem, }: { cells: DashboardCell[]; gridColumns: number; gridRows: number; items: DashboardItem[]; onChange: (cells: DashboardCell[], cols: number, rows: number) => void; /** 아이템 스타일 업데이트 콜백 */ onUpdateItem?: (updatedItem: DashboardItem) => void; }) { const ensuredCells = cells.length > 0 ? cells : generateDefaultCells(gridColumns, gridRows); return (
{/* 행/열 조절 버튼 */}
{gridColumns}
{gridRows}
{/* 시각적 그리드 프리뷰 + 아이템 배정 */}
{ensuredCells.map((cell) => (
))}

각 셀을 클릭하여 아이템을 배정하세요. +/- 버튼으로 행/열을 추가/삭제할 수 있습니다.

{/* 배정된 아이템별 스타일 설정 */} {onUpdateItem && (() => { const assignedItemIds = ensuredCells .map((c) => c.itemId) .filter((id): id is string => !!id); const uniqueIds = [...new Set(assignedItemIds)]; const assignedItems = uniqueIds .map((id) => items.find((i) => i.id === id)) .filter((i): i is DashboardItem => !!i); if (assignedItems.length === 0) return null; return (
아이템 스타일 {assignedItems.map((item) => ( ))}
); })()}
); } // ===== 페이지 편집기 ===== function PageEditor({ page, pageIndex, items, onChange, onDelete, onPreview, isPreviewing, onUpdateItem, }: { page: DashboardPage; pageIndex: number; items: DashboardItem[]; onChange: (updatedPage: DashboardPage) => void; onDelete: () => void; onPreview?: () => void; isPreviewing?: boolean; onUpdateItem?: (updatedItem: DashboardItem) => void; }) { const [expanded, setExpanded] = useState(true); return (
{/* 헤더 */}
{page.label || `페이지 ${pageIndex + 1}`} {page.gridColumns}x{page.gridRows}
{/* 상세 */} {expanded && (
{/* 라벨 */}
onChange({ ...page, label: e.target.value }) } placeholder={`페이지 ${pageIndex + 1}`} className="h-7 text-xs" />
{/* GridLayoutEditor 재사용 */} onChange({ ...page, gridCells: cells, gridColumns: cols, gridRows: rows, }) } onUpdateItem={onUpdateItem} />
)}
); } // ===== 메인 설정 패널 ===== export function PopDashboardConfigPanel(props: ConfigPanelProps) { const { config, onUpdate: onChange } = props; // config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장 const merged = { ...DEFAULT_CONFIG, ...(config || {}) }; // 마이그레이션: 기존 useGridLayout/grid* -> pages 기반으로 변환 const cfg = migrateConfig( merged as unknown as Record ) as PopDashboardConfig; const [activeTab, setActiveTab] = useState<"basic" | "items" | "pages">( "basic" ); // 설정 변경 헬퍼 const updateConfig = useCallback( (partial: Partial) => { onChange({ ...cfg, ...partial }); }, [cfg, onChange] ); // 아이템 추가 const addItem = useCallback( (subType: DashboardSubType) => { const newItem: DashboardItem = { id: `item-${Date.now()}`, label: `${SUBTYPE_LABELS[subType]} ${cfg.items.length + 1}`, visible: true, subType, dataSource: { ...DEFAULT_DATASOURCE }, visibility: { ...DEFAULT_VISIBILITY }, }; updateConfig({ items: [...cfg.items, newItem] }); }, [cfg.items, updateConfig] ); // 아이템 업데이트 const updateItem = useCallback( (index: number, item: DashboardItem) => { const newItems = [...cfg.items]; newItems[index] = item; updateConfig({ items: newItems }); }, [cfg.items, updateConfig] ); // 아이템 삭제 (모든 페이지의 셀 배정도 해제) const deleteItem = useCallback( (index: number) => { const deletedId = cfg.items[index].id; const newItems = cfg.items.filter((_, i) => i !== index); const newPages = cfg.pages?.map((page) => ({ ...page, gridCells: page.gridCells.map((cell) => cell.itemId === deletedId ? { ...cell, itemId: null } : cell ), })); updateConfig({ items: newItems, pages: newPages }); }, [cfg.items, cfg.pages, updateConfig] ); // 아이템 순서 변경 const moveItem = useCallback( (from: number, to: number) => { if (to < 0 || to >= cfg.items.length) return; const newItems = [...cfg.items]; const [moved] = newItems.splice(from, 1); newItems.splice(to, 0, moved); updateConfig({ items: newItems }); }, [cfg.items, updateConfig] ); return (
{/* 탭 헤더 */}
{( [ ["basic", "기본 설정"], ["items", "아이템"], ["pages", "페이지"], ] as const ).map(([key, label]) => ( ))}
{/* ===== 기본 설정 탭 ===== */} {activeTab === "basic" && (
{/* 표시 모드 */}
{/* 자동 슬라이드 설정 */} {cfg.displayMode === "auto-slide" && (
updateConfig({ autoSlideInterval: parseInt(e.target.value) || 5, }) } className="h-8 text-xs" min={1} />
updateConfig({ autoSlideResumeDelay: parseInt(e.target.value) || 3, }) } className="h-8 text-xs" min={1} />
)} {/* 인디케이터 */}
updateConfig({ showIndicator: checked }) } />
{/* 간격 */}
updateConfig({ gap: parseInt(e.target.value) || 8 }) } className="h-8 text-xs" min={0} />
{/* 배경색 */}
updateConfig({ backgroundColor: e.target.value || undefined, }) } placeholder="예: #f0f0f0" className="h-8 text-xs" />
)} {/* ===== 아이템 관리 탭 ===== */} {activeTab === "items" && (
{cfg.items.map((item, index) => ( updateItem(index, updated)} onDelete={() => deleteItem(index)} onMoveUp={() => moveItem(index, index - 1)} onMoveDown={() => moveItem(index, index + 1)} isFirst={index === 0} isLast={index === cfg.items.length - 1} /> ))}
{(Object.keys(SUBTYPE_LABELS) as DashboardSubType[]).map( (subType) => ( ) )}
)} {/* ===== 페이지 탭 ===== */} {activeTab === "pages" && (
{/* 페이지 목록 */} {(cfg.pages ?? []).map((page, pageIdx) => ( { const newPages = [...(cfg.pages ?? [])]; newPages[pageIdx] = updatedPage; updateConfig({ pages: newPages }); }} onDelete={() => { const newPages = (cfg.pages ?? []).filter( (_, i) => i !== pageIdx ); updateConfig({ pages: newPages }); }} onPreview={() => { if (props.onPreviewPage) { // 같은 페이지를 다시 누르면 미리보기 해제 props.onPreviewPage(props.previewPageIndex === pageIdx ? -1 : pageIdx); } }} isPreviewing={props.previewPageIndex === pageIdx} onUpdateItem={(updatedItem) => { const newItems = cfg.items.map((i) => i.id === updatedItem.id ? updatedItem : i ); updateConfig({ items: newItems }); // 스타일 변경 시 자동으로 해당 페이지 미리보기 활성화 if (props.onPreviewPage && props.previewPageIndex !== pageIdx) { props.onPreviewPage(pageIdx); } }} /> ))} {/* 페이지 추가 버튼 */} {(cfg.pages?.length ?? 0) === 0 && (

페이지를 추가하면 각 페이지에 독립적인 그리드 레이아웃을 설정할 수 있습니다.
페이지가 없으면 아이템이 하나씩 슬라이드됩니다.

)}
)}
); }