그리드? 일단 추가랑 복사기능 되게 했음
This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 메인 컴포넌트
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PivotGridProps,
|
||||
PivotResult,
|
||||
PivotFieldConfig,
|
||||
PivotCellData,
|
||||
PivotFlatRow,
|
||||
PivotCellValue,
|
||||
PivotGridState,
|
||||
} from "./types";
|
||||
import { processPivotData, pathToKey } from "./utils/pivotEngine";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// ==================== 서브 컴포넌트 ====================
|
||||
|
||||
// 행 헤더 셀
|
||||
interface RowHeaderCellProps {
|
||||
row: PivotFlatRow;
|
||||
rowFields: PivotFieldConfig[];
|
||||
onToggleExpand: (path: string[]) => void;
|
||||
}
|
||||
|
||||
const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
|
||||
row,
|
||||
rowFields,
|
||||
onToggleExpand,
|
||||
}) => {
|
||||
const indentSize = row.level * 20;
|
||||
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"border-r border-b border-border bg-muted/50",
|
||||
"px-2 py-1.5 text-left text-sm",
|
||||
"whitespace-nowrap font-medium",
|
||||
row.isExpanded && "bg-muted/70"
|
||||
)}
|
||||
style={{ paddingLeft: `${8 + indentSize}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{row.hasChildren && (
|
||||
<button
|
||||
onClick={() => onToggleExpand(row.path)}
|
||||
className="p-0.5 hover:bg-accent rounded"
|
||||
>
|
||||
{row.isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!row.hasChildren && <span className="w-4" />}
|
||||
<span>{row.caption}</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
// 데이터 셀
|
||||
interface DataCellProps {
|
||||
values: PivotCellValue[];
|
||||
isTotal?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const DataCell: React.FC<DataCellProps> = ({
|
||||
values,
|
||||
isTotal = false,
|
||||
onClick,
|
||||
}) => {
|
||||
if (!values || values.length === 0) {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"px-2 py-1.5 text-right text-sm",
|
||||
isTotal && "bg-primary/5 font-medium"
|
||||
)}
|
||||
>
|
||||
-
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// 단일 데이터 필드인 경우
|
||||
if (values.length === 1) {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||
isTotal && "bg-primary/5 font-medium",
|
||||
onClick && "cursor-pointer hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{values[0].formattedValue}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// 다중 데이터 필드인 경우
|
||||
return (
|
||||
<>
|
||||
{values.map((val, idx) => (
|
||||
<td
|
||||
key={idx}
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||
isTotal && "bg-primary/5 font-medium",
|
||||
onClick && "cursor-pointer hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{val.formattedValue}
|
||||
</td>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
title,
|
||||
fields = [],
|
||||
totals = {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
},
|
||||
style = {
|
||||
theme: "default",
|
||||
headerStyle: "default",
|
||||
cellPadding: "normal",
|
||||
borderStyle: "light",
|
||||
alternateRowColors: true,
|
||||
highlightTotals: true,
|
||||
},
|
||||
allowExpandAll = true,
|
||||
height = "auto",
|
||||
maxHeight,
|
||||
exportConfig,
|
||||
data: externalData,
|
||||
onCellClick,
|
||||
onExpandChange,
|
||||
}) => {
|
||||
// ==================== 상태 ====================
|
||||
|
||||
const [pivotState, setPivotState] = useState<PivotGridState>({
|
||||
expandedRowPaths: [],
|
||||
expandedColumnPaths: [],
|
||||
sortConfig: null,
|
||||
filterConfig: {},
|
||||
});
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// 데이터
|
||||
const data = externalData || [];
|
||||
|
||||
// ==================== 필드 분류 ====================
|
||||
|
||||
const rowFields = useMemo(
|
||||
() =>
|
||||
fields
|
||||
.filter((f) => f.area === "row" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
|
||||
[fields]
|
||||
);
|
||||
|
||||
const columnFields = useMemo(
|
||||
() =>
|
||||
fields
|
||||
.filter((f) => f.area === "column" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
|
||||
[fields]
|
||||
);
|
||||
|
||||
const dataFields = useMemo(
|
||||
() =>
|
||||
fields
|
||||
.filter((f) => f.area === "data" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
|
||||
[fields]
|
||||
);
|
||||
|
||||
// ==================== 피벗 처리 ====================
|
||||
|
||||
const pivotResult = useMemo<PivotResult | null>(() => {
|
||||
if (!data || data.length === 0 || fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return processPivotData(
|
||||
data,
|
||||
fields,
|
||||
pivotState.expandedRowPaths,
|
||||
pivotState.expandedColumnPaths
|
||||
);
|
||||
}, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
|
||||
|
||||
// ==================== 이벤트 핸들러 ====================
|
||||
|
||||
// 행 확장/축소
|
||||
const handleToggleRowExpand = useCallback(
|
||||
(path: string[]) => {
|
||||
setPivotState((prev) => {
|
||||
const pathKey = pathToKey(path);
|
||||
const existingIndex = prev.expandedRowPaths.findIndex(
|
||||
(p) => pathToKey(p) === pathKey
|
||||
);
|
||||
|
||||
let newPaths: string[][];
|
||||
if (existingIndex >= 0) {
|
||||
newPaths = prev.expandedRowPaths.filter(
|
||||
(_, i) => i !== existingIndex
|
||||
);
|
||||
} else {
|
||||
newPaths = [...prev.expandedRowPaths, path];
|
||||
}
|
||||
|
||||
onExpandChange?.(newPaths);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
expandedRowPaths: newPaths,
|
||||
};
|
||||
});
|
||||
},
|
||||
[onExpandChange]
|
||||
);
|
||||
|
||||
// 전체 확장
|
||||
const handleExpandAll = useCallback(() => {
|
||||
if (!pivotResult) return;
|
||||
|
||||
const allRowPaths: string[][] = [];
|
||||
|
||||
pivotResult.flatRows.forEach((row) => {
|
||||
if (row.hasChildren) {
|
||||
allRowPaths.push(row.path);
|
||||
}
|
||||
});
|
||||
|
||||
setPivotState((prev) => ({
|
||||
...prev,
|
||||
expandedRowPaths: allRowPaths,
|
||||
expandedColumnPaths: [],
|
||||
}));
|
||||
}, [pivotResult]);
|
||||
|
||||
// 전체 축소
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
setPivotState((prev) => ({
|
||||
...prev,
|
||||
expandedRowPaths: [],
|
||||
expandedColumnPaths: [],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 셀 클릭
|
||||
const handleCellClick = useCallback(
|
||||
(rowPath: string[], colPath: string[], values: PivotCellValue[]) => {
|
||||
if (!onCellClick) return;
|
||||
|
||||
const cellData: PivotCellData = {
|
||||
value: values[0]?.value,
|
||||
rowPath,
|
||||
columnPath: colPath,
|
||||
field: values[0]?.field,
|
||||
};
|
||||
|
||||
onCellClick(cellData);
|
||||
},
|
||||
[onCellClick]
|
||||
);
|
||||
|
||||
// CSV 내보내기
|
||||
const handleExportCSV = useCallback(() => {
|
||||
if (!pivotResult) return;
|
||||
|
||||
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
|
||||
|
||||
let csv = "";
|
||||
|
||||
// 헤더 행
|
||||
const headerRow = [""].concat(
|
||||
flatColumns.map((col) => col.caption || "총계")
|
||||
);
|
||||
if (totals?.showRowGrandTotals) {
|
||||
headerRow.push("총계");
|
||||
}
|
||||
csv += headerRow.join(",") + "\n";
|
||||
|
||||
// 데이터 행
|
||||
flatRows.forEach((row) => {
|
||||
const rowData = [row.caption];
|
||||
|
||||
flatColumns.forEach((col) => {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||
const values = dataMatrix.get(cellKey);
|
||||
rowData.push(values?.[0]?.value?.toString() || "");
|
||||
});
|
||||
|
||||
if (totals?.showRowGrandTotals) {
|
||||
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||||
rowData.push(rowTotal?.[0]?.value?.toString() || "");
|
||||
}
|
||||
|
||||
csv += rowData.join(",") + "\n";
|
||||
});
|
||||
|
||||
// 열 총계 행
|
||||
if (totals?.showColumnGrandTotals) {
|
||||
const totalRow = ["총계"];
|
||||
flatColumns.forEach((col) => {
|
||||
const colTotal = grandTotals.column.get(pathToKey(col.path));
|
||||
totalRow.push(colTotal?.[0]?.value?.toString() || "");
|
||||
});
|
||||
if (totals?.showRowGrandTotals) {
|
||||
totalRow.push(grandTotals.grand[0]?.value?.toString() || "");
|
||||
}
|
||||
csv += totalRow.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 = `${title || "pivot"}_export.csv`;
|
||||
link.click();
|
||||
}, [pivotResult, totals, title]);
|
||||
|
||||
// ==================== 렌더링 ====================
|
||||
|
||||
// 빈 상태
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"p-8 text-center text-muted-foreground",
|
||||
"border border-dashed border-border rounded-lg"
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">데이터가 없습니다</p>
|
||||
<p className="text-xs mt-1">데이터를 로드하거나 필드를 설정해주세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 필드 미설정
|
||||
if (fields.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"p-8 text-center text-muted-foreground",
|
||||
"border border-dashed border-border rounded-lg"
|
||||
)}
|
||||
>
|
||||
<Settings className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">필드가 설정되지 않았습니다</p>
|
||||
<p className="text-xs mt-1">
|
||||
행, 열, 데이터 영역에 필드를 배치해주세요
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 피벗 결과 없음
|
||||
if (!pivotResult) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<RefreshCw className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
"border border-border rounded-lg overflow-hidden",
|
||||
"bg-background",
|
||||
isFullscreen && "fixed inset-4 z-50 shadow-2xl"
|
||||
)}
|
||||
style={{
|
||||
height: isFullscreen ? "auto" : height,
|
||||
maxHeight: isFullscreen ? "none" : maxHeight,
|
||||
}}
|
||||
>
|
||||
{/* 헤더 툴바 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
{title && <h3 className="text-sm font-medium">{title}</h3>}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({data.length}건)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{allowExpandAll && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleExpandAll}
|
||||
title="전체 확장"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleCollapseAll}
|
||||
title="전체 축소"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{exportConfig?.excel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleExportCSV}
|
||||
title="CSV 내보내기"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
title={isFullscreen ? "원래 크기" : "전체 화면"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 피벗 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
{/* 열 헤더 */}
|
||||
<tr className="bg-muted/50">
|
||||
{/* 좌상단 코너 (행 필드 라벨) */}
|
||||
<th
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"px-2 py-2 text-left text-xs font-medium",
|
||||
"bg-muted sticky left-0 top-0 z-20"
|
||||
)}
|
||||
rowSpan={columnFields.length > 0 ? 2 : 1}
|
||||
>
|
||||
{rowFields.map((f) => f.caption).join(" / ") || "항목"}
|
||||
</th>
|
||||
|
||||
{/* 열 헤더 셀 */}
|
||||
{flatColumns.map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"px-2 py-1.5 text-center text-xs font-medium",
|
||||
"bg-muted/70 sticky top-0 z-10"
|
||||
)}
|
||||
colSpan={dataFields.length || 1}
|
||||
>
|
||||
{col.caption || "(전체)"}
|
||||
</th>
|
||||
))}
|
||||
|
||||
{/* 행 총계 헤더 */}
|
||||
{totals?.showRowGrandTotals && (
|
||||
<th
|
||||
className={cn(
|
||||
"border-b border-border",
|
||||
"px-2 py-1.5 text-center text-xs font-medium",
|
||||
"bg-primary/10 sticky top-0 z-10"
|
||||
)}
|
||||
colSpan={dataFields.length || 1}
|
||||
>
|
||||
총계
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
|
||||
{dataFields.length > 1 && (
|
||||
<tr className="bg-muted/30">
|
||||
{flatColumns.map((col, colIdx) => (
|
||||
<React.Fragment key={colIdx}>
|
||||
{dataFields.map((df, dfIdx) => (
|
||||
<th
|
||||
key={`${colIdx}-${dfIdx}`}
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"px-2 py-1 text-center text-xs font-normal",
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{df.caption}
|
||||
</th>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{totals?.showRowGrandTotals &&
|
||||
dataFields.map((df, dfIdx) => (
|
||||
<th
|
||||
key={`total-${dfIdx}`}
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"px-2 py-1 text-center text-xs font-normal",
|
||||
"bg-primary/5 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{df.caption}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{flatRows.map((row, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className={cn(
|
||||
style?.alternateRowColors &&
|
||||
rowIdx % 2 === 1 &&
|
||||
"bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{/* 행 헤더 */}
|
||||
<RowHeaderCell
|
||||
row={row}
|
||||
rowFields={rowFields}
|
||||
onToggleExpand={handleToggleRowExpand}
|
||||
/>
|
||||
|
||||
{/* 데이터 셀 */}
|
||||
{flatColumns.map((col, colIdx) => {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||
const values = dataMatrix.get(cellKey) || [];
|
||||
|
||||
return (
|
||||
<DataCell
|
||||
key={colIdx}
|
||||
values={values}
|
||||
onClick={
|
||||
onCellClick
|
||||
? () => handleCellClick(row.path, col.path, values)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 행 총계 */}
|
||||
{totals?.showRowGrandTotals && (
|
||||
<DataCell
|
||||
values={grandTotals.row.get(pathToKey(row.path)) || []}
|
||||
isTotal
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* 열 총계 행 */}
|
||||
{totals?.showColumnGrandTotals && (
|
||||
<tr className="bg-primary/5 font-medium">
|
||||
<td
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"px-2 py-1.5 text-left text-sm",
|
||||
"bg-primary/10 sticky left-0"
|
||||
)}
|
||||
>
|
||||
총계
|
||||
</td>
|
||||
|
||||
{flatColumns.map((col, colIdx) => (
|
||||
<DataCell
|
||||
key={colIdx}
|
||||
values={grandTotals.column.get(pathToKey(col.path)) || []}
|
||||
isTotal
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 대총합 */}
|
||||
{totals?.showRowGrandTotals && (
|
||||
<DataCell values={grandTotals.grand} isTotal />
|
||||
)}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotGridComponent;
|
||||
Reference in New Issue
Block a user