ERP-node/frontend/contexts/ReportDesignerContext.tsx

1242 lines
40 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[];
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;
components: ComponentConfig[];
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;
// 캔버스 설정
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 [components, setComponents] = useState<ComponentConfig[]>([]);
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 [gridSize, setGridSize] = useState(10); // Grid Snap 크기 (px)
const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부
const [snapToGrid, setSnapToGrid] = useState(true); // Grid Snap 활성화
// 눈금자 표시
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, 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 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 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("레이아웃 없음, 기본값 사용");
}
// 쿼리 조회는 이미 위에서 처리됨 (reportResponse.data.queries)
} 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) => {
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, 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 (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(
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,
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,
};
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;
}