Enhance dashboard with customizable widgets and data visualization. Adds drag-and-drop layout, data export options, and improved data fetching.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/8f36e120-5301-4443-89d0-23e9edfc5120.jpg
This commit is contained in:
alphaeusmote
2025-04-10 00:18:23 +00:00
parent c7cf7059d8
commit 499076dcf9
10 changed files with 2482 additions and 112 deletions
@@ -0,0 +1,363 @@
import { useState, useEffect } from "react";
import { Responsive, WidthProvider } from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import { DashboardWidget, WidgetProps } from "./dashboard-widget";
import { WidgetEditor, WidgetFormValues } from "./widget-editor";
import { Button } from "@/components/ui/button";
import { Plus, Save, Download, Upload } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { saveAs } from "file-saver";
const ResponsiveGridLayout = WidthProvider(Responsive);
// Default layout configuration
const DEFAULT_LAYOUTS = {
lg: [], // Large devices
md: [], // Medium devices
sm: [], // Small devices
xs: [], // Extra small devices
xxs: [], // Mobile devices
};
export interface DashboardConfig {
id: string;
name: string;
layouts: any;
widgets: WidgetProps[];
}
interface DashboardLayoutProps {
config?: DashboardConfig;
onSave?: (config: DashboardConfig) => void;
isEditable?: boolean;
dataSources: Array<{ id: string; name: string; data: any[]; fields: Array<{ name: string; type: string }> }>;
}
export function DashboardLayout({
config,
onSave,
isEditable = true,
dataSources
}: DashboardLayoutProps) {
const { toast } = useToast();
const [layouts, setLayouts] = useState(config?.layouts || DEFAULT_LAYOUTS);
const [widgets, setWidgets] = useState<WidgetProps[]>(config?.widgets || []);
const [isWidgetEditorOpen, setIsWidgetEditorOpen] = useState(false);
const [editingWidget, setEditingWidget] = useState<WidgetFormValues | undefined>(undefined);
const [dashboardName, setDashboardName] = useState(config?.name || "New Dashboard");
const [availableFields, setAvailableFields] = useState<Array<{ name: string; type: string }>>([]);
// When data source changes, update available fields
useEffect(() => {
if (editingWidget?.dataSource) {
const source = dataSources.find(s => s.id === editingWidget.dataSource);
if (source) {
setAvailableFields(source.fields);
} else {
setAvailableFields([]);
}
} else {
setAvailableFields([]);
}
}, [editingWidget?.dataSource, dataSources]);
// Handle layout changes (resizing, moving widgets)
const handleLayoutChange = (currentLayout: any, allLayouts: any) => {
setLayouts(allLayouts);
};
// Add a new widget
const handleAddWidget = () => {
setEditingWidget(undefined);
setIsWidgetEditorOpen(true);
};
// Edit an existing widget
const handleEditWidget = (widgetId: string) => {
const widget = widgets.find(w => w.id === widgetId);
if (widget) {
// Convert widget props to form values
const formValues: WidgetFormValues = {
id: widget.id,
title: widget.title,
type: widget.type,
dataSource: widget.data[0]?.dataSource || "",
config: {
xAxis: widget.config.xAxis,
yAxis: Array.isArray(widget.config.yAxis) ? widget.config.yAxis : [widget.config.yAxis].filter(Boolean),
dimensions: widget.config.dimensions || [],
metrics: widget.config.metrics || [],
showLegend: widget.config.showLegend ?? true,
stacked: widget.config.stacked ?? false,
precision: widget.config.precision ?? 2,
},
};
setEditingWidget(formValues);
setIsWidgetEditorOpen(true);
}
};
// Delete a widget
const handleDeleteWidget = (widgetId: string) => {
setWidgets(widgets.filter(w => w.id !== widgetId));
// Also remove from layouts
Object.keys(layouts).forEach(breakpoint => {
layouts[breakpoint] = layouts[breakpoint].filter((item: any) => item.i !== widgetId);
});
setLayouts({...layouts});
toast({
title: "Widget Deleted",
description: "The widget has been removed from the dashboard.",
});
};
// Save widget from editor
const handleSaveWidget = (values: WidgetFormValues) => {
const dataSource = dataSources.find(ds => ds.id === values.dataSource);
if (!dataSource) {
toast({
title: "Error",
description: "Selected data source not found.",
variant: "destructive",
});
return;
}
const widgetId = values.id || `widget-${Date.now().toString(36)}`;
// Create or update widget
const newWidget: WidgetProps = {
id: widgetId,
title: values.title,
type: values.type,
data: dataSource.data,
config: {
xAxis: values.config.xAxis || "",
yAxis: values.config.yAxis || [],
dimensions: values.config.dimensions || [],
metrics: values.config.metrics || [],
showLegend: values.config.showLegend,
stacked: values.config.stacked,
precision: values.config.precision,
colors: ['#8884d8', '#82ca9d', '#ffc658', '#ff8042', '#0088fe'],
},
onEdit: handleEditWidget,
onDelete: handleDeleteWidget,
};
if (values.id) {
// Update existing widget
setWidgets(widgets.map(w => w.id === values.id ? newWidget : w));
toast({
title: "Widget Updated",
description: "The widget has been updated successfully.",
});
} else {
// Add new widget and position it on the layout
setWidgets([...widgets, newWidget]);
// Add to layout at the bottom
const newItem = {
i: widgetId,
x: 0,
y: Infinity, // This puts it at the bottom
w: 6, // Half width by default
h: 4, // Default height
minW: 2, // Minimum width
minH: 2, // Minimum height
};
Object.keys(layouts).forEach(breakpoint => {
if (!layouts[breakpoint]) layouts[breakpoint] = [];
layouts[breakpoint].push(newItem);
});
setLayouts({...layouts});
toast({
title: "Widget Added",
description: "The new widget has been added to the dashboard.",
});
}
};
// Save the dashboard configuration
const handleSaveDashboard = () => {
if (onSave) {
const dashboardConfig: DashboardConfig = {
id: config?.id || `dashboard-${Date.now().toString(36)}`,
name: dashboardName,
layouts,
widgets,
};
onSave(dashboardConfig);
toast({
title: "Dashboard Saved",
description: "Your dashboard configuration has been saved.",
});
}
};
// Export dashboard configuration
const handleExportDashboard = () => {
const dashboardConfig: DashboardConfig = {
id: config?.id || `dashboard-${Date.now().toString(36)}`,
name: dashboardName,
layouts,
widgets,
};
const blob = new Blob([JSON.stringify(dashboardConfig, null, 2)], {
type: "application/json",
});
saveAs(blob, `${dashboardName.replace(/\s+/g, '_')}_dashboard.json`);
toast({
title: "Dashboard Exported",
description: "Your dashboard configuration has been exported as JSON.",
});
};
// Import dashboard configuration
const handleImportDashboard = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
try {
const importedConfig = JSON.parse(event.target?.result as string) as DashboardConfig;
setDashboardName(importedConfig.name);
setLayouts(importedConfig.layouts);
setWidgets(importedConfig.widgets.map(w => ({
...w,
onEdit: handleEditWidget,
onDelete: handleDeleteWidget,
})));
toast({
title: "Dashboard Imported",
description: "The dashboard configuration has been imported successfully.",
});
} catch (error) {
toast({
title: "Import Error",
description: "Failed to import dashboard configuration. Invalid file format.",
variant: "destructive",
});
}
};
reader.readAsText(file);
}
};
input.click();
};
return (
<div className="dashboard-container">
{isEditable && (
<div className="flex justify-between items-center mb-4">
<input
type="text"
value={dashboardName}
onChange={(e) => setDashboardName(e.target.value)}
className="text-2xl font-bold bg-transparent border-none focus:outline-none focus:ring-0 px-0"
/>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={handleImportDashboard}
className="flex items-center gap-1"
>
<Upload className="h-4 w-4" />
Import
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExportDashboard}
className="flex items-center gap-1"
>
<Download className="h-4 w-4" />
Export
</Button>
<Button
variant="outline"
size="sm"
onClick={handleAddWidget}
className="flex items-center gap-1"
>
<Plus className="h-4 w-4" />
Add Widget
</Button>
<Button
variant="default"
size="sm"
onClick={handleSaveDashboard}
className="flex items-center gap-1"
>
<Save className="h-4 w-4" />
Save Dashboard
</Button>
</div>
</div>
)}
{!isEditable && (
<h2 className="text-2xl font-bold mb-4">{dashboardName}</h2>
)}
<div className="dashboard-grid">
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
onLayoutChange={handleLayoutChange}
isDraggable={isEditable}
isResizable={isEditable}
isBounded={true}
>
{widgets.map((widget) => (
<div key={widget.id}>
<DashboardWidget
id={widget.id}
title={widget.title}
type={widget.type}
data={widget.data}
config={widget.config}
onEdit={isEditable ? handleEditWidget : () => {}}
onDelete={isEditable ? handleDeleteWidget : () => {}}
/>
</div>
))}
</ResponsiveGridLayout>
</div>
{/* Widget Editor Dialog */}
<WidgetEditor
isOpen={isWidgetEditorOpen}
onClose={() => setIsWidgetEditorOpen(false)}
onSave={handleSaveWidget}
editWidget={editingWidget}
availableFields={availableFields}
availableDataSources={dataSources.map(ds => ({ id: ds.id, name: ds.name }))}
/>
</div>
);
}
@@ -0,0 +1,303 @@
import { useState, useEffect, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Settings, Trash2, Maximize2, Download } from "lucide-react";
import { AreaChart, Area, BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { exportToExcel, exportToPDF, exportToCSV } from "../../lib/export-utils";
// Type for dataKey to work around TypeScript restrictions
type DataKeyType = string | number | ((obj: any) => any);
export interface DataPoint {
[key: string]: any;
}
export interface WidgetProps {
id: string;
title: string;
type: "bar" | "line" | "pie" | "area" | "table" | "number";
data: DataPoint[];
config: {
xAxis?: string;
yAxis?: string | string[];
dimensions?: string[];
metrics?: string[];
colors?: string[];
showLegend?: boolean;
stacked?: boolean;
precision?: number;
};
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
const COLORS = [
"#8884d8", "#82ca9d", "#ffc658", "#ff8042", "#0088fe",
"#00C49F", "#FFBB28", "#FF8042", "#a4de6c", "#d0ed57"
];
export function DashboardWidget({ id, title, type, data, config, onEdit, onDelete }: WidgetProps) {
const [expanded, setExpanded] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
// Format data for number type
const calculateNumber = () => {
if (!data || data.length === 0 || !config.metrics || config.metrics.length === 0) return 0;
const metric = config.metrics[0];
let value = 0;
// Sum all values for the metric
data.forEach(item => {
if (item[metric] !== undefined && !isNaN(Number(item[metric]))) {
value += Number(item[metric]);
}
});
// Apply precision if specified
if (config.precision !== undefined) {
return value.toFixed(config.precision);
}
return value;
};
const handleExport = (format: 'excel' | 'pdf' | 'csv') => {
switch (format) {
case 'excel':
exportToExcel(data, title);
break;
case 'pdf':
exportToPDF(data, title, config);
break;
case 'csv':
exportToCSV(data, title);
break;
}
};
// Render a table
const renderTable = () => {
if (!data || data.length === 0) return <p className="text-muted-foreground text-center py-4">No data available</p>;
// Determine columns from first data point and config
const columns = Object.keys(data[0])
.filter(key => {
if (config.dimensions && config.metrics) {
return [...config.dimensions, ...config.metrics].includes(key);
}
return true;
});
return (
<div className="overflow-x-auto max-h-[300px] overflow-y-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted">
<tr>
{columns.map((column) => (
<th
key={column}
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
{column}
</th>
))}
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{data.map((item, idx) => (
<tr key={idx} className={idx % 2 === 0 ? "bg-background" : "bg-card"}>
{columns.map((column) => (
<td key={column} className="px-3 py-2 text-sm">
{item[column] !== undefined ? String(item[column]) : '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
// Render different chart types
const renderChart = () => {
if (!data || data.length === 0) return <p className="text-muted-foreground text-center py-4">No data available</p>;
const { xAxis, yAxis, dimensions, metrics, colors = COLORS, showLegend = true, stacked = false } = config;
switch (type) {
case 'bar':
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 30 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={xAxis} />
<YAxis />
<Tooltip />
{showLegend && <Legend />}
{Array.isArray(yAxis) && yAxis.length > 0
? yAxis.map((axis, index) => (
<Bar
key={axis}
dataKey={axis}
stackId={stacked ? "a" : undefined}
fill={colors[index % colors.length]}
/>
))
: yAxis ? <Bar dataKey={yAxis} fill={colors[0]} /> : null
}
</BarChart>
</ResponsiveContainer>
);
case 'line':
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 30 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={xAxis} />
<YAxis />
<Tooltip />
{showLegend && <Legend />}
{Array.isArray(yAxis) && yAxis.length > 0
? yAxis.map((axis, index) => (
<Line
key={axis}
type="monotone"
dataKey={axis}
stroke={colors[index % colors.length]}
activeDot={{ r: 8 }}
/>
))
: yAxis ? <Line type="monotone" dataKey={yAxis} stroke={colors[0]} activeDot={{ r: 8 }} /> : null
}
</LineChart>
</ResponsiveContainer>
);
case 'area':
return (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 30 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={xAxis} />
<YAxis />
<Tooltip />
{showLegend && <Legend />}
{Array.isArray(yAxis) && yAxis.length > 0
? yAxis.map((axis, index) => (
<Area
key={axis}
type="monotone"
dataKey={axis}
stackId={stacked ? "a" : `${index}`}
fill={colors[index % colors.length]}
stroke={colors[index % colors.length]}
/>
))
: yAxis ? <Area type="monotone" dataKey={yAxis} fill={colors[0]} stroke={colors[0]} /> : null
}
</AreaChart>
</ResponsiveContainer>
);
case 'pie':
// For pie charts, we need to process the data differently
const pieData = data.map(item => {
const dimensionField = dimensions?.[0] || xAxis || "name";
const metricField = Array.isArray(metrics) && metrics.length > 0 ? metrics[0] :
(Array.isArray(yAxis) && yAxis.length > 0 ? yAxis[0] :
(typeof yAxis === 'string' ? yAxis : "value"));
const name = item[dimensionField];
const value = item[metricField];
return { name, value: Number(value) };
}).filter(item => !isNaN(item.value));
return (
<ResponsiveContainer width="100%" height={300}>
<PieChart margin={{ top: 20, right: 30, left: 20, bottom: 30 }}>
<Pie
data={pieData}
cx="50%"
cy="50%"
labelLine={true}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
<Tooltip formatter={(value) => [`${value}`, ""]} />
{showLegend && <Legend />}
</PieChart>
</ResponsiveContainer>
);
case 'number':
return (
<div className="flex justify-center items-center h-[300px]">
<div className="text-center">
<h3 className="text-5xl font-bold">{calculateNumber()}</h3>
{config.metrics && config.metrics.length > 0 && (
<p className="text-muted-foreground mt-4">{config.metrics[0]}</p>
)}
</div>
</div>
);
case 'table':
default:
return renderTable();
}
};
return (
<Card className={`shadow-sm hover:shadow-md transition-shadow ${expanded ? 'fixed inset-4 z-50 overflow-auto' : ''}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-md font-medium">{title}</CardTitle>
<div className="flex space-x-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleExport('excel')}
title="Export to Excel"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setExpanded(!expanded)}
title={expanded ? "Minimize" : "Maximize"}
>
<Maximize2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
title="Edit Widget"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
title="Delete Widget"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent ref={contentRef}>
{renderChart()}
</CardContent>
</Card>
);
}
@@ -0,0 +1,670 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import { Checkbox } from "@/components/ui/checkbox";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { X, Plus } from "lucide-react";
const chartTypeOptions = [
{ value: "bar", label: "Bar Chart" },
{ value: "line", label: "Line Chart" },
{ value: "area", label: "Area Chart" },
{ value: "pie", label: "Pie Chart" },
{ value: "table", label: "Table" },
{ value: "number", label: "Number (Sum)" },
];
const widgetSchema = z.object({
id: z.string().optional(),
title: z.string().min(1, "Title is required"),
type: z.enum(["bar", "line", "area", "pie", "table", "number"]),
dataSource: z.string().min(1, "Data source is required"),
config: z.object({
xAxis: z.string().optional(),
yAxis: z.array(z.string()).optional(),
dimensions: z.array(z.string()).optional(),
metrics: z.array(z.string()).optional(),
showLegend: z.boolean().default(true),
stacked: z.boolean().default(false),
precision: z.number().min(0).max(10).default(2),
}),
});
export type WidgetFormValues = z.infer<typeof widgetSchema>;
interface WidgetEditorProps {
isOpen: boolean;
onClose: () => void;
onSave: (values: WidgetFormValues) => void;
editWidget?: WidgetFormValues;
availableFields: Array<{ name: string; type: string }>;
availableDataSources: Array<{ id: string; name: string }>;
}
export function WidgetEditor({
isOpen,
onClose,
onSave,
editWidget,
availableFields,
availableDataSources
}: WidgetEditorProps) {
const [selectedFields, setSelectedFields] = useState<string[]>([]);
const form = useForm<WidgetFormValues>({
resolver: zodResolver(widgetSchema),
defaultValues: {
id: editWidget?.id || undefined,
title: editWidget?.title || "",
type: editWidget?.type || "table",
dataSource: editWidget?.dataSource || "",
config: {
xAxis: editWidget?.config?.xAxis || "",
yAxis: editWidget?.config?.yAxis || [],
dimensions: editWidget?.config?.dimensions || [],
metrics: editWidget?.config?.metrics || [],
showLegend: editWidget?.config?.showLegend ?? true,
stacked: editWidget?.config?.stacked ?? false,
precision: editWidget?.config?.precision ?? 2,
},
},
});
// Reset form when edit widget changes
useEffect(() => {
if (editWidget) {
form.reset({
id: editWidget.id,
title: editWidget.title,
type: editWidget.type,
dataSource: editWidget.dataSource,
config: {
xAxis: editWidget.config.xAxis || "",
yAxis: editWidget.config.yAxis || [],
dimensions: editWidget.config.dimensions || [],
metrics: editWidget.config.metrics || [],
showLegend: editWidget.config.showLegend ?? true,
stacked: editWidget.config.stacked ?? false,
precision: editWidget.config.precision ?? 2,
},
});
// Also update selected fields
const fields = [
...(editWidget.config.dimensions || []),
...(editWidget.config.metrics || []),
];
setSelectedFields(fields);
} else {
form.reset({
id: undefined,
title: "",
type: "table",
dataSource: "",
config: {
xAxis: "",
yAxis: [],
dimensions: [],
metrics: [],
showLegend: true,
stacked: false,
precision: 2,
},
});
setSelectedFields([]);
}
}, [editWidget, form]);
const watchType = form.watch("type");
const watchDataSource = form.watch("dataSource");
const handleSubmit = (values: WidgetFormValues) => {
onSave(values);
onClose();
};
const addField = (field: string) => {
if (!selectedFields.includes(field)) {
setSelectedFields([...selectedFields, field]);
// Automatically categorize the field as dimension or metric based on its type
const fieldInfo = availableFields.find(f => f.name === field);
if (fieldInfo) {
// Numbers and dates are typically metrics, other types are dimensions
const isMetric = ['number', 'integer', 'float', 'date', 'datetime'].includes(fieldInfo.type.toLowerCase());
if (isMetric) {
const currentMetrics = form.getValues('config.metrics') || [];
form.setValue('config.metrics', [...currentMetrics, field]);
} else {
const currentDimensions = form.getValues('config.dimensions') || [];
form.setValue('config.dimensions', [...currentDimensions, field]);
// If no X-axis is set and this is a dimension, use it as X-axis
if (!form.getValues('config.xAxis')) {
form.setValue('config.xAxis', field);
}
}
}
}
};
const removeField = (field: string) => {
setSelectedFields(selectedFields.filter(f => f !== field));
// Also remove from config
const currentDimensions = form.getValues('config.dimensions') || [];
const currentMetrics = form.getValues('config.metrics') || [];
const currentYAxis = form.getValues('config.yAxis') || [];
form.setValue('config.dimensions', currentDimensions.filter(d => d !== field));
form.setValue('config.metrics', currentMetrics.filter(m => m !== field));
form.setValue('config.yAxis', currentYAxis.filter(y => y !== field));
// If this was the X-axis, clear it
if (form.getValues('config.xAxis') === field) {
form.setValue('config.xAxis', '');
}
};
const isFieldDimension = (field: string) => {
const dimensions = form.getValues('config.dimensions') || [];
return dimensions.includes(field);
};
const toggleFieldType = (field: string) => {
const dimensions = form.getValues('config.dimensions') || [];
const metrics = form.getValues('config.metrics') || [];
if (dimensions.includes(field)) {
// Move from dimension to metric
form.setValue('config.dimensions', dimensions.filter(d => d !== field));
form.setValue('config.metrics', [...metrics, field]);
// If this was the X-axis, clear it
if (form.getValues('config.xAxis') === field) {
form.setValue('config.xAxis', '');
}
} else if (metrics.includes(field)) {
// Move from metric to dimension
form.setValue('config.metrics', metrics.filter(m => m !== field));
form.setValue('config.dimensions', [...dimensions, field]);
}
};
const setAsXAxis = (field: string) => {
form.setValue('config.xAxis', field);
};
const toggleAsYAxis = (field: string) => {
const currentYAxis = form.getValues('config.yAxis') || [];
if (currentYAxis.includes(field)) {
form.setValue('config.yAxis', currentYAxis.filter(y => y !== field));
} else {
form.setValue('config.yAxis', [...currentYAxis, field]);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[650px]">
<DialogHeader>
<DialogTitle>{editWidget ? "Edit Widget" : "Add New Widget"}</DialogTitle>
<DialogDescription>
Configure your dashboard widget. Select data source, chart type, and fields to display.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Widget Title</FormLabel>
<FormControl>
<Input placeholder="Widget Title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Chart Type</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select chart type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{chartTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dataSource"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Data Source</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select data source" />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableDataSources.map((source) => (
<SelectItem key={source.id} value={source.id}>
{source.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select the data source for this widget
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{watchDataSource && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-medium mb-2">Available Fields</h3>
<div className="border rounded-md p-2 max-h-40 overflow-y-auto">
{availableFields
.filter(field => !selectedFields.includes(field.name))
.map(field => (
<div
key={field.name}
className="flex justify-between items-center p-1 hover:bg-muted rounded cursor-pointer"
onClick={() => addField(field.name)}
>
<span className="text-sm">{field.name}</span>
<span className="text-xs text-muted-foreground">{field.type}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
addField(field.name);
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-2">Selected Fields</h3>
<div className="border rounded-md p-2 max-h-40 overflow-y-auto">
{selectedFields.length === 0 ? (
<div className="text-sm text-muted-foreground p-1">
No fields selected
</div>
) : (
selectedFields.map(field => (
<div
key={field}
className="flex justify-between items-center p-1 hover:bg-muted rounded"
>
<span className="text-sm">{field}</span>
<div className="flex space-x-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => toggleFieldType(field)}
className="h-6 text-xs"
>
{isFieldDimension(field) ? 'Dimension' : 'Metric'}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(field)}
className="h-6 w-6"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
))
)}
</div>
</div>
</div>
<Accordion type="single" collapsible defaultValue="chart-options">
<AccordionItem value="chart-options">
<AccordionTrigger>Chart Options</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
{["bar", "line", "area"].includes(watchType) && (
<div className="space-y-2">
<FormField
control={form.control}
name="config.xAxis"
render={({ field }) => (
<FormItem>
<FormLabel>X-Axis Field</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select X-Axis field" />
</SelectTrigger>
</FormControl>
<SelectContent>
{(form.getValues('config.dimensions') || []).map((dimension) => (
<SelectItem key={dimension} value={dimension}>
{dimension}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose a dimension field for the X-Axis
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>Y-Axis Fields</FormLabel>
<div className="space-y-2 mt-2">
{(form.getValues('config.metrics') || []).map((metric) => (
<div key={metric} className="flex items-center space-x-2">
<Checkbox
checked={(form.getValues('config.yAxis') || []).includes(metric)}
onCheckedChange={(checked) => {
if (checked) {
toggleAsYAxis(metric);
} else {
toggleAsYAxis(metric);
}
}}
id={`y-axis-${metric}`}
/>
<label
htmlFor={`y-axis-${metric}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{metric}
</label>
</div>
))}
</div>
<FormDescription>
Select metric fields to display on the Y-Axis
</FormDescription>
</div>
<FormField
control={form.control}
name="config.stacked"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm">Stacked Chart</FormLabel>
<FormDescription>
Stack the values on top of each other
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
)}
{watchType === "pie" && (
<div className="space-y-4">
<FormField
control={form.control}
name="config.dimensions"
render={({ field }) => (
<FormItem>
<FormLabel>Label Field</FormLabel>
<Select
value={field.value?.[0] || ""}
onValueChange={(value) => {
// For pie charts we only want one dimension
field.onChange([value]);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select label field" />
</SelectTrigger>
</FormControl>
<SelectContent>
{selectedFields.map((fieldName) => (
<SelectItem key={fieldName} value={fieldName}>
{fieldName}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose a field for the pie chart labels
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.metrics"
render={({ field }) => (
<FormItem>
<FormLabel>Value Field</FormLabel>
<Select
value={field.value?.[0] || ""}
onValueChange={(value) => {
// For pie charts we only want one metric
field.onChange([value]);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select value field" />
</SelectTrigger>
</FormControl>
<SelectContent>
{selectedFields.map((fieldName) => (
<SelectItem key={fieldName} value={fieldName}>
{fieldName}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose a field for the pie chart values
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{watchType === "number" && (
<div className="space-y-4">
<FormField
control={form.control}
name="config.metrics"
render={({ field }) => (
<FormItem>
<FormLabel>Metric to Sum</FormLabel>
<Select
value={field.value?.[0] || ""}
onValueChange={(value) => {
// For number widgets we only want one metric
field.onChange([value]);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select metric to sum" />
</SelectTrigger>
</FormControl>
<SelectContent>
{selectedFields.map((fieldName) => (
<SelectItem key={fieldName} value={fieldName}>
{fieldName}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose a field to sum
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.precision"
render={({ field }) => (
<FormItem>
<FormLabel>Decimal Precision: {field.value}</FormLabel>
<FormControl>
<Slider
min={0}
max={10}
step={1}
defaultValue={[field.value]}
onValueChange={(values) => field.onChange(values[0])}
/>
</FormControl>
<FormDescription>
Number of decimal places to display
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{["bar", "line", "area", "pie"].includes(watchType) && (
<FormField
control={form.control}
name="config.showLegend"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm">Show Legend</FormLabel>
<FormDescription>
Display a legend for the chart
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={!watchDataSource}>
{editWidget ? "Save Changes" : "Add Widget"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}