feat: 테이블 pivot 모드 본체 통째 흡수 (T3b)

5 viewMode 통합 세번째 단계 — Codex 권고 (Pivot 통째 흡수, 회귀 위험
차단) 그대로 적용. v2-pivot-grid 의 본체 + utils + components + hooks +
보조 타입을 모두 table/ 하위로 이전. 리팩토링 X (V2Date picker 패턴 동일).

table/types.ts 보조 타입 흡수
- PivotResult, PivotHeaderNode, PivotFlatRow, PivotFlatColumn, PivotCellValue,
  PivotCellData, PivotGridState, PivotGridProps
- PivotDataSourceType, PivotFilterCondition, PivotJoinConfig,
  PivotDataSourceConfig, PivotTotalsConfig, PivotFieldChooserConfig,
  PivotChartConfig, PivotConditionalFormatRule, PivotStyleConfig,
  PivotExportConfig
- PivotFieldConfig 에 filterValues / filterType / isCalculated /
  calculateFormula 누락 속성 추가
- TableConfig 에 pivot 보조 키 (pivotTotals / pivotStyle /
  pivotFieldChooser / pivotChart / pivotExportConfig)

table/utils/pivot/ 4개 파일 이전 (1505줄)
- pivotEngine.ts (812) — processPivotData / pathToKey / 헤더 트리 / 매트릭스 /
  10종 displayMode (runningTotal, percentDifferenceFromPrevious 등)
- aggregation.ts (180) — sum/count/avg/min/max/countDistinct + 포맷
- conditionalFormat.ts (311) — colorScale/dataBar/iconSet/cellValue 4 종
- exportExcel.ts (202) — Excel 내보내기 (xlsx)
- 옛 prefix(AggregationType 등) → Pivot prefix 일괄 정리

table/internals/pivot/components/ 7개 파일 이전 (2347줄)
- ContextMenu / DrillDownModal / FieldChooser / FieldPanel / FilterPopup /
  PivotChart / index

table/internals/pivot/hooks/ 3개 파일 이전 (570줄)
- usePivotState / useVirtualScroll / index

table/views/PivotView.tsx 신규 (PivotGridComponent.tsx 1963줄 통째 흡수)
- import 경로 일괄 정정 (../../types → ../types, ./utils → ../utils/pivot,
  ./components → ../internals/pivot/components, ./hooks →
  ../internals/pivot/hooks)
- 컴포넌트 이름 PivotGridComponent → PivotView
- 본체 로직 그대로 (리팩토링 X)

TableComponent.switch
- pivot 분기 placeholder 제거 → PivotView 호출
- DOM filter 에 pivotTotals/pivotStyle/pivotFieldChooser/pivotChart/
  pivotExportConfig 추가

