피벗 테스트만 하면 됨 기능은 완료
This commit is contained in:
parent
f07448ac17
commit
ba20a2bf42
|
|
@ -40,9 +40,62 @@ import {
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
|
Printer,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
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;
|
row: PivotFlatRow;
|
||||||
rowFields: PivotFieldConfig[];
|
rowFields: PivotFieldConfig[];
|
||||||
onToggleExpand: (path: string[]) => void;
|
onToggleExpand: (path: string[]) => void;
|
||||||
|
rowSpan?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
|
const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
|
||||||
row,
|
row,
|
||||||
rowFields,
|
rowFields,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
|
rowSpan = 1,
|
||||||
}) => {
|
}) => {
|
||||||
const indentSize = row.level * 20;
|
const indentSize = row.level * 20;
|
||||||
|
|
||||||
|
|
@ -68,6 +123,7 @@ const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
|
||||||
row.isExpanded && "bg-muted/70"
|
row.isExpanded && "bg-muted/70"
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${8 + indentSize}px` }}
|
style={{ paddingLeft: `${8 + indentSize}px` }}
|
||||||
|
rowSpan={rowSpan > 1 ? rowSpan : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{row.hasChildren && (
|
{row.hasChildren && (
|
||||||
|
|
@ -94,7 +150,7 @@ interface DataCellProps {
|
||||||
values: PivotCellValue[];
|
values: PivotCellValue[];
|
||||||
isTotal?: boolean;
|
isTotal?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: (e?: React.MouseEvent) => void;
|
||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
conditionalStyle?: CellFormatStyle;
|
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) {
|
if (values.length === 1) {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
className={cn(
|
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",
|
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||||
isTotal && "bg-primary/5 font-medium",
|
isTotal && "bg-primary/5 font-medium",
|
||||||
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50",
|
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50",
|
||||||
|
|
@ -147,6 +208,7 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
style={cellStyle}
|
style={cellStyle}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
|
title={tooltipContent}
|
||||||
>
|
>
|
||||||
{/* Data Bar */}
|
{/* Data Bar */}
|
||||||
{hasDataBar && (
|
{hasDataBar && (
|
||||||
|
|
@ -182,6 +244,7 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||||
style={cellStyle}
|
style={cellStyle}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
|
title={`${val.field || "값"}: ${val.formattedValue || val.value}`}
|
||||||
>
|
>
|
||||||
{hasDataBar && (
|
{hasDataBar && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -260,11 +323,17 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
const [containerHeight, setContainerHeight] = useState(400);
|
const [containerHeight, setContainerHeight] = useState(400);
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 셀 선택 상태
|
// 셀 선택 상태 (범위 선택 지원)
|
||||||
const [selectedCell, setSelectedCell] = useState<{
|
const [selectedCell, setSelectedCell] = useState<{
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
colIndex: number;
|
colIndex: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [selectionRange, setSelectionRange] = useState<{
|
||||||
|
startRow: number;
|
||||||
|
startCol: number;
|
||||||
|
endRow: number;
|
||||||
|
endCol: number;
|
||||||
|
} | null>(null);
|
||||||
const tableRef = useRef<HTMLTableElement>(null);
|
const tableRef = useRef<HTMLTableElement>(null);
|
||||||
|
|
||||||
// 정렬 상태
|
// 정렬 상태
|
||||||
|
|
@ -272,6 +341,12 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
field: string;
|
field: string;
|
||||||
direction: "asc" | "desc";
|
direction: "asc" | "desc";
|
||||||
} | null>(null);
|
} | 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 변경 시 동기화
|
// 외부 fields 변경 시 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -280,6 +355,38 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
}
|
}
|
||||||
}, [initialFields]);
|
}, [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 || [];
|
const data = externalData || [];
|
||||||
|
|
||||||
|
|
@ -456,12 +563,72 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
return () => observer.disconnect();
|
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 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({
|
const virtualScroll = useVirtualScroll({
|
||||||
itemCount: flatRows.length,
|
itemCount: sortedFlatRows.length,
|
||||||
itemHeight: ROW_HEIGHT,
|
itemHeight: ROW_HEIGHT,
|
||||||
containerHeight: containerHeight,
|
containerHeight: containerHeight,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
|
|
@ -469,9 +636,9 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
|
|
||||||
// 가상 스크롤 적용된 행 데이터
|
// 가상 스크롤 적용된 행 데이터
|
||||||
const visibleFlatRows = useMemo(() => {
|
const visibleFlatRows = useMemo(() => {
|
||||||
if (!enableVirtualScroll) return flatRows;
|
if (!enableVirtualScroll) return sortedFlatRows;
|
||||||
return flatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1);
|
return sortedFlatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1);
|
||||||
}, [enableVirtualScroll, flatRows, virtualScroll.startIndex, virtualScroll.endIndex]);
|
}, [enableVirtualScroll, sortedFlatRows, virtualScroll.startIndex, virtualScroll.endIndex]);
|
||||||
|
|
||||||
// 조건부 서식 스타일 계산 헬퍼
|
// 조건부 서식 스타일 계산 헬퍼
|
||||||
const getCellConditionalStyle = useCallback(
|
const getCellConditionalStyle = useCallback(
|
||||||
|
|
@ -660,6 +827,154 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
console.error("Excel 내보내기 실패:", error);
|
console.error("Excel 내보내기 실패:", error);
|
||||||
}
|
}
|
||||||
}, [pivotResult, fields, totals, title]);
|
}, [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":
|
case "Escape":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedCell(null);
|
setSelectedCell(null);
|
||||||
|
setSelectionRange(null);
|
||||||
break;
|
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:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -828,9 +1163,85 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 셀 클릭으로 선택
|
// 셀 클릭으로 선택 (Shift+클릭으로 범위 선택)
|
||||||
const handleCellSelect = (rowIndex: number, colIndex: number) => {
|
const handleCellSelect = (rowIndex: number, colIndex: number, shiftKey: boolean = false) => {
|
||||||
setSelectedCell({ rowIndex, colIndex });
|
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" />
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -1102,18 +1606,28 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
<th
|
<th
|
||||||
key={idx}
|
key={idx}
|
||||||
className={cn(
|
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",
|
"px-2 py-1.5 text-center text-xs font-medium",
|
||||||
"bg-muted/70 sticky top-0 z-10",
|
"bg-muted/70 sticky top-0 z-10",
|
||||||
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
|
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
colSpan={dataFields.length || 1}
|
colSpan={dataFields.length || 1}
|
||||||
|
style={{ width: columnWidths[idx] || "auto", minWidth: 50 }}
|
||||||
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined}
|
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<span>{col.caption || "(전체)"}</span>
|
<span>{col.caption || "(전체)"}</span>
|
||||||
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
|
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
|
||||||
</div>
|
</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>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -1214,6 +1728,34 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<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 && (
|
{enableVirtualScroll && virtualScroll.offsetTop > 0 && (
|
||||||
<tr style={{ height: virtualScroll.offsetTop }}>
|
<tr style={{ height: virtualScroll.offsetTop }}>
|
||||||
|
|
@ -1221,26 +1763,34 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleFlatRows.map((row, idx) => {
|
{(() => {
|
||||||
// 실제 행 인덱스 계산
|
// 셀 병합 정보 계산
|
||||||
const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx;
|
const mergeInfo = calculateMergeCells(visibleFlatRows, style?.mergeCells || false);
|
||||||
|
|
||||||
return (
|
return visibleFlatRows.map((row, idx) => {
|
||||||
<tr
|
// 실제 행 인덱스 계산
|
||||||
key={rowIdx}
|
const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx;
|
||||||
className={cn(
|
const cellMerge = mergeInfo.get(idx) || { rowSpan: 1, skip: false };
|
||||||
style?.alternateRowColors &&
|
|
||||||
rowIdx % 2 === 1 &&
|
return (
|
||||||
"bg-muted/20"
|
<tr
|
||||||
)}
|
key={rowIdx}
|
||||||
style={{ height: ROW_HEIGHT }}
|
className={cn(
|
||||||
>
|
style?.alternateRowColors &&
|
||||||
{/* 행 헤더 */}
|
rowIdx % 2 === 1 &&
|
||||||
<RowHeaderCell
|
"bg-muted/20"
|
||||||
row={row}
|
)}
|
||||||
rowFields={rowFields}
|
style={{ height: ROW_HEIGHT }}
|
||||||
onToggleExpand={handleToggleRowExpand}
|
>
|
||||||
/>
|
{/* 행 헤더 (병합되면 skip) */}
|
||||||
|
{!cellMerge.skip && (
|
||||||
|
<RowHeaderCell
|
||||||
|
row={row}
|
||||||
|
rowFields={rowFields}
|
||||||
|
onToggleExpand={handleToggleRowExpand}
|
||||||
|
rowSpan={cellMerge.rowSpan}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 데이터 셀 */}
|
{/* 데이터 셀 */}
|
||||||
{flatColumns.map((col, colIdx) => {
|
{flatColumns.map((col, colIdx) => {
|
||||||
|
|
@ -1253,8 +1803,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
? getCellConditionalStyle(values[0].value ?? undefined, values[0].field)
|
? getCellConditionalStyle(values[0].value ?? undefined, values[0].field)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// 선택 상태 확인
|
// 선택 상태 확인 (범위 선택 포함)
|
||||||
const isCellSelected = selectedCell?.rowIndex === rowIdx && selectedCell?.colIndex === colIdx;
|
const isCellSelected = isCellInRange(rowIdx, colIdx);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataCell
|
<DataCell
|
||||||
|
|
@ -1262,8 +1812,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
values={values}
|
values={values}
|
||||||
conditionalStyle={conditionalStyle}
|
conditionalStyle={conditionalStyle}
|
||||||
isSelected={isCellSelected}
|
isSelected={isCellSelected}
|
||||||
onClick={() => {
|
onClick={(e?: React.MouseEvent) => {
|
||||||
handleCellSelect(rowIdx, colIdx);
|
handleCellSelect(rowIdx, colIdx, e?.shiftKey || false);
|
||||||
if (onCellClick) {
|
if (onCellClick) {
|
||||||
handleCellClick(row.path, col.path, values);
|
handleCellClick(row.path, col.path, values);
|
||||||
}
|
}
|
||||||
|
|
@ -1283,8 +1833,9 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 가상 스크롤 하단 여백 */}
|
{/* 가상 스크롤 하단 여백 */}
|
||||||
{enableVirtualScroll && (
|
{enableVirtualScroll && (
|
||||||
|
|
@ -1293,8 +1844,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 열 총계 행 */}
|
{/* 열 총계 행 (하단 위치 - 기본값) */}
|
||||||
{totals?.showColumnGrandTotals && (
|
{totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && (
|
||||||
<tr className="bg-primary/5 font-medium">
|
<tr className="bg-primary/5 font-medium">
|
||||||
<td
|
<td
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,62 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||||
<Label className="text-xs">줄무늬</Label>
|
<Label className="text-xs">줄무늬</Label>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -557,6 +613,16 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
||||||
<Label className="text-xs">CSV 내보내기</Label>
|
<Label className="text-xs">CSV 내보내기</Label>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -566,6 +632,16 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -593,6 +669,126 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,15 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
|
||||||
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
|
{ 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> = {
|
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||||
string: <Type className="h-3.5 w-3.5" />,
|
string: <Type className="h-3.5 w-3.5" />,
|
||||||
number: <Hash 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; // 필드 선택기에서 폴더 구조
|
displayFolder?: string; // 필드 선택기에서 폴더 구조
|
||||||
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
|
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
|
||||||
|
|
||||||
|
// 계산 필드
|
||||||
|
isCalculated?: boolean; // 계산 필드 여부
|
||||||
|
calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 데이터 소스 설정 ====================
|
// ==================== 데이터 소스 설정 ====================
|
||||||
|
|
@ -140,11 +144,13 @@ export interface PivotTotalsConfig {
|
||||||
showRowGrandTotals?: boolean; // 행 총합계 표시
|
showRowGrandTotals?: boolean; // 행 총합계 표시
|
||||||
showRowTotals?: boolean; // 행 소계 표시
|
showRowTotals?: boolean; // 행 소계 표시
|
||||||
rowTotalsPosition?: "first" | "last"; // 소계 위치
|
rowTotalsPosition?: "first" | "last"; // 소계 위치
|
||||||
|
rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단)
|
||||||
|
|
||||||
// 열 총합계
|
// 열 총합계
|
||||||
showColumnGrandTotals?: boolean; // 열 총합계 표시
|
showColumnGrandTotals?: boolean; // 열 총합계 표시
|
||||||
showColumnTotals?: boolean; // 열 소계 표시
|
showColumnTotals?: boolean; // 열 소계 표시
|
||||||
columnTotalsPosition?: "first" | "last"; // 소계 위치
|
columnTotalsPosition?: "first" | "last"; // 소계 위치
|
||||||
|
columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필드 선택기 설정
|
// 필드 선택기 설정
|
||||||
|
|
@ -214,6 +220,7 @@ export interface PivotStyleConfig {
|
||||||
alternateRowColors?: boolean;
|
alternateRowColors?: boolean;
|
||||||
highlightTotals?: boolean; // 총합계 강조
|
highlightTotals?: boolean; // 총합계 강조
|
||||||
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
|
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
|
||||||
|
mergeCells?: boolean; // 같은 값 셀 병합
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 내보내기 설정 ====================
|
// ==================== 내보내기 설정 ====================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue