273 lines
9.7 KiB
TypeScript
273 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
import { Plus, Copy, Trash2, Edit2, Check, X, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|
import { MM_TO_PX } from "./ReportDesignerCanvas";
|
|
import type { ComponentConfig, ReportPage } from "@/types/report";
|
|
|
|
const THUMB_W = 80;
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
text: "#6366f1",
|
|
label: "#6366f1",
|
|
table: "#0891b2",
|
|
image: "#16a34a",
|
|
divider: "#9ca3af",
|
|
signature: "#d97706",
|
|
stamp: "#d97706",
|
|
pageNumber: "#8b5cf6",
|
|
card: "#0ea5e9",
|
|
calculation: "#ec4899",
|
|
barcode: "#1e293b",
|
|
checkbox: "#f59e0b",
|
|
};
|
|
|
|
function PageThumbnail({ page }: { page: ReportPage }) {
|
|
const canvasW = page.width * MM_TO_PX;
|
|
const canvasH = page.height * MM_TO_PX;
|
|
const scale = THUMB_W / canvasW;
|
|
const thumbH = canvasH * scale;
|
|
|
|
return (
|
|
<div
|
|
className="relative overflow-hidden rounded border border-gray-200 bg-white shadow-sm"
|
|
style={{ width: THUMB_W, height: thumbH }}
|
|
>
|
|
{page.components.map((comp: ComponentConfig) => (
|
|
<div
|
|
key={comp.id}
|
|
className="absolute rounded-[1px]"
|
|
style={{
|
|
left: comp.x * scale,
|
|
top: comp.y * scale,
|
|
width: Math.max(comp.width * scale, 2),
|
|
height: Math.max(comp.height * scale, 2),
|
|
backgroundColor: TYPE_COLORS[comp.type] ?? "#94a3b8",
|
|
opacity: 0.6,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PageListPanel() {
|
|
const {
|
|
layoutConfig,
|
|
currentPageId,
|
|
addPage,
|
|
deletePage,
|
|
duplicatePage,
|
|
reorderPages,
|
|
selectPage,
|
|
updatePageSettings,
|
|
isPageListCollapsed,
|
|
setIsPageListCollapsed,
|
|
} = useReportDesigner();
|
|
|
|
const [editingPageId, setEditingPageId] = useState<string | null>(null);
|
|
const [editingName, setEditingName] = useState("");
|
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
|
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
|
|
|
|
const handleStartEdit = (pageId: string, currentName: string) => {
|
|
setEditingPageId(pageId);
|
|
setEditingName(currentName);
|
|
};
|
|
|
|
const handleSaveEdit = () => {
|
|
if (editingPageId && editingName.trim()) {
|
|
updatePageSettings(editingPageId, { page_name: editingName.trim() });
|
|
}
|
|
setEditingPageId(null);
|
|
setEditingName("");
|
|
};
|
|
|
|
const handleCancelEdit = () => {
|
|
setEditingPageId(null);
|
|
setEditingName("");
|
|
};
|
|
|
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
|
setDraggedIndex(index);
|
|
e.dataTransfer.effectAllowed = "move";
|
|
e.dataTransfer.setData("text/plain", String(index));
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = "move";
|
|
if (draggedIndex === null || draggedIndex === index) {
|
|
setDropTargetIndex(null);
|
|
return;
|
|
}
|
|
setDropTargetIndex(index);
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (draggedIndex === null) return;
|
|
|
|
if (draggedIndex !== targetIndex) {
|
|
reorderPages(draggedIndex, targetIndex);
|
|
}
|
|
|
|
setDraggedIndex(null);
|
|
setDropTargetIndex(null);
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
setDraggedIndex(null);
|
|
setDropTargetIndex(null);
|
|
};
|
|
|
|
if (isPageListCollapsed) {
|
|
return (
|
|
<div className="flex h-full w-10 shrink-0 flex-col items-center border-r border-gray-200 bg-white pt-2">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8"
|
|
onClick={() => setIsPageListCollapsed(false)}
|
|
title="페이지 목록 열기"
|
|
>
|
|
<PanelLeftOpen className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full w-40 shrink-0 flex-col border-r border-gray-200 bg-white">
|
|
{/* 헤더 */}
|
|
<div className="flex h-11 items-center justify-between border-b border-gray-200 px-2">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-7 w-7"
|
|
onClick={() => setIsPageListCollapsed(true)}
|
|
title="페이지 목록 접기"
|
|
>
|
|
<PanelLeftClose className="h-4 w-4" />
|
|
</Button>
|
|
<span className="text-sm font-bold text-gray-800">페이지</span>
|
|
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => addPage()} title="페이지 추가">
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 페이지 목록 */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<ScrollArea className="h-full p-2">
|
|
<div className="space-y-2" onDragOver={(e) => e.preventDefault()}>
|
|
{layoutConfig.pages
|
|
.sort((a, b) => a.page_order - b.page_order)
|
|
.map((page, index) => (
|
|
<div
|
|
key={page.page_id}
|
|
draggable
|
|
onDragStart={(e) => handleDragStart(e, index)}
|
|
onDragEnd={handleDragEnd}
|
|
onDragOver={(e) => handleDragOver(e, index)}
|
|
onDrop={(e) => handleDrop(e, index)}
|
|
className={`group relative cursor-grab rounded-md border px-2 pt-1 pb-2 transition-all hover:shadow active:cursor-grabbing ${
|
|
page.page_id === currentPageId
|
|
? "border-indigo-400 bg-indigo-50 shadow-md ring-1 ring-indigo-200"
|
|
: "border-gray-100 bg-white hover:border-gray-200"
|
|
} ${draggedIndex === index ? "opacity-30" : ""} ${dropTargetIndex === index ? "border-dashed border-blue-400" : ""}`}
|
|
onClick={() => selectPage(page.page_id)}
|
|
>
|
|
{/* 상단 우측: 액션 버튼 모음 */}
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-4 w-4 p-0 text-gray-400 hover:text-blue-600"
|
|
title="이름 변경"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleStartEdit(page.page_id, page.page_name);
|
|
}}
|
|
>
|
|
<Edit2 className="h-2.5 w-2.5" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-4 w-4 p-0 text-gray-400 hover:text-blue-600"
|
|
title="복제"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
duplicatePage(page.page_id);
|
|
}}
|
|
>
|
|
<Copy className="h-2.5 w-2.5" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-4 w-4 p-0 text-gray-400 hover:text-red-500"
|
|
title="삭제"
|
|
disabled={layoutConfig.pages.length <= 1}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
deletePage(page.page_id);
|
|
}}
|
|
>
|
|
<Trash2 className="h-2.5 w-2.5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 썸네일 + 제목 (하단부 배치) */}
|
|
<div className="mt-4 flex flex-col items-center">
|
|
<PageThumbnail page={page} />
|
|
<div className="mt-2 w-full text-center">
|
|
{editingPageId === page.page_id ? (
|
|
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
<Input
|
|
value={editingName}
|
|
onChange={(e) => setEditingName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleSaveEdit();
|
|
if (e.key === "Escape") handleCancelEdit();
|
|
}}
|
|
className="h-6 w-full px-1 text-center text-xs"
|
|
autoFocus
|
|
/>
|
|
<Button size="sm" variant="ghost" className="h-5 w-5 shrink-0 p-0 text-blue-600 hover:text-blue-700" onClick={handleSaveEdit}>
|
|
<Check className="h-3 w-3" />
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-5 w-5 shrink-0 p-0 text-red-500 hover:text-red-600" onClick={handleCancelEdit}>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="cursor-text"
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
handleStartEdit(page.page_id, page.page_name);
|
|
}}
|
|
>
|
|
<span className="block text-[11px] font-semibold text-gray-700">
|
|
{index + 1}. {page.page_name}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|