603 lines
20 KiB
TypeScript
603 lines
20 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* PivotGrid 설정 패널 - 간소화 버전
|
|
*
|
|
* 피벗 테이블 설정 방법:
|
|
* 1. 테이블 선택
|
|
* 2. 컬럼을 드래그하여 행/열/값 영역에 배치
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
PivotGridComponentConfig,
|
|
PivotFieldConfig,
|
|
PivotAreaType,
|
|
AggregationType,
|
|
FieldDataType,
|
|
} from "./types";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Rows,
|
|
Columns,
|
|
Calculator,
|
|
X,
|
|
Plus,
|
|
GripVertical,
|
|
Table2,
|
|
BarChart3,
|
|
Settings,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Info,
|
|
} from "lucide-react";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
|
|
// ==================== 타입 ====================
|
|
|
|
interface TableInfo {
|
|
table_name: string;
|
|
table_comment?: string;
|
|
}
|
|
|
|
interface ColumnInfo {
|
|
column_name: string;
|
|
data_type: string;
|
|
column_comment?: string;
|
|
}
|
|
|
|
interface PivotGridConfigPanelProps {
|
|
config: PivotGridComponentConfig;
|
|
onChange: (config: PivotGridComponentConfig) => void;
|
|
}
|
|
|
|
// DB 타입을 FieldDataType으로 변환
|
|
function mapDbTypeToFieldType(dbType: string): FieldDataType {
|
|
const type = dbType.toLowerCase();
|
|
if (type.includes("int") || type.includes("numeric") || type.includes("decimal") || type.includes("float")) {
|
|
return "number";
|
|
}
|
|
if (type.includes("date") || type.includes("time") || type.includes("timestamp")) {
|
|
return "date";
|
|
}
|
|
if (type.includes("bool")) {
|
|
return "boolean";
|
|
}
|
|
return "string";
|
|
}
|
|
|
|
// ==================== 컬럼 칩 컴포넌트 ====================
|
|
|
|
interface ColumnChipProps {
|
|
column: ColumnInfo;
|
|
isUsed: boolean;
|
|
onClick: () => void;
|
|
}
|
|
|
|
const ColumnChip: React.FC<ColumnChipProps> = ({ column, isUsed, onClick }) => {
|
|
const dataType = mapDbTypeToFieldType(column.data_type);
|
|
const typeColor = {
|
|
number: "bg-blue-100 text-blue-700 border-blue-200",
|
|
string: "bg-green-100 text-green-700 border-green-200",
|
|
date: "bg-purple-100 text-purple-700 border-purple-200",
|
|
boolean: "bg-orange-100 text-orange-700 border-orange-200",
|
|
}[dataType];
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={isUsed}
|
|
className={cn(
|
|
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs border transition-all",
|
|
isUsed
|
|
? "bg-muted text-muted-foreground border-muted cursor-not-allowed opacity-50"
|
|
: cn(typeColor, "hover:shadow-sm cursor-pointer")
|
|
)}
|
|
>
|
|
<span className="font-medium truncate max-w-[120px]">
|
|
{column.column_comment || column.column_name}
|
|
</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// ==================== 영역 드롭존 컴포넌트 ====================
|
|
|
|
interface AreaDropZoneProps {
|
|
area: PivotAreaType;
|
|
label: string;
|
|
description: string;
|
|
icon: React.ReactNode;
|
|
fields: PivotFieldConfig[];
|
|
columns: ColumnInfo[];
|
|
onAddField: (column: ColumnInfo) => void;
|
|
onRemoveField: (index: number) => void;
|
|
onUpdateField: (index: number, updates: Partial<PivotFieldConfig>) => void;
|
|
color: string;
|
|
}
|
|
|
|
const AreaDropZone: React.FC<AreaDropZoneProps> = ({
|
|
area,
|
|
label,
|
|
description,
|
|
icon,
|
|
fields,
|
|
columns,
|
|
onAddField,
|
|
onRemoveField,
|
|
onUpdateField,
|
|
color,
|
|
}) => {
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|
|
|
// 사용 가능한 컬럼 (이미 추가된 컬럼 제외)
|
|
const availableColumns = columns.filter(
|
|
(col) => !fields.some((f) => f.field === col.column_name)
|
|
);
|
|
|
|
return (
|
|
<div className={cn("rounded-lg border-2 p-3", color)}>
|
|
{/* 헤더 */}
|
|
<div
|
|
className="flex items-center justify-between cursor-pointer"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{icon}
|
|
<span className="font-medium text-sm">{label}</span>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{fields.length}
|
|
</Badge>
|
|
</div>
|
|
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
</div>
|
|
|
|
{/* 설명 */}
|
|
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
|
|
|
{isExpanded && (
|
|
<div className="mt-3 space-y-2">
|
|
{/* 추가된 필드 목록 */}
|
|
{fields.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{fields.map((field, idx) => (
|
|
<div
|
|
key={`${field.field}-${idx}`}
|
|
className="flex items-center gap-2 bg-background rounded-md px-2 py-1.5 border"
|
|
>
|
|
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
|
<span className="flex-1 text-xs font-medium truncate">
|
|
{field.caption || field.field}
|
|
</span>
|
|
|
|
{/* 데이터 영역일 때 집계 함수 선택 */}
|
|
{area === "data" && (
|
|
<Select
|
|
value={field.summaryType || "sum"}
|
|
onValueChange={(v) => onUpdateField(idx, { summaryType: v as AggregationType })}
|
|
>
|
|
<SelectTrigger className="h-6 w-20 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="sum">합계</SelectItem>
|
|
<SelectItem value="count">개수</SelectItem>
|
|
<SelectItem value="avg">평균</SelectItem>
|
|
<SelectItem value="min">최소</SelectItem>
|
|
<SelectItem value="max">최대</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-5 w-5"
|
|
onClick={() => onRemoveField(idx)}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground text-center py-2 border border-dashed rounded-md">
|
|
아래에서 컬럼을 선택하세요
|
|
</div>
|
|
)}
|
|
|
|
{/* 컬럼 추가 드롭다운 */}
|
|
{availableColumns.length > 0 && (
|
|
<Select onValueChange={(v) => {
|
|
const col = columns.find(c => c.column_name === v);
|
|
if (col) onAddField(col);
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
<span>컬럼 추가</span>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableColumns.map((col) => (
|
|
<SelectItem key={col.column_name} value={col.column_name}>
|
|
<div className="flex items-center gap-2">
|
|
<span>{col.column_comment || col.column_name}</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
({mapDbTypeToFieldType(col.data_type)})
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ==================== 메인 컴포넌트 ====================
|
|
|
|
export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
}) => {
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
|
|
// 테이블 목록 로드
|
|
useEffect(() => {
|
|
const loadTables = async () => {
|
|
setLoadingTables(true);
|
|
try {
|
|
const tableList = await tableTypeApi.getTables();
|
|
const mappedTables: TableInfo[] = tableList.map((t: any) => ({
|
|
table_name: t.tableName,
|
|
table_comment: t.tableLabel || t.displayName || t.tableName,
|
|
}));
|
|
setTables(mappedTables);
|
|
} catch (error) {
|
|
console.error("테이블 목록 로드 실패:", error);
|
|
} finally {
|
|
setLoadingTables(false);
|
|
}
|
|
};
|
|
loadTables();
|
|
}, []);
|
|
|
|
// 테이블 선택 시 컬럼 로드
|
|
useEffect(() => {
|
|
const loadColumns = async () => {
|
|
if (!config.dataSource?.tableName) {
|
|
setColumns([]);
|
|
return;
|
|
}
|
|
|
|
setLoadingColumns(true);
|
|
try {
|
|
const columnList = await tableTypeApi.getColumns(config.dataSource.tableName);
|
|
const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({
|
|
column_name: c.columnName || c.column_name,
|
|
data_type: c.dataType || c.data_type || "text",
|
|
column_comment: c.columnLabel || c.column_label || c.columnName || c.column_name,
|
|
}));
|
|
setColumns(mappedColumns);
|
|
} catch (error) {
|
|
console.error("컬럼 목록 로드 실패:", error);
|
|
} finally {
|
|
setLoadingColumns(false);
|
|
}
|
|
};
|
|
loadColumns();
|
|
}, [config.dataSource?.tableName]);
|
|
|
|
// 설정 업데이트 헬퍼
|
|
const updateConfig = useCallback(
|
|
(updates: Partial<PivotGridComponentConfig>) => {
|
|
onChange({ ...config, ...updates });
|
|
},
|
|
[config, onChange]
|
|
);
|
|
|
|
// 필드 추가
|
|
const handleAddField = (area: PivotAreaType, column: ColumnInfo) => {
|
|
const currentFields = config.fields || [];
|
|
const areaFields = currentFields.filter(f => f.area === area);
|
|
|
|
const newField: PivotFieldConfig = {
|
|
field: column.column_name,
|
|
caption: column.column_comment || column.column_name,
|
|
area,
|
|
areaIndex: areaFields.length,
|
|
dataType: mapDbTypeToFieldType(column.data_type),
|
|
visible: true,
|
|
};
|
|
|
|
if (area === "data") {
|
|
newField.summaryType = "sum";
|
|
}
|
|
|
|
updateConfig({ fields: [...currentFields, newField] });
|
|
};
|
|
|
|
// 필드 제거
|
|
const handleRemoveField = (area: PivotAreaType, index: number) => {
|
|
const currentFields = config.fields || [];
|
|
const newFields = currentFields.filter(
|
|
(f) => !(f.area === area && f.areaIndex === index)
|
|
);
|
|
|
|
// 인덱스 재정렬
|
|
let idx = 0;
|
|
newFields.forEach((f) => {
|
|
if (f.area === area) {
|
|
f.areaIndex = idx++;
|
|
}
|
|
});
|
|
|
|
updateConfig({ fields: newFields });
|
|
};
|
|
|
|
// 필드 업데이트
|
|
const handleUpdateField = (area: PivotAreaType, index: number, updates: Partial<PivotFieldConfig>) => {
|
|
const currentFields = config.fields || [];
|
|
const newFields = currentFields.map((f) => {
|
|
if (f.area === area && f.areaIndex === index) {
|
|
return { ...f, ...updates };
|
|
}
|
|
return f;
|
|
});
|
|
updateConfig({ fields: newFields });
|
|
};
|
|
|
|
// 영역별 필드 가져오기
|
|
const getFieldsByArea = (area: PivotAreaType) => {
|
|
return (config.fields || [])
|
|
.filter(f => f.area === area)
|
|
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 사용 가이드 */}
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<div className="flex items-start gap-2">
|
|
<Info className="h-4 w-4 text-blue-600 mt-0.5" />
|
|
<div className="text-xs text-blue-800">
|
|
<p className="font-medium mb-1">피벗 테이블 설정 방법</p>
|
|
<ol className="list-decimal list-inside space-y-0.5 text-blue-700">
|
|
<li>데이터를 가져올 <strong>테이블</strong>을 선택하세요</li>
|
|
<li><strong>행 그룹</strong>에 그룹화할 컬럼을 추가하세요 (예: 지역, 부서)</li>
|
|
<li><strong>열 그룹</strong>에 가로로 펼칠 컬럼을 추가하세요 (예: 월, 분기)</li>
|
|
<li><strong>값</strong>에 집계할 숫자 컬럼을 추가하세요 (예: 매출, 수량)</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* STEP 1: 테이블 선택 */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Table2 className="h-4 w-4 text-primary" />
|
|
<Label className="text-sm font-semibold">STEP 1. 테이블 선택</Label>
|
|
</div>
|
|
|
|
<Select
|
|
value={config.dataSource?.tableName || "__none__"}
|
|
onValueChange={(v) =>
|
|
updateConfig({
|
|
dataSource: {
|
|
...config.dataSource,
|
|
type: "table",
|
|
tableName: v === "__none__" ? undefined : v,
|
|
},
|
|
fields: [], // 테이블 변경 시 필드 초기화
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-10">
|
|
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">선택 안 함</SelectItem>
|
|
{tables.map((table) => (
|
|
<SelectItem key={table.table_name} value={table.table_name}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{table.table_comment || table.table_name}</span>
|
|
{table.table_comment && (
|
|
<span className="text-muted-foreground text-xs">({table.table_name})</span>
|
|
)}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* STEP 2: 필드 배치 */}
|
|
{config.dataSource?.tableName && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<BarChart3 className="h-4 w-4 text-primary" />
|
|
<Label className="text-sm font-semibold">STEP 2. 필드 배치</Label>
|
|
{loadingColumns && <span className="text-xs text-muted-foreground">(컬럼 로딩 중...)</span>}
|
|
</div>
|
|
|
|
{/* 사용 가능한 컬럼 목록 */}
|
|
{columns.length > 0 && (
|
|
<div className="bg-muted/30 rounded-lg p-3 space-y-2">
|
|
<Label className="text-xs text-muted-foreground">사용 가능한 컬럼</Label>
|
|
<div className="flex flex-wrap gap-1">
|
|
{columns.map((col) => {
|
|
const isUsed = (config.fields || []).some(f => f.field === col.column_name);
|
|
return (
|
|
<ColumnChip
|
|
key={col.column_name}
|
|
column={col}
|
|
isUsed={isUsed}
|
|
onClick={() => {/* 클릭 시 아무것도 안함 - 드롭존에서 추가 */}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 영역별 드롭존 */}
|
|
<div className="grid gap-3">
|
|
<AreaDropZone
|
|
area="row"
|
|
label="행 그룹"
|
|
description="세로로 그룹화할 항목 (예: 지역, 부서, 제품)"
|
|
icon={<Rows className="h-4 w-4 text-emerald-600" />}
|
|
fields={getFieldsByArea("row")}
|
|
columns={columns}
|
|
onAddField={(col) => handleAddField("row", col)}
|
|
onRemoveField={(idx) => handleRemoveField("row", idx)}
|
|
onUpdateField={(idx, updates) => handleUpdateField("row", idx, updates)}
|
|
color="border-emerald-200 bg-emerald-50/50"
|
|
/>
|
|
|
|
<AreaDropZone
|
|
area="column"
|
|
label="열 그룹"
|
|
description="가로로 펼칠 항목 (예: 월, 분기, 연도)"
|
|
icon={<Columns className="h-4 w-4 text-blue-600" />}
|
|
fields={getFieldsByArea("column")}
|
|
columns={columns}
|
|
onAddField={(col) => handleAddField("column", col)}
|
|
onRemoveField={(idx) => handleRemoveField("column", idx)}
|
|
onUpdateField={(idx, updates) => handleUpdateField("column", idx, updates)}
|
|
color="border-blue-200 bg-blue-50/50"
|
|
/>
|
|
|
|
<AreaDropZone
|
|
area="data"
|
|
label="값 (집계)"
|
|
description="합계, 평균 등을 계산할 숫자 항목 (예: 매출, 수량)"
|
|
icon={<Calculator className="h-4 w-4 text-amber-600" />}
|
|
fields={getFieldsByArea("data")}
|
|
columns={columns}
|
|
onAddField={(col) => handleAddField("data", col)}
|
|
onRemoveField={(idx) => handleRemoveField("data", idx)}
|
|
onUpdateField={(idx, updates) => handleUpdateField("data", idx, updates)}
|
|
color="border-amber-200 bg-amber-50/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 고급 설정 토글 */}
|
|
<div className="pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full justify-between"
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-4 w-4" />
|
|
<span>고급 설정</span>
|
|
</div>
|
|
{showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 고급 설정 */}
|
|
{showAdvanced && (
|
|
<div className="space-y-4 pt-2 border-t">
|
|
{/* 표시 설정 */}
|
|
<div className="space-y-3">
|
|
<Label className="text-xs font-medium text-muted-foreground">표시 설정</Label>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
|
<Label className="text-xs">행 총계</Label>
|
|
<Switch
|
|
checked={config.totals?.showRowGrandTotals !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({ totals: { ...config.totals, showRowGrandTotals: 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?.showColumnGrandTotals !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
|
<Label className="text-xs">줄무늬</Label>
|
|
<Switch
|
|
checked={config.style?.alternateRowColors !== false}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({ style: { ...config.style, alternateRowColors: v } })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between p-2 rounded-md bg-muted/30">
|
|
<Label className="text-xs">CSV 내보내기</Label>
|
|
<Switch
|
|
checked={config.exportConfig?.excel === true}
|
|
onCheckedChange={(v) =>
|
|
updateConfig({ exportConfig: { ...config.exportConfig, excel: v } })
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 크기 설정 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium text-muted-foreground">크기 설정</Label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-xs">높이</Label>
|
|
<Input
|
|
value={config.height || ""}
|
|
onChange={(e) => updateConfig({ height: e.target.value })}
|
|
placeholder="400px"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">최대 높이</Label>
|
|
<Input
|
|
value={config.maxHeight || ""}
|
|
onChange={(e) => updateConfig({ maxHeight: e.target.value })}
|
|
placeholder="600px"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PivotGridConfigPanel;
|