ERP-node/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx

989 lines
28 KiB
TypeScript
Raw Normal View History

"use client";
/**
* PivotGrid
*
*/
import React, { useState, useMemo, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
PivotGridProps,
PivotResult,
PivotFieldConfig,
PivotCellData,
PivotFlatRow,
PivotCellValue,
PivotGridState,
PivotAreaType,
} from "./types";
import { processPivotData, pathToKey } from "./utils/pivotEngine";
import { exportPivotToExcel } from "./utils/exportExcel";
import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat";
import { FieldPanel } from "./components/FieldPanel";
import { FieldChooser } from "./components/FieldChooser";
import { DrillDownModal } from "./components/DrillDownModal";
import { PivotChart } from "./components/PivotChart";
import {
ChevronRight,
ChevronDown,
Download,
Settings,
RefreshCw,
Maximize2,
Minimize2,
LayoutGrid,
FileSpreadsheet,
BarChart3,
} 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;
onDoubleClick?: () => void;
conditionalStyle?: CellFormatStyle;
}
const DataCell: React.FC<DataCellProps> = ({
values,
isTotal = false,
onClick,
onDoubleClick,
conditionalStyle,
}) => {
// 조건부 서식 스타일 계산
const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {};
const hasDataBar = conditionalStyle?.dataBarWidth !== undefined;
const icon = conditionalStyle?.icon;
if (!values || values.length === 0) {
return (
<td
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-right text-sm",
isTotal && "bg-primary/5 font-medium"
)}
style={cellStyle}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
-
</td>
);
}
// 단일 데이터 필드인 경우
if (values.length === 1) {
return (
<td
className={cn(
"border-r border-b border-border relative",
"px-2 py-1.5 text-right text-sm tabular-nums",
isTotal && "bg-primary/5 font-medium",
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
)}
style={cellStyle}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
{/* Data Bar */}
{hasDataBar && (
<div
className="absolute inset-y-0 left-0 opacity-30"
style={{
width: `${conditionalStyle?.dataBarWidth}%`,
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
}}
/>
)}
<span className="relative z-10 flex items-center justify-end gap-1">
{icon && <span>{icon}</span>}
{values[0].formattedValue}
</span>
</td>
);
}
// 다중 데이터 필드인 경우
return (
<>
{values.map((val, idx) => (
<td
key={idx}
className={cn(
"border-r border-b border-border relative",
"px-2 py-1.5 text-right text-sm tabular-nums",
isTotal && "bg-primary/5 font-medium",
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
)}
style={cellStyle}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
{hasDataBar && (
<div
className="absolute inset-y-0 left-0 opacity-30"
style={{
width: `${conditionalStyle?.dataBarWidth}%`,
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
}}
/>
)}
<span className="relative z-10 flex items-center justify-end gap-1">
{icon && <span>{icon}</span>}
{val.formattedValue}
</span>
</td>
))}
</>
);
};
// ==================== 메인 컴포넌트 ====================
export const PivotGridComponent: React.FC<PivotGridProps> = ({
title,
fields: initialFields = [],
totals = {
showRowGrandTotals: true,
showColumnGrandTotals: true,
showRowTotals: true,
showColumnTotals: true,
},
style = {
theme: "default",
headerStyle: "default",
cellPadding: "normal",
borderStyle: "light",
alternateRowColors: true,
highlightTotals: true,
},
fieldChooser,
chart: chartConfig,
allowExpandAll = true,
height = "auto",
maxHeight,
exportConfig,
data: externalData,
onCellClick,
onCellDoubleClick,
onFieldDrop,
onExpandChange,
}) => {
// 디버깅 로그
console.log("🔶 PivotGridComponent props:", {
title,
hasExternalData: !!externalData,
externalDataLength: externalData?.length,
initialFieldsLength: initialFields?.length,
});
// ==================== 상태 ====================
const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields);
const [pivotState, setPivotState] = useState<PivotGridState>({
expandedRowPaths: [],
expandedColumnPaths: [],
sortConfig: null,
filterConfig: {},
});
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFieldPanel, setShowFieldPanel] = useState(true);
const [showFieldChooser, setShowFieldChooser] = useState(false);
const [drillDownData, setDrillDownData] = useState<{
open: boolean;
cellData: PivotCellData | null;
}>({ open: false, cellData: null });
const [showChart, setShowChart] = useState(chartConfig?.enabled || false);
// 외부 fields 변경 시 동기화
useEffect(() => {
if (initialFields.length > 0) {
setFields(initialFields);
}
}, [initialFields]);
// 데이터
const data = externalData || [];
// ==================== 필드 분류 ====================
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 filterFields = useMemo(
() =>
fields
.filter((f) => f.area === "filter" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
[fields]
);
// 사용 가능한 필드 목록 (FieldChooser용)
const availableFields = useMemo(() => {
if (data.length === 0) return [];
const sampleRow = data[0];
return Object.keys(sampleRow).map((key) => {
const existingField = fields.find((f) => f.field === key);
const value = sampleRow[key];
// 데이터 타입 추론
let dataType: "string" | "number" | "date" | "boolean" = "string";
if (typeof value === "number") dataType = "number";
else if (typeof value === "boolean") dataType = "boolean";
else if (value instanceof Date) dataType = "date";
else if (typeof value === "string") {
// 날짜 문자열 감지
if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date";
}
return {
field: key,
caption: existingField?.caption || key,
dataType,
isSelected: existingField?.visible !== false,
currentArea: existingField?.area,
};
});
}, [data, fields]);
// ==================== 피벗 처리 ====================
const pivotResult = useMemo<PivotResult | null>(() => {
if (!data || data.length === 0 || fields.length === 0) {
return null;
}
const visibleFields = fields.filter((f) => f.visible !== false);
if (visibleFields.filter((f) => f.area !== "filter").length === 0) {
return null;
}
return processPivotData(
data,
visibleFields,
pivotState.expandedRowPaths,
pivotState.expandedColumnPaths
);
}, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
// 조건부 서식용 전체 값 수집
const allCellValues = useMemo(() => {
if (!pivotResult) return new Map<string, number[]>();
const valuesByField = new Map<string, number[]>();
// 데이터 매트릭스에서 모든 값 수집
pivotResult.dataMatrix.forEach((values) => {
values.forEach((val) => {
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
const existing = valuesByField.get(val.field) || [];
existing.push(val.value);
valuesByField.set(val.field, existing);
}
});
});
// 행 총계 값 수집
pivotResult.grandTotals.row.forEach((values) => {
values.forEach((val) => {
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
const existing = valuesByField.get(val.field) || [];
existing.push(val.value);
valuesByField.set(val.field, existing);
}
});
});
// 열 총계 값 수집
pivotResult.grandTotals.column.forEach((values) => {
values.forEach((val) => {
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
const existing = valuesByField.get(val.field) || [];
existing.push(val.value);
valuesByField.set(val.field, existing);
}
});
});
return valuesByField;
}, [pivotResult]);
// 조건부 서식 스타일 계산 헬퍼
const getCellConditionalStyle = useCallback(
(value: number | undefined, field: string): CellFormatStyle => {
if (!style?.conditionalFormats || style.conditionalFormats.length === 0) {
return {};
}
const allValues = allCellValues.get(field) || [];
return getConditionalStyle(value, field, style.conditionalFormats, allValues);
},
[style?.conditionalFormats, allCellValues]
);
// ==================== 이벤트 핸들러 ====================
// 필드 변경
const handleFieldsChange = useCallback(
(newFields: PivotFieldConfig[]) => {
setFields(newFields);
},
[]
);
// 행 확장/축소
const handleToggleRowExpand = useCallback(
(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]
);
// 셀 더블클릭 (Drill Down)
const handleCellDoubleClick = useCallback(
(rowPath: string[], colPath: string[], values: PivotCellValue[]) => {
const cellData: PivotCellData = {
value: values[0]?.value,
rowPath,
columnPath: colPath,
field: values[0]?.field,
};
// Drill Down 모달 열기
setDrillDownData({ open: true, cellData });
// 외부 콜백 호출
if (onCellDoubleClick) {
onCellDoubleClick(cellData);
}
},
[onCellDoubleClick]
);
// CSV 내보내기
const handleExportCSV = useCallback(() => {
if (!pivotResult) return;
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]);
// Excel 내보내기
const handleExportExcel = useCallback(async () => {
if (!pivotResult) return;
try {
await exportPivotToExcel(pivotResult, fields, totals, {
fileName: title || "pivot_export",
title: title,
});
} catch (error) {
console.error("Excel 내보내기 실패:", error);
}
}, [pivotResult, fields, totals, title]);
// ==================== 렌더링 ====================
// 빈 상태
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>
);
}
// 필드 미설정
const hasActiveFields = fields.some(
(f) => f.visible !== false && f.area !== "filter"
);
if (!hasActiveFields) {
return (
<div
className={cn(
"flex flex-col",
"border border-border rounded-lg overflow-hidden bg-background"
)}
>
{/* 필드 패널 */}
<FieldPanel
fields={fields}
onFieldsChange={handleFieldsChange}
collapsed={!showFieldPanel}
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
/>
{/* 안내 메시지 */}
<div className="flex flex-col items-center justify-center p-8 text-center text-muted-foreground">
<Settings className="h-8 w-8 mb-2 opacity-50" />
<p className="text-sm"> </p>
<p className="text-xs mt-1">
, ,
</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setShowFieldChooser(true)}
>
<LayoutGrid className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 필드 선택기 모달 */}
<FieldChooser
open={showFieldChooser}
onOpenChange={setShowFieldChooser}
availableFields={availableFields}
selectedFields={fields}
onFieldsChange={handleFieldsChange}
/>
</div>
);
}
// 피벗 결과 없음
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,
}}
>
{/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */}
<FieldPanel
fields={fields}
onFieldsChange={handleFieldsChange}
collapsed={!showFieldPanel}
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
/>
{/* 헤더 툴바 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
<div className="flex items-center 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">
{/* 필드 선택기 버튼 */}
{fieldChooser?.enabled !== false && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => setShowFieldChooser(true)}
title="필드 선택기"
>
<LayoutGrid className="h-4 w-4" />
</Button>
)}
{/* 필드 패널 토글 */}
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => setShowFieldPanel(!showFieldPanel)}
title={showFieldPanel ? "필드 패널 숨기기" : "필드 패널 보기"}
>
<Settings className="h-4 w-4" />
</Button>
{allowExpandAll && (
<>
<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>
</>
)}
{/* 차트 토글 */}
{chartConfig && (
<Button
variant={showChart ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
onClick={() => setShowChart(!showChart)}
title={showChart ? "차트 숨기기" : "차트 보기"}
>
<BarChart3 className="h-4 w-4" />
</Button>
)}
{/* 내보내기 버튼들 */}
{exportConfig?.excel && (
<>
<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={handleExportExcel}
title="Excel 내보내기"
>
<FileSpreadsheet 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) || [];
// 조건부 서식 (첫 번째 값 기준)
const conditionalStyle =
values.length > 0 && values[0].field
? getCellConditionalStyle(values[0].value, values[0].field)
: undefined;
return (
<DataCell
key={colIdx}
values={values}
conditionalStyle={conditionalStyle}
onClick={
onCellClick
? () => handleCellClick(row.path, col.path, values)
: undefined
}
onDoubleClick={() =>
handleCellDoubleClick(row.path, col.path, values)
}
/>
);
})}
{/* 행 총계 */}
{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>
{/* 차트 */}
{showChart && chartConfig && pivotResult && (
<PivotChart
pivotResult={pivotResult}
config={{
...chartConfig,
enabled: true,
}}
dataFields={dataFields}
/>
)}
{/* 필드 선택기 모달 */}
<FieldChooser
open={showFieldChooser}
onOpenChange={setShowFieldChooser}
availableFields={availableFields}
selectedFields={fields}
onFieldsChange={handleFieldsChange}
/>
{/* Drill Down 모달 */}
<DrillDownModal
open={drillDownData.open}
onOpenChange={(open) => setDrillDownData((prev) => ({ ...prev, open }))}
cellData={drillDownData.cellData}
data={data}
fields={fields}
rowFields={rowFields}
columnFields={columnFields}
/>
</div>
);
};
export default PivotGridComponent;