"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"; export interface ReportQuery { id: string; name: string; type: "MASTER" | "DETAIL"; sqlQuery: string; parameters: string[]; externalConnectionId?: number | null; // 외부 DB 연결 ID } // 템플릿 레이아웃 정의 interface TemplateLayout { components: ComponentConfig[]; queries: ReportQuery[]; } function getTemplateLayout(templateId: string): TemplateLayout | null { switch (templateId) { case "order": return { components: [ { id: `comp-${Date.now()}-1`, type: "label", x: 50, y: 30, width: 200, height: 40, fontSize: 24, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, defaultValue: "발주서", }, { id: `comp-${Date.now()}-2`, type: "text", x: 50, y: 80, width: 150, height: 30, fontSize: 14, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, }, { id: `comp-${Date.now()}-3`, type: "text", x: 220, y: 80, width: 150, height: 30, fontSize: 14, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, }, { id: `comp-${Date.now()}-4`, type: "text", x: 390, y: 80, width: 150, height: 30, fontSize: 14, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, }, { id: `comp-${Date.now()}-5`, type: "table", x: 50, y: 130, width: 500, height: 200, fontSize: 12, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, }, ], queries: [ { id: `query-${Date.now()}-1`, name: "발주 헤더", type: "MASTER", sqlQuery: "SELECT order_no, order_date, supplier_name FROM orders WHERE order_no = $1", parameters: ["$1"], }, { id: `query-${Date.now()}-2`, name: "발주 품목", type: "DETAIL", sqlQuery: "SELECT item_name, quantity, unit_price FROM order_items WHERE order_no = $1", parameters: ["$1"], }, ], }; case "invoice": return { components: [ { id: `comp-${Date.now()}-1`, type: "label", x: 50, y: 30, width: 200, height: 40, fontSize: 24, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, defaultValue: "청구서", }, { id: `comp-${Date.now()}-2`, type: "text", x: 50, y: 80, width: 150, height: 30, fontSize: 14, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, }, { id: `comp-${Date.now()}-3`, type: "text", x: 220, y: 80, width: 150, height: 30, fontSize: 14, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, }, { id: `comp-${Date.now()}-4`, type: "table", x: 50, y: 130, width: 500, height: 200, fontSize: 12, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, }, { id: `comp-${Date.now()}-5`, type: "label", x: 400, y: 350, width: 150, height: 30, fontSize: 16, fontColor: "#000000", backgroundColor: "#ffffcc", borderColor: "#000000", borderWidth: 1, zIndex: 1, defaultValue: "합계: 0원", }, ], queries: [ { id: `query-${Date.now()}-1`, name: "청구 헤더", type: "MASTER", sqlQuery: "SELECT invoice_no, invoice_date, customer_name FROM invoices WHERE invoice_no = $1", parameters: ["$1"], }, { id: `query-${Date.now()}-2`, name: "청구 항목", type: "DETAIL", sqlQuery: "SELECT description, quantity, unit_price, amount FROM invoice_items WHERE invoice_no = $1", parameters: ["$1"], }, ], }; case "basic": return { components: [ { id: `comp-${Date.now()}-1`, type: "label", x: 50, y: 30, width: 300, height: 40, fontSize: 20, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, defaultValue: "리포트 제목", }, { id: `comp-${Date.now()}-2`, type: "text", x: 50, y: 80, width: 500, height: 100, fontSize: 14, fontColor: "#000000", backgroundColor: "transparent", borderColor: "#cccccc", borderWidth: 0, zIndex: 1, defaultValue: "내용을 입력하세요", }, ], queries: [ { id: `query-${Date.now()}-1`, name: "기본 쿼리", type: "MASTER", sqlQuery: "SELECT * FROM table_name WHERE id = $1", parameters: ["$1"], }, ], }; default: return null; } } export interface QueryResult { queryId: string; fields: string[]; rows: Record[]; } interface ReportDesignerContextType { reportId: string; reportDetail: ReportDetail | null; layout: ReportLayout | null; components: ComponentConfig[]; selectedComponentId: string | null; selectedComponentIds: string[]; // 다중 선택된 컴포넌트 ID 배열 isLoading: boolean; isSaving: boolean; // 쿼리 관리 queries: ReportQuery[]; setQueries: (queries: ReportQuery[]) => void; // 쿼리 실행 결과 queryResults: QueryResult[]; setQueryResult: (queryId: string, fields: string[], rows: Record[]) => void; getQueryResult: (queryId: string) => QueryResult | null; // 컴포넌트 관리 addComponent: (component: ComponentConfig) => void; updateComponent: (id: string, updates: Partial) => void; removeComponent: (id: string) => void; selectComponent: (id: string | null, isMultiSelect?: boolean) => void; // 레이아웃 관리 updateLayout: (updates: Partial) => void; saveLayout: () => Promise; loadLayout: () => Promise; // 템플릿 적용 applyTemplate: (templateId: string) => void; // 캔버스 설정 canvasWidth: number; canvasHeight: number; pageOrientation: string; margins: { top: number; bottom: number; left: number; right: number; }; // 레이아웃 도구 gridSize: number; setGridSize: (size: number) => void; showGrid: boolean; setShowGrid: (show: boolean) => void; snapToGrid: boolean; setSnapToGrid: (snap: boolean) => void; snapValueToGrid: (value: number) => number; // 정렬 가이드라인 alignmentGuides: { vertical: number[]; horizontal: number[] }; calculateAlignmentGuides: (draggingId: string, x: number, y: number, width: number, height: number) => void; clearAlignmentGuides: () => void; // 복사/붙여넣기 copyComponents: () => void; pasteComponents: () => void; // Undo/Redo undo: () => void; redo: () => void; canUndo: boolean; canRedo: boolean; // 정렬 기능 alignLeft: () => void; alignRight: () => void; alignTop: () => void; alignBottom: () => void; alignCenterHorizontal: () => void; alignCenterVertical: () => void; distributeHorizontal: () => void; distributeVertical: () => void; makeSameWidth: () => void; makeSameHeight: () => void; makeSameSize: () => void; // 레이어 관리 bringToFront: () => void; sendToBack: () => void; bringForward: () => void; sendBackward: () => void; // 잠금 관리 toggleLock: () => void; lockComponents: () => void; unlockComponents: () => void; // 눈금자 표시 showRuler: boolean; setShowRuler: (show: boolean) => void; } 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 [queries, setQueries] = useState([]); const [queryResults, setQueryResults] = useState([]); const [selectedComponentId, setSelectedComponentId] = useState(null); const [selectedComponentIds, setSelectedComponentIds] = useState([]); // 다중 선택 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const { toast } = useToast(); // 레이아웃 도구 설정 const [gridSize, setGridSize] = useState(10); // Grid Snap 크기 (px) const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부 const [snapToGrid, setSnapToGrid] = useState(true); // Grid Snap 활성화 // 눈금자 표시 const [showRuler, setShowRuler] = useState(true); // 정렬 가이드라인 const [alignmentGuides, setAlignmentGuides] = useState<{ vertical: number[]; horizontal: number[]; }>({ vertical: [], horizontal: [] }); // 클립보드 (복사/붙여넣기) const [clipboard, setClipboard] = useState([]); // Undo/Redo 히스토리 const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); const [isUndoRedoing, setIsUndoRedoing] = useState(false); // Undo/Redo 실행 중 플래그 // Grid Snap 함수 const snapValueToGrid = useCallback( (value: number): number => { if (!snapToGrid) return value; return Math.round(value / gridSize) * gridSize; }, [snapToGrid, gridSize], ); // 정렬 가이드라인 계산 (드래그 중인 컴포넌트 제외) const calculateAlignmentGuides = useCallback( (draggingId: string, x: number, y: number, width: number, height: number) => { const verticalLines: number[] = []; const horizontalLines: number[] = []; // 드래그 중인 컴포넌트의 주요 위치 const left = x; const right = x + width; const centerX = x + width / 2; const top = y; const bottom = y + height; const centerY = y + height / 2; // 다른 컴포넌트들과 비교 components.forEach((comp) => { if (comp.id === draggingId) return; const compLeft = comp.x; const compRight = comp.x + comp.width; const compCenterX = comp.x + comp.width / 2; const compTop = comp.y; const compBottom = comp.y + comp.height; const compCenterY = comp.y + comp.height / 2; // 세로 정렬 체크 (left, center, right) - 정확히 일치할 때만 if (left === compLeft) verticalLines.push(compLeft); if (left === compRight) verticalLines.push(compRight); if (right === compLeft) verticalLines.push(compLeft); if (right === compRight) verticalLines.push(compRight); if (centerX === compCenterX) verticalLines.push(compCenterX); // 가로 정렬 체크 (top, center, bottom) - 정확히 일치할 때만 if (top === compTop) horizontalLines.push(compTop); if (top === compBottom) horizontalLines.push(compBottom); if (bottom === compTop) horizontalLines.push(compTop); if (bottom === compBottom) horizontalLines.push(compBottom); if (centerY === compCenterY) horizontalLines.push(compCenterY); }); // 중복 제거 setAlignmentGuides({ vertical: Array.from(new Set(verticalLines)), horizontal: Array.from(new Set(horizontalLines)), }); }, [components], ); // 정렬 가이드라인 초기화 const clearAlignmentGuides = useCallback(() => { setAlignmentGuides({ vertical: [], horizontal: [] }); }, []); // 복사 (Ctrl+C) const copyComponents = useCallback(() => { if (selectedComponentIds.length > 0) { const componentsToCopy = components.filter((comp) => selectedComponentIds.includes(comp.id)); setClipboard(componentsToCopy); toast({ title: "복사 완료", description: `${componentsToCopy.length}개의 컴포넌트가 복사되었습니다.`, }); } else if (selectedComponentId) { const componentToCopy = components.find((comp) => comp.id === selectedComponentId); if (componentToCopy) { setClipboard([componentToCopy]); toast({ title: "복사 완료", description: "컴포넌트가 복사되었습니다.", }); } } }, [selectedComponentId, selectedComponentIds, components, toast]); // 붙여넣기 (Ctrl+V) const pasteComponents = useCallback(() => { if (clipboard.length === 0) return; const newComponents = clipboard.map((comp) => ({ ...comp, id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, x: comp.x + 20, // 약간 오프셋 y: comp.y + 20, zIndex: components.length, })); // setComponents를 직접 사용 setComponents((prev) => [...prev, ...newComponents]); // 새로 생성된 컴포넌트 선택 if (newComponents.length === 1) { setSelectedComponentId(newComponents[0].id); setSelectedComponentIds([newComponents[0].id]); } else { setSelectedComponentIds(newComponents.map((c) => c.id)); setSelectedComponentId(newComponents[0].id); } toast({ title: "붙여넣기 완료", description: `${newComponents.length}개의 컴포넌트가 추가되었습니다.`, }); }, [clipboard, components.length, toast]); // 히스토리에 현재 상태 저장 const saveToHistory = useCallback( (newComponents: ComponentConfig[]) => { setHistory((prev) => { // 현재 인덱스 이후의 히스토리 제거 (새 분기 시작) const newHistory = prev.slice(0, historyIndex + 1); // 새 상태 추가 (최대 50개까지만 유지) newHistory.push(JSON.parse(JSON.stringify(newComponents))); return newHistory.slice(-50); }); setHistoryIndex((prev) => Math.min(prev + 1, 49)); }, [historyIndex], ); // Undo const undo = useCallback(() => { if (historyIndex > 0) { setIsUndoRedoing(true); setHistoryIndex((prev) => prev - 1); setComponents(JSON.parse(JSON.stringify(history[historyIndex - 1]))); setTimeout(() => setIsUndoRedoing(false), 100); toast({ title: "실행 취소", description: "이전 상태로 되돌렸습니다.", }); } }, [historyIndex, history, toast]); // Redo const redo = useCallback(() => { if (historyIndex < history.length - 1) { setIsUndoRedoing(true); setHistoryIndex((prev) => prev + 1); setComponents(JSON.parse(JSON.stringify(history[historyIndex + 1]))); setTimeout(() => setIsUndoRedoing(false), 100); toast({ title: "다시 실행", description: "다음 상태로 이동했습니다.", }); } }, [historyIndex, history, toast]); // 정렬 함수들 (선택된 컴포넌트들 기준) const getSelectedComponents = useCallback(() => { return components.filter((c) => selectedComponentIds.includes(c.id)); }, [components, selectedComponentIds]); // 왼쪽 정렬 const alignLeft = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 2) return; const minX = Math.min(...selected.map((c) => c.x)); const updates = selected.map((c) => ({ ...c, x: minX })); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "정렬 완료", description: "왼쪽 정렬되었습니다." }); }, [getSelectedComponents, toast]); // 오른쪽 정렬 const alignRight = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 2) return; const maxRight = Math.max(...selected.map((c) => c.x + c.width)); const updates = selected.map((c) => ({ ...c, x: maxRight - c.width })); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "정렬 완료", description: "오른쪽 정렬되었습니다." }); }, [getSelectedComponents, toast]); // 위쪽 정렬 const alignTop = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 2) return; const minY = Math.min(...selected.map((c) => c.y)); const updates = selected.map((c) => ({ ...c, y: minY })); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "정렬 완료", description: "위쪽 정렬되었습니다." }); }, [getSelectedComponents, toast]); // 아래쪽 정렬 const alignBottom = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 2) return; const maxBottom = Math.max(...selected.map((c) => c.y + c.height)); const updates = selected.map((c) => ({ ...c, y: maxBottom - c.height })); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "정렬 완료", description: "아래쪽 정렬되었습니다." }); }, [getSelectedComponents, toast]); // 가로 중앙 정렬 const alignCenterHorizontal = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 2) return; const minX = Math.min(...selected.map((c) => c.x)); const maxRight = Math.max(...selected.map((c) => c.x + c.width)); const centerX = (minX + maxRight) / 2; const updates = selected.map((c) => ({ ...c, x: centerX - c.width / 2 })); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "정렬 완료", description: "가로 중앙 정렬되었습니다." }); }, [getSelectedComponents, toast]); // 세로 중앙 정렬 const alignCenterVertical = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 2) return; const minY = Math.min(...selected.map((c) => c.y)); const maxBottom = Math.max(...selected.map((c) => c.y + c.height)); const centerY = (minY + maxBottom) / 2; const updates = selected.map((c) => ({ ...c, y: centerY - c.height / 2 })); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "정렬 완료", description: "세로 중앙 정렬되었습니다." }); }, [getSelectedComponents, toast]); // 가로 균등 배치 const distributeHorizontal = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 3) return; const sorted = [...selected].sort((a, b) => a.x - b.x); const minX = sorted[0].x; const maxX = sorted[sorted.length - 1].x + sorted[sorted.length - 1].width; const totalWidth = sorted.reduce((sum, c) => sum + c.width, 0); const totalGap = maxX - minX - totalWidth; const gap = totalGap / (sorted.length - 1); let currentX = minX; const updates = sorted.map((c) => { const newC = { ...c, x: currentX }; currentX += c.width + gap; return newC; }); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "정렬 완료", description: "가로 균등 배치되었습니다." }); }, [getSelectedComponents, toast]); // 세로 균등 배치 const distributeVertical = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 3) return; const sorted = [...selected].sort((a, b) => a.y - b.y); const minY = sorted[0].y; const maxY = sorted[sorted.length - 1].y + sorted[sorted.length - 1].height; const totalHeight = sorted.reduce((sum, c) => sum + c.height, 0); const totalGap = maxY - minY - totalHeight; const gap = totalGap / (sorted.length - 1); let currentY = minY; const updates = sorted.map((c) => { const newC = { ...c, y: currentY }; currentY += c.height + gap; return newC; }); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "정렬 완료", description: "세로 균등 배치되었습니다." }); }, [getSelectedComponents, toast]); // 같은 너비로 const makeSameWidth = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 2) return; const targetWidth = selected[0].width; const updates = selected.map((c) => ({ ...c, width: targetWidth })); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "크기 조정 완료", description: "같은 너비로 조정되었습니다." }); }, [getSelectedComponents, toast]); // 같은 높이로 const makeSameHeight = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 2) return; const targetHeight = selected[0].height; const updates = selected.map((c) => ({ ...c, height: targetHeight })); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "크기 조정 완료", description: "같은 높이로 조정되었습니다." }); }, [getSelectedComponents, toast]); // 같은 크기로 const makeSameSize = useCallback(() => { const selected = getSelectedComponents(); if (selected.length < 2) return; const targetWidth = selected[0].width; const targetHeight = selected[0].height; const updates = selected.map((c) => ({ ...c, width: targetWidth, height: targetHeight })); setComponents((prev) => prev.map((c) => { const update = updates.find((u) => u.id === c.id); return update || c; }), ); toast({ title: "크기 조정 완료", description: "같은 크기로 조정되었습니다." }); }, [getSelectedComponents, toast]); // 레이어 관리 함수들 const bringToFront = useCallback(() => { if (!selectedComponentId && selectedComponentIds.length === 0) return; const idsToUpdate = selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]); const maxZIndex = Math.max(...components.map((c) => c.zIndex)); setComponents((prev) => prev.map((c) => { if (idsToUpdate.includes(c.id)) { return { ...c, zIndex: maxZIndex + 1 }; } return c; }), ); toast({ title: "레이어 변경", description: "맨 앞으로 이동했습니다." }); }, [selectedComponentId, selectedComponentIds, components, toast]); const sendToBack = useCallback(() => { if (!selectedComponentId && selectedComponentIds.length === 0) return; const idsToUpdate = selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]); const minZIndex = Math.min(...components.map((c) => c.zIndex)); setComponents((prev) => prev.map((c) => { if (idsToUpdate.includes(c.id)) { return { ...c, zIndex: minZIndex - 1 }; } return c; }), ); toast({ title: "레이어 변경", description: "맨 뒤로 이동했습니다." }); }, [selectedComponentId, selectedComponentIds, components, toast]); const bringForward = useCallback(() => { if (!selectedComponentId && selectedComponentIds.length === 0) return; const idsToUpdate = selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]); setComponents((prev) => { const sorted = [...prev].sort((a, b) => a.zIndex - b.zIndex); const updated = sorted.map((c, index) => ({ ...c, zIndex: index })); return updated.map((c) => { if (idsToUpdate.includes(c.id)) { return { ...c, zIndex: Math.min(c.zIndex + 1, updated.length - 1) }; } return c; }); }); toast({ title: "레이어 변경", description: "한 단계 앞으로 이동했습니다." }); }, [selectedComponentId, selectedComponentIds, toast]); const sendBackward = useCallback(() => { if (!selectedComponentId && selectedComponentIds.length === 0) return; const idsToUpdate = selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]); setComponents((prev) => { const sorted = [...prev].sort((a, b) => a.zIndex - b.zIndex); const updated = sorted.map((c, index) => ({ ...c, zIndex: index })); return updated.map((c) => { if (idsToUpdate.includes(c.id)) { return { ...c, zIndex: Math.max(c.zIndex - 1, 0) }; } return c; }); }); toast({ title: "레이어 변경", description: "한 단계 뒤로 이동했습니다." }); }, [selectedComponentId, selectedComponentIds, toast]); // 잠금 관리 함수들 const toggleLock = useCallback(() => { if (!selectedComponentId && selectedComponentIds.length === 0) return; const idsToUpdate = selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]); setComponents((prev) => prev.map((c) => { if (idsToUpdate.includes(c.id)) { return { ...c, locked: !c.locked }; } return c; }), ); const isLocking = components.find((c) => idsToUpdate.includes(c.id))?.locked === false; toast({ title: isLocking ? "잠금 설정" : "잠금 해제", description: isLocking ? "선택된 컴포넌트가 잠겼습니다." : "선택된 컴포넌트의 잠금이 해제되었습니다.", }); }, [selectedComponentId, selectedComponentIds, components, toast]); const lockComponents = useCallback(() => { if (!selectedComponentId && selectedComponentIds.length === 0) return; const idsToUpdate = selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]); setComponents((prev) => prev.map((c) => { if (idsToUpdate.includes(c.id)) { return { ...c, locked: true }; } return c; }), ); toast({ title: "잠금 설정", description: "선택된 컴포넌트가 잠겼습니다." }); }, [selectedComponentId, selectedComponentIds, toast]); const unlockComponents = useCallback(() => { if (!selectedComponentId && selectedComponentIds.length === 0) return; const idsToUpdate = selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]); setComponents((prev) => prev.map((c) => { if (idsToUpdate.includes(c.id)) { return { ...c, locked: false }; } return c; }), ); toast({ title: "잠금 해제", description: "선택된 컴포넌트의 잠금이 해제되었습니다." }); }, [selectedComponentId, selectedComponentIds, toast]); // 캔버스 설정 (기본값) 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); // 쿼리 로드 if (detailResponse.data.queries && detailResponse.data.queries.length > 0) { const loadedQueries = detailResponse.data.queries.map((q) => ({ id: q.query_id, name: q.query_name, type: q.query_type, sqlQuery: q.sql_query, parameters: Array.isArray(q.parameters) ? q.parameters : [], })); setQueries(loadedQueries); } } // 레이아웃 조회 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 { // 레이아웃이 없으면 기본값 사용 console.log("레이아웃 없음, 기본값 사용"); } // 쿼리 조회는 이미 위에서 처리됨 (reportResponse.data.queries) } catch (error) { const errorMessage = error instanceof Error ? error.message : "리포트를 불러오는데 실패했습니다."; toast({ title: "오류", description: errorMessage, variant: "destructive", }); } finally { setIsLoading(false); } }, [reportId, toast]); // 초기 로드 useEffect(() => { loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportId]); // 초기 히스토리 설정 useEffect(() => { if (!isLoading && components.length > 0 && history.length === 0) { // 최초 컴포넌트 로드 시 히스토리에 추가 setHistory([JSON.parse(JSON.stringify(components))]); setHistoryIndex(0); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoading, components.length]); // 컴포넌트 변경 시 히스토리 저장 (디바운스 적용) useEffect(() => { if (components.length === 0) return; if (isLoading) return; // 로딩 중에는 히스토리 저장 안 함 if (isUndoRedoing) return; // Undo/Redo 중에는 히스토리 저장 안 함 const timeoutId = setTimeout(() => { // 현재 히스토리의 마지막 항목과 비교하여 다를 때만 저장 const lastHistory = history[historyIndex]; const isDifferent = !lastHistory || JSON.stringify(lastHistory) !== JSON.stringify(components); if (isDifferent) { saveToHistory(components); } }, 300); // 300ms 디바운스 return () => clearTimeout(timeoutId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [components, isUndoRedoing]); // 쿼리 결과 저장 const setQueryResult = useCallback((queryId: string, fields: string[], rows: Record[]) => { setQueryResults((prev) => { const existing = prev.find((r) => r.queryId === queryId); if (existing) { return prev.map((r) => (r.queryId === queryId ? { queryId, fields, rows } : r)); } return [...prev, { queryId, fields, rows }]; }); }, []); // 쿼리 결과 조회 const getQueryResult = useCallback( (queryId: string): QueryResult | null => { return queryResults.find((r) => r.queryId === queryId) || null; }, [queryResults], ); // 컴포넌트 추가 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, isMultiSelect = false) => { if (id === null) { // 선택 해제 setSelectedComponentId(null); setSelectedComponentIds([]); return; } if (isMultiSelect) { // Ctrl+클릭: 다중 선택 토글 setSelectedComponentIds((prev) => { if (prev.includes(id)) { // 이미 선택되어 있으면 제거 const newSelection = prev.filter((compId) => compId !== id); setSelectedComponentId(newSelection.length > 0 ? newSelection[0] : null); return newSelection; } else { // 선택 추가 setSelectedComponentId(id); return [...prev, id]; } }); } else { // 일반 클릭: 단일 선택 setSelectedComponentId(id); setSelectedComponentIds([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, queries, }); toast({ title: "성공", description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다.", }); // 새 리포트였다면 데이터 다시 로드 if (reportId === "new") { await loadLayout(); } } catch (error) { const errorMessage = error instanceof Error ? error.message : "저장에 실패했습니다."; toast({ title: "오류", description: errorMessage, variant: "destructive", }); } finally { setIsSaving(false); } }, [reportId, canvasWidth, canvasHeight, pageOrientation, margins, components, queries, toast, loadLayout]); // 템플릿 적용 const applyTemplate = useCallback( async (templateId: string) => { try { // 기존 컴포넌트가 있으면 확인 if (components.length > 0) { if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) { return; } } // 1. 먼저 하드코딩된 시스템 템플릿 확인 (order, invoice, basic) const systemTemplate = getTemplateLayout(templateId); if (systemTemplate) { // 시스템 템플릿 적용 setComponents(systemTemplate.components); setQueries(systemTemplate.queries); toast({ title: "성공", description: "템플릿이 적용되었습니다.", }); return; } // 2. 사용자 정의 템플릿은 백엔드에서 조회 const response = await reportApi.getTemplates(); if (!response.success || !response.data) { throw new Error("템플릿 목록을 불러올 수 없습니다."); } // 커스텀 템플릿 찾기 const customTemplates = response.data.custom || []; const template = customTemplates.find((t: { template_id: string }) => t.template_id === templateId); if (!template) { throw new Error("템플릿을 찾을 수 없습니다."); } // 3. 템플릿 데이터 파싱 및 적용 const layoutConfig = typeof template.layout_config === "string" ? JSON.parse(template.layout_config) : template.layout_config; const defaultQueries = typeof template.default_queries === "string" ? JSON.parse(template.default_queries) : template.default_queries || []; // 컴포넌트 적용 (ID 재생성) const newComponents = (layoutConfig.components as ComponentConfig[]).map((comp) => ({ ...comp, id: `comp-${Date.now()}-${Math.random()}`, })); // 쿼리 적용 (ID 재생성) const newQueries = ( defaultQueries as Array<{ name: string; type: "MASTER" | "DETAIL"; sqlQuery: string; parameters: string[]; externalConnectionId?: number | null; }> ).map((q) => ({ id: `query-${Date.now()}-${Math.random()}`, name: q.name, type: q.type, sqlQuery: q.sqlQuery, parameters: q.parameters || [], externalConnectionId: q.externalConnectionId || null, })); setComponents(newComponents); setQueries(newQueries); toast({ title: "성공", description: "사용자 정의 템플릿이 적용되었습니다.", }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "템플릿 적용에 실패했습니다."; toast({ title: "오류", description: errorMessage, variant: "destructive", }); } }, [components.length, toast], ); const value: ReportDesignerContextType = { reportId, reportDetail, layout, components, queries, setQueries, queryResults, setQueryResult, getQueryResult, selectedComponentId, selectedComponentIds, isLoading, isSaving, addComponent, updateComponent, removeComponent, selectComponent, updateLayout, saveLayout, loadLayout, applyTemplate, canvasWidth, canvasHeight, pageOrientation, margins, // 레이아웃 도구 gridSize, setGridSize, showGrid, setShowGrid, snapToGrid, setSnapToGrid, snapValueToGrid, // 정렬 가이드라인 alignmentGuides, calculateAlignmentGuides, clearAlignmentGuides, // 복사/붙여넣기 copyComponents, pasteComponents, // Undo/Redo undo, redo, canUndo: historyIndex > 0, canRedo: historyIndex < history.length - 1, // 정렬 기능 alignLeft, alignRight, alignTop, alignBottom, alignCenterHorizontal, alignCenterVertical, distributeHorizontal, distributeVertical, makeSameWidth, makeSameHeight, makeSameSize, // 레이어 관리 bringToFront, sendToBack, bringForward, sendBackward, // 잠금 관리 toggleLock, lockComponents, unlockComponents, // 눈금자 표시 showRuler, setShowRuler, }; return {children}; } export function useReportDesigner() { const context = useContext(ReportDesignerContext); if (context === undefined) { throw new Error("useReportDesigner must be used within a ReportDesignerProvider"); } return context; }