1205 lines
46 KiB
TypeScript
1205 lines
46 KiB
TypeScript
"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 <Type className="w-3 h-3" />;
|
||
case "dataCell":
|
||
return <CreditCard className="w-3 h-3" />;
|
||
case "divider":
|
||
return <Minus className="w-3 h-3" />;
|
||
case "badge":
|
||
return <Tag className="w-3 h-3" />;
|
||
case "image":
|
||
return <ImageIcon className="w-3 h-3" />;
|
||
case "number":
|
||
return <Hash className="w-3 h-3" />;
|
||
case "date":
|
||
return <Calendar className="w-3 h-3" />;
|
||
case "link":
|
||
return <Link2 className="w-3 h-3" />;
|
||
case "status":
|
||
return <Circle className="w-3 h-3" />;
|
||
case "spacer":
|
||
return <Space className="w-3 h-3" />;
|
||
case "staticText":
|
||
return <FileText className="w-3 h-3" />;
|
||
}
|
||
};
|
||
|
||
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<string, string> = {
|
||
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 (
|
||
<div
|
||
ref={combinedRef}
|
||
onClick={onClick}
|
||
className={`
|
||
relative group border rounded p-2 cursor-pointer transition-all min-h-[50px] flex flex-col justify-center
|
||
${isSelected ? "border-blue-500 bg-blue-50 ring-2 ring-blue-200" : "border-gray-200 bg-gray-50 hover:bg-gray-100"}
|
||
${isOver && canDrop ? "border-blue-400 bg-blue-100" : ""}
|
||
${!element ? "border-dashed" : ""}
|
||
${isDragging ? "opacity-40" : ""}
|
||
`}
|
||
style={{ gridColumn: `span ${colspan}` }}
|
||
>
|
||
{element ? (
|
||
<>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-gray-500">{getElementIcon(element.type)}</span>
|
||
<span className="text-xs text-gray-700 truncate">
|
||
{getElementLabel(element)}
|
||
</span>
|
||
</div>
|
||
{onDelete && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||
className="absolute -top-1.5 -right-1.5 hidden group-hover:flex w-5 h-5 items-center justify-center rounded-full bg-red-500 text-white text-xs shadow-sm hover:bg-red-600 transition-colors"
|
||
title="요소 삭제"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="text-xs text-gray-400 text-center">
|
||
드롭하여 추가
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<div className="text-xs font-medium text-muted-foreground">
|
||
기본 설정
|
||
</div>
|
||
</div>
|
||
|
||
{/* 공통: colspan */}
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">
|
||
열 병합
|
||
</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => handleColspanChange(-1)}
|
||
className="h-8 w-8 p-0"
|
||
disabled={(element.colspan || 1) <= 1}
|
||
>
|
||
<MinusIcon className="w-3.5 h-3.5" />
|
||
</Button>
|
||
<span className="text-sm font-medium w-8 text-center">
|
||
{element.colspan || 1}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => handleColspanChange(1)}
|
||
className="h-8 w-8 p-0"
|
||
disabled={(element.colspan || 1) >= maxColspan}
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 방향 설정 (수직/수평) — header, divider, spacer 제외 */}
|
||
{!["header", "divider", "spacer"].includes(element.type) && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">배치 방향</Label>
|
||
<RadioGroup
|
||
value={element.direction || "vertical"}
|
||
onValueChange={(value: CellDirection) =>
|
||
onUpdate({ ...element, direction: value })
|
||
}
|
||
className="flex gap-4"
|
||
>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="vertical" id={`dir-v-${element.id}`} />
|
||
<Label htmlFor={`dir-v-${element.id}`} className="text-xs">
|
||
수직 (라벨 위)
|
||
</Label>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="horizontal" id={`dir-h-${element.id}`} />
|
||
<Label htmlFor={`dir-h-${element.id}`} className="text-xs">
|
||
수평 (라벨:값)
|
||
</Label>
|
||
</div>
|
||
</RadioGroup>
|
||
</div>
|
||
)}
|
||
|
||
{/* 헤더 설정 */}
|
||
{element.type === "header" && (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">제목</Label>
|
||
<Input
|
||
value={(element as CardHeaderElement).title}
|
||
onChange={(e) =>
|
||
onUpdate({ ...element, title: e.target.value } as CardHeaderElement)
|
||
}
|
||
placeholder="헤더 제목"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">크기</Label>
|
||
<Input
|
||
type="number"
|
||
min={10}
|
||
max={48}
|
||
value={(element as CardHeaderElement).fontSize || 20}
|
||
onChange={(e) =>
|
||
onUpdate({ ...element, fontSize: parseInt(e.target.value) || 20 } as CardHeaderElement)
|
||
}
|
||
className="h-9 text-xs"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">굵기</Label>
|
||
<Select
|
||
value={(element as any).fontWeight || "bold"}
|
||
onValueChange={(v) =>
|
||
onUpdate({ ...element, fontWeight: v } as CardHeaderElement)
|
||
}
|
||
>
|
||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="normal">보통</SelectItem>
|
||
<SelectItem value="bold">굵게</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
||
<div className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||
<input
|
||
type="color"
|
||
value={(element as CardHeaderElement).color || "#1e40af"}
|
||
onChange={(e) =>
|
||
onUpdate({ ...element, color: e.target.value } as CardHeaderElement)
|
||
}
|
||
className="h-6 w-6 shrink-0 cursor-pointer rounded border-0 p-0"
|
||
/>
|
||
<Input
|
||
value={(element as CardHeaderElement).color || "#1e40af"}
|
||
onChange={(e) =>
|
||
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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 데이터 셀 설정 */}
|
||
{element.type === "dataCell" && (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">라벨</Label>
|
||
<Input
|
||
value={(element as CardDataCellElement).label}
|
||
onChange={(e) =>
|
||
onUpdate({ ...element, label: e.target.value } as CardDataCellElement)
|
||
}
|
||
placeholder="라벨 입력"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">라벨 굵기</Label>
|
||
<Select
|
||
value={(element as any).labelFontWeight || "normal"}
|
||
onValueChange={(v) => onUpdate({ ...element, labelFontWeight: v })}
|
||
>
|
||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="normal">보통</SelectItem>
|
||
<SelectItem value="bold">굵게</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">라벨 색상</Label>
|
||
<div className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||
<input type="color" value={(element as any).labelColor || "#374151"} onChange={(e) => onUpdate({ ...element, labelColor: e.target.value })} className="h-6 w-6 shrink-0 cursor-pointer rounded border-0 p-0" />
|
||
<Input value={(element as any).labelColor || "#374151"} onChange={(e) => 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" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">값 색상</Label>
|
||
<div className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||
<input type="color" value={(element as any).valueColor || "#000000"} onChange={(e) => onUpdate({ ...element, valueColor: e.target.value })} className="h-6 w-6 shrink-0 cursor-pointer rounded border-0 p-0" />
|
||
<Input value={(element as any).valueColor || "#000000"} onChange={(e) => 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" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 구분선 설정 */}
|
||
{element.type === "divider" && (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">스타일</Label>
|
||
<Select
|
||
value={(element as CardDividerElement).style || "solid"}
|
||
onValueChange={(value: "solid" | "dashed" | "dotted") =>
|
||
onUpdate({ ...element, style: value } as CardDividerElement)
|
||
}
|
||
>
|
||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="solid">실선</SelectItem>
|
||
<SelectItem value="dashed">점선</SelectItem>
|
||
<SelectItem value="dotted">점점선</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">두께</Label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={10}
|
||
value={(element as any).thickness || 1}
|
||
onChange={(e) => onUpdate({ ...element, thickness: parseInt(e.target.value) || 1 })}
|
||
className="h-9 text-xs"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
||
<div className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||
<input type="color" value={(element as any).color || "#e5e7eb"} onChange={(e) => onUpdate({ ...element, color: e.target.value })} className="h-6 w-6 shrink-0 cursor-pointer rounded border-0 p-0" />
|
||
<Input value={(element as any).color || "#e5e7eb"} onChange={(e) => 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" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 뱃지 설정 */}
|
||
{element.type === "badge" && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">
|
||
앞 라벨 (선택)
|
||
</Label>
|
||
<Input
|
||
value={(element as CardBadgeElement).label || ""}
|
||
onChange={(e) =>
|
||
onUpdate({ ...element, label: e.target.value } as CardBadgeElement)
|
||
}
|
||
placeholder="예: 상태"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">배경색</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="color"
|
||
value={(element as any).bgColor || "#dbeafe"}
|
||
onChange={(e) => onUpdate({ ...element, bgColor: e.target.value })}
|
||
className="w-9 h-9 rounded cursor-pointer border border-gray-200"
|
||
/>
|
||
<Input
|
||
value={(element as any).bgColor || "#dbeafe"}
|
||
onChange={(e) => onUpdate({ ...element, bgColor: e.target.value })}
|
||
className="h-9 text-sm flex-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">텍스트 색상</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="color"
|
||
value={(element as any).textColor || "#1e40af"}
|
||
onChange={(e) => onUpdate({ ...element, textColor: e.target.value })}
|
||
className="w-9 h-9 rounded cursor-pointer border border-gray-200"
|
||
/>
|
||
<Input
|
||
value={(element as any).textColor || "#1e40af"}
|
||
onChange={(e) => onUpdate({ ...element, textColor: e.target.value })}
|
||
className="h-9 text-sm flex-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 이미지 설정 */}
|
||
{element.type === "image" && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">컬럼명 (이미지 URL)</Label>
|
||
<Input
|
||
value={(element as CardImageElement).columnName || ""}
|
||
onChange={(e) => onUpdate({ ...element, columnName: e.target.value } as CardImageElement)}
|
||
placeholder="image_url"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">표시 방식</Label>
|
||
<Select
|
||
value={(element as CardImageElement).objectFit || "contain"}
|
||
onValueChange={(v) => onUpdate({ ...element, objectFit: v as "contain" | "cover" | "fill" } as CardImageElement)}
|
||
>
|
||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="contain">비율 유지 (contain)</SelectItem>
|
||
<SelectItem value="cover">영역 채우기 (cover)</SelectItem>
|
||
<SelectItem value="fill">늘리기 (fill)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">높이 (px)</Label>
|
||
<Input
|
||
type="number"
|
||
value={(element as CardImageElement).height || 80}
|
||
onChange={(e) => onUpdate({ ...element, height: parseInt(e.target.value) || 80 } as CardImageElement)}
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 숫자/금액 설정 */}
|
||
{element.type === "number" && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">라벨</Label>
|
||
<Input
|
||
value={(element as CardNumberElement).label}
|
||
onChange={(e) => onUpdate({ ...element, label: e.target.value } as CardNumberElement)}
|
||
placeholder="금액"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">컬럼명</Label>
|
||
<Input
|
||
value={(element as CardNumberElement).columnName || ""}
|
||
onChange={(e) => onUpdate({ ...element, columnName: e.target.value } as CardNumberElement)}
|
||
placeholder="amount"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">숫자 포맷</Label>
|
||
<Select
|
||
value={(element as CardNumberElement).numberFormat || "none"}
|
||
onValueChange={(v) => onUpdate({ ...element, numberFormat: v as "none" | "comma" | "currency" } as CardNumberElement)}
|
||
>
|
||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">없음</SelectItem>
|
||
<SelectItem value="comma">콤마 (1,000)</SelectItem>
|
||
<SelectItem value="currency">통화 (1,000원)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{(element as CardNumberElement).numberFormat === "currency" && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">통화 단위</Label>
|
||
<Input
|
||
value={(element as CardNumberElement).currencySuffix || "원"}
|
||
onChange={(e) => onUpdate({ ...element, currencySuffix: e.target.value } as CardNumberElement)}
|
||
placeholder="원"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 날짜 설정 */}
|
||
{element.type === "date" && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">라벨</Label>
|
||
<Input
|
||
value={(element as CardDateElement).label}
|
||
onChange={(e) => onUpdate({ ...element, label: e.target.value } as CardDateElement)}
|
||
placeholder="날짜"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">컬럼명</Label>
|
||
<Input
|
||
value={(element as CardDateElement).columnName || ""}
|
||
onChange={(e) => onUpdate({ ...element, columnName: e.target.value } as CardDateElement)}
|
||
placeholder="created_at"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">날짜 포맷</Label>
|
||
<Input
|
||
value={(element as CardDateElement).dateFormat || "YYYY-MM-DD"}
|
||
onChange={(e) => onUpdate({ ...element, dateFormat: e.target.value } as CardDateElement)}
|
||
placeholder="YYYY-MM-DD"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 링크 설정 */}
|
||
{element.type === "link" && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">라벨</Label>
|
||
<Input
|
||
value={(element as CardLinkElement).label}
|
||
onChange={(e) => onUpdate({ ...element, label: e.target.value } as CardLinkElement)}
|
||
placeholder="링크"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">컬럼명 (URL)</Label>
|
||
<Input
|
||
value={(element as CardLinkElement).columnName || ""}
|
||
onChange={(e) => onUpdate({ ...element, columnName: e.target.value } as CardLinkElement)}
|
||
placeholder="url"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">링크 텍스트</Label>
|
||
<Input
|
||
value={(element as CardLinkElement).linkText || ""}
|
||
onChange={(e) => onUpdate({ ...element, linkText: e.target.value } as CardLinkElement)}
|
||
placeholder="자동 (URL 표시)"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox
|
||
checked={(element as CardLinkElement).openInNewTab ?? true}
|
||
onCheckedChange={(checked) => onUpdate({ ...element, openInNewTab: !!checked } as CardLinkElement)}
|
||
/>
|
||
<Label className="text-xs">새 탭에서 열기</Label>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 상태 설정 */}
|
||
{element.type === "status" && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">컬럼명</Label>
|
||
<Input
|
||
value={(element as CardStatusElement).columnName || ""}
|
||
onChange={(e) => onUpdate({ ...element, columnName: e.target.value } as CardStatusElement)}
|
||
placeholder="status"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">상태 매핑</Label>
|
||
{((element as CardStatusElement).statusMappings || []).map((mapping, i) => (
|
||
<div key={i} className="flex items-center gap-1.5">
|
||
<Input
|
||
value={mapping.value}
|
||
onChange={(e) => {
|
||
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"
|
||
/>
|
||
<Input
|
||
value={mapping.label}
|
||
onChange={(e) => {
|
||
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"
|
||
/>
|
||
<input
|
||
type="color"
|
||
value={mapping.color || "#3b82f6"}
|
||
onChange={(e) => {
|
||
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"
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
const mappings = [...((element as CardStatusElement).statusMappings || [])];
|
||
mappings.splice(i, 1);
|
||
onUpdate({ ...element, statusMappings: mappings } as CardStatusElement);
|
||
}}
|
||
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||
>
|
||
<Trash2 className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
const mappings = [...((element as CardStatusElement).statusMappings || [])];
|
||
mappings.push({ value: "", label: "", color: "#3b82f6" });
|
||
onUpdate({ ...element, statusMappings: mappings } as CardStatusElement);
|
||
}}
|
||
className="w-full gap-2"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />매핑 추가
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 빈 공간 설정 */}
|
||
{element.type === "spacer" && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">높이 (px)</Label>
|
||
<Input
|
||
type="number"
|
||
value={(element as CardSpacerElement).height || 16}
|
||
onChange={(e) => onUpdate({ ...element, height: parseInt(e.target.value) || 16 } as CardSpacerElement)}
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 고정 텍스트 설정 */}
|
||
{element.type === "staticText" && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">텍스트</Label>
|
||
<Textarea
|
||
value={(element as CardStaticTextElement).text}
|
||
onChange={(e) => onUpdate({ ...element, text: e.target.value } as CardStaticTextElement)}
|
||
placeholder="텍스트 입력"
|
||
className="text-sm min-h-[60px]"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">글꼴 크기</Label>
|
||
<Input
|
||
type="number"
|
||
value={(element as CardStaticTextElement).fontSize || 13}
|
||
onChange={(e) => onUpdate({ ...element, fontSize: parseInt(e.target.value) || 13 } as CardStaticTextElement)}
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">굵기</Label>
|
||
<Select
|
||
value={(element as CardStaticTextElement).fontWeight || "normal"}
|
||
onValueChange={(v) => onUpdate({ ...element, fontWeight: v as "normal" | "bold" } as CardStaticTextElement)}
|
||
>
|
||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="normal">보통</SelectItem>
|
||
<SelectItem value="bold">굵게</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">색상</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="color"
|
||
value={(element as CardStaticTextElement).color || "#000000"}
|
||
onChange={(e) => onUpdate({ ...element, color: e.target.value } as CardStaticTextElement)}
|
||
className="w-9 h-9 rounded cursor-pointer border border-gray-200"
|
||
/>
|
||
<Input
|
||
value={(element as CardStaticTextElement).color || "#000000"}
|
||
onChange={(e) => onUpdate({ ...element, color: e.target.value } as CardStaticTextElement)}
|
||
className="h-9 text-sm flex-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium text-foreground">정렬</Label>
|
||
<Select
|
||
value={(element as CardStaticTextElement).textAlign || "left"}
|
||
onValueChange={(v) => onUpdate({ ...element, textAlign: v as "left" | "center" | "right" } as CardStaticTextElement)}
|
||
>
|
||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="left">왼쪽</SelectItem>
|
||
<SelectItem value="center">가운데</SelectItem>
|
||
<SelectItem value="right">오른쪽</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CardCanvasEditor({ rows, onRowsChange }: CardCanvasEditorProps) {
|
||
const [selectedCell, setSelectedCell] = useState<{
|
||
rowIndex: number;
|
||
cellIndex: number;
|
||
} | null>(null);
|
||
const [collapsedRows, setCollapsedRows] = useState<Set<number>>(new Set());
|
||
|
||
const toggleRowCollapse = (rowIndex: number) => {
|
||
setCollapsedRows((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(rowIndex)) next.delete(rowIndex);
|
||
else next.add(rowIndex);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleAddRow = () => {
|
||
const newRow: CardLayoutRow = {
|
||
id: generateId(),
|
||
gridColumns: 2,
|
||
elements: [],
|
||
};
|
||
onRowsChange([...rows, newRow]);
|
||
};
|
||
|
||
const handleDeleteRow = (rowIndex: number) => {
|
||
onRowsChange(rows.filter((_, i) => i !== rowIndex));
|
||
setSelectedCell(null);
|
||
};
|
||
|
||
const handleMoveRow = (rowIndex: number, direction: "up" | "down") => {
|
||
const newIndex = direction === "up" ? rowIndex - 1 : rowIndex + 1;
|
||
if (newIndex < 0 || newIndex >= rows.length) return;
|
||
|
||
const newRows = [...rows];
|
||
[newRows[rowIndex], newRows[newIndex]] = [newRows[newIndex], newRows[rowIndex]];
|
||
onRowsChange(newRows);
|
||
};
|
||
|
||
const handleGridColumnsChange = (rowIndex: number, gridColumns: number) => {
|
||
const newRows = [...rows];
|
||
newRows[rowIndex] = { ...newRows[rowIndex], gridColumns };
|
||
onRowsChange(newRows);
|
||
};
|
||
|
||
const handleDropElement = useCallback(
|
||
(rowIndex: number, cellIndex: number, elementType: CardElementType) => {
|
||
const newRows = [...rows];
|
||
const row = newRows[rowIndex];
|
||
const newElement = createDefaultElement(elementType);
|
||
|
||
const existingElements = [...row.elements];
|
||
if (cellIndex < existingElements.length && existingElements[cellIndex]) {
|
||
existingElements.splice(cellIndex, 0, newElement);
|
||
} else if (cellIndex < existingElements.length) {
|
||
existingElements[cellIndex] = newElement;
|
||
} else {
|
||
existingElements.push(newElement);
|
||
}
|
||
|
||
newRows[rowIndex] = { ...row, elements: existingElements };
|
||
onRowsChange(newRows);
|
||
setSelectedCell({ rowIndex, cellIndex });
|
||
},
|
||
[rows, onRowsChange],
|
||
);
|
||
|
||
const handleCellClick = (rowIndex: number, cellIndex: number) => {
|
||
const element = rows[rowIndex]?.elements[cellIndex];
|
||
if (element) {
|
||
setSelectedCell({ rowIndex, cellIndex });
|
||
} else {
|
||
setSelectedCell(null);
|
||
}
|
||
};
|
||
|
||
const handleElementUpdate = (element: CardElement) => {
|
||
if (!selectedCell) return;
|
||
const { rowIndex, cellIndex } = selectedCell;
|
||
const newRows = [...rows];
|
||
const newElements = [...newRows[rowIndex].elements];
|
||
newElements[cellIndex] = element;
|
||
newRows[rowIndex] = { ...newRows[rowIndex], elements: newElements };
|
||
onRowsChange(newRows);
|
||
};
|
||
|
||
const handleMoveElement = useCallback(
|
||
(fromRow: number, fromCell: number, toRow: number, toCell: number) => {
|
||
if (fromRow === toRow && fromCell === toCell) return;
|
||
const newRows = [...rows];
|
||
|
||
const sourceElement = newRows[fromRow].elements[fromCell];
|
||
if (!sourceElement) return;
|
||
|
||
const sourceElements = [...newRows[fromRow].elements];
|
||
sourceElements.splice(fromCell, 1);
|
||
newRows[fromRow] = { ...newRows[fromRow], elements: sourceElements };
|
||
|
||
const targetElements = [...newRows[toRow].elements];
|
||
if (toCell < targetElements.length) {
|
||
targetElements.splice(toCell, 0, sourceElement);
|
||
} else {
|
||
targetElements.push(sourceElement);
|
||
}
|
||
newRows[toRow] = { ...newRows[toRow], elements: targetElements };
|
||
|
||
onRowsChange(newRows);
|
||
setSelectedCell(null);
|
||
},
|
||
[rows, onRowsChange],
|
||
);
|
||
|
||
const handleQuickDelete = useCallback(
|
||
(rowIndex: number, cellIndex: number) => {
|
||
const newRows = [...rows];
|
||
const newElements = [...newRows[rowIndex].elements];
|
||
newElements.splice(cellIndex, 1);
|
||
newRows[rowIndex] = { ...newRows[rowIndex], elements: newElements };
|
||
onRowsChange(newRows);
|
||
if (selectedCell?.rowIndex === rowIndex && selectedCell?.cellIndex === cellIndex) {
|
||
setSelectedCell(null);
|
||
}
|
||
},
|
||
[rows, onRowsChange, selectedCell],
|
||
);
|
||
|
||
const handleElementDelete = () => {
|
||
if (!selectedCell) return;
|
||
const { rowIndex, cellIndex } = selectedCell;
|
||
const newRows = [...rows];
|
||
const newElements = [...newRows[rowIndex].elements];
|
||
newElements.splice(cellIndex, 1);
|
||
newRows[rowIndex] = { ...newRows[rowIndex], elements: newElements };
|
||
onRowsChange(newRows);
|
||
setSelectedCell(null);
|
||
};
|
||
|
||
const selectedElement =
|
||
selectedCell !== null
|
||
? rows[selectedCell.rowIndex]?.elements[selectedCell.cellIndex]
|
||
: null;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* 캔버스 영역 */}
|
||
<div className="bg-white border border-border rounded-xl p-4 space-y-3">
|
||
<div className="text-sm font-bold text-gray-800 mb-2">
|
||
레이아웃 캔버스
|
||
</div>
|
||
|
||
{rows.map((row, rowIndex) => {
|
||
const isCollapsed = collapsedRows.has(rowIndex);
|
||
const hasElements = row.elements.length > 0;
|
||
|
||
return (
|
||
<div
|
||
key={row.id}
|
||
className="border border-gray-200 rounded-lg overflow-hidden"
|
||
>
|
||
{/* 행 헤더 */}
|
||
<div className={`flex items-center justify-between px-3 py-2 ${isCollapsed ? "bg-gray-50" : ""}`}>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => toggleRowCollapse(rowIndex)}
|
||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${isCollapsed ? "" : "rotate-90"}`} />
|
||
Row {rowIndex + 1}
|
||
{hasElements && (
|
||
<span className="text-[10px] text-blue-600 bg-blue-50 rounded px-1">
|
||
{row.elements.length}개
|
||
</span>
|
||
)}
|
||
</button>
|
||
{!isCollapsed && (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center gap-1">
|
||
<Label className="text-[10px] text-muted-foreground">열</Label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={6}
|
||
value={row.gridColumns}
|
||
onChange={(e) =>
|
||
handleGridColumnsChange(
|
||
rowIndex,
|
||
parseInt(e.target.value) || 1,
|
||
)
|
||
}
|
||
className="w-12 h-6 text-[10px] text-center"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Label className="text-[10px] text-muted-foreground">아래 여백</Label>
|
||
<Select
|
||
value={row.marginBottom || "0px"}
|
||
onValueChange={(v) => {
|
||
const newRows = [...rows];
|
||
newRows[rowIndex] = { ...newRows[rowIndex], marginBottom: v };
|
||
onRowsChange(newRows);
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="0px">없음</SelectItem>
|
||
<SelectItem value="2px">보통</SelectItem>
|
||
<SelectItem value="14px">넓게</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleMoveRow(rowIndex, "up")}
|
||
disabled={rowIndex === 0}
|
||
className="h-7 w-7 p-0"
|
||
>
|
||
<ChevronUp className="w-3.5 h-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleMoveRow(rowIndex, "down")}
|
||
disabled={rowIndex === rows.length - 1}
|
||
className="h-7 w-7 p-0"
|
||
>
|
||
<ChevronDown className="w-3.5 h-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleDeleteRow(rowIndex)}
|
||
disabled={rows.length <= 1}
|
||
className="h-7 w-7 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 셀 그리드 (접힌 상태에서는 숨김) */}
|
||
{!isCollapsed && (
|
||
<div
|
||
className="grid gap-2 px-3 pb-3"
|
||
style={{ gridTemplateColumns: `repeat(${row.gridColumns}, 1fr)` }}
|
||
>
|
||
{Array.from({ length: row.gridColumns }).map((_, cellIndex) => {
|
||
const element = row.elements[cellIndex] || null;
|
||
const isSelected =
|
||
selectedCell?.rowIndex === rowIndex &&
|
||
selectedCell?.cellIndex === cellIndex;
|
||
return (
|
||
<DropZone
|
||
key={cellIndex}
|
||
rowIndex={rowIndex}
|
||
cellIndex={cellIndex}
|
||
element={element}
|
||
isSelected={isSelected}
|
||
onClick={() => handleCellClick(rowIndex, cellIndex)}
|
||
onDrop={(type) => handleDropElement(rowIndex, cellIndex, type)}
|
||
onDelete={element ? () => handleQuickDelete(rowIndex, cellIndex) : undefined}
|
||
onMove={(fromRow, fromCell) => handleMoveElement(fromRow, fromCell, rowIndex, cellIndex)}
|
||
colspan={element?.colspan || 1}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* 행 추가 버튼 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleAddRow}
|
||
className="w-full gap-2"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />행 추가
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 선택된 셀 설정 다이얼로그 */}
|
||
{selectedElement && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||
onClick={() => setSelectedCell(null)}
|
||
>
|
||
<div className="absolute inset-0 bg-black/25" />
|
||
<div
|
||
className="relative z-10 w-[520px] max-h-[70vh] bg-white rounded-xl shadow-2xl flex flex-col"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="px-5 py-4 border-b flex items-center gap-2">
|
||
<span className="text-gray-500">{getElementIcon(selectedElement.type)}</span>
|
||
<span className="text-base font-bold text-foreground">
|
||
{TYPE_FIXED_LABELS[selectedElement.type] || selectedElement.type} 설정
|
||
</span>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||
<CellSettingsPanel
|
||
element={selectedElement}
|
||
onUpdate={handleElementUpdate}
|
||
maxColspan={rows[selectedCell!.rowIndex].gridColumns}
|
||
/>
|
||
</div>
|
||
<div className="px-5 py-3 border-t flex items-center justify-end gap-2">
|
||
<Button
|
||
size="sm"
|
||
className="text-xs bg-blue-600 hover:bg-blue-700 text-white"
|
||
onClick={() => setSelectedCell(null)}
|
||
>
|
||
확인
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="text-xs text-red-600 border-red-300 hover:bg-red-50 hover:text-red-700"
|
||
onClick={() => setSelectedCell(null)}
|
||
>
|
||
취소
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|