1672 lines
54 KiB
TypeScript
1672 lines
54 KiB
TypeScript
"use client";
|
|
|
|
import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from "react";
|
|
import {
|
|
ComponentConfig,
|
|
ReportDetail,
|
|
ReportLayout,
|
|
ReportPage,
|
|
ReportLayoutConfig,
|
|
GridConfig,
|
|
} from "@/types/report";
|
|
import { reportApi } from "@/lib/api/reportApi";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import {
|
|
snapComponentToGrid,
|
|
createDefaultGridConfig,
|
|
calculateGridDimensions,
|
|
detectGridCollision,
|
|
} from "@/lib/utils/gridUtils";
|
|
|
|
export interface ReportQuery {
|
|
id: string;
|
|
name: string;
|
|
type: "MASTER" | "DETAIL";
|
|
sqlQuery: string;
|
|
parameters: string[];
|
|
externalConnectionId?: number | null; // 외부 DB 연결 ID
|
|
}
|
|
|
|
// 하드코딩된 템플릿 제거 - 모든 템플릿은 DB에서 관리
|
|
|
|
export interface QueryResult {
|
|
queryId: string;
|
|
fields: string[];
|
|
rows: Record<string, unknown>[];
|
|
}
|
|
|
|
interface ReportDesignerContextType {
|
|
reportId: string;
|
|
reportDetail: ReportDetail | null;
|
|
layout: ReportLayout | null;
|
|
|
|
// 페이지 관리
|
|
layoutConfig: ReportLayoutConfig; // { pages: [...] }
|
|
currentPageId: string | null;
|
|
currentPage: ReportPage | undefined;
|
|
|
|
// 페이지 액션
|
|
addPage: (name?: string) => void;
|
|
deletePage: (pageId: string) => void;
|
|
duplicatePage: (pageId: string) => void;
|
|
reorderPages: (sourceIndex: number, targetIndex: number) => void;
|
|
selectPage: (pageId: string) => void;
|
|
updatePageSettings: (pageId: string, settings: Partial<ReportPage>) => void;
|
|
|
|
// 컴포넌트 (현재 페이지)
|
|
components: ComponentConfig[]; // currentPage의 components (읽기 전용)
|
|
selectedComponentId: string | null;
|
|
selectedComponentIds: string[]; // 다중 선택된 컴포넌트 ID 배열
|
|
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, isMultiSelect?: boolean) => void;
|
|
|
|
// 레이아웃 관리
|
|
updateLayout: (updates: Partial<ReportLayout>) => void;
|
|
saveLayout: () => Promise<void>;
|
|
loadLayout: () => Promise<void>;
|
|
|
|
// 템플릿 적용
|
|
applyTemplate: (templateId: string) => void;
|
|
|
|
// 그리드 관리
|
|
gridConfig: GridConfig;
|
|
updateGridConfig: (updates: Partial<GridConfig>) => void;
|
|
|
|
// 캔버스 설정
|
|
canvasWidth: number;
|
|
canvasHeight: number;
|
|
pageOrientation: string;
|
|
margins: {
|
|
top: number;
|
|
bottom: number;
|
|
left: number;
|
|
right: number;
|
|
};
|
|
|
|
// 레이아웃 도구
|
|
gridSize: number;
|
|
setGridSize: (size: number) => void;
|
|
showGrid: boolean;
|
|
setShowGrid: (show: boolean) => void;
|
|
snapToGrid: boolean;
|
|
setSnapToGrid: (snap: boolean) => void;
|
|
snapValueToGrid: (value: number) => number;
|
|
|
|
// 정렬 가이드라인
|
|
alignmentGuides: { vertical: number[]; horizontal: number[] };
|
|
calculateAlignmentGuides: (draggingId: string, x: number, y: number, width: number, height: number) => void;
|
|
clearAlignmentGuides: () => void;
|
|
|
|
// 복사/붙여넣기
|
|
copyComponents: () => void;
|
|
pasteComponents: () => void;
|
|
|
|
// Undo/Redo
|
|
undo: () => void;
|
|
redo: () => void;
|
|
canUndo: boolean;
|
|
canRedo: boolean;
|
|
|
|
// 정렬 기능
|
|
alignLeft: () => void;
|
|
alignRight: () => void;
|
|
alignTop: () => void;
|
|
alignBottom: () => void;
|
|
alignCenterHorizontal: () => void;
|
|
alignCenterVertical: () => void;
|
|
distributeHorizontal: () => void;
|
|
distributeVertical: () => void;
|
|
makeSameWidth: () => void;
|
|
makeSameHeight: () => void;
|
|
makeSameSize: () => void;
|
|
|
|
// 레이어 관리
|
|
bringToFront: () => void;
|
|
sendToBack: () => void;
|
|
bringForward: () => void;
|
|
sendBackward: () => void;
|
|
|
|
// 잠금 관리
|
|
toggleLock: () => void;
|
|
lockComponents: () => void;
|
|
unlockComponents: () => void;
|
|
|
|
// 눈금자 표시
|
|
showRuler: boolean;
|
|
setShowRuler: (show: boolean) => void;
|
|
|
|
// 그룹화
|
|
groupComponents: () => void;
|
|
ungroupComponents: () => void;
|
|
}
|
|
|
|
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 [layoutConfig, setLayoutConfig] = useState<ReportLayoutConfig>({
|
|
pages: [],
|
|
});
|
|
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
|
|
|
|
const [queries, setQueries] = useState<ReportQuery[]>([]);
|
|
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
|
const [selectedComponentIds, setSelectedComponentIds] = useState<string[]>([]); // 다중 선택
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const { toast } = useToast();
|
|
|
|
// 현재 페이지 계산
|
|
const currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId);
|
|
|
|
// 현재 페이지의 컴포넌트 (읽기 전용)
|
|
const components = currentPage?.components || [];
|
|
|
|
// currentPageId를 ref로 저장하여 클로저 문제 해결
|
|
const currentPageIdRef = useRef<string | null>(currentPageId);
|
|
useEffect(() => {
|
|
currentPageIdRef.current = currentPageId;
|
|
}, [currentPageId]);
|
|
|
|
// 현재 페이지의 컴포넌트를 업데이트하는 헬퍼 함수
|
|
const setComponents = useCallback(
|
|
(updater: ComponentConfig[] | ((prev: ComponentConfig[]) => ComponentConfig[])) => {
|
|
setLayoutConfig((prev) => {
|
|
const pageId = currentPageIdRef.current;
|
|
if (!pageId) {
|
|
console.error("❌ currentPageId가 없음");
|
|
return prev;
|
|
}
|
|
|
|
// 현재 선택된 페이지 찾기
|
|
const currentPageIndex = prev.pages.findIndex((p) => p.page_id === pageId);
|
|
if (currentPageIndex === -1) {
|
|
console.error("❌ 페이지를 찾을 수 없음:", pageId);
|
|
return prev;
|
|
}
|
|
|
|
const currentPageData = prev.pages[currentPageIndex];
|
|
const newComponents = typeof updater === "function" ? updater(currentPageData.components) : updater;
|
|
|
|
const newPages = [...prev.pages];
|
|
newPages[currentPageIndex] = {
|
|
...currentPageData,
|
|
components: newComponents,
|
|
};
|
|
|
|
console.log("✅ 컴포넌트 업데이트:", {
|
|
pageId,
|
|
before: currentPageData.components.length,
|
|
after: newComponents.length,
|
|
});
|
|
|
|
return { pages: newPages };
|
|
});
|
|
},
|
|
[], // ref를 사용하므로 의존성 배열 비움
|
|
);
|
|
|
|
// 그리드 설정
|
|
const [gridConfig, setGridConfig] = useState<GridConfig>(() => {
|
|
// 기본 페이지 크기 (A4: 794 x 1123 px at 96 DPI)
|
|
const defaultPageWidth = 794;
|
|
const defaultPageHeight = 1123;
|
|
return createDefaultGridConfig(defaultPageWidth, defaultPageHeight);
|
|
});
|
|
|
|
// gridConfig 업데이트 함수
|
|
const updateGridConfig = useCallback(
|
|
(updates: Partial<GridConfig>) => {
|
|
setGridConfig((prev) => {
|
|
const newConfig = { ...prev, ...updates };
|
|
|
|
// cellWidth나 cellHeight가 변경되면 rows/columns 재계산
|
|
if (updates.cellWidth || updates.cellHeight) {
|
|
const pageWidth = currentPage?.width ? currentPage.width * 3.7795275591 : 794; // mm to px
|
|
const pageHeight = currentPage?.height ? currentPage.height * 3.7795275591 : 1123;
|
|
const { rows, columns } = calculateGridDimensions(
|
|
pageWidth,
|
|
pageHeight,
|
|
newConfig.cellWidth,
|
|
newConfig.cellHeight,
|
|
);
|
|
newConfig.rows = rows;
|
|
newConfig.columns = columns;
|
|
}
|
|
|
|
return newConfig;
|
|
});
|
|
},
|
|
[currentPage],
|
|
);
|
|
|
|
// 레거시 호환성을 위한 별칭
|
|
const gridSize = gridConfig.cellWidth;
|
|
const showGrid = gridConfig.visible;
|
|
const snapToGrid = gridConfig.snapToGrid;
|
|
const setGridSize = useCallback(
|
|
(size: number) => updateGridConfig({ cellWidth: size, cellHeight: size }),
|
|
[updateGridConfig],
|
|
);
|
|
const setShowGrid = useCallback((visible: boolean) => updateGridConfig({ visible }), [updateGridConfig]);
|
|
const setSnapToGrid = useCallback((snap: boolean) => updateGridConfig({ snapToGrid: snap }), [updateGridConfig]);
|
|
|
|
// 눈금자 표시
|
|
const [showRuler, setShowRuler] = useState(true);
|
|
|
|
// 정렬 가이드라인
|
|
const [alignmentGuides, setAlignmentGuides] = useState<{
|
|
vertical: number[];
|
|
horizontal: number[];
|
|
}>({ vertical: [], horizontal: [] });
|
|
|
|
// 클립보드 (복사/붙여넣기)
|
|
const [clipboard, setClipboard] = useState<ComponentConfig[]>([]);
|
|
|
|
// Undo/Redo 히스토리
|
|
const [history, setHistory] = useState<ComponentConfig[][]>([]);
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
const [isUndoRedoing, setIsUndoRedoing] = useState(false); // Undo/Redo 실행 중 플래그
|
|
|
|
// Grid Snap 함수
|
|
const snapValueToGrid = useCallback(
|
|
(value: number): number => {
|
|
if (!snapToGrid) return value;
|
|
return Math.round(value / gridSize) * gridSize;
|
|
},
|
|
[snapToGrid, gridSize],
|
|
);
|
|
|
|
// 복사 (Ctrl+C)
|
|
const copyComponents = useCallback(() => {
|
|
if (selectedComponentIds.length > 0) {
|
|
const componentsToCopy = components.filter((comp) => selectedComponentIds.includes(comp.id));
|
|
setClipboard(componentsToCopy);
|
|
toast({
|
|
title: "복사 완료",
|
|
description: `${componentsToCopy.length}개의 컴포넌트가 복사되었습니다.`,
|
|
});
|
|
} else if (selectedComponentId) {
|
|
const componentToCopy = components.find((comp) => comp.id === selectedComponentId);
|
|
if (componentToCopy) {
|
|
setClipboard([componentToCopy]);
|
|
toast({
|
|
title: "복사 완료",
|
|
description: "컴포넌트가 복사되었습니다.",
|
|
});
|
|
}
|
|
}
|
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
|
|
|
// 붙여넣기 (Ctrl+V)
|
|
const pasteComponents = useCallback(() => {
|
|
if (clipboard.length === 0) return;
|
|
|
|
const newComponents = clipboard.map((comp) => ({
|
|
...comp,
|
|
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
x: comp.x + 20, // 약간 오프셋
|
|
y: comp.y + 20,
|
|
zIndex: components.length,
|
|
}));
|
|
|
|
// setComponents를 직접 사용
|
|
setComponents((prev) => [...prev, ...newComponents]);
|
|
|
|
// 새로 생성된 컴포넌트 선택
|
|
if (newComponents.length === 1) {
|
|
setSelectedComponentId(newComponents[0].id);
|
|
setSelectedComponentIds([newComponents[0].id]);
|
|
} else {
|
|
setSelectedComponentIds(newComponents.map((c) => c.id));
|
|
setSelectedComponentId(newComponents[0].id);
|
|
}
|
|
|
|
toast({
|
|
title: "붙여넣기 완료",
|
|
description: `${newComponents.length}개의 컴포넌트가 추가되었습니다.`,
|
|
});
|
|
}, [clipboard, components.length, toast]);
|
|
|
|
// 히스토리에 현재 상태 저장
|
|
const saveToHistory = useCallback(
|
|
(newComponents: ComponentConfig[]) => {
|
|
setHistory((prev) => {
|
|
// 현재 인덱스 이후의 히스토리 제거 (새 분기 시작)
|
|
const newHistory = prev.slice(0, historyIndex + 1);
|
|
// 새 상태 추가 (최대 50개까지만 유지)
|
|
newHistory.push(JSON.parse(JSON.stringify(newComponents)));
|
|
return newHistory.slice(-50);
|
|
});
|
|
setHistoryIndex((prev) => Math.min(prev + 1, 49));
|
|
},
|
|
[historyIndex],
|
|
);
|
|
|
|
// Undo
|
|
const undo = useCallback(() => {
|
|
if (historyIndex > 0) {
|
|
setIsUndoRedoing(true);
|
|
setHistoryIndex((prev) => prev - 1);
|
|
setComponents(JSON.parse(JSON.stringify(history[historyIndex - 1])));
|
|
setTimeout(() => setIsUndoRedoing(false), 100);
|
|
toast({
|
|
title: "실행 취소",
|
|
description: "이전 상태로 되돌렸습니다.",
|
|
});
|
|
}
|
|
}, [historyIndex, history, toast]);
|
|
|
|
// Redo
|
|
const redo = useCallback(() => {
|
|
if (historyIndex < history.length - 1) {
|
|
setIsUndoRedoing(true);
|
|
setHistoryIndex((prev) => prev + 1);
|
|
setComponents(JSON.parse(JSON.stringify(history[historyIndex + 1])));
|
|
setTimeout(() => setIsUndoRedoing(false), 100);
|
|
toast({
|
|
title: "다시 실행",
|
|
description: "다음 상태로 이동했습니다.",
|
|
});
|
|
}
|
|
}, [historyIndex, history, toast]);
|
|
|
|
// 정렬 함수들 (선택된 컴포넌트들 기준)
|
|
const getSelectedComponents = useCallback(() => {
|
|
return components.filter((c) => selectedComponentIds.includes(c.id));
|
|
}, [components, selectedComponentIds]);
|
|
|
|
// 왼쪽 정렬
|
|
const alignLeft = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 2) return;
|
|
|
|
const minX = Math.min(...selected.map((c) => c.x));
|
|
const updates = selected.map((c) => ({ ...c, x: minX }));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "정렬 완료", description: "왼쪽 정렬되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 오른쪽 정렬
|
|
const alignRight = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 2) return;
|
|
|
|
const maxRight = Math.max(...selected.map((c) => c.x + c.width));
|
|
const updates = selected.map((c) => ({ ...c, x: maxRight - c.width }));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "정렬 완료", description: "오른쪽 정렬되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 위쪽 정렬
|
|
const alignTop = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 2) return;
|
|
|
|
const minY = Math.min(...selected.map((c) => c.y));
|
|
const updates = selected.map((c) => ({ ...c, y: minY }));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "정렬 완료", description: "위쪽 정렬되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 아래쪽 정렬
|
|
const alignBottom = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 2) return;
|
|
|
|
const maxBottom = Math.max(...selected.map((c) => c.y + c.height));
|
|
const updates = selected.map((c) => ({ ...c, y: maxBottom - c.height }));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "정렬 완료", description: "아래쪽 정렬되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 가로 중앙 정렬
|
|
const alignCenterHorizontal = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 2) return;
|
|
|
|
const minX = Math.min(...selected.map((c) => c.x));
|
|
const maxRight = Math.max(...selected.map((c) => c.x + c.width));
|
|
const centerX = (minX + maxRight) / 2;
|
|
|
|
const updates = selected.map((c) => ({ ...c, x: centerX - c.width / 2 }));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "정렬 완료", description: "가로 중앙 정렬되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 세로 중앙 정렬
|
|
const alignCenterVertical = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 2) return;
|
|
|
|
const minY = Math.min(...selected.map((c) => c.y));
|
|
const maxBottom = Math.max(...selected.map((c) => c.y + c.height));
|
|
const centerY = (minY + maxBottom) / 2;
|
|
|
|
const updates = selected.map((c) => ({ ...c, y: centerY - c.height / 2 }));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "정렬 완료", description: "세로 중앙 정렬되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 가로 균등 배치
|
|
const distributeHorizontal = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 3) return;
|
|
|
|
const sorted = [...selected].sort((a, b) => a.x - b.x);
|
|
const minX = sorted[0].x;
|
|
const maxX = sorted[sorted.length - 1].x + sorted[sorted.length - 1].width;
|
|
const totalWidth = sorted.reduce((sum, c) => sum + c.width, 0);
|
|
const totalGap = maxX - minX - totalWidth;
|
|
const gap = totalGap / (sorted.length - 1);
|
|
|
|
let currentX = minX;
|
|
const updates = sorted.map((c) => {
|
|
const newC = { ...c, x: currentX };
|
|
currentX += c.width + gap;
|
|
return newC;
|
|
});
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "정렬 완료", description: "가로 균등 배치되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 세로 균등 배치
|
|
const distributeVertical = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 3) return;
|
|
|
|
const sorted = [...selected].sort((a, b) => a.y - b.y);
|
|
const minY = sorted[0].y;
|
|
const maxY = sorted[sorted.length - 1].y + sorted[sorted.length - 1].height;
|
|
const totalHeight = sorted.reduce((sum, c) => sum + c.height, 0);
|
|
const totalGap = maxY - minY - totalHeight;
|
|
const gap = totalGap / (sorted.length - 1);
|
|
|
|
let currentY = minY;
|
|
const updates = sorted.map((c) => {
|
|
const newC = { ...c, y: currentY };
|
|
currentY += c.height + gap;
|
|
return newC;
|
|
});
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "정렬 완료", description: "세로 균등 배치되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 같은 너비로
|
|
const makeSameWidth = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 2) return;
|
|
|
|
const targetWidth = selected[0].width;
|
|
const updates = selected.map((c) => ({ ...c, width: targetWidth }));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "크기 조정 완료", description: "같은 너비로 조정되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 같은 높이로
|
|
const makeSameHeight = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 2) return;
|
|
|
|
const targetHeight = selected[0].height;
|
|
const updates = selected.map((c) => ({ ...c, height: targetHeight }));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "크기 조정 완료", description: "같은 높이로 조정되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 같은 크기로
|
|
const makeSameSize = useCallback(() => {
|
|
const selected = getSelectedComponents();
|
|
if (selected.length < 2) return;
|
|
|
|
const targetWidth = selected[0].width;
|
|
const targetHeight = selected[0].height;
|
|
const updates = selected.map((c) => ({ ...c, width: targetWidth, height: targetHeight }));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
const update = updates.find((u) => u.id === c.id);
|
|
return update || c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "크기 조정 완료", description: "같은 크기로 조정되었습니다." });
|
|
}, [getSelectedComponents, toast]);
|
|
|
|
// 레이어 관리 함수들
|
|
const bringToFront = useCallback(() => {
|
|
if (!selectedComponentId && selectedComponentIds.length === 0) return;
|
|
|
|
const idsToUpdate =
|
|
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
|
|
const maxZIndex = Math.max(...components.map((c) => c.zIndex));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
if (idsToUpdate.includes(c.id)) {
|
|
return { ...c, zIndex: maxZIndex + 1 };
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "레이어 변경", description: "맨 앞으로 이동했습니다." });
|
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
|
|
|
const sendToBack = useCallback(() => {
|
|
if (!selectedComponentId && selectedComponentIds.length === 0) return;
|
|
|
|
const idsToUpdate =
|
|
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
|
|
const minZIndex = Math.min(...components.map((c) => c.zIndex));
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
if (idsToUpdate.includes(c.id)) {
|
|
// zIndex는 최소 1로 제한 (0이면 캔버스 배경 뒤로 가버림)
|
|
return { ...c, zIndex: Math.max(1, minZIndex - 1) };
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "레이어 변경", description: "맨 뒤로 이동했습니다." });
|
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
|
|
|
const bringForward = useCallback(() => {
|
|
if (!selectedComponentId && selectedComponentIds.length === 0) return;
|
|
|
|
const idsToUpdate =
|
|
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
|
|
|
|
setComponents((prev) => {
|
|
const sorted = [...prev].sort((a, b) => a.zIndex - b.zIndex);
|
|
const updated = sorted.map((c, index) => ({ ...c, zIndex: index }));
|
|
|
|
return updated.map((c) => {
|
|
if (idsToUpdate.includes(c.id)) {
|
|
return { ...c, zIndex: Math.min(c.zIndex + 1, updated.length - 1) };
|
|
}
|
|
return c;
|
|
});
|
|
});
|
|
|
|
toast({ title: "레이어 변경", description: "한 단계 앞으로 이동했습니다." });
|
|
}, [selectedComponentId, selectedComponentIds, toast]);
|
|
|
|
const sendBackward = useCallback(() => {
|
|
if (!selectedComponentId && selectedComponentIds.length === 0) return;
|
|
|
|
const idsToUpdate =
|
|
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
|
|
|
|
setComponents((prev) => {
|
|
const sorted = [...prev].sort((a, b) => a.zIndex - b.zIndex);
|
|
const updated = sorted.map((c, index) => ({ ...c, zIndex: index + 1 }));
|
|
|
|
return updated.map((c) => {
|
|
if (idsToUpdate.includes(c.id)) {
|
|
// zIndex는 최소 1로 제한
|
|
return { ...c, zIndex: Math.max(c.zIndex - 1, 1) };
|
|
}
|
|
return c;
|
|
});
|
|
});
|
|
|
|
toast({ title: "레이어 변경", description: "한 단계 뒤로 이동했습니다." });
|
|
}, [selectedComponentId, selectedComponentIds, toast]);
|
|
|
|
// 잠금 관리 함수들
|
|
const toggleLock = useCallback(() => {
|
|
if (!selectedComponentId && selectedComponentIds.length === 0) return;
|
|
|
|
const idsToUpdate =
|
|
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
if (idsToUpdate.includes(c.id)) {
|
|
return { ...c, locked: !c.locked };
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
|
|
const isLocking = components.find((c) => idsToUpdate.includes(c.id))?.locked === false;
|
|
toast({
|
|
title: isLocking ? "잠금 설정" : "잠금 해제",
|
|
description: isLocking ? "선택된 컴포넌트가 잠겼습니다." : "선택된 컴포넌트의 잠금이 해제되었습니다.",
|
|
});
|
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
|
|
|
const lockComponents = useCallback(() => {
|
|
if (!selectedComponentId && selectedComponentIds.length === 0) return;
|
|
|
|
const idsToUpdate =
|
|
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
if (idsToUpdate.includes(c.id)) {
|
|
return { ...c, locked: true };
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "잠금 설정", description: "선택된 컴포넌트가 잠겼습니다." });
|
|
}, [selectedComponentId, selectedComponentIds, toast]);
|
|
|
|
const unlockComponents = useCallback(() => {
|
|
if (!selectedComponentId && selectedComponentIds.length === 0) return;
|
|
|
|
const idsToUpdate =
|
|
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
if (idsToUpdate.includes(c.id)) {
|
|
return { ...c, locked: false };
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
|
|
toast({ title: "잠금 해제", description: "선택된 컴포넌트의 잠금이 해제되었습니다." });
|
|
}, [selectedComponentId, selectedComponentIds, toast]);
|
|
|
|
// 그룹화 함수들
|
|
const groupComponents = useCallback(() => {
|
|
if (selectedComponentIds.length < 2) {
|
|
toast({
|
|
title: "그룹화 불가",
|
|
description: "2개 이상의 컴포넌트를 선택해야 합니다.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 새로운 그룹 ID 생성
|
|
const newGroupId = `group_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
if (selectedComponentIds.includes(c.id)) {
|
|
return { ...c, groupId: newGroupId };
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
|
|
toast({
|
|
title: "그룹화 완료",
|
|
description: `${selectedComponentIds.length}개의 컴포넌트가 그룹화되었습니다.`,
|
|
});
|
|
}, [selectedComponentIds, toast]);
|
|
|
|
const ungroupComponents = useCallback(() => {
|
|
if (!selectedComponentId && selectedComponentIds.length === 0) {
|
|
toast({
|
|
title: "그룹 해제 불가",
|
|
description: "그룹을 해제할 컴포넌트를 선택해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const idsToUngroup =
|
|
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
|
|
|
|
// 선택된 컴포넌트들의 그룹 ID 수집
|
|
const groupIds = new Set<string>();
|
|
components.forEach((c) => {
|
|
if (idsToUngroup.includes(c.id) && c.groupId) {
|
|
groupIds.add(c.groupId);
|
|
}
|
|
});
|
|
|
|
if (groupIds.size === 0) {
|
|
toast({
|
|
title: "그룹 해제 불가",
|
|
description: "선택된 컴포넌트 중 그룹화된 것이 없습니다.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 해당 그룹 ID를 가진 모든 컴포넌트의 그룹 해제
|
|
setComponents((prev) =>
|
|
prev.map((c) => {
|
|
if (c.groupId && groupIds.has(c.groupId)) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { groupId, ...rest } = c;
|
|
return rest as ComponentConfig;
|
|
}
|
|
return c;
|
|
}),
|
|
);
|
|
|
|
toast({
|
|
title: "그룹 해제 완료",
|
|
description: `${groupIds.size}개의 그룹이 해제되었습니다.`,
|
|
});
|
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
|
|
|
// 캔버스 설정 (현재 페이지 기반)
|
|
const canvasWidth = currentPage?.width || 210;
|
|
const canvasHeight = currentPage?.height || 297;
|
|
const pageOrientation = currentPage?.orientation || "portrait";
|
|
const margins = currentPage?.margins || {
|
|
top: 20,
|
|
bottom: 20,
|
|
left: 20,
|
|
right: 20,
|
|
};
|
|
|
|
// 정렬 가이드라인 계산 (캔버스 중앙선 포함)
|
|
const calculateAlignmentGuides = useCallback(
|
|
(draggingId: string, x: number, y: number, width: number, height: number) => {
|
|
const verticalLines: number[] = [];
|
|
const horizontalLines: number[] = [];
|
|
const threshold = 5; // 5px 오차 허용
|
|
|
|
// 캔버스를 픽셀로 변환 (1mm = 3.7795px)
|
|
const canvasWidthPx = canvasWidth * 3.7795;
|
|
const canvasHeightPx = canvasHeight * 3.7795;
|
|
const canvasCenterX = canvasWidthPx / 2;
|
|
const canvasCenterY = canvasHeightPx / 2;
|
|
|
|
// 드래그 중인 컴포넌트의 주요 위치
|
|
const left = x;
|
|
const right = x + width;
|
|
const centerX = x + width / 2;
|
|
const top = y;
|
|
const bottom = y + height;
|
|
const centerY = y + height / 2;
|
|
|
|
// 캔버스 중앙선 체크
|
|
if (Math.abs(centerX - canvasCenterX) < threshold) {
|
|
verticalLines.push(canvasCenterX);
|
|
}
|
|
if (Math.abs(centerY - canvasCenterY) < threshold) {
|
|
horizontalLines.push(canvasCenterY);
|
|
}
|
|
|
|
// 다른 컴포넌트들과 비교
|
|
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, canvasWidth, canvasHeight],
|
|
);
|
|
|
|
// 정렬 가이드라인 초기화
|
|
const clearAlignmentGuides = useCallback(() => {
|
|
setAlignmentGuides({ vertical: [], horizontal: [] });
|
|
}, []);
|
|
|
|
// 페이지 관리 함수들
|
|
const addPage = useCallback(
|
|
(name?: string) => {
|
|
const newPageId = uuidv4();
|
|
const newPage: ReportPage = {
|
|
page_id: newPageId,
|
|
page_name: name || `페이지 ${layoutConfig.pages.length + 1}`,
|
|
page_order: layoutConfig.pages.length,
|
|
width: 210,
|
|
height: 297,
|
|
orientation: "portrait",
|
|
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
|
background_color: "#ffffff",
|
|
components: [],
|
|
};
|
|
|
|
setLayoutConfig((prev) => ({
|
|
pages: [...prev.pages, newPage],
|
|
}));
|
|
|
|
// 새 페이지로 자동 선택
|
|
setCurrentPageId(newPageId);
|
|
|
|
toast({
|
|
title: "페이지 추가",
|
|
description: `${newPage.page_name}이(가) 추가되었습니다.`,
|
|
});
|
|
},
|
|
[layoutConfig.pages.length, toast],
|
|
);
|
|
|
|
const deletePage = useCallback(
|
|
(pageId: string) => {
|
|
if (layoutConfig.pages.length <= 1) {
|
|
toast({
|
|
title: "삭제 불가",
|
|
description: "최소 1개의 페이지는 필요합니다.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const pageIndex = layoutConfig.pages.findIndex((p) => p.page_id === pageId);
|
|
if (pageIndex === -1) return;
|
|
|
|
setLayoutConfig((prev) => ({
|
|
pages: prev.pages.filter((p) => p.page_id !== pageId).map((p, idx) => ({ ...p, page_order: idx })), // 순서 재정렬
|
|
}));
|
|
|
|
// 현재 페이지가 삭제된 경우 첫 번째 페이지로 이동
|
|
if (currentPageId === pageId) {
|
|
const remainingPages = layoutConfig.pages.filter((p) => p.page_id !== pageId);
|
|
setCurrentPageId(remainingPages[0]?.page_id || null);
|
|
}
|
|
|
|
toast({
|
|
title: "페이지 삭제",
|
|
description: "페이지가 삭제되었습니다.",
|
|
});
|
|
},
|
|
[layoutConfig.pages, currentPageId, toast],
|
|
);
|
|
|
|
const duplicatePage = useCallback(
|
|
(pageId: string) => {
|
|
const sourcePage = layoutConfig.pages.find((p) => p.page_id === pageId);
|
|
if (!sourcePage) return;
|
|
|
|
const newPageId = uuidv4();
|
|
const newPage: ReportPage = {
|
|
...sourcePage,
|
|
page_id: newPageId,
|
|
page_name: `${sourcePage.page_name} (복사)`,
|
|
page_order: layoutConfig.pages.length,
|
|
// 컴포넌트도 복제 (새로운 ID 부여)
|
|
components: sourcePage.components.map((comp) => ({
|
|
...comp,
|
|
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
})),
|
|
};
|
|
|
|
setLayoutConfig((prev) => ({
|
|
pages: [...prev.pages, newPage],
|
|
}));
|
|
|
|
setCurrentPageId(newPageId);
|
|
|
|
toast({
|
|
title: "페이지 복제",
|
|
description: `${newPage.page_name}이(가) 생성되었습니다.`,
|
|
});
|
|
},
|
|
[layoutConfig.pages, toast],
|
|
);
|
|
|
|
const reorderPages = useCallback(
|
|
(sourceIndex: number, targetIndex: number) => {
|
|
if (sourceIndex === targetIndex) return;
|
|
|
|
const newPages = [...layoutConfig.pages];
|
|
const [movedPage] = newPages.splice(sourceIndex, 1);
|
|
newPages.splice(targetIndex, 0, movedPage);
|
|
|
|
// page_order 업데이트
|
|
newPages.forEach((page, idx) => {
|
|
page.page_order = idx;
|
|
});
|
|
|
|
setLayoutConfig({ pages: newPages });
|
|
},
|
|
[layoutConfig.pages],
|
|
);
|
|
|
|
const selectPage = useCallback((pageId: string) => {
|
|
setCurrentPageId(pageId);
|
|
// 페이지 전환 시 선택 해제
|
|
setSelectedComponentId(null);
|
|
setSelectedComponentIds([]);
|
|
}, []);
|
|
|
|
const updatePageSettings = useCallback((pageId: string, settings: Partial<ReportPage>) => {
|
|
setLayoutConfig((prev) => ({
|
|
pages: prev.pages.map((page) => (page.page_id === pageId ? { ...page, ...settings } : page)),
|
|
}));
|
|
}, []);
|
|
|
|
// 리포트 및 레이아웃 로드
|
|
const loadLayout = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
// 'new'는 새 리포트 생성 모드 - 기본 페이지 1개 생성
|
|
if (reportId === "new") {
|
|
const defaultPageId = uuidv4();
|
|
const defaultPage: ReportPage = {
|
|
page_id: defaultPageId,
|
|
page_name: "페이지 1",
|
|
page_order: 0,
|
|
width: 210,
|
|
height: 297,
|
|
orientation: "portrait",
|
|
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
|
background_color: "#ffffff",
|
|
components: [],
|
|
};
|
|
setLayoutConfig({ pages: [defaultPage] });
|
|
setCurrentPageId(defaultPageId);
|
|
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 : [],
|
|
externalConnectionId: q.external_connection_id || undefined,
|
|
}));
|
|
setQueries(loadedQueries);
|
|
}
|
|
}
|
|
|
|
// 레이아웃 조회
|
|
try {
|
|
const layoutResponse = await reportApi.getLayout(reportId);
|
|
if (layoutResponse.success && layoutResponse.data) {
|
|
const layoutData = layoutResponse.data;
|
|
setLayout(layoutData);
|
|
|
|
// layoutData가 이미 pages를 가지고 있는지 확인
|
|
if (layoutData.pages && Array.isArray(layoutData.pages) && layoutData.pages.length > 0) {
|
|
// 이미 새 구조 (pages 배열)
|
|
setLayoutConfig({ pages: layoutData.pages });
|
|
setCurrentPageId(layoutData.pages[0].page_id);
|
|
} else {
|
|
// 자동 마이그레이션: 기존 단일 페이지 구조 → 다중 페이지 구조
|
|
const oldComponents = layoutData.components || [];
|
|
|
|
// 기존 구조 감지
|
|
if (oldComponents.length > 0) {
|
|
const migratedPageId = uuidv4();
|
|
const migratedPage: ReportPage = {
|
|
page_id: migratedPageId,
|
|
page_name: "페이지 1",
|
|
page_order: 0,
|
|
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,
|
|
},
|
|
background_color: "#ffffff",
|
|
components: oldComponents,
|
|
};
|
|
|
|
setLayoutConfig({ pages: [migratedPage] });
|
|
setCurrentPageId(migratedPageId);
|
|
} else {
|
|
// 빈 레이아웃 - 기본 페이지 생성
|
|
const defaultPageId = uuidv4();
|
|
const defaultPage: ReportPage = {
|
|
page_id: defaultPageId,
|
|
page_name: "페이지 1",
|
|
page_order: 0,
|
|
width: 210,
|
|
height: 297,
|
|
orientation: "portrait",
|
|
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
|
background_color: "#ffffff",
|
|
components: [],
|
|
};
|
|
setLayoutConfig({ pages: [defaultPage] });
|
|
setCurrentPageId(defaultPageId);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// 레이아웃이 없으면 기본 페이지 생성
|
|
const defaultPageId = uuidv4();
|
|
const defaultPage: ReportPage = {
|
|
page_id: defaultPageId,
|
|
page_name: "페이지 1",
|
|
page_order: 0,
|
|
width: 210,
|
|
height: 297,
|
|
orientation: "portrait",
|
|
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
|
background_color: "#ffffff",
|
|
components: [],
|
|
};
|
|
setLayoutConfig({ pages: [defaultPage] });
|
|
setCurrentPageId(defaultPageId);
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : "리포트를 불러오는데 실패했습니다.";
|
|
toast({
|
|
title: "오류",
|
|
description: errorMessage,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [reportId, toast]);
|
|
|
|
// 초기 로드
|
|
useEffect(() => {
|
|
loadLayout();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [reportId]);
|
|
|
|
// 초기 히스토리 설정
|
|
useEffect(() => {
|
|
if (!isLoading && components.length > 0 && history.length === 0) {
|
|
// 최초 컴포넌트 로드 시 히스토리에 추가
|
|
setHistory([JSON.parse(JSON.stringify(components))]);
|
|
setHistoryIndex(0);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isLoading, components.length]);
|
|
|
|
// 컴포넌트 변경 시 히스토리 저장 (디바운스 적용)
|
|
useEffect(() => {
|
|
if (components.length === 0) return;
|
|
if (isLoading) return; // 로딩 중에는 히스토리 저장 안 함
|
|
if (isUndoRedoing) return; // Undo/Redo 중에는 히스토리 저장 안 함
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
// 현재 히스토리의 마지막 항목과 비교하여 다를 때만 저장
|
|
const lastHistory = history[historyIndex];
|
|
const isDifferent = !lastHistory || JSON.stringify(lastHistory) !== JSON.stringify(components);
|
|
|
|
if (isDifferent) {
|
|
saveToHistory(components);
|
|
}
|
|
}, 300); // 300ms 디바운스
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [components, isUndoRedoing]);
|
|
|
|
// 쿼리 결과 저장
|
|
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) => {
|
|
// 그리드 스냅 적용
|
|
const snappedComponent = snapComponentToGrid(component, gridConfig);
|
|
|
|
// 충돌 감지
|
|
const currentComponents = currentPage?.components || [];
|
|
if (detectGridCollision(snappedComponent, currentComponents, gridConfig)) {
|
|
toast({
|
|
title: "경고",
|
|
description: "다른 컴포넌트와 겹칩니다. 다른 위치에 배치해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setComponents((prev) => [...prev, snappedComponent]);
|
|
},
|
|
[setComponents, gridConfig, currentPage, toast],
|
|
);
|
|
|
|
// 컴포넌트 업데이트 (현재 페이지에서)
|
|
const updateComponent = useCallback(
|
|
(id: string, updates: Partial<ComponentConfig>) => {
|
|
if (!currentPageId) return;
|
|
|
|
setLayoutConfig((prev) => {
|
|
let hasCollision = false;
|
|
|
|
const newPages = prev.pages.map((page) => {
|
|
if (page.page_id !== currentPageId) return page;
|
|
|
|
const newComponents = page.components.map((comp) => {
|
|
if (comp.id !== id) return comp;
|
|
|
|
// 업데이트된 컴포넌트에 그리드 스냅 적용
|
|
const updated = { ...comp, ...updates };
|
|
|
|
// 위치나 크기가 변경된 경우에만 스냅 적용 및 충돌 감지
|
|
if (
|
|
updates.x !== undefined ||
|
|
updates.y !== undefined ||
|
|
updates.width !== undefined ||
|
|
updates.height !== undefined
|
|
) {
|
|
const snapped = snapComponentToGrid(updated, gridConfig);
|
|
|
|
// 충돌 감지 (자신을 제외한 다른 컴포넌트와)
|
|
const otherComponents = page.components.filter((c) => c.id !== id);
|
|
if (detectGridCollision(snapped, otherComponents, gridConfig)) {
|
|
hasCollision = true;
|
|
return comp; // 충돌 시 원래 상태 유지
|
|
}
|
|
|
|
return snapped;
|
|
}
|
|
|
|
return updated;
|
|
});
|
|
|
|
return {
|
|
...page,
|
|
components: newComponents,
|
|
};
|
|
});
|
|
|
|
// 충돌이 감지된 경우 토스트 메시지 표시 및 업데이트 취소
|
|
if (hasCollision) {
|
|
toast({
|
|
title: "경고",
|
|
description: "다른 컴포넌트와 겹칩니다.",
|
|
variant: "destructive",
|
|
});
|
|
return prev;
|
|
}
|
|
|
|
return { pages: newPages };
|
|
});
|
|
},
|
|
[currentPageId, gridConfig, toast],
|
|
);
|
|
|
|
// 컴포넌트 삭제 (현재 페이지에서)
|
|
const removeComponent = useCallback(
|
|
(id: string) => {
|
|
if (!currentPageId) return;
|
|
|
|
setLayoutConfig((prev) => ({
|
|
pages: prev.pages.map((page) =>
|
|
page.page_id === currentPageId
|
|
? { ...page, components: page.components.filter((comp) => comp.id !== id) }
|
|
: page,
|
|
),
|
|
}));
|
|
|
|
if (selectedComponentId === id) {
|
|
setSelectedComponentId(null);
|
|
}
|
|
// 다중 선택에서도 제거
|
|
setSelectedComponentIds((prev) => prev.filter((compId) => compId !== id));
|
|
},
|
|
[currentPageId, selectedComponentId],
|
|
);
|
|
|
|
// 컴포넌트 선택 (단일/다중)
|
|
const selectComponent = useCallback((id: string | null, isMultiSelect = false) => {
|
|
if (id === null) {
|
|
// 선택 해제
|
|
setSelectedComponentId(null);
|
|
setSelectedComponentIds([]);
|
|
return;
|
|
}
|
|
|
|
if (isMultiSelect) {
|
|
// Ctrl+클릭: 다중 선택 토글
|
|
setSelectedComponentIds((prev) => {
|
|
if (prev.includes(id)) {
|
|
// 이미 선택되어 있으면 제거
|
|
const newSelection = prev.filter((compId) => compId !== id);
|
|
setSelectedComponentId(newSelection.length > 0 ? newSelection[0] : null);
|
|
return newSelection;
|
|
} else {
|
|
// 선택 추가
|
|
setSelectedComponentId(id);
|
|
return [...prev, id];
|
|
}
|
|
});
|
|
} else {
|
|
// 일반 클릭: 단일 선택
|
|
setSelectedComponentId(id);
|
|
setSelectedComponentIds([id]);
|
|
}
|
|
}, []);
|
|
|
|
// 레이아웃 업데이트
|
|
const updateLayout = useCallback(
|
|
(updates: Partial<ReportLayout>) => {
|
|
setLayout((prev) => (prev ? { ...prev, ...updates } : null));
|
|
|
|
// 현재 페이지 설정 업데이트
|
|
if (!currentPageId) return;
|
|
|
|
const pageUpdates: Partial<ReportPage> = {};
|
|
if (updates.canvas_width !== undefined) pageUpdates.width = updates.canvas_width;
|
|
if (updates.canvas_height !== undefined) pageUpdates.height = updates.canvas_height;
|
|
if (updates.page_orientation !== undefined)
|
|
pageUpdates.orientation = updates.page_orientation as "portrait" | "landscape";
|
|
|
|
if (
|
|
updates.margin_top !== undefined ||
|
|
updates.margin_bottom !== undefined ||
|
|
updates.margin_left !== undefined ||
|
|
updates.margin_right !== undefined
|
|
) {
|
|
pageUpdates.margins = {
|
|
top: updates.margin_top ?? currentPage?.margins.top ?? 20,
|
|
bottom: updates.margin_bottom ?? currentPage?.margins.bottom ?? 20,
|
|
left: updates.margin_left ?? currentPage?.margins.left ?? 20,
|
|
right: updates.margin_right ?? currentPage?.margins.right ?? 20,
|
|
};
|
|
}
|
|
|
|
if (Object.keys(pageUpdates).length > 0) {
|
|
updatePageSettings(currentPageId, pageUpdates);
|
|
}
|
|
},
|
|
[currentPageId, currentPage, updatePageSettings],
|
|
);
|
|
|
|
// 레이아웃 저장
|
|
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, {
|
|
layoutConfig, // 페이지 기반 구조
|
|
queries: queries.map((q) => ({
|
|
...q,
|
|
externalConnectionId: q.externalConnectionId || undefined,
|
|
})),
|
|
});
|
|
|
|
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, layoutConfig, queries, toast, loadLayout]);
|
|
|
|
// 템플릿 적용
|
|
const applyTemplate = useCallback(
|
|
async (templateId: string) => {
|
|
try {
|
|
// 기존 컴포넌트가 있으면 확인
|
|
if (components.length > 0) {
|
|
if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// DB에서 템플릿 조회 (시스템 템플릿 또는 사용자 정의 템플릿)
|
|
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("템플릿을 찾을 수 없습니다.");
|
|
}
|
|
|
|
// 템플릿 데이터 확인용 로그
|
|
console.log("===== 선택된 템플릿 =====");
|
|
console.log("Template ID:", template.template_id);
|
|
console.log("Template Name:", template.template_name_kor);
|
|
console.log("Layout Config:", template.layout_config);
|
|
console.log("Default Queries:", template.default_queries);
|
|
console.log("========================");
|
|
|
|
// 템플릿 데이터 파싱 및 적용
|
|
let layoutConfig: { components?: ComponentConfig[] } | null = null;
|
|
let defaultQueries: unknown[] = [];
|
|
|
|
// layout_config 파싱 (안전하게)
|
|
try {
|
|
if (template.layout_config) {
|
|
layoutConfig =
|
|
typeof template.layout_config === "string" ? JSON.parse(template.layout_config) : template.layout_config;
|
|
}
|
|
} catch (e) {
|
|
console.error("layout_config 파싱 오류:", e);
|
|
layoutConfig = { components: [] };
|
|
}
|
|
|
|
// default_queries 파싱 (안전하게)
|
|
try {
|
|
if (template.default_queries) {
|
|
defaultQueries =
|
|
typeof template.default_queries === "string"
|
|
? JSON.parse(template.default_queries)
|
|
: template.default_queries;
|
|
}
|
|
} catch (e) {
|
|
console.error("default_queries 파싱 오류:", e);
|
|
defaultQueries = [];
|
|
}
|
|
|
|
// layoutConfig가 없으면 빈 구조로 초기화
|
|
if (!layoutConfig || typeof layoutConfig !== "object") {
|
|
layoutConfig = { components: [] };
|
|
}
|
|
|
|
// 컴포넌트 적용 (ID 재생성)
|
|
const templateComponents = Array.isArray(layoutConfig.components) ? layoutConfig.components : [];
|
|
const newComponents = templateComponents.map((comp: ComponentConfig) => ({
|
|
...comp,
|
|
id: `comp-${Date.now()}-${Math.random()}`,
|
|
}));
|
|
|
|
// 쿼리 적용 (ID 재생성)
|
|
const templateQueries = Array.isArray(defaultQueries) ? defaultQueries : [];
|
|
const newQueries = templateQueries
|
|
.filter((q): q is Record<string, unknown> => typeof q === "object" && q !== null)
|
|
.map((q) => ({
|
|
id: `query-${Date.now()}-${Math.random()}`,
|
|
name: (q.name as string) || "",
|
|
type: (q.type as "MASTER" | "DETAIL") || "MASTER",
|
|
sqlQuery: (q.sqlQuery as string) || "",
|
|
parameters: Array.isArray(q.parameters) ? (q.parameters as string[]) : [],
|
|
externalConnectionId: (q.externalConnectionId as number | null) || null,
|
|
}));
|
|
|
|
setComponents(newComponents);
|
|
setQueries(newQueries);
|
|
|
|
const message =
|
|
newComponents.length === 0
|
|
? "템플릿이 적용되었습니다. (빈 템플릿)"
|
|
: `템플릿이 적용되었습니다. (컴포넌트 ${newComponents.length}개, 쿼리 ${newQueries.length}개)`;
|
|
|
|
toast({
|
|
title: "성공",
|
|
description: message,
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : "템플릿 적용에 실패했습니다.";
|
|
toast({
|
|
title: "오류",
|
|
description: errorMessage,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[toast],
|
|
);
|
|
|
|
const value: ReportDesignerContextType = {
|
|
reportId,
|
|
reportDetail,
|
|
layout,
|
|
|
|
// 페이지 관리
|
|
layoutConfig,
|
|
currentPageId,
|
|
currentPage,
|
|
addPage,
|
|
deletePage,
|
|
duplicatePage,
|
|
reorderPages,
|
|
selectPage,
|
|
updatePageSettings,
|
|
|
|
// 컴포넌트 (현재 페이지)
|
|
components,
|
|
queries,
|
|
setQueries,
|
|
queryResults,
|
|
setQueryResult,
|
|
getQueryResult,
|
|
selectedComponentId,
|
|
selectedComponentIds,
|
|
isLoading,
|
|
isSaving,
|
|
addComponent,
|
|
updateComponent,
|
|
removeComponent,
|
|
selectComponent,
|
|
updateLayout,
|
|
saveLayout,
|
|
loadLayout,
|
|
applyTemplate,
|
|
canvasWidth,
|
|
canvasHeight,
|
|
pageOrientation,
|
|
margins,
|
|
// 레이아웃 도구
|
|
gridSize,
|
|
setGridSize,
|
|
showGrid,
|
|
setShowGrid,
|
|
snapToGrid,
|
|
setSnapToGrid,
|
|
snapValueToGrid,
|
|
// 정렬 가이드라인
|
|
alignmentGuides,
|
|
calculateAlignmentGuides,
|
|
clearAlignmentGuides,
|
|
// 복사/붙여넣기
|
|
copyComponents,
|
|
pasteComponents,
|
|
// Undo/Redo
|
|
undo,
|
|
redo,
|
|
canUndo: historyIndex > 0,
|
|
canRedo: historyIndex < history.length - 1,
|
|
// 정렬 기능
|
|
alignLeft,
|
|
alignRight,
|
|
alignTop,
|
|
alignBottom,
|
|
alignCenterHorizontal,
|
|
alignCenterVertical,
|
|
distributeHorizontal,
|
|
distributeVertical,
|
|
makeSameWidth,
|
|
makeSameHeight,
|
|
makeSameSize,
|
|
// 레이어 관리
|
|
bringToFront,
|
|
sendToBack,
|
|
bringForward,
|
|
sendBackward,
|
|
// 잠금 관리
|
|
toggleLock,
|
|
lockComponents,
|
|
unlockComponents,
|
|
// 눈금자 표시
|
|
showRuler,
|
|
setShowRuler,
|
|
// 그룹화
|
|
groupComponents,
|
|
ungroupComponents,
|
|
// 그리드 관리
|
|
gridConfig,
|
|
updateGridConfig,
|
|
};
|
|
|
|
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;
|
|
}
|