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

1205 lines
46 KiB
TypeScript
Raw Normal View History

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