리포트 디자이너 초기 구현
This commit is contained in:
parent
aad1a7b447
commit
6a221d3e7e
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue