피벗 테스트만 하면 됨 기능은 완료
This commit is contained in:
parent
f07448ac17
commit
ba20a2bf42
|
|
@ -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);
|
||||
|
||||
// 정렬 상태
|
||||
|
|
@ -273,6 +342,12 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
|||
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(() => {
|
||||
if (initialFields.length > 0) {
|
||||
|
|
@ -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(
|
||||
|
|
@ -661,6 +828,154 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
|||
}
|
||||
}, [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) => {
|
||||
// 셀 클릭으로 선택 (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,9 +1385,102 @@ 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"
|
||||
size="sm"
|
||||
|
|
@ -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,9 +1763,14 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
|||
</tr>
|
||||
)}
|
||||
|
||||
{visibleFlatRows.map((row, idx) => {
|
||||
{(() => {
|
||||
// 셀 병합 정보 계산
|
||||
const mergeInfo = calculateMergeCells(visibleFlatRows, style?.mergeCells || false);
|
||||
|
||||
return visibleFlatRows.map((row, idx) => {
|
||||
// 실제 행 인덱스 계산
|
||||
const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx;
|
||||
const cellMerge = mergeInfo.get(idx) || { rowSpan: 1, skip: false };
|
||||
|
||||
return (
|
||||
<tr
|
||||
|
|
@ -1235,12 +1782,15 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
|||
)}
|
||||
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);
|
||||
}
|
||||
|
|
@ -1284,7 +1834,8 @@ 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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />,
|
||||
|
|
|
|||
|
|
@ -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; // 같은 값 셀 병합
|
||||
}
|
||||
|
||||
// ==================== 내보내기 설정 ====================
|
||||
|
|
|
|||
Loading…
Reference in New Issue