diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index f826a86a..7e1108c3 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/bwip-js": "^3.2.3", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -3214,6 +3215,16 @@ "@types/node": "*" } }, + "node_modules/@types/bwip-js": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz", + "integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/compression": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index e9ce3729..b1bfa319 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/bwip-js": "^3.2.3", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx index 16eca3cd..b30bc1f4 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx @@ -4,7 +4,7 @@ * DELETE 액션 노드 속성 편집 */ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -24,6 +24,12 @@ interface DeleteActionPropertiesProps { data: DeleteActionNodeData; } +// 소스 필드 타입 +interface SourceField { + name: string; + label?: string; +} + const OPERATORS = [ { value: "EQUALS", label: "=" }, { value: "NOT_EQUALS", label: "≠" }, @@ -34,7 +40,7 @@ const OPERATORS = [ ] as const; export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) { - const { updateNode, getExternalConnectionsCache } = useFlowEditorStore(); + const { updateNode, getExternalConnectionsCache, nodes, edges } = useFlowEditorStore(); // 🔥 타겟 타입 상태 const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal"); @@ -43,6 +49,10 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP const [targetTable, setTargetTable] = useState(data.targetTable); const [whereConditions, setWhereConditions] = useState(data.whereConditions || []); + // 🆕 소스 필드 목록 (연결된 입력 노드에서 가져오기) + const [sourceFields, setSourceFields] = useState([]); + const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState([]); + // 🔥 외부 DB 관련 상태 const [externalConnections, setExternalConnections] = useState([]); const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false); @@ -124,8 +134,106 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP // whereConditions 변경 시 fieldOpenState 초기화 useEffect(() => { setFieldOpenState(new Array(whereConditions.length).fill(false)); + setSourceFieldsOpenState(new Array(whereConditions.length).fill(false)); }, [whereConditions.length]); + // 🆕 소스 필드 로딩 (연결된 입력 노드에서) + const loadSourceFields = useCallback(async () => { + // 현재 노드로 연결된 엣지 찾기 + const incomingEdges = edges.filter((e) => e.target === nodeId); + console.log("🔍 DELETE 노드 연결 엣지:", incomingEdges); + + if (incomingEdges.length === 0) { + console.log("⚠️ 연결된 소스 노드가 없습니다"); + setSourceFields([]); + return; + } + + const fields: SourceField[] = []; + const processedFields = new Set(); + + for (const edge of incomingEdges) { + const sourceNode = nodes.find((n) => n.id === edge.source); + if (!sourceNode) continue; + + console.log("🔗 소스 노드:", sourceNode.type, sourceNode.data); + + // 소스 노드 타입에 따라 필드 추출 + if (sourceNode.type === "trigger" && sourceNode.data.tableName) { + // 트리거 노드: 테이블 컬럼 조회 + try { + const columns = await tableTypeApi.getColumns(sourceNode.data.tableName); + if (columns && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (!processedFields.has(colName)) { + processedFields.add(colName); + fields.push({ + name: colName, + label: col.columnLabel || col.column_label || colName, + }); + } + }); + } + } catch (error) { + console.error("트리거 노드 컬럼 로딩 실패:", error); + } + } else if (sourceNode.type === "tableSource" && sourceNode.data.tableName) { + // 테이블 소스 노드 + try { + const columns = await tableTypeApi.getColumns(sourceNode.data.tableName); + if (columns && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (!processedFields.has(colName)) { + processedFields.add(colName); + fields.push({ + name: colName, + label: col.columnLabel || col.column_label || colName, + }); + } + }); + } + } catch (error) { + console.error("테이블 소스 노드 컬럼 로딩 실패:", error); + } + } else if (sourceNode.type === "condition") { + // 조건 노드: 연결된 이전 노드에서 필드 가져오기 + const conditionIncomingEdges = edges.filter((e) => e.target === sourceNode.id); + for (const condEdge of conditionIncomingEdges) { + const condSourceNode = nodes.find((n) => n.id === condEdge.source); + if (condSourceNode?.type === "trigger" && condSourceNode.data.tableName) { + try { + const columns = await tableTypeApi.getColumns(condSourceNode.data.tableName); + if (columns && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (!processedFields.has(colName)) { + processedFields.add(colName); + fields.push({ + name: colName, + label: col.columnLabel || col.column_label || colName, + }); + } + }); + } + } catch (error) { + console.error("조건 노드 소스 컬럼 로딩 실패:", error); + } + } + } + } + } + + console.log("✅ DELETE 노드 소스 필드:", fields); + setSourceFields(fields); + }, [nodeId, nodes, edges]); + + // 소스 필드 로딩 + useEffect(() => { + loadSourceFields(); + }, [loadSourceFields]); + const loadExternalConnections = async () => { try { setExternalConnectionsLoading(true); @@ -239,22 +347,41 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP field: "", operator: "EQUALS", value: "", + sourceField: undefined, + staticValue: undefined, }, ]; setWhereConditions(newConditions); setFieldOpenState(new Array(newConditions.length).fill(false)); + setSourceFieldsOpenState(new Array(newConditions.length).fill(false)); + + // 자동 저장 + updateNode(nodeId, { + whereConditions: newConditions, + }); }; const handleRemoveCondition = (index: number) => { const newConditions = whereConditions.filter((_, i) => i !== index); setWhereConditions(newConditions); setFieldOpenState(new Array(newConditions.length).fill(false)); + setSourceFieldsOpenState(new Array(newConditions.length).fill(false)); + + // 자동 저장 + updateNode(nodeId, { + whereConditions: newConditions, + }); }; const handleConditionChange = (index: number, field: string, value: any) => { const newConditions = [...whereConditions]; newConditions[index] = { ...newConditions[index], [field]: value }; setWhereConditions(newConditions); + + // 자동 저장 + updateNode(nodeId, { + whereConditions: newConditions, + }); }; const handleSave = () => { @@ -840,14 +967,125 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP + {/* 🆕 소스 필드 - Combobox */}
- + + {sourceFields.length > 0 ? ( + { + const newState = [...sourceFieldsOpenState]; + newState[index] = open; + setSourceFieldsOpenState(newState); + }} + > + + + + + + + + 필드를 찾을 수 없습니다. + + { + handleConditionChange(index, "sourceField", undefined); + const newState = [...sourceFieldsOpenState]; + newState[index] = false; + setSourceFieldsOpenState(newState); + }} + className="text-xs text-gray-400 sm:text-sm" + > + + 없음 (정적 값 사용) + + {sourceFields.map((field) => ( + { + handleConditionChange(index, "sourceField", currentValue); + const newState = [...sourceFieldsOpenState]; + newState[index] = false; + setSourceFieldsOpenState(newState); + }} + className="text-xs sm:text-sm" + > + +
+ {field.label || field.name} + {field.label && field.label !== field.name && ( + + {field.name} + + )} +
+
+ ))} +
+
+
+
+
+ ) : ( +
+ 연결된 소스 노드가 없습니다 +
+ )} +

소스 데이터에서 값을 가져올 필드

+
+ + {/* 정적 값 */} +
+ handleConditionChange(index, "value", e.target.value)} - placeholder="비교 값" + value={condition.staticValue || condition.value || ""} + onChange={(e) => { + handleConditionChange(index, "staticValue", e.target.value || undefined); + handleConditionChange(index, "value", e.target.value); + }} + placeholder="비교할 고정 값" className="mt-1 h-8 text-xs" /> +

소스 필드가 비어있을 때 사용됩니다

diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 967e43ca..9b5b7aea 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -34,7 +34,11 @@ const getApiBaseUrl = (): string => { export const API_BASE_URL = getApiBaseUrl(); // 이미지 URL을 완전한 URL로 변환하는 함수 +// 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지 export const getFullImageUrl = (imagePath: string): string => { + // 빈 값 체크 + if (!imagePath) return ""; + // 이미 전체 URL인 경우 그대로 반환 if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { return imagePath; @@ -42,8 +46,29 @@ export const getFullImageUrl = (imagePath: string): string => { // /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가 if (imagePath.startsWith("/uploads")) { - const baseUrl = API_BASE_URL.replace("/api", ""); // /api 제거 - return `${baseUrl}${imagePath}`; + // 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때) + if (typeof window !== "undefined") { + const currentHost = window.location.hostname; + + // 프로덕션 환경: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return `https://api.vexplor.com${imagePath}`; + } + + // 로컬 개발환경 + if (currentHost === "localhost" || currentHost === "127.0.0.1") { + return `http://localhost:8080${imagePath}`; + } + } + + // SSR 또는 알 수 없는 환경에서는 API_BASE_URL 사용 (fallback) + const baseUrl = API_BASE_URL.replace("/api", ""); + if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { + return `${baseUrl}${imagePath}`; + } + + // 최종 fallback + return imagePath; } return imagePath; diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index 4908b381..e6cab8ae 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -247,10 +247,40 @@ export const getFileDownloadUrl = (fileId: string): string => { /** * 직접 파일 경로 URL 생성 (정적 파일 서빙) + * 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지 */ export const getDirectFileUrl = (filePath: string): string => { + // 빈 값 체크 + if (!filePath) return ""; + + // 이미 전체 URL인 경우 그대로 반환 + if (filePath.startsWith("http://") || filePath.startsWith("https://")) { + return filePath; + } + + // 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때) + if (typeof window !== "undefined") { + const currentHost = window.location.hostname; + + // 프로덕션 환경: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return `https://api.vexplor.com${filePath}`; + } + + // 로컬 개발환경 + if (currentHost === "localhost" || currentHost === "127.0.0.1") { + return `http://localhost:8080${filePath}`; + } + } + + // SSR 또는 알 수 없는 환경에서는 환경변수 사용 (fallback) const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || ""; - return `${baseUrl}${filePath}`; + if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { + return `${baseUrl}${filePath}`; + } + + // 최종 fallback + return filePath; }; /** diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 9ca202ed..e28e1755 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -88,9 +88,6 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인 // 🆕 연관 데이터 버튼 컴포넌트 import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시 -// 🆕 피벗 그리드 컴포넌트 -import "./pivot-grid/PivotGridRenderer"; // 다차원 데이터 분석 피벗 테이블 - /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/pivot-grid/PLAN.md b/frontend/lib/registry/components/pivot-grid/PLAN.md new file mode 100644 index 00000000..7b96ab38 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PLAN.md @@ -0,0 +1,159 @@ +# PivotGrid 컴포넌트 전체 구현 계획 + +## 개요 +DevExtreme PivotGrid (https://js.devexpress.com/React/Demos/WidgetsGallery/Demo/PivotGrid/Overview/FluentBlueLight/) 수준의 다차원 데이터 분석 컴포넌트 구현 + +## 현재 상태: ✅ 모든 기능 구현 완료! + +--- + +## 구현된 기능 목록 + +### 1. 기본 피벗 테이블 ✅ +- [x] 피벗 테이블 렌더링 +- [x] 행/열 확장/축소 +- [x] 합계/소계 표시 +- [x] 전체 확장/축소 버튼 + +### 2. 필드 패널 (드래그앤드롭) ✅ +- [x] 상단에 4개 영역 표시 (필터, 열, 행, 데이터) +- [x] 각 영역에 배치된 필드 칩/태그 표시 +- [x] 필드 제거 버튼 (X) +- [x] 필드 간 드래그 앤 드롭 지원 (@dnd-kit 사용) +- [x] 영역 간 필드 이동 +- [x] 같은 영역 내 순서 변경 +- [x] 드래그 시 시각적 피드백 + +### 3. 필드 선택기 (모달) ✅ +- [x] 모달 열기/닫기 +- [x] 사용 가능한 필드 목록 +- [x] 필드 검색 기능 +- [x] 필드별 영역 선택 드롭다운 +- [x] 데이터 타입 아이콘 표시 +- [x] 집계 함수 선택 (데이터 영역) +- [x] 표시 모드 선택 (데이터 영역) + +### 4. 데이터 요약 (누계, % 모드) ✅ +- [x] 절대값 표시 (기본) +- [x] 행 총계 대비 % +- [x] 열 총계 대비 % +- [x] 전체 총계 대비 % +- [x] 행/열 방향 누계 +- [x] 이전 대비 차이 +- [x] 이전 대비 % 차이 + +### 5. 필터링 ✅ +- [x] 필터 팝업 컴포넌트 (FilterPopup) +- [x] 값 검색 기능 +- [x] 체크박스 기반 값 선택 +- [x] 포함/제외 모드 +- [x] 전체 선택/해제 +- [x] 선택된 항목 수 표시 + +### 6. Drill Down ✅ +- [x] 셀 더블클릭 시 상세 데이터 모달 +- [x] 원본 데이터 테이블 표시 +- [x] 검색 기능 +- [x] 정렬 기능 +- [x] 페이지네이션 +- [x] CSV/Excel 내보내기 + +### 7. Virtual Scrolling ✅ +- [x] useVirtualScroll 훅 (행) +- [x] useVirtualColumnScroll 훅 (열) +- [x] useVirtual2DScroll 훅 (행+열) +- [x] overscan 버퍼 지원 + +### 8. Excel 내보내기 ✅ +- [x] xlsx 라이브러리 사용 +- [x] 피벗 데이터 Excel 내보내기 +- [x] Drill Down 데이터 Excel 내보내기 +- [x] CSV 내보내기 (기본) +- [x] 스타일링 (헤더, 데이터, 총계) +- [x] 숫자 포맷 + +### 9. 차트 통합 ✅ +- [x] recharts 라이브러리 사용 +- [x] 막대 차트 +- [x] 누적 막대 차트 +- [x] 선 차트 +- [x] 영역 차트 +- [x] 파이 차트 +- [x] 범례 표시 +- [x] 커스텀 툴팁 +- [x] 차트 토글 버튼 + +### 10. 조건부 서식 (Conditional Formatting) ✅ +- [x] Color Scale (색상 그라데이션) +- [x] Data Bar (데이터 막대) +- [x] Icon Set (아이콘) +- [x] Cell Value (조건 기반 스타일) +- [x] ConfigPanel에서 설정 UI + +### 11. 상태 저장/복원 ✅ +- [x] usePivotState 훅 +- [x] localStorage/sessionStorage 지원 +- [x] 자동 저장 (디바운스) + +### 12. ConfigPanel 고도화 ✅ +- [x] 데이터 소스 설정 (테이블 선택) +- [x] 필드별 영역 설정 (행, 열, 데이터, 필터) +- [x] 총계 옵션 설정 +- [x] 스타일 설정 (테마, 교차 색상 등) +- [x] 내보내기 설정 (Excel/CSV) +- [x] 차트 설정 UI +- [x] 필드 선택기 설정 UI +- [x] 조건부 서식 설정 UI +- [x] 크기 설정 + +--- + +## 파일 구조 + +``` +pivot-grid/ +├── components/ +│ ├── FieldPanel.tsx # 필드 패널 (드래그앤드롭) +│ ├── FieldChooser.tsx # 필드 선택기 모달 +│ ├── DrillDownModal.tsx # Drill Down 모달 +│ ├── FilterPopup.tsx # 필터 팝업 +│ ├── PivotChart.tsx # 차트 컴포넌트 +│ └── index.ts # 내보내기 +├── hooks/ +│ ├── useVirtualScroll.ts # 가상 스크롤 훅 +│ ├── usePivotState.ts # 상태 저장 훅 +│ └── index.ts # 내보내기 +├── utils/ +│ ├── aggregation.ts # 집계 함수 +│ ├── pivotEngine.ts # 피벗 엔진 +│ ├── exportExcel.ts # Excel 내보내기 +│ ├── conditionalFormat.ts # 조건부 서식 +│ └── index.ts # 내보내기 +├── types.ts # 타입 정의 +├── PivotGridComponent.tsx # 메인 컴포넌트 +├── PivotGridConfigPanel.tsx # 설정 패널 +├── PivotGridRenderer.tsx # 렌더러 +├── index.ts # 모듈 내보내기 +└── PLAN.md # 이 파일 +``` + +--- + +## 후순위 기능 (선택적) + +다음 기능들은 필요 시 추가 구현 가능: + +### 데이터 바인딩 확장 +- [ ] OLAP Data Source 연동 (복잡) +- [ ] GraphQL 연동 +- [ ] 실시간 데이터 업데이트 (WebSocket) + +### 고급 기능 +- [ ] 피벗 테이블 병합 (여러 데이터 소스) +- [ ] 계산 필드 (커스텀 수식) +- [ ] 데이터 정렬 옵션 강화 +- [ ] 그룹핑 옵션 (날짜 그룹핑 등) + +--- + +## 완료일: 2026-01-08 diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index b81057a3..e7904a95 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -5,7 +5,7 @@ * 다차원 데이터 분석을 위한 피벗 테이블 */ -import React, { useState, useMemo, useCallback } from "react"; +import React, { useState, useMemo, useCallback, useEffect } from "react"; import { cn } from "@/lib/utils"; import { PivotGridProps, @@ -15,8 +15,15 @@ import { PivotFlatRow, PivotCellValue, PivotGridState, + PivotAreaType, } from "./types"; import { processPivotData, pathToKey } from "./utils/pivotEngine"; +import { exportPivotToExcel } from "./utils/exportExcel"; +import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat"; +import { FieldPanel } from "./components/FieldPanel"; +import { FieldChooser } from "./components/FieldChooser"; +import { DrillDownModal } from "./components/DrillDownModal"; +import { PivotChart } from "./components/PivotChart"; import { ChevronRight, ChevronDown, @@ -25,6 +32,9 @@ import { RefreshCw, Maximize2, Minimize2, + LayoutGrid, + FileSpreadsheet, + BarChart3, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -79,13 +89,22 @@ interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; onClick?: () => void; + onDoubleClick?: () => void; + conditionalStyle?: CellFormatStyle; } const DataCell: React.FC = ({ values, isTotal = false, onClick, + onDoubleClick, + conditionalStyle, }) => { + // 조건부 서식 스타일 계산 + const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {}; + const hasDataBar = conditionalStyle?.dataBarWidth !== undefined; + const icon = conditionalStyle?.icon; + if (!values || values.length === 0) { return ( = ({ "px-2 py-1.5 text-right text-sm", isTotal && "bg-primary/5 font-medium" )} + style={cellStyle} + onClick={onClick} + onDoubleClick={onDoubleClick} > - @@ -105,14 +127,29 @@ const DataCell: React.FC = ({ return ( - {values[0].formattedValue} + {/* Data Bar */} + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {values[0].formattedValue} + ); } @@ -124,14 +161,28 @@ const DataCell: React.FC = ({ - {val.formattedValue} + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {val.formattedValue} + ))} @@ -142,7 +193,7 @@ const DataCell: React.FC = ({ export const PivotGridComponent: React.FC = ({ title, - fields = [], + fields: initialFields = [], totals = { showRowGrandTotals: true, showColumnGrandTotals: true, @@ -157,24 +208,49 @@ export const PivotGridComponent: React.FC = ({ alternateRowColors: true, highlightTotals: true, }, + fieldChooser, + chart: chartConfig, allowExpandAll = true, height = "auto", maxHeight, exportConfig, data: externalData, onCellClick, + onCellDoubleClick, + onFieldDrop, onExpandChange, }) => { + // 디버깅 로그 + console.log("🔶 PivotGridComponent props:", { + title, + hasExternalData: !!externalData, + externalDataLength: externalData?.length, + initialFieldsLength: initialFields?.length, + }); // ==================== 상태 ==================== + const [fields, setFields] = useState(initialFields); const [pivotState, setPivotState] = useState({ expandedRowPaths: [], expandedColumnPaths: [], sortConfig: null, filterConfig: {}, }); - const [isFullscreen, setIsFullscreen] = useState(false); + const [showFieldPanel, setShowFieldPanel] = useState(true); + const [showFieldChooser, setShowFieldChooser] = useState(false); + const [drillDownData, setDrillDownData] = useState<{ + open: boolean; + cellData: PivotCellData | null; + }>({ open: false, cellData: null }); + const [showChart, setShowChart] = useState(chartConfig?.enabled || false); + + // 외부 fields 변경 시 동기화 + useEffect(() => { + if (initialFields.length > 0) { + setFields(initialFields); + } + }, [initialFields]); // 데이터 const data = externalData || []; @@ -205,6 +281,43 @@ export const PivotGridComponent: React.FC = ({ [fields] ); + const filterFields = useMemo( + () => + fields + .filter((f) => f.area === "filter" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + // 사용 가능한 필드 목록 (FieldChooser용) + const availableFields = useMemo(() => { + if (data.length === 0) return []; + + const sampleRow = data[0]; + return Object.keys(sampleRow).map((key) => { + const existingField = fields.find((f) => f.field === key); + const value = sampleRow[key]; + + // 데이터 타입 추론 + let dataType: "string" | "number" | "date" | "boolean" = "string"; + if (typeof value === "number") dataType = "number"; + else if (typeof value === "boolean") dataType = "boolean"; + else if (value instanceof Date) dataType = "date"; + else if (typeof value === "string") { + // 날짜 문자열 감지 + if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date"; + } + + return { + field: key, + caption: existingField?.caption || key, + dataType, + isSelected: existingField?.visible !== false, + currentArea: existingField?.area, + }; + }); + }, [data, fields]); + // ==================== 피벗 처리 ==================== const pivotResult = useMemo(() => { @@ -212,16 +325,83 @@ export const PivotGridComponent: React.FC = ({ return null; } + const visibleFields = fields.filter((f) => f.visible !== false); + if (visibleFields.filter((f) => f.area !== "filter").length === 0) { + return null; + } + return processPivotData( data, - fields, + visibleFields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths ); }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + // 조건부 서식용 전체 값 수집 + const allCellValues = useMemo(() => { + if (!pivotResult) return new Map(); + + const valuesByField = new Map(); + + // 데이터 매트릭스에서 모든 값 수집 + pivotResult.dataMatrix.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + // 행 총계 값 수집 + pivotResult.grandTotals.row.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + // 열 총계 값 수집 + pivotResult.grandTotals.column.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + return valuesByField; + }, [pivotResult]); + + // 조건부 서식 스타일 계산 헬퍼 + const getCellConditionalStyle = useCallback( + (value: number | undefined, field: string): CellFormatStyle => { + if (!style?.conditionalFormats || style.conditionalFormats.length === 0) { + return {}; + } + const allValues = allCellValues.get(field) || []; + return getConditionalStyle(value, field, style.conditionalFormats, allValues); + }, + [style?.conditionalFormats, allCellValues] + ); + // ==================== 이벤트 핸들러 ==================== + // 필드 변경 + const handleFieldsChange = useCallback( + (newFields: PivotFieldConfig[]) => { + setFields(newFields); + }, + [] + ); + // 행 확장/축소 const handleToggleRowExpand = useCallback( (path: string[]) => { @@ -256,7 +436,6 @@ export const PivotGridComponent: React.FC = ({ if (!pivotResult) return; const allRowPaths: string[][] = []; - pivotResult.flatRows.forEach((row) => { if (row.hasChildren) { allRowPaths.push(row.path); @@ -296,6 +475,27 @@ export const PivotGridComponent: React.FC = ({ [onCellClick] ); + // 셀 더블클릭 (Drill Down) + const handleCellDoubleClick = useCallback( + (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath, + columnPath: colPath, + field: values[0]?.field, + }; + + // Drill Down 모달 열기 + setDrillDownData({ open: true, cellData }); + + // 외부 콜백 호출 + if (onCellDoubleClick) { + onCellDoubleClick(cellData); + } + }, + [onCellDoubleClick] + ); + // CSV 내보내기 const handleExportCSV = useCallback(() => { if (!pivotResult) return; @@ -354,6 +554,20 @@ export const PivotGridComponent: React.FC = ({ link.click(); }, [pivotResult, totals, title]); + // Excel 내보내기 + const handleExportExcel = useCallback(async () => { + if (!pivotResult) return; + + try { + await exportPivotToExcel(pivotResult, fields, totals, { + fileName: title || "pivot_export", + title: title, + }); + } catch (error) { + console.error("Excel 내보내기 실패:", error); + } + }, [pivotResult, fields, totals, title]); + // ==================== 렌더링 ==================== // 빈 상태 @@ -374,20 +588,51 @@ export const PivotGridComponent: React.FC = ({ } // 필드 미설정 - if (fields.length === 0) { + const hasActiveFields = fields.some( + (f) => f.visible !== false && f.area !== "filter" + ); + if (!hasActiveFields) { return (
- -

필드가 설정되지 않았습니다

-

- 행, 열, 데이터 영역에 필드를 배치해주세요 -

+ {/* 필드 패널 */} + setShowFieldPanel(!showFieldPanel)} + /> + + {/* 안내 메시지 */} +
+ +

필드가 설정되지 않았습니다

+

+ 행, 열, 데이터 영역에 필드를 배치해주세요 +

