"use client"; /** * @module ReportDesignerContext * @description 리포트 디자이너의 전역 상태를 제공하는 Context. * * 이 파일은 얇은 조합 레이어(thin orchestrator)로, 실제 로직은 각 훅에 위임한다. * * 책임 분배: * - useSelectionState → 컴포넌트 선택 상태 (단일/다중) * - usePageManager → 페이지 CRUD 및 설정 변경 * - useQueryManager → 쿼리 정의 및 실행 결과 캐시 * - useUIState → 그리드/줌/패널/정렬 가이드 등 UI 전용 상태 * - useHistoryManager → Undo/Redo 이력 관리 * - useClipboardActions → 복사/붙여넣기/복제/스타일 전달 * - useAlignmentActions → 정렬·균등 배치·크기 동일화 * - useLayerActions → 레이어 순서·잠금·그룹화 * - useLayoutIO → 서버 저장/로드 및 템플릿 적용 */ import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, } from "react"; import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig, WatermarkConfig, } from "@/types/report"; import { useToast } from "@/hooks/use-toast"; // 각 역할별 훅 import { useSelectionState } from "./report-designer/useSelectionState"; import { usePageManager } from "./report-designer/usePageManager"; import { useQueryManager } from "./report-designer/useQueryManager"; import { useUIState } from "./report-designer/useUIState"; import { useHistoryManager } from "./report-designer/useHistoryManager"; import { useClipboardActions } from "./report-designer/useClipboardActions"; import { useAlignmentActions } from "./report-designer/useAlignmentActions"; import { useLayerActions } from "./report-designer/useLayerActions"; import { useLayoutIO } from "./report-designer/useLayoutIO"; // 공유 타입 export type { ReportQuery, QueryResult } from "./report-designer/types"; // ───────────────────────────────────────────── // Context 타입 정의 // ───────────────────────────────────────────── interface ReportDesignerContextType { reportId: string; reportDetail: ReportDetail | null; layout: ReportLayout | null; // 페이지 관리 layoutConfig: ReportLayoutConfig; 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[]; selectedComponentId: string | null; selectedComponentIds: string[]; isLoading: boolean; isSaving: boolean; 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) => Promise; // 캔버스 설정 (현재 페이지 기반) canvasWidth: number; canvasHeight: number; pageOrientation: string; margins: { top: number; bottom: number; left: number; right: number }; // UI 도구 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; copyStyles: () => void; pasteStyles: () => void; duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; fitSelectedToContent: () => 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; // 캔버스 줌 zoom: number; setZoom: (zoom: number) => void; fitTrigger: number; fitToScreen: () => void; // 그룹화 groupComponents: () => void; ungroupComponents: () => void; // 메뉴 연결 menuObjids: number[]; setMenuObjids: (menuObjids: number[]) => void; saveLayoutWithMenus: (menuObjids: number[]) => Promise; // 쿼리 관리 queries: import("./report-designer/types").ReportQuery[]; setQueries: (queries: import("./report-designer/types").ReportQuery[]) => void; queryResults: import("./report-designer/types").QueryResult[]; setQueryResult: (queryId: string, fields: string[], rows: Record[]) => void; getQueryResult: (queryId: string) => import("./report-designer/types").QueryResult | null; // 인캔버스 설정 모달 componentModalTargetId: string | null; openComponentModal: (componentId: string) => void; closeComponentModal: () => void; // 패널 접기/펼치기 isPageListCollapsed: boolean; setIsPageListCollapsed: (v: boolean) => void; isLeftPanelCollapsed: boolean; setIsLeftPanelCollapsed: (v: boolean) => void; isRightPanelCollapsed: boolean; setIsRightPanelCollapsed: (v: boolean) => void; } const ReportDesignerContext = createContext(undefined); // ───────────────────────────────────────────── // Provider // ───────────────────────────────────────────── export function ReportDesignerProvider({ reportId, children, }: { reportId: string; children: ReactNode; }) { const { toast } = useToast(); // ── 공유 레이아웃 상태 (여러 훅이 공유) ── const [layoutConfig, setLayoutConfig] = useState({ pages: [] }); const [currentPageId, setCurrentPageId] = useState(null); const [reportDetail, setReportDetail] = useState(null); const [layout, setLayout] = useState(null); const [menuObjids, setMenuObjids] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); // currentPageId를 ref로 유지하여 클로저 내에서도 최신 값을 읽을 수 있게 한다 const currentPageIdRef = useRef(currentPageId); useEffect(() => { currentPageIdRef.current = currentPageId; }, [currentPageId]); // ── 파생 값 ── const currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId); const components: ComponentConfig[] = Array.isArray(currentPage?.components) ? currentPage.components : []; // 현재 페이지의 컴포넌트를 업데이트하는 공유 헬퍼 (ref 기반으로 클로저 문제 회피) const setComponents = useCallback( (updater: ComponentConfig[] | ((prev: ComponentConfig[]) => ComponentConfig[])) => { setLayoutConfig((prev) => { const pageId = currentPageIdRef.current; if (!pageId) return prev; const pageIndex = prev.pages.findIndex((p) => p.page_id === pageId); if (pageIndex === -1) return prev; const page = prev.pages[pageIndex]; const newComponents = typeof updater === "function" ? updater(page.components) : updater; const newPages = [...prev.pages]; newPages[pageIndex] = { ...page, components: newComponents }; return { ...prev, pages: newPages }; }); }, [], // ref를 사용하므로 의존성 배열 비움 ); // 캔버스 치수 (현재 페이지 기반, 단위: mm) 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 selection = useSelectionState(); const pageManager = usePageManager({ layoutConfig, setLayoutConfig, currentPageId, setCurrentPageId, clearSelection: selection.clearSelection, toast, }); const queryManager = useQueryManager(); const uiState = useUIState(components, { canvasWidth, canvasHeight }); const historyManager = useHistoryManager(components, setComponents, isLoading, toast); const clipboard = useClipboardActions({ components, selectedComponentId: selection.selectedComponentId, selectedComponentIds: selection.selectedComponentIds, setComponents, currentPage, currentPageId, setLayoutConfig, snapToGrid: uiState.snapToGrid, gridSize: uiState.gridSize, toast, }); const alignment = useAlignmentActions({ components, selectedComponentIds: selection.selectedComponentIds, setComponents, toast, }); const layer = useLayerActions({ components, selectedComponentId: selection.selectedComponentId, selectedComponentIds: selection.selectedComponentIds, setComponents, toast, }); const layoutIO = useLayoutIO({ reportId, layoutConfig, queries: queryManager.queries, menuObjids, setMenuObjids, setIsLoading, setIsSaving, setReportDetail, setLayout, setLayoutConfig, setCurrentPageId, setQueries: queryManager.setQueries, setComponents, currentPageIdRef, toast, }); // ── 초기 로드 ── useEffect(() => { layoutIO.loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportId]); // ── 초기 히스토리 설정 (레이아웃 로드 완료 시) ── useEffect(() => { if (!isLoading && components.length > 0) { historyManager.initHistory(components); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoading]); // ── 컴포넌트 관리 (현재 페이지 직접 조작) ── /** 현재 페이지에 새 컴포넌트를 추가한다. */ 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((c) => (c.id === id ? { ...c, ...updates } : c)) } : 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((c) => c.id !== id) } : page, ), })); if (selection.selectedComponentId === id) selection.setSelectedComponentId(null); selection.setSelectedComponentIds((prev) => prev.filter((compId) => compId !== id)); }, [currentPageId, selection], ); /** * 레이아웃 메타데이터(canvas_width, page_orientation 등)를 업데이트한다. * 현재 페이지의 페이지 설정과 동기화된다. */ 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) { pageManager.updatePageSettings(currentPageId, pageUpdates); } }, [currentPageId, currentPage, pageManager, setLayout], ); // ───────────────────────────────────────────── // Context 값 조합 // ───────────────────────────────────────────── const value: ReportDesignerContextType = { reportId, reportDetail, layout, // 페이지 관리 layoutConfig, currentPageId, currentPage, ...pageManager, // 컴포넌트 상태 및 관리 components, isLoading, isSaving, addComponent, updateComponent, removeComponent, updateLayout, // 선택 상태 selectedComponentId: selection.selectedComponentId, selectedComponentIds: selection.selectedComponentIds, selectComponent: selection.selectComponent, selectMultipleComponents: selection.selectMultipleComponents, // 레이아웃 I/O saveLayout: layoutIO.saveLayout, loadLayout: layoutIO.loadLayout, applyTemplate: layoutIO.applyTemplate, saveLayoutWithMenus: layoutIO.saveLayoutWithMenus, // 캔버스 설정 canvasWidth, canvasHeight, pageOrientation, margins, // UI 상태 ...uiState, // 복사/붙여넣기 ...clipboard, // Undo/Redo undo: historyManager.undo, redo: historyManager.redo, canUndo: historyManager.canUndo, canRedo: historyManager.canRedo, // 정렬 ...alignment, // 레이어/잠금/그룹 ...layer, // 메뉴 연결 menuObjids, setMenuObjids, // 쿼리 관리 ...queryManager, }; return ( {children} ); } /** * 리포트 디자이너 Context를 소비하는 훅. * ReportDesignerProvider 내부에서만 사용 가능하다. */ export function useReportDesigner() { const context = useContext(ReportDesignerContext); if (context === undefined) { throw new Error("useReportDesigner must be used within a ReportDesignerProvider"); } return context; }