"use client"; import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from "react"; import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig, WatermarkConfig } 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; updateWatermark: (watermark: WatermarkConfig | undefined) => 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; selectMultipleComponents: (ids: string[]) => 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; duplicateComponents: () => void; // Ctrl+D 즉시 복제 copyStyles: () => void; // Ctrl+Shift+C 스타일만 복사 pasteStyles: () => void; // Ctrl+Shift+V 스타일만 붙여넣기 duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; // Alt+드래그 복제용 fitSelectedToContent: () => void; // Ctrl+Shift+F 텍스트 크기 자동 맞춤 // 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; // 메뉴 연결 menuObjids: number[]; setMenuObjids: (menuObjids: number[]) => void; saveLayoutWithMenus: (menuObjids: number[]) => Promise; } const ReportDesignerContext = createContext(undefined); // 페이지 사이즈 변경 시 컴포넌트 위치 및 크기 재계산 유틸리티 함수 const recalculateComponentPositions = ( components: ComponentConfig[], oldWidth: number, oldHeight: number, newWidth: number, newHeight: number ): ComponentConfig[] => { // 사이즈가 동일하면 그대로 반환 if (oldWidth === newWidth && oldHeight === newHeight) { return components; } const widthRatio = newWidth / oldWidth; const heightRatio = newHeight / oldHeight; return components.map((comp) => { // 위치와 크기 모두 비율대로 재계산 // 소수점 2자리까지만 유지 const newX = Math.round(comp.x * widthRatio * 100) / 100; const newY = Math.round(comp.y * heightRatio * 100) / 100; const newCompWidth = Math.round(comp.width * widthRatio * 100) / 100; const newCompHeight = Math.round(comp.height * heightRatio * 100) / 100; return { ...comp, x: newX, y: newY, width: newCompWidth, height: newCompHeight, }; }); }; 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 [menuObjids, setMenuObjids] = useState([]); // 연결된 메뉴 ID 목록 const { toast } = useToast(); // 현재 페이지 계산 const currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId); // 현재 페이지의 컴포넌트 (읽기 전용) - 배열인지 확인 const components = Array.isArray(currentPage?.components) ? currentPage.components : []; // currentPageId를 ref로 저장하여 클로저 문제 해결 const currentPageIdRef = useRef(currentPageId); useEffect(() => { currentPageIdRef.current = currentPageId; }, [currentPageId]); // 현재 페이지의 컴포넌트를 업데이트하는 헬퍼 함수 const setComponents = useCallback( (updater: ComponentConfig[] | ((prev: ComponentConfig[]) => ComponentConfig[])) => { setLayoutConfig((prev) => { const pageId = currentPageIdRef.current; if (!pageId) { console.error("❌ currentPageId가 없음"); return prev; } // 현재 선택된 페이지 찾기 const currentPageIndex = prev.pages.findIndex((p) => p.page_id === pageId); if (currentPageIndex === -1) { console.error("❌ 페이지를 찾을 수 없음:", pageId); return prev; } const currentPageData = prev.pages[currentPageIndex]; const newComponents = typeof updater === "function" ? updater(currentPageData.components) : updater; const newPages = [...prev.pages]; newPages[currentPageIndex] = { ...currentPageData, components: newComponents, }; console.log("✅ 컴포넌트 업데이트:", { pageId, before: currentPageData.components.length, after: newComponents.length, }); return { pages: newPages }; }); }, [], // ref를 사용하므로 의존성 배열 비움 ); // 레이아웃 도구 설정 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([]); // 스타일 클립보드 (스타일만 복사/붙여넣기) const [styleClipboard, setStyleClipboard] = useState | null>(null); // 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) && !comp.locked ); if (componentsToCopy.length === 0) { toast({ title: "복사 불가", description: "잠긴 컴포넌트는 복사할 수 없습니다.", variant: "destructive", }); return; } setClipboard(componentsToCopy); toast({ title: "복사 완료", description: `${componentsToCopy.length}개의 컴포넌트가 복사되었습니다.`, }); } else if (selectedComponentId) { const componentToCopy = components.find((comp) => comp.id === selectedComponentId); if (componentToCopy) { // 잠긴 컴포넌트는 복사 불가 if (componentToCopy.locked) { toast({ title: "복사 불가", description: "잠긴 컴포넌트는 복사할 수 없습니다.", variant: "destructive", }); return; } 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]); // 복제 (Ctrl+D) - 선택된 컴포넌트를 즉시 복제 const duplicateComponents = useCallback(() => { // 복제할 컴포넌트 결정 let componentsToDuplicate: ComponentConfig[] = []; if (selectedComponentIds.length > 0) { componentsToDuplicate = components.filter( (comp) => selectedComponentIds.includes(comp.id) && !comp.locked ); } else if (selectedComponentId) { const comp = components.find((c) => c.id === selectedComponentId); if (comp && !comp.locked) { componentsToDuplicate = [comp]; } } if (componentsToDuplicate.length === 0) { toast({ title: "복제 불가", description: "복제할 컴포넌트가 없거나 잠긴 컴포넌트입니다.", variant: "destructive", }); return; } const newComponents = componentsToDuplicate.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, locked: false, // 복제된 컴포넌트는 잠금 해제 })); 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}개의 컴포넌트가 복제되었습니다.`, }); }, [selectedComponentId, selectedComponentIds, components, toast]); // 스타일 복사 (Ctrl+Shift+C) const copyStyles = useCallback(() => { // 단일 컴포넌트만 스타일 복사 가능 const targetId = selectedComponentId || selectedComponentIds[0]; if (!targetId) { toast({ title: "스타일 복사 불가", description: "컴포넌트를 선택해주세요.", variant: "destructive", }); return; } const component = components.find((c) => c.id === targetId); if (!component) return; // 스타일 관련 속성만 추출 const styleProperties: Partial = { fontSize: component.fontSize, fontColor: component.fontColor, fontWeight: component.fontWeight, fontFamily: component.fontFamily, textAlign: component.textAlign, backgroundColor: component.backgroundColor, borderWidth: component.borderWidth, borderColor: component.borderColor, borderStyle: component.borderStyle, borderRadius: component.borderRadius, boxShadow: component.boxShadow, opacity: component.opacity, padding: component.padding, letterSpacing: component.letterSpacing, lineHeight: component.lineHeight, }; // undefined 값 제거 Object.keys(styleProperties).forEach((key) => { if (styleProperties[key as keyof typeof styleProperties] === undefined) { delete styleProperties[key as keyof typeof styleProperties]; } }); setStyleClipboard(styleProperties); toast({ title: "스타일 복사 완료", description: "스타일이 복사되었습니다. Ctrl+Shift+V로 적용할 수 있습니다.", }); }, [selectedComponentId, selectedComponentIds, components, toast]); // 스타일 붙여넣기 (Ctrl+Shift+V) const pasteStyles = useCallback(() => { if (!styleClipboard) { toast({ title: "스타일 붙여넣기 불가", description: "먼저 Ctrl+Shift+C로 스타일을 복사해주세요.", variant: "destructive", }); return; } // 선택된 컴포넌트들에 스타일 적용 const targetIds = selectedComponentIds.length > 0 ? selectedComponentIds : selectedComponentId ? [selectedComponentId] : []; if (targetIds.length === 0) { toast({ title: "스타일 붙여넣기 불가", description: "스타일을 적용할 컴포넌트를 선택해주세요.", variant: "destructive", }); return; } // 잠긴 컴포넌트 필터링 const applicableIds = targetIds.filter((id) => { const comp = components.find((c) => c.id === id); return comp && !comp.locked; }); if (applicableIds.length === 0) { toast({ title: "스타일 붙여넣기 불가", description: "잠긴 컴포넌트에는 스타일을 적용할 수 없습니다.", variant: "destructive", }); return; } setComponents((prev) => prev.map((comp) => { if (applicableIds.includes(comp.id)) { return { ...comp, ...styleClipboard }; } return comp; }) ); toast({ title: "스타일 적용 완료", description: `${applicableIds.length}개의 컴포넌트에 스타일이 적용되었습니다.`, }); }, [styleClipboard, selectedComponentId, selectedComponentIds, components, toast]); // Alt+드래그 복제용: 지정된 위치에 컴포넌트 복제 const duplicateAtPosition = useCallback( (componentIds: string[], offsetX: number = 0, offsetY: number = 0): string[] => { const componentsToDuplicate = components.filter( (comp) => componentIds.includes(comp.id) && !comp.locked ); if (componentsToDuplicate.length === 0) return []; const newComponents = componentsToDuplicate.map((comp) => ({ ...comp, id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, x: comp.x + offsetX, y: comp.y + offsetY, zIndex: components.length, locked: false, })); setComponents((prev) => [...prev, ...newComponents]); return newComponents.map((c) => c.id); }, [components] ); // 히스토리에 현재 상태 저장 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 = 4px) const MM_TO_PX = 4; const canvasWidthPx = canvasWidth * MM_TO_PX; const canvasHeightPx = canvasHeight * MM_TO_PX; 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) => { const targetPage = prev.pages.find((p) => p.page_id === pageId); if (!targetPage) { return prev; } // 페이지 사이즈 변경 감지 const isWidthChanging = settings.width !== undefined && settings.width !== targetPage.width; const isHeightChanging = settings.height !== undefined && settings.height !== targetPage.height; // 사이즈 변경 시 컴포넌트 위치 재계산 let updatedComponents = targetPage.components; if (isWidthChanging || isHeightChanging) { const oldWidth = targetPage.width; const oldHeight = targetPage.height; const newWidth = settings.width ?? targetPage.width; const newHeight = settings.height ?? targetPage.height; updatedComponents = recalculateComponentPositions( targetPage.components, oldWidth, oldHeight, newWidth, newHeight ); } return { ...prev, pages: prev.pages.map((page) => page.page_id === pageId ? { ...page, ...settings, components: updatedComponents } : page ), }; }); }, []); // 전체 페이지 공유 워터마크 업데이트 const updateWatermark = useCallback((watermark: WatermarkConfig | undefined) => { setLayoutConfig((prev) => ({ ...prev, watermark, })); }, []); // 리포트 및 레이아웃 로드 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); } // 연결된 메뉴 로드 if (detailResponse.data.menuObjids && detailResponse.data.menuObjids.length > 0) { setMenuObjids(detailResponse.data.menuObjids); } else { setMenuObjids([]); } } // 레이아웃 조회 try { const layoutResponse = await reportApi.getLayout(reportId); if (layoutResponse.success && layoutResponse.data) { const layoutData = layoutResponse.data; setLayout(layoutData); // layoutData가 이미 pages를 가지고 있는지 확인 if (layoutData.pages && Array.isArray(layoutData.pages) && layoutData.pages.length > 0) { // 이미 새 구조 (pages 배열) setLayoutConfig({ pages: layoutData.pages }); setCurrentPageId(layoutData.pages[0].page_id); } else { // 자동 마이그레이션: 기존 단일 페이지 구조 → 다중 페이지 구조 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); } 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); } } 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]); }, [setComponents], ); // 컴포넌트 업데이트 (현재 페이지에서) 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], ); // 텍스트 컴포넌트 크기 자동 맞춤 (Ctrl+Shift+F) const fitSelectedToContent = useCallback(() => { const MM_TO_PX = 4; // 고정 스케일 팩터 // 선택된 컴포넌트 ID 결정 const targetIds = selectedComponentIds.length > 0 ? selectedComponentIds : selectedComponentId ? [selectedComponentId] : []; if (targetIds.length === 0) return; // 텍스트/레이블 컴포넌트만 필터링 const textComponents = components.filter( (c) => targetIds.includes(c.id) && (c.type === "text" || c.type === "label") && !c.locked ); if (textComponents.length === 0) { toast({ title: "크기 조정 불가", description: "선택된 텍스트 컴포넌트가 없습니다.", variant: "destructive", }); return; } // 현재 페이지 설정 가져오기 const page = currentPage; if (!page) return; const canvasWidthPx = page.width * MM_TO_PX; const canvasHeightPx = page.height * MM_TO_PX; const marginRightPx = (page.margins?.right || 10) * MM_TO_PX; const marginBottomPx = (page.margins?.bottom || 10) * MM_TO_PX; // 각 텍스트 컴포넌트 크기 조정 textComponents.forEach((comp) => { const displayValue = comp.defaultValue || (comp.type === "text" ? "텍스트 입력" : "레이블 텍스트"); const fontSize = comp.fontSize || 14; // 최대 크기 (여백 고려) const maxWidth = canvasWidthPx - marginRightPx - comp.x; const maxHeight = canvasHeightPx - marginBottomPx - comp.y; // 줄바꿈으로 분리하여 각 줄의 너비 측정 const lines = displayValue.split("\n"); let maxLineWidth = 0; lines.forEach((line: string) => { const measureEl = document.createElement("span"); measureEl.style.position = "absolute"; measureEl.style.visibility = "hidden"; measureEl.style.whiteSpace = "nowrap"; measureEl.style.fontSize = `${fontSize}px`; measureEl.style.fontWeight = comp.fontWeight || "normal"; measureEl.style.fontFamily = "system-ui, -apple-system, sans-serif"; measureEl.textContent = line || " "; document.body.appendChild(measureEl); const lineWidth = measureEl.getBoundingClientRect().width; maxLineWidth = Math.max(maxLineWidth, lineWidth); document.body.removeChild(measureEl); }); // 패딩 및 높이 계산 const horizontalPadding = 24; const verticalPadding = 20; const lineHeight = fontSize * 1.5; const totalHeight = lines.length * lineHeight; const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth); const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight); const newWidth = Math.max(50, finalWidth); const newHeight = Math.max(30, finalHeight); // 크기 업데이트 - setLayoutConfig 직접 사용 setLayoutConfig((prev) => ({ pages: prev.pages.map((p) => p.page_id === currentPageId ? { ...p, components: p.components.map((c) => c.id === comp.id ? { ...c, width: snapToGrid ? Math.round(newWidth / gridSize) * gridSize : newWidth, height: snapToGrid ? Math.round(newHeight / gridSize) * gridSize : newHeight, } : c ), } : p ), })); }); toast({ title: "크기 조정 완료", description: `${textComponents.length}개의 컴포넌트 크기가 조정되었습니다.`, }); }, [selectedComponentId, selectedComponentIds, components, currentPage, currentPageId, snapToGrid, gridSize, toast]); // 컴포넌트 삭제 (현재 페이지에서) 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 selectMultipleComponents = useCallback((ids: string[]) => { if (ids.length === 0) { setSelectedComponentId(null); setSelectedComponentIds([]); return; } setSelectedComponentId(ids[0]); setSelectedComponentIds(ids); }, []); // 레이아웃 업데이트 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, })), menuObjids, // 연결된 메뉴 목록 }); 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, menuObjids, toast, loadLayout]); // 메뉴를 선택하고 저장하는 함수 const saveLayoutWithMenus = useCallback( async (selectedMenuObjids: number[]) => { // 먼저 메뉴 상태 업데이트 setMenuObjids(selectedMenuObjids); 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, })), menuObjids: selectedMenuObjids, }); 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, updateWatermark, // 컴포넌트 (현재 페이지) components, queries, setQueries, queryResults, setQueryResult, getQueryResult, selectedComponentId, selectedComponentIds, isLoading, isSaving, addComponent, updateComponent, removeComponent, selectComponent, selectMultipleComponents, updateLayout, saveLayout, loadLayout, applyTemplate, canvasWidth, canvasHeight, pageOrientation, margins, // 레이아웃 도구 gridSize, setGridSize, showGrid, setShowGrid, snapToGrid, setSnapToGrid, snapValueToGrid, // 정렬 가이드라인 alignmentGuides, calculateAlignmentGuides, clearAlignmentGuides, // 복사/붙여넣기 copyComponents, pasteComponents, duplicateComponents, copyStyles, pasteStyles, duplicateAtPosition, fitSelectedToContent, // 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, // 메뉴 연결 menuObjids, setMenuObjids, saveLayoutWithMenus, }; return {children}; } export function useReportDesigner() { const context = useContext(ReportDesignerContext); if (context === undefined) { throw new Error("useReportDesigner must be used within a ReportDesignerProvider"); } return context; }