/** * @module usePageManager * @description 리포트 레이아웃의 페이지 단위 CRUD 및 설정 변경을 담당한다. * - 페이지 추가/삭제/복제/순서 변경 * - 현재 활성 페이지 전환 (selectPage) * - 개별 페이지 설정(크기, 방향, 여백 등) 업데이트 * - 전체 페이지 공유 워터마크 설정 * * 페이지 크기 변경 시 기존 컴포넌트 위치/크기를 비율에 따라 자동 재계산한다. */ import { useCallback } from "react"; import { v4 as uuidv4 } from "uuid"; import type { ReportLayoutConfig, ReportPage, WatermarkConfig } from "@/types/report"; import type { ToastFunction } from "./internalTypes"; import { generateComponentId } from "@/lib/report/constants"; export interface PageManagerActions { 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; } interface PageManagerDeps { layoutConfig: ReportLayoutConfig; setLayoutConfig: React.Dispatch>; currentPageId: string | null; setCurrentPageId: React.Dispatch>; /** 페이지 전환 시 선택 상태를 초기화하는 콜백 */ clearSelection: () => void; toast: ToastFunction; } /** * 페이지 크기가 변경될 때 기존 컴포넌트의 위치/크기를 비율에 맞춰 재계산한다. * 소수점 2자리까지만 유지하여 부동소수점 오차를 최소화한다. */ function recalculateComponentPositions( components: ReportPage["components"], oldWidth: number, oldHeight: number, newWidth: number, newHeight: number, ): ReportPage["components"] { if (oldWidth === newWidth && oldHeight === newHeight) return components; const widthRatio = newWidth / oldWidth; const heightRatio = newHeight / oldHeight; return components.map((comp) => ({ ...comp, x: Math.round(comp.x * widthRatio * 100) / 100, y: Math.round(comp.y * heightRatio * 100) / 100, width: Math.round(comp.width * widthRatio * 100) / 100, height: Math.round(comp.height * heightRatio * 100) / 100, })); } export function usePageManager({ layoutConfig, setLayoutConfig, currentPageId, setCurrentPageId, clearSelection, toast, }: PageManagerDeps): PageManagerActions { /** 새 페이지를 마지막에 추가하고 해당 페이지로 자동 전환한다. */ 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, setLayoutConfig, setCurrentPageId, 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 remaining = layoutConfig.pages.filter((p) => p.page_id !== pageId); setCurrentPageId(remaining[0]?.page_id ?? null); } toast({ title: "페이지 삭제", description: "페이지가 삭제되었습니다." }); }, [layoutConfig.pages, currentPageId, setLayoutConfig, setCurrentPageId, toast], ); /** 지정한 페이지를 복제하고 복제된 페이지로 이동한다. 컴포넌트에도 새 ID가 부여된다. */ 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, components: sourcePage.components.map((comp) => ({ ...comp, id: generateComponentId(), })), }; setLayoutConfig((prev) => ({ pages: [...prev.pages, newPage] })); setCurrentPageId(newPageId); toast({ title: "페이지 복제", description: `${newPage.page_name}이(가) 생성되었습니다.` }); }, [layoutConfig.pages, setLayoutConfig, setCurrentPageId, toast], ); /** 드래그&드롭으로 페이지 순서를 변경한다. page_order는 인덱스와 동기화된다. */ 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); newPages.forEach((page, idx) => { page.page_order = idx; }); setLayoutConfig({ pages: newPages }); }, [layoutConfig.pages, setLayoutConfig], ); /** 지정한 페이지로 전환하고, 기존에 선택된 컴포넌트를 초기화한다. */ const selectPage = useCallback( (pageId: string) => { setCurrentPageId(pageId); clearSelection(); }, [setCurrentPageId, clearSelection], ); /** * 페이지의 크기, 방향, 여백, 배경색 등을 업데이트한다. * 페이지 크기가 변경될 경우 기존 컴포넌트 위치/크기를 비율에 따라 재계산한다. */ 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; const updatedComponents = isWidthChanging || isHeightChanging ? recalculateComponentPositions( targetPage.components, targetPage.width, targetPage.height, settings.width ?? targetPage.width, settings.height ?? targetPage.height, ) : targetPage.components; return { ...prev, pages: prev.pages.map((page) => page.page_id === pageId ? { ...page, ...settings, components: updatedComponents } : page, ), }; }); }, [setLayoutConfig], ); /** * 전체 페이지에 공유되는 워터마크를 설정한다. * undefined를 전달하면 워터마크가 제거된다. */ const updateWatermark = useCallback( (watermark: WatermarkConfig | undefined) => { setLayoutConfig((prev) => ({ ...prev, watermark })); }, [setLayoutConfig], ); return { addPage, deletePage, duplicatePage, reorderPages, selectPage, updatePageSettings, updateWatermark, }; }