mirror of
https://github.com/freedbygrace/ActiveDirectoryManager.git
synced 2026-08-05 16:57:40 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user