ERP-node/frontend/components/report/designer/modals/CardCanvasEditor.tsx

1205 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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