ERP-node/frontend/components/v2/config-panels/V2PivotGridConfigPanel.tsx

804 lines
33 KiB
TypeScript
Raw Normal View History

"use client";
/**
* V2
* UX: 테이블 (Combobox) -> (AreaDropZone) -> (Collapsible)
*/
import React, { useState, useEffect, useCallback } from "react";
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import {
Rows, Columns, Calculator, X, Plus, GripVertical,
Check, ChevronsUpDown, ChevronDown, ChevronUp,
Settings, Database, Info,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import type {
PivotGridComponentConfig,
PivotFieldConfig,
PivotAreaType,
AggregationType,
FieldDataType,
ConditionalFormatRule,
} from "@/lib/registry/components/v2-pivot-grid/types";
interface TableInfo {
tableName: string;
displayName: string;
}
interface ColumnInfo {
column_name: string;
data_type: string;
column_comment?: string;
}
interface V2PivotGridConfigPanelProps {
config: PivotGridComponentConfig;
onChange: (config: PivotGridComponentConfig) => void;
}
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 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 cursor-pointer items-center justify-between"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
{icon}
<span className="text-sm font-medium">{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="mt-1 text-xs text-muted-foreground">{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 rounded-md border bg-background px-2 py-1.5"
>
<GripVertical className="h-3 w-3 text-muted-foreground" />
<span className="flex-1 truncate text-xs font-medium">
{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="rounded-md border border-dashed py-2 text-center text-xs text-muted-foreground">
</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="mr-1 h-3 w-3" />
<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-xs text-muted-foreground">
({mapDbTypeToFieldType(col.data_type)})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
</div>
);
};
const STYLE_DEFAULTS: { theme: "default"; headerStyle: "default"; cellPadding: "normal"; borderStyle: "light" } = {
theme: "default",
headerStyle: "default",
cellPadding: "normal",
borderStyle: "light",
};
/* ─── 메인 컴포넌트 ─── */
export const V2PivotGridConfigPanel: React.FC<V2PivotGridConfigPanelProps> = ({
config,
onChange,
}) => {
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
const [tableOpen, setTableOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const tableList = await tableTypeApi.getTables();
setTables(
tableList.map((t: any) => ({
tableName: t.tableName,
displayName: t.tableLabel || t.displayName || t.tableName,
}))
);
} catch {
/* ignore */
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
useEffect(() => {
if (!config.dataSource?.tableName) {
setColumns([]);
return;
}
const loadColumns = async () => {
setLoadingColumns(true);
try {
const columnList = await tableTypeApi.getColumns(config.dataSource!.tableName!);
setColumns(
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,
}))
);
} catch {
/* ignore */
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [config.dataSource?.tableName]);
const updateConfig = useCallback(
(updates: Partial<PivotGridComponentConfig>) => {
const newConfig = { ...config, ...updates };
onChange(newConfig);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: newConfig },
})
);
}
},
[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) =>
f.area === area && f.areaIndex === index ? { ...f, ...updates } : f
);
updateConfig({ fields: newFields });
};
const getFieldsByArea = (area: PivotAreaType) =>
(config.fields || []).filter((f) => f.area === area).sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const handleTableChange = (tableName: string) => {
updateConfig({
dataSource: { ...config.dataSource, type: "table", tableName },
fields: [],
});
setTableOpen(false);
};
return (
<div className="space-y-4">
{/* ─── 안내 ─── */}
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
<div className="flex items-start gap-2">
<Info className="mt-0.5 h-4 w-4 text-primary" />
<div className="text-xs text-primary">
<p className="mb-1 font-medium"> </p>
<ol className="list-inside list-decimal space-y-0.5">
<li> <strong></strong> </li>
<li><strong> </strong> (: 지역, )</li>
<li><strong> </strong> (: , )</li>
<li><strong></strong> (: 매출, )</li>
</ol>
</div>
</div>
</div>
{/* ─── 1단계: 테이블 선택 ─── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium"> </p>
</div>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Popover open={tableOpen} onOpenChange={setTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
{loadingTables
? "로딩 중..."
: config.dataSource?.tableName
? tables.find((t) => t.tableName === config.dataSource?.tableName)?.displayName ||
config.dataSource.tableName
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-4 text-center text-xs">
.
</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => handleTableChange(table.tableName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.dataSource?.tableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-muted-foreground">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* ─── 2단계: 필드 배치 ─── */}
{config.dataSource?.tableName && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Rows className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium"> </p>
{loadingColumns && (
<span className="text-[11px] text-muted-foreground">( ...)</span>
)}
</div>
<p className="text-[11px] text-muted-foreground">
</p>
<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-primary" />}
fields={getFieldsByArea("column")}
columns={columns}
onAddField={(col) => handleAddField("column", col)}
onRemoveField={(idx) => handleRemoveField("column", idx)}
onUpdateField={(idx, updates) => handleUpdateField("column", idx, updates)}
color="border-primary/20 bg-primary/5"
/>
<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>
)}
{/* ─── 3단계: 고급 설정 (Collapsible) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-4 rounded-b-lg border border-t-0 p-4">
{/* 총계 설정 */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"> </p>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
<span className="text-xs"> </span>
<Switch
checked={config.totals?.showRowGrandTotals !== false}
onCheckedChange={(v) =>
updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } })
}
/>
</div>
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
<span className="text-xs"> </span>
<Switch
checked={config.totals?.showColumnGrandTotals !== false}
onCheckedChange={(v) =>
updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } })
}
/>
</div>
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
<span className="text-xs"> </span>
<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 rounded-md bg-muted/30 p-2">
<span className="text-xs"> </span>
<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 rounded-md bg-muted/30 p-2">
<span className="text-xs"> </span>
<Switch
checked={config.totals?.showRowTotals !== false}
onCheckedChange={(v) =>
updateConfig({ totals: { ...config.totals, showRowTotals: v } })
}
/>
</div>
<div className="flex items-center justify-between rounded-md bg-muted/30 p-2">
<span className="text-xs"> </span>
<Switch
checked={config.totals?.showColumnTotals !== false}
onCheckedChange={(v) =>
updateConfig({ totals: { ...config.totals, showColumnTotals: v } })
}
/>
</div>
</div>
</div>
{/* 스타일 설정 */}
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground"> </p>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.style?.alternateRowColors !== false}
onCheckedChange={(v) =>
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, alternateRowColors: v } })
}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.style?.mergeCells === true}
onCheckedChange={(v) =>
updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, mergeCells: v } })
}
/>
</div>
</div>
{/* 크기 설정 */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"> </p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[11px] text-muted-foreground"></span>
<Input
value={config.height || ""}
onChange={(e) => updateConfig({ height: e.target.value })}
placeholder="400px"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<span className="text-[11px] text-muted-foreground"> </span>
<Input
value={config.maxHeight || ""}
onChange={(e) => updateConfig({ maxHeight: e.target.value })}
placeholder="600px"
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* 기능 설정 */}
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground"> </p>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm">CSV </p>
<p className="text-[11px] text-muted-foreground"> CSV </p>
</div>
<Switch
checked={config.exportConfig?.excel === true}
onCheckedChange={(v) =>
updateConfig({ exportConfig: { ...config.exportConfig, excel: v } })
}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> /</p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.allowExpandAll !== false}
onCheckedChange={(v) => updateConfig({ allowExpandAll: v })}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.allowFiltering !== false}
onCheckedChange={(v) => updateConfig({ allowFiltering: v })}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.allowSortingBySummary !== false}
onCheckedChange={(v) => updateConfig({ allowSortingBySummary: v })}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.wordWrapEnabled === true}
onCheckedChange={(v) => updateConfig({ wordWrapEnabled: v })}
/>
</div>
</div>
{/* 조건부 서식 */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"> </p>
<div className="space-y-2">
{(config.style?.conditionalFormats || []).map((rule, index) => (
<div key={rule.id} className="flex items-center gap-2 rounded-md bg-muted/30 p-2">
<Select
value={rule.type}
onValueChange={(v) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = { ...rule, type: v as ConditionalFormatRule["type"] };
updateConfig({ style: { ...STYLE_DEFAULTS, ...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: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
className="h-6 w-6 cursor-pointer rounded"
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: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
className="h-6 w-6 cursor-pointer rounded"
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: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
className="h-6 w-6 cursor-pointer rounded"
title="바 색상"
/>
)}
{rule.type === "iconSet" && (
<Select
value={rule.iconSet?.type || "traffic"}
onValueChange={(v) => {
const newFormats = [...(config.style?.conditionalFormats || [])];
newFormats[index] = { ...rule, iconSet: { type: v as "arrows" | "traffic" | "rating" | "flags", thresholds: [33, 67] } };
updateConfig({ style: { ...STYLE_DEFAULTS, ...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="ml-auto h-6 w-6"
onClick={() => {
const newFormats = (config.style?.conditionalFormats || []).filter((_, i) => i !== index);
updateConfig({ style: { ...STYLE_DEFAULTS, ...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: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } });
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
export default V2PivotGridConfigPanel;