From 499076dcf9c4b90611dc63bcd2e625fabf401765 Mon Sep 17 00:00:00 2001 From: alphaeusmote <41258468-alphaeusmote@users.noreply.replit.com> Date: Thu, 10 Apr 2025 00:18:23 +0000 Subject: [PATCH] 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 --- .../components/dashboard/dashboard-layout.tsx | 363 ++++++++++ .../components/dashboard/dashboard-widget.tsx | 303 ++++++++ .../components/dashboard/widget-editor.tsx | 670 ++++++++++++++++++ client/src/lib/export-utils.ts | 142 ++++ client/src/pages/dashboard-page.tsx | 472 +++++++++--- client/src/types/file-saver.d.ts | 1 + client/src/types/react-grid-layout.d.ts | 1 + package-lock.json | 455 +++++++++++- package.json | 8 +- server/routes.ts | 179 +++++ 10 files changed, 2482 insertions(+), 112 deletions(-) create mode 100644 client/src/components/dashboard/dashboard-layout.tsx create mode 100644 client/src/components/dashboard/dashboard-widget.tsx create mode 100644 client/src/components/dashboard/widget-editor.tsx create mode 100644 client/src/lib/export-utils.ts create mode 100644 client/src/types/file-saver.d.ts create mode 100644 client/src/types/react-grid-layout.d.ts diff --git a/client/src/components/dashboard/dashboard-layout.tsx b/client/src/components/dashboard/dashboard-layout.tsx new file mode 100644 index 0000000..499b2b3 --- /dev/null +++ b/client/src/components/dashboard/dashboard-layout.tsx @@ -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(config?.widgets || []); + const [isWidgetEditorOpen, setIsWidgetEditorOpen] = useState(false); + const [editingWidget, setEditingWidget] = useState(undefined); + const [dashboardName, setDashboardName] = useState(config?.name || "New Dashboard"); + const [availableFields, setAvailableFields] = useState>([]); + + // 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 ( +
+ {isEditable && ( +
+ setDashboardName(e.target.value)} + className="text-2xl font-bold bg-transparent border-none focus:outline-none focus:ring-0 px-0" + /> +
+ + + + +
+
+ )} + + {!isEditable && ( +

{dashboardName}

+ )} + +
+ + {widgets.map((widget) => ( +
+ {}} + onDelete={isEditable ? handleDeleteWidget : () => {}} + /> +
+ ))} +
+
+ + {/* Widget Editor Dialog */} + setIsWidgetEditorOpen(false)} + onSave={handleSaveWidget} + editWidget={editingWidget} + availableFields={availableFields} + availableDataSources={dataSources.map(ds => ({ id: ds.id, name: ds.name }))} + /> +
+ ); +} \ No newline at end of file diff --git a/client/src/components/dashboard/dashboard-widget.tsx b/client/src/components/dashboard/dashboard-widget.tsx new file mode 100644 index 0000000..bae1742 --- /dev/null +++ b/client/src/components/dashboard/dashboard-widget.tsx @@ -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(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

No data available

; + + // 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 ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {data.map((item, idx) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ {column} +
+ {item[column] !== undefined ? String(item[column]) : '—'} +
+
+ ); + }; + + // Render different chart types + const renderChart = () => { + if (!data || data.length === 0) return

No data available

; + + const { xAxis, yAxis, dimensions, metrics, colors = COLORS, showLegend = true, stacked = false } = config; + + switch (type) { + case 'bar': + return ( + + + + + + + {showLegend && } + {Array.isArray(yAxis) && yAxis.length > 0 + ? yAxis.map((axis, index) => ( + + )) + : yAxis ? : null + } + + + ); + + case 'line': + return ( + + + + + + + {showLegend && } + {Array.isArray(yAxis) && yAxis.length > 0 + ? yAxis.map((axis, index) => ( + + )) + : yAxis ? : null + } + + + ); + + case 'area': + return ( + + + + + + + {showLegend && } + {Array.isArray(yAxis) && yAxis.length > 0 + ? yAxis.map((axis, index) => ( + + )) + : yAxis ? : null + } + + + ); + + 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 ( + + + `${name}: ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {pieData.map((entry, index) => ( + + ))} + + [`${value}`, ""]} /> + {showLegend && } + + + ); + + case 'number': + return ( +
+
+

{calculateNumber()}

+ {config.metrics && config.metrics.length > 0 && ( +

{config.metrics[0]}

+ )} +
+
+ ); + + case 'table': + default: + return renderTable(); + } + }; + + return ( + + + {title} +
+ + + + +
+
+ + {renderChart()} + +
+ ); +} \ No newline at end of file diff --git a/client/src/components/dashboard/widget-editor.tsx b/client/src/components/dashboard/widget-editor.tsx new file mode 100644 index 0000000..c95acef --- /dev/null +++ b/client/src/components/dashboard/widget-editor.tsx @@ -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; + +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([]); + + const form = useForm({ + 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 ( + !open && onClose()}> + + + {editWidget ? "Edit Widget" : "Add New Widget"} + + Configure your dashboard widget. Select data source, chart type, and fields to display. + + + +
+ +
+ ( + + Widget Title + + + + + + )} + /> + + ( + + Chart Type + + + + )} + /> + + ( + + Data Source + + + Select the data source for this widget + + + + )} + /> +
+ + {watchDataSource && ( +
+
+
+

Available Fields

+
+ {availableFields + .filter(field => !selectedFields.includes(field.name)) + .map(field => ( +
addField(field.name)} + > + {field.name} + {field.type} + +
+ ))} +
+
+ +
+

Selected Fields

+
+ {selectedFields.length === 0 ? ( +
+ No fields selected +
+ ) : ( + selectedFields.map(field => ( +
+ {field} +
+ + +
+
+ )) + )} +
+
+
+ + + + Chart Options + +
+ {["bar", "line", "area"].includes(watchType) && ( +
+ ( + + X-Axis Field + + + Choose a dimension field for the X-Axis + + + + )} + /> + +
+ Y-Axis Fields +
+ {(form.getValues('config.metrics') || []).map((metric) => ( +
+ { + if (checked) { + toggleAsYAxis(metric); + } else { + toggleAsYAxis(metric); + } + }} + id={`y-axis-${metric}`} + /> + +
+ ))} +
+ + Select metric fields to display on the Y-Axis + +
+ + ( + +
+ Stacked Chart + + Stack the values on top of each other + +
+ + + +
+ )} + /> +
+ )} + + {watchType === "pie" && ( +
+ ( + + Label Field + + + Choose a field for the pie chart labels + + + + )} + /> + + ( + + Value Field + + + Choose a field for the pie chart values + + + + )} + /> +
+ )} + + {watchType === "number" && ( +
+ ( + + Metric to Sum + + + Choose a field to sum + + + + )} + /> + + ( + + Decimal Precision: {field.value} + + field.onChange(values[0])} + /> + + + Number of decimal places to display + + + + )} + /> +
+ )} + + {["bar", "line", "area", "pie"].includes(watchType) && ( + ( + +
+ Show Legend + + Display a legend for the chart + +
+ + + +
+ )} + /> + )} +
+
+
+
+
+ )} + + + + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/client/src/lib/export-utils.ts b/client/src/lib/export-utils.ts new file mode 100644 index 0000000..fc8d792 --- /dev/null +++ b/client/src/lib/export-utils.ts @@ -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 +} \ No newline at end of file diff --git a/client/src/pages/dashboard-page.tsx b/client/src/pages/dashboard-page.tsx index 46ce694..1a8cce7 100644 --- a/client/src/pages/dashboard-page.tsx +++ b/client/src/pages/dashboard-page.tsx @@ -1,100 +1,394 @@ +import { useState, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; -import { DashboardLayout } from "@/layouts/dashboard-layout"; -import StatsCard from "@/components/dashboard/stats-card"; -import ApiActivityCard from "@/components/dashboard/api-activity-card"; -import ApiTokensCard from "@/components/dashboard/api-tokens-card"; -import LdapConnectionsCard from "@/components/dashboard/ldap-connections-card"; -import { ApiDocumentationCard } from "@/components/dashboard/api-documentation-card"; -import { - Users, - UserPlus, - FolderClosed, - Monitor -} from "lucide-react"; -import { LdapConnection, ApiToken } from "@shared/schema"; +import { useToast } from "@/hooks/use-toast"; +import { DashboardLayout, DashboardConfig } from "@/components/dashboard/dashboard-layout"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Plus, RefreshCw } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} 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; export default function DashboardPage() { - const { data: connections = [] } = useQuery({ - queryKey: ["/api/ldap-connections"], - }); - - const { data: tokens = [] } = useQuery({ - queryKey: ["/api/tokens"], + const { toast } = useToast(); + const [dashboards, setDashboards] = useState([]); + const [activeTab, setActiveTab] = useState("dashboard-overview"); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [dataSourcesLoading, setDataSourcesLoading] = useState(true); + const [dataSources, setDataSources] = useState; + }>>([]); + + // Form for creating a new dashboard + const form = useForm({ + resolver: zodResolver(dashboardSchema), + defaultValues: { + name: "", + }, }); + // Fetch LDAP connections to use as data sources + const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + queryKey: ['/api/ldap-connections'], + }); + + // Get users as a data source + const { data: usersData, isLoading: isLoadingUsers } = useQuery({ + 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({ + 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({ + 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 ( - - {/* Statistics Cards */} -
- } - iconBgColor="bg-blue-100" - iconColor="text-primary" - changeValue={4.75} - changeType="increase" - changePeriod="from last month" - /> - - } - iconBgColor="bg-purple-100" - iconColor="text-purple-600" - changeValue={2.3} - changeType="increase" - changePeriod="from last month" - /> - - } - iconBgColor="bg-amber-100" - iconColor="text-amber-600" - changeValue={0} - changeType="nochange" - changePeriod="from last month" - /> - - } - iconBgColor="bg-teal-100" - iconColor="text-teal-600" - changeValue={1.2} - changeType="decrease" - changePeriod="from last month" - /> +
+
+

Dashboards

+
-
- {/* API Activity Card */} -
- -
+ + + {dashboards.map((dashboard) => ( + + {dashboard.name} + + ))} + - {/* Active API Tokens */} -
- -
-
+ {dashboards.map((dashboard) => ( + + {dataSourcesLoading ? ( +
+ + Loading data sources... +
+ ) : ( + + )} +
+ ))} + - {/* LDAP Connections */} -
- -
- - {/* API Documentation Preview */} -
- -
- + {/* Create Dashboard Dialog */} + + + + Create New Dashboard + +
+ + ( + + Dashboard Name + + + + + Give your dashboard a descriptive name. + + + + )} + /> + + + + + + +
+
+
); -} +} \ No newline at end of file diff --git a/client/src/types/file-saver.d.ts b/client/src/types/file-saver.d.ts new file mode 100644 index 0000000..7b7eb72 --- /dev/null +++ b/client/src/types/file-saver.d.ts @@ -0,0 +1 @@ +declare module 'file-saver'; \ No newline at end of file diff --git a/client/src/types/react-grid-layout.d.ts b/client/src/types/react-grid-layout.d.ts new file mode 100644 index 0000000..4284e5d --- /dev/null +++ b/client/src/types/react-grid-layout.d.ts @@ -0,0 +1 @@ +declare module 'react-grid-layout'; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3f1f170..1389f1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,10 +65,13 @@ "express": "^4.21.2", "express-rate-limit": "^7.5.0", "express-session": "^1.18.1", + "file-saver": "^2.0.5", "framer-motion": "^11.13.1", "input-otp": "^1.2.4", "ioredis": "^5.6.0", "jsonwebtoken": "^9.0.2", + "jspdf": "^3.0.1", + "jspdf-autotable": "^5.0.2", "ldapjs": "^3.0.7", "lucide-react": "^0.453.0", "memorystore": "^1.6.7", @@ -80,10 +83,12 @@ "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", + "react-grid-layout": "^1.5.1", "react-hook-form": "^7.53.1", "react-icons": "^5.4.0", "react-resizable-panels": "^2.1.4", - "recharts": "^2.13.0", + "react-to-print": "^3.0.6", + "recharts": "^2.15.2", "redis": "^4.7.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", @@ -93,6 +98,7 @@ "vaul": "^1.1.0", "wouter": "^3.3.5", "ws": "^8.18.0", + "xlsx": "^0.18.5", "zod": "^3.23.8", "zod-validation-error": "^3.4.0" }, @@ -416,9 +422,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -3790,6 +3796,13 @@ "integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==", "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": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -3854,6 +3867,13 @@ "@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": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -3909,6 +3929,15 @@ "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": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -3991,6 +4020,18 @@ "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": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", @@ -4047,6 +4088,16 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "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": { "version": "2.0.1", "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_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": { "version": "1.0.1", "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" }, + "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": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4352,6 +4455,15 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4500,12 +4612,36 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4520,6 +4656,16 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4791,6 +4937,16 @@ "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": { "version": "0.30.4", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.4.tgz", @@ -5693,13 +5849,10 @@ "license": "MIT" }, "node_modules/fast-equals": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", - "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -5738,6 +5891,18 @@ "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": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5808,6 +5973,15 @@ "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": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -6049,6 +6223,20 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -6329,6 +6517,33 @@ "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": { "version": "1.4.1", "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", "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": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", @@ -7471,6 +7693,16 @@ ], "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -7543,6 +7775,47 @@ "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": { "version": "7.53.1", "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": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.6.tgz", @@ -7642,9 +7928,9 @@ } }, "node_modules/react-smooth": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz", - "integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", "license": "MIT", "dependencies": { "fast-equals": "^5.0.1", @@ -7652,8 +7938,17 @@ "react-transition-group": "^4.4.5" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^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 || ^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": { @@ -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": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -7717,16 +8021,16 @@ } }, "node_modules/recharts": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.13.3.tgz", - "integrity": "sha512-YDZ9dOfK9t3ycwxgKbrnDlRC4BHdjlY73fet3a0C1+qGMjXVZe6+VXmpOIIhzkje5MMEL8AN4hLIe4AMskBzlA==", + "version": "2.15.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.2.tgz", + "integrity": "sha512-xv9lVztv3ingk7V3Jf05wfAZbM9Q2umJzu5t/cfnAK7LUslNrGT7LPBr74G+ok8kSCeFMaePmWMg0rcYOnczTw==", "license": "MIT", "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", - "react-smooth": "^4.0.0", + "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" @@ -7735,8 +8039,8 @@ "node": ">=14" }, "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^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 || ^19.0.0" } }, "node_modules/recharts-scale": { @@ -7801,6 +8105,12 @@ "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": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -7838,6 +8148,16 @@ "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": { "version": "4.24.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.4.tgz", @@ -8120,6 +8440,28 @@ "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": { "version": "2.1.0", "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" } }, + "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": { "version": "6.2.8", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", @@ -8438,6 +8790,16 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -9118,6 +9480,16 @@ "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": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -9729,6 +10101,24 @@ "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": { "version": "3.3.5", "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 31fe5e6..ddad678 100644 --- a/package.json +++ b/package.json @@ -67,10 +67,13 @@ "express": "^4.21.2", "express-rate-limit": "^7.5.0", "express-session": "^1.18.1", + "file-saver": "^2.0.5", "framer-motion": "^11.13.1", "input-otp": "^1.2.4", "ioredis": "^5.6.0", "jsonwebtoken": "^9.0.2", + "jspdf": "^3.0.1", + "jspdf-autotable": "^5.0.2", "ldapjs": "^3.0.7", "lucide-react": "^0.453.0", "memorystore": "^1.6.7", @@ -82,10 +85,12 @@ "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", + "react-grid-layout": "^1.5.1", "react-hook-form": "^7.53.1", "react-icons": "^5.4.0", "react-resizable-panels": "^2.1.4", - "recharts": "^2.13.0", + "react-to-print": "^3.0.6", + "recharts": "^2.15.2", "redis": "^4.7.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", @@ -95,6 +100,7 @@ "vaul": "^1.1.0", "wouter": "^3.3.5", "ws": "^8.18.0", + "xlsx": "^0.18.5", "zod": "^3.23.8", "zod-validation-error": "^3.4.0" }, diff --git a/server/routes.ts b/server/routes.ts index 72ac636..adba92c 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -4939,6 +4939,185 @@ export async function registerRoutes(app: Express): Promise { } }); + /** + * @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); return httpServer;