+ +
+ + {/* 필드 선택기 모달 */} +
); } @@ -416,6 +661,14 @@ export const PivotGridComponent: React.FC = ({ maxHeight: isFullscreen ? "none" : maxHeight, }} > + {/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */} + setShowFieldPanel(!showFieldPanel)} + /> + {/* 헤더 툴바 */}
@@ -426,6 +679,30 @@ export const PivotGridComponent: React.FC = ({
+ {/* 필드 선택기 버튼 */} + {fieldChooser?.enabled !== false && ( + + )} + + {/* 필드 패널 토글 */} + + {allowExpandAll && ( <> )} + {/* 내보내기 버튼들 */} + {exportConfig?.excel && ( + <> + + + + )} +
+ + {/* 차트 */} + {showChart && chartConfig && pivotResult && ( + + )} + + {/* 필드 선택기 모달 */} + + + {/* Drill Down 모달 */} + setDrillDownData((prev) => ({ ...prev, open }))} + cellData={drillDownData.cellData} + data={data} + fields={fields} + rowFields={rowFields} + columnFields={columnFields} + />
); }; diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx index a0e322d9..f3e9a976 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -431,14 +431,9 @@ const AreaFieldList: React.FC = ({ ) : ( availableColumns.map((col) => ( -
- {col.column_name} - {col.column_comment && ( - - ({col.column_comment}) - - )} -
+ {col.column_comment + ? `${col.column_name} (${col.column_comment})` + : col.column_name}
)) )} @@ -476,7 +471,8 @@ export const PivotGridConfigPanel: React.FC = ({ const loadTables = async () => { setLoadingTables(true); try { - const response = await apiClient.get("/api/table-management/list"); + // apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외 + const response = await apiClient.get("/table-management/tables"); if (response.data.success) { setTables(response.data.data || []); } @@ -499,8 +495,9 @@ export const PivotGridConfigPanel: React.FC = ({ setLoadingColumns(true); try { + // apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외 const response = await apiClient.get( - `/api/table-management/columns/${config.dataSource.tableName}` + `/table-management/tables/${config.dataSource.tableName}/columns` ); if (response.data.success) { setColumns(response.data.data || []); @@ -550,14 +547,9 @@ export const PivotGridConfigPanel: React.FC = ({ 선택 안 함 {tables.map((table) => ( -
- {table.table_name} - {table.table_comment && ( - - ({table.table_comment}) - - )} -
+ {table.table_comment + ? `${table.table_name} (${table.table_comment})` + : table.table_name}
))} @@ -717,6 +709,270 @@ export const PivotGridConfigPanel: React.FC = ({ + {/* 차트 설정 */} +
+ + +
+
+ + + updateConfig({ + chart: { + ...config.chart, + enabled: v, + type: config.chart?.type || "bar", + position: config.chart?.position || "bottom", + }, + }) + } + /> +
+ + {config.chart?.enabled && ( +
+
+ + +
+ +
+ + + updateConfig({ + chart: { + ...config.chart, + enabled: true, + type: config.chart?.type || "bar", + position: config.chart?.position || "bottom", + height: Number(e.target.value), + }, + }) + } + className="h-8 text-xs" + /> +
+ +
+ + + updateConfig({ + chart: { + ...config.chart, + enabled: true, + type: config.chart?.type || "bar", + position: config.chart?.position || "bottom", + showLegend: v, + }, + }) + } + /> +
+
+ )} +
+
+ + + + {/* 필드 선택기 설정 */} +
+ + +
+
+ + + updateConfig({ + fieldChooser: { ...config.fieldChooser, enabled: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + fieldChooser: { ...config.fieldChooser, allowSearch: v }, + }) + } + /> +
+
+
+ + + + {/* 조건부 서식 설정 */} +
+ + +
+
+ + r.type === "colorScale" + ) || false + } + onCheckedChange={(v) => { + const existingFormats = config.style?.conditionalFormats || []; + const filtered = existingFormats.filter( + (r) => r.type !== "colorScale" + ); + updateConfig({ + style: { + ...config.style, + theme: config.style?.theme || "default", + headerStyle: config.style?.headerStyle || "default", + cellPadding: config.style?.cellPadding || "normal", + borderStyle: config.style?.borderStyle || "light", + conditionalFormats: v + ? [ + ...filtered, + { + id: "colorScale-1", + type: "colorScale" as const, + colorScale: { + minColor: "#ff6b6b", + midColor: "#ffd93d", + maxColor: "#6bcb77", + }, + }, + ] + : filtered, + }, + }); + }} + /> +
+ +
+ + r.type === "dataBar" + ) || false + } + onCheckedChange={(v) => { + const existingFormats = config.style?.conditionalFormats || []; + const filtered = existingFormats.filter( + (r) => r.type !== "dataBar" + ); + updateConfig({ + style: { + ...config.style, + theme: config.style?.theme || "default", + headerStyle: config.style?.headerStyle || "default", + cellPadding: config.style?.cellPadding || "normal", + borderStyle: config.style?.borderStyle || "light", + conditionalFormats: v + ? [ + ...filtered, + { + id: "dataBar-1", + type: "dataBar" as const, + dataBar: { + color: "#3b82f6", + showValue: true, + }, + }, + ] + : filtered, + }, + }); + }} + /> +
+ +
+ + r.type === "iconSet" + ) || false + } + onCheckedChange={(v) => { + const existingFormats = config.style?.conditionalFormats || []; + const filtered = existingFormats.filter( + (r) => r.type !== "iconSet" + ); + updateConfig({ + style: { + ...config.style, + theme: config.style?.theme || "default", + headerStyle: config.style?.headerStyle || "default", + cellPadding: config.style?.cellPadding || "normal", + borderStyle: config.style?.borderStyle || "light", + conditionalFormats: v + ? [ + ...filtered, + { + id: "iconSet-1", + type: "iconSet" as const, + iconSet: { + type: "traffic", + thresholds: [33, 66], + }, + }, + ] + : filtered, + }, + }); + }} + /> +
+ + {config.style?.conditionalFormats && + config.style.conditionalFormats.length > 0 && ( +

+ {config.style.conditionalFormats.length}개의 조건부 서식이 + 적용됨 +

+ )} +
+
+ + + {/* 크기 설정 */}
diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx index 7c34192a..8e3563d9 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -6,6 +6,160 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition import { ComponentCategory } from "@/types/component"; import { PivotGridComponent } from "./PivotGridComponent"; import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; +import { PivotFieldConfig } from "./types"; + +// ==================== 샘플 데이터 (미리보기용) ==================== + +const SAMPLE_DATA = [ + { region: "서울", product: "노트북", quarter: "Q1", sales: 1500000, quantity: 15 }, + { region: "서울", product: "노트북", quarter: "Q2", sales: 1800000, quantity: 18 }, + { region: "서울", product: "노트북", quarter: "Q3", sales: 2100000, quantity: 21 }, + { region: "서울", product: "노트북", quarter: "Q4", sales: 2500000, quantity: 25 }, + { region: "서울", product: "스마트폰", quarter: "Q1", sales: 2000000, quantity: 40 }, + { region: "서울", product: "스마트폰", quarter: "Q2", sales: 2200000, quantity: 44 }, + { region: "서울", product: "스마트폰", quarter: "Q3", sales: 2500000, quantity: 50 }, + { region: "서울", product: "스마트폰", quarter: "Q4", sales: 3000000, quantity: 60 }, + { region: "서울", product: "태블릿", quarter: "Q1", sales: 800000, quantity: 10 }, + { region: "서울", product: "태블릿", quarter: "Q2", sales: 900000, quantity: 11 }, + { region: "서울", product: "태블릿", quarter: "Q3", sales: 1000000, quantity: 12 }, + { region: "서울", product: "태블릿", quarter: "Q4", sales: 1200000, quantity: 15 }, + { region: "부산", product: "노트북", quarter: "Q1", sales: 1000000, quantity: 10 }, + { region: "부산", product: "노트북", quarter: "Q2", sales: 1200000, quantity: 12 }, + { region: "부산", product: "노트북", quarter: "Q3", sales: 1400000, quantity: 14 }, + { region: "부산", product: "노트북", quarter: "Q4", sales: 1600000, quantity: 16 }, + { region: "부산", product: "스마트폰", quarter: "Q1", sales: 1500000, quantity: 30 }, + { region: "부산", product: "스마트폰", quarter: "Q2", sales: 1700000, quantity: 34 }, + { region: "부산", product: "스마트폰", quarter: "Q3", sales: 1900000, quantity: 38 }, + { region: "부산", product: "스마트폰", quarter: "Q4", sales: 2200000, quantity: 44 }, + { region: "부산", product: "태블릿", quarter: "Q1", sales: 500000, quantity: 6 }, + { region: "부산", product: "태블릿", quarter: "Q2", sales: 600000, quantity: 7 }, + { region: "부산", product: "태블릿", quarter: "Q3", sales: 700000, quantity: 8 }, + { region: "부산", product: "태블릿", quarter: "Q4", sales: 800000, quantity: 10 }, + { region: "대구", product: "노트북", quarter: "Q1", sales: 700000, quantity: 7 }, + { region: "대구", product: "노트북", quarter: "Q2", sales: 850000, quantity: 8 }, + { region: "대구", product: "노트북", quarter: "Q3", sales: 900000, quantity: 9 }, + { region: "대구", product: "노트북", quarter: "Q4", sales: 1100000, quantity: 11 }, + { region: "대구", product: "스마트폰", quarter: "Q1", sales: 1000000, quantity: 20 }, + { region: "대구", product: "스마트폰", quarter: "Q2", sales: 1200000, quantity: 24 }, + { region: "대구", product: "스마트폰", quarter: "Q3", sales: 1300000, quantity: 26 }, + { region: "대구", product: "스마트폰", quarter: "Q4", sales: 1500000, quantity: 30 }, + { region: "대구", product: "태블릿", quarter: "Q1", sales: 400000, quantity: 5 }, + { region: "대구", product: "태블릿", quarter: "Q2", sales: 450000, quantity: 5 }, + { region: "대구", product: "태블릿", quarter: "Q3", sales: 500000, quantity: 6 }, + { region: "대구", product: "태블릿", quarter: "Q4", sales: 600000, quantity: 7 }, +]; + +const SAMPLE_FIELDS: PivotFieldConfig[] = [ + { + field: "region", + caption: "지역", + area: "row", + areaIndex: 0, + dataType: "string", + visible: true, + }, + { + field: "product", + caption: "제품", + area: "row", + areaIndex: 1, + dataType: "string", + visible: true, + }, + { + field: "quarter", + caption: "분기", + area: "column", + areaIndex: 0, + dataType: "string", + visible: true, + }, + { + field: "sales", + caption: "매출", + area: "data", + areaIndex: 0, + dataType: "number", + summaryType: "sum", + format: { type: "number", precision: 0 }, + visible: true, + }, +]; + +/** + * PivotGrid 래퍼 컴포넌트 (디자인 모드에서 샘플 데이터 주입) + */ +const PivotGridWrapper: React.FC = (props) => { + // 컴포넌트 설정에서 값 추출 + const componentConfig = props.componentConfig || props.config || {}; + const configFields = componentConfig.fields || props.fields; + const configData = props.data; + + // 디버깅 로그 + console.log("🔷 PivotGridWrapper props:", { + isDesignMode: props.isDesignMode, + isInteractive: props.isInteractive, + hasComponentConfig: !!props.componentConfig, + hasConfig: !!props.config, + hasData: !!configData, + dataLength: configData?.length, + hasFields: !!configFields, + fieldsLength: configFields?.length, + }); + + // 디자인 모드 판단: + // 1. isDesignMode === true + // 2. isInteractive === false (편집 모드) + // 3. 데이터가 없는 경우 + const isDesignMode = props.isDesignMode === true || props.isInteractive === false; + const hasValidData = configData && Array.isArray(configData) && configData.length > 0; + const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; + + // 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용 + const usePreviewData = isDesignMode || !hasValidData; + + // 최종 데이터/필드 결정 + const finalData = usePreviewData ? SAMPLE_DATA : configData; + const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; + const finalTitle = usePreviewData + ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" + : (componentConfig.title || props.title); + + console.log("🔷 PivotGridWrapper final:", { + isDesignMode, + usePreviewData, + finalDataLength: finalData?.length, + finalFieldsLength: finalFields?.length, + }); + + // 총계 설정 + const totalsConfig = componentConfig.totals || props.totals || { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }; + + return ( + + ); +}; /** * PivotGrid 컴포넌트 정의 @@ -17,13 +171,15 @@ const PivotGridDefinition = createComponentDefinition({ description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트", category: ComponentCategory.DISPLAY, webType: "text", - component: PivotGridComponent, + component: PivotGridWrapper, // 래퍼 컴포넌트 사용 defaultConfig: { dataSource: { type: "table", tableName: "", }, - fields: [], + fields: SAMPLE_FIELDS, + // 미리보기용 샘플 데이터 + sampleData: SAMPLE_DATA, totals: { showRowGrandTotals: true, showColumnGrandTotals: true, @@ -61,9 +217,75 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = PivotGridDefinition; render(): React.ReactElement { + const props = this.props as any; + + // 컴포넌트 설정에서 값 추출 + const componentConfig = props.componentConfig || props.config || {}; + const configFields = componentConfig.fields || props.fields; + const configData = props.data; + + // 디버깅 로그 + console.log("🔷 PivotGridRenderer props:", { + isDesignMode: props.isDesignMode, + isInteractive: props.isInteractive, + hasComponentConfig: !!props.componentConfig, + hasConfig: !!props.config, + hasData: !!configData, + dataLength: configData?.length, + hasFields: !!configFields, + fieldsLength: configFields?.length, + }); + + // 디자인 모드 판단: + // 1. isDesignMode === true + // 2. isInteractive === false (편집 모드) + // 3. 데이터가 없는 경우 + const isDesignMode = props.isDesignMode === true || props.isInteractive === false; + const hasValidData = configData && Array.isArray(configData) && configData.length > 0; + const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; + + // 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용 + const usePreviewData = isDesignMode || !hasValidData; + + // 최종 데이터/필드 결정 + const finalData = usePreviewData ? SAMPLE_DATA : configData; + const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; + const finalTitle = usePreviewData + ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" + : (componentConfig.title || props.title); + + console.log("🔷 PivotGridRenderer final:", { + isDesignMode, + usePreviewData, + finalDataLength: finalData?.length, + finalFieldsLength: finalFields?.length, + }); + + // 총계 설정 + const totalsConfig = componentConfig.totals || props.totals || { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }; + return ( ); } diff --git a/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx b/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx new file mode 100644 index 00000000..994d782f --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx @@ -0,0 +1,429 @@ +"use client"; + +/** + * DrillDownModal 컴포넌트 + * 피벗 셀 클릭 시 해당 셀의 상세 원본 데이터를 표시하는 모달 + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotCellData, PivotFieldConfig } from "../types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Search, + Download, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + ArrowUpDown, + ArrowUp, + ArrowDown, +} from "lucide-react"; + +// ==================== 타입 ==================== + +interface DrillDownModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + cellData: PivotCellData | null; + data: any[]; // 전체 원본 데이터 + fields: PivotFieldConfig[]; + rowFields: PivotFieldConfig[]; + columnFields: PivotFieldConfig[]; +} + +interface SortConfig { + field: string; + direction: "asc" | "desc"; +} + +// ==================== 메인 컴포넌트 ==================== + +export const DrillDownModal: React.FC = ({ + open, + onOpenChange, + cellData, + data, + fields, + rowFields, + columnFields, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [sortConfig, setSortConfig] = useState(null); + + // 드릴다운 데이터 필터링 + const filteredData = useMemo(() => { + if (!cellData || !data) return []; + + // 행/열 경로에 해당하는 데이터 필터링 + let result = data.filter((row) => { + // 행 경로 매칭 + for (let i = 0; i < cellData.rowPath.length; i++) { + const field = rowFields[i]; + if (field && String(row[field.field]) !== cellData.rowPath[i]) { + return false; + } + } + + // 열 경로 매칭 + for (let i = 0; i < cellData.columnPath.length; i++) { + const field = columnFields[i]; + if (field && String(row[field.field]) !== cellData.columnPath[i]) { + return false; + } + } + + return true; + }); + + // 검색 필터 + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter((row) => + Object.values(row).some((val) => + String(val).toLowerCase().includes(query) + ) + ); + } + + // 정렬 + if (sortConfig) { + result = [...result].sort((a, b) => { + const aVal = a[sortConfig.field]; + const bVal = b[sortConfig.field]; + + if (aVal === null || aVal === undefined) return 1; + if (bVal === null || bVal === undefined) return -1; + + let comparison = 0; + if (typeof aVal === "number" && typeof bVal === "number") { + comparison = aVal - bVal; + } else { + comparison = String(aVal).localeCompare(String(bVal)); + } + + return sortConfig.direction === "asc" ? comparison : -comparison; + }); + } + + return result; + }, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]); + + // 페이지네이션 + const totalPages = Math.ceil(filteredData.length / pageSize); + const paginatedData = useMemo(() => { + const start = (currentPage - 1) * pageSize; + return filteredData.slice(start, start + pageSize); + }, [filteredData, currentPage, pageSize]); + + // 표시할 컬럼 결정 + const displayColumns = useMemo(() => { + // 모든 필드의 field명 수집 + const fieldNames = new Set(); + + // fields에서 가져오기 + fields.forEach((f) => fieldNames.add(f.field)); + + // 데이터에서 추가 컬럼 가져오기 + if (data.length > 0) { + Object.keys(data[0]).forEach((key) => fieldNames.add(key)); + } + + return Array.from(fieldNames).map((fieldName) => { + const fieldConfig = fields.find((f) => f.field === fieldName); + return { + field: fieldName, + caption: fieldConfig?.caption || fieldName, + dataType: fieldConfig?.dataType || "string", + }; + }); + }, [fields, data]); + + // 정렬 토글 + const handleSort = (field: string) => { + setSortConfig((prev) => { + if (!prev || prev.field !== field) { + return { field, direction: "asc" }; + } + if (prev.direction === "asc") { + return { field, direction: "desc" }; + } + return null; + }); + }; + + // CSV 내보내기 + const handleExportCSV = () => { + if (filteredData.length === 0) return; + + const headers = displayColumns.map((c) => c.caption); + const rows = filteredData.map((row) => + displayColumns.map((c) => { + const val = row[c.field]; + if (val === null || val === undefined) return ""; + if (typeof val === "string" && val.includes(",")) { + return `"${val}"`; + } + return String(val); + }) + ); + + const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); + + const blob = new Blob(["\uFEFF" + csv], { + type: "text/csv;charset=utf-8;", + }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`; + link.click(); + }; + + // 페이지 변경 + const goToPage = (page: number) => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }; + + // 경로 표시 + const pathDisplay = cellData + ? [ + ...(cellData.rowPath.length > 0 + ? [`행: ${cellData.rowPath.join(" > ")}`] + : []), + ...(cellData.columnPath.length > 0 + ? [`열: ${cellData.columnPath.join(" > ")}`] + : []), + ].join(" | ") + : ""; + + return ( + + + + 상세 데이터 + + {pathDisplay || "선택한 셀의 원본 데이터"} + + ({filteredData.length}건) + + + + + {/* 툴바 */} +
+
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="pl-9 h-9" + /> +
+ + + + +
+ + {/* 테이블 */} + +
+ + + + {displayColumns.map((col) => ( + handleSort(col.field)} + > +
+ {col.caption} + {sortConfig?.field === col.field ? ( + sortConfig.direction === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ))} +
+
+ + {paginatedData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + paginatedData.map((row, idx) => ( + + {displayColumns.map((col) => ( + + {formatCellValue(row[col.field], col.dataType)} + + ))} + + )) + )} + +
+
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+
+ {(currentPage - 1) * pageSize + 1} -{" "} + {Math.min(currentPage * pageSize, filteredData.length)} /{" "} + {filteredData.length}건 +
+ +
+ + + + + {currentPage} / {totalPages} + + + + +
+
+ )} +
+
+ ); +}; + +// ==================== 유틸리티 ==================== + +function formatCellValue(value: any, dataType: string): string { + if (value === null || value === undefined) return "-"; + + if (dataType === "number") { + const num = Number(value); + if (isNaN(num)) return String(value); + return num.toLocaleString(); + } + + if (dataType === "date") { + try { + const date = new Date(value); + if (!isNaN(date.getTime())) { + return date.toLocaleDateString("ko-KR"); + } + } catch { + // 변환 실패 시 원본 반환 + } + } + + return String(value); +} + +export default DrillDownModal; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx new file mode 100644 index 00000000..ec194a12 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx @@ -0,0 +1,441 @@ +"use client"; + +/** + * FieldChooser 컴포넌트 + * 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달 + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Search, + Filter, + Columns, + Rows, + BarChart3, + GripVertical, + Plus, + Minus, + Type, + Hash, + Calendar, + ToggleLeft, +} from "lucide-react"; + +// ==================== 타입 ==================== + +interface AvailableField { + field: string; + caption: string; + dataType: "string" | "number" | "date" | "boolean"; + isSelected: boolean; + currentArea?: PivotAreaType; +} + +interface FieldChooserProps { + open: boolean; + onOpenChange: (open: boolean) => void; + availableFields: AvailableField[]; + selectedFields: PivotFieldConfig[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; +} + +// ==================== 영역 설정 ==================== + +const AREA_OPTIONS: { + value: PivotAreaType | "none"; + label: string; + icon: React.ReactNode; +}[] = [ + { value: "none", label: "사용 안함", icon: }, + { value: "filter", label: "필터", icon: }, + { value: "row", label: "행", icon: }, + { value: "column", label: "열", icon: }, + { value: "data", label: "데이터", icon: }, +]; + +const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [ + { value: "sum", label: "합계" }, + { value: "count", label: "개수" }, + { value: "avg", label: "평균" }, + { value: "min", label: "최소" }, + { value: "max", label: "최대" }, + { value: "countDistinct", label: "고유 개수" }, +]; + +const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [ + { value: "absoluteValue", label: "절대값" }, + { value: "percentOfRowTotal", label: "행 총계 %" }, + { value: "percentOfColumnTotal", label: "열 총계 %" }, + { value: "percentOfGrandTotal", label: "전체 총계 %" }, + { value: "runningTotalByRow", label: "행 누계" }, + { value: "runningTotalByColumn", label: "열 누계" }, + { value: "differenceFromPrevious", label: "이전 대비 차이" }, + { value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" }, +]; + +const DATA_TYPE_ICONS: Record = { + string: , + number: , + date: , + boolean: , +}; + +// ==================== 필드 아이템 ==================== + +interface FieldItemProps { + field: AvailableField; + config?: PivotFieldConfig; + onAreaChange: (area: PivotAreaType | "none") => void; + onSummaryChange?: (summary: AggregationType) => void; + onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void; +} + +const FieldItem: React.FC = ({ + field, + config, + onAreaChange, + onSummaryChange, + onDisplayModeChange, +}) => { + const currentArea = config?.area || "none"; + const isSelected = currentArea !== "none"; + + return ( +
+ {/* 데이터 타입 아이콘 */} +
+ {DATA_TYPE_ICONS[field.dataType] || } +
+ + {/* 필드명 */} +
+
{field.caption}
+
+ {field.field} +
+
+ + {/* 영역 선택 */} + + + {/* 집계 함수 선택 (데이터 영역인 경우) */} + {currentArea === "data" && onSummaryChange && ( + + )} + + {/* 표시 모드 선택 (데이터 영역인 경우) */} + {currentArea === "data" && onDisplayModeChange && ( + + )} +
+ ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const FieldChooser: React.FC = ({ + open, + onOpenChange, + availableFields, + selectedFields, + onFieldsChange, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">( + "all" + ); + + // 필터링된 필드 목록 + const filteredFields = useMemo(() => { + let result = availableFields; + + // 검색어 필터 + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (f) => + f.caption.toLowerCase().includes(query) || + f.field.toLowerCase().includes(query) + ); + } + + // 선택 상태 필터 + if (filterType === "selected") { + result = result.filter((f) => + selectedFields.some((sf) => sf.field === f.field && sf.visible !== false) + ); + } else if (filterType === "unselected") { + result = result.filter( + (f) => + !selectedFields.some( + (sf) => sf.field === f.field && sf.visible !== false + ) + ); + } + + return result; + }, [availableFields, selectedFields, searchQuery, filterType]); + + // 필드 영역 변경 + const handleAreaChange = ( + field: AvailableField, + area: PivotAreaType | "none" + ) => { + const existingConfig = selectedFields.find((f) => f.field === field.field); + + if (area === "none") { + // 필드 제거 또는 숨기기 + if (existingConfig) { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, visible: false } : f + ); + onFieldsChange(newFields); + } + } else { + // 필드 추가 또는 영역 변경 + if (existingConfig) { + const newFields = selectedFields.map((f) => + f.field === field.field + ? { ...f, area, visible: true } + : f + ); + onFieldsChange(newFields); + } else { + // 새 필드 추가 + const newField: PivotFieldConfig = { + field: field.field, + caption: field.caption, + area, + dataType: field.dataType, + visible: true, + summaryType: area === "data" ? "sum" : undefined, + areaIndex: selectedFields.filter((f) => f.area === area).length, + }; + onFieldsChange([...selectedFields, newField]); + } + } + }; + + // 집계 함수 변경 + const handleSummaryChange = ( + field: AvailableField, + summaryType: AggregationType + ) => { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, summaryType } : f + ); + onFieldsChange(newFields); + }; + + // 표시 모드 변경 + const handleDisplayModeChange = ( + field: AvailableField, + displayMode: SummaryDisplayMode + ) => { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f + ); + onFieldsChange(newFields); + }; + + // 모든 필드 선택 해제 + const handleClearAll = () => { + const newFields = selectedFields.map((f) => ({ ...f, visible: false })); + onFieldsChange(newFields); + }; + + // 통계 + const stats = useMemo(() => { + const visible = selectedFields.filter((f) => f.visible !== false); + return { + total: availableFields.length, + selected: visible.length, + filter: visible.filter((f) => f.area === "filter").length, + row: visible.filter((f) => f.area === "row").length, + column: visible.filter((f) => f.area === "column").length, + data: visible.filter((f) => f.area === "data").length, + }; + }, [availableFields, selectedFields]); + + return ( + + + + 필드 선택기 + + 피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요. + + + + {/* 통계 */} +
+ 전체: {stats.total} + + 선택됨: {stats.selected} + + 필터: {stats.filter} + 행: {stats.row} + 열: {stats.column} + 데이터: {stats.data} +
+ + {/* 검색 및 필터 */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9" + /> +
+ + + + +
+ + {/* 필드 목록 */} + +
+ {filteredFields.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredFields.map((field) => { + const config = selectedFields.find( + (f) => f.field === field.field && f.visible !== false + ); + return ( + handleAreaChange(field, area)} + onSummaryChange={ + config?.area === "data" + ? (summary) => handleSummaryChange(field, summary) + : undefined + } + onDisplayModeChange={ + config?.area === "data" + ? (mode) => handleDisplayModeChange(field, mode) + : undefined + } + /> + ); + }) + )} +
+
+ + {/* 푸터 */} +
+ +
+
+
+ ); +}; + +export default FieldChooser; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx new file mode 100644 index 00000000..063b4c6c --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -0,0 +1,551 @@ +"use client"; + +/** + * FieldPanel 컴포넌트 + * 피벗 그리드 상단의 필드 배치 영역 (필터, 열, 행, 데이터) + * 드래그 앤 드롭으로 필드 재배치 가능 + */ + +import React, { useState } from "react"; +import { + DndContext, + DragOverlay, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragStartEvent, + DragEndEvent, + DragOverEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig, PivotAreaType } from "../types"; +import { + X, + Filter, + Columns, + Rows, + BarChart3, + GripVertical, + ChevronDown, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +// ==================== 타입 ==================== + +interface FieldPanelProps { + fields: PivotFieldConfig[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; + onFieldRemove?: (field: PivotFieldConfig) => void; + onFieldSettingsChange?: (field: PivotFieldConfig) => void; + collapsed?: boolean; + onToggleCollapse?: () => void; +} + +interface FieldChipProps { + field: PivotFieldConfig; + onRemove: () => void; + onSettingsChange?: (field: PivotFieldConfig) => void; +} + +interface DroppableAreaProps { + area: PivotAreaType; + fields: PivotFieldConfig[]; + title: string; + icon: React.ReactNode; + onFieldRemove: (field: PivotFieldConfig) => void; + onFieldSettingsChange?: (field: PivotFieldConfig) => void; + isOver?: boolean; +} + +// ==================== 영역 설정 ==================== + +const AREA_CONFIG: Record< + PivotAreaType, + { title: string; icon: React.ReactNode; color: string } +> = { + filter: { + title: "필터", + icon: , + color: "bg-orange-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800", + }, + column: { + title: "열", + icon: , + color: "bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800", + }, + row: { + title: "행", + icon: , + color: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-800", + }, + data: { + title: "데이터", + icon: , + color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800", + }, +}; + +// ==================== 필드 칩 (드래그 가능) ==================== + +const SortableFieldChip: React.FC = ({ + field, + onRemove, + onSettingsChange, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `${field.area}-${field.field}` }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {/* 드래그 핸들 */} + + + {/* 필드 라벨 */} + + + + + + {field.area === "data" && ( + <> + + onSettingsChange?.({ ...field, summaryType: "sum" }) + } + > + 합계 + + + onSettingsChange?.({ ...field, summaryType: "count" }) + } + > + 개수 + + + onSettingsChange?.({ ...field, summaryType: "avg" }) + } + > + 평균 + + + onSettingsChange?.({ ...field, summaryType: "min" }) + } + > + 최소 + + + onSettingsChange?.({ ...field, summaryType: "max" }) + } + > + 최대 + + + + )} + + onSettingsChange?.({ + ...field, + sortOrder: field.sortOrder === "asc" ? "desc" : "asc", + }) + } + > + {field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"} + + + onSettingsChange?.({ ...field, visible: false })} + > + 필드 숨기기 + + + + + {/* 삭제 버튼 */} + +
+ ); +}; + +// ==================== 드롭 영역 ==================== + +const DroppableArea: React.FC = ({ + area, + fields, + title, + icon, + onFieldRemove, + onFieldSettingsChange, + isOver, +}) => { + const config = AREA_CONFIG[area]; + const areaFields = fields.filter((f) => f.area === area && f.visible !== false); + const fieldIds = areaFields.map((f) => `${area}-${f.field}`); + + return ( +
+ {/* 영역 헤더 */} +
+ {icon} + {title} + {areaFields.length > 0 && ( + + {areaFields.length} + + )} +
+ + {/* 필드 목록 */} + +
+ {areaFields.length === 0 ? ( + + 필드를 여기로 드래그 + + ) : ( + areaFields.map((field) => ( + onFieldRemove(field)} + onSettingsChange={onFieldSettingsChange} + /> + )) + )} +
+
+
+ ); +}; + +// ==================== 유틸리티 ==================== + +function getSummaryLabel(type: string): string { + const labels: Record = { + sum: "합계", + count: "개수", + avg: "평균", + min: "최소", + max: "최대", + countDistinct: "고유", + }; + return labels[type] || type; +} + +// ==================== 메인 컴포넌트 ==================== + +export const FieldPanel: React.FC = ({ + fields, + onFieldsChange, + onFieldRemove, + onFieldSettingsChange, + collapsed = false, + onToggleCollapse, +}) => { + const [activeId, setActiveId] = useState(null); + const [overArea, setOverArea] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // 드래그 시작 + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + }; + + // 드래그 오버 + const handleDragOver = (event: DragOverEvent) => { + const { over } = event; + if (!over) { + setOverArea(null); + return; + } + + // 드롭 영역 감지 + const overId = over.id as string; + const targetArea = overId.split("-")[0] as PivotAreaType; + if (["filter", "column", "row", "data"].includes(targetArea)) { + setOverArea(targetArea); + } + }; + + // 드래그 종료 + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + setOverArea(null); + + if (!over) return; + + const activeId = active.id as string; + const overId = over.id as string; + + // 필드 정보 파싱 + const [sourceArea, sourceField] = activeId.split("-") as [ + PivotAreaType, + string + ]; + const [targetArea] = overId.split("-") as [PivotAreaType, string]; + + // 같은 영역 내 정렬 + if (sourceArea === targetArea) { + const areaFields = fields.filter((f) => f.area === sourceArea); + const sourceIndex = areaFields.findIndex((f) => f.field === sourceField); + const targetIndex = areaFields.findIndex( + (f) => `${f.area}-${f.field}` === overId + ); + + if (sourceIndex !== targetIndex && targetIndex >= 0) { + // 순서 변경 + const newFields = [...fields]; + const fieldToMove = newFields.find( + (f) => f.field === sourceField && f.area === sourceArea + ); + if (fieldToMove) { + fieldToMove.areaIndex = targetIndex; + // 다른 필드들 인덱스 조정 + newFields + .filter((f) => f.area === sourceArea && f.field !== sourceField) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) + .forEach((f, idx) => { + f.areaIndex = idx >= targetIndex ? idx + 1 : idx; + }); + } + onFieldsChange(newFields); + } + return; + } + + // 다른 영역으로 이동 + if (["filter", "column", "row", "data"].includes(targetArea)) { + const newFields = fields.map((f) => { + if (f.field === sourceField && f.area === sourceArea) { + return { + ...f, + area: targetArea as PivotAreaType, + areaIndex: fields.filter((ff) => ff.area === targetArea).length, + }; + } + return f; + }); + onFieldsChange(newFields); + } + }; + + // 필드 제거 + const handleFieldRemove = (field: PivotFieldConfig) => { + if (onFieldRemove) { + onFieldRemove(field); + } else { + // 기본 동작: visible을 false로 설정 + const newFields = fields.map((f) => + f.field === field.field && f.area === field.area + ? { ...f, visible: false } + : f + ); + onFieldsChange(newFields); + } + }; + + // 필드 설정 변경 + const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => { + if (onFieldSettingsChange) { + onFieldSettingsChange(updatedField); + } + const newFields = fields.map((f) => + f.field === updatedField.field && f.area === updatedField.area + ? updatedField + : f + ); + onFieldsChange(newFields); + }; + + // 활성 필드 찾기 (드래그 중인 필드) + const activeField = activeId + ? fields.find((f) => `${f.area}-${f.field}` === activeId) + : null; + + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( + +
+ {/* 2x2 그리드로 영역 배치 */} +
+ {/* 필터 영역 */} + + + {/* 열 영역 */} + + + {/* 행 영역 */} + + + {/* 데이터 영역 */} + +
+ + {/* 접기 버튼 */} + {onToggleCollapse && ( +
+ +
+ )} +
+ + {/* 드래그 오버레이 */} + + {activeField ? ( +
+ + {activeField.caption} +
+ ) : null} +
+
+ ); +}; + +export default FieldPanel; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx b/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx new file mode 100644 index 00000000..e3185f5a --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx @@ -0,0 +1,265 @@ +"use client"; + +/** + * FilterPopup 컴포넌트 + * 피벗 필드의 값을 필터링하는 팝업 + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig } from "../types"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + Search, + Filter, + Check, + X, + CheckSquare, + Square, +} from "lucide-react"; + +// ==================== 타입 ==================== + +interface FilterPopupProps { + field: PivotFieldConfig; + data: any[]; + onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void; + trigger?: React.ReactNode; +} + +// ==================== 메인 컴포넌트 ==================== + +export const FilterPopup: React.FC = ({ + field, + data, + onFilterChange, + trigger, +}) => { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedValues, setSelectedValues] = useState>( + new Set(field.filterValues || []) + ); + const [filterType, setFilterType] = useState<"include" | "exclude">( + field.filterType || "include" + ); + + // 고유 값 추출 + const uniqueValues = useMemo(() => { + const values = new Set(); + data.forEach((row) => { + const value = row[field.field]; + if (value !== null && value !== undefined) { + values.add(value); + } + }); + return Array.from(values).sort((a, b) => { + if (typeof a === "number" && typeof b === "number") return a - b; + return String(a).localeCompare(String(b), "ko"); + }); + }, [data, field.field]); + + // 필터링된 값 목록 + const filteredValues = useMemo(() => { + if (!searchQuery) return uniqueValues; + const query = searchQuery.toLowerCase(); + return uniqueValues.filter((val) => + String(val).toLowerCase().includes(query) + ); + }, [uniqueValues, searchQuery]); + + // 값 토글 + const handleValueToggle = (value: any) => { + const newSelected = new Set(selectedValues); + if (newSelected.has(value)) { + newSelected.delete(value); + } else { + newSelected.add(value); + } + setSelectedValues(newSelected); + }; + + // 모두 선택 + const handleSelectAll = () => { + setSelectedValues(new Set(filteredValues)); + }; + + // 모두 해제 + const handleClearAll = () => { + setSelectedValues(new Set()); + }; + + // 적용 + const handleApply = () => { + onFilterChange(field, Array.from(selectedValues), filterType); + setOpen(false); + }; + + // 초기화 + const handleReset = () => { + setSelectedValues(new Set()); + setFilterType("include"); + onFilterChange(field, [], "include"); + setOpen(false); + }; + + // 필터 활성 상태 + const isFilterActive = field.filterValues && field.filterValues.length > 0; + + // 선택된 항목 수 + const selectedCount = selectedValues.size; + const totalCount = uniqueValues.length; + + return ( + + + {trigger || ( + + )} + + +
+
+ {field.caption} 필터 +
+ + +
+
+ + {/* 검색 */} +
+ + setSearchQuery(e.target.value)} + className="pl-8 h-8 text-sm" + /> +
+ + {/* 전체 선택/해제 */} +
+ + {selectedCount} / {totalCount} 선택됨 + +
+ + +
+
+
+ + {/* 값 목록 */} + +
+ {filteredValues.length === 0 ? ( +
+ 결과가 없습니다 +
+ ) : ( + filteredValues.map((value) => ( + + )) + )} +
+
+ + {/* 버튼 */} +
+ +
+ + +
+
+
+
+ ); +}; + +export default FilterPopup; + diff --git a/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx b/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx new file mode 100644 index 00000000..6f7c3708 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx @@ -0,0 +1,386 @@ +"use client"; + +/** + * PivotChart 컴포넌트 + * 피벗 데이터를 차트로 시각화 + */ + +import React, { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types"; +import { pathToKey } from "../utils/pivotEngine"; +import { + BarChart, + Bar, + LineChart, + Line, + AreaChart, + Area, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; + +// ==================== 타입 ==================== + +interface PivotChartProps { + pivotResult: PivotResult; + config: PivotChartConfig; + dataFields: PivotFieldConfig[]; + className?: string; +} + +// ==================== 색상 ==================== + +const COLORS = [ + "#4472C4", // 파랑 + "#ED7D31", // 주황 + "#A5A5A5", // 회색 + "#FFC000", // 노랑 + "#5B9BD5", // 하늘 + "#70AD47", // 초록 + "#264478", // 진한 파랑 + "#9E480E", // 진한 주황 + "#636363", // 진한 회색 + "#997300", // 진한 노랑 +]; + +// ==================== 데이터 변환 ==================== + +function transformDataForChart( + pivotResult: PivotResult, + dataFields: PivotFieldConfig[] +): any[] { + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + // 행 기준 차트 데이터 생성 + return flatRows.map((row) => { + const dataPoint: any = { + name: row.caption, + path: row.path, + }; + + // 각 열에 대한 데이터 추가 + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey); + + if (values && values.length > 0) { + const columnName = col.caption || "전체"; + dataPoint[columnName] = values[0].value; + } + }); + + // 총계 추가 + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + if (rowTotal && rowTotal.length > 0) { + dataPoint["총계"] = rowTotal[0].value; + } + + return dataPoint; + }); +} + +function transformDataForPie( + pivotResult: PivotResult, + dataFields: PivotFieldConfig[] +): any[] { + const { flatRows, grandTotals } = pivotResult; + + return flatRows.map((row, idx) => { + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + return { + name: row.caption, + value: rowTotal?.[0]?.value || 0, + color: COLORS[idx % COLORS.length], + }; + }); +} + +// ==================== 차트 컴포넌트 ==================== + +const CustomTooltip: React.FC = ({ active, payload, label }) => { + if (!active || !payload || !payload.length) return null; + + return ( +
+

{label}

+ {payload.map((entry: any, idx: number) => ( +

+ {entry.name}: {entry.value?.toLocaleString()} +

+ ))} +
+ ); +}; + +// 막대 차트 +const PivotBarChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; + stacked?: boolean; +}> = ({ data, columns, height, showLegend, stacked }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 선 차트 +const PivotLineChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; +}> = ({ data, columns, height, showLegend }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 영역 차트 +const PivotAreaChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; +}> = ({ data, columns, height, showLegend }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 파이 차트 +const PivotPieChart: React.FC<{ + data: any[]; + height: number; + showLegend: boolean; +}> = ({ data, height, showLegend }) => { + return ( + + + + `${name} (${(percent * 100).toFixed(1)}%)` + } + labelLine + > + {data.map((entry, idx) => ( + + ))} + + } /> + {showLegend && ( + + )} + + + ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotChart: React.FC = ({ + pivotResult, + config, + dataFields, + className, +}) => { + // 차트 데이터 변환 + const chartData = useMemo(() => { + if (config.type === "pie") { + return transformDataForPie(pivotResult, dataFields); + } + return transformDataForChart(pivotResult, dataFields); + }, [pivotResult, dataFields, config.type]); + + // 열 이름 목록 (파이 차트 제외) + const columns = useMemo(() => { + if (config.type === "pie" || chartData.length === 0) return []; + + const firstItem = chartData[0]; + return Object.keys(firstItem).filter( + (key) => key !== "name" && key !== "path" + ); + }, [chartData, config.type]); + + const height = config.height || 300; + const showLegend = config.showLegend !== false; + + if (!config.enabled) { + return null; + } + + return ( +
+ {/* 차트 렌더링 */} + {config.type === "bar" && ( + + )} + + {config.type === "stackedBar" && ( + + )} + + {config.type === "line" && ( + + )} + + {config.type === "area" && ( + + )} + + {config.type === "pie" && ( + + )} +
+ ); +}; + +export default PivotChart; + diff --git a/frontend/lib/registry/components/pivot-grid/components/index.ts b/frontend/lib/registry/components/pivot-grid/components/index.ts new file mode 100644 index 00000000..a901a7cf --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/index.ts @@ -0,0 +1,10 @@ +/** + * PivotGrid 서브 컴포넌트 내보내기 + */ + +export { FieldPanel } from "./FieldPanel"; +export { FieldChooser } from "./FieldChooser"; +export { DrillDownModal } from "./DrillDownModal"; +export { FilterPopup } from "./FilterPopup"; +export { PivotChart } from "./PivotChart"; + diff --git a/frontend/lib/registry/components/pivot-grid/hooks/index.ts b/frontend/lib/registry/components/pivot-grid/hooks/index.ts new file mode 100644 index 00000000..a9a1a4eb --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/hooks/index.ts @@ -0,0 +1,27 @@ +/** + * PivotGrid 커스텀 훅 내보내기 + */ + +export { + useVirtualScroll, + useVirtualColumnScroll, + useVirtual2DScroll, +} from "./useVirtualScroll"; + +export type { + VirtualScrollOptions, + VirtualScrollResult, + VirtualColumnScrollOptions, + VirtualColumnScrollResult, + Virtual2DScrollOptions, + Virtual2DScrollResult, +} from "./useVirtualScroll"; + +export { usePivotState } from "./usePivotState"; + +export type { + PivotStateConfig, + SavedPivotState, + UsePivotStateResult, +} from "./usePivotState"; + diff --git a/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts b/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts new file mode 100644 index 00000000..9b001377 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts @@ -0,0 +1,231 @@ +"use client"; + +/** + * PivotState 훅 + * 피벗 그리드 상태 저장/복원 관리 + */ + +import { useState, useEffect, useCallback } from "react"; +import { PivotFieldConfig, PivotGridState } from "../types"; + +// ==================== 타입 ==================== + +export interface PivotStateConfig { + enabled: boolean; + storageKey?: string; + storageType?: "localStorage" | "sessionStorage"; +} + +export interface SavedPivotState { + version: string; + timestamp: number; + fields: PivotFieldConfig[]; + expandedRowPaths: string[][]; + expandedColumnPaths: string[][]; + filterConfig: Record; + sortConfig: { + field: string; + direction: "asc" | "desc"; + } | null; +} + +export interface UsePivotStateResult { + // 상태 + fields: PivotFieldConfig[]; + pivotState: PivotGridState; + + // 상태 변경 + setFields: (fields: PivotFieldConfig[]) => void; + setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void; + + // 저장/복원 + saveState: () => void; + loadState: () => boolean; + clearState: () => void; + hasStoredState: () => boolean; + + // 상태 정보 + lastSaved: Date | null; + isDirty: boolean; +} + +// ==================== 상수 ==================== + +const STATE_VERSION = "1.0.0"; +const DEFAULT_STORAGE_KEY = "pivot-grid-state"; + +// ==================== 훅 ==================== + +export function usePivotState( + initialFields: PivotFieldConfig[], + config: PivotStateConfig +): UsePivotStateResult { + const { + enabled, + storageKey = DEFAULT_STORAGE_KEY, + storageType = "localStorage", + } = config; + + // 상태 + const [fields, setFieldsInternal] = useState(initialFields); + const [pivotState, setPivotStateInternal] = useState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + const [lastSaved, setLastSaved] = useState(null); + const [isDirty, setIsDirty] = useState(false); + const [initialStateLoaded, setInitialStateLoaded] = useState(false); + + // 스토리지 가져오기 + const getStorage = useCallback(() => { + if (typeof window === "undefined") return null; + return storageType === "localStorage" ? localStorage : sessionStorage; + }, [storageType]); + + // 저장된 상태 확인 + const hasStoredState = useCallback((): boolean => { + const storage = getStorage(); + if (!storage) return false; + return storage.getItem(storageKey) !== null; + }, [getStorage, storageKey]); + + // 상태 저장 + const saveState = useCallback(() => { + if (!enabled) return; + + const storage = getStorage(); + if (!storage) return; + + const stateToSave: SavedPivotState = { + version: STATE_VERSION, + timestamp: Date.now(), + fields, + expandedRowPaths: pivotState.expandedRowPaths, + expandedColumnPaths: pivotState.expandedColumnPaths, + filterConfig: pivotState.filterConfig, + sortConfig: pivotState.sortConfig, + }; + + try { + storage.setItem(storageKey, JSON.stringify(stateToSave)); + setLastSaved(new Date()); + setIsDirty(false); + console.log("✅ 피벗 상태 저장됨:", storageKey); + } catch (error) { + console.error("❌ 피벗 상태 저장 실패:", error); + } + }, [enabled, getStorage, storageKey, fields, pivotState]); + + // 상태 불러오기 + const loadState = useCallback((): boolean => { + if (!enabled) return false; + + const storage = getStorage(); + if (!storage) return false; + + try { + const saved = storage.getItem(storageKey); + if (!saved) return false; + + const parsedState: SavedPivotState = JSON.parse(saved); + + // 버전 체크 + if (parsedState.version !== STATE_VERSION) { + console.warn("⚠️ 저장된 상태 버전이 다름, 무시됨"); + return false; + } + + // 상태 복원 + setFieldsInternal(parsedState.fields); + setPivotStateInternal({ + expandedRowPaths: parsedState.expandedRowPaths, + expandedColumnPaths: parsedState.expandedColumnPaths, + sortConfig: parsedState.sortConfig, + filterConfig: parsedState.filterConfig, + }); + setLastSaved(new Date(parsedState.timestamp)); + setIsDirty(false); + + console.log("✅ 피벗 상태 복원됨:", storageKey); + return true; + } catch (error) { + console.error("❌ 피벗 상태 복원 실패:", error); + return false; + } + }, [enabled, getStorage, storageKey]); + + // 상태 초기화 + const clearState = useCallback(() => { + const storage = getStorage(); + if (!storage) return; + + try { + storage.removeItem(storageKey); + setLastSaved(null); + console.log("🗑️ 피벗 상태 삭제됨:", storageKey); + } catch (error) { + console.error("❌ 피벗 상태 삭제 실패:", error); + } + }, [getStorage, storageKey]); + + // 필드 변경 (dirty 플래그 설정) + const setFields = useCallback((newFields: PivotFieldConfig[]) => { + setFieldsInternal(newFields); + setIsDirty(true); + }, []); + + // 피벗 상태 변경 (dirty 플래그 설정) + const setPivotState = useCallback( + (newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => { + setPivotStateInternal(newState); + setIsDirty(true); + }, + [] + ); + + // 초기 로드 + useEffect(() => { + if (!initialStateLoaded && enabled && hasStoredState()) { + loadState(); + setInitialStateLoaded(true); + } + }, [enabled, hasStoredState, loadState, initialStateLoaded]); + + // 초기 필드 동기화 (저장된 상태가 없을 때) + useEffect(() => { + if (initialStateLoaded) return; + if (!hasStoredState() && initialFields.length > 0) { + setFieldsInternal(initialFields); + setInitialStateLoaded(true); + } + }, [initialFields, hasStoredState, initialStateLoaded]); + + // 자동 저장 (변경 시) + useEffect(() => { + if (!enabled || !isDirty) return; + + const timeout = setTimeout(() => { + saveState(); + }, 1000); // 1초 디바운스 + + return () => clearTimeout(timeout); + }, [enabled, isDirty, saveState]); + + return { + fields, + pivotState, + setFields, + setPivotState, + saveState, + loadState, + clearState, + hasStoredState, + lastSaved, + isDirty, + }; +} + +export default usePivotState; + diff --git a/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts new file mode 100644 index 00000000..152cb2df --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts @@ -0,0 +1,312 @@ +"use client"; + +/** + * Virtual Scroll 훅 + * 대용량 피벗 데이터의 가상 스크롤 처리 + */ + +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; + +// ==================== 타입 ==================== + +export interface VirtualScrollOptions { + itemCount: number; // 전체 아이템 수 + itemHeight: number; // 각 아이템 높이 (px) + containerHeight: number; // 컨테이너 높이 (px) + overscan?: number; // 버퍼 아이템 수 (기본: 5) +} + +export interface VirtualScrollResult { + // 현재 보여야 할 아이템 범위 + startIndex: number; + endIndex: number; + + // 가상 스크롤 관련 값 + totalHeight: number; // 전체 높이 + offsetTop: number; // 상단 오프셋 + + // 보여지는 아이템 목록 + visibleItems: number[]; + + // 이벤트 핸들러 + onScroll: (scrollTop: number) => void; + + // 컨테이너 ref + containerRef: React.RefObject; +} + +// ==================== 훅 ==================== + +export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult { + const { + itemCount, + itemHeight, + containerHeight, + overscan = 5, + } = options; + + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + + // 보이는 아이템 수 + const visibleCount = Math.ceil(containerHeight / itemHeight); + + // 시작/끝 인덱스 계산 + const { startIndex, endIndex } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const end = Math.min( + itemCount - 1, + Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan + ); + return { startIndex: start, endIndex: end }; + }, [scrollTop, itemHeight, containerHeight, itemCount, overscan]); + + // 전체 높이 + const totalHeight = itemCount * itemHeight; + + // 상단 오프셋 + const offsetTop = startIndex * itemHeight; + + // 보이는 아이템 인덱스 배열 + const visibleItems = useMemo(() => { + const items: number[] = []; + for (let i = startIndex; i <= endIndex; i++) { + items.push(i); + } + return items; + }, [startIndex, endIndex]); + + // 스크롤 핸들러 + const onScroll = useCallback((newScrollTop: number) => { + setScrollTop(newScrollTop); + }, []); + + // 스크롤 이벤트 리스너 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + setScrollTop(container.scrollTop); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, []); + + return { + startIndex, + endIndex, + totalHeight, + offsetTop, + visibleItems, + onScroll, + containerRef, + }; +} + +// ==================== 열 가상 스크롤 ==================== + +export interface VirtualColumnScrollOptions { + columnCount: number; // 전체 열 수 + columnWidth: number; // 각 열 너비 (px) + containerWidth: number; // 컨테이너 너비 (px) + overscan?: number; +} + +export interface VirtualColumnScrollResult { + startIndex: number; + endIndex: number; + totalWidth: number; + offsetLeft: number; + visibleColumns: number[]; + onScroll: (scrollLeft: number) => void; +} + +export function useVirtualColumnScroll( + options: VirtualColumnScrollOptions +): VirtualColumnScrollResult { + const { + columnCount, + columnWidth, + containerWidth, + overscan = 3, + } = options; + + const [scrollLeft, setScrollLeft] = useState(0); + + const { startIndex, endIndex } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan); + const end = Math.min( + columnCount - 1, + Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan + ); + return { startIndex: start, endIndex: end }; + }, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]); + + const totalWidth = columnCount * columnWidth; + const offsetLeft = startIndex * columnWidth; + + const visibleColumns = useMemo(() => { + const cols: number[] = []; + for (let i = startIndex; i <= endIndex; i++) { + cols.push(i); + } + return cols; + }, [startIndex, endIndex]); + + const onScroll = useCallback((newScrollLeft: number) => { + setScrollLeft(newScrollLeft); + }, []); + + return { + startIndex, + endIndex, + totalWidth, + offsetLeft, + visibleColumns, + onScroll, + }; +} + +// ==================== 2D 가상 스크롤 (행 + 열) ==================== + +export interface Virtual2DScrollOptions { + rowCount: number; + columnCount: number; + rowHeight: number; + columnWidth: number; + containerHeight: number; + containerWidth: number; + rowOverscan?: number; + columnOverscan?: number; +} + +export interface Virtual2DScrollResult { + // 행 범위 + rowStartIndex: number; + rowEndIndex: number; + totalHeight: number; + offsetTop: number; + visibleRows: number[]; + + // 열 범위 + columnStartIndex: number; + columnEndIndex: number; + totalWidth: number; + offsetLeft: number; + visibleColumns: number[]; + + // 스크롤 핸들러 + onScroll: (scrollTop: number, scrollLeft: number) => void; + + // 컨테이너 ref + containerRef: React.RefObject; +} + +export function useVirtual2DScroll( + options: Virtual2DScrollOptions +): Virtual2DScrollResult { + const { + rowCount, + columnCount, + rowHeight, + columnWidth, + containerHeight, + containerWidth, + rowOverscan = 5, + columnOverscan = 3, + } = options; + + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + + // 행 계산 + const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan); + const end = Math.min( + rowCount - 1, + Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan + ); + + const rows: number[] = []; + for (let i = start; i <= end; i++) { + rows.push(i); + } + + return { + rowStartIndex: start, + rowEndIndex: end, + visibleRows: rows, + }; + }, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]); + + // 열 계산 + const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan); + const end = Math.min( + columnCount - 1, + Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan + ); + + const cols: number[] = []; + for (let i = start; i <= end; i++) { + cols.push(i); + } + + return { + columnStartIndex: start, + columnEndIndex: end, + visibleColumns: cols, + }; + }, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]); + + const totalHeight = rowCount * rowHeight; + const totalWidth = columnCount * columnWidth; + const offsetTop = rowStartIndex * rowHeight; + const offsetLeft = columnStartIndex * columnWidth; + + const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => { + setScrollTop(newScrollTop); + setScrollLeft(newScrollLeft); + }, []); + + // 스크롤 이벤트 리스너 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + setScrollTop(container.scrollTop); + setScrollLeft(container.scrollLeft); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, []); + + return { + rowStartIndex, + rowEndIndex, + totalHeight, + offsetTop, + visibleRows, + columnStartIndex, + columnEndIndex, + totalWidth, + offsetLeft, + visibleColumns, + onScroll, + containerRef, + }; +} + +export default useVirtualScroll; + diff --git a/frontend/lib/registry/components/pivot-grid/index.ts b/frontend/lib/registry/components/pivot-grid/index.ts index 16044dbc..b1bbe99b 100644 --- a/frontend/lib/registry/components/pivot-grid/index.ts +++ b/frontend/lib/registry/components/pivot-grid/index.ts @@ -8,6 +8,7 @@ export type { // 기본 타입 PivotAreaType, AggregationType, + SummaryDisplayMode, SortDirection, DateGroupInterval, FieldDataType, diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts index e0ea3199..e711a255 100644 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -11,6 +11,19 @@ export type PivotAreaType = "row" | "column" | "data" | "filter"; // 집계 함수 타입 export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct"; +// 요약 표시 모드 +export type SummaryDisplayMode = + | "absoluteValue" // 절대값 (기본) + | "percentOfColumnTotal" // 열 총계 대비 % + | "percentOfRowTotal" // 행 총계 대비 % + | "percentOfGrandTotal" // 전체 총계 대비 % + | "percentOfColumnGrandTotal" // 열 대총계 대비 % + | "percentOfRowGrandTotal" // 행 대총계 대비 % + | "runningTotalByRow" // 행 방향 누계 + | "runningTotalByColumn" // 열 방향 누계 + | "differenceFromPrevious" // 이전 대비 차이 + | "percentDifferenceFromPrevious"; // 이전 대비 % 차이 + // 정렬 방향 export type SortDirection = "asc" | "desc" | "none"; @@ -48,6 +61,8 @@ export interface PivotFieldConfig { // 집계 설정 (data 영역용) summaryType?: AggregationType; // 집계 함수 + summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드 + showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭) // 정렬 설정 sortBy?: "value" | "caption"; // 정렬 기준 @@ -151,6 +166,45 @@ export interface PivotChartConfig { animate?: boolean; } +// 조건부 서식 규칙 +export interface ConditionalFormatRule { + id: string; + type: "colorScale" | "dataBar" | "iconSet" | "cellValue"; + field?: string; // 적용할 데이터 필드 (없으면 전체) + + // colorScale: 값 범위에 따른 색상 그라데이션 + colorScale?: { + minColor: string; // 최소값 색상 (예: "#ff0000") + midColor?: string; // 중간값 색상 (선택) + maxColor: string; // 최대값 색상 (예: "#00ff00") + }; + + // dataBar: 값에 따른 막대 표시 + dataBar?: { + color: string; // 막대 색상 + showValue?: boolean; // 값 표시 여부 + minValue?: number; // 최소값 (없으면 자동) + maxValue?: number; // 최대값 (없으면 자동) + }; + + // iconSet: 값에 따른 아이콘 표시 + iconSet?: { + type: "arrows" | "traffic" | "rating" | "flags"; + thresholds: number[]; // 경계값 (예: [30, 70] = 0-30, 30-70, 70-100) + reverse?: boolean; // 아이콘 순서 반전 + }; + + // cellValue: 조건에 따른 스타일 + cellValue?: { + operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between"; + value1: number; + value2?: number; // between 연산자용 + backgroundColor?: string; + textColor?: string; + bold?: boolean; + }; +} + // 스타일 설정 export interface PivotStyleConfig { theme: "default" | "compact" | "modern"; @@ -159,6 +213,7 @@ export interface PivotStyleConfig { borderStyle: "none" | "light" | "heavy"; alternateRowColors?: boolean; highlightTotals?: boolean; // 총합계 강조 + conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙 } // ==================== 내보내기 설정 ==================== diff --git a/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts b/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts new file mode 100644 index 00000000..a9195d92 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts @@ -0,0 +1,311 @@ +/** + * 조건부 서식 유틸리티 + * 셀 값에 따른 스타일 계산 + */ + +import { ConditionalFormatRule } from "../types"; + +// ==================== 타입 ==================== + +export interface CellFormatStyle { + backgroundColor?: string; + textColor?: string; + fontWeight?: string; + dataBarWidth?: number; // 0-100% + dataBarColor?: string; + icon?: string; // 이모지 또는 아이콘 이름 +} + +// ==================== 색상 유틸리티 ==================== + +/** + * HEX 색상을 RGB로 변환 + */ +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; +} + +/** + * RGB를 HEX로 변환 + */ +function rgbToHex(r: number, g: number, b: number): string { + return ( + "#" + + [r, g, b] + .map((x) => { + const hex = Math.round(x).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }) + .join("") + ); +} + +/** + * 두 색상 사이의 보간 + */ +function interpolateColor( + color1: string, + color2: string, + factor: number +): string { + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + + if (!rgb1 || !rgb2) return color1; + + const r = rgb1.r + (rgb2.r - rgb1.r) * factor; + const g = rgb1.g + (rgb2.g - rgb1.g) * factor; + const b = rgb1.b + (rgb2.b - rgb1.b) * factor; + + return rgbToHex(r, g, b); +} + +// ==================== 조건부 서식 계산 ==================== + +/** + * Color Scale 스타일 계산 + */ +function applyColorScale( + value: number, + minValue: number, + maxValue: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.colorScale) return {}; + + const { minColor, midColor, maxColor } = rule.colorScale; + const range = maxValue - minValue; + + if (range === 0) { + return { backgroundColor: minColor }; + } + + const normalizedValue = (value - minValue) / range; + + let backgroundColor: string; + + if (midColor) { + // 3색 그라데이션 + if (normalizedValue <= 0.5) { + backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2); + } else { + backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2); + } + } else { + // 2색 그라데이션 + backgroundColor = interpolateColor(minColor, maxColor, normalizedValue); + } + + // 배경색에 따른 텍스트 색상 결정 + const rgb = hexToRgb(backgroundColor); + const textColor = + rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186 + ? "#000000" + : "#ffffff"; + + return { backgroundColor, textColor }; +} + +/** + * Data Bar 스타일 계산 + */ +function applyDataBar( + value: number, + minValue: number, + maxValue: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.dataBar) return {}; + + const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar; + + const min = ruleMin ?? minValue; + const max = ruleMax ?? maxValue; + const range = max - min; + + if (range === 0) { + return { dataBarWidth: 100, dataBarColor: color }; + } + + const width = Math.max(0, Math.min(100, ((value - min) / range) * 100)); + + return { + dataBarWidth: width, + dataBarColor: color, + }; +} + +/** + * Icon Set 스타일 계산 + */ +function applyIconSet( + value: number, + minValue: number, + maxValue: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.iconSet) return {}; + + const { type, thresholds, reverse } = rule.iconSet; + const range = maxValue - minValue; + const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100; + + // 아이콘 정의 + const iconSets: Record = { + arrows: ["↓", "→", "↑"], + traffic: ["🔴", "🟡", "🟢"], + rating: ["⭐", "⭐⭐", "⭐⭐⭐"], + flags: ["🚩", "🏳️", "🏁"], + }; + + const icons = iconSets[type] || iconSets.arrows; + const sortedIcons = reverse ? [...icons].reverse() : icons; + + // 임계값에 따른 아이콘 선택 + let iconIndex = 0; + for (let i = 0; i < thresholds.length; i++) { + if (percentage >= thresholds[i]) { + iconIndex = i + 1; + } + } + iconIndex = Math.min(iconIndex, sortedIcons.length - 1); + + return { + icon: sortedIcons[iconIndex], + }; +} + +/** + * Cell Value 조건 스타일 계산 + */ +function applyCellValue( + value: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.cellValue) return {}; + + const { operator, value1, value2, backgroundColor, textColor, bold } = + rule.cellValue; + + let matches = false; + + switch (operator) { + case ">": + matches = value > value1; + break; + case ">=": + matches = value >= value1; + break; + case "<": + matches = value < value1; + break; + case "<=": + matches = value <= value1; + break; + case "=": + matches = value === value1; + break; + case "!=": + matches = value !== value1; + break; + case "between": + matches = value2 !== undefined && value >= value1 && value <= value2; + break; + } + + if (!matches) return {}; + + return { + backgroundColor, + textColor, + fontWeight: bold ? "bold" : undefined, + }; +} + +// ==================== 메인 함수 ==================== + +/** + * 조건부 서식 적용 + */ +export function getConditionalStyle( + value: number | null | undefined, + field: string, + rules: ConditionalFormatRule[], + allValues: number[] // 해당 필드의 모든 값 (min/max 계산용) +): CellFormatStyle { + if (value === null || value === undefined || isNaN(value)) { + return {}; + } + + if (!rules || rules.length === 0) { + return {}; + } + + // min/max 계산 + const numericValues = allValues.filter((v) => !isNaN(v)); + const minValue = Math.min(...numericValues); + const maxValue = Math.max(...numericValues); + + let resultStyle: CellFormatStyle = {}; + + // 해당 필드에 적용되는 규칙 필터링 및 적용 + for (const rule of rules) { + // 필드 필터 확인 + if (rule.field && rule.field !== field) { + continue; + } + + let ruleStyle: CellFormatStyle = {}; + + switch (rule.type) { + case "colorScale": + ruleStyle = applyColorScale(value, minValue, maxValue, rule); + break; + case "dataBar": + ruleStyle = applyDataBar(value, minValue, maxValue, rule); + break; + case "iconSet": + ruleStyle = applyIconSet(value, minValue, maxValue, rule); + break; + case "cellValue": + ruleStyle = applyCellValue(value, rule); + break; + } + + // 스타일 병합 (나중 규칙이 우선) + resultStyle = { ...resultStyle, ...ruleStyle }; + } + + return resultStyle; +} + +/** + * 조건부 서식 스타일을 React 스타일 객체로 변환 + */ +export function formatStyleToReact( + style: CellFormatStyle +): React.CSSProperties { + const result: React.CSSProperties = {}; + + if (style.backgroundColor) { + result.backgroundColor = style.backgroundColor; + } + if (style.textColor) { + result.color = style.textColor; + } + if (style.fontWeight) { + result.fontWeight = style.fontWeight as any; + } + + return result; +} + +export default getConditionalStyle; + diff --git a/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts b/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts new file mode 100644 index 00000000..6069a3a5 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts @@ -0,0 +1,202 @@ +/** + * Excel 내보내기 유틸리티 + * 피벗 테이블 데이터를 Excel 파일로 내보내기 + * xlsx 라이브러리 사용 (브라우저 호환) + */ + +import * as XLSX from "xlsx"; +import { + PivotResult, + PivotFieldConfig, + PivotTotalsConfig, +} from "../types"; +import { pathToKey } from "./pivotEngine"; + +// ==================== 타입 ==================== + +export interface ExportOptions { + fileName?: string; + sheetName?: string; + title?: string; + subtitle?: string; + includeHeaders?: boolean; + includeTotals?: boolean; +} + +// ==================== 메인 함수 ==================== + +/** + * 피벗 데이터를 Excel로 내보내기 + */ +export async function exportPivotToExcel( + pivotResult: PivotResult, + fields: PivotFieldConfig[], + totals: PivotTotalsConfig, + options: ExportOptions = {} +): Promise { + const { + fileName = "pivot_export", + sheetName = "Pivot", + title, + includeHeaders = true, + includeTotals = true, + } = options; + + // 필드 분류 + const rowFields = fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + // 데이터 배열 생성 + const data: any[][] = []; + + // 제목 추가 + if (title) { + data.push([title]); + data.push([]); // 빈 행 + } + + // 헤더 행 + if (includeHeaders) { + const headerRow: any[] = [ + rowFields.map((f) => f.caption).join(" / ") || "항목", + ]; + + // 열 헤더 + for (const col of pivotResult.flatColumns) { + headerRow.push(col.caption || "(전체)"); + } + + // 총계 헤더 + if (totals?.showRowGrandTotals && includeTotals) { + headerRow.push("총계"); + } + + data.push(headerRow); + } + + // 데이터 행 + for (const row of pivotResult.flatRows) { + const excelRow: any[] = []; + + // 행 헤더 (들여쓰기 포함) + const indent = " ".repeat(row.level); + excelRow.push(indent + row.caption); + + // 데이터 셀 + for (const col of pivotResult.flatColumns) { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = pivotResult.dataMatrix.get(cellKey); + + if (values && values.length > 0) { + excelRow.push(values[0].value); + } else { + excelRow.push(""); + } + } + + // 행 총계 + if (totals?.showRowGrandTotals && includeTotals) { + const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path)); + if (rowTotal && rowTotal.length > 0) { + excelRow.push(rowTotal[0].value); + } else { + excelRow.push(""); + } + } + + data.push(excelRow); + } + + // 열 총계 행 + if (totals?.showColumnGrandTotals && includeTotals) { + const totalRow: any[] = ["총계"]; + + for (const col of pivotResult.flatColumns) { + const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path)); + if (colTotal && colTotal.length > 0) { + totalRow.push(colTotal[0].value); + } else { + totalRow.push(""); + } + } + + // 대총합 + if (totals?.showRowGrandTotals) { + const grandTotal = pivotResult.grandTotals.grand; + if (grandTotal && grandTotal.length > 0) { + totalRow.push(grandTotal[0].value); + } else { + totalRow.push(""); + } + } + + data.push(totalRow); + } + + // 워크시트 생성 + const worksheet = XLSX.utils.aoa_to_sheet(data); + + // 컬럼 너비 설정 + const colWidths: XLSX.ColInfo[] = []; + const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0); + for (let i = 0; i < maxCols; i++) { + colWidths.push({ wch: i === 0 ? 25 : 15 }); + } + worksheet["!cols"] = colWidths; + + // 워크북 생성 + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // 파일 다운로드 + XLSX.writeFile(workbook, `${fileName}.xlsx`); +} + +/** + * Drill Down 데이터를 Excel로 내보내기 + */ +export async function exportDrillDownToExcel( + data: any[], + columns: { field: string; caption: string }[], + options: ExportOptions = {} +): Promise { + const { + fileName = "drilldown_export", + sheetName = "Data", + title, + } = options; + + // 데이터 배열 생성 + const sheetData: any[][] = []; + + // 제목 + if (title) { + sheetData.push([title]); + sheetData.push([]); // 빈 행 + } + + // 헤더 + const headerRow = columns.map((col) => col.caption); + sheetData.push(headerRow); + + // 데이터 + for (const row of data) { + const dataRow = columns.map((col) => row[col.field] ?? ""); + sheetData.push(dataRow); + } + + // 워크시트 생성 + const worksheet = XLSX.utils.aoa_to_sheet(sheetData); + + // 컬럼 너비 설정 + const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 })); + worksheet["!cols"] = colWidths; + + // 워크북 생성 + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // 파일 다운로드 + XLSX.writeFile(workbook, `${fileName}.xlsx`); +} diff --git a/frontend/lib/registry/components/pivot-grid/utils/index.ts b/frontend/lib/registry/components/pivot-grid/utils/index.ts index f832187e..2c0a83d6 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/index.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/index.ts @@ -1,4 +1,6 @@ export * from "./aggregation"; export * from "./pivotEngine"; +export * from "./exportExcel"; +export * from "./conditionalFormat"; diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 18113066..4d3fecfd 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -12,6 +12,7 @@ import { PivotCellValue, DateGroupInterval, AggregationType, + SummaryDisplayMode, } from "../types"; import { aggregate, formatNumber, formatDate } from "./aggregation"; @@ -418,6 +419,185 @@ function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] { return leaves; } +// ==================== Summary Display Mode 적용 ==================== + +/** + * Summary Display Mode에 따른 값 변환 + */ +function applyDisplayMode( + value: number, + displayMode: SummaryDisplayMode | undefined, + rowTotal: number, + columnTotal: number, + grandTotal: number, + prevValue: number | null, + runningTotal: number, + format?: PivotFieldConfig["format"] +): { value: number; formattedValue: string } { + if (!displayMode || displayMode === "absoluteValue") { + return { + value, + formattedValue: formatNumber(value, format), + }; + } + + let resultValue: number; + let formatOverride: PivotFieldConfig["format"] | undefined; + + switch (displayMode) { + case "percentOfRowTotal": + resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfColumnTotal": + resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfGrandTotal": + resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfRowGrandTotal": + resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfColumnGrandTotal": + resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "runningTotalByRow": + case "runningTotalByColumn": + resultValue = runningTotal; + break; + + case "differenceFromPrevious": + resultValue = prevValue === null ? 0 : value - prevValue; + break; + + case "percentDifferenceFromPrevious": + resultValue = prevValue === null || prevValue === 0 + ? 0 + : ((value - prevValue) / Math.abs(prevValue)) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + default: + resultValue = value; + } + + return { + value: resultValue, + formattedValue: formatNumber(resultValue, formatOverride || format), + }; +} + +/** + * 데이터 매트릭스에 Summary Display Mode 적용 + */ +function applyDisplayModeToMatrix( + matrix: Map, + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][], + rowTotals: Map, + columnTotals: Map, + grandTotals: PivotCellValue[] +): Map { + // displayMode가 있는 데이터 필드가 있는지 확인 + const hasDisplayMode = dataFields.some( + (df) => df.summaryDisplayMode || df.showValuesAs + ); + if (!hasDisplayMode) return matrix; + + const newMatrix = new Map(); + + // 누계를 위한 추적 (행별, 열별) + const rowRunningTotals: Map = new Map(); // fieldIndex -> 누계 + const colRunningTotals: Map> = new Map(); // colKey -> fieldIndex -> 누계 + + // 행 순서대로 처리 + for (const row of flatRows) { + // 이전 열 값 추적 (차이 계산용) + const prevColValues: (number | null)[] = dataFields.map(() => null); + + for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) { + const colPath = flatColumnLeaves[colIdx]; + const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; + const values = matrix.get(cellKey); + + if (!values) { + newMatrix.set(cellKey, []); + continue; + } + + const rowKey = pathToKey(row.path); + const colKey = pathToKey(colPath); + + // 총합 가져오기 + const rowTotal = rowTotals.get(rowKey); + const colTotal = columnTotals.get(colKey); + + const newValues: PivotCellValue[] = values.map((val, fieldIdx) => { + const dataField = dataFields[fieldIdx]; + const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs; + + if (!displayMode || displayMode === "absoluteValue") { + prevColValues[fieldIdx] = val.value; + return val; + } + + // 누계 계산 + // 행 방향 누계 + if (!rowRunningTotals.has(rowKey)) { + rowRunningTotals.set(rowKey, dataFields.map(() => 0)); + } + const rowRunning = rowRunningTotals.get(rowKey)!; + rowRunning[fieldIdx] += val.value || 0; + + // 열 방향 누계 + if (!colRunningTotals.has(colKey)) { + colRunningTotals.set(colKey, new Map()); + } + const colRunning = colRunningTotals.get(colKey)!; + if (!colRunning.has(fieldIdx)) { + colRunning.set(fieldIdx, 0); + } + colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0)); + + const result = applyDisplayMode( + val.value || 0, + displayMode, + rowTotal?.[fieldIdx]?.value || 0, + colTotal?.[fieldIdx]?.value || 0, + grandTotals[fieldIdx]?.value || 0, + prevColValues[fieldIdx], + displayMode === "runningTotalByRow" + ? rowRunning[fieldIdx] + : colRunning.get(fieldIdx) || 0, + dataField.format + ); + + prevColValues[fieldIdx] = val.value; + + return { + field: val.field, + value: result.value, + formattedValue: result.formattedValue, + }; + }); + + newMatrix.set(cellKey, newValues); + } + } + + return newMatrix; +} + // ==================== 총합계 계산 ==================== /** @@ -584,7 +764,7 @@ export function processPivotData( const flatColumns = flattenColumns(columnHeaders, maxColumnLevel); // 데이터 매트릭스 생성 - const dataMatrix = buildDataMatrix( + let dataMatrix = buildDataMatrix( filteredData, rowFields, columnFields, @@ -603,6 +783,17 @@ export function processPivotData( flatColumnLeaves ); + // Summary Display Mode 적용 + dataMatrix = applyDisplayModeToMatrix( + dataMatrix, + dataFields, + flatRows, + flatColumnLeaves, + grandTotals.row, + grandTotals.column, + grandTotals.grand + ); + return { rowHeaders, columnHeaders, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e4f5a1fd..f0ef7c70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -59,6 +59,7 @@ "date-fns": "^4.1.0", "docx": "^9.5.1", "docx-preview": "^0.3.6", + "exceljs": "^4.4.0", "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "isomorphic-dompurify": "^2.28.0", @@ -542,6 +543,47 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -6963,6 +7005,59 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -7158,6 +7253,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -7225,7 +7326,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-arraybuffer": { @@ -7266,6 +7366,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -7275,6 +7384,68 @@ "node": "*" } }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -7285,7 +7456,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -7329,6 +7499,32 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -7501,6 +7697,18 @@ "node": ">=0.8" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7665,11 +7873,39 @@ "node": ">= 10" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concaveman": { @@ -7731,6 +7967,33 @@ "node": ">=0.8" } }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -8323,6 +8586,12 @@ "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -8605,6 +8874,15 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", @@ -8639,6 +8917,15 @@ "node": ">=14" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -9338,6 +9625,61 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/exit-on-epipe": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", @@ -9377,6 +9719,19 @@ "node": ">=8.0.0" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9586,6 +9941,34 @@ "node": ">=0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9773,6 +10156,27 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -9847,7 +10251,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -10121,6 +10524,17 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -10843,6 +11257,18 @@ "node": ">=0.10" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -11142,6 +11568,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11158,6 +11590,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -11165,6 +11664,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -11386,7 +11897,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -11399,12 +11909,23 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11557,6 +12078,15 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nypm": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", @@ -11707,6 +12237,15 @@ "dev": true, "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/option": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", @@ -12829,6 +13368,36 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -13086,6 +13655,19 @@ "node": ">= 0.8.15" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/robust-predicates": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", @@ -13891,6 +14473,36 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -14032,6 +14644,15 @@ "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -14107,6 +14728,15 @@ "node": ">=20" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/troika-three-text": { "version": "0.52.4", "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", @@ -14402,6 +15032,24 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -14754,6 +15402,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -14974,6 +15628,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index e9cf087c..1dc6c6fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,6 +67,7 @@ "date-fns": "^4.1.0", "docx": "^9.5.1", "docx-preview": "^0.3.6", + "exceljs": "^4.4.0", "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "isomorphic-dompurify": "^2.28.0",