피벗 테스트만 하면 됨 기능은 완료

This commit is contained in:
leeheejin 2026-01-09 15:11:30 +09:00
parent f07448ac17
commit ba20a2bf42
4 changed files with 802 additions and 39 deletions

View File

@ -40,9 +40,62 @@ import {
ArrowUp,
ArrowDown,
ArrowUpDown,
Printer,
Save,
RotateCcw,
FileText,
Loader2,
Eye,
EyeOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
// ==================== 유틸리티 함수 ====================
// 셀 병합 정보 계산
interface MergeCellInfo {
rowSpan: number;
skip: boolean; // 병합된 셀에서 건너뛸지 여부
}
const calculateMergeCells = (
rows: PivotFlatRow[],
mergeCells: boolean
): Map<number, MergeCellInfo> => {
const mergeInfo = new Map<number, MergeCellInfo>();
if (!mergeCells || rows.length === 0) {
rows.forEach((_, idx) => mergeInfo.set(idx, { rowSpan: 1, skip: false }));
return mergeInfo;
}
let i = 0;
while (i < rows.length) {
const currentPath = rows[i].path.join("|||");
let spanCount = 1;
// 같은 path를 가진 연속 행 찾기
while (
i + spanCount < rows.length &&
rows[i + spanCount].path.join("|||") === currentPath
) {
spanCount++;
}
// 첫 번째 행은 rowSpan 설정
mergeInfo.set(i, { rowSpan: spanCount, skip: false });
// 나머지 행은 skip
for (let j = 1; j < spanCount; j++) {
mergeInfo.set(i + j, { rowSpan: 1, skip: true });
}
i += spanCount;
}
return mergeInfo;
};
// ==================== 서브 컴포넌트 ====================
// 행 헤더 셀
@ -50,12 +103,14 @@ interface RowHeaderCellProps {
row: PivotFlatRow;
rowFields: PivotFieldConfig[];
onToggleExpand: (path: string[]) => void;
rowSpan?: number;
}
const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
row,
rowFields,
onToggleExpand,
rowSpan = 1,
}) => {
const indentSize = row.level * 20;
@ -68,6 +123,7 @@ const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
row.isExpanded && "bg-muted/70"
)}
style={{ paddingLeft: `${8 + indentSize}px` }}
rowSpan={rowSpan > 1 ? rowSpan : undefined}
>
<div className="flex items-center gap-1">
{row.hasChildren && (
@ -94,7 +150,7 @@ interface DataCellProps {
values: PivotCellValue[];
isTotal?: boolean;
isSelected?: boolean;
onClick?: () => void;
onClick?: (e?: React.MouseEvent) => void;
onDoubleClick?: () => void;
conditionalStyle?: CellFormatStyle;
}
@ -133,12 +189,17 @@ const DataCell: React.FC<DataCellProps> = ({
);
}
// 툴팁 내용 생성
const tooltipContent = values.map((v) =>
`${v.field || "값"}: ${v.formattedValue || v.value}`
).join("\n");
// 단일 데이터 필드인 경우
if (values.length === 1) {
return (
<td
className={cn(
"border-r border-b border-border relative",
"border-r border-b border-border relative group/cell",
"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",
@ -147,6 +208,7 @@ const DataCell: React.FC<DataCellProps> = ({
style={cellStyle}
onClick={onClick}
onDoubleClick={onDoubleClick}
title={tooltipContent}
>
{/* Data Bar */}
{hasDataBar && (
@ -182,6 +244,7 @@ const DataCell: React.FC<DataCellProps> = ({
style={cellStyle}
onClick={onClick}
onDoubleClick={onDoubleClick}
title={`${val.field || "값"}: ${val.formattedValue || val.value}`}
>
{hasDataBar && (
<div
@ -260,11 +323,17 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
const [containerHeight, setContainerHeight] = useState(400);
const tableContainerRef = useRef<HTMLDivElement>(null);
// 셀 선택 상태
// 셀 선택 상태 (범위 선택 지원)
const [selectedCell, setSelectedCell] = useState<{
rowIndex: number;
colIndex: number;
} | null>(null);
const [selectionRange, setSelectionRange] = useState<{
startRow: number;
startCol: number;
endRow: number;
endCol: number;
} | null>(null);
const tableRef = useRef<HTMLTableElement>(null);
// 정렬 상태
@ -272,6 +341,12 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
field: string;
direction: "asc" | "desc";
} | null>(null);
// 열 너비 상태
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
const [resizingColumn, setResizingColumn] = useState<number | null>(null);
const [resizeStartX, setResizeStartX] = useState<number>(0);
const [resizeStartWidth, setResizeStartWidth] = useState<number>(0);
// 외부 fields 변경 시 동기화
useEffect(() => {
@ -280,6 +355,38 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
}
}, [initialFields]);
// 상태 저장 키
const stateStorageKey = `pivot-state-${title || "default"}`;
// 상태 저장 (localStorage)
const saveStateToStorage = useCallback(() => {
if (typeof window === "undefined") return;
const stateToSave = {
fields,
pivotState,
sortConfig,
columnWidths,
};
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
// 상태 복원 (localStorage)
useEffect(() => {
if (typeof window === "undefined") return;
const savedState = localStorage.getItem(stateStorageKey);
if (savedState) {
try {
const parsed = JSON.parse(savedState);
if (parsed.fields) setFields(parsed.fields);
if (parsed.pivotState) setPivotState(parsed.pivotState);
if (parsed.sortConfig) setSortConfig(parsed.sortConfig);
if (parsed.columnWidths) setColumnWidths(parsed.columnWidths);
} catch (e) {
console.warn("피벗 상태 복원 실패:", e);
}
}
}, [stateStorageKey]);
// 데이터
const data = externalData || [];
@ -456,12 +563,72 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
return () => observer.disconnect();
}, []);
// 열 크기 조절 중
useEffect(() => {
if (resizingColumn === null) return;
const handleMouseMove = (e: MouseEvent) => {
const diff = e.clientX - resizeStartX;
const newWidth = Math.max(50, resizeStartWidth + diff); // 최소 50px
setColumnWidths((prev) => ({
...prev,
[resizingColumn]: newWidth,
}));
};
const handleMouseUp = () => {
setResizingColumn(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [resizingColumn, resizeStartX, resizeStartWidth]);
// 가상 스크롤 훅 사용
const flatRows = pivotResult?.flatRows || [];
const enableVirtualScroll = flatRows.length > VIRTUAL_SCROLL_THRESHOLD;
// 정렬된 행 데이터
const sortedFlatRows = useMemo(() => {
if (!sortConfig || !pivotResult) return flatRows;
const { field, direction } = sortConfig;
const { dataMatrix, flatColumns } = pivotResult;
// 각 행의 정렬 기준 값 계산
const rowsWithSortValue = flatRows.map((row) => {
let sortValue = 0;
// 모든 열에 대해 해당 필드의 합계 계산
flatColumns.forEach((col) => {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
const values = dataMatrix.get(cellKey) || [];
const targetValue = values.find((v) => v.field === field);
if (targetValue?.value != null) {
sortValue += targetValue.value;
}
});
return { row, sortValue };
});
// 정렬
rowsWithSortValue.sort((a, b) => {
if (direction === "asc") {
return a.sortValue - b.sortValue;
}
return b.sortValue - a.sortValue;
});
return rowsWithSortValue.map((item) => item.row);
}, [flatRows, sortConfig, pivotResult]);
const enableVirtualScroll = sortedFlatRows.length > VIRTUAL_SCROLL_THRESHOLD;
const virtualScroll = useVirtualScroll({
itemCount: flatRows.length,
itemCount: sortedFlatRows.length,
itemHeight: ROW_HEIGHT,
containerHeight: containerHeight,
overscan: 10,
@ -469,9 +636,9 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// 가상 스크롤 적용된 행 데이터
const visibleFlatRows = useMemo(() => {
if (!enableVirtualScroll) return flatRows;
return flatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1);
}, [enableVirtualScroll, flatRows, virtualScroll.startIndex, virtualScroll.endIndex]);
if (!enableVirtualScroll) return sortedFlatRows;
return sortedFlatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1);
}, [enableVirtualScroll, sortedFlatRows, virtualScroll.startIndex, virtualScroll.endIndex]);
// 조건부 서식 스타일 계산 헬퍼
const getCellConditionalStyle = useCallback(
@ -660,6 +827,154 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
console.error("Excel 내보내기 실패:", error);
}
}, [pivotResult, fields, totals, title]);
// 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함)
const handlePrint = useCallback(() => {
const printContent = tableRef.current;
if (!printContent) return;
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${title || "피벗 테이블"}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; }
h1 { font-size: 18px; margin-bottom: 16px; }
table { border-collapse: collapse; width: 100%; font-size: 12px; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
th { background-color: #f5f5f5; font-weight: 600; }
td { text-align: right; }
td:first-child { text-align: left; font-weight: 500; }
tr:nth-child(even) { background-color: #fafafa; }
.total-row { background-color: #e8e8e8 !important; font-weight: 600; }
@media print {
body { padding: 0; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; }
}
</style>
</head>
<body>
<h1>${title || "피벗 테이블"}</h1>
${printContent.outerHTML}
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 250);
}, [title]);
// PDF 내보내기
const handleExportPDF = useCallback(async () => {
if (!pivotResult || !tableRef.current) return;
try {
// 동적 import로 jspdf와 html2canvas 로드
const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([
import("jspdf"),
import("html2canvas"),
]);
const canvas = await html2canvas(tableRef.current, {
scale: 2,
useCORS: true,
logging: false,
});
const imgData = canvas.toDataURL("image/png");
const pdf = new jsPDF({
orientation: canvas.width > canvas.height ? "landscape" : "portrait",
unit: "px",
format: [canvas.width, canvas.height],
});
pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height);
pdf.save(`${title || "pivot"}_export.pdf`);
} catch (error) {
console.error("PDF 내보내기 실패:", error);
// jspdf가 없으면 인쇄 대화상자로 대체
handlePrint();
}
}, [pivotResult, title, handlePrint]);
// 데이터 새로고침
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefreshData = useCallback(async () => {
setIsRefreshing(true);
// 외부 데이터 소스가 있으면 새로고침
// 여기서는 상태만 초기화
setPivotState({
expandedRowPaths: [],
expandedColumnPaths: [],
sortConfig: null,
filterConfig: {},
});
setSortConfig(null);
setSelectedCell(null);
setSelectionRange(null);
setTimeout(() => setIsRefreshing(false), 500);
}, []);
// 상태 저장 버튼 핸들러
const handleSaveState = useCallback(() => {
saveStateToStorage();
console.log("피벗 상태가 저장되었습니다.");
}, [saveStateToStorage]);
// 상태 초기화
const handleResetState = useCallback(() => {
localStorage.removeItem(stateStorageKey);
setFields(initialFields);
setPivotState({
expandedRowPaths: [],
expandedColumnPaths: [],
sortConfig: null,
filterConfig: {},
});
setSortConfig(null);
setColumnWidths({});
setSelectedCell(null);
setSelectionRange(null);
}, [stateStorageKey, initialFields]);
// 필드 숨기기/표시 상태
const [hiddenFields, setHiddenFields] = useState<Set<string>>(new Set());
const toggleFieldVisibility = useCallback((fieldName: string) => {
setHiddenFields((prev) => {
const newSet = new Set(prev);
if (newSet.has(fieldName)) {
newSet.delete(fieldName);
} else {
newSet.add(fieldName);
}
return newSet;
});
}, []);
// 숨겨진 필드 제외한 활성 필드들
const visibleFields = useMemo(() => {
return fields.filter((f) => !hiddenFields.has(f.field));
}, [fields, hiddenFields]);
// 숨겨진 필드 목록
const hiddenFieldsList = useMemo(() => {
return fields.filter((f) => hiddenFields.has(f.field));
}, [fields, hiddenFields]);
// 모든 필드 표시
const showAllFields = useCallback(() => {
setHiddenFields(new Set());
}, []);
// ==================== 렌더링 ====================
@ -818,7 +1133,27 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
case "Escape":
e.preventDefault();
setSelectedCell(null);
setSelectionRange(null);
break;
case "c":
// Ctrl+C: 클립보드 복사
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
copySelectionToClipboard();
}
return;
case "a":
// Ctrl+A: 전체 선택
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
setSelectionRange({
startRow: 0,
startCol: 0,
endRow: visibleFlatRows.length - 1,
endCol: flatColumns.length - 1,
});
}
return;
default:
return;
}
@ -828,9 +1163,85 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
}
};
// 셀 클릭으로 선택
const handleCellSelect = (rowIndex: number, colIndex: number) => {
setSelectedCell({ rowIndex, colIndex });
// 셀 클릭으로 선택 (Shift+클릭으로 범위 선택)
const handleCellSelect = (rowIndex: number, colIndex: number, shiftKey: boolean = false) => {
if (shiftKey && selectedCell) {
// Shift+클릭: 범위 선택
setSelectionRange({
startRow: Math.min(selectedCell.rowIndex, rowIndex),
startCol: Math.min(selectedCell.colIndex, colIndex),
endRow: Math.max(selectedCell.rowIndex, rowIndex),
endCol: Math.max(selectedCell.colIndex, colIndex),
});
} else {
// 일반 클릭: 단일 선택
setSelectedCell({ rowIndex, colIndex });
setSelectionRange(null);
}
};
// 셀이 선택 범위 내에 있는지 확인
const isCellInRange = (rowIndex: number, colIndex: number): boolean => {
if (selectionRange) {
return (
rowIndex >= selectionRange.startRow &&
rowIndex <= selectionRange.endRow &&
colIndex >= selectionRange.startCol &&
colIndex <= selectionRange.endCol
);
}
if (selectedCell) {
return selectedCell.rowIndex === rowIndex && selectedCell.colIndex === colIndex;
}
return false;
};
// 열 크기 조절 시작
const handleResizeStart = (colIdx: number, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setResizingColumn(colIdx);
setResizeStartX(e.clientX);
setResizeStartWidth(columnWidths[colIdx] || 100);
};
// 클립보드에 선택 영역 복사
const copySelectionToClipboard = () => {
const range = selectionRange || (selectedCell ? {
startRow: selectedCell.rowIndex,
startCol: selectedCell.colIndex,
endRow: selectedCell.rowIndex,
endCol: selectedCell.colIndex,
} : null);
if (!range) return;
const lines: string[] = [];
for (let rowIdx = range.startRow; rowIdx <= range.endRow; rowIdx++) {
const row = visibleFlatRows[rowIdx];
if (!row) continue;
const rowValues: string[] = [];
for (let colIdx = range.startCol; colIdx <= range.endCol; colIdx++) {
const col = flatColumns[colIdx];
if (!col) continue;
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
const values = dataMatrix.get(cellKey) || [];
const cellValue = values.map((v) => v.formattedValue || v.value || "").join(", ");
rowValues.push(cellValue);
}
lines.push(rowValues.join("\t"));
}
const text = lines.join("\n");
navigator.clipboard.writeText(text).then(() => {
// 복사 성공 피드백 (선택적)
console.log("클립보드에 복사됨:", text);
}).catch((err) => {
console.error("클립보드 복사 실패:", err);
});
};
// 정렬 토글
@ -974,8 +1385,101 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
>
<FileSpreadsheet className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handlePrint}
title="인쇄"
>
<Printer className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleExportPDF}
title="PDF 내보내기"
>
<FileText className="h-4 w-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleRefreshData}
disabled={isRefreshing}
title="새로고침"
>
{isRefreshing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleSaveState}
title="상태 저장"
>
<Save className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleResetState}
title="상태 초기화"
>
<RotateCcw className="h-4 w-4" />
</Button>
{/* 숨겨진 필드 표시 드롭다운 */}
{hiddenFieldsList.length > 0 && (
<div className="relative group">
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
title={`숨겨진 필드: ${hiddenFieldsList.length}`}
>
<EyeOff className="h-4 w-4" />
<span className="ml-1 text-xs">{hiddenFieldsList.length}</span>
</Button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50 bg-popover border border-border rounded-md shadow-lg min-w-[160px]">
<div className="p-2 border-b border-border">
<span className="text-xs font-medium text-muted-foreground"> </span>
</div>
<div className="max-h-[200px] overflow-y-auto">
{hiddenFieldsList.map((field) => (
<button
key={field.field}
className="w-full px-3 py-1.5 text-xs text-left hover:bg-accent flex items-center gap-2"
onClick={() => toggleFieldVisibility(field.field)}
>
<Eye className="h-3 w-3" />
<span>{field.caption || field.field}</span>
</button>
))}
</div>
<div className="p-1 border-t border-border">
<button
className="w-full px-3 py-1.5 text-xs text-left hover:bg-accent text-primary"
onClick={showAllFields}
>
</button>
</div>
</div>
</div>
)}
<Button
variant="ghost"
@ -1102,18 +1606,28 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
<th
key={idx}
className={cn(
"border-r border-b border-border",
"border-r border-b border-border relative group",
"px-2 py-1.5 text-center text-xs font-medium",
"bg-muted/70 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>
))}
@ -1214,6 +1728,34 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</thead>
<tbody>
{/* 열 총계 행 (상단 위치) */}
{totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition === "top" && (
<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>
)}
{/* 가상 스크롤 상단 여백 */}
{enableVirtualScroll && virtualScroll.offsetTop > 0 && (
<tr style={{ height: virtualScroll.offsetTop }}>
@ -1221,26 +1763,34 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</tr>
)}
{visibleFlatRows.map((row, idx) => {
// 실제 행 인덱스 계산
const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx;
{(() => {
// 셀 병합 정보 계산
const mergeInfo = calculateMergeCells(visibleFlatRows, style?.mergeCells || false);
return (
<tr
key={rowIdx}
className={cn(
style?.alternateRowColors &&
rowIdx % 2 === 1 &&
"bg-muted/20"
)}
style={{ height: ROW_HEIGHT }}
>
{/* 행 헤더 */}
<RowHeaderCell
row={row}
rowFields={rowFields}
onToggleExpand={handleToggleRowExpand}
/>
return visibleFlatRows.map((row, idx) => {
// 실제 행 인덱스 계산
const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx;
const cellMerge = mergeInfo.get(idx) || { rowSpan: 1, skip: false };
return (
<tr
key={rowIdx}
className={cn(
style?.alternateRowColors &&
rowIdx % 2 === 1 &&
"bg-muted/20"
)}
style={{ height: ROW_HEIGHT }}
>
{/* 행 헤더 (병합되면 skip) */}
{!cellMerge.skip && (
<RowHeaderCell
row={row}
rowFields={rowFields}
onToggleExpand={handleToggleRowExpand}
rowSpan={cellMerge.rowSpan}
/>
)}
{/* 데이터 셀 */}
{flatColumns.map((col, colIdx) => {
@ -1253,8 +1803,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
? getCellConditionalStyle(values[0].value ?? undefined, values[0].field)
: undefined;
// 선택 상태 확인
const isCellSelected = selectedCell?.rowIndex === rowIdx && selectedCell?.colIndex === colIdx;
// 선택 상태 확인 (범위 선택 포함)
const isCellSelected = isCellInRange(rowIdx, colIdx);
return (
<DataCell
@ -1262,8 +1812,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
values={values}
conditionalStyle={conditionalStyle}
isSelected={isCellSelected}
onClick={() => {
handleCellSelect(rowIdx, colIdx);
onClick={(e?: React.MouseEvent) => {
handleCellSelect(rowIdx, colIdx, e?.shiftKey || false);
if (onCellClick) {
handleCellClick(row.path, col.path, values);
}
@ -1283,8 +1833,9 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
/>
)}
</tr>
);
})}
);
});
})()}
{/* 가상 스크롤 하단 여백 */}
{enableVirtualScroll && (
@ -1293,8 +1844,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</tr>
)}
{/* 열 총계 행 */}
{totals?.showColumnGrandTotals && (
{/* 열 총계 행 (하단 위치 - 기본값) */}
{totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && (
<tr className="bg-primary/5 font-medium">
<td
className={cn(

View File

@ -547,6 +547,62 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
/>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
<Label className="text-xs"> </Label>
<Select
value={config.totals?.rowGrandTotalPosition || "bottom"}
onValueChange={(v) =>
updateConfig({ totals: { ...config.totals, rowGrandTotalPosition: v as "top" | "bottom" } })
}
>
<SelectTrigger className="h-6 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
<Label className="text-xs"> </Label>
<Select
value={config.totals?.columnGrandTotalPosition || "right"}
onValueChange={(v) =>
updateConfig({ totals: { ...config.totals, columnGrandTotalPosition: v as "left" | "right" } })
}
>
<SelectTrigger className="h-6 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
<Label className="text-xs"> </Label>
<Switch
checked={config.totals?.showRowTotals !== false}
onCheckedChange={(v) =>
updateConfig({ totals: { ...config.totals, showRowTotals: v } })
}
/>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
<Label className="text-xs"> </Label>
<Switch
checked={config.totals?.showColumnTotals !== false}
onCheckedChange={(v) =>
updateConfig({ totals: { ...config.totals, showColumnTotals: v } })
}
/>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
<Label className="text-xs"></Label>
<Switch
@ -557,6 +613,16 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
/>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
<Label className="text-xs"> </Label>
<Switch
checked={config.style?.mergeCells === true}
onCheckedChange={(v) =>
updateConfig({ style: { ...config.style, mergeCells: v } })
}
/>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
<Label className="text-xs">CSV </Label>
<Switch
@ -566,6 +632,16 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
}
/>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
<Label className="text-xs"> </Label>
<Switch
checked={config.saveState === true}
onCheckedChange={(v) =>
updateConfig({ saveState: v })
}
/>
</div>
</div>
</div>
@ -593,6 +669,126 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
</div>
</div>
</div>
{/* 조건부 서식 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground"> </Label>
<div className="space-y-2">
{(config.style?.conditionalFormats || []).map((rule, index) => (
<div key={rule.id} className="flex items-center gap-2 p-2 rounded-md bg-muted/30">
<Select
value={rule.type}
onValueChange={(v) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = { ...rule, type: v as any };
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
}}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="colorScale"> </SelectItem>
<SelectItem value="dataBar"> </SelectItem>
<SelectItem value="iconSet"> </SelectItem>
<SelectItem value="cellValue"> </SelectItem>
</SelectContent>
</Select>
{rule.type === "colorScale" && (
<div className="flex items-center gap-1">
<input
type="color"
value={rule.colorScale?.minColor || "#ff0000"}
onChange={(e) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: e.target.value, maxColor: rule.colorScale?.maxColor || "#00ff00" } };
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
}}
className="w-6 h-6 rounded cursor-pointer"
title="최소값 색상"
/>
<span className="text-xs"></span>
<input
type="color"
value={rule.colorScale?.maxColor || "#00ff00"}
onChange={(e) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: rule.colorScale?.minColor || "#ff0000", maxColor: e.target.value } };
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
}}
className="w-6 h-6 rounded cursor-pointer"
title="최대값 색상"
/>
</div>
)}
{rule.type === "dataBar" && (
<input
type="color"
value={rule.dataBar?.color || "#3b82f6"}
onChange={(e) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = { ...rule, dataBar: { color: e.target.value } };
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
}}
className="w-6 h-6 rounded cursor-pointer"
title="바 색상"
/>
)}
{rule.type === "iconSet" && (
<Select
value={rule.iconSet?.type || "traffic"}
onValueChange={(v) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = { ...rule, iconSet: { type: v as any, thresholds: [33, 67] } };
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
}}
>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="arrows"></SelectItem>
<SelectItem value="traffic"></SelectItem>
<SelectItem value="rating"></SelectItem>
<SelectItem value="flags"></SelectItem>
</SelectContent>
</Select>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-auto"
onClick={() => {
const newFormats = (config.style?.conditionalFormats || []).filter((_, i) => i !== index);
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
const newFormats = [
...(config.style?.conditionalFormats || []),
{ id: `cf_${Date.now()}`, type: "colorScale" as const, colorScale: { minColor: "#ff0000", maxColor: "#00ff00" } }
];
updateConfig({ style: { ...config.style, conditionalFormats: newFormats } });
}}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
</div>
)}
</div>

View File

@ -94,6 +94,15 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
{ 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" />,

View File

@ -90,6 +90,10 @@ export interface PivotFieldConfig {
// 계층 관련
displayFolder?: string; // 필드 선택기에서 폴더 구조
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
// 계산 필드
isCalculated?: boolean; // 계산 필드 여부
calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]")
}
// ==================== 데이터 소스 설정 ====================
@ -140,11 +144,13 @@ export interface PivotTotalsConfig {
showRowGrandTotals?: boolean; // 행 총합계 표시
showRowTotals?: boolean; // 행 소계 표시
rowTotalsPosition?: "first" | "last"; // 소계 위치
rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단)
// 열 총합계
showColumnGrandTotals?: boolean; // 열 총합계 표시
showColumnTotals?: boolean; // 열 소계 표시
columnTotalsPosition?: "first" | "last"; // 소계 위치
columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측)
}
// 필드 선택기 설정
@ -214,6 +220,7 @@ export interface PivotStyleConfig {
alternateRowColors?: boolean;
highlightTotals?: boolean; // 총합계 강조
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
mergeCells?: boolean; // 같은 값 셀 병합
}
// ==================== 내보내기 설정 ====================