2025-10-01 12:00:13 +09:00
|
|
|
"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";
|
|
|
|
|
|
2025-10-01 13:53:45 +09:00
|
|
|
export interface ReportQuery {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
type: "MASTER" | "DETAIL";
|
|
|
|
|
sqlQuery: string;
|
|
|
|
|
parameters: string[];
|
2025-10-01 14:36:46 +09:00
|
|
|
externalConnectionId?: number | null; // 외부 DB 연결 ID
|
2025-10-01 13:53:45 +09:00
|
|
|
}
|
|
|
|
|
|
2025-10-01 14:05:06 +09:00
|
|
|
// 템플릿 레이아웃 정의
|
|
|
|
|
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",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
|
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderWidth: 0,
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
defaultValue: "발주서",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: `comp-${Date.now()}-2`,
|
|
|
|
|
type: "text",
|
|
|
|
|
x: 50,
|
|
|
|
|
y: 80,
|
|
|
|
|
width: 150,
|
|
|
|
|
height: 30,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontColor: "#000000",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:23:00 +09:00
|
|
|
borderWidth: 0,
|
2025-10-01 14:05:06 +09:00
|
|
|
zIndex: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: `comp-${Date.now()}-3`,
|
|
|
|
|
type: "text",
|
|
|
|
|
x: 220,
|
|
|
|
|
y: 80,
|
|
|
|
|
width: 150,
|
|
|
|
|
height: 30,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontColor: "#000000",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:23:00 +09:00
|
|
|
borderWidth: 0,
|
2025-10-01 14:05:06 +09:00
|
|
|
zIndex: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: `comp-${Date.now()}-4`,
|
|
|
|
|
type: "text",
|
|
|
|
|
x: 390,
|
|
|
|
|
y: 80,
|
|
|
|
|
width: 150,
|
|
|
|
|
height: 30,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontColor: "#000000",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:23:00 +09:00
|
|
|
borderWidth: 0,
|
2025-10-01 14:05:06 +09:00
|
|
|
zIndex: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: `comp-${Date.now()}-5`,
|
|
|
|
|
type: "table",
|
|
|
|
|
x: 50,
|
|
|
|
|
y: 130,
|
|
|
|
|
width: 500,
|
|
|
|
|
height: 200,
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontColor: "#000000",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:23:00 +09:00
|
|
|
borderWidth: 0,
|
2025-10-01 14:05:06 +09:00
|
|
|
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",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
|
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderWidth: 0,
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
defaultValue: "청구서",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: `comp-${Date.now()}-2`,
|
|
|
|
|
type: "text",
|
|
|
|
|
x: 50,
|
|
|
|
|
y: 80,
|
|
|
|
|
width: 150,
|
|
|
|
|
height: 30,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontColor: "#000000",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:23:00 +09:00
|
|
|
borderWidth: 0,
|
2025-10-01 14:05:06 +09:00
|
|
|
zIndex: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: `comp-${Date.now()}-3`,
|
|
|
|
|
type: "text",
|
|
|
|
|
x: 220,
|
|
|
|
|
y: 80,
|
|
|
|
|
width: 150,
|
|
|
|
|
height: 30,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontColor: "#000000",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:23:00 +09:00
|
|
|
borderWidth: 0,
|
2025-10-01 14:05:06 +09:00
|
|
|
zIndex: 1,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: `comp-${Date.now()}-4`,
|
|
|
|
|
type: "table",
|
|
|
|
|
x: 50,
|
|
|
|
|
y: 130,
|
|
|
|
|
width: 500,
|
|
|
|
|
height: 200,
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontColor: "#000000",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:23:00 +09:00
|
|
|
borderWidth: 0,
|
2025-10-01 14:05:06 +09:00
|
|
|
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",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
|
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderWidth: 0,
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
defaultValue: "리포트 제목",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: `comp-${Date.now()}-2`,
|
|
|
|
|
type: "text",
|
|
|
|
|
x: 50,
|
|
|
|
|
y: 80,
|
|
|
|
|
width: 500,
|
|
|
|
|
height: 100,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontColor: "#000000",
|
2025-10-01 14:23:00 +09:00
|
|
|
backgroundColor: "transparent",
|
2025-10-01 14:05:06 +09:00
|
|
|
borderColor: "#cccccc",
|
2025-10-01 14:23:00 +09:00
|
|
|
borderWidth: 0,
|
2025-10-01 14:05:06 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 13:53:45 +09:00
|
|
|
export interface QueryResult {
|
|
|
|
|
queryId: string;
|
|
|
|
|
fields: string[];
|
|
|
|
|
rows: Record<string, unknown>[];
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 12:00:13 +09:00
|
|
|
interface ReportDesignerContextType {
|
|
|
|
|
reportId: string;
|
|
|
|
|
reportDetail: ReportDetail | null;
|
|
|
|
|
layout: ReportLayout | null;
|
|
|
|
|
components: ComponentConfig[];
|
|
|
|
|
selectedComponentId: string | null;
|
|
|
|
|
isLoading: boolean;
|
|
|
|
|
isSaving: boolean;
|
|
|
|
|
|
2025-10-01 13:53:45 +09:00
|
|
|
// 쿼리 관리
|
|
|
|
|
queries: ReportQuery[];
|
|
|
|
|
setQueries: (queries: ReportQuery[]) => void;
|
|
|
|
|
|
|
|
|
|
// 쿼리 실행 결과
|
|
|
|
|
queryResults: QueryResult[];
|
|
|
|
|
setQueryResult: (queryId: string, fields: string[], rows: Record<string, unknown>[]) => void;
|
|
|
|
|
getQueryResult: (queryId: string) => QueryResult | null;
|
|
|
|
|
|
2025-10-01 12:00:13 +09:00
|
|
|
// 컴포넌트 관리
|
|
|
|
|
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>;
|
|
|
|
|
|
2025-10-01 14:05:06 +09:00
|
|
|
// 템플릿 적용
|
|
|
|
|
applyTemplate: (templateId: string) => void;
|
|
|
|
|
|
2025-10-01 12:00:13 +09:00
|
|
|
// 캔버스 설정
|
|
|
|
|
canvasWidth: number;
|
|
|
|
|
canvasHeight: number;
|
|
|
|
|
pageOrientation: string;
|
|
|
|
|
margins: {
|
|
|
|
|
top: number;
|
|
|
|
|
bottom: number;
|
|
|
|
|
left: number;
|
|
|
|
|
right: number;
|
|
|
|
|
};
|
2025-10-01 15:32:35 +09:00
|
|
|
|
|
|
|
|
// 레이아웃 도구
|
|
|
|
|
gridSize: number;
|
|
|
|
|
setGridSize: (size: number) => void;
|
|
|
|
|
showGrid: boolean;
|
|
|
|
|
setShowGrid: (show: boolean) => void;
|
|
|
|
|
snapToGrid: boolean;
|
|
|
|
|
setSnapToGrid: (snap: boolean) => void;
|
|
|
|
|
snapValueToGrid: (value: number) => number;
|
2025-10-01 15:35:16 +09:00
|
|
|
|
|
|
|
|
// 정렬 가이드라인
|
|
|
|
|
alignmentGuides: { vertical: number[]; horizontal: number[] };
|
|
|
|
|
calculateAlignmentGuides: (draggingId: string, x: number, y: number, width: number, height: number) => void;
|
|
|
|
|
clearAlignmentGuides: () => void;
|
2025-10-01 12:00:13 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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[]>([]);
|
2025-10-01 13:53:45 +09:00
|
|
|
const [queries, setQueries] = useState<ReportQuery[]>([]);
|
|
|
|
|
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
2025-10-01 12:00:13 +09:00
|
|
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
|
2025-10-01 15:32:35 +09:00
|
|
|
// 레이아웃 도구 설정
|
|
|
|
|
const [gridSize, setGridSize] = useState(10); // Grid Snap 크기 (px)
|
|
|
|
|
const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부
|
|
|
|
|
const [snapToGrid, setSnapToGrid] = useState(true); // Grid Snap 활성화
|
|
|
|
|
|
2025-10-01 15:35:16 +09:00
|
|
|
// 정렬 가이드라인
|
|
|
|
|
const [alignmentGuides, setAlignmentGuides] = useState<{
|
|
|
|
|
vertical: number[];
|
|
|
|
|
horizontal: number[];
|
|
|
|
|
}>({ vertical: [], horizontal: [] });
|
|
|
|
|
|
2025-10-01 15:32:35 +09:00
|
|
|
// Grid Snap 함수
|
|
|
|
|
const snapValueToGrid = useCallback(
|
|
|
|
|
(value: number): number => {
|
|
|
|
|
if (!snapToGrid) return value;
|
|
|
|
|
return Math.round(value / gridSize) * gridSize;
|
|
|
|
|
},
|
|
|
|
|
[snapToGrid, gridSize],
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-01 15:35:16 +09:00
|
|
|
// 정렬 가이드라인 계산 (드래그 중인 컴포넌트 제외)
|
|
|
|
|
const calculateAlignmentGuides = useCallback(
|
|
|
|
|
(draggingId: string, x: number, y: number, width: number, height: number) => {
|
|
|
|
|
const threshold = 5; // 정렬 감지 임계값 (px)
|
|
|
|
|
const verticalLines: number[] = [];
|
|
|
|
|
const horizontalLines: number[] = [];
|
|
|
|
|
|
|
|
|
|
// 드래그 중인 컴포넌트의 주요 위치
|
|
|
|
|
const left = x;
|
|
|
|
|
const right = x + width;
|
|
|
|
|
const centerX = x + width / 2;
|
|
|
|
|
const top = y;
|
|
|
|
|
const bottom = y + height;
|
|
|
|
|
const centerY = y + height / 2;
|
|
|
|
|
|
|
|
|
|
// 다른 컴포넌트들과 비교
|
|
|
|
|
components.forEach((comp) => {
|
|
|
|
|
if (comp.id === draggingId) return;
|
|
|
|
|
|
|
|
|
|
const compLeft = comp.x;
|
|
|
|
|
const compRight = comp.x + comp.width;
|
|
|
|
|
const compCenterX = comp.x + comp.width / 2;
|
|
|
|
|
const compTop = comp.y;
|
|
|
|
|
const compBottom = comp.y + comp.height;
|
|
|
|
|
const compCenterY = comp.y + comp.height / 2;
|
|
|
|
|
|
|
|
|
|
// 세로 정렬 체크 (left, center, right)
|
|
|
|
|
if (Math.abs(left - compLeft) < threshold) verticalLines.push(compLeft);
|
|
|
|
|
if (Math.abs(left - compRight) < threshold) verticalLines.push(compRight);
|
|
|
|
|
if (Math.abs(right - compLeft) < threshold) verticalLines.push(compLeft);
|
|
|
|
|
if (Math.abs(right - compRight) < threshold) verticalLines.push(compRight);
|
|
|
|
|
if (Math.abs(centerX - compCenterX) < threshold) verticalLines.push(compCenterX);
|
|
|
|
|
|
|
|
|
|
// 가로 정렬 체크 (top, center, bottom)
|
|
|
|
|
if (Math.abs(top - compTop) < threshold) horizontalLines.push(compTop);
|
|
|
|
|
if (Math.abs(top - compBottom) < threshold) horizontalLines.push(compBottom);
|
|
|
|
|
if (Math.abs(bottom - compTop) < threshold) horizontalLines.push(compTop);
|
|
|
|
|
if (Math.abs(bottom - compBottom) < threshold) horizontalLines.push(compBottom);
|
|
|
|
|
if (Math.abs(centerY - compCenterY) < threshold) horizontalLines.push(compCenterY);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 중복 제거
|
|
|
|
|
setAlignmentGuides({
|
|
|
|
|
vertical: Array.from(new Set(verticalLines)),
|
|
|
|
|
horizontal: Array.from(new Set(horizontalLines)),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[components],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 정렬 가이드라인 초기화
|
|
|
|
|
const clearAlignmentGuides = useCallback(() => {
|
|
|
|
|
setAlignmentGuides({ vertical: [], horizontal: [] });
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-10-01 12:00:13 +09:00
|
|
|
// 캔버스 설정 (기본값)
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 13:53:45 +09:00
|
|
|
// 리포트 상세 조회 (쿼리 포함)
|
2025-10-01 12:00:13 +09:00
|
|
|
const detailResponse = await reportApi.getReportById(reportId);
|
|
|
|
|
if (detailResponse.success && detailResponse.data) {
|
|
|
|
|
setReportDetail(detailResponse.data);
|
2025-10-01 13:53:45 +09:00
|
|
|
|
|
|
|
|
// 쿼리 로드
|
|
|
|
|
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);
|
|
|
|
|
}
|
2025-10-01 12:00:13 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 레이아웃 조회
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-01 13:53:45 +09:00
|
|
|
} catch {
|
2025-10-01 12:00:13 +09:00
|
|
|
// 레이아웃이 없으면 기본값 사용
|
|
|
|
|
console.log("레이아웃 없음, 기본값 사용");
|
|
|
|
|
}
|
2025-10-01 14:23:00 +09:00
|
|
|
|
|
|
|
|
// 쿼리 조회는 이미 위에서 처리됨 (reportResponse.data.queries)
|
2025-10-01 13:53:45 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : "리포트를 불러오는데 실패했습니다.";
|
2025-10-01 12:00:13 +09:00
|
|
|
toast({
|
|
|
|
|
title: "오류",
|
2025-10-01 13:53:45 +09:00
|
|
|
description: errorMessage,
|
2025-10-01 12:00:13 +09:00
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [reportId, toast]);
|
|
|
|
|
|
|
|
|
|
// 초기 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadLayout();
|
2025-10-01 14:23:00 +09:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [reportId]);
|
2025-10-01 12:00:13 +09:00
|
|
|
|
2025-10-01 13:53:45 +09:00
|
|
|
// 쿼리 결과 저장
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-01 12:00:13 +09:00
|
|
|
// 컴포넌트 추가
|
|
|
|
|
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}`);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 13:53:45 +09:00
|
|
|
// 레이아웃 저장 (쿼리 포함)
|
2025-10-01 12:00:13 +09:00
|
|
|
await reportApi.saveLayout(actualReportId, {
|
|
|
|
|
canvasWidth,
|
|
|
|
|
canvasHeight,
|
|
|
|
|
pageOrientation,
|
|
|
|
|
marginTop: margins.top,
|
|
|
|
|
marginBottom: margins.bottom,
|
|
|
|
|
marginLeft: margins.left,
|
|
|
|
|
marginRight: margins.right,
|
|
|
|
|
components,
|
2025-10-01 13:53:45 +09:00
|
|
|
queries,
|
2025-10-01 12:00:13 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
toast({
|
|
|
|
|
title: "성공",
|
|
|
|
|
description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다.",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 새 리포트였다면 데이터 다시 로드
|
|
|
|
|
if (reportId === "new") {
|
|
|
|
|
await loadLayout();
|
|
|
|
|
}
|
2025-10-01 13:53:45 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : "저장에 실패했습니다.";
|
2025-10-01 12:00:13 +09:00
|
|
|
toast({
|
|
|
|
|
title: "오류",
|
2025-10-01 13:53:45 +09:00
|
|
|
description: errorMessage,
|
2025-10-01 12:00:13 +09:00
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
}
|
2025-10-01 13:53:45 +09:00
|
|
|
}, [reportId, canvasWidth, canvasHeight, pageOrientation, margins, components, queries, toast, loadLayout]);
|
2025-10-01 12:00:13 +09:00
|
|
|
|
2025-10-01 14:05:06 +09:00
|
|
|
// 템플릿 적용
|
|
|
|
|
const applyTemplate = useCallback(
|
2025-10-01 15:03:52 +09:00
|
|
|
async (templateId: string) => {
|
|
|
|
|
try {
|
|
|
|
|
// 기존 컴포넌트가 있으면 확인
|
|
|
|
|
if (components.length > 0) {
|
|
|
|
|
if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-01 14:05:06 +09:00
|
|
|
|
2025-10-01 15:03:52 +09:00
|
|
|
// 1. 먼저 하드코딩된 시스템 템플릿 확인 (order, invoice, basic)
|
|
|
|
|
const systemTemplate = getTemplateLayout(templateId);
|
|
|
|
|
|
|
|
|
|
if (systemTemplate) {
|
|
|
|
|
// 시스템 템플릿 적용
|
|
|
|
|
setComponents(systemTemplate.components);
|
|
|
|
|
setQueries(systemTemplate.queries);
|
2025-10-01 14:05:06 +09:00
|
|
|
|
2025-10-01 15:03:52 +09:00
|
|
|
toast({
|
|
|
|
|
title: "성공",
|
|
|
|
|
description: "템플릿이 적용되었습니다.",
|
|
|
|
|
});
|
2025-10-01 14:05:06 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 15:03:52 +09:00
|
|
|
// 2. 사용자 정의 템플릿은 백엔드에서 조회
|
|
|
|
|
const response = await reportApi.getTemplates();
|
2025-10-01 14:05:06 +09:00
|
|
|
|
2025-10-01 15:03:52 +09:00
|
|
|
if (!response.success || !response.data) {
|
|
|
|
|
throw new Error("템플릿 목록을 불러올 수 없습니다.");
|
|
|
|
|
}
|
2025-10-01 14:05:06 +09:00
|
|
|
|
2025-10-01 15:03:52 +09:00
|
|
|
// 커스텀 템플릿 찾기
|
|
|
|
|
const customTemplates = response.data.custom || [];
|
2025-10-01 15:32:35 +09:00
|
|
|
const template = customTemplates.find((t: { template_id: string }) => t.template_id === templateId);
|
2025-10-01 15:03:52 +09:00
|
|
|
|
|
|
|
|
if (!template) {
|
|
|
|
|
throw new Error("템플릿을 찾을 수 없습니다.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. 템플릿 데이터 파싱 및 적용
|
|
|
|
|
const layoutConfig =
|
|
|
|
|
typeof template.layout_config === "string" ? JSON.parse(template.layout_config) : template.layout_config;
|
|
|
|
|
|
|
|
|
|
const defaultQueries =
|
|
|
|
|
typeof template.default_queries === "string"
|
|
|
|
|
? JSON.parse(template.default_queries)
|
|
|
|
|
: template.default_queries || [];
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 적용 (ID 재생성)
|
2025-10-01 15:32:35 +09:00
|
|
|
const newComponents = (layoutConfig.components as ComponentConfig[]).map((comp) => ({
|
2025-10-01 15:03:52 +09:00
|
|
|
...comp,
|
|
|
|
|
id: `comp-${Date.now()}-${Math.random()}`,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 쿼리 적용 (ID 재생성)
|
2025-10-01 15:32:35 +09:00
|
|
|
const newQueries = (
|
|
|
|
|
defaultQueries as Array<{
|
|
|
|
|
name: string;
|
|
|
|
|
type: "MASTER" | "DETAIL";
|
|
|
|
|
sqlQuery: string;
|
|
|
|
|
parameters: string[];
|
|
|
|
|
externalConnectionId?: number | null;
|
|
|
|
|
}>
|
|
|
|
|
).map((q) => ({
|
2025-10-01 15:03:52 +09:00
|
|
|
id: `query-${Date.now()}-${Math.random()}`,
|
|
|
|
|
name: q.name,
|
|
|
|
|
type: q.type,
|
|
|
|
|
sqlQuery: q.sqlQuery,
|
|
|
|
|
parameters: q.parameters || [],
|
|
|
|
|
externalConnectionId: q.externalConnectionId || null,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
setComponents(newComponents);
|
|
|
|
|
setQueries(newQueries);
|
|
|
|
|
|
|
|
|
|
toast({
|
|
|
|
|
title: "성공",
|
|
|
|
|
description: "사용자 정의 템플릿이 적용되었습니다.",
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : "템플릿 적용에 실패했습니다.";
|
|
|
|
|
toast({
|
|
|
|
|
title: "오류",
|
|
|
|
|
description: errorMessage,
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-01 14:05:06 +09:00
|
|
|
},
|
|
|
|
|
[components.length, toast],
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-01 12:00:13 +09:00
|
|
|
const value: ReportDesignerContextType = {
|
|
|
|
|
reportId,
|
|
|
|
|
reportDetail,
|
|
|
|
|
layout,
|
|
|
|
|
components,
|
2025-10-01 13:53:45 +09:00
|
|
|
queries,
|
|
|
|
|
setQueries,
|
|
|
|
|
queryResults,
|
|
|
|
|
setQueryResult,
|
|
|
|
|
getQueryResult,
|
2025-10-01 12:00:13 +09:00
|
|
|
selectedComponentId,
|
|
|
|
|
isLoading,
|
|
|
|
|
isSaving,
|
|
|
|
|
addComponent,
|
|
|
|
|
updateComponent,
|
|
|
|
|
removeComponent,
|
|
|
|
|
selectComponent,
|
|
|
|
|
updateLayout,
|
|
|
|
|
saveLayout,
|
|
|
|
|
loadLayout,
|
2025-10-01 14:05:06 +09:00
|
|
|
applyTemplate,
|
2025-10-01 12:00:13 +09:00
|
|
|
canvasWidth,
|
|
|
|
|
canvasHeight,
|
|
|
|
|
pageOrientation,
|
|
|
|
|
margins,
|
2025-10-01 15:32:35 +09:00
|
|
|
// 레이아웃 도구
|
|
|
|
|
gridSize,
|
|
|
|
|
setGridSize,
|
|
|
|
|
showGrid,
|
|
|
|
|
setShowGrid,
|
|
|
|
|
snapToGrid,
|
|
|
|
|
setSnapToGrid,
|
|
|
|
|
snapValueToGrid,
|
2025-10-01 15:35:16 +09:00
|
|
|
// 정렬 가이드라인
|
|
|
|
|
alignmentGuides,
|
|
|
|
|
calculateAlignmentGuides,
|
|
|
|
|
clearAlignmentGuides,
|
2025-10-01 12:00:13 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|