ERP-node/frontend/contexts/ReportDesignerContext.tsx

485 lines
16 KiB
TypeScript
Raw Normal View History

2025-10-01 12:00:13 +09:00
"use client";
/**
* @module ReportDesignerContext
* @description Context.
*
* (thin orchestrator), .
*
* :
* - useSelectionState (/)
* - usePageManager CRUD
* - useQueryManager
* - useUIState /// UI
* - useHistoryManager Undo/Redo
* - useClipboardActions ///
* - useAlignmentActions · ·
* - useLayerActions ··
* - useLayoutIO / 릿
*/
import {
createContext,
useContext,
useState,
useCallback,
ReactNode,
useEffect,
useRef,
} from "react";
import {
ComponentConfig,
ReportDetail,
ReportLayout,
ReportPage,
ReportLayoutConfig,
WatermarkConfig,
} from "@/types/report";
2025-10-01 12:00:13 +09:00
import { useToast } from "@/hooks/use-toast";
// 각 역할별 훅
import { useSelectionState } from "./report-designer/useSelectionState";
import { usePageManager } from "./report-designer/usePageManager";
import { useQueryManager } from "./report-designer/useQueryManager";
import { useUIState } from "./report-designer/useUIState";
import { useHistoryManager } from "./report-designer/useHistoryManager";
import { useClipboardActions } from "./report-designer/useClipboardActions";
import { useAlignmentActions } from "./report-designer/useAlignmentActions";
import { useLayerActions } from "./report-designer/useLayerActions";
import { useLayoutIO } from "./report-designer/useLayoutIO";
2025-10-01 14:05:06 +09:00
// 공유 타입
export type { ReportQuery, QueryResult } from "./report-designer/types";
// ─────────────────────────────────────────────
// Context 타입 정의
// ─────────────────────────────────────────────
2025-10-01 12:00:13 +09:00
interface ReportDesignerContextType {
reportId: string;
reportDetail: ReportDetail | null;
layout: ReportLayout | null;
// 페이지 관리
layoutConfig: ReportLayoutConfig;
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;
updateWatermark: (watermark: WatermarkConfig | undefined) => void;
// 컴포넌트 (현재 페이지)
components: ComponentConfig[];
2025-10-01 12:00:13 +09:00
selectedComponentId: string | null;
selectedComponentIds: string[];
2025-10-01 12:00:13 +09:00
isLoading: boolean;
isSaving: boolean;
addComponent: (component: ComponentConfig) => void;
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
removeComponent: (id: string) => void;
selectComponent: (id: string | null, isMultiSelect?: boolean) => void;
selectMultipleComponents: (ids: string[]) => void;
2025-10-01 12:00:13 +09:00
// 레이아웃 관리
updateLayout: (updates: Partial<ReportLayout>) => void;
saveLayout: () => Promise<void>;
loadLayout: () => Promise<void>;
applyTemplate: (templateId: string) => Promise<void>;
2025-10-01 12:00:13 +09:00
// 캔버스 설정 (현재 페이지 기반)
2025-10-01 12:00:13 +09:00
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
margins: { top: number; bottom: number; left: number; right: number };
// UI 도구
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;
// 복사/붙여넣기
copyComponents: () => void;
pasteComponents: () => void;
duplicateComponents: () => void;
copyStyles: () => void;
pasteStyles: () => void;
duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[];
fitSelectedToContent: () => void;
// Undo/Redo
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
2025-10-01 16:06:47 +09:00
// 정렬 기능
alignLeft: () => void;
alignRight: () => void;
alignTop: () => void;
alignBottom: () => void;
alignCenterHorizontal: () => void;
alignCenterVertical: () => void;
distributeHorizontal: () => void;
distributeVertical: () => void;
makeSameWidth: () => void;
makeSameHeight: () => void;
makeSameSize: () => void;
2025-10-01 16:17:41 +09:00
// 레이어 관리
bringToFront: () => void;
sendToBack: () => void;
bringForward: () => void;
sendBackward: () => void;
2025-10-01 16:23:20 +09:00
toggleLock: () => void;
lockComponents: () => void;
unlockComponents: () => void;
2025-10-01 16:27:05 +09:00
// 눈금자 표시
showRuler: boolean;
setShowRuler: (show: boolean) => void;
// 캔버스 줌
zoom: number;
setZoom: (zoom: number) => void;
fitTrigger: number;
fitToScreen: () => void;
// 그룹화
groupComponents: () => void;
ungroupComponents: () => void;
// 메뉴 연결
menuObjids: number[];
setMenuObjids: (menuObjids: number[]) => void;
saveLayoutWithMenus: (menuObjids: number[]) => Promise<void>;
// 쿼리 관리
queries: import("./report-designer/types").ReportQuery[];
setQueries: (queries: import("./report-designer/types").ReportQuery[]) => void;
queryResults: import("./report-designer/types").QueryResult[];
setQueryResult: (queryId: string, fields: string[], rows: Record<string, unknown>[]) => void;
getQueryResult: (queryId: string) => import("./report-designer/types").QueryResult | null;
// 인캔버스 설정 모달
componentModalTargetId: string | null;
openComponentModal: (componentId: string) => void;
closeComponentModal: () => void;
// 패널 접기/펼치기
isPageListCollapsed: boolean;
setIsPageListCollapsed: (v: boolean) => void;
isLeftPanelCollapsed: boolean;
setIsLeftPanelCollapsed: (v: boolean) => void;
isRightPanelCollapsed: boolean;
setIsRightPanelCollapsed: (v: boolean) => void;
2025-10-01 12:00:13 +09:00
}
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
// ─────────────────────────────────────────────
// Provider
// ─────────────────────────────────────────────
export function ReportDesignerProvider({
reportId,
children,
}: {
reportId: string;
children: ReactNode;
}) {
const { toast } = useToast();
// ── 공유 레이아웃 상태 (여러 훅이 공유) ──
const [layoutConfig, setLayoutConfig] = useState<ReportLayoutConfig>({ pages: [] });
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
2025-10-01 12:00:13 +09:00
const [reportDetail, setReportDetail] = useState<ReportDetail | null>(null);
const [layout, setLayout] = useState<ReportLayout | null>(null);
const [menuObjids, setMenuObjids] = useState<number[]>([]);
2025-10-01 12:00:13 +09:00
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// currentPageId를 ref로 유지하여 클로저 내에서도 최신 값을 읽을 수 있게 한다
2025-10-02 14:13:11 +09:00
const currentPageIdRef = useRef<string | null>(currentPageId);
useEffect(() => { currentPageIdRef.current = currentPageId; }, [currentPageId]);
// ── 파생 값 ──
const currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId);
const components: ComponentConfig[] = Array.isArray(currentPage?.components) ? currentPage.components : [];
2025-10-02 14:13:11 +09:00
// 현재 페이지의 컴포넌트를 업데이트하는 공유 헬퍼 (ref 기반으로 클로저 문제 회피)
const setComponents = useCallback(
(updater: ComponentConfig[] | ((prev: ComponentConfig[]) => ComponentConfig[])) => {
2025-10-02 14:13:11 +09:00
setLayoutConfig((prev) => {
const pageId = currentPageIdRef.current;
if (!pageId) return prev;
const pageIndex = prev.pages.findIndex((p) => p.page_id === pageId);
if (pageIndex === -1) return prev;
const page = prev.pages[pageIndex];
const newComponents = typeof updater === "function" ? updater(page.components) : updater;
2025-10-02 14:13:11 +09:00
const newPages = [...prev.pages];
newPages[pageIndex] = { ...page, components: newComponents };
return { ...prev, pages: newPages };
2025-10-02 14:13:11 +09:00
});
},
2025-10-02 14:13:11 +09:00
[], // ref를 사용하므로 의존성 배열 비움
);
// 캔버스 치수 (현재 페이지 기반, 단위: mm)
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 };
// ── 각 역할별 훅 조합 ──
2025-10-01 16:17:41 +09:00
const selection = useSelectionState();
2025-10-01 16:17:41 +09:00
const pageManager = usePageManager({
layoutConfig,
setLayoutConfig,
currentPageId,
setCurrentPageId,
clearSelection: selection.clearSelection,
toast,
});
const queryManager = useQueryManager();
const uiState = useUIState(components, { canvasWidth, canvasHeight });
const historyManager = useHistoryManager(components, setComponents, isLoading, toast);
const clipboard = useClipboardActions({
components,
selectedComponentId: selection.selectedComponentId,
selectedComponentIds: selection.selectedComponentIds,
setComponents,
currentPage,
currentPageId,
setLayoutConfig,
snapToGrid: uiState.snapToGrid,
gridSize: uiState.gridSize,
toast,
});
const alignment = useAlignmentActions({
components,
selectedComponentIds: selection.selectedComponentIds,
setComponents,
toast,
});
2025-10-01 12:00:13 +09:00
const layer = useLayerActions({
components,
selectedComponentId: selection.selectedComponentId,
selectedComponentIds: selection.selectedComponentIds,
setComponents,
toast,
});
2025-10-01 12:00:13 +09:00
const layoutIO = useLayoutIO({
reportId,
layoutConfig,
queries: queryManager.queries,
menuObjids,
setMenuObjids,
setIsLoading,
setIsSaving,
setReportDetail,
setLayout,
setLayoutConfig,
setCurrentPageId,
setQueries: queryManager.setQueries,
setComponents,
currentPageIdRef,
toast,
});
2025-10-01 12:00:13 +09:00
// ── 초기 로드 ──
2025-10-01 12:00:13 +09:00
useEffect(() => {
layoutIO.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
// ── 초기 히스토리 설정 (레이아웃 로드 완료 시) ──
useEffect(() => {
if (!isLoading && components.length > 0) {
historyManager.initHistory(components);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]);
// ── 컴포넌트 관리 (현재 페이지 직접 조작) ──
/** 현재 페이지에 새 컴포넌트를 추가한다. */
const addComponent = useCallback(
(component: ComponentConfig) => setComponents((prev) => [...prev, component]),
2025-10-13 19:15:52 +09:00
[setComponents],
);
2025-10-01 12:00:13 +09:00
/** 현재 페이지에서 특정 컴포넌트의 속성을 업데이트한다. */
const updateComponent = useCallback(
(id: string, updates: Partial<ComponentConfig>) => {
if (!currentPageId) return;
2025-10-13 19:15:52 +09:00
setLayoutConfig((prev) => ({
pages: prev.pages.map((page) =>
page.page_id === currentPageId
? { ...page, components: page.components.map((c) => (c.id === id ? { ...c, ...updates } : c)) }
2025-10-13 19:15:52 +09:00
: page,
),
}));
},
2025-10-13 19:15:52 +09:00
[currentPageId],
);
2025-10-01 12:00:13 +09:00
/** 현재 페이지에서 특정 컴포넌트를 삭제하고 선택 상태를 정리한다. */
2025-10-01 12:00:13 +09:00
const removeComponent = useCallback(
(id: string) => {
if (!currentPageId) return;
setLayoutConfig((prev) => ({
pages: prev.pages.map((page) =>
page.page_id === currentPageId
? { ...page, components: page.components.filter((c) => c.id !== id) }
: page,
),
}));
if (selection.selectedComponentId === id) selection.setSelectedComponentId(null);
selection.setSelectedComponentIds((prev) => prev.filter((compId) => compId !== id));
2025-10-01 12:00:13 +09:00
},
[currentPageId, selection],
2025-10-01 12:00:13 +09:00
);
/**
* (canvas_width, page_orientation ) .
* .
*/
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) {
pageManager.updatePageSettings(currentPageId, pageUpdates);
}
},
[currentPageId, currentPage, pageManager, setLayout],
);
2025-10-01 12:00:13 +09:00
// ─────────────────────────────────────────────
// Context 값 조합
// ─────────────────────────────────────────────
2025-10-01 14:05:06 +09:00
2025-10-01 12:00:13 +09:00
const value: ReportDesignerContextType = {
reportId,
reportDetail,
layout,
// 페이지 관리
layoutConfig,
currentPageId,
currentPage,
...pageManager,
// 컴포넌트 상태 및 관리
2025-10-01 12:00:13 +09:00
components,
isLoading,
isSaving,
addComponent,
updateComponent,
removeComponent,
updateLayout,
// 선택 상태
selectedComponentId: selection.selectedComponentId,
selectedComponentIds: selection.selectedComponentIds,
selectComponent: selection.selectComponent,
selectMultipleComponents: selection.selectMultipleComponents,
// 레이아웃 I/O
saveLayout: layoutIO.saveLayout,
loadLayout: layoutIO.loadLayout,
applyTemplate: layoutIO.applyTemplate,
saveLayoutWithMenus: layoutIO.saveLayoutWithMenus,
// 캔버스 설정
2025-10-01 12:00:13 +09:00
canvasWidth,
canvasHeight,
pageOrientation,
margins,
// UI 상태
...uiState,
// 복사/붙여넣기
...clipboard,
// Undo/Redo
undo: historyManager.undo,
redo: historyManager.redo,
canUndo: historyManager.canUndo,
canRedo: historyManager.canRedo,
// 정렬
...alignment,
// 레이어/잠금/그룹
...layer,
// 메뉴 연결
menuObjids,
setMenuObjids,
// 쿼리 관리
...queryManager,
2025-10-01 12:00:13 +09:00
};
return (
<ReportDesignerContext.Provider value={value}>
{children}
</ReportDesignerContext.Provider>
);
2025-10-01 12:00:13 +09:00
}
/**
* Context를 .
* ReportDesignerProvider .
*/
2025-10-01 12:00:13 +09:00
export function useReportDesigner() {
const context = useContext(ReportDesignerContext);
if (context === undefined) {
throw new Error("useReportDesigner must be used within a ReportDesignerProvider");
}
return context;
}