그리드랑 노드에서 delete 가 where 입력했는데도 저장이 안되던 오류 해결
This commit is contained in:
parent
e8516d9d6b
commit
222a00b8a9
|
|
@ -42,6 +42,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/bwip-js": "^3.2.3",
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|
@ -3214,6 +3215,16 @@
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/compression": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/bwip-js": "^3.2.3",
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,6 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
|
||||||
// 🆕 연관 데이터 버튼 컴포넌트
|
// 🆕 연관 데이터 버튼 컴포넌트
|
||||||
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||||
|
|
||||||
// 🆕 피벗 그리드 컴포넌트
|
|
||||||
import "./pivot-grid/PivotGridRenderer"; // 다차원 데이터 분석 피벗 테이블
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PivotGridProps,
|
PivotGridProps,
|
||||||
|
|
@ -15,8 +15,15 @@ import {
|
||||||
PivotFlatRow,
|
PivotFlatRow,
|
||||||
PivotCellValue,
|
PivotCellValue,
|
||||||
PivotGridState,
|
PivotGridState,
|
||||||
|
PivotAreaType,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { processPivotData, pathToKey } from "./utils/pivotEngine";
|
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 {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -25,6 +32,9 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
|
LayoutGrid,
|
||||||
|
FileSpreadsheet,
|
||||||
|
BarChart3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
|
@ -79,13 +89,22 @@ interface DataCellProps {
|
||||||
values: PivotCellValue[];
|
values: PivotCellValue[];
|
||||||
isTotal?: boolean;
|
isTotal?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
|
conditionalStyle?: CellFormatStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DataCell: React.FC<DataCellProps> = ({
|
const DataCell: React.FC<DataCellProps> = ({
|
||||||
values,
|
values,
|
||||||
isTotal = false,
|
isTotal = false,
|
||||||
onClick,
|
onClick,
|
||||||
|
onDoubleClick,
|
||||||
|
conditionalStyle,
|
||||||
}) => {
|
}) => {
|
||||||
|
// 조건부 서식 스타일 계산
|
||||||
|
const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {};
|
||||||
|
const hasDataBar = conditionalStyle?.dataBarWidth !== undefined;
|
||||||
|
const icon = conditionalStyle?.icon;
|
||||||
|
|
||||||
if (!values || values.length === 0) {
|
if (!values || values.length === 0) {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
|
|
@ -94,6 +113,9 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
"px-2 py-1.5 text-right text-sm",
|
"px-2 py-1.5 text-right text-sm",
|
||||||
isTotal && "bg-primary/5 font-medium"
|
isTotal && "bg-primary/5 font-medium"
|
||||||
)}
|
)}
|
||||||
|
style={cellStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -105,14 +127,29 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-b border-border",
|
"border-r border-b border-border relative",
|
||||||
"px-2 py-1.5 text-right text-sm tabular-nums",
|
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||||
isTotal && "bg-primary/5 font-medium",
|
isTotal && "bg-primary/5 font-medium",
|
||||||
onClick && "cursor-pointer hover:bg-accent/50"
|
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
|
style={cellStyle}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
>
|
>
|
||||||
|
{/* Data Bar */}
|
||||||
|
{hasDataBar && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 opacity-30"
|
||||||
|
style={{
|
||||||
|
width: `${conditionalStyle?.dataBarWidth}%`,
|
||||||
|
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10 flex items-center justify-end gap-1">
|
||||||
|
{icon && <span>{icon}</span>}
|
||||||
{values[0].formattedValue}
|
{values[0].formattedValue}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -124,14 +161,28 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
<td
|
<td
|
||||||
key={idx}
|
key={idx}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-b border-border",
|
"border-r border-b border-border relative",
|
||||||
"px-2 py-1.5 text-right text-sm tabular-nums",
|
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||||
isTotal && "bg-primary/5 font-medium",
|
isTotal && "bg-primary/5 font-medium",
|
||||||
onClick && "cursor-pointer hover:bg-accent/50"
|
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
|
style={cellStyle}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
>
|
>
|
||||||
|
{hasDataBar && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 opacity-30"
|
||||||
|
style={{
|
||||||
|
width: `${conditionalStyle?.dataBarWidth}%`,
|
||||||
|
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10 flex items-center justify-end gap-1">
|
||||||
|
{icon && <span>{icon}</span>}
|
||||||
{val.formattedValue}
|
{val.formattedValue}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
@ -142,7 +193,7 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
|
|
||||||
export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
title,
|
title,
|
||||||
fields = [],
|
fields: initialFields = [],
|
||||||
totals = {
|
totals = {
|
||||||
showRowGrandTotals: true,
|
showRowGrandTotals: true,
|
||||||
showColumnGrandTotals: true,
|
showColumnGrandTotals: true,
|
||||||
|
|
@ -157,24 +208,49 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
alternateRowColors: true,
|
alternateRowColors: true,
|
||||||
highlightTotals: true,
|
highlightTotals: true,
|
||||||
},
|
},
|
||||||
|
fieldChooser,
|
||||||
|
chart: chartConfig,
|
||||||
allowExpandAll = true,
|
allowExpandAll = true,
|
||||||
height = "auto",
|
height = "auto",
|
||||||
maxHeight,
|
maxHeight,
|
||||||
exportConfig,
|
exportConfig,
|
||||||
data: externalData,
|
data: externalData,
|
||||||
onCellClick,
|
onCellClick,
|
||||||
|
onCellDoubleClick,
|
||||||
|
onFieldDrop,
|
||||||
onExpandChange,
|
onExpandChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
// 디버깅 로그
|
||||||
|
console.log("🔶 PivotGridComponent props:", {
|
||||||
|
title,
|
||||||
|
hasExternalData: !!externalData,
|
||||||
|
externalDataLength: externalData?.length,
|
||||||
|
initialFieldsLength: initialFields?.length,
|
||||||
|
});
|
||||||
// ==================== 상태 ====================
|
// ==================== 상태 ====================
|
||||||
|
|
||||||
|
const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields);
|
||||||
const [pivotState, setPivotState] = useState<PivotGridState>({
|
const [pivotState, setPivotState] = useState<PivotGridState>({
|
||||||
expandedRowPaths: [],
|
expandedRowPaths: [],
|
||||||
expandedColumnPaths: [],
|
expandedColumnPaths: [],
|
||||||
sortConfig: null,
|
sortConfig: null,
|
||||||
filterConfig: {},
|
filterConfig: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
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 || [];
|
const data = externalData || [];
|
||||||
|
|
@ -205,6 +281,43 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
[fields]
|
[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<PivotResult | null>(() => {
|
const pivotResult = useMemo<PivotResult | null>(() => {
|
||||||
|
|
@ -212,16 +325,83 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleFields = fields.filter((f) => f.visible !== false);
|
||||||
|
if (visibleFields.filter((f) => f.area !== "filter").length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return processPivotData(
|
return processPivotData(
|
||||||
data,
|
data,
|
||||||
fields,
|
visibleFields,
|
||||||
pivotState.expandedRowPaths,
|
pivotState.expandedRowPaths,
|
||||||
pivotState.expandedColumnPaths
|
pivotState.expandedColumnPaths
|
||||||
);
|
);
|
||||||
}, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
|
}, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
|
||||||
|
|
||||||
|
// 조건부 서식용 전체 값 수집
|
||||||
|
const allCellValues = useMemo(() => {
|
||||||
|
if (!pivotResult) return new Map<string, number[]>();
|
||||||
|
|
||||||
|
const valuesByField = new Map<string, number[]>();
|
||||||
|
|
||||||
|
// 데이터 매트릭스에서 모든 값 수집
|
||||||
|
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(
|
const handleToggleRowExpand = useCallback(
|
||||||
(path: string[]) => {
|
(path: string[]) => {
|
||||||
|
|
@ -256,7 +436,6 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
if (!pivotResult) return;
|
if (!pivotResult) return;
|
||||||
|
|
||||||
const allRowPaths: string[][] = [];
|
const allRowPaths: string[][] = [];
|
||||||
|
|
||||||
pivotResult.flatRows.forEach((row) => {
|
pivotResult.flatRows.forEach((row) => {
|
||||||
if (row.hasChildren) {
|
if (row.hasChildren) {
|
||||||
allRowPaths.push(row.path);
|
allRowPaths.push(row.path);
|
||||||
|
|
@ -296,6 +475,27 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
[onCellClick]
|
[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 내보내기
|
// CSV 내보내기
|
||||||
const handleExportCSV = useCallback(() => {
|
const handleExportCSV = useCallback(() => {
|
||||||
if (!pivotResult) return;
|
if (!pivotResult) return;
|
||||||
|
|
@ -354,6 +554,20 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
link.click();
|
link.click();
|
||||||
}, [pivotResult, totals, title]);
|
}, [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<PivotGridProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필드 미설정
|
// 필드 미설정
|
||||||
if (fields.length === 0) {
|
const hasActiveFields = fields.some(
|
||||||
|
(f) => f.visible !== false && f.area !== "filter"
|
||||||
|
);
|
||||||
|
if (!hasActiveFields) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center",
|
"flex flex-col",
|
||||||
"p-8 text-center text-muted-foreground",
|
"border border-border rounded-lg overflow-hidden bg-background"
|
||||||
"border border-dashed border-border rounded-lg"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* 필드 패널 */}
|
||||||
|
<FieldPanel
|
||||||
|
fields={fields}
|
||||||
|
onFieldsChange={handleFieldsChange}
|
||||||
|
collapsed={!showFieldPanel}
|
||||||
|
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 안내 메시지 */}
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center text-muted-foreground">
|
||||||
<Settings className="h-8 w-8 mb-2 opacity-50" />
|
<Settings className="h-8 w-8 mb-2 opacity-50" />
|
||||||
<p className="text-sm">필드가 설정되지 않았습니다</p>
|
<p className="text-sm">필드가 설정되지 않았습니다</p>
|
||||||
<p className="text-xs mt-1">
|
<p className="text-xs mt-1">
|
||||||
행, 열, 데이터 영역에 필드를 배치해주세요
|
행, 열, 데이터 영역에 필드를 배치해주세요
|
||||||
</p>
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => setShowFieldChooser(true)}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4 mr-2" />
|
||||||
|
필드 선택기 열기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 선택기 모달 */}
|
||||||
|
<FieldChooser
|
||||||
|
open={showFieldChooser}
|
||||||
|
onOpenChange={setShowFieldChooser}
|
||||||
|
availableFields={availableFields}
|
||||||
|
selectedFields={fields}
|
||||||
|
onFieldsChange={handleFieldsChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -416,6 +661,14 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
maxHeight: isFullscreen ? "none" : maxHeight,
|
maxHeight: isFullscreen ? "none" : maxHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */}
|
||||||
|
<FieldPanel
|
||||||
|
fields={fields}
|
||||||
|
onFieldsChange={handleFieldsChange}
|
||||||
|
collapsed={!showFieldPanel}
|
||||||
|
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 헤더 툴바 */}
|
{/* 헤더 툴바 */}
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -426,6 +679,30 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{/* 필드 선택기 버튼 */}
|
||||||
|
{fieldChooser?.enabled !== false && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={() => setShowFieldChooser(true)}
|
||||||
|
title="필드 선택기"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필드 패널 토글 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={() => setShowFieldPanel(!showFieldPanel)}
|
||||||
|
title={showFieldPanel ? "필드 패널 숨기기" : "필드 패널 보기"}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
{allowExpandAll && (
|
{allowExpandAll && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -450,7 +727,22 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 차트 토글 */}
|
||||||
|
{chartConfig && (
|
||||||
|
<Button
|
||||||
|
variant={showChart ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={() => setShowChart(!showChart)}
|
||||||
|
title={showChart ? "차트 숨기기" : "차트 보기"}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 내보내기 버튼들 */}
|
||||||
{exportConfig?.excel && (
|
{exportConfig?.excel && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -460,6 +752,16 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
title="Excel 내보내기"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -584,15 +886,25 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||||
const values = dataMatrix.get(cellKey) || [];
|
const values = dataMatrix.get(cellKey) || [];
|
||||||
|
|
||||||
|
// 조건부 서식 (첫 번째 값 기준)
|
||||||
|
const conditionalStyle =
|
||||||
|
values.length > 0 && values[0].field
|
||||||
|
? getCellConditionalStyle(values[0].value, values[0].field)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataCell
|
<DataCell
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
values={values}
|
values={values}
|
||||||
|
conditionalStyle={conditionalStyle}
|
||||||
onClick={
|
onClick={
|
||||||
onCellClick
|
onCellClick
|
||||||
? () => handleCellClick(row.path, col.path, values)
|
? () => handleCellClick(row.path, col.path, values)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onDoubleClick={() =>
|
||||||
|
handleCellDoubleClick(row.path, col.path, values)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -637,6 +949,38 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 차트 */}
|
||||||
|
{showChart && chartConfig && pivotResult && (
|
||||||
|
<PivotChart
|
||||||
|
pivotResult={pivotResult}
|
||||||
|
config={{
|
||||||
|
...chartConfig,
|
||||||
|
enabled: true,
|
||||||
|
}}
|
||||||
|
dataFields={dataFields}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필드 선택기 모달 */}
|
||||||
|
<FieldChooser
|
||||||
|
open={showFieldChooser}
|
||||||
|
onOpenChange={setShowFieldChooser}
|
||||||
|
availableFields={availableFields}
|
||||||
|
selectedFields={fields}
|
||||||
|
onFieldsChange={handleFieldsChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drill Down 모달 */}
|
||||||
|
<DrillDownModal
|
||||||
|
open={drillDownData.open}
|
||||||
|
onOpenChange={(open) => setDrillDownData((prev) => ({ ...prev, open }))}
|
||||||
|
cellData={drillDownData.cellData}
|
||||||
|
data={data}
|
||||||
|
fields={fields}
|
||||||
|
rowFields={rowFields}
|
||||||
|
columnFields={columnFields}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -431,14 +431,9 @@ const AreaFieldList: React.FC<AreaFieldListProps> = ({
|
||||||
) : (
|
) : (
|
||||||
availableColumns.map((col) => (
|
availableColumns.map((col) => (
|
||||||
<SelectItem key={col.column_name} value={col.column_name}>
|
<SelectItem key={col.column_name} value={col.column_name}>
|
||||||
<div className="flex items-center gap-2">
|
{col.column_comment
|
||||||
<span>{col.column_name}</span>
|
? `${col.column_name} (${col.column_comment})`
|
||||||
{col.column_comment && (
|
: col.column_name}
|
||||||
<span className="text-muted-foreground">
|
|
||||||
({col.column_comment})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -476,7 +471,8 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
setLoadingTables(true);
|
setLoadingTables(true);
|
||||||
try {
|
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) {
|
if (response.data.success) {
|
||||||
setTables(response.data.data || []);
|
setTables(response.data.data || []);
|
||||||
}
|
}
|
||||||
|
|
@ -499,8 +495,9 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
|
|
||||||
setLoadingColumns(true);
|
setLoadingColumns(true);
|
||||||
try {
|
try {
|
||||||
|
// apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/api/table-management/columns/${config.dataSource.tableName}`
|
`/table-management/tables/${config.dataSource.tableName}/columns`
|
||||||
);
|
);
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setColumns(response.data.data || []);
|
setColumns(response.data.data || []);
|
||||||
|
|
@ -550,14 +547,9 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
<SelectItem value="__none__">선택 안 함</SelectItem>
|
<SelectItem value="__none__">선택 안 함</SelectItem>
|
||||||
{tables.map((table) => (
|
{tables.map((table) => (
|
||||||
<SelectItem key={table.table_name} value={table.table_name}>
|
<SelectItem key={table.table_name} value={table.table_name}>
|
||||||
<div className="flex items-center gap-2">
|
{table.table_comment
|
||||||
<span>{table.table_name}</span>
|
? `${table.table_name} (${table.table_comment})`
|
||||||
{table.table_comment && (
|
: table.table_name}
|
||||||
<span className="text-muted-foreground">
|
|
||||||
({table.table_comment})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -717,6 +709,270 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* 차트 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">차트 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">차트 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.chart?.enabled === true}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateConfig({
|
||||||
|
chart: {
|
||||||
|
...config.chart,
|
||||||
|
enabled: v,
|
||||||
|
type: config.chart?.type || "bar",
|
||||||
|
position: config.chart?.position || "bottom",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.chart?.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">차트 유형</Label>
|
||||||
|
<Select
|
||||||
|
value={config.chart?.type || "bar"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateConfig({
|
||||||
|
chart: {
|
||||||
|
...config.chart,
|
||||||
|
enabled: true,
|
||||||
|
type: v as "bar" | "line" | "area" | "pie" | "stackedBar",
|
||||||
|
position: config.chart?.position || "bottom",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="bar">막대 차트</SelectItem>
|
||||||
|
<SelectItem value="stackedBar">누적 막대 차트</SelectItem>
|
||||||
|
<SelectItem value="line">선 차트</SelectItem>
|
||||||
|
<SelectItem value="area">영역 차트</SelectItem>
|
||||||
|
<SelectItem value="pie">파이 차트</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">차트 높이 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.chart?.height || 300}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">범례 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.chart?.showLegend !== false}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateConfig({
|
||||||
|
chart: {
|
||||||
|
...config.chart,
|
||||||
|
enabled: true,
|
||||||
|
type: config.chart?.type || "bar",
|
||||||
|
position: config.chart?.position || "bottom",
|
||||||
|
showLegend: v,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 필드 선택기 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">필드 선택기 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">필드 선택기 활성화</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.fieldChooser?.enabled !== false}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateConfig({
|
||||||
|
fieldChooser: { ...config.fieldChooser, enabled: v },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">검색 허용</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.fieldChooser?.allowSearch !== false}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateConfig({
|
||||||
|
fieldChooser: { ...config.fieldChooser, allowSearch: v },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 조건부 서식 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">조건부 서식</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">Color Scale (색상 그라데이션)</Label>
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
config.style?.conditionalFormats?.some(
|
||||||
|
(r) => 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">Data Bar (데이터 막대)</Label>
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
config.style?.conditionalFormats?.some(
|
||||||
|
(r) => 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">Icon Set (아이콘)</Label>
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
config.style?.conditionalFormats?.some(
|
||||||
|
(r) => 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.style?.conditionalFormats &&
|
||||||
|
config.style.conditionalFormats.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{config.style.conditionalFormats.length}개의 조건부 서식이
|
||||||
|
적용됨
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* 크기 설정 */}
|
{/* 크기 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-medium">크기 설정</Label>
|
<Label className="text-sm font-medium">크기 설정</Label>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,160 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
||||||
import { ComponentCategory } from "@/types/component";
|
import { ComponentCategory } from "@/types/component";
|
||||||
import { PivotGridComponent } from "./PivotGridComponent";
|
import { PivotGridComponent } from "./PivotGridComponent";
|
||||||
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
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<any> = (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 (
|
||||||
|
<PivotGridComponent
|
||||||
|
title={finalTitle}
|
||||||
|
data={finalData}
|
||||||
|
fields={finalFields}
|
||||||
|
totals={totalsConfig}
|
||||||
|
style={componentConfig.style || props.style}
|
||||||
|
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||||
|
chart={componentConfig.chart || props.chart}
|
||||||
|
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||||
|
height={componentConfig.height || props.height || "400px"}
|
||||||
|
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||||
|
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||||
|
onCellClick={props.onCellClick}
|
||||||
|
onCellDoubleClick={props.onCellDoubleClick}
|
||||||
|
onFieldDrop={props.onFieldDrop}
|
||||||
|
onExpandChange={props.onExpandChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PivotGrid 컴포넌트 정의
|
* PivotGrid 컴포넌트 정의
|
||||||
|
|
@ -17,13 +171,15 @@ const PivotGridDefinition = createComponentDefinition({
|
||||||
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
|
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
|
||||||
category: ComponentCategory.DISPLAY,
|
category: ComponentCategory.DISPLAY,
|
||||||
webType: "text",
|
webType: "text",
|
||||||
component: PivotGridComponent,
|
component: PivotGridWrapper, // 래퍼 컴포넌트 사용
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
dataSource: {
|
dataSource: {
|
||||||
type: "table",
|
type: "table",
|
||||||
tableName: "",
|
tableName: "",
|
||||||
},
|
},
|
||||||
fields: [],
|
fields: SAMPLE_FIELDS,
|
||||||
|
// 미리보기용 샘플 데이터
|
||||||
|
sampleData: SAMPLE_DATA,
|
||||||
totals: {
|
totals: {
|
||||||
showRowGrandTotals: true,
|
showRowGrandTotals: true,
|
||||||
showColumnGrandTotals: true,
|
showColumnGrandTotals: true,
|
||||||
|
|
@ -61,9 +217,75 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
|
||||||
static componentDefinition = PivotGridDefinition;
|
static componentDefinition = PivotGridDefinition;
|
||||||
|
|
||||||
render(): React.ReactElement {
|
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 (
|
return (
|
||||||
<PivotGridComponent
|
<PivotGridComponent
|
||||||
{...this.props}
|
title={finalTitle}
|
||||||
|
data={finalData}
|
||||||
|
fields={finalFields}
|
||||||
|
totals={totalsConfig}
|
||||||
|
style={componentConfig.style || props.style}
|
||||||
|
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||||
|
chart={componentConfig.chart || props.chart}
|
||||||
|
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||||
|
height={componentConfig.height || props.height || "400px"}
|
||||||
|
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||||
|
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||||
|
onCellClick={props.onCellClick}
|
||||||
|
onCellDoubleClick={props.onCellDoubleClick}
|
||||||
|
onFieldDrop={props.onFieldDrop}
|
||||||
|
onExpandChange={props.onExpandChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<DrillDownModalProps> = ({
|
||||||
|
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<SortConfig | null>(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<string>();
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>상세 데이터</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{pathDisplay || "선택한 셀의 원본 데이터"}
|
||||||
|
<span className="ml-2 text-primary font-medium">
|
||||||
|
({filteredData.length}건)
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 툴바 */}
|
||||||
|
<div className="flex items-center gap-2 py-2 border-b border-border">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="검색..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setPageSize(Number(v));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-28 h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="10">10개씩</SelectItem>
|
||||||
|
<SelectItem value="20">20개씩</SelectItem>
|
||||||
|
<SelectItem value="50">50개씩</SelectItem>
|
||||||
|
<SelectItem value="100">100개씩</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
disabled={filteredData.length === 0}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<ScrollArea className="flex-1 -mx-6">
|
||||||
|
<div className="px-6">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{displayColumns.map((col) => (
|
||||||
|
<TableHead
|
||||||
|
key={col.field}
|
||||||
|
className="whitespace-nowrap cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleSort(col.field)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>{col.caption}</span>
|
||||||
|
{sortConfig?.field === col.field ? (
|
||||||
|
sortConfig.direction === "asc" ? (
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-3 w-3 opacity-30" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{paginatedData.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={displayColumns.length}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
|
데이터가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
paginatedData.map((row, idx) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
{displayColumns.map((col) => (
|
||||||
|
<TableCell
|
||||||
|
key={col.field}
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap",
|
||||||
|
col.dataType === "number" && "text-right tabular-nums"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCellValue(row[col.field], col.dataType)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{(currentPage - 1) * pageSize + 1} -{" "}
|
||||||
|
{Math.min(currentPage * pageSize, filteredData.length)} /{" "}
|
||||||
|
{filteredData.length}건
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => goToPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => goToPage(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="px-3 text-sm">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => goToPage(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => goToPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 유틸리티 ====================
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
@ -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: <Minus className="h-3.5 w-3.5" /> },
|
||||||
|
{ value: "filter", label: "필터", icon: <Filter className="h-3.5 w-3.5" /> },
|
||||||
|
{ value: "row", label: "행", icon: <Rows className="h-3.5 w-3.5" /> },
|
||||||
|
{ value: "column", label: "열", icon: <Columns className="h-3.5 w-3.5" /> },
|
||||||
|
{ value: "data", label: "데이터", icon: <BarChart3 className="h-3.5 w-3.5" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
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, React.ReactNode> = {
|
||||||
|
string: <Type className="h-3.5 w-3.5" />,
|
||||||
|
number: <Hash className="h-3.5 w-3.5" />,
|
||||||
|
date: <Calendar className="h-3.5 w-3.5" />,
|
||||||
|
boolean: <ToggleLeft className="h-3.5 w-3.5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 필드 아이템 ====================
|
||||||
|
|
||||||
|
interface FieldItemProps {
|
||||||
|
field: AvailableField;
|
||||||
|
config?: PivotFieldConfig;
|
||||||
|
onAreaChange: (area: PivotAreaType | "none") => void;
|
||||||
|
onSummaryChange?: (summary: AggregationType) => void;
|
||||||
|
onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldItem: React.FC<FieldItemProps> = ({
|
||||||
|
field,
|
||||||
|
config,
|
||||||
|
onAreaChange,
|
||||||
|
onSummaryChange,
|
||||||
|
onDisplayModeChange,
|
||||||
|
}) => {
|
||||||
|
const currentArea = config?.area || "none";
|
||||||
|
const isSelected = currentArea !== "none";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 p-2 rounded-md border",
|
||||||
|
"transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/5 border-primary/30"
|
||||||
|
: "bg-background border-border hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 데이터 타입 아이콘 */}
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{DATA_TYPE_ICONS[field.dataType] || <Type className="h-3.5 w-3.5" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드명 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{field.caption}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{field.field}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 영역 선택 */}
|
||||||
|
<Select
|
||||||
|
value={currentArea}
|
||||||
|
onValueChange={(value) => onAreaChange(value as PivotAreaType | "none")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-28 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AREA_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{option.icon}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 집계 함수 선택 (데이터 영역인 경우) */}
|
||||||
|
{currentArea === "data" && onSummaryChange && (
|
||||||
|
<Select
|
||||||
|
value={config?.summaryType || "sum"}
|
||||||
|
onValueChange={(value) => onSummaryChange(value as AggregationType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-24 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUMMARY_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 표시 모드 선택 (데이터 영역인 경우) */}
|
||||||
|
{currentArea === "data" && onDisplayModeChange && (
|
||||||
|
<Select
|
||||||
|
value={config?.summaryDisplayMode || "absoluteValue"}
|
||||||
|
onValueChange={(value) => onDisplayModeChange(value as SummaryDisplayMode)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-28 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DISPLAY_MODE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 메인 컴포넌트 ====================
|
||||||
|
|
||||||
|
export const FieldChooser: React.FC<FieldChooserProps> = ({
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>필드 선택기</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 통계 */}
|
||||||
|
<div className="flex items-center gap-4 py-2 px-1 text-xs text-muted-foreground border-b border-border">
|
||||||
|
<span>전체: {stats.total}</span>
|
||||||
|
<span className="text-primary font-medium">
|
||||||
|
선택됨: {stats.selected}
|
||||||
|
</span>
|
||||||
|
<span>필터: {stats.filter}</span>
|
||||||
|
<span>행: {stats.row}</span>
|
||||||
|
<span>열: {stats.column}</span>
|
||||||
|
<span>데이터: {stats.data}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 및 필터 */}
|
||||||
|
<div className="flex items-center gap-2 py-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="필드 검색..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filterType}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFilterType(v as "all" | "selected" | "unselected")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32 h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="selected">선택됨</SelectItem>
|
||||||
|
<SelectItem value="unselected">미선택</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 목록 */}
|
||||||
|
<ScrollArea className="flex-1 -mx-6 px-6">
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
{filteredFields.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredFields.map((field) => {
|
||||||
|
const config = selectedFields.find(
|
||||||
|
(f) => f.field === field.field && f.visible !== false
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<FieldItem
|
||||||
|
key={field.field}
|
||||||
|
field={field}
|
||||||
|
config={config}
|
||||||
|
onAreaChange={(area) => handleAreaChange(field, area)}
|
||||||
|
onSummaryChange={
|
||||||
|
config?.area === "data"
|
||||||
|
? (summary) => handleSummaryChange(field, summary)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDisplayModeChange={
|
||||||
|
config?.area === "data"
|
||||||
|
? (mode) => handleDisplayModeChange(field, mode)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FieldChooser;
|
||||||
|
|
||||||
|
|
@ -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: <Filter className="h-3.5 w-3.5" />,
|
||||||
|
color: "bg-orange-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800",
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
title: "열",
|
||||||
|
icon: <Columns className="h-3.5 w-3.5" />,
|
||||||
|
color: "bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
title: "행",
|
||||||
|
icon: <Rows className="h-3.5 w-3.5" />,
|
||||||
|
color: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-800",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: "데이터",
|
||||||
|
icon: <BarChart3 className="h-3.5 w-3.5" />,
|
||||||
|
color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 필드 칩 (드래그 가능) ====================
|
||||||
|
|
||||||
|
const SortableFieldChip: React.FC<FieldChipProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||||
|
"bg-background border border-border shadow-sm",
|
||||||
|
"hover:bg-accent/50 transition-colors",
|
||||||
|
isDragging && "opacity-50 shadow-lg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 필드 라벨 */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex items-center gap-1 hover:text-primary">
|
||||||
|
<span className="font-medium">{field.caption}</span>
|
||||||
|
{field.area === "data" && field.summaryType && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({getSummaryLabel(field.summaryType)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-48">
|
||||||
|
{field.area === "data" && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({ ...field, summaryType: "sum" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
합계
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({ ...field, summaryType: "count" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
개수
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({ ...field, summaryType: "avg" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
평균
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({ ...field, summaryType: "min" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
최소
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({ ...field, summaryType: "max" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
최대
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onSettingsChange?.({
|
||||||
|
...field,
|
||||||
|
sortOrder: field.sortOrder === "asc" ? "desc" : "asc",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onSettingsChange?.({ ...field, visible: false })}
|
||||||
|
>
|
||||||
|
필드 숨기기
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 드롭 영역 ====================
|
||||||
|
|
||||||
|
const DroppableArea: React.FC<DroppableAreaProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2",
|
||||||
|
"transition-colors duration-200",
|
||||||
|
config.color,
|
||||||
|
isOver && "border-primary bg-primary/5"
|
||||||
|
)}
|
||||||
|
data-area={area}
|
||||||
|
>
|
||||||
|
{/* 영역 헤더 */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
<span>{title}</span>
|
||||||
|
{areaFields.length > 0 && (
|
||||||
|
<span className="text-[10px] bg-muted px-1 rounded">
|
||||||
|
{areaFields.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 목록 */}
|
||||||
|
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
|
||||||
|
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
|
||||||
|
{areaFields.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground/50 italic">
|
||||||
|
필드를 여기로 드래그
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
areaFields.map((field) => (
|
||||||
|
<SortableFieldChip
|
||||||
|
key={`${area}-${field.field}`}
|
||||||
|
field={field}
|
||||||
|
onRemove={() => onFieldRemove(field)}
|
||||||
|
onSettingsChange={onFieldSettingsChange}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 유틸리티 ====================
|
||||||
|
|
||||||
|
function getSummaryLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
sum: "합계",
|
||||||
|
count: "개수",
|
||||||
|
avg: "평균",
|
||||||
|
min: "최소",
|
||||||
|
max: "최대",
|
||||||
|
countDistinct: "고유",
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 메인 컴포넌트 ====================
|
||||||
|
|
||||||
|
export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||||
|
fields,
|
||||||
|
onFieldsChange,
|
||||||
|
onFieldRemove,
|
||||||
|
onFieldSettingsChange,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapse,
|
||||||
|
}) => {
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [overArea, setOverArea] = useState<PivotAreaType | null>(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 (
|
||||||
|
<div className="border-b border-border px-3 py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
필드 패널 펼치기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="border-b border-border bg-muted/20 p-3">
|
||||||
|
{/* 2x2 그리드로 영역 배치 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* 필터 영역 */}
|
||||||
|
<DroppableArea
|
||||||
|
area="filter"
|
||||||
|
fields={fields}
|
||||||
|
title={AREA_CONFIG.filter.title}
|
||||||
|
icon={AREA_CONFIG.filter.icon}
|
||||||
|
onFieldRemove={handleFieldRemove}
|
||||||
|
onFieldSettingsChange={handleFieldSettingsChange}
|
||||||
|
isOver={overArea === "filter"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 열 영역 */}
|
||||||
|
<DroppableArea
|
||||||
|
area="column"
|
||||||
|
fields={fields}
|
||||||
|
title={AREA_CONFIG.column.title}
|
||||||
|
icon={AREA_CONFIG.column.icon}
|
||||||
|
onFieldRemove={handleFieldRemove}
|
||||||
|
onFieldSettingsChange={handleFieldSettingsChange}
|
||||||
|
isOver={overArea === "column"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 행 영역 */}
|
||||||
|
<DroppableArea
|
||||||
|
area="row"
|
||||||
|
fields={fields}
|
||||||
|
title={AREA_CONFIG.row.title}
|
||||||
|
icon={AREA_CONFIG.row.icon}
|
||||||
|
onFieldRemove={handleFieldRemove}
|
||||||
|
onFieldSettingsChange={handleFieldSettingsChange}
|
||||||
|
isOver={overArea === "row"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 데이터 영역 */}
|
||||||
|
<DroppableArea
|
||||||
|
area="data"
|
||||||
|
fields={fields}
|
||||||
|
title={AREA_CONFIG.data.title}
|
||||||
|
icon={AREA_CONFIG.data.icon}
|
||||||
|
onFieldRemove={handleFieldRemove}
|
||||||
|
onFieldSettingsChange={handleFieldSettingsChange}
|
||||||
|
isOver={overArea === "data"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 접기 버튼 */}
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<div className="flex justify-center mt-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="text-xs h-6"
|
||||||
|
>
|
||||||
|
필드 패널 접기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 드래그 오버레이 */}
|
||||||
|
<DragOverlay>
|
||||||
|
{activeField ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
|
||||||
|
"bg-background border border-primary shadow-lg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{activeField.caption}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FieldPanel;
|
||||||
|
|
||||||
|
|
@ -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<FilterPopupProps> = ({
|
||||||
|
field,
|
||||||
|
data,
|
||||||
|
onFilterChange,
|
||||||
|
trigger,
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedValues, setSelectedValues] = useState<Set<any>>(
|
||||||
|
new Set(field.filterValues || [])
|
||||||
|
);
|
||||||
|
const [filterType, setFilterType] = useState<"include" | "exclude">(
|
||||||
|
field.filterType || "include"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 고유 값 추출
|
||||||
|
const uniqueValues = useMemo(() => {
|
||||||
|
const values = new Set<any>();
|
||||||
|
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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
{trigger || (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"p-1 rounded hover:bg-accent",
|
||||||
|
isFilterActive && "text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 p-0" align="start">
|
||||||
|
<div className="p-3 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium">{field.caption} 필터</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType("include")}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 text-xs rounded",
|
||||||
|
filterType === "include"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
포함
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType("exclude")}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 text-xs rounded",
|
||||||
|
filterType === "exclude"
|
||||||
|
? "bg-destructive text-destructive-foreground"
|
||||||
|
: "bg-muted hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
제외
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="검색..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-8 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전체 선택/해제 */}
|
||||||
|
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{selectedCount} / {totalCount} 선택됨
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<CheckSquare className="h-3 w-3" />
|
||||||
|
전체 선택
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3" />
|
||||||
|
전체 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 값 목록 */}
|
||||||
|
<ScrollArea className="h-48">
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{filteredValues.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||||
|
결과가 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredValues.map((value) => (
|
||||||
|
<label
|
||||||
|
key={String(value)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer",
|
||||||
|
"hover:bg-muted text-sm"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedValues.has(value)}
|
||||||
|
onCheckedChange={() => handleValueToggle(value)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{String(value)}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
({data.filter((r) => r[field.field] === value).length})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex items-center justify-between p-2 border-t border-border">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApply}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterPopup;
|
||||||
|
|
||||||
|
|
@ -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<any> = ({ active, payload, label }) => {
|
||||||
|
if (!active || !payload || !payload.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background border border-border rounded-lg shadow-lg p-2">
|
||||||
|
<p className="text-sm font-medium mb-1">{label}</p>
|
||||||
|
{payload.map((entry: any, idx: number) => (
|
||||||
|
<p key={idx} className="text-xs" style={{ color: entry.color }}>
|
||||||
|
{entry.name}: {entry.value?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 막대 차트
|
||||||
|
const PivotBarChart: React.FC<{
|
||||||
|
data: any[];
|
||||||
|
columns: string[];
|
||||||
|
height: number;
|
||||||
|
showLegend: boolean;
|
||||||
|
stacked?: boolean;
|
||||||
|
}> = ({ data, columns, height, showLegend, stacked }) => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
tickFormatter={(value) => value.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
{showLegend && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||||
|
iconType="square"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<Bar
|
||||||
|
key={col}
|
||||||
|
dataKey={col}
|
||||||
|
fill={COLORS[idx % COLORS.length]}
|
||||||
|
stackId={stacked ? "stack" : undefined}
|
||||||
|
radius={stacked ? 0 : [4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선 차트
|
||||||
|
const PivotLineChart: React.FC<{
|
||||||
|
data: any[];
|
||||||
|
columns: string[];
|
||||||
|
height: number;
|
||||||
|
showLegend: boolean;
|
||||||
|
}> = ({ data, columns, height, showLegend }) => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<LineChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
tickFormatter={(value) => value.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
{showLegend && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||||
|
iconType="line"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<Line
|
||||||
|
key={col}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={col}
|
||||||
|
stroke={COLORS[idx % COLORS.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 4, fill: COLORS[idx % COLORS.length] }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 영역 차트
|
||||||
|
const PivotAreaChart: React.FC<{
|
||||||
|
data: any[];
|
||||||
|
columns: string[];
|
||||||
|
height: number;
|
||||||
|
showLegend: boolean;
|
||||||
|
}> = ({ data, columns, height, showLegend }) => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e5e5" }}
|
||||||
|
tickFormatter={(value) => value.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
{showLegend && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||||
|
iconType="square"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<Area
|
||||||
|
key={col}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={col}
|
||||||
|
fill={COLORS[idx % COLORS.length]}
|
||||||
|
stroke={COLORS[idx % COLORS.length]}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파이 차트
|
||||||
|
const PivotPieChart: React.FC<{
|
||||||
|
data: any[];
|
||||||
|
height: number;
|
||||||
|
showLegend: boolean;
|
||||||
|
}> = ({ data, height, showLegend }) => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={height / 3}
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
`${name} (${(percent * 100).toFixed(1)}%)`
|
||||||
|
}
|
||||||
|
labelLine
|
||||||
|
>
|
||||||
|
{data.map((entry, idx) => (
|
||||||
|
<Cell key={idx} fill={entry.color || COLORS[idx % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
{showLegend && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||||||
|
iconType="circle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 메인 컴포넌트 ====================
|
||||||
|
|
||||||
|
export const PivotChart: React.FC<PivotChartProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-t border-border bg-background p-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 차트 렌더링 */}
|
||||||
|
{config.type === "bar" && (
|
||||||
|
<PivotBarChart
|
||||||
|
data={chartData}
|
||||||
|
columns={columns}
|
||||||
|
height={height}
|
||||||
|
showLegend={showLegend}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.type === "stackedBar" && (
|
||||||
|
<PivotBarChart
|
||||||
|
data={chartData}
|
||||||
|
columns={columns}
|
||||||
|
height={height}
|
||||||
|
showLegend={showLegend}
|
||||||
|
stacked
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.type === "line" && (
|
||||||
|
<PivotLineChart
|
||||||
|
data={chartData}
|
||||||
|
columns={columns}
|
||||||
|
height={height}
|
||||||
|
showLegend={showLegend}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.type === "area" && (
|
||||||
|
<PivotAreaChart
|
||||||
|
data={chartData}
|
||||||
|
columns={columns}
|
||||||
|
height={height}
|
||||||
|
showLegend={showLegend}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.type === "pie" && (
|
||||||
|
<PivotPieChart
|
||||||
|
data={chartData}
|
||||||
|
height={height}
|
||||||
|
showLegend={showLegend}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PivotChart;
|
||||||
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
@ -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<string, any[]>;
|
||||||
|
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<PivotFieldConfig[]>(initialFields);
|
||||||
|
const [pivotState, setPivotStateInternal] = useState<PivotGridState>({
|
||||||
|
expandedRowPaths: [],
|
||||||
|
expandedColumnPaths: [],
|
||||||
|
sortConfig: null,
|
||||||
|
filterConfig: {},
|
||||||
|
});
|
||||||
|
const [lastSaved, setLastSaved] = useState<Date | null>(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;
|
||||||
|
|
||||||
|
|
@ -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<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 훅 ====================
|
||||||
|
|
||||||
|
export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult {
|
||||||
|
const {
|
||||||
|
itemCount,
|
||||||
|
itemHeight,
|
||||||
|
containerHeight,
|
||||||
|
overscan = 5,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVirtual2DScroll(
|
||||||
|
options: Virtual2DScrollOptions
|
||||||
|
): Virtual2DScrollResult {
|
||||||
|
const {
|
||||||
|
rowCount,
|
||||||
|
columnCount,
|
||||||
|
rowHeight,
|
||||||
|
columnWidth,
|
||||||
|
containerHeight,
|
||||||
|
containerWidth,
|
||||||
|
rowOverscan = 5,
|
||||||
|
columnOverscan = 3,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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;
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ export type {
|
||||||
// 기본 타입
|
// 기본 타입
|
||||||
PivotAreaType,
|
PivotAreaType,
|
||||||
AggregationType,
|
AggregationType,
|
||||||
|
SummaryDisplayMode,
|
||||||
SortDirection,
|
SortDirection,
|
||||||
DateGroupInterval,
|
DateGroupInterval,
|
||||||
FieldDataType,
|
FieldDataType,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,19 @@ export type PivotAreaType = "row" | "column" | "data" | "filter";
|
||||||
// 집계 함수 타입
|
// 집계 함수 타입
|
||||||
export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct";
|
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";
|
export type SortDirection = "asc" | "desc" | "none";
|
||||||
|
|
||||||
|
|
@ -48,6 +61,8 @@ export interface PivotFieldConfig {
|
||||||
|
|
||||||
// 집계 설정 (data 영역용)
|
// 집계 설정 (data 영역용)
|
||||||
summaryType?: AggregationType; // 집계 함수
|
summaryType?: AggregationType; // 집계 함수
|
||||||
|
summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드
|
||||||
|
showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭)
|
||||||
|
|
||||||
// 정렬 설정
|
// 정렬 설정
|
||||||
sortBy?: "value" | "caption"; // 정렬 기준
|
sortBy?: "value" | "caption"; // 정렬 기준
|
||||||
|
|
@ -151,6 +166,45 @@ export interface PivotChartConfig {
|
||||||
animate?: boolean;
|
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 {
|
export interface PivotStyleConfig {
|
||||||
theme: "default" | "compact" | "modern";
|
theme: "default" | "compact" | "modern";
|
||||||
|
|
@ -159,6 +213,7 @@ export interface PivotStyleConfig {
|
||||||
borderStyle: "none" | "light" | "heavy";
|
borderStyle: "none" | "light" | "heavy";
|
||||||
alternateRowColors?: boolean;
|
alternateRowColors?: boolean;
|
||||||
highlightTotals?: boolean; // 총합계 강조
|
highlightTotals?: boolean; // 총합계 강조
|
||||||
|
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 내보내기 설정 ====================
|
// ==================== 내보내기 설정 ====================
|
||||||
|
|
|
||||||
|
|
@ -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<string, string[]> = {
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
export * from "./aggregation";
|
export * from "./aggregation";
|
||||||
export * from "./pivotEngine";
|
export * from "./pivotEngine";
|
||||||
|
export * from "./exportExcel";
|
||||||
|
export * from "./conditionalFormat";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
PivotCellValue,
|
PivotCellValue,
|
||||||
DateGroupInterval,
|
DateGroupInterval,
|
||||||
AggregationType,
|
AggregationType,
|
||||||
|
SummaryDisplayMode,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { aggregate, formatNumber, formatDate } from "./aggregation";
|
import { aggregate, formatNumber, formatDate } from "./aggregation";
|
||||||
|
|
||||||
|
|
@ -418,6 +419,185 @@ function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] {
|
||||||
return leaves;
|
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<string, PivotCellValue[]>,
|
||||||
|
dataFields: PivotFieldConfig[],
|
||||||
|
flatRows: PivotFlatRow[],
|
||||||
|
flatColumnLeaves: string[][],
|
||||||
|
rowTotals: Map<string, PivotCellValue[]>,
|
||||||
|
columnTotals: Map<string, PivotCellValue[]>,
|
||||||
|
grandTotals: PivotCellValue[]
|
||||||
|
): Map<string, PivotCellValue[]> {
|
||||||
|
// displayMode가 있는 데이터 필드가 있는지 확인
|
||||||
|
const hasDisplayMode = dataFields.some(
|
||||||
|
(df) => df.summaryDisplayMode || df.showValuesAs
|
||||||
|
);
|
||||||
|
if (!hasDisplayMode) return matrix;
|
||||||
|
|
||||||
|
const newMatrix = new Map<string, PivotCellValue[]>();
|
||||||
|
|
||||||
|
// 누계를 위한 추적 (행별, 열별)
|
||||||
|
const rowRunningTotals: Map<string, number[]> = new Map(); // fieldIndex -> 누계
|
||||||
|
const colRunningTotals: Map<string, Map<number, number>> = 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 flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
|
||||||
|
|
||||||
// 데이터 매트릭스 생성
|
// 데이터 매트릭스 생성
|
||||||
const dataMatrix = buildDataMatrix(
|
let dataMatrix = buildDataMatrix(
|
||||||
filteredData,
|
filteredData,
|
||||||
rowFields,
|
rowFields,
|
||||||
columnFields,
|
columnFields,
|
||||||
|
|
@ -603,6 +783,17 @@ export function processPivotData(
|
||||||
flatColumnLeaves
|
flatColumnLeaves
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Summary Display Mode 적용
|
||||||
|
dataMatrix = applyDisplayModeToMatrix(
|
||||||
|
dataMatrix,
|
||||||
|
dataFields,
|
||||||
|
flatRows,
|
||||||
|
flatColumnLeaves,
|
||||||
|
grandTotals.row,
|
||||||
|
grandTotals.column,
|
||||||
|
grandTotals.grand
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rowHeaders,
|
rowHeaders,
|
||||||
columnHeaders,
|
columnHeaders,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"docx-preview": "^0.3.6",
|
"docx-preview": "^0.3.6",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"isomorphic-dompurify": "^2.28.0",
|
"isomorphic-dompurify": "^2.28.0",
|
||||||
|
|
@ -542,6 +543,47 @@
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.3",
|
"version": "1.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
"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"
|
"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": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
|
@ -7158,6 +7253,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/async-function": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
|
||||||
|
|
@ -7225,7 +7326,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64-arraybuffer": {
|
"node_modules/base64-arraybuffer": {
|
||||||
|
|
@ -7266,6 +7366,15 @@
|
||||||
"require-from-string": "^2.0.2"
|
"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": {
|
"node_modules/bignumber.js": {
|
||||||
"version": "9.3.1",
|
"version": "9.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||||
|
|
@ -7275,6 +7384,68 @@
|
||||||
"node": "*"
|
"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": {
|
"node_modules/bluebird": {
|
||||||
"version": "3.4.7",
|
"version": "3.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||||
|
|
@ -7285,7 +7456,6 @@
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
|
|
@ -7329,6 +7499,32 @@
|
||||||
"ieee754": "^1.2.1"
|
"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": {
|
"node_modules/c12": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
|
|
@ -7501,6 +7697,18 @@
|
||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -7665,11 +7873,39 @@
|
||||||
"node": ">= 10"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/concaveman": {
|
"node_modules/concaveman": {
|
||||||
|
|
@ -7731,6 +7967,33 @@
|
||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/crelt": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
"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==",
|
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -8605,6 +8874,15 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/earcut": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||||
|
|
@ -8639,6 +8917,15 @@
|
||||||
"node": ">=14"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.3",
|
"version": "5.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||||
|
|
@ -9338,6 +9625,61 @@
|
||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/exit-on-epipe": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||||
|
|
@ -9377,6 +9719,19 @@
|
||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -9586,6 +9941,34 @@
|
||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|
@ -9773,6 +10156,27 @@
|
||||||
"giget": "dist/cli.mjs"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
|
|
@ -9847,7 +10251,6 @@
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/graphemer": {
|
"node_modules/graphemer": {
|
||||||
|
|
@ -10121,6 +10524,17 @@
|
||||||
"node": ">=0.8.19"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
|
@ -10843,6 +11257,18 @@
|
||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/leaflet": {
|
||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
|
@ -11142,6 +11568,12 @@
|
||||||
"uc.micro": "^2.0.0"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|
@ -11158,6 +11590,73 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
|
@ -11165,6 +11664,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
|
@ -11386,7 +11897,6 @@
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
|
|
@ -11399,12 +11909,23 @@
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -11557,6 +12078,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
||||||
|
|
@ -11707,6 +12237,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/option": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
||||||
|
|
@ -12829,6 +13368,36 @@
|
||||||
"util-deprecate": "~1.0.1"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
|
|
@ -13086,6 +13655,19 @@
|
||||||
"node": ">= 0.8.15"
|
"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": {
|
"node_modules/robust-predicates": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
|
||||||
|
|
@ -13891,6 +14473,36 @@
|
||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/text-segmentation": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
|
@ -14032,6 +14644,15 @@
|
||||||
"integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==",
|
"integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|
@ -14107,6 +14728,15 @@
|
||||||
"node": ">=20"
|
"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": {
|
"node_modules/troika-three-text": {
|
||||||
"version": "0.52.4",
|
"version": "0.52.4",
|
||||||
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
|
"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"
|
"@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": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
|
|
@ -14754,6 +15402,12 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.3",
|
"version": "8.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
|
@ -14974,6 +15628,55 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"docx-preview": "^0.3.6",
|
"docx-preview": "^0.3.6",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"isomorphic-dompurify": "^2.28.0",
|
"isomorphic-dompurify": "^2.28.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue