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

752 lines
22 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}>
<div className="flex items-center gap-2">
<span>{col.column_name}</span>
{col.column_comment && (
<span className="text-muted-foreground">
({col.column_comment})
</span>
)}
</div>
</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 {
const response = await apiClient.get("/api/table-management/list");
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 {
const response = await apiClient.get(
`/api/table-management/columns/${config.dataSource.tableName}`
);
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}>
<div className="flex items-center gap-2">
<span>{table.table_name}</span>
{table.table_comment && (
<span className="text-muted-foreground">
({table.table_comment})
</span>
)}
</div>
</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="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;