/** * @module useLayoutIO * @description 리포트 레이아웃의 서버 저장/로드 및 템플릿 적용을 담당한다. * * - loadLayout: 서버에서 리포트 상세 정보와 레이아웃을 불러온다. * - 기존 단일 페이지 구조를 다중 페이지 구조로 자동 마이그레이션한다. * - 레이아웃이 없으면 A4 portrait 기본 페이지를 생성한다. * * - saveLayout: 현재 레이아웃과 쿼리, 메뉴 연결 정보를 서버에 저장한다. * - reportId가 "new"이면 먼저 리포트를 생성하고 URL을 교체한다. * * - saveLayoutWithMenus: 메뉴 연결 정보를 갱신한 뒤 레이아웃을 저장한다. * * - applyTemplate: DB에서 템플릿을 조회하여 현재 페이지에 적용한다. * 컴포넌트와 쿼리 ID를 재생성하여 충돌을 방지한다. */ import { useCallback } from "react"; import { v4 as uuidv4 } from "uuid"; import { reportApi } from "@/lib/api/reportApi"; import type { ReportDetail, ReportLayout, ReportLayoutConfig, ReportPage, ComponentConfig } from "@/types/report"; import type { ReportQuery } from "./types"; import type { ToastFunction, SetComponentsFn } from "./internalTypes"; /** A4 기본 페이지 설정 */ const DEFAULT_PAGE: Omit = { page_name: "페이지 1", page_order: 0, width: 210, height: 297, orientation: "portrait", margins: { top: 10, bottom: 10, left: 10, right: 10 }, background_color: "#ffffff", components: [], }; /** A4 기본 페이지 객체를 새 ID와 함께 생성한다. */ function createDefaultPage(overrides: Partial = {}): ReportPage { return { ...DEFAULT_PAGE, page_id: uuidv4(), ...overrides }; } export interface LayoutIOActions { isLoading: boolean; isSaving: boolean; loadLayout: () => Promise; saveLayout: () => Promise; saveLayoutWithMenus: (menuObjids: number[]) => Promise; applyTemplate: (templateId: string) => Promise; } interface LayoutIODeps { reportId: string; layoutConfig: ReportLayoutConfig; queries: ReportQuery[]; menuObjids: number[]; setMenuObjids: React.Dispatch>; setIsLoading: React.Dispatch>; setIsSaving: React.Dispatch>; setReportDetail: React.Dispatch>; setLayout: React.Dispatch>; setLayoutConfig: React.Dispatch>; setCurrentPageId: React.Dispatch>; setQueries: (queries: ReportQuery[]) => void; /** 현재 페이지의 컴포넌트를 업데이트하는 헬퍼 */ setComponents: SetComponentsFn; /** applyTemplate에서 현재 페이지 ID를 안정적으로 읽기 위한 ref */ currentPageIdRef: React.MutableRefObject; toast: ToastFunction; } /** 공통 저장 페이로드를 빌드하는 헬퍼 */ function buildSavePayload( layoutConfig: ReportLayoutConfig, queries: ReportQuery[], menuObjids: number[], ) { return { layoutConfig, queries: queries.map((q) => ({ ...q, externalConnectionId: q.externalConnectionId || undefined, })), menuObjids, }; } /** 백엔드 쿼리 응답을 ReportQuery 형태로 변환한다 (snake_case → camelCase). */ function mapBackendQuery(q: Record): ReportQuery { return { id: q.query_id as string, name: q.query_name as string, type: q.query_type as "MASTER" | "DETAIL", sqlQuery: q.sql_query as string, parameters: Array.isArray(q.parameters) ? (q.parameters as string[]) : [], externalConnectionId: (q.external_connection_id as number | null) || undefined, }; } export function useLayoutIO({ reportId, layoutConfig, queries, menuObjids, setMenuObjids, setIsLoading, setIsSaving, setReportDetail, setLayout, setLayoutConfig, setCurrentPageId, setQueries, setComponents, currentPageIdRef, toast, }: LayoutIODeps): Omit { /** * 서버에서 리포트 상세 정보와 레이아웃을 불러온다. * reportId가 "new"이면 기본 페이지만 생성한다. */ const loadLayout = useCallback(async () => { setIsLoading(true); try { if (reportId === "new") { setLayoutConfig({ pages: [createDefaultPage()] }); setCurrentPageId((prev) => prev ?? uuidv4()); return; } // 리포트 상세 조회 const detailResponse = await reportApi.getReportById(reportId); if (detailResponse.success && detailResponse.data) { setReportDetail(detailResponse.data); if (detailResponse.data.queries?.length > 0) { setQueries((detailResponse.data.queries as unknown as Record[]).map(mapBackendQuery)); } setMenuObjids(detailResponse.data.menuObjids ?? []); } // 레이아웃 조회 try { const layoutResponse = await reportApi.getLayout(reportId); if (layoutResponse.success && layoutResponse.data) { const layoutData = layoutResponse.data; setLayout(layoutData); // 다중 페이지 구조 감지 const storedConfig = layoutData.components; const topLevelPages = layoutData.pages; const nestedPages = storedConfig && typeof storedConfig === "object" && !Array.isArray(storedConfig) ? (storedConfig as Record).pages : null; const pages = Array.isArray(topLevelPages) && topLevelPages.length > 0 ? topLevelPages : Array.isArray(nestedPages) && (nestedPages as unknown[]).length > 0 ? (nestedPages as ReportPage[]) : null; if (pages) { // 다중 페이지 구조 로드 const watermark = (layoutData as unknown as Record).watermark || (storedConfig as unknown as Record)?.watermark; setLayoutConfig({ pages, watermark: watermark as ReportLayoutConfig["watermark"] }); setCurrentPageId((pages[0] as ReportPage).page_id); } else { // 기존 단일 페이지 구조 → 다중 페이지로 자동 마이그레이션 const oldComponents = Array.isArray(layoutData.components) ? (layoutData.components as ComponentConfig[]) : []; if (oldComponents.length > 0) { const migratedPage = createDefaultPage({ 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, }, components: oldComponents, }); setLayoutConfig({ pages: [migratedPage] }); setCurrentPageId(migratedPage.page_id); } else { const defaultPage = createDefaultPage(); setLayoutConfig({ pages: [defaultPage] }); setCurrentPageId(defaultPage.page_id); } } } } catch { // 레이아웃이 없으면 기본 페이지 생성 const defaultPage = createDefaultPage(); setLayoutConfig({ pages: [defaultPage] }); setCurrentPageId(defaultPage.page_id); } } catch (error) { toast({ title: "오류", description: error instanceof Error ? error.message : "리포트를 불러오는데 실패했습니다.", variant: "destructive", }); } finally { setIsLoading(false); } }, [reportId, setIsLoading, setLayoutConfig, setCurrentPageId, setReportDetail, setQueries, setMenuObjids, setLayout, toast]); /** * 현재 레이아웃, 쿼리, 메뉴 연결 정보를 서버에 저장한다. * reportId가 "new"이면 먼저 리포트를 생성하고 URL을 업데이트한다. */ 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; window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`); } await reportApi.saveLayout(actualReportId, buildSavePayload(layoutConfig, queries, menuObjids)); toast({ title: "성공", description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다." }); if (reportId === "new") await loadLayout(); } catch (error) { toast({ title: "오류", description: error instanceof Error ? error.message : "저장에 실패했습니다.", variant: "destructive", }); } finally { setIsSaving(false); } }, [reportId, layoutConfig, queries, menuObjids, setIsSaving, loadLayout, toast]); /** * 메뉴 연결 정보를 갱신한 뒤 레이아웃을 저장한다. * 상태 업데이트와 API 호출을 함께 수행한다. */ 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; window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`); } await reportApi.saveLayout(actualReportId, buildSavePayload(layoutConfig, queries, selectedMenuObjids)); toast({ title: "성공", description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다." }); if (reportId === "new") await loadLayout(); } catch (error) { toast({ title: "오류", description: error instanceof Error ? error.message : "저장에 실패했습니다.", variant: "destructive", }); } finally { setIsSaving(false); } }, [reportId, layoutConfig, queries, setMenuObjids, setIsSaving, loadLayout, toast], ); /** * DB에서 템플릿을 조회하여 현재 페이지에 적용한다. * 컴포넌트와 쿼리의 ID를 재생성하고, queryId 매핑을 통해 컴포넌트-쿼리 연결을 유지한다. * 템플릿에 pageSettings가 포함된 경우 현재 페이지 설정도 업데이트한다. */ const applyTemplate = useCallback( async (templateId: string) => { try { if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) return; 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("템플릿을 찾을 수 없습니다."); // layout_config 파싱 let parsedLayout: { components?: ComponentConfig[]; pageSettings?: Record } = {}; try { parsedLayout = template.layout_config ? typeof template.layout_config === "string" ? JSON.parse(template.layout_config) : template.layout_config : {}; } catch { parsedLayout = { components: [] }; } // default_queries 파싱 let defaultQueries: Record[] = []; try { if (template.default_queries) { const raw = typeof template.default_queries === "string" ? JSON.parse(template.default_queries) : template.default_queries; defaultQueries = Array.isArray(raw) ? raw : []; } } catch { defaultQueries = []; } // 쿼리 ID 재생성 및 원본→신규 매핑 생성 const queryIdMap = new Map(); const newQueries: ReportQuery[] = defaultQueries .filter((q): q is Record => typeof q === "object" && q !== null) .map((q) => { const oldId = (q.id as string) || (q.name as string) || ""; const newId = `query-${Date.now()}-${Math.random()}`; if (oldId) queryIdMap.set(oldId, newId); return { id: newId, name: (q.name as string) ?? "", type: ((q.type as string) || "MASTER") as "MASTER" | "DETAIL", sqlQuery: (q.sqlQuery as string) ?? "", parameters: Array.isArray(q.parameters) ? (q.parameters as string[]) : [], externalConnectionId: (q.externalConnectionId as number | null) ?? null, }; }); // 컴포넌트 ID 재생성 + queryId 매핑 const newComponents: ComponentConfig[] = (parsedLayout.components ?? []).map((comp) => ({ ...comp, id: `comp-${Date.now()}-${Math.random()}`, queryId: comp.queryId ? (queryIdMap.get(comp.queryId) ?? comp.queryId) : comp.queryId, })); // 페이지 설정 적용 (템플릿에 pageSettings가 있는 경우) const pageSettings = parsedLayout.pageSettings; if (pageSettings) { setLayoutConfig((prev) => { const pageId = currentPageIdRef.current; if (!pageId) return prev; return { ...prev, pages: prev.pages.map((p) => p.page_id === pageId ? { ...p, width: (pageSettings.width as number) ?? p.width, height: (pageSettings.height as number) ?? p.height, orientation: (pageSettings.orientation as "portrait" | "landscape") ?? p.orientation, margins: (pageSettings.margins as ReportPage["margins"]) ?? p.margins, } : p, ), }; }); } setComponents(newComponents); setQueries(newQueries); const description = newComponents.length === 0 ? "템플릿이 적용되었습니다. (빈 템플릿)" : `템플릿이 적용되었습니다. (컴포넌트 ${newComponents.length}개, 쿼리 ${newQueries.length}개)`; toast({ title: "성공", description }); } catch (error) { toast({ title: "오류", description: error instanceof Error ? error.message : "템플릿 적용에 실패했습니다.", variant: "destructive", }); } }, // currentPageIdRef는 ref이므로 deps에 포함하지 않음 // eslint-disable-next-line react-hooks/exhaustive-deps [setLayoutConfig, setComponents, setQueries, toast], ); return { loadLayout, saveLayout, saveLayoutWithMenus, applyTemplate }; }