"use client"; import React, { useState, useCallback } from "react"; import { useDrop, useDrag } from "react-dnd"; 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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import { Plus, Trash2, ChevronUp, ChevronDown, ChevronRight, Minus as MinusIcon, Type, CreditCard, Minus, Tag, ImageIcon, Hash, Calendar, Link2, Circle, Space, FileText, } from "lucide-react"; import type { CardLayoutRow, CardElement, CardElementType, CardHeaderElement, CardDataCellElement, CardDividerElement, CardBadgeElement, CardImageElement, CardNumberElement, CardDateElement, CardLinkElement, CardStatusElement, CardSpacerElement, CardStaticTextElement, CellDirection, } from "@/types/report"; import { CARD_ELEMENT_DND_TYPE } from "./CardElementPalette"; interface CardCanvasEditorProps { rows: CardLayoutRow[]; onRowsChange: (rows: CardLayoutRow[]) => void; } const generateId = () => `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; const createDefaultElement = (type: CardElementType): CardElement => { const id = generateId(); switch (type) { case "header": return { id, type: "header", title: "제목", colspan: 1 } as CardHeaderElement; case "dataCell": return { id, type: "dataCell", direction: "vertical", label: "라벨", colspan: 1, } as CardDataCellElement; case "divider": return { id, type: "divider", colspan: 1 } as CardDividerElement; case "badge": return { id, type: "badge", label: "", colspan: 1 } as CardBadgeElement; case "image": return { id, type: "image", colspan: 1, objectFit: "contain", height: 80 } as CardImageElement; case "number": return { id, type: "number", label: "금액", colspan: 1 } as CardNumberElement; case "date": return { id, type: "date", label: "날짜", colspan: 1, dateFormat: "YYYY-MM-DD" } as CardDateElement; case "link": return { id, type: "link", label: "링크", colspan: 1, openInNewTab: true } as CardLinkElement; case "status": return { id, type: "status", colspan: 1, statusMappings: [] } as CardStatusElement; case "spacer": return { id, type: "spacer", colspan: 1, height: 16 } as CardSpacerElement; case "staticText": return { id, type: "staticText", text: "텍스트", colspan: 1, fontSize: 13 } as CardStaticTextElement; } }; const getElementIcon = (type: CardElementType) => { switch (type) { case "header": return ; case "dataCell": return ; case "divider": return ; case "badge": return ; case "image": return ; case "number": return ; case "date": return ; case "link": return ; case "status": return ; case "spacer": return ; case "staticText": return ; } }; const getElementLabel = (element: CardElement): string => { switch (element.type) { case "header": return element.title || "헤더"; case "dataCell": return element.label || "데이터 셀"; case "divider": return "구분선"; case "badge": return element.label || "뱃지"; case "image": return "이미지"; case "number": return (element as CardNumberElement).label || "숫자/금액"; case "date": return (element as CardDateElement).label || "날짜"; case "link": return (element as CardLinkElement).label || "링크"; case "status": return "상태"; case "spacer": return "빈 공간"; case "staticText": return (element as CardStaticTextElement).text || "고정 텍스트"; } }; const TYPE_FIXED_LABELS: Record = { header: "헤더", dataCell: "데이터 셀", divider: "구분선", badge: "뱃지", image: "이미지", number: "숫자/금액", date: "날짜", link: "링크", status: "상태", spacer: "빈 공간", staticText: "고정 텍스트", }; const CARD_CELL_MOVE_TYPE = "card-cell-move"; interface DropZoneProps { rowIndex: number; cellIndex: number; element: CardElement | null; isSelected: boolean; onClick: () => void; onDrop: (type: CardElementType) => void; onDelete?: () => void; onMove?: (fromRow: number, fromCell: number) => void; colspan: number; } function DropZone({ rowIndex, cellIndex, element, isSelected, onClick, onDrop, onDelete, onMove, colspan, }: DropZoneProps) { const [{ isDragging }, drag] = useDrag(() => ({ type: CARD_CELL_MOVE_TYPE, item: { fromRow: rowIndex, fromCell: cellIndex }, canDrag: () => !!element, collect: (monitor) => ({ isDragging: monitor.isDragging() }), }), [rowIndex, cellIndex, element]); const [{ isOver, canDrop }, drop] = useDrop(() => ({ accept: [CARD_ELEMENT_DND_TYPE, CARD_CELL_MOVE_TYPE], drop: (item: { elementType?: CardElementType; fromRow?: number; fromCell?: number }, monitor) => { const itemType = monitor.getItemType(); if (itemType === CARD_ELEMENT_DND_TYPE && item.elementType) { onDrop(item.elementType); } else if (itemType === CARD_CELL_MOVE_TYPE && item.fromRow !== undefined && item.fromCell !== undefined) { onMove?.(item.fromRow, item.fromCell); } }, collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), }), [onDrop, onMove]); const combinedRef = (node: HTMLDivElement | null) => { drag(node); drop(node); }; return (
{element ? ( <>
{getElementIcon(element.type)} {getElementLabel(element)}
{onDelete && ( )} ) : (
드롭하여 추가
)}
); } interface CellSettingsPanelProps { element: CardElement; onUpdate: (element: CardElement) => void; maxColspan: number; } function CellSettingsPanel({ element, onUpdate, maxColspan, }: CellSettingsPanelProps) { const handleColspanChange = (delta: number) => { const currentColspan = element.colspan || 1; const newColspan = Math.max(1, Math.min(maxColspan, currentColspan + delta)); onUpdate({ ...element, colspan: newColspan }); }; return (
기본 설정
{/* 공통: colspan */}
{element.colspan || 1}
{/* 방향 설정 (수직/수평) — header, divider, spacer 제외 */} {!["header", "divider", "spacer"].includes(element.type) && (
onUpdate({ ...element, direction: value }) } className="flex gap-4" >
)} {/* 헤더 설정 */} {element.type === "header" && (
onUpdate({ ...element, title: e.target.value } as CardHeaderElement) } placeholder="헤더 제목" className="h-9 text-sm" />
onUpdate({ ...element, fontSize: parseInt(e.target.value) || 20 } as CardHeaderElement) } className="h-9 text-xs" />
onUpdate({ ...element, color: e.target.value } as CardHeaderElement) } className="h-6 w-6 shrink-0 cursor-pointer rounded border-0 p-0" /> onUpdate({ ...element, color: e.target.value } as CardHeaderElement) } className="h-6 border-0 bg-transparent px-0 font-mono text-[10px] shadow-none focus-visible:ring-0" />
)} {/* 데이터 셀 설정 */} {element.type === "dataCell" && (
onUpdate({ ...element, label: e.target.value } as CardDataCellElement) } placeholder="라벨 입력" className="h-9 text-sm" />
onUpdate({ ...element, labelColor: e.target.value })} className="h-6 w-6 shrink-0 cursor-pointer rounded border-0 p-0" /> onUpdate({ ...element, labelColor: e.target.value })} className="h-6 border-0 bg-transparent px-0 font-mono text-[10px] shadow-none focus-visible:ring-0" />
onUpdate({ ...element, valueColor: e.target.value })} className="h-6 w-6 shrink-0 cursor-pointer rounded border-0 p-0" /> onUpdate({ ...element, valueColor: e.target.value })} className="h-6 border-0 bg-transparent px-0 font-mono text-[10px] shadow-none focus-visible:ring-0" />
)} {/* 구분선 설정 */} {element.type === "divider" && (
onUpdate({ ...element, thickness: parseInt(e.target.value) || 1 })} className="h-9 text-xs" />
onUpdate({ ...element, color: e.target.value })} className="h-6 w-6 shrink-0 cursor-pointer rounded border-0 p-0" /> onUpdate({ ...element, color: e.target.value })} className="h-6 border-0 bg-transparent px-0 font-mono text-[10px] shadow-none focus-visible:ring-0" />
)} {/* 뱃지 설정 */} {element.type === "badge" && ( <>
onUpdate({ ...element, label: e.target.value } as CardBadgeElement) } placeholder="예: 상태" className="h-9 text-sm" />
onUpdate({ ...element, bgColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" /> onUpdate({ ...element, bgColor: e.target.value })} className="h-9 text-sm flex-1" />
onUpdate({ ...element, textColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" /> onUpdate({ ...element, textColor: e.target.value })} className="h-9 text-sm flex-1" />
)} {/* 이미지 설정 */} {element.type === "image" && ( <>
onUpdate({ ...element, columnName: e.target.value } as CardImageElement)} placeholder="image_url" className="h-9 text-sm" />
onUpdate({ ...element, height: parseInt(e.target.value) || 80 } as CardImageElement)} className="h-9 text-sm" />
)} {/* 숫자/금액 설정 */} {element.type === "number" && ( <>
onUpdate({ ...element, label: e.target.value } as CardNumberElement)} placeholder="금액" className="h-9 text-sm" />
onUpdate({ ...element, columnName: e.target.value } as CardNumberElement)} placeholder="amount" className="h-9 text-sm" />
{(element as CardNumberElement).numberFormat === "currency" && (
onUpdate({ ...element, currencySuffix: e.target.value } as CardNumberElement)} placeholder="원" className="h-9 text-sm" />
)} )} {/* 날짜 설정 */} {element.type === "date" && ( <>
onUpdate({ ...element, label: e.target.value } as CardDateElement)} placeholder="날짜" className="h-9 text-sm" />
onUpdate({ ...element, columnName: e.target.value } as CardDateElement)} placeholder="created_at" className="h-9 text-sm" />
onUpdate({ ...element, dateFormat: e.target.value } as CardDateElement)} placeholder="YYYY-MM-DD" className="h-9 text-sm" />
)} {/* 링크 설정 */} {element.type === "link" && ( <>
onUpdate({ ...element, label: e.target.value } as CardLinkElement)} placeholder="링크" className="h-9 text-sm" />
onUpdate({ ...element, columnName: e.target.value } as CardLinkElement)} placeholder="url" className="h-9 text-sm" />
onUpdate({ ...element, linkText: e.target.value } as CardLinkElement)} placeholder="자동 (URL 표시)" className="h-9 text-sm" />
onUpdate({ ...element, openInNewTab: !!checked } as CardLinkElement)} />
)} {/* 상태 설정 */} {element.type === "status" && ( <>
onUpdate({ ...element, columnName: e.target.value } as CardStatusElement)} placeholder="status" className="h-9 text-sm" />
{((element as CardStatusElement).statusMappings || []).map((mapping, i) => (
{ const mappings = [...((element as CardStatusElement).statusMappings || [])]; mappings[i] = { ...mappings[i], value: e.target.value }; onUpdate({ ...element, statusMappings: mappings } as CardStatusElement); }} placeholder="값" className="h-8 text-xs flex-1" /> { const mappings = [...((element as CardStatusElement).statusMappings || [])]; mappings[i] = { ...mappings[i], label: e.target.value }; onUpdate({ ...element, statusMappings: mappings } as CardStatusElement); }} placeholder="라벨" className="h-8 text-xs flex-1" /> { const mappings = [...((element as CardStatusElement).statusMappings || [])]; mappings[i] = { ...mappings[i], color: e.target.value }; onUpdate({ ...element, statusMappings: mappings } as CardStatusElement); }} className="w-8 h-8 rounded cursor-pointer border border-gray-200" />
))}
)} {/* 빈 공간 설정 */} {element.type === "spacer" && (
onUpdate({ ...element, height: parseInt(e.target.value) || 16 } as CardSpacerElement)} className="h-9 text-sm" />
)} {/* 고정 텍스트 설정 */} {element.type === "staticText" && ( <>