13 files, +5400+ insertions. v2-pivot-grid/ 폴더 자체는 Phase T5 dead code
일괄 삭제에서 정리 예정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DDD1542
2026-04-29 14:23:39 +09:00
parent a74dff4fa2
commit 49b4cdf562
18 changed files with 6585 additions and 39 deletions
@@ -243,7 +243,8 @@ export const TableComponent: React.FC<TableComponentProps> = ({
selectionMode: _54, showCheckbox: _55, showHeader: _56, showFooter: _57,
pagination: _58, rowHeight: _59, striped: _60, hoverable: _61, bordered: _62,
splitRatio: _63, groupBy: _64, pivotRows: _65, pivotColumns: _66, pivotValues: _67,
pivotFields: _67a,
pivotFields: _67a, pivotTotals: _67a1, pivotStyle: _67a2,
pivotFieldChooser: _67a3, pivotChart: _67a4, pivotExportConfig: _67a5,
cardsPerRow: _67b, cardSpacing: _67c, cardStyle: _67d, cardColumnMapping: _67e,
emptyMessage: _68, showToolbar: _69, showExcel: _70, showRefresh: _71,
disabled: _72, required: _73,
@@ -505,9 +506,13 @@ export const TableComponent: React.FC<TableComponentProps> = ({
case "pivot":
return (
<PivotView
config={componentConfig}
data={rows}
isDesignMode={isDesignMode}
fields={componentConfig.pivotFields}
totals={componentConfig.pivotTotals}
style={componentConfig.pivotStyle}
fieldChooser={componentConfig.pivotFieldChooser}
chart={componentConfig.pivotChart}
exportConfig={componentConfig.pivotExportConfig}
/>
);
case "table":
@@ -0,0 +1,213 @@
"use client";
/**
* PivotGrid 컨텍스트 메뉴 컴포넌트
* 우클릭 시 정렬, 필터, 확장/축소 등의 옵션 제공
*/
import React from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
ArrowUpAZ,
ArrowDownAZ,
Filter,
ChevronDown,
ChevronRight,
Copy,
Eye,
EyeOff,
BarChart3,
} from "lucide-react";
import { PivotFieldConfig, PivotAggregationType } from "../../../types";
interface PivotContextMenuProps {
children: React.ReactNode;
// 현재 컨텍스트 정보
cellType: "header" | "data" | "rowHeader" | "columnHeader";
field?: PivotFieldConfig;
rowPath?: string[];
columnPath?: string[];
value?: any;
// 콜백
onSort?: (field: string, direction: "asc" | "desc") => void;
onFilter?: (field: string) => void;
onExpand?: (path: string[]) => void;
onCollapse?: (path: string[]) => void;
onExpandAll?: () => void;
onCollapseAll?: () => void;
onCopy?: (value: any) => void;
onHideField?: (field: string) => void;
onChangeSummary?: (field: string, summaryType: PivotAggregationType) => void;
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
}
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
children,
cellType,
field,
rowPath,
columnPath,
value,
onSort,
onFilter,
onExpand,
onCollapse,
onExpandAll,
onCollapseAll,
onCopy,
onHideField,
onChangeSummary,
onDrillDown,
}) => {
const handleCopy = () => {
if (value !== undefined && value !== null) {
navigator.clipboard.writeText(String(value));
onCopy?.(value);
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 정렬 옵션 (헤더에서만) */}
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
<ArrowDownAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 확장/축소 옵션 */}
{(cellType === "rowHeader" || cellType === "columnHeader") && (
<>
{rowPath && rowPath.length > 0 && (
<>
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={onExpandAll}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={onCollapseAll}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필터 옵션 */}
{field && onFilter && (
<>
<ContextMenuItem onClick={() => onFilter(field.field)}>
<Filter className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 집계 함수 변경 (데이터 필드에서만) */}
{cellType === "data" && field && onChangeSummary && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<BarChart3 className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "sum")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "count")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "avg")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "min")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "max")}
>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 드릴다운 (데이터 셀에서만) */}
{cellType === "data" && rowPath && columnPath && onDrillDown && (
<>
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
<Eye className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필드 숨기기 */}
{field && onHideField && (
<ContextMenuItem onClick={() => onHideField(field.field)}>
<EyeOff className="mr-2 h-4 w-4" />
</ContextMenuItem>
)}
{/* 복사 */}
<ContextMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export default PivotContextMenu;
@@ -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,450 @@
"use client";
/**
* FieldChooser 컴포넌트
* 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달
*/
import React, { useState, useMemo } from "react";
import { cn } from "@/lib/utils";
import { PivotFieldConfig, PivotAreaType, PivotAggregationType, PivotSummaryDisplayMode } 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: PivotAggregationType; 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: PivotSummaryDisplayMode; 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 DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
{ value: "none", label: "그룹 없음" },
{ value: "year", label: "년" },
{ value: "quarter", label: "분기" },
{ value: "month", label: "월" },
{ value: "week", label: "주" },
{ value: "day", 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: PivotAggregationType) => void;
onDisplayModeChange?: (displayMode: PivotSummaryDisplayMode) => 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 PivotAggregationType)}
>
<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 PivotSummaryDisplayMode)}
>
<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: PivotAggregationType
) => {
const newFields = selectedFields.map((f) =>
f.field === field.field ? { ...f, summaryType } : f
);
onFieldsChange(newFields);
};
// 표시 모드 변경
const handleDisplayModeChange = (
field: AvailableField,
displayMode: PivotSummaryDisplayMode
) => {
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 max-h-[40vh] overflow-y-auto">
<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,577 @@
"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-amber-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-primary/10 border-primary/20 dark:bg-primary/10 dark:border-primary/30",
},
row: {
title: "행",
icon: <Rows className="h-3.5 w-3.5" />,
color: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-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-[44px] rounded border border-dashed p-1.5",
"transition-colors duration-200",
config.color,
isOver && "border-primary bg-primary/5"
)}
data-area={area}
>
{/* 영역 헤더 */}
<div className="flex items-center gap-1 mb-1 text-[11px] 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 min-h-[22px]">
{areaFields.length === 0 ? (
<span className="text-[10px] 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;
// 각 영역의 필드 수 계산
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
if (collapsed) {
return (
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{filterCount > 0 && (
<span className="flex items-center gap-1">
<Filter className="h-3 w-3" />
{filterCount}
</span>
)}
<span className="flex items-center gap-1">
<Columns className="h-3 w-3" />
{columnCount}
</span>
<span className="flex items-center gap-1">
<Rows className="h-3 w-3" />
{rowCount}
</span>
<span className="flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
{dataCount}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs h-6 px-2"
>
</Button>
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="border-b border-border bg-muted/20 p-2">
{/* 4개 영역 배치: 2x2 그리드 */}
<div className="grid grid-cols-2 gap-1.5">
{/* 필터 영역 */}
<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-1.5">
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs h-5 px-2"
>
</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/pivot/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 }: any) =>
`${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,11 @@
/**
* PivotGrid 서브 컴포넌트 내보내기
*/
export { FieldPanel } from "./FieldPanel";
export { FieldChooser } from "./FieldChooser";
export { DrillDownModal } from "./DrillDownModal";
export { FilterPopup } from "./FilterPopup";
export { PivotChart } from "./PivotChart";
export { PivotContextMenu } from "./ContextMenu";
@@ -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, PivotSortDirection } 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: PivotSortDirection;
} | 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 | null>;
}
// ==================== 훅 ====================
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 | null>;
}
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;
@@ -126,6 +126,214 @@ export interface PivotFieldConfig {
width?: number;
expanded?: boolean;
format?: PivotFieldFormat;
// 필터 설정 (filter area)
filterValues?: any[];
filterType?: "include" | "exclude";
// 계산 필드 (수식 기반)
isCalculated?: boolean;
calculateFormula?: string;
}
// ─── pivot 결과 데이터 구조 (utils/pivot 가 사용) ─────────────────
export interface PivotCellValue {
field: string;
value: number | null;
formattedValue: string;
}
export interface PivotHeaderNode {
value: any;
caption: string;
level: number;
children?: PivotHeaderNode[];
isExpanded: boolean;
path: string[];
subtotal?: PivotCellValue[];
span?: number;
}
export interface PivotFlatRow {
path: string[];
level: number;
caption: string;
isExpanded: boolean;
hasChildren: boolean;
isTotal?: boolean;
}
export interface PivotFlatColumn {
path: string[];
level: number;
caption: string;
span: number;
isTotal?: boolean;
}
export interface PivotResult {
rowHeaders: PivotHeaderNode[];
columnHeaders: PivotHeaderNode[];
dataMatrix: Map<string, PivotCellValue[]>;
flatRows: PivotFlatRow[];
flatColumns: PivotFlatColumn[];
grandTotals: {
row: Map<string, PivotCellValue[]>;
column: Map<string, PivotCellValue[]>;
grand: PivotCellValue[];
};
}
export interface PivotCellData {
value: any;
rowPath: string[];
columnPath: string[];
field?: string;
aggregationType?: PivotAggregationType;
isTotal?: boolean;
isGrandTotal?: boolean;
}
// ─── pivot 표시 / 차트 / 조건부 서식 / 데이터 소스 (본체가 사용) ────
export type PivotDataSourceType = "table" | "api" | "static";
export interface PivotFilterCondition {
field: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
value?: any;
valueFromField?: string;
}
export interface PivotJoinConfig {
joinType: "INNER" | "LEFT" | "RIGHT";
targetTable: string;
sourceColumn: string;
targetColumn: string;
columns: string[];
}
export interface PivotDataSourceConfig {
type: PivotDataSourceType;
tableName?: string;
apiEndpoint?: string;
apiMethod?: "GET" | "POST";
staticData?: any[];
filterConditions?: PivotFilterCondition[];
joinConfigs?: PivotJoinConfig[];
}
export interface PivotTotalsConfig {
showRowGrandTotals?: boolean;
showRowTotals?: boolean;
rowTotalsPosition?: "first" | "last";
rowGrandTotalPosition?: "top" | "bottom";
showColumnGrandTotals?: boolean;
showColumnTotals?: boolean;
columnTotalsPosition?: "first" | "last";
columnGrandTotalPosition?: "left" | "right";
}
export interface PivotFieldChooserConfig {
enabled: boolean;
allowSearch?: boolean;
layout?: "default" | "simplified";
height?: number;
applyChangesMode?: "instantly" | "onDemand";
}
export interface PivotChartConfig {
enabled: boolean;
type: "bar" | "line" | "area" | "pie" | "stackedBar";
position: "top" | "bottom" | "left" | "right";
height?: number;
showLegend?: boolean;
animate?: boolean;
}
export interface PivotConditionalFormatRule {
id: string;
type: "colorScale" | "dataBar" | "iconSet" | "cellValue";
field?: string;
colorScale?: {
minColor: string;
midColor?: string;
maxColor: string;
};
dataBar?: {
color: string;
showValue?: boolean;
minValue?: number;
maxValue?: number;
};
iconSet?: {
type: "arrows" | "traffic" | "rating" | "flags";
thresholds: number[];
reverse?: boolean;
};
cellValue?: {
operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between";
value1: number;
value2?: number;
backgroundColor?: string;
textColor?: string;
bold?: boolean;
};
}
export interface PivotStyleConfig {
theme: "default" | "compact" | "modern";
headerStyle: "default" | "dark" | "light";
cellPadding: "compact" | "normal" | "comfortable";
borderStyle: "none" | "light" | "heavy";
alternateRowColors?: boolean;
highlightTotals?: boolean;
conditionalFormats?: PivotConditionalFormatRule[];
mergeCells?: boolean;
}
export interface PivotExportConfig {
excel?: boolean;
pdf?: boolean;
fileName?: string;
}
export interface PivotGridState {
expandedRowPaths: string[][];
expandedColumnPaths: string[][];
sortConfig: {
field: string;
direction: PivotSortDirection;
} | null;
filterConfig: Record<string, any[]>;
}
// pivot 본체 props (PivotView 가 받음)
export interface PivotGridProps {
id?: string;
title?: string;
dataSource?: PivotDataSourceConfig;
fields?: PivotFieldConfig[];
totals?: PivotTotalsConfig;
style?: PivotStyleConfig;
fieldChooser?: PivotFieldChooserConfig;
chart?: PivotChartConfig;
allowSortingBySummary?: boolean;
allowFiltering?: boolean;
allowExpandAll?: boolean;
wordWrapEnabled?: boolean;
height?: string | number;
maxHeight?: string;
stateStoring?: {
enabled: boolean;
storageKey?: string;
};
exportConfig?: PivotExportConfig;
data?: any[];
onCellClick?: (cellData: PivotCellData) => void;
onCellDoubleClick?: (cellData: PivotCellData) => void;
onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void;
onExpandChange?: (expandedPaths: string[][]) => void;
onDataChange?: (data: any[]) => void;
}
export interface TableConfig extends ComponentConfig {
@@ -177,6 +385,16 @@ export interface TableConfig extends ComponentConfig {
* 단순 pivotRows/pivotColumns/pivotValues 보다 우선 사용 (있으면).
*/
pivotFields?: PivotFieldConfig[];
/** pivot 모드: 총합계 표시 옵션 */
pivotTotals?: PivotTotalsConfig;
/** pivot 모드: 시각 스타일 + 조건부 서식 */
pivotStyle?: PivotStyleConfig;
/** pivot 모드: 필드 선택기 */
pivotFieldChooser?: PivotFieldChooserConfig;
/** pivot 모드: 차트 연동 */
pivotChart?: PivotChartConfig;
/** pivot 모드: Excel/PDF 내보내기 */
pivotExportConfig?: PivotExportConfig;
// ─── card 모드 전용 ───
/** card 모드: 한 줄에 표시할 카드 수 */
@@ -0,0 +1,180 @@
/**
* PivotGrid 집계 함수 유틸리티
* 다양한 집계 연산을 수행합니다.
*/
import { getFormatRules } from "@/lib/formatting";
import { PivotAggregationType, PivotFieldFormat } from "../../types";
// ==================== 집계 함수 ====================
/**
* 합계 계산
*/
export function sum(values: number[]): number {
return values.reduce((acc, val) => acc + (val || 0), 0);
}
/**
* 개수 계산
*/
export function count(values: any[]): number {
return values.length;
}
/**
* 평균 계산
*/
export function avg(values: number[]): number {
if (values.length === 0) return 0;
return sum(values) / values.length;
}
/**
* 최소값 계산
*/
export function min(values: number[]): number {
if (values.length === 0) return 0;
return Math.min(...values.filter((v) => v !== null && v !== undefined));
}
/**
* 최대값 계산
*/
export function max(values: number[]): number {
if (values.length === 0) return 0;
return Math.max(...values.filter((v) => v !== null && v !== undefined));
}
/**
* 고유값 개수 계산
*/
export function countDistinct(values: any[]): number {
return new Set(values.filter((v) => v !== null && v !== undefined)).size;
}
/**
* 집계 타입에 따른 집계 수행
*/
export function aggregate(
values: any[],
type: PivotAggregationType = "sum"
): number {
const numericValues = values
.map((v) => (typeof v === "number" ? v : parseFloat(v)))
.filter((v) => !isNaN(v));
switch (type) {
case "sum":
return sum(numericValues);
case "count":
return count(values);
case "avg":
return avg(numericValues);
case "min":
return min(numericValues);
case "max":
return max(numericValues);
case "countDistinct":
return countDistinct(values);
default:
return sum(numericValues);
}
}
// ==================== 포맷 함수 ====================
/**
* 숫자 포맷팅
*/
export function formatNumber(
value: number | null | undefined,
format?: PivotFieldFormat
): string {
if (value === null || value === undefined) return "-";
const {
type = "number",
precision = 0,
thousandSeparator = true,
prefix = "",
suffix = "",
} = format || {};
let formatted: string;
const locale = getFormatRules().number.locale;
switch (type) {
case "currency":
formatted = value.toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
break;
case "percent":
formatted = (value * 100).toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
break;
case "number":
default:
if (thousandSeparator) {
formatted = value.toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
} else {
formatted = value.toFixed(precision);
}
break;
}
return `${prefix}${formatted}${suffix}`;
}
/**
* 날짜 포맷팅
*/
export function formatDate(
value: Date | string | null | undefined,
format: string = getFormatRules().date.display
): string {
if (!value) return "-";
const date = typeof value === "string" ? new Date(value) : value;
if (isNaN(date.getTime())) return "-";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const quarter = Math.ceil((date.getMonth() + 1) / 3);
return format
.replace("YYYY", String(year))
.replace("MM", month)
.replace("DD", day)
.replace("Q", `Q${quarter}`);
}
/**
* 집계 타입 라벨 반환
*/
export function getAggregationLabel(type: PivotAggregationType): string {
const labels: Record<PivotAggregationType, string> = {
sum: "합계",
count: "개수",
avg: "평균",
min: "최소",
max: "최대",
countDistinct: "고유값",
};
return labels[type] || "합계";
}
@@ -0,0 +1,311 @@
/**
* 조건부 서식 유틸리티
* 셀 값에 따른 스타일 계산
*/
import { PivotConditionalFormatRule } 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: PivotConditionalFormatRule
): 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: PivotConditionalFormatRule
): 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: PivotConditionalFormatRule
): 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: PivotConditionalFormatRule
): 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: PivotConditionalFormatRule[],
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`);
}
@@ -0,0 +1,6 @@
export * from "./aggregation";
export * from "./pivotEngine";
export * from "./exportExcel";
export * from "./conditionalFormat";
@@ -0,0 +1,812 @@
/**
* PivotGrid 데이터 처리 엔진
* 원시 데이터를 피벗 구조로 변환합니다.
*/
import {
PivotFieldConfig,
PivotResult,
PivotHeaderNode,
PivotFlatRow,
PivotFlatColumn,
PivotCellValue,
PivotDateGroupInterval,
PivotAggregationType,
PivotSummaryDisplayMode,
} from "../../types";
import { aggregate, formatNumber, formatDate } from "./aggregation";
// ==================== 헬퍼 함수 ====================
/**
* 필드 값 추출 (날짜 그룹핑 포함)
*/
function getFieldValue(
row: Record<string, any>,
field: PivotFieldConfig
): string {
const rawValue = row[field.field];
if (rawValue === null || rawValue === undefined) {
return "(빈 값)";
}
// 날짜 그룹핑 처리
if (field.groupInterval && field.dataType === "date") {
const date = new Date(rawValue);
if (isNaN(date.getTime())) return String(rawValue);
switch (field.groupInterval) {
case "year":
return String(date.getFullYear());
case "quarter":
return `Q${Math.ceil((date.getMonth() + 1) / 3)}`;
case "month":
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
case "week":
const weekNum = getWeekNumber(date);
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
case "day":
return formatDate(date);
default:
return String(rawValue);
}
}
return String(rawValue);
}
/**
* 주차 계산
*/
function getWeekNumber(date: Date): number {
const d = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
);
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
/**
* 경로를 키로 변환
*/
export function pathToKey(path: string[]): string {
return path.join("||");
}
/**
* 키를 경로로 변환
*/
export function keyToPath(key: string): string[] {
return key.split("||");
}
// ==================== 헤더 생성 ====================
/**
* 계층적 헤더 노드 생성
*/
function buildHeaderTree(
data: Record<string, any>[],
fields: PivotFieldConfig[],
expandedPaths: Set<string>
): PivotHeaderNode[] {
if (fields.length === 0) return [];
// 첫 번째 필드로 그룹화
const firstField = fields[0];
const groups = new Map<string, Record<string, any>[]>();
data.forEach((row) => {
const value = getFieldValue(row, firstField);
if (!groups.has(value)) {
groups.set(value, []);
}
groups.get(value)!.push(row);
});
// 정렬
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
if (firstField.sortOrder === "desc") {
return b.localeCompare(a, "ko");
}
return a.localeCompare(b, "ko");
});
// 노드 생성
const nodes: PivotHeaderNode[] = [];
const remainingFields = fields.slice(1);
for (const key of sortedKeys) {
const groupData = groups.get(key)!;
const path = [key];
const pathKey = pathToKey(path);
const node: PivotHeaderNode = {
value: key,
caption: key,
level: 0,
isExpanded: expandedPaths.has(pathKey),
path: path,
span: 1,
};
// 자식 노드 생성 (확장된 경우만)
if (remainingFields.length > 0 && node.isExpanded) {
node.children = buildChildNodes(
groupData,
remainingFields,
path,
expandedPaths,
1
);
// span 계산
node.span = calculateSpan(node.children);
}
nodes.push(node);
}
return nodes;
}
/**
* 자식 노드 재귀 생성
*/
function buildChildNodes(
data: Record<string, any>[],
fields: PivotFieldConfig[],
parentPath: string[],
expandedPaths: Set<string>,
level: number
): PivotHeaderNode[] {
if (fields.length === 0) return [];
const field = fields[0];
const groups = new Map<string, Record<string, any>[]>();
data.forEach((row) => {
const value = getFieldValue(row, field);
if (!groups.has(value)) {
groups.set(value, []);
}
groups.get(value)!.push(row);
});
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
if (field.sortOrder === "desc") {
return b.localeCompare(a, "ko");
}
return a.localeCompare(b, "ko");
});
const nodes: PivotHeaderNode[] = [];
const remainingFields = fields.slice(1);
for (const key of sortedKeys) {
const groupData = groups.get(key)!;
const path = [...parentPath, key];
const pathKey = pathToKey(path);
const node: PivotHeaderNode = {
value: key,
caption: key,
level: level,
isExpanded: expandedPaths.has(pathKey),
path: path,
span: 1,
};
if (remainingFields.length > 0 && node.isExpanded) {
node.children = buildChildNodes(
groupData,
remainingFields,
path,
expandedPaths,
level + 1
);
node.span = calculateSpan(node.children);
}
nodes.push(node);
}
return nodes;
}
/**
* span 계산 (colspan/rowspan)
*/
function calculateSpan(children?: PivotHeaderNode[]): number {
if (!children || children.length === 0) return 1;
return children.reduce((sum, child) => sum + (child.span ?? 1), 0);
}
// ==================== 플랫 구조 변환 ====================
/**
* 헤더 트리를 플랫 행으로 변환
*/
function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] {
const result: PivotFlatRow[] = [];
function traverse(node: PivotHeaderNode) {
result.push({
path: node.path,
level: node.level,
caption: node.caption,
isExpanded: node.isExpanded,
hasChildren: !!(node.children && node.children.length > 0),
});
if (node.isExpanded && node.children) {
for (const child of node.children) {
traverse(child);
}
}
}
for (const node of nodes) {
traverse(node);
}
return result;
}
/**
* 헤더 트리를 플랫 열로 변환 (각 레벨별)
*/
function flattenColumns(
nodes: PivotHeaderNode[],
maxLevel: number
): PivotFlatColumn[][] {
const levels: PivotFlatColumn[][] = Array.from(
{ length: maxLevel + 1 },
() => []
);
function traverse(node: PivotHeaderNode, currentLevel: number) {
levels[currentLevel].push({
path: node.path,
level: currentLevel,
caption: node.caption,
span: node.span ?? 1,
});
if (node.children && node.isExpanded) {
for (const child of node.children) {
traverse(child, currentLevel + 1);
}
} else if (currentLevel < maxLevel) {
// 확장되지 않은 노드는 다음 레벨들에서 span으로 처리
for (let i = currentLevel + 1; i <= maxLevel; i++) {
levels[i].push({
path: node.path,
level: i,
caption: "",
span: node.span ?? 1,
});
}
}
}
for (const node of nodes) {
traverse(node, 0);
}
return levels;
}
/**
* 열 헤더의 최대 깊이 계산
*/
function getMaxColumnLevel(
nodes: PivotHeaderNode[],
totalFields: number
): number {
let maxLevel = 0;
function traverse(node: PivotHeaderNode, level: number) {
maxLevel = Math.max(maxLevel, level);
if (node.children && node.isExpanded) {
for (const child of node.children) {
traverse(child, level + 1);
}
}
}
for (const node of nodes) {
traverse(node, 0);
}
return Math.min(maxLevel, totalFields - 1);
}
// ==================== 데이터 매트릭스 생성 ====================
/**
* 데이터 매트릭스 생성
*/
function buildDataMatrix(
data: Record<string, any>[],
rowFields: PivotFieldConfig[],
columnFields: PivotFieldConfig[],
dataFields: PivotFieldConfig[],
flatRows: PivotFlatRow[],
flatColumnLeaves: string[][]
): Map<string, PivotCellValue[]> {
const matrix = new Map<string, PivotCellValue[]>();
// 각 셀에 대해 해당하는 데이터 집계
for (const row of flatRows) {
for (const colPath of flatColumnLeaves) {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
// 해당 행/열 경로에 맞는 데이터 필터링
const filteredData = data.filter((record) => {
// 행 조건 확인
for (let i = 0; i < row.path.length; i++) {
const field = rowFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== row.path[i]) return false;
}
// 열 조건 확인
for (let i = 0; i < colPath.length; i++) {
const field = columnFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== colPath[i]) return false;
}
return true;
});
// 데이터 필드별 집계
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = filteredData.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(
values,
dataField.summaryType || "sum"
);
const formattedValue = formatNumber(
aggregatedValue,
dataField.format
);
return {
field: dataField.field,
value: aggregatedValue,
formattedValue,
};
});
matrix.set(cellKey, cellValues);
}
}
return matrix;
}
/**
* 열 leaf 노드 경로 추출
*/
function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] {
const leaves: string[][] = [];
function traverse(node: PivotHeaderNode) {
if (!node.isExpanded || !node.children || node.children.length === 0) {
leaves.push(node.path);
} else {
for (const child of node.children) {
traverse(child);
}
}
}
for (const node of nodes) {
traverse(node);
}
// 열 필드가 없을 경우 빈 경로 추가
if (leaves.length === 0) {
leaves.push([]);
}
return leaves;
}
// ==================== Summary Display Mode 적용 ====================
/**
* Summary Display Mode에 따른 값 변환
*/
function applyDisplayMode(
value: number,
displayMode: PivotSummaryDisplayMode | 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;
}
// ==================== 총합계 계산 ====================
/**
* 총합계 계산
*/
function calculateGrandTotals(
data: Record<string, any>[],
rowFields: PivotFieldConfig[],
columnFields: PivotFieldConfig[],
dataFields: PivotFieldConfig[],
flatRows: PivotFlatRow[],
flatColumnLeaves: string[][]
): {
row: Map<string, PivotCellValue[]>;
column: Map<string, PivotCellValue[]>;
grand: PivotCellValue[];
} {
const rowTotals = new Map<string, PivotCellValue[]>();
const columnTotals = new Map<string, PivotCellValue[]>();
// 행별 총합 (각 행의 모든 열 합계)
for (const row of flatRows) {
const filteredData = data.filter((record) => {
for (let i = 0; i < row.path.length; i++) {
const field = rowFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== row.path[i]) return false;
}
return true;
});
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = filteredData.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
return {
field: dataField.field,
value: aggregatedValue,
formattedValue: formatNumber(aggregatedValue, dataField.format),
};
});
rowTotals.set(pathToKey(row.path), cellValues);
}
// 열별 총합 (각 열의 모든 행 합계)
for (const colPath of flatColumnLeaves) {
const filteredData = data.filter((record) => {
for (let i = 0; i < colPath.length; i++) {
const field = columnFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== colPath[i]) return false;
}
return true;
});
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = filteredData.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
return {
field: dataField.field,
value: aggregatedValue,
formattedValue: formatNumber(aggregatedValue, dataField.format),
};
});
columnTotals.set(pathToKey(colPath), cellValues);
}
// 대총합
const grandValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = data.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
return {
field: dataField.field,
value: aggregatedValue,
formattedValue: formatNumber(aggregatedValue, dataField.format),
};
});
return {
row: rowTotals,
column: columnTotals,
grand: grandValues,
};
}
// ==================== 메인 함수 ====================
/**
* 피벗 데이터 처리
*/
export function processPivotData(
data: Record<string, any>[],
fields: PivotFieldConfig[],
expandedRowPaths: string[][] = [],
expandedColumnPaths: string[][] = []
): PivotResult {
// 영역별 필드 분리
const rowFields = fields
.filter((f) => f.area === "row" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const columnFields = fields
.filter((f) => f.area === "column" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const dataFields = fields
.filter((f) => f.area === "data" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const filterFields = fields.filter(
(f) => f.area === "filter" && f.visible !== false
);
// 필터 적용
let filteredData = data;
for (const filterField of filterFields) {
if (filterField.filterValues && filterField.filterValues.length > 0) {
filteredData = filteredData.filter((row) => {
const value = getFieldValue(row, filterField);
if (filterField.filterType === "exclude") {
return !filterField.filterValues!.includes(value);
}
return filterField.filterValues!.includes(value);
});
}
}
// 확장 경로 Set 변환
const expandedRowSet = new Set(expandedRowPaths.map(pathToKey));
const expandedColSet = new Set(expandedColumnPaths.map(pathToKey));
// 기본 확장: 첫 번째 레벨 모두 확장
if (expandedRowPaths.length === 0 && rowFields.length > 0) {
const firstField = rowFields[0];
const uniqueValues = new Set(
filteredData.map((row) => getFieldValue(row, firstField))
);
uniqueValues.forEach((val) => expandedRowSet.add(val));
}
if (expandedColumnPaths.length === 0 && columnFields.length > 0) {
const firstField = columnFields[0];
const uniqueValues = new Set(
filteredData.map((row) => getFieldValue(row, firstField))
);
uniqueValues.forEach((val) => expandedColSet.add(val));
}
// 헤더 트리 생성
const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet);
const columnHeaders = buildHeaderTree(
filteredData,
columnFields,
expandedColSet
);
// 플랫 구조 변환
const flatRows = flattenRows(rowHeaders);
const flatColumnLeaves = getColumnLeaves(columnHeaders);
const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length);
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
// 데이터 매트릭스 생성
let dataMatrix = buildDataMatrix(
filteredData,
rowFields,
columnFields,
dataFields,
flatRows,
flatColumnLeaves
);
// 총합계 계산
const grandTotals = calculateGrandTotals(
filteredData,
rowFields,
columnFields,
dataFields,
flatRows,
flatColumnLeaves
);
// Summary Display Mode 적용
dataMatrix = applyDisplayModeToMatrix(
dataMatrix,
dataFields,
flatRows,
flatColumnLeaves,
grandTotals.row,
grandTotals.column,
grandTotals.grand
);
return {
rowHeaders,
columnHeaders,
dataMatrix,
flatRows,
flatColumns: flatColumnLeaves.map((path, idx) => ({
path,
level: path.length - 1,
caption: path[path.length - 1] || "",
span: 1,
})),
grandTotals,
};
}
File diff suppressed because it is too large Load Diff