403 lines
16 KiB
TypeScript
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 };
|
||
|
|
}
|