lhj #376
|
|
@ -7,6 +7,8 @@
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
PivotGridProps,
|
PivotGridProps,
|
||||||
PivotResult,
|
PivotResult,
|
||||||
|
|
@ -50,6 +52,10 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
// ==================== 상수 ====================
|
||||||
|
|
||||||
|
const PIVOT_STATE_VERSION = "1.0"; // 상태 저장 버전 (호환성 체크용)
|
||||||
|
|
||||||
// ==================== 유틸리티 함수 ====================
|
// ==================== 유틸리티 함수 ====================
|
||||||
|
|
||||||
// 셀 병합 정보 계산
|
// 셀 병합 정보 계산
|
||||||
|
|
@ -128,7 +134,10 @@ const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{row.hasChildren && (
|
{row.hasChildren && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleExpand(row.path)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleExpand(row.path);
|
||||||
|
}}
|
||||||
className="p-0.5 hover:bg-accent rounded"
|
className="p-0.5 hover:bg-accent rounded"
|
||||||
>
|
>
|
||||||
{row.isExpanded ? (
|
{row.isExpanded ? (
|
||||||
|
|
@ -346,41 +355,44 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
const [resizeStartX, setResizeStartX] = useState<number>(0);
|
const [resizeStartX, setResizeStartX] = useState<number>(0);
|
||||||
const [resizeStartWidth, setResizeStartWidth] = useState<number>(0);
|
const [resizeStartWidth, setResizeStartWidth] = useState<number>(0);
|
||||||
|
|
||||||
// 외부 fields 변경 시 동기화
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialFields.length > 0) {
|
|
||||||
setFields(initialFields);
|
|
||||||
}
|
|
||||||
}, [initialFields]);
|
|
||||||
|
|
||||||
// 상태 저장 키
|
// 상태 저장 키
|
||||||
const stateStorageKey = `pivot-state-${title || "default"}`;
|
const stateStorageKey = `pivot-state-${title || "default"}`;
|
||||||
|
const persistSettingKey = `pivot-persist-${title || "default"}`;
|
||||||
|
|
||||||
// 상태 저장 (localStorage)
|
// 상태 유지 설정 (체크박스용)
|
||||||
const saveStateToStorage = useCallback(() => {
|
const [persistState, setPersistState] = useState<boolean>(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return true;
|
||||||
const stateToSave = {
|
const saved = localStorage.getItem(persistSettingKey);
|
||||||
fields,
|
return saved !== null ? saved === "true" : true; // 기본값 true
|
||||||
pivotState,
|
});
|
||||||
sortConfig,
|
|
||||||
columnWidths,
|
|
||||||
};
|
|
||||||
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
|
|
||||||
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
|
|
||||||
|
|
||||||
// 상태 복원 (localStorage) - 프로덕션 안전성 강화
|
// 복원 완료 여부 (initialFields 덮어쓰기 방지)
|
||||||
|
const [isStateRestored, setIsStateRestored] = useState(false);
|
||||||
|
|
||||||
|
// 상태 복원 (localStorage) - 마운트 시 한 번만 실행
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
// 상태 유지가 꺼져 있으면 복원하지 않음
|
||||||
|
if (!persistState) {
|
||||||
|
localStorage.removeItem(stateStorageKey);
|
||||||
|
setIsStateRestored(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedState = localStorage.getItem(stateStorageKey);
|
const savedState = localStorage.getItem(stateStorageKey);
|
||||||
if (!savedState) return;
|
if (!savedState) {
|
||||||
|
setIsStateRestored(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = JSON.parse(savedState);
|
const parsed = JSON.parse(savedState);
|
||||||
|
|
||||||
// 버전 체크 - 버전이 다르면 이전 상태 무시
|
// 버전 체크 - 버전이 다르면 이전 상태 무시
|
||||||
if (parsed.version !== PIVOT_STATE_VERSION) {
|
if (parsed.version !== PIVOT_STATE_VERSION) {
|
||||||
localStorage.removeItem(stateStorageKey);
|
localStorage.removeItem(stateStorageKey);
|
||||||
|
setIsStateRestored(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -426,7 +438,53 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
// 손상된 상태는 제거
|
// 손상된 상태는 제거
|
||||||
localStorage.removeItem(stateStorageKey);
|
localStorage.removeItem(stateStorageKey);
|
||||||
}
|
}
|
||||||
}, [stateStorageKey]);
|
|
||||||
|
setIsStateRestored(true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // 마운트 시 한 번만 실행
|
||||||
|
|
||||||
|
// 외부 fields 변경 시 동기화 (복원이 완료된 후에만, 저장된 상태가 없을 때만)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStateRestored) return; // 복원 완료 전에는 무시
|
||||||
|
|
||||||
|
// persistState가 켜져있고 저장된 상태가 있으면 initialFields로 덮어쓰지 않음
|
||||||
|
if (persistState && typeof window !== "undefined") {
|
||||||
|
const savedState = localStorage.getItem(stateStorageKey);
|
||||||
|
if (savedState) return; // 이미 저장된 상태가 있으면 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialFields.length > 0) {
|
||||||
|
setFields(initialFields);
|
||||||
|
}
|
||||||
|
}, [initialFields, isStateRestored, persistState, stateStorageKey]);
|
||||||
|
|
||||||
|
// 상태 유지 설정 저장
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(persistSettingKey, String(persistState));
|
||||||
|
}, [persistState, persistSettingKey]);
|
||||||
|
|
||||||
|
// 상태 저장 (localStorage)
|
||||||
|
const saveStateToStorage = useCallback(() => {
|
||||||
|
if (typeof window === "undefined" || !persistState) return;
|
||||||
|
const stateToSave = {
|
||||||
|
version: PIVOT_STATE_VERSION,
|
||||||
|
fields,
|
||||||
|
pivotState,
|
||||||
|
sortConfig,
|
||||||
|
columnWidths,
|
||||||
|
};
|
||||||
|
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
|
||||||
|
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey, persistState]);
|
||||||
|
|
||||||
|
// 상태 변경 시 자동 저장 (복원 완료 후에만)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!persistState || !isStateRestored) return;
|
||||||
|
// 초기 로드 후에만 저장 (빈 필드일 때는 저장 안 함)
|
||||||
|
if (fields.length > 0) {
|
||||||
|
saveStateToStorage();
|
||||||
|
}
|
||||||
|
}, [fields, pivotState, sortConfig, columnWidths, persistState, isStateRestored, saveStateToStorage]);
|
||||||
|
|
||||||
// 데이터
|
// 데이터
|
||||||
const data = externalData || [];
|
const data = externalData || [];
|
||||||
|
|
@ -1173,7 +1231,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { flatColumns, dataMatrix, grandTotals } = pivotResult;
|
const { flatColumns, dataMatrix, grandTotals, columnHeaderLevels } = pivotResult;
|
||||||
|
|
||||||
// ==================== 키보드 네비게이션 ====================
|
// ==================== 키보드 네비게이션 ====================
|
||||||
|
|
||||||
|
|
@ -1470,6 +1528,22 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 상태 유지 체크박스 */}
|
||||||
|
<div className="flex items-center gap-1.5 ml-2 pl-2 border-l">
|
||||||
|
<Checkbox
|
||||||
|
id="persist-state"
|
||||||
|
checked={persistState}
|
||||||
|
onCheckedChange={(checked) => setPersistState(checked === true)}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="persist-state"
|
||||||
|
className="text-xs text-muted-foreground cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
|
상태 유지
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 차트 토글 */}
|
{/* 차트 토글 */}
|
||||||
{chartConfig && (
|
{chartConfig && (
|
||||||
|
|
@ -1689,137 +1763,224 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
>
|
>
|
||||||
<table ref={tableRef} className="w-full border-collapse">
|
<table ref={tableRef} className="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
{/* 열 헤더 */}
|
{/* 다중 행 열 헤더 */}
|
||||||
<tr className="bg-background">
|
{columnHeaderLevels.length > 0 ? (
|
||||||
{/* 좌상단 코너 (행 필드 라벨 + 필터) */}
|
// 열 필드가 있는 경우: 각 레벨별로 행 생성
|
||||||
<th
|
columnHeaderLevels.map((levelCells, levelIdx) => (
|
||||||
className={cn(
|
<tr key={`col-level-${levelIdx}`} className="bg-background">
|
||||||
"border-r border-b border-border",
|
{/* 좌상단 코너 (첫 번째 레벨에만 표시) */}
|
||||||
"px-2 py-1 text-left text-xs font-medium",
|
{levelIdx === 0 && (
|
||||||
"bg-background sticky left-0 top-0 z-20"
|
<th
|
||||||
)}
|
className={cn(
|
||||||
rowSpan={columnFields.length > 0 ? 2 : 1}
|
"border-r border-b border-border",
|
||||||
>
|
"px-2 py-1 text-left text-xs font-medium",
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
"bg-background sticky left-0 top-0 z-20"
|
||||||
{rowFields.map((f, idx) => (
|
)}
|
||||||
<div key={f.field} className="flex items-center gap-0.5 group">
|
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
|
||||||
<span>{f.caption}</span>
|
>
|
||||||
<FilterPopup
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
field={f}
|
{rowFields.map((f, idx) => (
|
||||||
data={data}
|
<div key={f.field} className="flex items-center gap-0.5 group">
|
||||||
onFilterChange={(field, values, type) => {
|
<span>{f.caption}</span>
|
||||||
const newFields = fields.map((fld) =>
|
<FilterPopup
|
||||||
fld.field === field.field && fld.area === "row"
|
field={f}
|
||||||
? { ...fld, filterValues: values, filterType: type }
|
data={data}
|
||||||
: fld
|
onFilterChange={(field, values, type) => {
|
||||||
);
|
const newFields = fields.map((fld) =>
|
||||||
handleFieldsChange(newFields);
|
fld.field === field.field && fld.area === "row"
|
||||||
}}
|
? { ...fld, filterValues: values, filterType: type }
|
||||||
trigger={
|
: fld
|
||||||
<button
|
);
|
||||||
className={cn(
|
handleFieldsChange(newFields);
|
||||||
"p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
|
}}
|
||||||
"hover:bg-accent",
|
trigger={
|
||||||
f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
|
<button
|
||||||
)}
|
className={cn(
|
||||||
>
|
"p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
|
||||||
<Filter className="h-3 w-3" />
|
"hover:bg-accent",
|
||||||
</button>
|
f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
|
||||||
}
|
)}
|
||||||
/>
|
>
|
||||||
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>}
|
<Filter className="h-3 w-3" />
|
||||||
</div>
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rowFields.length === 0 && <span>항목</span>}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 열 헤더 셀 - 해당 레벨 */}
|
||||||
|
{levelCells.map((cell, cellIdx) => (
|
||||||
|
<th
|
||||||
|
key={`${levelIdx}-${cellIdx}`}
|
||||||
|
className={cn(
|
||||||
|
"border-r border-b border-border relative",
|
||||||
|
"px-2 py-1 text-center text-xs font-medium",
|
||||||
|
"bg-background sticky top-0 z-10",
|
||||||
|
levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
|
||||||
|
)}
|
||||||
|
colSpan={cell.colSpan * (dataFields.length || 1)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<span>{cell.caption || "(전체)"}</span>
|
||||||
|
{levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && (
|
||||||
|
<SortIcon field={dataFields[0].field} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
))}
|
))}
|
||||||
{rowFields.length === 0 && <span>항목</span>}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
|
|
||||||
{/* 열 헤더 셀 */}
|
{/* 행 총계 헤더 (첫 번째 레벨에만 표시) */}
|
||||||
{flatColumns.map((col, idx) => (
|
{levelIdx === 0 && totals?.showRowGrandTotals && (
|
||||||
<th
|
<th
|
||||||
key={idx}
|
className={cn(
|
||||||
className={cn(
|
"border-b border-border",
|
||||||
"border-r border-b border-border relative group",
|
"px-2 py-1 text-center text-xs font-medium",
|
||||||
"px-2 py-1 text-center text-xs font-medium",
|
"bg-background sticky top-0 z-10"
|
||||||
"bg-background sticky top-0 z-10",
|
)}
|
||||||
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
|
colSpan={dataFields.length || 1}
|
||||||
|
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
|
||||||
|
>
|
||||||
|
총계
|
||||||
|
</th>
|
||||||
)}
|
)}
|
||||||
colSpan={dataFields.length || 1}
|
|
||||||
style={{ width: columnWidths[idx] || "auto", minWidth: 50 }}
|
{/* 열 필드 필터 (첫 번째 레벨에만 표시) */}
|
||||||
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined}
|
{levelIdx === 0 && columnFields.length > 0 && (
|
||||||
>
|
<th
|
||||||
<div className="flex items-center justify-center gap-1">
|
className={cn(
|
||||||
<span>{col.caption || "(전체)"}</span>
|
"border-b border-border",
|
||||||
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
|
"px-1 py-1 text-center text-xs",
|
||||||
</div>
|
"bg-background sticky top-0 z-10"
|
||||||
{/* 열 리사이즈 핸들 */}
|
)}
|
||||||
<div
|
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
|
||||||
className={cn(
|
>
|
||||||
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize",
|
<div className="flex flex-col gap-0.5">
|
||||||
"hover:bg-primary/50 transition-colors",
|
{columnFields.map((f) => (
|
||||||
resizingColumn === idx && "bg-primary"
|
<FilterPopup
|
||||||
)}
|
key={f.field}
|
||||||
onMouseDown={(e) => handleResizeStart(idx, e)}
|
field={f}
|
||||||
/>
|
data={data}
|
||||||
</th>
|
onFilterChange={(field, values, type) => {
|
||||||
))}
|
const newFields = fields.map((fld) =>
|
||||||
|
fld.field === field.field && fld.area === "column"
|
||||||
{/* 행 총계 헤더 */}
|
? { ...fld, filterValues: values, filterType: type }
|
||||||
{totals?.showRowGrandTotals && (
|
: fld
|
||||||
<th
|
);
|
||||||
className={cn(
|
handleFieldsChange(newFields);
|
||||||
"border-b border-border",
|
}}
|
||||||
"px-2 py-1 text-center text-xs font-medium",
|
trigger={
|
||||||
"bg-background sticky top-0 z-10"
|
<button
|
||||||
|
className={cn(
|
||||||
|
"p-0.5 rounded hover:bg-accent",
|
||||||
|
f.filterValues && f.filterValues.length > 0 && "text-primary"
|
||||||
|
)}
|
||||||
|
title={`${f.caption} 필터`}
|
||||||
|
>
|
||||||
|
<Filter className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
)}
|
)}
|
||||||
colSpan={dataFields.length || 1}
|
</tr>
|
||||||
rowSpan={dataFields.length > 1 ? 2 : 1}
|
))
|
||||||
>
|
) : (
|
||||||
총계
|
// 열 필드가 없는 경우: 단일 행
|
||||||
</th>
|
<tr className="bg-background">
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */}
|
|
||||||
{columnFields.length > 0 && (
|
|
||||||
<th
|
<th
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b border-border",
|
"border-r border-b border-border",
|
||||||
"px-1 py-1 text-center text-xs",
|
"px-2 py-1 text-left text-xs font-medium",
|
||||||
"bg-background sticky top-0 z-10"
|
"bg-background sticky left-0 top-0 z-20"
|
||||||
)}
|
)}
|
||||||
rowSpan={dataFields.length > 1 ? 2 : 1}
|
rowSpan={dataFields.length > 1 ? 2 : 1}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{columnFields.map((f) => (
|
{rowFields.map((f, idx) => (
|
||||||
<FilterPopup
|
<div key={f.field} className="flex items-center gap-0.5 group">
|
||||||
key={f.field}
|
<span>{f.caption}</span>
|
||||||
field={f}
|
<FilterPopup
|
||||||
data={data}
|
field={f}
|
||||||
onFilterChange={(field, values, type) => {
|
data={data}
|
||||||
const newFields = fields.map((fld) =>
|
onFilterChange={(field, values, type) => {
|
||||||
fld.field === field.field && fld.area === "column"
|
const newFields = fields.map((fld) =>
|
||||||
? { ...fld, filterValues: values, filterType: type }
|
fld.field === field.field && fld.area === "row"
|
||||||
: fld
|
? { ...fld, filterValues: values, filterType: type }
|
||||||
);
|
: fld
|
||||||
handleFieldsChange(newFields);
|
);
|
||||||
}}
|
handleFieldsChange(newFields);
|
||||||
trigger={
|
}}
|
||||||
<button
|
trigger={
|
||||||
className={cn(
|
<button
|
||||||
"p-0.5 rounded hover:bg-accent",
|
className={cn(
|
||||||
f.filterValues && f.filterValues.length > 0 && "text-primary"
|
"p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
|
||||||
)}
|
"hover:bg-accent",
|
||||||
title={`${f.caption} 필터`}
|
f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
|
||||||
>
|
)}
|
||||||
<Filter className="h-3 w-3" />
|
>
|
||||||
</button>
|
<Filter className="h-3 w-3" />
|
||||||
}
|
</button>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{rowFields.length === 0 && <span>항목</span>}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
)}
|
|
||||||
</tr>
|
{/* 열 헤더 셀 (열 필드 없을 때) */}
|
||||||
|
{flatColumns.map((col, idx) => (
|
||||||
|
<th
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"border-r border-b border-border relative group",
|
||||||
|
"px-2 py-1 text-center text-xs font-medium",
|
||||||
|
"bg-background sticky top-0 z-10",
|
||||||
|
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
|
||||||
|
)}
|
||||||
|
colSpan={dataFields.length || 1}
|
||||||
|
style={{ width: columnWidths[idx] || "auto", minWidth: 50 }}
|
||||||
|
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<span>{col.caption || "(전체)"}</span>
|
||||||
|
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize",
|
||||||
|
"hover:bg-primary/50 transition-colors",
|
||||||
|
resizingColumn === idx && "bg-primary"
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => handleResizeStart(idx, e)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 행 총계 헤더 */}
|
||||||
|
{totals?.showRowGrandTotals && (
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
"border-b border-border",
|
||||||
|
"px-2 py-1 text-center text-xs font-medium",
|
||||||
|
"bg-background sticky top-0 z-10"
|
||||||
|
)}
|
||||||
|
colSpan={dataFields.length || 1}
|
||||||
|
rowSpan={dataFields.length > 1 ? 2 : 1}
|
||||||
|
>
|
||||||
|
총계
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
|
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
|
||||||
{dataFields.length > 1 && (
|
{dataFields.length > 1 && (
|
||||||
|
|
|
||||||
|
|
@ -331,8 +331,11 @@ export interface PivotResult {
|
||||||
// 플랫 행 목록 (렌더링용)
|
// 플랫 행 목록 (렌더링용)
|
||||||
flatRows: PivotFlatRow[];
|
flatRows: PivotFlatRow[];
|
||||||
|
|
||||||
// 플랫 열 목록 (렌더링용)
|
// 플랫 열 목록 (렌더링용) - 리프 노드만
|
||||||
flatColumns: PivotFlatColumn[];
|
flatColumns: PivotFlatColumn[];
|
||||||
|
|
||||||
|
// 열 헤더 레벨별 (다중 행 헤더용)
|
||||||
|
columnHeaderLevels: PivotColumnHeaderCell[][];
|
||||||
|
|
||||||
// 총합계
|
// 총합계
|
||||||
grandTotals: {
|
grandTotals: {
|
||||||
|
|
@ -361,6 +364,14 @@ export interface PivotFlatColumn {
|
||||||
isTotal?: boolean;
|
isTotal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 열 헤더 셀 (다중 행 헤더용)
|
||||||
|
export interface PivotColumnHeaderCell {
|
||||||
|
caption: string; // 표시 텍스트
|
||||||
|
colSpan: number; // 병합할 열 수
|
||||||
|
path: string[]; // 전체 경로
|
||||||
|
level: number; // 레벨 (0부터 시작)
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 상태 관리 ====================
|
// ==================== 상태 관리 ====================
|
||||||
|
|
||||||
export interface PivotGridState {
|
export interface PivotGridState {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
PivotFlatRow,
|
PivotFlatRow,
|
||||||
PivotFlatColumn,
|
PivotFlatColumn,
|
||||||
PivotCellValue,
|
PivotCellValue,
|
||||||
|
PivotColumnHeaderCell,
|
||||||
DateGroupInterval,
|
DateGroupInterval,
|
||||||
AggregationType,
|
AggregationType,
|
||||||
SummaryDisplayMode,
|
SummaryDisplayMode,
|
||||||
|
|
@ -76,6 +77,31 @@ export function pathToKey(path: string[]): string {
|
||||||
return path.join("||");
|
return path.join("||");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 가능한 경로 생성 (열 전체 확장용)
|
||||||
|
*/
|
||||||
|
function generateAllPaths(
|
||||||
|
data: Record<string, any>[],
|
||||||
|
fields: PivotFieldConfig[]
|
||||||
|
): string[] {
|
||||||
|
const allPaths: string[] = [];
|
||||||
|
|
||||||
|
// 각 레벨까지의 고유 경로 수집
|
||||||
|
for (let depth = 1; depth <= fields.length; depth++) {
|
||||||
|
const fieldsAtDepth = fields.slice(0, depth);
|
||||||
|
const pathSet = new Set<string>();
|
||||||
|
|
||||||
|
data.forEach((row) => {
|
||||||
|
const path = fieldsAtDepth.map((f) => getFieldValue(row, f));
|
||||||
|
pathSet.add(pathToKey(path));
|
||||||
|
});
|
||||||
|
|
||||||
|
pathSet.forEach((pathKey) => allPaths.push(pathKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
return allPaths;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 키를 경로로 변환
|
* 키를 경로로 변환
|
||||||
*/
|
*/
|
||||||
|
|
@ -326,6 +352,66 @@ function getMaxColumnLevel(
|
||||||
return Math.min(maxLevel, totalFields - 1);
|
return Math.min(maxLevel, totalFields - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다중 행 열 헤더 생성
|
||||||
|
* 각 레벨별로 셀과 colSpan 정보를 반환
|
||||||
|
*/
|
||||||
|
function buildColumnHeaderLevels(
|
||||||
|
nodes: PivotHeaderNode[],
|
||||||
|
totalLevels: number
|
||||||
|
): PivotColumnHeaderCell[][] {
|
||||||
|
if (totalLevels === 0 || nodes.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const levels: PivotColumnHeaderCell[][] = Array.from(
|
||||||
|
{ length: totalLevels },
|
||||||
|
() => []
|
||||||
|
);
|
||||||
|
|
||||||
|
// 리프 노드 수 계산 (colSpan 계산용)
|
||||||
|
function countLeaves(node: PivotHeaderNode): number {
|
||||||
|
if (!node.children || node.children.length === 0 || !node.isExpanded) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트리 순회하며 각 레벨에 셀 추가
|
||||||
|
function traverse(node: PivotHeaderNode, level: number) {
|
||||||
|
const colSpan = countLeaves(node);
|
||||||
|
|
||||||
|
levels[level].push({
|
||||||
|
caption: node.caption,
|
||||||
|
colSpan,
|
||||||
|
path: node.path,
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (node.children && node.isExpanded) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
traverse(child, level + 1);
|
||||||
|
}
|
||||||
|
} else if (level < totalLevels - 1) {
|
||||||
|
// 확장되지 않은 노드는 다음 레벨들에 빈 셀로 채움
|
||||||
|
for (let i = level + 1; i < totalLevels; i++) {
|
||||||
|
levels[i].push({
|
||||||
|
caption: "",
|
||||||
|
colSpan,
|
||||||
|
path: node.path,
|
||||||
|
level: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
traverse(node, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return levels;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 데이터 매트릭스 생성 ====================
|
// ==================== 데이터 매트릭스 생성 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -735,12 +821,11 @@ export function processPivotData(
|
||||||
uniqueValues.forEach((val) => expandedRowSet.add(val));
|
uniqueValues.forEach((val) => expandedRowSet.add(val));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expandedColumnPaths.length === 0 && columnFields.length > 0) {
|
// 열은 항상 전체 확장 (열 헤더는 확장/축소 UI가 없음)
|
||||||
const firstField = columnFields[0];
|
// 모든 가능한 열 경로를 확장 상태로 설정
|
||||||
const uniqueValues = new Set(
|
if (columnFields.length > 0) {
|
||||||
filteredData.map((row) => getFieldValue(row, firstField))
|
const allColumnPaths = generateAllPaths(filteredData, columnFields);
|
||||||
);
|
allColumnPaths.forEach((pathKey) => expandedColSet.add(pathKey));
|
||||||
uniqueValues.forEach((val) => expandedColSet.add(val));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 헤더 트리 생성
|
// 헤더 트리 생성
|
||||||
|
|
@ -788,6 +873,12 @@ export function processPivotData(
|
||||||
grandTotals.grand
|
grandTotals.grand
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 다중 행 열 헤더 생성
|
||||||
|
const columnHeaderLevels = buildColumnHeaderLevels(
|
||||||
|
columnHeaders,
|
||||||
|
columnFields.length
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rowHeaders,
|
rowHeaders,
|
||||||
columnHeaders,
|
columnHeaders,
|
||||||
|
|
@ -799,6 +890,7 @@ export function processPivotData(
|
||||||
caption: path[path.length - 1] || "",
|
caption: path[path.length - 1] || "",
|
||||||
span: 1,
|
span: 1,
|
||||||
})),
|
})),
|
||||||
|
columnHeaderLevels,
|
||||||
grandTotals,
|
grandTotals,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue