"use client"; import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Dialog, DialogContent, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { LayoutGrid, Database, Palette, X, Type, CreditCard, Minus, Tag, } from "lucide-react"; import { reportApi } from "@/lib/api/reportApi"; import type { CardLayoutConfig, CardLayoutRow, CardElement, CardDataCellElement, CardBadgeElement, } from "@/types/report"; import { CardElementPalette } from "./CardElementPalette"; import { CardCanvasEditor } from "./CardCanvasEditor"; interface CardLayoutModalProps { open: boolean; onOpenChange: (open: boolean) => void; initialConfig?: CardLayoutConfig; onSave: (config: CardLayoutConfig) => void; } type TabType = "layout" | "binding" | "style"; interface TableInfo { table_name: string; table_type: string; } interface ColumnInfo { column_name: string; data_type: string; is_nullable: string; } const generateId = () => `row_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; const DEFAULT_CONFIG: CardLayoutConfig = { tableName: "", primaryKey: "", rows: [ { id: generateId(), gridColumns: 4, elements: [], }, ], padding: "12px", gap: "8px", borderStyle: "solid", borderColor: "#e5e7eb", backgroundColor: "#ffffff", headerTitleFontSize: 14, headerTitleColor: "#1e40af", labelFontSize: 13, labelColor: "#374151", valueFontSize: 13, valueColor: "#000000", dividerThickness: 1, dividerColor: "#e5e7eb", }; const getElementIcon = (type: string) => { switch (type) { case "header": return ; case "dataCell": return ; case "divider": return ; case "badge": return ; default: return null; } }; export function CardLayoutModal({ open, onOpenChange, initialConfig, onSave, }: CardLayoutModalProps) { const [activeTab, setActiveTab] = useState("layout"); const [config, setConfig] = useState( initialConfig || DEFAULT_CONFIG, ); const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false); const initialSnapshotRef = useRef(""); const hasChanges = useCallback(() => { return JSON.stringify(config) !== initialSnapshotRef.current; }, [config]); const guard = useUnsavedChangesGuard({ hasChanges, onClose: () => onOpenChange(false), }); useEffect(() => { if (open) { const initConfig = initialConfig || DEFAULT_CONFIG; setConfig(initConfig); initialSnapshotRef.current = JSON.stringify(initConfig); setActiveTab("layout"); fetchTables(); } }, [open, initialConfig]); useEffect(() => { if (config.tableName) { fetchColumns(config.tableName); } else { setColumns([]); } }, [config.tableName]); const fetchTables = async () => { setLoadingTables(true); try { const response = await reportApi.getSchemaTableList(); if (response.success) { setTables(response.data); } } catch (error) { console.error("테이블 목록 조회 실패:", error); } finally { setLoadingTables(false); } }; const fetchColumns = async (tableName: string) => { setLoadingColumns(true); try { const response = await reportApi.getSchemaTableColumns(tableName); if (response.success) { setColumns(response.data); const pkCandidate = response.data.find( (col) => col.column_name.endsWith("_id") || col.column_name === "id" || col.column_name.endsWith("_pk"), ); if (pkCandidate && !config.primaryKey) { setConfig((prev) => ({ ...prev, primaryKey: pkCandidate.column_name, })); } } } catch (error) { console.error("컬럼 목록 조회 실패:", error); } finally { setLoadingColumns(false); } }; const usedColumns = useMemo(() => { const used = new Set(); config.rows.forEach((row) => { row.elements.forEach((el) => { if (el.type === "dataCell" && (el as CardDataCellElement).columnName) { used.add((el as CardDataCellElement).columnName!); } if (el.type === "badge" && (el as CardBadgeElement).columnName) { used.add((el as CardBadgeElement).columnName!); } }); }); return used; }, [config.rows]); const handleTableChange = (tableName: string) => { setConfig((prev) => ({ ...prev, tableName, primaryKey: "", rows: prev.rows.map((row) => ({ ...row, elements: row.elements.map((el) => { if (el.type === "dataCell") { return { ...el, columnName: undefined } as CardDataCellElement; } if (el.type === "badge") { return { ...el, columnName: undefined } as CardBadgeElement; } return el; }), })), })); }; const handleRowsChange = (rows: CardLayoutRow[]) => { setConfig((prev) => ({ ...prev, rows })); }; const handleColumnMapping = ( rowIndex: number, elementIndex: number, columnName: string, ) => { setConfig((prev) => { const newRows = [...prev.rows]; const newElements = [...newRows[rowIndex].elements]; const element = newElements[elementIndex]; if (element.type === "dataCell") { newElements[elementIndex] = { ...element, columnName, } as CardDataCellElement; } else if (element.type === "badge") { newElements[elementIndex] = { ...element, columnName, } as CardBadgeElement; } newRows[rowIndex] = { ...newRows[rowIndex], elements: newElements }; return { ...prev, rows: newRows }; }); }; const handleSave = () => { onSave(config); onOpenChange(false); }; const renderLayoutTab = () => ( ); const renderBindingTab = () => ( 테이블 선택 {tables.map((table) => ( {table.table_name} ))} 기본 키 setConfig((prev) => ({ ...prev, primaryKey: pk }))} disabled={!config.tableName || loadingColumns} > {columns.map((col) => ( {col.column_name} ))} {config.rows.map((row, rowIndex) => ( {row.elements.map((element, elementIndex) => { const needsBinding = element.type === "dataCell" || element.type === "badge"; const currentColumn = element.type === "dataCell" ? (element as CardDataCellElement).columnName : element.type === "badge" ? (element as CardBadgeElement).columnName : undefined; return ( {getElementIcon(element.type)} {element.type === "header" && (element as any).title} {element.type === "dataCell" && (element as CardDataCellElement).label} {element.type === "divider" && "구분선"} {element.type === "badge" && ((element as CardBadgeElement).label || "뱃지")} {needsBinding && ( handleColumnMapping(rowIndex, elementIndex, col) } disabled={!config.tableName} > {columns.map((col) => { const isUsed = usedColumns.has(col.column_name) && currentColumn !== col.column_name; return ( {col.column_name} {isUsed && " (사용 중)"} ); })} )} ); })} ))} {!config.tableName && ( 테이블을 먼저 선택해주세요. )} {config.tableName && config.rows.every((r) => r.elements.length === 0) && ( 레이아웃 탭에서 요소를 먼저 추가해주세요. )} ); const renderStyleTab = () => ( 카드 스타일 패딩 setConfig((prev) => ({ ...prev, padding: v }))}> 8px 12px 16px 20px 행 간격 setConfig((prev) => ({ ...prev, gap: v }))}> 4px 8px 12px 16px 테두리 setConfig((prev) => ({ ...prev, borderStyle: v }))}> 실선 점선 없음 테두리 색상 setConfig((prev) => ({ ...prev, borderColor: e.target.value }))} className="h-9 w-full p-1" /> 배경색 setConfig((prev) => ({ ...prev, backgroundColor: e.target.value }))} className="h-9 w-full p-1" /> 요소별 스타일 헤더 폰트 크기 setConfig((prev) => ({ ...prev, headerTitleFontSize: parseInt(e.target.value) || 14 }))} className="h-9 text-sm" /> 헤더 색상 setConfig((prev) => ({ ...prev, headerTitleColor: e.target.value }))} className="h-9 w-full p-1" /> 라벨 폰트 크기 setConfig((prev) => ({ ...prev, labelFontSize: parseInt(e.target.value) || 13 }))} className="h-9 text-sm" /> 라벨 색상 setConfig((prev) => ({ ...prev, labelColor: e.target.value }))} className="h-9 w-full p-1" /> 값 폰트 크기 setConfig((prev) => ({ ...prev, valueFontSize: parseInt(e.target.value) || 13 }))} className="h-9 text-sm" /> 값 색상 setConfig((prev) => ({ ...prev, valueColor: e.target.value }))} className="h-9 w-full p-1" /> 구분선 두께 setConfig((prev) => ({ ...prev, dividerThickness: parseInt(e.target.value) || 1 }))} className="h-9 text-sm" /> 구분선 색상 setConfig((prev) => ({ ...prev, dividerColor: e.target.value }))} className="h-9 w-full p-1" /> ); return ( <> 카드 레이아웃 설정 카드 컴포넌트의 레이아웃, 데이터 바인딩, 스타일을 설정합니다 {/* Header */} 카드 레이아웃 설정 {/* Tab */} setActiveTab("layout")} className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${ activeTab === "layout" ? "bg-blue-50 text-blue-700 shadow-sm" : "bg-transparent text-foreground hover:text-foreground/80" }`} > 레이아웃 setActiveTab("binding")} className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${ activeTab === "binding" ? "bg-blue-50 text-blue-700 shadow-sm" : "bg-transparent text-foreground hover:text-foreground/80" }`} > 데이터 바인딩 setActiveTab("style")} className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${ activeTab === "style" ? "bg-blue-50 text-blue-700 shadow-sm" : "bg-transparent text-foreground hover:text-foreground/80" }`} > 스타일 {/* Content */} {activeTab === "layout" && renderLayoutTab()} {activeTab === "binding" && renderBindingTab()} {activeTab === "style" && renderStyleTab()} {/* Footer */} 취소 저장 > ); }