ERP-node/frontend/contexts/ReportDesignerContext.tsx

598 lines
17 KiB
TypeScript

"use client";
import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from "react";
import { ComponentConfig, ReportDetail, ReportLayout } from "@/types/report";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
export interface ReportQuery {
id: string;
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
}
// 템플릿 레이아웃 정의
interface TemplateLayout {
components: ComponentConfig[];
queries: ReportQuery[];
}
function getTemplateLayout(templateId: string): TemplateLayout | null {
switch (templateId) {
case "order":
return {
components: [
{
id: `comp-${Date.now()}-1`,
type: "label",
x: 50,
y: 30,
width: 200,
height: 40,
fontSize: 24,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#000000",
borderWidth: 0,
zIndex: 1,
defaultValue: "발주서",
},
{
id: `comp-${Date.now()}-2`,
type: "text",
x: 50,
y: 80,
width: 150,
height: 30,
fontSize: 14,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#cccccc",
borderWidth: 1,
zIndex: 1,
},
{
id: `comp-${Date.now()}-3`,
type: "text",
x: 220,
y: 80,
width: 150,
height: 30,
fontSize: 14,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#cccccc",
borderWidth: 1,
zIndex: 1,
},
{
id: `comp-${Date.now()}-4`,
type: "text",
x: 390,
y: 80,
width: 150,
height: 30,
fontSize: 14,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#cccccc",
borderWidth: 1,
zIndex: 1,
},
{
id: `comp-${Date.now()}-5`,
type: "table",
x: 50,
y: 130,
width: 500,
height: 200,
fontSize: 12,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#cccccc",
borderWidth: 1,
zIndex: 1,
},
],
queries: [
{
id: `query-${Date.now()}-1`,
name: "발주 헤더",
type: "MASTER",
sqlQuery: "SELECT order_no, order_date, supplier_name FROM orders WHERE order_no = $1",
parameters: ["$1"],
},
{
id: `query-${Date.now()}-2`,
name: "발주 품목",
type: "DETAIL",
sqlQuery: "SELECT item_name, quantity, unit_price FROM order_items WHERE order_no = $1",
parameters: ["$1"],
},
],
};
case "invoice":
return {
components: [
{
id: `comp-${Date.now()}-1`,
type: "label",
x: 50,
y: 30,
width: 200,
height: 40,
fontSize: 24,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#000000",
borderWidth: 0,
zIndex: 1,
defaultValue: "청구서",
},
{
id: `comp-${Date.now()}-2`,
type: "text",
x: 50,
y: 80,
width: 150,
height: 30,
fontSize: 14,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#cccccc",
borderWidth: 1,
zIndex: 1,
},
{
id: `comp-${Date.now()}-3`,
type: "text",
x: 220,
y: 80,
width: 150,
height: 30,
fontSize: 14,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#cccccc",
borderWidth: 1,
zIndex: 1,
},
{
id: `comp-${Date.now()}-4`,
type: "table",
x: 50,
y: 130,
width: 500,
height: 200,
fontSize: 12,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#cccccc",
borderWidth: 1,
zIndex: 1,
},
{
id: `comp-${Date.now()}-5`,
type: "label",
x: 400,
y: 350,
width: 150,
height: 30,
fontSize: 16,
fontColor: "#000000",
backgroundColor: "#ffffcc",
borderColor: "#000000",
borderWidth: 1,
zIndex: 1,
defaultValue: "합계: 0원",
},
],
queries: [
{
id: `query-${Date.now()}-1`,
name: "청구 헤더",
type: "MASTER",
sqlQuery: "SELECT invoice_no, invoice_date, customer_name FROM invoices WHERE invoice_no = $1",
parameters: ["$1"],
},
{
id: `query-${Date.now()}-2`,
name: "청구 항목",
type: "DETAIL",
sqlQuery: "SELECT description, quantity, unit_price, amount FROM invoice_items WHERE invoice_no = $1",
parameters: ["$1"],
},
],
};
case "basic":
return {
components: [
{
id: `comp-${Date.now()}-1`,
type: "label",
x: 50,
y: 30,
width: 300,
height: 40,
fontSize: 20,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#000000",
borderWidth: 0,
zIndex: 1,
defaultValue: "리포트 제목",
},
{
id: `comp-${Date.now()}-2`,
type: "text",
x: 50,
y: 80,
width: 500,
height: 100,
fontSize: 14,
fontColor: "#000000",
backgroundColor: "#ffffff",
borderColor: "#cccccc",
borderWidth: 1,
zIndex: 1,
defaultValue: "내용을 입력하세요",
},
],
queries: [
{
id: `query-${Date.now()}-1`,
name: "기본 쿼리",
type: "MASTER",
sqlQuery: "SELECT * FROM table_name WHERE id = $1",
parameters: ["$1"],
},
],
};
default:
return null;
}
}
export interface QueryResult {
queryId: string;
fields: string[];
rows: Record<string, unknown>[];
}
interface ReportDesignerContextType {
reportId: string;
reportDetail: ReportDetail | null;
layout: ReportLayout | null;
components: ComponentConfig[];
selectedComponentId: string | null;
isLoading: boolean;
isSaving: boolean;
// 쿼리 관리
queries: ReportQuery[];
setQueries: (queries: ReportQuery[]) => void;
// 쿼리 실행 결과
queryResults: QueryResult[];
setQueryResult: (queryId: string, fields: string[], rows: Record<string, unknown>[]) => void;
getQueryResult: (queryId: string) => QueryResult | null;
// 컴포넌트 관리
addComponent: (component: ComponentConfig) => void;
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
removeComponent: (id: string) => void;
selectComponent: (id: string | null) => void;
// 레이아웃 관리
updateLayout: (updates: Partial<ReportLayout>) => void;
saveLayout: () => Promise<void>;
loadLayout: () => Promise<void>;
// 템플릿 적용
applyTemplate: (templateId: string) => void;
// 캔버스 설정
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
}
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
export function ReportDesignerProvider({ reportId, children }: { reportId: string; children: ReactNode }) {
const [reportDetail, setReportDetail] = useState<ReportDetail | null>(null);
const [layout, setLayout] = useState<ReportLayout | null>(null);
const [components, setComponents] = useState<ComponentConfig[]>([]);
const [queries, setQueries] = useState<ReportQuery[]>([]);
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const { toast } = useToast();
// 캔버스 설정 (기본값)
const [canvasWidth, setCanvasWidth] = useState(210);
const [canvasHeight, setCanvasHeight] = useState(297);
const [pageOrientation, setPageOrientation] = useState("portrait");
const [margins, setMargins] = useState({
top: 20,
bottom: 20,
left: 20,
right: 20,
});
// 리포트 및 레이아웃 로드
const loadLayout = useCallback(async () => {
setIsLoading(true);
try {
// 'new'는 새 리포트 생성 모드
if (reportId === "new") {
setIsLoading(false);
return;
}
// 리포트 상세 조회 (쿼리 포함)
const detailResponse = await reportApi.getReportById(reportId);
if (detailResponse.success && detailResponse.data) {
setReportDetail(detailResponse.data);
// 쿼리 로드
if (detailResponse.data.queries && detailResponse.data.queries.length > 0) {
const loadedQueries = detailResponse.data.queries.map((q) => ({
id: q.query_id,
name: q.query_name,
type: q.query_type,
sqlQuery: q.sql_query,
parameters: Array.isArray(q.parameters) ? q.parameters : [],
}));
setQueries(loadedQueries);
}
}
// 레이아웃 조회
try {
const layoutResponse = await reportApi.getLayout(reportId);
if (layoutResponse.success && layoutResponse.data) {
const layoutData = layoutResponse.data;
setLayout(layoutData);
setComponents(layoutData.components || []);
setCanvasWidth(layoutData.canvas_width);
setCanvasHeight(layoutData.canvas_height);
setPageOrientation(layoutData.page_orientation);
setMargins({
top: layoutData.margin_top,
bottom: layoutData.margin_bottom,
left: layoutData.margin_left,
right: layoutData.margin_right,
});
}
} catch {
// 레이아웃이 없으면 기본값 사용
console.log("레이아웃 없음, 기본값 사용");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "리포트를 불러오는데 실패했습니다.";
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}, [reportId, toast]);
// 초기 로드
useEffect(() => {
loadLayout();
}, [loadLayout]);
// 쿼리 결과 저장
const setQueryResult = useCallback((queryId: string, fields: string[], rows: Record<string, unknown>[]) => {
setQueryResults((prev) => {
const existing = prev.find((r) => r.queryId === queryId);
if (existing) {
return prev.map((r) => (r.queryId === queryId ? { queryId, fields, rows } : r));
}
return [...prev, { queryId, fields, rows }];
});
}, []);
// 쿼리 결과 조회
const getQueryResult = useCallback(
(queryId: string): QueryResult | null => {
return queryResults.find((r) => r.queryId === queryId) || null;
},
[queryResults],
);
// 컴포넌트 추가
const addComponent = useCallback((component: ComponentConfig) => {
setComponents((prev) => [...prev, component]);
}, []);
// 컴포넌트 업데이트
const updateComponent = useCallback((id: string, updates: Partial<ComponentConfig>) => {
setComponents((prev) => prev.map((comp) => (comp.id === id ? { ...comp, ...updates } : comp)));
}, []);
// 컴포넌트 삭제
const removeComponent = useCallback(
(id: string) => {
setComponents((prev) => prev.filter((comp) => comp.id !== id));
if (selectedComponentId === id) {
setSelectedComponentId(null);
}
},
[selectedComponentId],
);
// 컴포넌트 선택
const selectComponent = useCallback((id: string | null) => {
setSelectedComponentId(id);
}, []);
// 레이아웃 업데이트
const updateLayout = useCallback((updates: Partial<ReportLayout>) => {
setLayout((prev) => (prev ? { ...prev, ...updates } : null));
if (updates.canvas_width !== undefined) setCanvasWidth(updates.canvas_width);
if (updates.canvas_height !== undefined) setCanvasHeight(updates.canvas_height);
if (updates.page_orientation !== undefined) setPageOrientation(updates.page_orientation);
if (
updates.margin_top !== undefined ||
updates.margin_bottom !== undefined ||
updates.margin_left !== undefined ||
updates.margin_right !== undefined
) {
setMargins((prev) => ({
top: updates.margin_top ?? prev.top,
bottom: updates.margin_bottom ?? prev.bottom,
left: updates.margin_left ?? prev.left,
right: updates.margin_right ?? prev.right,
}));
}
}, []);
// 레이아웃 저장
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;
// URL 업데이트 (페이지 리로드 없이)
window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`);
}
// 레이아웃 저장 (쿼리 포함)
await reportApi.saveLayout(actualReportId, {
canvasWidth,
canvasHeight,
pageOrientation,
marginTop: margins.top,
marginBottom: margins.bottom,
marginLeft: margins.left,
marginRight: margins.right,
components,
queries,
});
toast({
title: "성공",
description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다.",
});
// 새 리포트였다면 데이터 다시 로드
if (reportId === "new") {
await loadLayout();
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "저장에 실패했습니다.";
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSaving(false);
}
}, [reportId, canvasWidth, canvasHeight, pageOrientation, margins, components, queries, toast, loadLayout]);
// 템플릿 적용
const applyTemplate = useCallback(
(templateId: string) => {
const templates = getTemplateLayout(templateId);
if (!templates) {
toast({
title: "오류",
description: "템플릿을 찾을 수 없습니다.",
variant: "destructive",
});
return;
}
// 기존 컴포넌트가 있으면 확인
if (components.length > 0) {
if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) {
return;
}
}
// 컴포넌트 배치
setComponents(templates.components);
// 쿼리 설정
setQueries(templates.queries);
toast({
title: "성공",
description: "템플릿이 적용되었습니다.",
});
},
[components.length, toast],
);
const value: ReportDesignerContextType = {
reportId,
reportDetail,
layout,
components,
queries,
setQueries,
queryResults,
setQueryResult,
getQueryResult,
selectedComponentId,
isLoading,
isSaving,
addComponent,
updateComponent,
removeComponent,
selectComponent,
updateLayout,
saveLayout,
loadLayout,
applyTemplate,
canvasWidth,
canvasHeight,
pageOrientation,
margins,
};
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
}
export function useReportDesigner() {
const context = useContext(ReportDesignerContext);
if (context === undefined) {
throw new Error("useReportDesigner must be used within a ReportDesignerProvider");
}
return context;
}