리포트 디자이너 초기 구현

This commit is contained in:
dohyeons 2025-10-01 12:00:13 +09:00
parent aad1a7b447
commit 6a221d3e7e
14 changed files with 1231 additions and 22 deletions

View File

@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
import { ReportDesignerProvider } from "@/contexts/ReportDesignerContext";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { Loader2 } from "lucide-react";
export default function ReportDesignerPage() {
const params = useParams();
const router = useRouter();
const reportId = params.reportId as string;
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
const loadReport = async () => {
// 'new'는 새 리포트 생성 모드
if (reportId === "new") {
setIsLoading(false);
return;
}
try {
const response = await reportApi.getReportById(reportId);
if (!response.success) {
toast({
title: "오류",
description: "리포트를 찾을 수 없습니다.",
variant: "destructive",
});
router.push("/admin/report");
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트를 불러오는데 실패했습니다.",
variant: "destructive",
});
router.push("/admin/report");
} finally {
setIsLoading(false);
}
};
if (reportId) {
loadReport();
}
}, [reportId, router, toast]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
return (
<DndProvider backend={HTML5Backend}>
<ReportDesignerProvider reportId={reportId}>
<div className="flex h-screen flex-col overflow-hidden bg-gray-50">
{/* 상단 툴바 */}
<ReportDesignerToolbar />
{/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측 패널 */}
<ReportDesignerLeftPanel />
{/* 중앙 캔버스 */}
<ReportDesignerCanvas />
{/* 우측 패널 */}
<ReportDesignerRightPanel />
</div>
</div>
</ReportDesignerProvider>
</DndProvider>
);
}

View File

@ -1,17 +1,17 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ReportListTable } from "@/components/report/ReportListTable";
import { ReportCreateModal } from "@/components/report/ReportCreateModal";
import { Plus, Search, RotateCcw } from "lucide-react";
import { useReportList } from "@/hooks/useReportList";
export default function ReportManagementPage() {
const router = useRouter();
const [searchText, setSearchText] = useState("");
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList();
@ -24,9 +24,9 @@ export default function ReportManagementPage() {
handleSearch("");
};
const handleCreateSuccess = () => {
setIsCreateModalOpen(false);
refetch();
const handleCreateNew = () => {
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입
router.push("/admin/report/designer/new");
};
return (
@ -38,7 +38,7 @@ export default function ReportManagementPage() {
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<Button onClick={() => setIsCreateModalOpen(true)} className="gap-2">
<Button onClick={handleCreateNew} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
@ -99,13 +99,6 @@ export default function ReportManagementPage() {
</CardContent>
</Card>
</div>
{/* 리포트 생성 모달 */}
<ReportCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={handleCreateSuccess}
/>
</div>
);
}

View File

@ -29,7 +29,7 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
const [formData, setFormData] = useState<CreateReportRequest>({
reportNameKor: "",
reportNameEng: "",
templateId: "",
templateId: undefined,
reportType: "BASIC",
description: "",
});
@ -109,7 +109,7 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
setFormData({
reportNameKor: "",
reportNameEng: "",
templateId: "",
templateId: undefined,
reportType: "BASIC",
description: "",
});
@ -153,15 +153,15 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
<div className="space-y-2">
<Label htmlFor="templateId">릿</Label>
<Select
value={formData.templateId}
onValueChange={(value) => setFormData({ ...formData, templateId: value })}
value={formData.templateId || "none"}
onValueChange={(value) => setFormData({ ...formData, templateId: value === "none" ? undefined : value })}
disabled={isLoadingTemplates}
>
<SelectTrigger>
<SelectValue placeholder="템플릿 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">릿 </SelectItem>
<SelectItem value="none">릿 </SelectItem>
{templates.map((template) => (
<SelectItem key={template.template_id} value={template.template_id}>
{template.template_name_kor}

View File

@ -0,0 +1,163 @@
"use client";
import { useRef, useState } from "react";
import { ComponentConfig } from "@/types/report";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
interface CanvasComponentProps {
component: ComponentConfig;
}
export function CanvasComponent({ component }: CanvasComponentProps) {
const { selectedComponentId, selectComponent, updateComponent } = useReportDesigner();
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
const componentRef = useRef<HTMLDivElement>(null);
const isSelected = selectedComponentId === component.id;
// 드래그 시작
const handleMouseDown = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
return;
}
e.stopPropagation();
selectComponent(component.id);
setIsDragging(true);
setDragStart({
x: e.clientX - component.x,
y: e.clientY - component.y,
});
};
// 리사이즈 시작
const handleResizeStart = (e: React.MouseEvent) => {
e.stopPropagation();
setIsResizing(true);
setResizeStart({
x: e.clientX,
y: e.clientY,
width: component.width,
height: component.height,
});
};
// 마우스 이동 핸들러 (전역)
useState(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newX = Math.max(0, e.clientX - dragStart.x);
const newY = Math.max(0, e.clientY - dragStart.y);
updateComponent(component.id, { x: newX, y: newY });
} else if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
const deltaY = e.clientY - resizeStart.y;
const newWidth = Math.max(50, resizeStart.width + deltaX);
const newHeight = Math.max(30, resizeStart.height + deltaY);
updateComponent(component.id, { width: newWidth, height: newHeight });
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
};
if (isDragging || isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
});
// 컴포넌트 타입별 렌더링
const renderContent = () => {
switch (component.type) {
case "text":
return (
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"> </div>
<input
type="text"
className="w-full rounded border px-2 py-1 text-sm"
placeholder="텍스트 입력"
onClick={(e) => e.stopPropagation()}
/>
</div>
);
case "label":
return (
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div className="font-semibold"> </div>
</div>
);
case "table":
return (
<div className="h-full w-full overflow-auto">
<div className="mb-1 text-xs text-gray-500"> ( )</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border p-1"></th>
<th className="border p-1"></th>
<th className="border p-1"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border p-1">1</td>
<td className="border p-1">10</td>
<td className="border p-1">50,000</td>
</tr>
<tr>
<td className="border p-1">2</td>
<td className="border p-1">5</td>
<td className="border p-1">30,000</td>
</tr>
</tbody>
</table>
</div>
);
default:
return <div> </div>;
}
};
return (
<div
ref={componentRef}
className={`absolute cursor-move rounded border-2 bg-white p-2 shadow-sm ${
isSelected ? "border-blue-500 ring-2 ring-blue-300" : "border-gray-400"
}`}
style={{
left: `${component.x}px`,
top: `${component.y}px`,
width: `${component.width}px`,
height: `${component.height}px`,
zIndex: component.zIndex,
}}
onMouseDown={handleMouseDown}
>
{renderContent()}
{/* 리사이즈 핸들 (선택된 경우만) */}
{isSelected && (
<div
className="resize-handle absolute right-0 bottom-0 h-3 w-3 cursor-se-resize rounded-full bg-blue-500"
style={{ transform: "translate(50%, 50%)" }}
onMouseDown={handleResizeStart}
/>
)}
</div>
);
}

View File

@ -0,0 +1,48 @@
"use client";
import { useDrag } from "react-dnd";
import { Type, Table, Tag } from "lucide-react";
interface ComponentItem {
type: string;
label: string;
icon: React.ReactNode;
}
const COMPONENTS: ComponentItem[] = [
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
{ type: "table", label: "테이블", icon: <Table className="h-4 w-4" /> },
{ type: "label", label: "레이블", icon: <Tag className="h-4 w-4" /> },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {
const [{ isDragging }, drag] = useDrag(() => ({
type: "component",
item: { componentType: type },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
return (
<div
ref={drag}
className={`flex cursor-move items-center gap-2 rounded border p-2 text-sm transition-all hover:border-blue-500 hover:bg-blue-50 ${
isDragging ? "opacity-50" : ""
}`}
>
{icon}
<span>{label}</span>
</div>
);
}
export function ComponentPalette() {
return (
<div className="space-y-2">
{COMPONENTS.map((component) => (
<DraggableComponentItem key={component.type} {...component} />
))}
</div>
);
}

View File

@ -0,0 +1,97 @@
"use client";
import { useRef } from "react";
import { useDrop } from "react-dnd";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig } from "@/types/report";
import { CanvasComponent } from "./CanvasComponent";
import { v4 as uuidv4 } from "uuid";
export function ReportDesignerCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
const { components, addComponent, canvasWidth, canvasHeight, selectComponent } = useReportDesigner();
const [{ isOver }, drop] = useDrop(() => ({
accept: "component",
drop: (item: { componentType: string }, monitor) => {
if (!canvasRef.current) return;
const offset = monitor.getClientOffset();
const canvasRect = canvasRef.current.getBoundingClientRect();
if (!offset) return;
const x = offset.x - canvasRect.left;
const y = offset.y - canvasRect.top;
// 새 컴포넌트 생성
const newComponent: ComponentConfig = {
id: `comp_${uuidv4()}`,
type: item.componentType,
x: Math.max(0, x - 100),
y: Math.max(0, y - 25),
width: 200,
height: item.componentType === "table" ? 200 : 100,
zIndex: components.length,
fontSize: 13,
fontFamily: "Malgun Gothic",
fontWeight: "normal",
fontColor: "#000000",
backgroundColor: "#ffffff",
borderWidth: 1,
borderColor: "#666666",
borderRadius: 5,
textAlign: "left",
padding: 10,
visible: true,
printable: true,
};
addComponent(newComponent);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
}));
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
selectComponent(null);
}
};
return (
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
{/* 작업 영역 제목 */}
<div className="border-b bg-white px-4 py-2 text-center text-sm font-medium text-gray-700"> </div>
{/* 캔버스 스크롤 영역 */}
<div className="flex-1 overflow-auto p-8">
<div
ref={(node) => {
canvasRef.current = node;
drop(node);
}}
className={`relative mx-auto bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`}
style={{
width: `${canvasWidth}mm`,
minHeight: `${canvasHeight}mm`,
}}
onClick={handleCanvasClick}
>
{/* 컴포넌트 렌더링 */}
{components.map((component) => (
<CanvasComponent key={component.id} component={component} />
))}
{/* 빈 캔버스 안내 */}
{components.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<p className="text-sm"> </p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ComponentPalette } from "./ComponentPalette";
import { TemplatePalette } from "./TemplatePalette";
export function ReportDesignerLeftPanel() {
return (
<div className="w-60 border-r bg-white">
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
{/* 템플릿 */}
<Card className="border-2">
<CardHeader className="pb-3">
<CardTitle className="text-sm"> 릿</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<TemplatePalette />
</CardContent>
</Card>
{/* 컴포넌트 */}
<Card className="border-2">
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ComponentPalette />
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
);
}

View File

@ -0,0 +1,162 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
export function ReportDesignerRightPanel() {
const { selectedComponentId, components, updateComponent, removeComponent } = useReportDesigner();
const selectedComponent = components.find((c) => c.id === selectedComponentId);
if (!selectedComponent) {
return (
<div className="w-80 border-l bg-white">
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
</div>
</div>
);
}
return (
<div className="w-80 border-l bg-white">
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
{/* 컴포넌트 정보 */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm"> </CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => removeComponent(selectedComponent.id)}
className="text-destructive hover:bg-destructive/10 h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* 타입 */}
<div>
<Label className="text-xs"></Label>
<div className="mt-1 text-sm font-medium capitalize">{selectedComponent.type}</div>
</div>
{/* 위치 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs">X</Label>
<Input
type="number"
value={Math.round(selectedComponent.x)}
onChange={(e) =>
updateComponent(selectedComponent.id, {
x: parseInt(e.target.value) || 0,
})
}
className="h-8"
/>
</div>
<div>
<Label className="text-xs">Y</Label>
<Input
type="number"
value={Math.round(selectedComponent.y)}
onChange={(e) =>
updateComponent(selectedComponent.id, {
y: parseInt(e.target.value) || 0,
})
}
className="h-8"
/>
</div>
</div>
{/* 크기 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
type="number"
value={Math.round(selectedComponent.width)}
onChange={(e) =>
updateComponent(selectedComponent.id, {
width: parseInt(e.target.value) || 50,
})
}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
value={Math.round(selectedComponent.height)}
onChange={(e) =>
updateComponent(selectedComponent.id, {
height: parseInt(e.target.value) || 30,
})
}
className="h-8"
/>
</div>
</div>
{/* 글꼴 크기 */}
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.fontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
fontSize: parseInt(e.target.value) || 13,
})
}
className="h-8"
/>
</div>
{/* 글꼴 색상 */}
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.fontColor || "#000000"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
fontColor: e.target.value,
})
}
className="h-8"
/>
</div>
{/* 배경 색상 */}
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.backgroundColor || "#ffffff"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
backgroundColor: e.target.value,
})
}
className="h-8"
/>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
);
}

