ERP-node/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx

1008 lines
32 KiB
TypeScript

"use client";
/**
* PivotGrid 설정 패널
* 화면 관리에서 PivotGrid 컴포넌트를 설정하는 UI
*/
import React, { useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import {
PivotGridComponentConfig,
PivotFieldConfig,
PivotAreaType,
AggregationType,
DateGroupInterval,
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 { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Plus,
Trash2,
GripVertical,
Settings2,
Rows,
Columns,
Database,
Filter,
ChevronUp,
ChevronDown,
} from "lucide-react";
import { apiClient } from "@/lib/api/client";
// ==================== 타입 ====================
interface TableInfo {
table_name: string;
table_comment?: string;
}
interface ColumnInfo {
column_name: string;
data_type: string;
column_comment?: string;
is_nullable: string;
}
interface PivotGridConfigPanelProps {
config: PivotGridComponentConfig;
onChange: (config: PivotGridComponentConfig) => void;
}
// ==================== 유틸리티 ====================
const AREA_LABELS: Record<PivotAreaType, { label: string; icon: React.ReactNode }> = {
row: { label: "행 영역", icon: <Rows className="h-4 w-4" /> },
column: { label: "열 영역", icon: <Columns className="h-4 w-4" /> },
data: { label: "데이터 영역", icon: <Database className="h-4 w-4" /> },
filter: { label: "필터 영역", icon: <Filter className="h-4 w-4" /> },
};
const AGGREGATION_OPTIONS: { value: AggregationType; label: string }[] = [
{ value: "sum", label: "합계" },
{ value: "count", label: "개수" },
{ value: "avg", label: "평균" },
{ value: "min", label: "최소" },
{ value: "max", label: "최대" },
{ value: "countDistinct", label: "고유값 개수" },
];
const DATE_GROUP_OPTIONS: { value: DateGroupInterval; label: string }[] = [
{ value: "year", label: "연도" },
{ value: "quarter", label: "분기" },
{ value: "month", label: "월" },
{ value: "week", label: "주" },
{ value: "day", label: "일" },
];
const DATA_TYPE_OPTIONS: { value: FieldDataType; label: string }[] = [
{ value: "string", label: "문자열" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "boolean", label: "부울" },
];
// DB 타입을 FieldDataType으로 변환
function mapDbTypeToFieldType(dbType: string): FieldDataType {
const type = dbType.toLowerCase();
if (
type.includes("int") ||
type.includes("numeric") ||
type.includes("decimal") ||
type.includes("float") ||
type.includes("double") ||
type.includes("real")
) {
return "number";
}
if (
type.includes("date") ||
type.includes("time") ||
type.includes("timestamp")
) {
return "date";
}
if (type.includes("bool")) {
return "boolean";
}
return "string";
}
// ==================== 필드 설정 컴포넌트 ====================
interface FieldConfigItemProps {
field: PivotFieldConfig;
index: number;
onChange: (field: PivotFieldConfig) => void;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
isFirst: boolean;
isLast: boolean;
}
const FieldConfigItem: React.FC<FieldConfigItemProps> = ({
field,
index,
onChange,
onRemove,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}) => {
return (
<div className="flex items-start gap-2 p-2 rounded border border-border bg-background">
{/* 드래그 핸들 & 순서 버튼 */}
<div className="flex flex-col items-center gap-0.5 pt-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onMoveUp}
disabled={isFirst}
>
<ChevronUp className="h-3 w-3" />
</Button>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onMoveDown}
disabled={isLast}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* 필드 설정 */}
<div className="flex-1 space-y-2">
{/* 필드명 & 라벨 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-xs"></Label>
<Input
value={field.field}
onChange={(e) => onChange({ ...field, field: e.target.value })}
placeholder="column_name"
className="h-8 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-xs"> </Label>
<Input
value={field.caption}
onChange={(e) => onChange({ ...field, caption: e.target.value })}
placeholder="표시명"
className="h-8 text-xs"
/>
</div>
</div>
{/* 데이터 타입 & 집계 함수 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-xs"> </Label>
<Select
value={field.dataType || "string"}
onValueChange={(v) =>
onChange({ ...field, dataType: v as FieldDataType })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{field.area === "data" && (
<div className="flex-1">
<Label className="text-xs"> </Label>
<Select
value={field.summaryType || "sum"}
onValueChange={(v) =>
onChange({ ...field, summaryType: v as AggregationType })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGGREGATION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{field.dataType === "date" &&
(field.area === "row" || field.area === "column") && (
<div className="flex-1">
<Label className="text-xs"> </Label>
<Select
value={field.groupInterval || "__none__"}
onValueChange={(v) =>
onChange({
...field,
groupInterval:
v === "__none__" ? undefined : (v as DateGroupInterval),
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{DATE_GROUP_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
};
// ==================== 영역별 필드 목록 ====================
interface AreaFieldListProps {
area: PivotAreaType;
fields: PivotFieldConfig[];
allColumns: ColumnInfo[];
onFieldsChange: (fields: PivotFieldConfig[]) => void;
}
const AreaFieldList: React.FC<AreaFieldListProps> = ({
area,
fields,
allColumns,
onFieldsChange,
}) => {
const areaFields = fields.filter((f) => f.area === area);
const { label, icon } = AREA_LABELS[area];
const handleAddField = () => {
const newField: PivotFieldConfig = {
field: "",
caption: "",
area,
areaIndex: areaFields.length,
dataType: "string",
visible: true,
};
if (area === "data") {
newField.summaryType = "sum";
}
onFieldsChange([...fields, newField]);
};
const handleAddFromColumn = (column: ColumnInfo) => {
const dataType = mapDbTypeToFieldType(column.data_type);
const newField: PivotFieldConfig = {
field: column.column_name,
caption: column.column_comment || column.column_name,
area,
areaIndex: areaFields.length,
dataType,
visible: true,
};
if (area === "data") {
newField.summaryType = "sum";
}
onFieldsChange([...fields, newField]);
};
const handleFieldChange = (index: number, updatedField: PivotFieldConfig) => {
const newFields = [...fields];
const globalIndex = fields.findIndex(
(f) => f.area === area && f.areaIndex === index
);
if (globalIndex >= 0) {
newFields[globalIndex] = updatedField;
onFieldsChange(newFields);
}
};
const handleRemoveField = (index: number) => {
const newFields = fields.filter(
(f) => !(f.area === area && f.areaIndex === index)
);
// 인덱스 재정렬
let idx = 0;
newFields.forEach((f) => {
if (f.area === area) {
f.areaIndex = idx++;
}
});
onFieldsChange(newFields);
};
const handleMoveField = (fromIndex: number, direction: "up" | "down") => {
const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1;
if (toIndex < 0 || toIndex >= areaFields.length) return;
const newAreaFields = [...areaFields];
const [moved] = newAreaFields.splice(fromIndex, 1);
newAreaFields.splice(toIndex, 0, moved);
// 인덱스 재정렬
newAreaFields.forEach((f, idx) => {
f.areaIndex = idx;
});
// 전체 필드 업데이트
const newFields = fields.filter((f) => f.area !== area);
onFieldsChange([...newFields, ...newAreaFields]);
};
// 이미 추가된 컬럼 제외
const availableColumns = allColumns.filter(
(col) => !fields.some((f) => f.field === col.column_name)
);
return (
<AccordionItem value={area}>
<AccordionTrigger className="py-2">
<div className="flex items-center gap-2">
{icon}
<span>{label}</span>
<Badge variant="secondary" className="ml-2">
{areaFields.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-2 pt-2">
{/* 필드 목록 */}
{areaFields
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0))
.map((field, idx) => (
<FieldConfigItem
key={`${field.field}-${idx}`}
field={field}
index={field.areaIndex || idx}
onChange={(f) => handleFieldChange(field.areaIndex || idx, f)}
onRemove={() => handleRemoveField(field.areaIndex || idx)}
onMoveUp={() => handleMoveField(idx, "up")}
onMoveDown={() => handleMoveField(idx, "down")}
isFirst={idx === 0}
isLast={idx === areaFields.length - 1}
/>
))}
{/* 필드 추가 */}
<div className="flex gap-2">
<Select onValueChange={(v) => {
const col = allColumns.find(c => c.column_name === v);
if (col) handleAddFromColumn(col);
}}>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{availableColumns.length === 0 ? (
<SelectItem value="__none__" disabled>
</SelectItem>
) : (
availableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_comment
? `${col.column_name} (${col.column_comment})`
: col.column_name}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={handleAddField}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</AccordionContent>
</AccordionItem>
);
};
// ==================== 메인 컴포넌트 ====================
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);
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
// apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외
const response = await apiClient.get("/table-management/tables");
if (response.data.success) {
setTables(response.data.data || []);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.dataSource?.tableName) {
setColumns([]);
return;
}
setLoadingColumns(true);
try {
// apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외
const response = await apiClient.get(
`/table-management/tables/${config.dataSource.tableName}/columns`
);
if (response.data.success) {
setColumns(response.data.data || []);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [config.dataSource?.tableName]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
(updates: Partial<PivotGridComponentConfig>) => {
onChange({ ...config, ...updates });
},
[config, onChange]
);
return (
<div className="space-y-4">
{/* 데이터 소스 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.dataSource?.tableName || "__none__"}
onValueChange={(v) =>
updateConfig({
dataSource: {
...config.dataSource,
type: "table",
tableName: v === "__none__" ? undefined : v,
},
fields: [], // 테이블 변경 시 필드 초기화
})
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{tables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
{table.table_comment
? `${table.table_name} (${table.table_comment})`
: table.table_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Separator />
{/* 필드 설정 */}
{config.dataSource?.tableName && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"> </Label>
<Badge variant="outline">
{columns.length}
</Badge>
</div>
{loadingColumns ? (
<div className="text-sm text-muted-foreground">
...
</div>
) : (
<Accordion
type="multiple"
defaultValue={["row", "column", "data"]}
className="w-full"
>
{(["row", "column", "data", "filter"] as PivotAreaType[]).map(
(area) => (
<AreaFieldList
key={area}
area={area}
fields={config.fields || []}
allColumns={columns}
onFieldsChange={(fields) => updateConfig({ fields })}
/>
)
)}
</Accordion>
)}
</div>
)}
<Separator />
{/* 표시 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center justify-between">
<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">
<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">
<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">
<Label className="text-xs"> </Label>
<Switch
checked={config.totals?.showColumnTotals !== false}
onCheckedChange={(v) =>
updateConfig({
totals: { ...config.totals, showColumnTotals: v },
})
}
/>
</div>
</div>
<div className="flex items-center justify-between">
<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">
<Label className="text-xs"> </Label>
<Switch
checked={config.style?.highlightTotals !== false}
onCheckedChange={(v) =>
updateConfig({
style: { ...config.style, highlightTotals: v },
})
}
/>
</div>
</div>
<Separator />
{/* 기능 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> / </Label>
<Switch
checked={config.allowExpandAll !== false}
onCheckedChange={(v) =>
updateConfig({ allowExpandAll: v })
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">CSV </Label>
<Switch
checked={config.exportConfig?.excel === true}
onCheckedChange={(v) =>
updateConfig({
exportConfig: { ...config.exportConfig, excel: v },
})
}
/>
</div>
</div>
</div>
<Separator />
{/* 차트 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.chart?.enabled === true}
onCheckedChange={(v) =>
updateConfig({
chart: {
...config.chart,
enabled: v,
type: config.chart?.type || "bar",
position: config.chart?.position || "bottom",
},
})
}
/>
</div>
{config.chart?.enabled && (
<div className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select
value={config.chart?.type || "bar"}
onValueChange={(v) =>
updateConfig({
chart: {
...config.chart,
enabled: true,
type: v as "bar" | "line" | "area" | "pie" | "stackedBar",
position: config.chart?.position || "bottom",
},
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bar"> </SelectItem>
<SelectItem value="stackedBar"> </SelectItem>
<SelectItem value="line"> </SelectItem>
<SelectItem value="area"> </SelectItem>
<SelectItem value="pie"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={config.chart?.height || 300}
onChange={(e) =>
updateConfig({
chart: {
...config.chart,
enabled: true,
type: config.chart?.type || "bar",
position: config.chart?.position || "bottom",
height: Number(e.target.value),
},
})
}
className="h-8 text-xs"
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.chart?.showLegend !== false}
onCheckedChange={(v) =>
updateConfig({
chart: {
...config.chart,
enabled: true,
type: config.chart?.type || "bar",
position: config.chart?.position || "bottom",
showLegend: v,
},
})
}
/>
</div>
</div>
)}
</div>
</div>
<Separator />
{/* 필드 선택기 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.fieldChooser?.enabled !== false}
onCheckedChange={(v) =>
updateConfig({
fieldChooser: { ...config.fieldChooser, enabled: v },
})
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.fieldChooser?.allowSearch !== false}
onCheckedChange={(v) =>
updateConfig({
fieldChooser: { ...config.fieldChooser, allowSearch: v },
})
}
/>
</div>
</div>
</div>
<Separator />
{/* 조건부 서식 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">Color Scale ( )</Label>
<Switch
checked={
config.style?.conditionalFormats?.some(
(r) => r.type === "colorScale"
) || false
}
onCheckedChange={(v) => {
const existingFormats = config.style?.conditionalFormats || [];
const filtered = existingFormats.filter(
(r) => r.type !== "colorScale"
);
updateConfig({
style: {
...config.style,
theme: config.style?.theme || "default",
headerStyle: config.style?.headerStyle || "default",
cellPadding: config.style?.cellPadding || "normal",
borderStyle: config.style?.borderStyle || "light",
conditionalFormats: v
? [
...filtered,
{
id: "colorScale-1",
type: "colorScale" as const,
colorScale: {
minColor: "#ff6b6b",
midColor: "#ffd93d",
maxColor: "#6bcb77",
},
},
]
: filtered,
},
});
}}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">Data Bar ( )</Label>
<Switch
checked={
config.style?.conditionalFormats?.some(
(r) => r.type === "dataBar"
) || false
}
onCheckedChange={(v) => {
const existingFormats = config.style?.conditionalFormats || [];
const filtered = existingFormats.filter(
(r) => r.type !== "dataBar"
);
updateConfig({
style: {
...config.style,
theme: config.style?.theme || "default",
headerStyle: config.style?.headerStyle || "default",
cellPadding: config.style?.cellPadding || "normal",
borderStyle: config.style?.borderStyle || "light",
conditionalFormats: v
? [
...filtered,
{
id: "dataBar-1",
type: "dataBar" as const,
dataBar: {
color: "#3b82f6",
showValue: true,
},
},
]
: filtered,
},
});
}}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">Icon Set ()</Label>
<Switch
checked={
config.style?.conditionalFormats?.some(
(r) => r.type === "iconSet"
) || false
}
onCheckedChange={(v) => {
const existingFormats = config.style?.conditionalFormats || [];
const filtered = existingFormats.filter(
(r) => r.type !== "iconSet"
);
updateConfig({
style: {
...config.style,
theme: config.style?.theme || "default",
headerStyle: config.style?.headerStyle || "default",
cellPadding: config.style?.cellPadding || "normal",
borderStyle: config.style?.borderStyle || "light",
conditionalFormats: v
? [
...filtered,
{
id: "iconSet-1",
type: "iconSet" as const,
iconSet: {
type: "traffic",
thresholds: [33, 66],
},
},
]
: filtered,
},
});
}}
/>
</div>
{config.style?.conditionalFormats &&
config.style.conditionalFormats.length > 0 && (
<p className="text-xs text-muted-foreground">
{config.style.conditionalFormats.length}
</p>
)}
</div>
</div>
<Separator />
{/* 크기 설정 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input
value={config.height || ""}
onChange={(e) => updateConfig({ height: e.target.value })}
placeholder="auto 또는 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>
);
};
export default PivotGridConfigPanel;