ERP-node/frontend/contexts/report-designer/useLayoutIO.ts

403 lines
16 KiB
TypeScript

/**
* @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<ReportPage, "page_id"> = {
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> = {}): ReportPage {
return { ...DEFAULT_PAGE, page_id: uuidv4(), ...overrides };
}
export interface LayoutIOActions {
isLoading: boolean;
isSaving: boolean;
loadLayout: () => Promise<void>;
saveLayout: () => Promise<void>;
saveLayoutWithMenus: (menuObjids: number[]) => Promise<void>;
applyTemplate: (templateId: string) => Promise<void>;
}
interface LayoutIODeps {
reportId: string;
layoutConfig: ReportLayoutConfig;
queries: ReportQuery[];
menuObjids: number[];
setMenuObjids: React.Dispatch<React.SetStateAction<number[]>>;
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
setIsSaving: React.Dispatch<React.SetStateAction<boolean>>;
setReportDetail: React.Dispatch<React.SetStateAction<ReportDetail | null>>;
setLayout: React.Dispatch<React.SetStateAction<ReportLayout | null>>;
setLayoutConfig: React.Dispatch<React.SetStateAction<ReportLayoutConfig>>;
setCurrentPageId: React.Dispatch<React.SetStateAction<string | null>>;
setQueries: (queries: ReportQuery[]) => void;
/** 현재 페이지의 컴포넌트를 업데이트하는 헬퍼 */
setComponents: SetComponentsFn;
/** applyTemplate에서 현재 페이지 ID를 안정적으로 읽기 위한 ref */
currentPageIdRef: React.MutableRefObject<string | null>;
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<string, unknown>): 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<LayoutIOActions, "isLoading" | "isSaving"> {
/**
* 서버에서 리포트 상세 정보와 레이아웃을 불러온다.
* 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<string, unknown>[]).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<string, unknown>).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<string, unknown>).watermark ||
(storedConfig as unknown as Record<string, unknown>)?.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<string, unknown> } = {};
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<string, unknown>[] = [];
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<string, string>();
const newQueries: ReportQuery[] = defaultQueries
.filter((q): q is Record<string, unknown> => 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 };
}