그리드랑 노드에서 delete 가 where 입력했는데도 저장이 안되던 오류 해결
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PivotGridProps,
|
||||
@@ -15,8 +15,15 @@ import {
|
||||
PivotFlatRow,
|
||||
PivotCellValue,
|
||||
PivotGridState,
|
||||
PivotAreaType,
|
||||
} from "./types";
|
||||
import { processPivotData, pathToKey } from "./utils/pivotEngine";
|
||||
import { exportPivotToExcel } from "./utils/exportExcel";
|
||||
import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat";
|
||||
import { FieldPanel } from "./components/FieldPanel";
|
||||
import { FieldChooser } from "./components/FieldChooser";
|
||||
import { DrillDownModal } from "./components/DrillDownModal";
|
||||
import { PivotChart } from "./components/PivotChart";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
@@ -25,6 +32,9 @@ import {
|
||||
RefreshCw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
LayoutGrid,
|
||||
FileSpreadsheet,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -79,13 +89,22 @@ interface DataCellProps {
|
||||
values: PivotCellValue[];
|
||||
isTotal?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
conditionalStyle?: CellFormatStyle;
|
||||
}
|
||||
|
||||
const DataCell: React.FC<DataCellProps> = ({
|
||||
values,
|
||||
isTotal = false,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
conditionalStyle,
|
||||
}) => {
|
||||
// 조건부 서식 스타일 계산
|
||||
const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {};
|
||||
const hasDataBar = conditionalStyle?.dataBarWidth !== undefined;
|
||||
const icon = conditionalStyle?.icon;
|
||||
|
||||
if (!values || values.length === 0) {
|
||||
return (
|
||||
<td
|
||||
@@ -94,6 +113,9 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||
"px-2 py-1.5 text-right text-sm",
|
||||
isTotal && "bg-primary/5 font-medium"
|
||||
)}
|
||||
style={cellStyle}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
-
|
||||
</td>
|
||||
@@ -105,14 +127,29 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"border-r border-b border-border relative",
|
||||
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||
isTotal && "bg-primary/5 font-medium",
|
||||
onClick && "cursor-pointer hover:bg-accent/50"
|
||||
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
|
||||
)}
|
||||
style={cellStyle}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{values[0].formattedValue}
|
||||
{/* Data Bar */}
|
||||
{hasDataBar && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 opacity-30"
|
||||
style={{
|
||||
width: `${conditionalStyle?.dataBarWidth}%`,
|
||||
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center justify-end gap-1">
|
||||
{icon && <span>{icon}</span>}
|
||||
{values[0].formattedValue}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -124,14 +161,28 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||
<td
|
||||
key={idx}
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"border-r border-b border-border relative",
|
||||
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||
isTotal && "bg-primary/5 font-medium",
|
||||
onClick && "cursor-pointer hover:bg-accent/50"
|
||||
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
|
||||
)}
|
||||
style={cellStyle}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{val.formattedValue}
|
||||
{hasDataBar && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 opacity-30"
|
||||
style={{
|
||||
width: `${conditionalStyle?.dataBarWidth}%`,
|
||||
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center justify-end gap-1">
|
||||
{icon && <span>{icon}</span>}
|
||||
{val.formattedValue}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</>
|
||||
@@ -142,7 +193,7 @@ const DataCell: React.FC<DataCellProps> = ({
|
||||
|
||||
export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
title,
|
||||
fields = [],
|
||||
fields: initialFields = [],
|
||||
totals = {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
@@ -157,24 +208,49 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
alternateRowColors: true,
|
||||
highlightTotals: true,
|
||||
},
|
||||
fieldChooser,
|
||||
chart: chartConfig,
|
||||
allowExpandAll = true,
|
||||
height = "auto",
|
||||
maxHeight,
|
||||
exportConfig,
|
||||
data: externalData,
|
||||
onCellClick,
|
||||
onCellDoubleClick,
|
||||
onFieldDrop,
|
||||
onExpandChange,
|
||||
}) => {
|
||||
// 디버깅 로그
|
||||
console.log("🔶 PivotGridComponent props:", {
|
||||
title,
|
||||
hasExternalData: !!externalData,
|
||||
externalDataLength: externalData?.length,
|
||||
initialFieldsLength: initialFields?.length,
|
||||
});
|
||||
// ==================== 상태 ====================
|
||||
|
||||
const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields);
|
||||
const [pivotState, setPivotState] = useState<PivotGridState>({
|
||||
expandedRowPaths: [],
|
||||
expandedColumnPaths: [],
|
||||
sortConfig: null,
|
||||
filterConfig: {},
|
||||
});
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showFieldPanel, setShowFieldPanel] = useState(true);
|
||||
const [showFieldChooser, setShowFieldChooser] = useState(false);
|
||||
const [drillDownData, setDrillDownData] = useState<{
|
||||
open: boolean;
|
||||
cellData: PivotCellData | null;
|
||||
}>({ open: false, cellData: null });
|
||||
const [showChart, setShowChart] = useState(chartConfig?.enabled || false);
|
||||
|
||||
// 외부 fields 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (initialFields.length > 0) {
|
||||
setFields(initialFields);
|
||||
}
|
||||
}, [initialFields]);
|
||||
|
||||
// 데이터
|
||||
const data = externalData || [];
|
||||
@@ -205,6 +281,43 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
[fields]
|
||||
);
|
||||
|
||||
const filterFields = useMemo(
|
||||
() =>
|
||||
fields
|
||||
.filter((f) => f.area === "filter" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
|
||||
[fields]
|
||||
);
|
||||
|
||||
// 사용 가능한 필드 목록 (FieldChooser용)
|
||||
const availableFields = useMemo(() => {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const sampleRow = data[0];
|
||||
return Object.keys(sampleRow).map((key) => {
|
||||
const existingField = fields.find((f) => f.field === key);
|
||||
const value = sampleRow[key];
|
||||
|
||||
// 데이터 타입 추론
|
||||
let dataType: "string" | "number" | "date" | "boolean" = "string";
|
||||
if (typeof value === "number") dataType = "number";
|
||||
else if (typeof value === "boolean") dataType = "boolean";
|
||||
else if (value instanceof Date) dataType = "date";
|
||||
else if (typeof value === "string") {
|
||||
// 날짜 문자열 감지
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date";
|
||||
}
|
||||
|
||||
return {
|
||||
field: key,
|
||||
caption: existingField?.caption || key,
|
||||
dataType,
|
||||
isSelected: existingField?.visible !== false,
|
||||
currentArea: existingField?.area,
|
||||
};
|
||||
});
|
||||
}, [data, fields]);
|
||||
|
||||
// ==================== 피벗 처리 ====================
|
||||
|
||||
const pivotResult = useMemo<PivotResult | null>(() => {
|
||||
@@ -212,16 +325,83 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleFields = fields.filter((f) => f.visible !== false);
|
||||
if (visibleFields.filter((f) => f.area !== "filter").length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return processPivotData(
|
||||
data,
|
||||
fields,
|
||||
visibleFields,
|
||||
pivotState.expandedRowPaths,
|
||||
pivotState.expandedColumnPaths
|
||||
);
|
||||
}, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
|
||||
|
||||
// 조건부 서식용 전체 값 수집
|
||||
const allCellValues = useMemo(() => {
|
||||
if (!pivotResult) return new Map<string, number[]>();
|
||||
|
||||
const valuesByField = new Map<string, number[]>();
|
||||
|
||||
// 데이터 매트릭스에서 모든 값 수집
|
||||
pivotResult.dataMatrix.forEach((values) => {
|
||||
values.forEach((val) => {
|
||||
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
|
||||
const existing = valuesByField.get(val.field) || [];
|
||||
existing.push(val.value);
|
||||
valuesByField.set(val.field, existing);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 행 총계 값 수집
|
||||
pivotResult.grandTotals.row.forEach((values) => {
|
||||
values.forEach((val) => {
|
||||
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
|
||||
const existing = valuesByField.get(val.field) || [];
|
||||
existing.push(val.value);
|
||||
valuesByField.set(val.field, existing);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 열 총계 값 수집
|
||||
pivotResult.grandTotals.column.forEach((values) => {
|
||||
values.forEach((val) => {
|
||||
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
|
||||
const existing = valuesByField.get(val.field) || [];
|
||||
existing.push(val.value);
|
||||
valuesByField.set(val.field, existing);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return valuesByField;
|
||||
}, [pivotResult]);
|
||||
|
||||
// 조건부 서식 스타일 계산 헬퍼
|
||||
const getCellConditionalStyle = useCallback(
|
||||
(value: number | undefined, field: string): CellFormatStyle => {
|
||||
if (!style?.conditionalFormats || style.conditionalFormats.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const allValues = allCellValues.get(field) || [];
|
||||
return getConditionalStyle(value, field, style.conditionalFormats, allValues);
|
||||
},
|
||||
[style?.conditionalFormats, allCellValues]
|
||||
);
|
||||
|
||||
// ==================== 이벤트 핸들러 ====================
|
||||
|
||||
// 필드 변경
|
||||
const handleFieldsChange = useCallback(
|
||||
(newFields: PivotFieldConfig[]) => {
|
||||
setFields(newFields);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 행 확장/축소
|
||||
const handleToggleRowExpand = useCallback(
|
||||
(path: string[]) => {
|
||||
@@ -256,7 +436,6 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
if (!pivotResult) return;
|
||||
|
||||
const allRowPaths: string[][] = [];
|
||||
|
||||
pivotResult.flatRows.forEach((row) => {
|
||||
if (row.hasChildren) {
|
||||
allRowPaths.push(row.path);
|
||||
@@ -296,6 +475,27 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
[onCellClick]
|
||||
);
|
||||
|
||||
// 셀 더블클릭 (Drill Down)
|
||||
const handleCellDoubleClick = useCallback(
|
||||
(rowPath: string[], colPath: string[], values: PivotCellValue[]) => {
|
||||
const cellData: PivotCellData = {
|
||||
value: values[0]?.value,
|
||||
rowPath,
|
||||
columnPath: colPath,
|
||||
field: values[0]?.field,
|
||||
};
|
||||
|
||||
// Drill Down 모달 열기
|
||||
setDrillDownData({ open: true, cellData });
|
||||
|
||||
// 외부 콜백 호출
|
||||
if (onCellDoubleClick) {
|
||||
onCellDoubleClick(cellData);
|
||||
}
|
||||
},
|
||||
[onCellDoubleClick]
|
||||
);
|
||||
|
||||
// CSV 내보내기
|
||||
const handleExportCSV = useCallback(() => {
|
||||
if (!pivotResult) return;
|
||||
@@ -354,6 +554,20 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
link.click();
|
||||
}, [pivotResult, totals, title]);
|
||||
|
||||
// Excel 내보내기
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
if (!pivotResult) return;
|
||||
|
||||
try {
|
||||
await exportPivotToExcel(pivotResult, fields, totals, {
|
||||
fileName: title || "pivot_export",
|
||||
title: title,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Excel 내보내기 실패:", error);
|
||||
}
|
||||
}, [pivotResult, fields, totals, title]);
|
||||
|
||||
// ==================== 렌더링 ====================
|
||||
|
||||
// 빈 상태
|
||||
@@ -374,20 +588,51 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
}
|
||||
|
||||
// 필드 미설정
|
||||
if (fields.length === 0) {
|
||||
const hasActiveFields = fields.some(
|
||||
(f) => f.visible !== false && f.area !== "filter"
|
||||
);
|
||||
if (!hasActiveFields) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"p-8 text-center text-muted-foreground",
|
||||
"border border-dashed border-border rounded-lg"
|
||||
"flex flex-col",
|
||||
"border border-border rounded-lg overflow-hidden bg-background"
|
||||
)}
|
||||
>
|
||||
<Settings className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">필드가 설정되지 않았습니다</p>
|
||||
<p className="text-xs mt-1">
|
||||
행, 열, 데이터 영역에 필드를 배치해주세요
|
||||
</p>
|
||||
{/* 필드 패널 */}
|
||||
<FieldPanel
|
||||
fields={fields}
|
||||
onFieldsChange={handleFieldsChange}
|
||||
collapsed={!showFieldPanel}
|
||||
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
|
||||
/>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center text-muted-foreground">
|
||||
<Settings className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">필드가 설정되지 않았습니다</p>
|
||||
<p className="text-xs mt-1">
|
||||
행, 열, 데이터 영역에 필드를 배치해주세요
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => setShowFieldChooser(true)}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 mr-2" />
|
||||
필드 선택기 열기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필드 선택기 모달 */}
|
||||
<FieldChooser
|
||||
open={showFieldChooser}
|
||||
onOpenChange={setShowFieldChooser}
|
||||
availableFields={availableFields}
|
||||
selectedFields={fields}
|
||||
onFieldsChange={handleFieldsChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -416,6 +661,14 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
maxHeight: isFullscreen ? "none" : maxHeight,
|
||||
}}
|
||||
>
|
||||
{/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */}
|
||||
<FieldPanel
|
||||
fields={fields}
|
||||
onFieldsChange={handleFieldsChange}
|
||||
collapsed={!showFieldPanel}
|
||||
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
|
||||
/>
|
||||
|
||||
{/* 헤더 툴바 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -426,6 +679,30 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 필드 선택기 버튼 */}
|
||||
{fieldChooser?.enabled !== false && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setShowFieldChooser(true)}
|
||||
title="필드 선택기"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 필드 패널 토글 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setShowFieldPanel(!showFieldPanel)}
|
||||
title={showFieldPanel ? "필드 패널 숨기기" : "필드 패널 보기"}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{allowExpandAll && (
|
||||
<>
|
||||
<Button
|
||||
@@ -450,18 +727,43 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{exportConfig?.excel && (
|
||||
{/* 차트 토글 */}
|
||||
{chartConfig && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant={showChart ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleExportCSV}
|
||||
title="CSV 내보내기"
|
||||
onClick={() => setShowChart(!showChart)}
|
||||
title={showChart ? "차트 숨기기" : "차트 보기"}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 내보내기 버튼들 */}
|
||||
{exportConfig?.excel && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleExportCSV}
|
||||
title="CSV 내보내기"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleExportExcel}
|
||||
title="Excel 내보내기"
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -584,15 +886,25 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||||
const values = dataMatrix.get(cellKey) || [];
|
||||
|
||||
// 조건부 서식 (첫 번째 값 기준)
|
||||
const conditionalStyle =
|
||||
values.length > 0 && values[0].field
|
||||
? getCellConditionalStyle(values[0].value, values[0].field)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<DataCell
|
||||
key={colIdx}
|
||||
values={values}
|
||||
conditionalStyle={conditionalStyle}
|
||||
onClick={
|
||||
onCellClick
|
||||
? () => handleCellClick(row.path, col.path, values)
|
||||
: undefined
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
handleCellDoubleClick(row.path, col.path, values)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -637,6 +949,38 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 차트 */}
|
||||
{showChart && chartConfig && pivotResult && (
|
||||
<PivotChart
|
||||
pivotResult={pivotResult}
|
||||
config={{
|
||||
...chartConfig,
|
||||
enabled: true,
|
||||
}}
|
||||
dataFields={dataFields}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 필드 선택기 모달 */}
|
||||
<FieldChooser
|
||||
open={showFieldChooser}
|
||||
onOpenChange={setShowFieldChooser}
|
||||
availableFields={availableFields}
|
||||
selectedFields={fields}
|
||||
onFieldsChange={handleFieldsChange}
|
||||
/>
|
||||
|
||||
{/* Drill Down 모달 */}
|
||||
<DrillDownModal
|
||||
open={drillDownData.open}
|
||||
onOpenChange={(open) => setDrillDownData((prev) => ({ ...prev, open }))}
|
||||
cellData={drillDownData.cellData}
|
||||
data={data}
|
||||
fields={fields}
|
||||
rowFields={rowFields}
|
||||
columnFields={columnFields}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user