From 6a221d3e7e6d4494c8e509d02d711d053fa98980 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 1 Oct 2025 12:00:13 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=20=EC=B4=88=EA=B8=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/report/designer/[reportId]/page.tsx | 88 +++++++ frontend/app/(main)/admin/report/page.tsx | 19 +- .../components/report/ReportCreateModal.tsx | 10 +- .../report/designer/CanvasComponent.tsx | 163 ++++++++++++ .../report/designer/ComponentPalette.tsx | 48 ++++ .../report/designer/ReportDesignerCanvas.tsx | 97 +++++++ .../designer/ReportDesignerLeftPanel.tsx | 36 +++ .../designer/ReportDesignerRightPanel.tsx | 162 ++++++++++++ .../report/designer/ReportDesignerToolbar.tsx | 78 ++++++ .../report/designer/ReportPreviewModal.tsx | 128 +++++++++ .../report/designer/TemplatePalette.tsx | 34 +++ frontend/contexts/ReportDesignerContext.tsx | 249 ++++++++++++++++++ frontend/package-lock.json | 136 +++++++++- frontend/package.json | 5 + 14 files changed, 1231 insertions(+), 22 deletions(-) create mode 100644 frontend/app/(main)/admin/report/designer/[reportId]/page.tsx create mode 100644 frontend/components/report/designer/CanvasComponent.tsx create mode 100644 frontend/components/report/designer/ComponentPalette.tsx create mode 100644 frontend/components/report/designer/ReportDesignerCanvas.tsx create mode 100644 frontend/components/report/designer/ReportDesignerLeftPanel.tsx create mode 100644 frontend/components/report/designer/ReportDesignerRightPanel.tsx create mode 100644 frontend/components/report/designer/ReportDesignerToolbar.tsx create mode 100644 frontend/components/report/designer/ReportPreviewModal.tsx create mode 100644 frontend/components/report/designer/TemplatePalette.tsx create mode 100644 frontend/contexts/ReportDesignerContext.tsx diff --git a/frontend/app/(main)/admin/report/designer/[reportId]/page.tsx b/frontend/app/(main)/admin/report/designer/[reportId]/page.tsx new file mode 100644 index 00000000..f4fce164 --- /dev/null +++ b/frontend/app/(main)/admin/report/designer/[reportId]/page.tsx @@ -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 ( +
+ +
+ ); + } + + return ( + + +
+ {/* 상단 툴바 */} + + + {/* 메인 영역 */} +
+ {/* 좌측 패널 */} + + + {/* 중앙 캔버스 */} + + + {/* 우측 패널 */} + +
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/report/page.tsx b/frontend/app/(main)/admin/report/page.tsx index 11c3e89d..37270683 100644 --- a/frontend/app/(main)/admin/report/page.tsx +++ b/frontend/app/(main)/admin/report/page.tsx @@ -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() {

리포트 관리

리포트를 생성하고 관리합니다

- @@ -99,13 +99,6 @@ export default function ReportManagementPage() { - - {/* 리포트 생성 모달 */} - setIsCreateModalOpen(false)} - onSuccess={handleCreateSuccess} - /> ); } diff --git a/frontend/components/report/ReportCreateModal.tsx b/frontend/components/report/ReportCreateModal.tsx index 56644632..df9810c8 100644 --- a/frontend/components/report/ReportCreateModal.tsx +++ b/frontend/components/report/ReportCreateModal.tsx @@ -29,7 +29,7 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo const [formData, setFormData] = useState({ 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
e.stopPropagation()} + /> +
+ ); + + case "label": + return ( +
+
레이블
+
레이블 텍스트
+
+ ); + + case "table": + return ( +
+
테이블 (디테일 데이터)
+ + + + + + + + + + + + + + + + + + + + +
품목명수량단가
품목11050,000
품목2530,000
+
+ ); + + default: + return
알 수 없는 컴포넌트
; + } + }; + + return ( +
+ {renderContent()} + + {/* 리사이즈 핸들 (선택된 경우만) */} + {isSelected && ( +
+ )} +
+ ); +} diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx new file mode 100644 index 00000000..ec9a6615 --- /dev/null +++ b/frontend/components/report/designer/ComponentPalette.tsx @@ -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: "table", label: "테이블", icon: }, + { type: "label", label: "레이블", icon: }, +]; + +function DraggableComponentItem({ type, label, icon }: ComponentItem) { + const [{ isDragging }, drag] = useDrag(() => ({ + type: "component", + item: { componentType: type }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + })); + + return ( +
+ {icon} + {label} +
+ ); +} + +export function ComponentPalette() { + return ( +
+ {COMPONENTS.map((component) => ( + + ))} +
+ ); +} diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx new file mode 100644 index 00000000..c56a298e --- /dev/null +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -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(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) => { + if (e.target === e.currentTarget) { + selectComponent(null); + } + }; + + return ( +
+ {/* 작업 영역 제목 */} +
작업 영역
+ + {/* 캔버스 스크롤 영역 */} +
+
{ + 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) => ( + + ))} + + {/* 빈 캔버스 안내 */} + {components.length === 0 && ( +
+

왼쪽에서 컴포넌트를 드래그하여 추가하세요

+
+ )} +
+
+
+ ); +} diff --git a/frontend/components/report/designer/ReportDesignerLeftPanel.tsx b/frontend/components/report/designer/ReportDesignerLeftPanel.tsx new file mode 100644 index 00000000..5034b480 --- /dev/null +++ b/frontend/components/report/designer/ReportDesignerLeftPanel.tsx @@ -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 ( +
+ +
+ {/* 템플릿 */} + + + 기본 템플릿 + + + + + + + {/* 컴포넌트 */} + + + 컴포넌트 + + + + + +
+
+
+ ); +} diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx new file mode 100644 index 00000000..95a929ae --- /dev/null +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -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 ( +
+
+ 컴포넌트를 선택하면 속성을 편집할 수 있습니다 +
+
+ ); + } + + return ( +
+ +
+ {/* 컴포넌트 정보 */} + + +
+ 컴포넌트 속성 + +
+
+ + {/* 타입 */} +
+ +
{selectedComponent.type}
+
+ + {/* 위치 */} +
+
+ + + updateComponent(selectedComponent.id, { + x: parseInt(e.target.value) || 0, + }) + } + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + y: parseInt(e.target.value) || 0, + }) + } + className="h-8" + /> +
+
+ + {/* 크기 */} +
+
+ + + updateComponent(selectedComponent.id, { + width: parseInt(e.target.value) || 50, + }) + } + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + height: parseInt(e.target.value) || 30, + }) + } + className="h-8" + /> +
+
+ + {/* 글꼴 크기 */} +
+ + + updateComponent(selectedComponent.id, { + fontSize: parseInt(e.target.value) || 13, + }) + } + className="h-8" + /> +
+ + {/* 글꼴 색상 */} +
+ + + updateComponent(selectedComponent.id, { + fontColor: e.target.value, + }) + } + className="h-8" + /> +
+ + {/* 배경 색상 */} +
+ + + updateComponent(selectedComponent.id, { + backgroundColor: e.target.value, + }) + } + className="h-8" + /> +
+
+
+
+
+
+ ); +} diff --git a/frontend/components/report/designer/ReportDesignerToolbar.tsx b/frontend/components/report/designer/ReportDesignerToolbar.tsx new file mode 100644 index 00000000..1075b387 --- /dev/null +++ b/frontend/components/report/designer/ReportDesignerToolbar.tsx @@ -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 ( + <> +
+
+ +
+
+

+ {reportDetail?.report.report_name_kor || "리포트 디자이너"} +

+ {reportDetail?.report.report_name_eng && ( +

{reportDetail.report.report_name_eng}

+ )} +
+
+ +
+ + + +
+
+ + setShowPreview(false)} /> + + ); +} diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx new file mode 100644 index 00000000..c6fb2604 --- /dev/null +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -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 ( + + + + 미리보기 + + 현재 레이아웃의 미리보기입니다. 인쇄하거나 파일로 다운로드할 수 있습니다. + + + + {/* 미리보기 영역 */} +
+
+ {components.map((component) => ( +
+ {component.type === "text" && ( +
+
텍스트 필드
+
샘플 텍스트
+
+ )} + {component.type === "label" && ( +
+
레이블
+
레이블 텍스트
+
+ )} + {component.type === "table" && ( +
+
테이블
+
+ + + + + + + + + + + + + + +
품목명수량단가
샘플11050,000
+
+ )} + + ))} + + + + + + + + + + + + ); +} diff --git a/frontend/components/report/designer/TemplatePalette.tsx b/frontend/components/report/designer/TemplatePalette.tsx new file mode 100644 index 00000000..ffe8ea08 --- /dev/null +++ b/frontend/components/report/designer/TemplatePalette.tsx @@ -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 ( +
+ {TEMPLATES.map((template) => ( + + ))} +
+ ); +} diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx new file mode 100644 index 00000000..8974ad07 --- /dev/null +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -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) => void; + removeComponent: (id: string) => void; + selectComponent: (id: string | null) => void; + + // 레이아웃 관리 + updateLayout: (updates: Partial) => void; + saveLayout: () => Promise; + loadLayout: () => Promise; + + // 캔버스 설정 + canvasWidth: number; + canvasHeight: number; + pageOrientation: string; + margins: { + top: number; + bottom: number; + left: number; + right: number; + }; +} + +const ReportDesignerContext = createContext(undefined); + +export function ReportDesignerProvider({ reportId, children }: { reportId: string; children: ReactNode }) { + const [reportDetail, setReportDetail] = useState(null); + const [layout, setLayout] = useState(null); + const [components, setComponents] = useState([]); + const [selectedComponentId, setSelectedComponentId] = useState(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) => { + 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) => { + 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 {children}; +} + +export function useReportDesigner() { + const context = useContext(ReportDesignerContext); + if (context === undefined) { + throw new Error("useReportDesigner must be used within a ReportDesignerProvider"); + } + return context; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7db705eb..56c36b4c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index a66a452a..28b80494 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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",