mirror of
https://github.com/freedbygrace/ActiveDirectoryManager.git
synced 2026-08-20 23:33:05 +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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
|
||||||
|
export interface DataPoint {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportConfig {
|
||||||
|
xAxis?: string;
|
||||||
|
yAxis?: string | string[];
|
||||||
|
dimensions?: string[];
|
||||||
|
metrics?: string[];
|
||||||
|
colors?: string[];
|
||||||
|
showLegend?: boolean;
|
||||||
|
stacked?: boolean;
|
||||||
|
precision?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export data to Excel file
|
||||||
|
* @param data Array of data objects
|
||||||
|
* @param title Title of the export (used for filename)
|
||||||
|
*/
|
||||||
|
export function exportToExcel(data: DataPoint[], title: string): void {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
console.error('No data to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a worksheet
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||||
|
|
||||||
|
// Create a workbook
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Data');
|
||||||
|
|
||||||
|
// Generate Excel file and trigger download
|
||||||
|
const fileName = `${sanitizeFilename(title)}_${formatDate(new Date())}.xlsx`;
|
||||||
|
XLSX.writeFile(workbook, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export data to CSV file
|
||||||
|
* @param data Array of data objects
|
||||||
|
* @param title Title of the export (used for filename)
|
||||||
|
*/
|
||||||
|
export function exportToCSV(data: DataPoint[], title: string): void {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
console.error('No data to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to CSV
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||||
|
const csv = XLSX.utils.sheet_to_csv(worksheet);
|
||||||
|
|
||||||
|
// Create a blob and trigger download
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const fileName = `${sanitizeFilename(title)}_${formatDate(new Date())}.csv`;
|
||||||
|
saveAs(blob, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export data to PDF file
|
||||||
|
* @param data Array of data objects
|
||||||
|
* @param title Title of the export (used for filename)
|
||||||
|
* @param config Configuration for the export
|
||||||
|
*/
|
||||||
|
export function exportToPDF(data: DataPoint[], title: string, config?: ExportConfig): void {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
console.error('No data to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create PDF document
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
// Set title
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.text(title, 14, 22);
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.text(`Generated on ${new Date().toLocaleString()}`, 14, 30);
|
||||||
|
|
||||||
|
// Determine columns based on config
|
||||||
|
let columns: string[] = [];
|
||||||
|
if (config?.dimensions || config?.metrics) {
|
||||||
|
columns = [...(config.dimensions || []), ...(config.metrics || [])];
|
||||||
|
} else if (data.length > 0) {
|
||||||
|
columns = Object.keys(data[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert data to rows for autotable
|
||||||
|
const rows = data.map(item => columns.map(col => item[col] !== undefined ? String(item[col]) : ''));
|
||||||
|
|
||||||
|
// Generate the table
|
||||||
|
autoTable(doc, {
|
||||||
|
head: [columns],
|
||||||
|
body: rows,
|
||||||
|
startY: 40,
|
||||||
|
styles: {
|
||||||
|
fontSize: 8,
|
||||||
|
cellPadding: 2,
|
||||||
|
},
|
||||||
|
headStyles: {
|
||||||
|
fillColor: [66, 139, 202],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save the PDF
|
||||||
|
const fileName = `${sanitizeFilename(title)}_${formatDate(new Date())}.pdf`;
|
||||||
|
doc.save(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for filenames
|
||||||
|
* @param date Date to format
|
||||||
|
* @returns Formatted date string (YYYYMMDD_HHMMSS)
|
||||||
|
*/
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a string for use as a filename
|
||||||
|
* @param input Input string
|
||||||
|
* @returns Sanitized string
|
||||||
|
*/
|
||||||
|
function sanitizeFilename(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/[/\\?%*:|"<>]/g, '-') // Replace invalid characters with hyphens
|
||||||
|
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||||
|
.trim(); // Trim whitespace
|
||||||
|
}
|
||||||
@@ -1,100 +1,394 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import StatsCard from "@/components/dashboard/stats-card";
|
import { DashboardLayout, DashboardConfig } from "@/components/dashboard/dashboard-layout";
|
||||||
import ApiActivityCard from "@/components/dashboard/api-activity-card";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import ApiTokensCard from "@/components/dashboard/api-tokens-card";
|
import { Button } from "@/components/ui/button";
|
||||||
import LdapConnectionsCard from "@/components/dashboard/ldap-connections-card";
|
import { Plus, RefreshCw } from "lucide-react";
|
||||||
import { ApiDocumentationCard } from "@/components/dashboard/api-documentation-card";
|
import {
|
||||||
import {
|
Dialog,
|
||||||
Users,
|
DialogContent,
|
||||||
UserPlus,
|
DialogFooter,
|
||||||
FolderClosed,
|
DialogHeader,
|
||||||
Monitor
|
DialogTitle,
|
||||||
} from "lucide-react";
|
DialogTrigger,
|
||||||
import { LdapConnection, ApiToken } from "@shared/schema";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
// Dashboard creation form schema
|
||||||
|
const dashboardSchema = z.object({
|
||||||
|
name: z.string().min(1, "Dashboard name is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DashboardFormValues = z.infer<typeof dashboardSchema>;
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { data: connections = [] } = useQuery<LdapConnection[]>({
|
const { toast } = useToast();
|
||||||
queryKey: ["/api/ldap-connections"],
|
const [dashboards, setDashboards] = useState<DashboardConfig[]>([]);
|
||||||
});
|
const [activeTab, setActiveTab] = useState<string>("dashboard-overview");
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const { data: tokens = [] } = useQuery<ApiToken[]>({
|
const [dataSourcesLoading, setDataSourcesLoading] = useState(true);
|
||||||
queryKey: ["/api/tokens"],
|
const [dataSources, setDataSources] = useState<Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: any[];
|
||||||
|
fields: Array<{ name: string; type: string }>;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
|
// Form for creating a new dashboard
|
||||||
|
const form = useForm<DashboardFormValues>({
|
||||||
|
resolver: zodResolver(dashboardSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch LDAP connections to use as data sources
|
||||||
|
const { data: connections = [], isLoading: isLoadingConnections } = useQuery<any[]>({
|
||||||
|
queryKey: ['/api/ldap-connections'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get users as a data source
|
||||||
|
const { data: usersData, isLoading: isLoadingUsers } = useQuery<any>({
|
||||||
|
queryKey: ['/api/user-dashboard-data'],
|
||||||
|
queryFn: async () => {
|
||||||
|
// This would normally come from an API endpoint
|
||||||
|
// Here we're implementing a mock data fetcher for the dashboard
|
||||||
|
const response = await fetch('/api/ad/users?properties=name,mail,title,department,employeeId,manager,memberOf,whenCreated');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch users');
|
||||||
|
const users = await response.json();
|
||||||
|
|
||||||
|
// Get the structure of the data to determine field types
|
||||||
|
const fields = getFieldTypes(users.data || []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'users',
|
||||||
|
name: 'Active Directory Users',
|
||||||
|
data: users.data || [],
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get computers as a data source
|
||||||
|
const { data: computersData, isLoading: isLoadingComputers } = useQuery<any>({
|
||||||
|
queryKey: ['/api/computer-dashboard-data'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch('/api/ad/computers?properties=name,operatingSystem,operatingSystemVersion,whenCreated,lastLogonTimestamp,enabled,memberOf');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch computers');
|
||||||
|
const computers = await response.json();
|
||||||
|
|
||||||
|
// Get the structure of the data to determine field types
|
||||||
|
const fields = getFieldTypes(computers.data || []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'computers',
|
||||||
|
name: 'Active Directory Computers',
|
||||||
|
data: computers.data || [],
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get groups as a data source
|
||||||
|
const { data: groupsData, isLoading: isLoadingGroups } = useQuery<any>({
|
||||||
|
queryKey: ['/api/group-dashboard-data'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch('/api/ad/groups?properties=name,description,whenCreated,member,memberOf');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch groups');
|
||||||
|
const groups = await response.json();
|
||||||
|
|
||||||
|
// Get the structure of the data to determine field types
|
||||||
|
const fields = getFieldTypes(groups.data || []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'groups',
|
||||||
|
name: 'Active Directory Groups',
|
||||||
|
data: groups.data || [],
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved dashboards from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const savedDashboards = localStorage.getItem('dashboards');
|
||||||
|
if (savedDashboards) {
|
||||||
|
setDashboards(JSON.parse(savedDashboards));
|
||||||
|
|
||||||
|
// Set the active tab to the first dashboard if it exists
|
||||||
|
const parsedDashboards = JSON.parse(savedDashboards) as DashboardConfig[];
|
||||||
|
if (parsedDashboards.length > 0) {
|
||||||
|
setActiveTab(parsedDashboards[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load dashboards from localStorage', e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update data sources when API data is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoadingUsers && !isLoadingComputers && !isLoadingGroups) {
|
||||||
|
const sources = [];
|
||||||
|
|
||||||
|
if (usersData) sources.push(usersData);
|
||||||
|
if (computersData) sources.push(computersData);
|
||||||
|
if (groupsData) sources.push(groupsData);
|
||||||
|
|
||||||
|
// Add a mock audit logs data source
|
||||||
|
sources.push({
|
||||||
|
id: 'audit-logs',
|
||||||
|
name: 'Audit Logs',
|
||||||
|
data: generateMockAuditLogs(),
|
||||||
|
fields: [
|
||||||
|
{ name: 'timestamp', type: 'date' },
|
||||||
|
{ name: 'action', type: 'string' },
|
||||||
|
{ name: 'user', type: 'string' },
|
||||||
|
{ name: 'objectType', type: 'string' },
|
||||||
|
{ name: 'objectName', type: 'string' },
|
||||||
|
{ name: 'status', type: 'string' },
|
||||||
|
{ name: 'details', type: 'string' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
setDataSources(sources);
|
||||||
|
setDataSourcesLoading(false);
|
||||||
|
}
|
||||||
|
}, [isLoadingUsers, isLoadingComputers, isLoadingGroups, usersData, computersData, groupsData]);
|
||||||
|
|
||||||
|
// Helper function to determine field types from data
|
||||||
|
function getFieldTypes(data: any[]): Array<{ name: string; type: string }> {
|
||||||
|
if (!data || data.length === 0) return [];
|
||||||
|
|
||||||
|
const sample = data[0];
|
||||||
|
const fields: Array<{ name: string; type: string }> = [];
|
||||||
|
|
||||||
|
Object.keys(sample).forEach(key => {
|
||||||
|
let type = 'string';
|
||||||
|
const value = sample[key];
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
type = Number.isInteger(value) ? 'integer' : 'float';
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
type = 'boolean';
|
||||||
|
} else if (value instanceof Date) {
|
||||||
|
type = 'date';
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
type = 'array';
|
||||||
|
} else if (value === null) {
|
||||||
|
// Try to infer type from other records
|
||||||
|
for (let i = 1; i < Math.min(data.length, 10); i++) {
|
||||||
|
const otherValue = data[i][key];
|
||||||
|
if (otherValue !== null) {
|
||||||
|
if (typeof otherValue === 'number') {
|
||||||
|
type = Number.isInteger(otherValue) ? 'integer' : 'float';
|
||||||
|
} else if (typeof otherValue === 'boolean') {
|
||||||
|
type = 'boolean';
|
||||||
|
} else if (otherValue instanceof Date) {
|
||||||
|
type = 'date';
|
||||||
|
} else if (Array.isArray(otherValue)) {
|
||||||
|
type = 'array';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({ name: key, type });
|
||||||
|
});
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock function to generate audit logs for the dashboard demo
|
||||||
|
function generateMockAuditLogs() {
|
||||||
|
const actions = ['create', 'update', 'delete', 'move', 'enable', 'disable'];
|
||||||
|
const objectTypes = ['user', 'computer', 'group', 'organizationalUnit'];
|
||||||
|
const statuses = ['success', 'failure'];
|
||||||
|
const users = ['admin', 'system', 'operator1', 'operator2'];
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Generate 100 mock logs
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const action = actions[Math.floor(Math.random() * actions.length)];
|
||||||
|
const objectType = objectTypes[Math.floor(Math.random() * objectTypes.length)];
|
||||||
|
const status = statuses[Math.floor(Math.random() * statuses.length)];
|
||||||
|
const user = users[Math.floor(Math.random() * users.length)];
|
||||||
|
|
||||||
|
// Create a random date within the last 30 days
|
||||||
|
const date = new Date(now);
|
||||||
|
date.setDate(date.getDate() - Math.floor(Math.random() * 30));
|
||||||
|
|
||||||
|
logs.push({
|
||||||
|
timestamp: date.toISOString(),
|
||||||
|
action,
|
||||||
|
user,
|
||||||
|
objectType,
|
||||||
|
objectName: `${objectType}-${Math.floor(Math.random() * 1000)}`,
|
||||||
|
status,
|
||||||
|
details: `${action} operation on ${objectType} by ${user} ${status === 'success' ? 'completed successfully' : 'failed'}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp
|
||||||
|
logs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
|
|
||||||
|
return logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save dashboard to localStorage
|
||||||
|
const saveDashboards = (updatedDashboards: DashboardConfig[]) => {
|
||||||
|
localStorage.setItem('dashboards', JSON.stringify(updatedDashboards));
|
||||||
|
setDashboards(updatedDashboards);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a new dashboard
|
||||||
|
const handleCreateDashboard = (values: DashboardFormValues) => {
|
||||||
|
const newDashboard: DashboardConfig = {
|
||||||
|
id: `dashboard-${Date.now().toString(36)}`,
|
||||||
|
name: values.name,
|
||||||
|
layouts: {
|
||||||
|
lg: [],
|
||||||
|
md: [],
|
||||||
|
sm: [],
|
||||||
|
xs: [],
|
||||||
|
xxs: [],
|
||||||
|
},
|
||||||
|
widgets: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedDashboards = [...dashboards, newDashboard];
|
||||||
|
saveDashboards(updatedDashboards);
|
||||||
|
setActiveTab(newDashboard.id);
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Dashboard Created",
|
||||||
|
description: `A new dashboard named "${values.name}" has been created.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save dashboard changes
|
||||||
|
const handleSaveDashboard = (updatedConfig: DashboardConfig) => {
|
||||||
|
const updatedDashboards = dashboards.map(dash =>
|
||||||
|
dash.id === updatedConfig.id ? updatedConfig : dash
|
||||||
|
);
|
||||||
|
saveDashboards(updatedDashboards);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete a dashboard
|
||||||
|
const handleDeleteDashboard = (dashboardId: string) => {
|
||||||
|
const updatedDashboards = dashboards.filter(dash => dash.id !== dashboardId);
|
||||||
|
saveDashboards(updatedDashboards);
|
||||||
|
|
||||||
|
// Set active tab to overview or first dashboard
|
||||||
|
if (updatedDashboards.length > 0) {
|
||||||
|
setActiveTab(updatedDashboards[0].id);
|
||||||
|
} else {
|
||||||
|
setActiveTab("dashboard-overview");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Dashboard Deleted",
|
||||||
|
description: "The dashboard has been deleted.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<div className="container mx-auto py-6">
|
||||||
title="Dashboard"
|
<div className="flex justify-between items-center mb-6">
|
||||||
description="Active Directory Management API Overview"
|
<h1 className="text-3xl font-bold">Dashboards</h1>
|
||||||
>
|
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||||
{/* Statistics Cards */}
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
New Dashboard
|
||||||
<StatsCard
|
</Button>
|
||||||
title="Total Users"
|
|
||||||
value={1284}
|
|
||||||
icon={<Users className="h-5 w-5" />}
|
|
||||||
iconBgColor="bg-blue-100"
|
|
||||||
iconColor="text-primary"
|
|
||||||
changeValue={4.75}
|
|
||||||
changeType="increase"
|
|
||||||
changePeriod="from last month"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatsCard
|
|
||||||
title="Total Groups"
|
|
||||||
value={87}
|
|
||||||
icon={<UserPlus className="h-5 w-5" />}
|
|
||||||
iconBgColor="bg-purple-100"
|
|
||||||
iconColor="text-purple-600"
|
|
||||||
changeValue={2.3}
|
|
||||||
changeType="increase"
|
|
||||||
changePeriod="from last month"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatsCard
|
|
||||||
title="Organizational Units"
|
|
||||||
value={32}
|
|
||||||
icon={<FolderClosed className="h-5 w-5" />}
|
|
||||||
iconBgColor="bg-amber-100"
|
|
||||||
iconColor="text-amber-600"
|
|
||||||
changeValue={0}
|
|
||||||
changeType="nochange"
|
|
||||||
changePeriod="from last month"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatsCard
|
|
||||||
title="Total Computers"
|
|
||||||
value={563}
|
|
||||||
icon={<Monitor className="h-5 w-5" />}
|
|
||||||
iconBgColor="bg-teal-100"
|
|
||||||
iconColor="text-teal-600"
|
|
||||||
changeValue={1.2}
|
|
||||||
changeType="decrease"
|
|
||||||
changePeriod="from last month"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
{/* API Activity Card */}
|
<TabsList className="mb-6">
|
||||||
<div className="lg:col-span-2">
|
{dashboards.map((dashboard) => (
|
||||||
<ApiActivityCard />
|
<TabsTrigger key={dashboard.id} value={dashboard.id}>
|
||||||
</div>
|
{dashboard.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
{/* Active API Tokens */}
|
{dashboards.map((dashboard) => (
|
||||||
<div>
|
<TabsContent key={dashboard.id} value={dashboard.id}>
|
||||||
<ApiTokensCard tokens={tokens} />
|
{dataSourcesLoading ? (
|
||||||
</div>
|
<div className="flex justify-center items-center min-h-[60vh]">
|
||||||
</div>
|
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<span className="ml-2">Loading data sources...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DashboardLayout
|
||||||
|
config={dashboard}
|
||||||
|
onSave={handleSaveDashboard}
|
||||||
|
dataSources={dataSources}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{/* LDAP Connections */}
|
{/* Create Dashboard Dialog */}
|
||||||
<div className="mt-6">
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
<LdapConnectionsCard connections={connections} />
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
</div>
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Dashboard</DialogTitle>
|
||||||
{/* API Documentation Preview */}
|
</DialogHeader>
|
||||||
<div className="mt-6">
|
<Form {...form}>
|
||||||
<ApiDocumentationCard />
|
<form onSubmit={form.handleSubmit(handleCreateDashboard)} className="space-y-6">
|
||||||
</div>
|
<FormField
|
||||||
</DashboardLayout>
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Dashboard Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Enter dashboard name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Give your dashboard a descriptive name.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsCreateDialogOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Create Dashboard</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module 'file-saver';
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module 'react-grid-layout';
|
||||||
Generated
+433
-22
@@ -65,10 +65,13 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"ioredis": "^5.6.0",
|
"ioredis": "^5.6.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jspdf": "^3.0.1",
|
||||||
|
"jspdf-autotable": "^5.0.2",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
@@ -80,10 +83,12 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-grid-layout": "^1.5.1",
|
||||||
"react-hook-form": "^7.53.1",
|
"react-hook-form": "^7.53.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-resizable-panels": "^2.1.4",
|
"react-resizable-panels": "^2.1.4",
|
||||||
"recharts": "^2.13.0",
|
"react-to-print": "^3.0.6",
|
||||||
|
"recharts": "^2.15.2",
|
||||||
"redis": "^4.7.0",
|
"redis": "^4.7.0",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
@@ -93,6 +98,7 @@
|
|||||||
"vaul": "^1.1.0",
|
"vaul": "^1.1.0",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-validation-error": "^3.4.0"
|
"zod-validation-error": "^3.4.0"
|
||||||
},
|
},
|
||||||
@@ -416,9 +422,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.26.0",
|
"version": "7.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||||
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
|
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"regenerator-runtime": "^0.14.0"
|
"regenerator-runtime": "^0.14.0"
|
||||||
@@ -3790,6 +3796,13 @@
|
|||||||
"integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==",
|
"integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/raf": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/range-parser": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
@@ -3854,6 +3867,13 @@
|
|||||||
"@types/serve-static": "*"
|
"@types/serve-static": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/uuid": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
@@ -3909,6 +3929,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||||
@@ -3991,6 +4020,18 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/atob": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
|
||||||
|
"license": "(MIT OR Apache-2.0)",
|
||||||
|
"bin": {
|
||||||
|
"atob": "bin/atob.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.20",
|
"version": "10.4.20",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
|
||||||
@@ -4047,6 +4088,16 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/basic-auth": {
|
"node_modules/basic-auth": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||||
@@ -4170,6 +4221,18 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/btoa": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
|
||||||
|
"license": "(MIT OR Apache-2.0)",
|
||||||
|
"bin": {
|
||||||
|
"btoa": "bin/btoa.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
@@ -4261,6 +4324,46 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/canvg": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@types/raf": "^3.4.0",
|
||||||
|
"core-js": "^3.8.3",
|
||||||
|
"raf": "^3.4.1",
|
||||||
|
"regenerator-runtime": "^0.13.7",
|
||||||
|
"rgbcolor": "^1.0.1",
|
||||||
|
"stackblur-canvas": "^2.0.0",
|
||||||
|
"svg-pathdata": "^6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/canvg/node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -4352,6 +4455,15 @@
|
|||||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -4500,12 +4612,36 @@
|
|||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz",
|
||||||
|
"integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||||
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4520,6 +4656,16 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -4791,6 +4937,16 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
|
||||||
|
"integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optional": true,
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/drizzle-kit": {
|
"node_modules/drizzle-kit": {
|
||||||
"version": "0.30.4",
|
"version": "0.30.4",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.4.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.4.tgz",
|
||||||
@@ -5693,13 +5849,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-equals": {
|
"node_modules/fast-equals": {
|
||||||
"version": "5.0.1",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
|
||||||
"integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==",
|
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
@@ -5738,6 +5891,18 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/file-saver": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -5808,6 +5973,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
@@ -6049,6 +6223,20 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
@@ -6329,6 +6517,33 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jspdf": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.26.7",
|
||||||
|
"atob": "^2.1.2",
|
||||||
|
"btoa": "^1.2.1",
|
||||||
|
"fflate": "^0.8.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"canvg": "^3.0.11",
|
||||||
|
"core-js": "^3.6.0",
|
||||||
|
"dompurify": "^3.2.4",
|
||||||
|
"html2canvas": "^1.0.0-rc.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jspdf-autotable": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"jspdf": "^2 || ^3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
||||||
@@ -6995,6 +7210,13 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/performance-now": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.14.1",
|
"version": "8.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz",
|
||||||
@@ -7471,6 +7693,16 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/raf": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"performance-now": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/random-bytes": {
|
"node_modules/random-bytes": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||||
@@ -7543,6 +7775,47 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-draggable": {
|
||||||
|
"version": "4.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
|
||||||
|
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^1.1.1",
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.3.0",
|
||||||
|
"react-dom": ">= 16.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-draggable/node_modules/clsx": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-grid-layout": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-4Fr+kKMk0+m1HL/BWfHxi/lRuaOmDNNKQDcu7m12+NEYcen20wIuZFo789u3qWCyvUsNUxCiyf0eKq4WiJSNYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"fast-equals": "^4.0.3",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-draggable": "^4.4.5",
|
||||||
|
"react-resizable": "^3.0.5",
|
||||||
|
"resize-observer-polyfill": "^1.5.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.3.0",
|
||||||
|
"react-dom": ">= 16.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.53.1",
|
"version": "7.53.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.1.tgz",
|
||||||
@@ -7631,6 +7904,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-resizable": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "15.x",
|
||||||
|
"react-draggable": "^4.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-resizable-panels": {
|
"node_modules/react-resizable-panels": {
|
||||||
"version": "2.1.6",
|
"version": "2.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.6.tgz",
|
||||||
@@ -7642,9 +7928,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-smooth": {
|
"node_modules/react-smooth": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||||
"integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==",
|
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-equals": "^5.0.1",
|
"fast-equals": "^5.0.1",
|
||||||
@@ -7652,8 +7938,17 @@
|
|||||||
"react-transition-group": "^4.4.5"
|
"react-transition-group": "^4.4.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-smooth/node_modules/fast-equals": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
@@ -7679,6 +7974,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-to-print": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-K/jFxkUifbfVnu1XyinM6AB6zAq0VMw0lH/6WJpkdlChoqqvEOE/BGOxYN2xOmu8f72isTTU5DNatK/j0Lfc+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-transition-group": {
|
"node_modules/react-transition-group": {
|
||||||
"version": "4.4.5",
|
"version": "4.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
@@ -7717,16 +8021,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "2.13.3",
|
"version": "2.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.2.tgz",
|
||||||
"integrity": "sha512-YDZ9dOfK9t3ycwxgKbrnDlRC4BHdjlY73fet3a0C1+qGMjXVZe6+VXmpOIIhzkje5MMEL8AN4hLIe4AMskBzlA==",
|
"integrity": "sha512-xv9lVztv3ingk7V3Jf05wfAZbM9Q2umJzu5t/cfnAK7LUslNrGT7LPBr74G+ok8kSCeFMaePmWMg0rcYOnczTw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"eventemitter3": "^4.0.1",
|
"eventemitter3": "^4.0.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"react-is": "^18.3.1",
|
"react-is": "^18.3.1",
|
||||||
"react-smooth": "^4.0.0",
|
"react-smooth": "^4.0.4",
|
||||||
"recharts-scale": "^0.4.4",
|
"recharts-scale": "^0.4.4",
|
||||||
"tiny-invariant": "^1.3.1",
|
"tiny-invariant": "^1.3.1",
|
||||||
"victory-vendor": "^36.6.8"
|
"victory-vendor": "^36.6.8"
|
||||||
@@ -7735,8 +8039,8 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0",
|
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/recharts-scale": {
|
"node_modules/recharts-scale": {
|
||||||
@@ -7801,6 +8105,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resize-observer-polyfill": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.8",
|
"version": "1.22.8",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
||||||
@@ -7838,6 +8148,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rgbcolor": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||||
|
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.24.4",
|
"version": "4.24.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.4.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.4.tgz",
|
||||||
@@ -8120,6 +8440,28 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/stackblur-canvas": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/standard-as-callback": {
|
"node_modules/standard-as-callback": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
@@ -8265,6 +8607,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-pathdata": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/swagger-jsdoc": {
|
"node_modules/swagger-jsdoc": {
|
||||||
"version": "6.2.8",
|
"version": "6.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
||||||
@@ -8438,6 +8790,16 @@
|
|||||||
"tailwindcss": ">=3.0.0 || insiders"
|
"tailwindcss": ">=3.0.0 || insiders"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
@@ -9118,6 +9480,16 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
@@ -9729,6 +10101,24 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wouter": {
|
"node_modules/wouter": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.3.5.tgz",
|
||||||
@@ -9861,6 +10251,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
+7
-1
@@ -67,10 +67,13 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"ioredis": "^5.6.0",
|
"ioredis": "^5.6.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jspdf": "^3.0.1",
|
||||||
|
"jspdf-autotable": "^5.0.2",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
@@ -82,10 +85,12 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-grid-layout": "^1.5.1",
|
||||||
"react-hook-form": "^7.53.1",
|
"react-hook-form": "^7.53.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-resizable-panels": "^2.1.4",
|
"react-resizable-panels": "^2.1.4",
|
||||||
"recharts": "^2.13.0",
|
"react-to-print": "^3.0.6",
|
||||||
|
"recharts": "^2.15.2",
|
||||||
"redis": "^4.7.0",
|
"redis": "^4.7.0",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
@@ -95,6 +100,7 @@
|
|||||||
"vaul": "^1.1.0",
|
"vaul": "^1.1.0",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-validation-error": "^3.4.0"
|
"zod-validation-error": "^3.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4939,6 +4939,185 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/user-dashboard-data:
|
||||||
|
* get:
|
||||||
|
* summary: Get user data for dashboard visualizations
|
||||||
|
* tags: [Dashboard]
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: User data for dashboard
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/UnauthorizedError'
|
||||||
|
*/
|
||||||
|
app.get("/api/user-dashboard-data", requireAuth({ allowApiToken: true }), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Get data from the first available LDAP connection
|
||||||
|
const connections = await storage.listLdapConnections();
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return res.status(200).json({ data: [], message: "No LDAP connections available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = connections[0].id;
|
||||||
|
const users = await storage.listAdUsers(connectionId);
|
||||||
|
|
||||||
|
// Add objectType to make filtering easier in the dashboard
|
||||||
|
const result = users.map(user => ({
|
||||||
|
...user,
|
||||||
|
objectType: 'user'
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/computer-dashboard-data:
|
||||||
|
* get:
|
||||||
|
* summary: Get computer data for dashboard visualizations
|
||||||
|
* tags: [Dashboard]
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Computer data for dashboard
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/UnauthorizedError'
|
||||||
|
*/
|
||||||
|
app.get("/api/computer-dashboard-data", requireAuth({ allowApiToken: true }), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Get data from the first available LDAP connection
|
||||||
|
const connections = await storage.listLdapConnections();
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return res.status(200).json({ data: [], message: "No LDAP connections available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = connections[0].id;
|
||||||
|
const computers = await storage.listAdComputers(connectionId);
|
||||||
|
|
||||||
|
// Add objectType to make filtering easier in the dashboard
|
||||||
|
const result = computers.map(computer => ({
|
||||||
|
...computer,
|
||||||
|
objectType: 'computer'
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/group-dashboard-data:
|
||||||
|
* get:
|
||||||
|
* summary: Get group data for dashboard visualizations
|
||||||
|
* tags: [Dashboard]
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Group data for dashboard
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/UnauthorizedError'
|
||||||
|
*/
|
||||||
|
app.get("/api/group-dashboard-data", requireAuth({ allowApiToken: true }), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Get data from the first available LDAP connection
|
||||||
|
const connections = await storage.listLdapConnections();
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return res.status(200).json({ data: [], message: "No LDAP connections available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = connections[0].id;
|
||||||
|
const groups = await storage.listAdGroups(connectionId);
|
||||||
|
|
||||||
|
// Add objectType to make filtering easier in the dashboard
|
||||||
|
const result = groups.map(group => ({
|
||||||
|
...group,
|
||||||
|
objectType: 'group'
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/dashboard-summary:
|
||||||
|
* get:
|
||||||
|
* summary: Get summary statistics for the dashboard
|
||||||
|
* tags: [Dashboard]
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Summary statistics for the dashboard
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/UnauthorizedError'
|
||||||
|
*/
|
||||||
|
app.get("/api/dashboard-summary", requireAuth({ allowApiToken: true }), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Get data from the first available LDAP connection
|
||||||
|
const connections = await storage.listLdapConnections();
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return res.status(200).json({
|
||||||
|
userCount: 0,
|
||||||
|
computerCount: 0,
|
||||||
|
groupCount: 0,
|
||||||
|
sitesCount: 0,
|
||||||
|
domainsCount: 0,
|
||||||
|
message: "No LDAP connections available"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = connections[0].id;
|
||||||
|
|
||||||
|
// Get counts from all AD objects
|
||||||
|
const usersCount = await db.select({ count: sql`count(*)` }).from(adUsers)
|
||||||
|
.where(eq(adUsers.connectionId, connectionId));
|
||||||
|
|
||||||
|
const computersCount = await db.select({ count: sql`count(*)` }).from(adComputers)
|
||||||
|
.where(eq(adComputers.connectionId, connectionId));
|
||||||
|
|
||||||
|
const groupsCount = await db.select({ count: sql`count(*)` }).from(adGroups)
|
||||||
|
.where(eq(adGroups.connectionId, connectionId));
|
||||||
|
|
||||||
|
const sitesCount = await db.select({ count: sql`count(*)` }).from(adSites)
|
||||||
|
.where(eq(adSites.connectionId, connectionId));
|
||||||
|
|
||||||
|
const domainsCount = await db.select({ count: sql`count(*)` }).from(adDomains)
|
||||||
|
.where(eq(adDomains.connectionId, connectionId));
|
||||||
|
|
||||||
|
// Return the counts
|
||||||
|
res.json({
|
||||||
|
userCount: Number(usersCount[0]?.count || 0),
|
||||||
|
computerCount: Number(computersCount[0]?.count || 0),
|
||||||
|
groupCount: Number(groupsCount[0]?.count || 0),
|
||||||
|
sitesCount: Number(sitesCount[0]?.count || 0),
|
||||||
|
domainsCount: Number(domainsCount[0]?.count || 0)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
|
|
||||||
return httpServer;
|
return httpServer;
|
||||||
|
|||||||
Reference in New Issue
Block a user