"use client"; import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from "react"; import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig } from "@/types/report"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; import { v4 as uuidv4 } from "uuid"; export interface ReportQuery { id: string; name: string; type: "MASTER" | "DETAIL"; sqlQuery: string; parameters: string[]; externalConnectionId?: number | null; // 외부 DB 연결 ID } // 하드코딩된 템플릿 제거 - 모든 템플릿은 DB에서 관리 export interface QueryResult { queryId: string; fields: string[]; rows: Record[]; } interface ReportDesignerContextType { reportId: string; reportDetail: ReportDetail | null; layout: ReportLayout | null; // 페이지 관리 layoutConfig: ReportLayoutConfig; // { pages: [...] } currentPageId: string | null; currentPage: ReportPage | undefined; // 페이지 액션 addPage: (name?: string) => void; deletePage: (pageId: string) => void; duplicatePage: (pageId: string) => void; reorderPages: (sourceIndex: number, targetIndex: number) => void; selectPage: (pageId: string) => void; updatePageSettings: (pageId: string, settings: Partial) => void; // 컴포넌트 (현재 페이지) components: ComponentConfig[]; // currentPage의 components (읽기 전용) 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; // 그룹화 groupComponents: () => void; ungroupComponents: () => 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 [layoutConfig, setLayoutConfig] = useState({ pages: [], }); const [currentPageId, setCurrentPageId] = useState(null); 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 currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId); // 현재 페이지의 컴포넌트 (읽기 전용) const components = currentPage?.components || []; // 현재 페이지의 컴포넌트를 업데이트하는 헬퍼 함수 const setComponents = useCallback( (updater: ComponentConfig[] | ((prev: ComponentConfig[]) => ComponentConfig[])) => { if (!currentPageId) return; setLayoutConfig((prev) => ({ pages: prev.pages.map((page) => page.page_id === currentPageId ? { ...page, components: typeof updater === "function" ? updater(page.components) : updater, } : page, ), })); }, [currentPageId], ); // 레이아웃 도구 설정 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], ); // 복사 (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)) { // zIndex는 최소 1로 제한 (0이면 캔버스 배경 뒤로 가버림) return { ...c, zIndex: Math.max(1, 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 + 1 })); return updated.map((c) => { if (idsToUpdate.includes(c.id)) { // zIndex는 최소 1로 제한 return { ...c, zIndex: Math.max(c.zIndex - 1, 1) }; } 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 groupComponents = useCallback(() => { if (selectedComponentIds.length < 2) { toast({ title: "그룹화 불가", description: "2개 이상의 컴포넌트를 선택해야 합니다.", variant: "destructive", }); return; } // 새로운 그룹 ID 생성 const newGroupId = `group_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; setComponents((prev) => prev.map((c) => { if (selectedComponentIds.includes(c.id)) { return { ...c, groupId: newGroupId }; } return c; }), ); toast({ title: "그룹화 완료", description: `${selectedComponentIds.length}개의 컴포넌트가 그룹화되었습니다.`, }); }, [selectedComponentIds, toast]); const ungroupComponents = useCallback(() => { if (!selectedComponentId && selectedComponentIds.length === 0) { toast({ title: "그룹 해제 불가", description: "그룹을 해제할 컴포넌트를 선택해주세요.", variant: "destructive", }); return; } const idsToUngroup = selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]); // 선택된 컴포넌트들의 그룹 ID 수집 const groupIds = new Set(); components.forEach((c) => { if (idsToUngroup.includes(c.id) && c.groupId) { groupIds.add(c.groupId); } }); if (groupIds.size === 0) { toast({ title: "그룹 해제 불가", description: "선택된 컴포넌트 중 그룹화된 것이 없습니다.", variant: "destructive", }); return; } // 해당 그룹 ID를 가진 모든 컴포넌트의 그룹 해제 setComponents((prev) => prev.map((c) => { if (c.groupId && groupIds.has(c.groupId)) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { groupId, ...rest } = c; return rest as ComponentConfig; } return c; }), ); toast({ title: "그룹 해제 완료", description: `${groupIds.size}개의 그룹이 해제되었습니다.`, }); }, [selectedComponentId, selectedComponentIds, components, toast]); // 캔버스 설정 (현재 페이지 기반) const canvasWidth = currentPage?.width || 210; const canvasHeight = currentPage?.height || 297; const pageOrientation = currentPage?.orientation || "portrait"; const margins = currentPage?.margins || { top: 20, bottom: 20, left: 20, right: 20, }; // 정렬 가이드라인 계산 (캔버스 중앙선 포함) const calculateAlignmentGuides = useCallback( (draggingId: string, x: number, y: number, width: number, height: number) => { const verticalLines: number[] = []; const horizontalLines: number[] = []; const threshold = 5; // 5px 오차 허용 // 캔버스를 픽셀로 변환 (1mm = 3.7795px) const canvasWidthPx = canvasWidth * 3.7795; const canvasHeightPx = canvasHeight * 3.7795; const canvasCenterX = canvasWidthPx / 2; const canvasCenterY = canvasHeightPx / 2; // 드래그 중인 컴포넌트의 주요 위치 const left = x; const right = x + width; const centerX = x + width / 2; const top = y; const bottom = y + height; const centerY = y + height / 2; // 캔버스 중앙선 체크 if (Math.abs(centerX - canvasCenterX) < threshold) { verticalLines.push(canvasCenterX); } if (Math.abs(centerY - canvasCenterY) < threshold) { horizontalLines.push(canvasCenterY); } // 다른 컴포넌트들과 비교 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 (Math.abs(left - compLeft) < threshold) verticalLines.push(compLeft); if (Math.abs(left - compRight) < threshold) verticalLines.push(compRight); if (Math.abs(right - compLeft) < threshold) verticalLines.push(compLeft); if (Math.abs(right - compRight) < threshold) verticalLines.push(compRight); if (Math.abs(centerX - compCenterX) < threshold) verticalLines.push(compCenterX); // 가로 정렬 체크 (top, center, bottom) - 오차 허용 if (Math.abs(top - compTop) < threshold) horizontalLines.push(compTop); if (Math.abs(top - compBottom) < threshold) horizontalLines.push(compBottom); if (Math.abs(bottom - compTop) < threshold) horizontalLines.push(compTop); if (Math.abs(bottom - compBottom) < threshold) horizontalLines.push(compBottom); if (Math.abs(centerY - compCenterY) < threshold) horizontalLines.push(compCenterY); }); // 중복 제거 setAlignmentGuides({ vertical: Array.from(new Set(verticalLines)), horizontal: Array.from(new Set(horizontalLines)), }); }, [components, canvasWidth, canvasHeight], ); // 정렬 가이드라인 초기화 const clearAlignmentGuides = useCallback(() => { setAlignmentGuides({ vertical: [], horizontal: [] }); }, []); // 페이지 관리 함수들 const addPage = useCallback( (name?: string) => { const newPageId = uuidv4(); const newPage: ReportPage = { page_id: newPageId, page_name: name || `페이지 ${layoutConfig.pages.length + 1}`, page_order: layoutConfig.pages.length, width: 210, height: 297, orientation: "portrait", margins: { top: 20, bottom: 20, left: 20, right: 20 }, background_color: "#ffffff", components: [], }; setLayoutConfig((prev) => ({ pages: [...prev.pages, newPage], })); // 새 페이지로 자동 선택 setCurrentPageId(newPageId); toast({ title: "페이지 추가", description: `${newPage.page_name}이(가) 추가되었습니다.`, }); }, [layoutConfig.pages.length, toast], ); const deletePage = useCallback( (pageId: string) => { if (layoutConfig.pages.length <= 1) { toast({ title: "삭제 불가", description: "최소 1개의 페이지는 필요합니다.", variant: "destructive", }); return; } const pageIndex = layoutConfig.pages.findIndex((p) => p.page_id === pageId); if (pageIndex === -1) return; setLayoutConfig((prev) => ({ pages: prev.pages.filter((p) => p.page_id !== pageId).map((p, idx) => ({ ...p, page_order: idx })), // 순서 재정렬 })); // 현재 페이지가 삭제된 경우 첫 번째 페이지로 이동 if (currentPageId === pageId) { const remainingPages = layoutConfig.pages.filter((p) => p.page_id !== pageId); setCurrentPageId(remainingPages[0]?.page_id || null); } toast({ title: "페이지 삭제", description: "페이지가 삭제되었습니다.", }); }, [layoutConfig.pages, currentPageId, toast], ); const duplicatePage = useCallback( (pageId: string) => { const sourcePage = layoutConfig.pages.find((p) => p.page_id === pageId); if (!sourcePage) return; const newPageId = uuidv4(); const newPage: ReportPage = { ...sourcePage, page_id: newPageId, page_name: `${sourcePage.page_name} (복사)`, page_order: layoutConfig.pages.length, // 컴포넌트도 복제 (새로운 ID 부여) components: sourcePage.components.map((comp) => ({ ...comp, id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, })), }; setLayoutConfig((prev) => ({ pages: [...prev.pages, newPage], })); setCurrentPageId(newPageId); toast({ title: "페이지 복제", description: `${newPage.page_name}이(가) 생성되었습니다.`, }); }, [layoutConfig.pages, toast], ); const reorderPages = useCallback( (sourceIndex: number, targetIndex: number) => { if (sourceIndex === targetIndex) return; const newPages = [...layoutConfig.pages]; const [movedPage] = newPages.splice(sourceIndex, 1); newPages.splice(targetIndex, 0, movedPage); // page_order 업데이트 newPages.forEach((page, idx) => { page.page_order = idx; }); setLayoutConfig({ pages: newPages }); }, [layoutConfig.pages], ); const selectPage = useCallback((pageId: string) => { setCurrentPageId(pageId); // 페이지 전환 시 선택 해제 setSelectedComponentId(null); setSelectedComponentIds([]); }, []); const updatePageSettings = useCallback((pageId: string, settings: Partial) => { setLayoutConfig((prev) => ({ pages: prev.pages.map((page) => (page.page_id === pageId ? { ...page, ...settings } : page)), })); }, []); // 리포트 및 레이아웃 로드 const loadLayout = useCallback(async () => { setIsLoading(true); try { // 'new'는 새 리포트 생성 모드 - 기본 페이지 1개 생성 if (reportId === "new") { const defaultPageId = uuidv4(); const defaultPage: ReportPage = { page_id: defaultPageId, page_name: "페이지 1", page_order: 0, width: 210, height: 297, orientation: "portrait", margins: { top: 20, bottom: 20, left: 20, right: 20 }, background_color: "#ffffff", components: [], }; setLayoutConfig({ pages: [defaultPage] }); setCurrentPageId(defaultPageId); 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 : [], externalConnectionId: q.external_connection_id || undefined, })); setQueries(loadedQueries); } } // 레이아웃 조회 try { const layoutResponse = await reportApi.getLayout(reportId); if (layoutResponse.success && layoutResponse.data) { const layoutData = layoutResponse.data; setLayout(layoutData); // 자동 마이그레이션: 기존 단일 페이지 구조 → 다중 페이지 구조 const oldComponents = layoutData.components || []; // 기존 구조 감지 if (oldComponents.length > 0) { const migratedPageId = uuidv4(); const migratedPage: ReportPage = { page_id: migratedPageId, page_name: "페이지 1", page_order: 0, width: layoutData.canvas_width || 210, height: layoutData.canvas_height || 297, orientation: (layoutData.page_orientation as "portrait" | "landscape") || "portrait", margins: { top: layoutData.margin_top || 20, bottom: layoutData.margin_bottom || 20, left: layoutData.margin_left || 20, right: layoutData.margin_right || 20, }, background_color: "#ffffff", components: oldComponents, }; setLayoutConfig({ pages: [migratedPage] }); setCurrentPageId(migratedPageId); console.log("✅ 기존 레이아웃을 페이지 구조로 자동 마이그레이션", migratedPage); } else { // 빈 레이아웃 - 기본 페이지 생성 const defaultPageId = uuidv4(); const defaultPage: ReportPage = { page_id: defaultPageId, page_name: "페이지 1", page_order: 0, width: 210, height: 297, orientation: "portrait", margins: { top: 20, bottom: 20, left: 20, right: 20 }, background_color: "#ffffff", components: [], }; setLayoutConfig({ pages: [defaultPage] }); setCurrentPageId(defaultPageId); } } } catch { // 레이아웃이 없으면 기본 페이지 생성 const defaultPageId = uuidv4(); const defaultPage: ReportPage = { page_id: defaultPageId, page_name: "페이지 1", page_order: 0, width: 210, height: 297, orientation: "portrait", margins: { top: 20, bottom: 20, left: 20, right: 20 }, background_color: "#ffffff", components: [], }; setLayoutConfig({ pages: [defaultPage] }); setCurrentPageId(defaultPageId); console.log("레이아웃 없음, 기본 페이지 생성"); } } 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) => { if (!currentPageId) return; setLayoutConfig((prev) => ({ pages: prev.pages.map((page) => page.page_id === currentPageId ? { ...page, components: [...page.components, component] } : page, ), })); }, [currentPageId], ); // 컴포넌트 업데이트 (현재 페이지에서) const updateComponent = useCallback( (id: string, updates: Partial) => { if (!currentPageId) return; setLayoutConfig((prev) => ({ pages: prev.pages.map((page) => page.page_id === currentPageId ? { ...page, components: page.components.map((comp) => (comp.id === id ? { ...comp, ...updates } : comp)), } : page, ), })); }, [currentPageId], ); // 컴포넌트 삭제 (현재 페이지에서) const removeComponent = useCallback( (id: string) => { if (!currentPageId) return; setLayoutConfig((prev) => ({ pages: prev.pages.map((page) => page.page_id === currentPageId ? { ...page, components: page.components.filter((comp) => comp.id !== id) } : page, ), })); if (selectedComponentId === id) { setSelectedComponentId(null); } // 다중 선택에서도 제거 setSelectedComponentIds((prev) => prev.filter((compId) => compId !== id)); }, [currentPageId, 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 (!currentPageId) return; const pageUpdates: Partial = {}; if (updates.canvas_width !== undefined) pageUpdates.width = updates.canvas_width; if (updates.canvas_height !== undefined) pageUpdates.height = updates.canvas_height; if (updates.page_orientation !== undefined) pageUpdates.orientation = updates.page_orientation as "portrait" | "landscape"; if ( updates.margin_top !== undefined || updates.margin_bottom !== undefined || updates.margin_left !== undefined || updates.margin_right !== undefined ) { pageUpdates.margins = { top: updates.margin_top ?? currentPage?.margins.top ?? 20, bottom: updates.margin_bottom ?? currentPage?.margins.bottom ?? 20, left: updates.margin_left ?? currentPage?.margins.left ?? 20, right: updates.margin_right ?? currentPage?.margins.right ?? 20, }; } if (Object.keys(pageUpdates).length > 0) { updatePageSettings(currentPageId, pageUpdates); } }, [currentPageId, currentPage, updatePageSettings], ); // 레이아웃 저장 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, { layoutConfig, // 페이지 기반 구조 queries: queries.map((q) => ({ ...q, externalConnectionId: q.externalConnectionId || undefined, })), }); 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, layoutConfig, queries, toast, loadLayout]); // 템플릿 적용 const applyTemplate = useCallback( async (templateId: string) => { try { // 기존 컴포넌트가 있으면 확인 if (components.length > 0) { if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) { return; } } // DB에서 템플릿 조회 (시스템 템플릿 또는 사용자 정의 템플릿) const response = await reportApi.getTemplates(); if (!response.success || !response.data) { throw new Error("템플릿 목록을 불러올 수 없습니다."); } // 시스템 템플릿과 커스텀 템플릿 모두에서 찾기 const allTemplates = [...(response.data.system || []), ...(response.data.custom || [])]; const template = allTemplates.find((t: { template_id: string }) => t.template_id === templateId); if (!template) { throw new Error("템플릿을 찾을 수 없습니다."); } // 템플릿 데이터 확인용 로그 console.log("===== 선택된 템플릿 ====="); console.log("Template ID:", template.template_id); console.log("Template Name:", template.template_name_kor); console.log("Layout Config:", template.layout_config); console.log("Default Queries:", template.default_queries); console.log("========================"); // 템플릿 데이터 파싱 및 적용 let layoutConfig: { components?: ComponentConfig[] } | null = null; let defaultQueries: unknown[] = []; // layout_config 파싱 (안전하게) try { if (template.layout_config) { layoutConfig = typeof template.layout_config === "string" ? JSON.parse(template.layout_config) : template.layout_config; } } catch (e) { console.error("layout_config 파싱 오류:", e); layoutConfig = { components: [] }; } // default_queries 파싱 (안전하게) try { if (template.default_queries) { defaultQueries = typeof template.default_queries === "string" ? JSON.parse(template.default_queries) : template.default_queries; } } catch (e) { console.error("default_queries 파싱 오류:", e); defaultQueries = []; } // layoutConfig가 없으면 빈 구조로 초기화 if (!layoutConfig || typeof layoutConfig !== "object") { layoutConfig = { components: [] }; } // 컴포넌트 적용 (ID 재생성) const templateComponents = Array.isArray(layoutConfig.components) ? layoutConfig.components : []; const newComponents = templateComponents.map((comp: ComponentConfig) => ({ ...comp, id: `comp-${Date.now()}-${Math.random()}`, })); // 쿼리 적용 (ID 재생성) const templateQueries = Array.isArray(defaultQueries) ? defaultQueries : []; const newQueries = templateQueries .filter((q): q is Record => typeof q === "object" && q !== null) .map((q) => ({ id: `query-${Date.now()}-${Math.random()}`, name: (q.name as string) || "", type: (q.type as "MASTER" | "DETAIL") || "MASTER", sqlQuery: (q.sqlQuery as string) || "", parameters: Array.isArray(q.parameters) ? (q.parameters as string[]) : [], externalConnectionId: (q.externalConnectionId as number | null) || null, })); setComponents(newComponents); setQueries(newQueries); const message = newComponents.length === 0 ? "템플릿이 적용되었습니다. (빈 템플릿)" : `템플릿이 적용되었습니다. (컴포넌트 ${newComponents.length}개, 쿼리 ${newQueries.length}개)`; toast({ title: "성공", description: message, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "템플릿 적용에 실패했습니다."; toast({ title: "오류", description: errorMessage, variant: "destructive", }); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [toast], ); const value: ReportDesignerContextType = { reportId, reportDetail, layout, // 페이지 관리 layoutConfig, currentPageId, currentPage, addPage, deletePage, duplicatePage, reorderPages, selectPage, updatePageSettings, // 컴포넌트 (현재 페이지) 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, // 그룹화 groupComponents, ungroupComponents, }; return {children}; } export function useReportDesigner() { const context = useContext(ReportDesignerContext); if (context === undefined) { throw new Error("useReportDesigner must be used within a ReportDesignerProvider"); } return context; }