View File

@ -0,0 +1,78 @@
"use client";
import { Button } from "@/components/ui/button";
import { Save, Eye, RotateCcw, ArrowLeft, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react";
import { ReportPreviewModal } from "./ReportPreviewModal";
export function ReportDesignerToolbar() {
const router = useRouter();
const { reportDetail, saveLayout, isSaving, loadLayout } = useReportDesigner();
const [showPreview, setShowPreview] = useState(false);
const handleSave = async () => {
await saveLayout();
};
const handleReset = async () => {
if (confirm("현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까?")) {
await loadLayout();
}
};
const handleBack = () => {
if (confirm("저장하지 않은 변경사항이 있을 수 있습니다. 목록으로 돌아가시겠습니까?")) {
router.push("/admin/report");
}
};
return (
<>
<div className="flex items-center justify-between border-b bg-white px-4 py-3 shadow-sm">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack} className="gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="h-6 w-px bg-gray-300" />
<div>
<h2 className="text-lg font-semibold text-gray-900">
{reportDetail?.report.report_name_kor || "리포트 디자이너"}
</h2>
{reportDetail?.report.report_name_eng && (
<p className="text-sm text-gray-500">{reportDetail.report.report_name_eng}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setShowPreview(true)} className="gap-2">
<Eye className="h-4 w-4" />
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4" />
</>
)}
</Button>
</div>
</div>
<ReportPreviewModal isOpen={showPreview} onClose={() => setShowPreview(false)} />
</>
);
}

View File

@ -0,0 +1,128 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Printer, FileDown } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
interface ReportPreviewModalProps {
isOpen: boolean;
onClose: () => void;
}
export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) {
const { components, canvasWidth, canvasHeight } = useReportDesigner();
const handlePrint = () => {
window.print();
};
const handleDownloadPDF = () => {
alert("PDF 다운로드 기능은 추후 구현 예정입니다.");
};
const handleDownloadWord = () => {
alert("WORD 다운로드 기능은 추후 구현 예정입니다.");
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
{/* 미리보기 영역 */}
<div className="max-h-[500px] overflow-auto rounded border bg-gray-100 p-4">
<div
className="relative mx-auto bg-white shadow-lg"
style={{
width: `${canvasWidth}mm`,
minHeight: `${canvasHeight}mm`,
}}
>
{components.map((component) => (
<div
key={component.id}
className="absolute rounded border bg-white p-2"
style={{
left: `${component.x}px`,
top: `${component.y}px`,
width: `${component.width}px`,
height: `${component.height}px`,
fontSize: `${component.fontSize}px`,
color: component.fontColor,
backgroundColor: component.backgroundColor,
borderColor: component.borderColor,
borderWidth: `${component.borderWidth}px`,
}}
>
{component.type === "text" && (
<div>
<div className="mb-1 text-xs text-gray-500"> </div>
<div> </div>
</div>
)}
{component.type === "label" && (
<div>
<div className="mb-1 text-xs text-gray-500"></div>
<div className="font-semibold"> </div>
</div>
)}
{component.type === "table" && (
<div className="overflow-auto">
<div className="mb-1 text-xs text-gray-500"></div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border p-1"></th>
<th className="border p-1"></th>
<th className="border p-1"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border p-1">1</td>
<td className="border p-1">10</td>
<td className="border p-1">50,000</td>
</tr>
</tbody>
</table>
</div>
)}
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
</Button>
<Button variant="outline" onClick={handlePrint} className="gap-2">
<Printer className="h-4 w-4" />
</Button>
<Button onClick={handleDownloadPDF} className="gap-2">
<FileDown className="h-4 w-4" />
PDF
</Button>
<Button onClick={handleDownloadWord} variant="secondary" className="gap-2">
<FileDown className="h-4 w-4" />
WORD
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,34 @@
"use client";
import { Button } from "@/components/ui/button";
import { FileText } from "lucide-react";
const TEMPLATES = [
{ id: "order", name: "발주서", icon: "📋" },
{ id: "invoice", name: "청구서", icon: "💰" },
{ id: "basic", name: "기본", icon: "📄" },
];
export function TemplatePalette() {
const handleApplyTemplate = (templateId: string) => {
// TODO: 템플릿 적용 로직
console.log("Apply template:", templateId);
};
return (
<div className="space-y-2">
{TEMPLATES.map((template) => (
<Button
key={template.id}
variant="outline"
size="sm"
className="w-full justify-start gap-2 text-sm"
onClick={() => handleApplyTemplate(template.id)}
>
<span>{template.icon}</span>
<span>{template.name}</span>
</Button>
))}
</div>
);
}

View File

@ -0,0 +1,249 @@
"use client";
import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from "react";
import { ComponentConfig, ReportDetail, ReportLayout } from "@/types/report";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
interface ReportDesignerContextType {
reportId: string;
reportDetail: ReportDetail | null;
layout: ReportLayout | null;
components: ComponentConfig[];
selectedComponentId: string | null;
isLoading: boolean;
isSaving: boolean;
// 컴포넌트 관리
addComponent: (component: ComponentConfig) => void;
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
removeComponent: (id: string) => void;
selectComponent: (id: string | null) => void;
// 레이아웃 관리
updateLayout: (updates: Partial<ReportLayout>) => void;
saveLayout: () => Promise<void>;
loadLayout: () => Promise<void>;
// 캔버스 설정
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
}
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
export function ReportDesignerProvider({ reportId, children }: { reportId: string; children: ReactNode }) {
const [reportDetail, setReportDetail] = useState<ReportDetail | null>(null);
const [layout, setLayout] = useState<ReportLayout | null>(null);
const [components, setComponents] = useState<ComponentConfig[]>([]);
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const { toast } = useToast();
// 캔버스 설정 (기본값)
const [canvasWidth, setCanvasWidth] = useState(210);
const [canvasHeight, setCanvasHeight] = useState(297);
const [pageOrientation, setPageOrientation] = useState("portrait");
const [margins, setMargins] = useState({
top: 20,
bottom: 20,
left: 20,
right: 20,
});
// 리포트 및 레이아웃 로드
const loadLayout = useCallback(async () => {
setIsLoading(true);
try {
// 'new'는 새 리포트 생성 모드
if (reportId === "new") {
setIsLoading(false);
return;
}
// 리포트 상세 조회
const detailResponse = await reportApi.getReportById(reportId);
if (detailResponse.success && detailResponse.data) {
setReportDetail(detailResponse.data);
}
// 레이아웃 조회
try {
const layoutResponse = await reportApi.getLayout(reportId);
if (layoutResponse.success && layoutResponse.data) {
const layoutData = layoutResponse.data;
setLayout(layoutData);
setComponents(layoutData.components || []);
setCanvasWidth(layoutData.canvas_width);
setCanvasHeight(layoutData.canvas_height);
setPageOrientation(layoutData.page_orientation);
setMargins({
top: layoutData.margin_top,
bottom: layoutData.margin_bottom,
left: layoutData.margin_left,
right: layoutData.margin_right,
});
}
} catch (layoutError) {
// 레이아웃이 없으면 기본값 사용
console.log("레이아웃 없음, 기본값 사용");
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트를 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}, [reportId, toast]);
// 초기 로드
useEffect(() => {
loadLayout();
}, [loadLayout]);
// 컴포넌트 추가
const addComponent = useCallback((component: ComponentConfig) => {
setComponents((prev) => [...prev, component]);
}, []);
// 컴포넌트 업데이트
const updateComponent = useCallback((id: string, updates: Partial<ComponentConfig>) => {
setComponents((prev) => prev.map((comp) => (comp.id === id ? { ...comp, ...updates } : comp)));
}, []);
// 컴포넌트 삭제
const removeComponent = useCallback(
(id: string) => {
setComponents((prev) => prev.filter((comp) => comp.id !== id));
if (selectedComponentId === id) {
setSelectedComponentId(null);
}
},
[selectedComponentId],
);
// 컴포넌트 선택
const selectComponent = useCallback((id: string | null) => {
setSelectedComponentId(id);
}, []);
// 레이아웃 업데이트
const updateLayout = useCallback((updates: Partial<ReportLayout>) => {
setLayout((prev) => (prev ? { ...prev, ...updates } : null));
if (updates.canvas_width !== undefined) setCanvasWidth(updates.canvas_width);
if (updates.canvas_height !== undefined) setCanvasHeight(updates.canvas_height);
if (updates.page_orientation !== undefined) setPageOrientation(updates.page_orientation);
if (
updates.margin_top !== undefined ||
updates.margin_bottom !== undefined ||
updates.margin_left !== undefined ||
updates.margin_right !== undefined
) {
setMargins((prev) => ({
top: updates.margin_top ?? prev.top,
bottom: updates.margin_bottom ?? prev.bottom,
left: updates.margin_left ?? prev.left,
right: updates.margin_right ?? prev.right,
}));
}
}, []);
// 레이아웃 저장
const saveLayout = useCallback(async () => {
setIsSaving(true);
try {
let actualReportId = reportId;
// 새 리포트인 경우 먼저 리포트 생성
if (reportId === "new") {
const createResponse = await reportApi.createReport({
reportNameKor: "새 리포트",
reportType: "BASIC",
description: "새로 생성된 리포트입니다.",
});
if (!createResponse.success || !createResponse.data) {
throw new Error("리포트 생성에 실패했습니다.");
}
actualReportId = createResponse.data.reportId;
// URL 업데이트 (페이지 리로드 없이)
window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`);
}
// 레이아웃 저장
await reportApi.saveLayout(actualReportId, {
canvasWidth,
canvasHeight,
pageOrientation,
marginTop: margins.top,
marginBottom: margins.bottom,
marginLeft: margins.left,
marginRight: margins.right,
components,
});
toast({
title: "성공",
description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다.",
});
// 새 리포트였다면 데이터 다시 로드
if (reportId === "new") {
await loadLayout();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "저장에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsSaving(false);
}
}, [reportId, canvasWidth, canvasHeight, pageOrientation, margins, components, toast, loadLayout]);
const value: ReportDesignerContextType = {
reportId,
reportDetail,
layout,
components,
selectedComponentId,
isLoading,
isSaving,
addComponent,
updateComponent,
removeComponent,
selectComponent,
updateLayout,
saveLayout,
loadLayout,
canvasWidth,
canvasHeight,
pageOrientation,
margins,
};
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
}
export function useReportDesigner() {
const context = useContext(ReportDesignerContext);
if (context === undefined) {
throw new Error("useReportDesigner must be used within a ReportDesignerProvider");
}
return context;
}

View File

@ -45,13 +45,17 @@
"next": "15.4.4",
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-resizable-panels": "^3.0.6",
"react-window": "^2.1.0",
"sheetjs-style": "^0.15.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^4.1.5"
},
@ -62,6 +66,7 @@
"@types/node": "^20",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@types/uuid": "^10.0.0",
"eslint": "^9",
"eslint-config-next": "15.4.4",
"eslint-config-prettier": "^10.1.8",
@ -87,6 +92,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@ -2279,6 +2293,24 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==",
"license": "MIT"
},
"node_modules/@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==",
"license": "MIT"
},
"node_modules/@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
"license": "MIT"
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -2764,7 +2796,7 @@
"version": "20.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -2798,6 +2830,13 @@
"@types/react": "*"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.44.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
@ -4437,6 +4476,17 @@
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"license": "MIT",
"dependencies": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
"redux": "^4.2.0"
}
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -5239,7 +5289,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-diff": {
@ -5732,6 +5781,15 @@
"node": ">= 0.4"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -7587,6 +7645,45 @@
"react": ">=16.8.0"
}
},
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"license": "MIT",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"license": "MIT",
"dependencies": {
"dnd-core": "^16.0.1"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@ -7636,7 +7733,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-remove-scroll": {
@ -7686,6 +7782,16 @@
}
}
},
"node_modules/react-resizable-panels": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
"integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==",
"license": "MIT",
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -7753,6 +7859,15 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -8735,7 +8850,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@ -8841,6 +8956,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -53,13 +53,17 @@
"next": "15.4.4",
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-resizable-panels": "^3.0.6",
"react-window": "^2.1.0",
"sheetjs-style": "^0.15.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^4.1.5"
},
@ -70,6 +74,7 @@
"@types/node": "^20",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@types/uuid": "^10.0.0",
"eslint": "^9",
"eslint-config-next": "15.4.4",
"eslint-config-prettier": "^10.1.8",