From 29f181d5e13ac675555b2e94884f343514163046 Mon Sep 17 00:00:00 2001 From: "Kalana B. Thilakarathna" Date: Sat, 29 Aug 2026 16:51:25 +0530 Subject: [PATCH] feat(dashboard): implement customizable dashboard with widget support - Added `react-grid-layout` for drag-and-drop functionality. - Created `DashboardCanvas` component to manage layout and widget interactions. - Introduced `WidgetContainer` and `WidgetPicker` components for widget management. - Implemented various widgets: Active Rides, Today Sales, Pending Purchase Orders, Stock Alerts, Recent Orders, and Loading Sheets. - Integrated API calls for fetching and saving dashboard preferences. - Defined default layouts for different user roles. - Created a card component for consistent widget styling. --- distribution_fe/app/(dashboard)/page.tsx | 9 +- .../components/dashboard/dashboard-canvas.tsx | 252 ++++++++++++++++++ .../components/dashboard/widget-container.tsx | 55 ++++ .../components/dashboard/widget-picker.tsx | 76 ++++++ .../dashboard/widgets/active-rides-widget.tsx | 66 +++++ .../widgets/loading-sheets-widget.tsx | 82 ++++++ .../dashboard/widgets/pending-pos-widget.tsx | 65 +++++ .../widgets/recent-orders-widget.tsx | 69 +++++ .../dashboard/widgets/stock-alerts-widget.tsx | 77 ++++++ .../dashboard/widgets/today-sales-widget.tsx | 69 +++++ distribution_fe/components/ui/card.tsx | 63 +++++ distribution_fe/lib/api/dashboard.ts | 31 +++ distribution_fe/lib/api/index.ts | 2 + distribution_fe/lib/widgets/defaults.ts | 22 ++ distribution_fe/lib/widgets/registry.tsx | 97 +++++++ distribution_fe/package-lock.json | 62 ++++- distribution_fe/package.json | 1 + 17 files changed, 1089 insertions(+), 9 deletions(-) create mode 100644 distribution_fe/components/dashboard/dashboard-canvas.tsx create mode 100644 distribution_fe/components/dashboard/widget-container.tsx create mode 100644 distribution_fe/components/dashboard/widget-picker.tsx create mode 100644 distribution_fe/components/dashboard/widgets/active-rides-widget.tsx create mode 100644 distribution_fe/components/dashboard/widgets/loading-sheets-widget.tsx create mode 100644 distribution_fe/components/dashboard/widgets/pending-pos-widget.tsx create mode 100644 distribution_fe/components/dashboard/widgets/recent-orders-widget.tsx create mode 100644 distribution_fe/components/dashboard/widgets/stock-alerts-widget.tsx create mode 100644 distribution_fe/components/dashboard/widgets/today-sales-widget.tsx create mode 100644 distribution_fe/components/ui/card.tsx create mode 100644 distribution_fe/lib/api/dashboard.ts create mode 100644 distribution_fe/lib/widgets/defaults.ts create mode 100644 distribution_fe/lib/widgets/registry.tsx diff --git a/distribution_fe/app/(dashboard)/page.tsx b/distribution_fe/app/(dashboard)/page.tsx index 342568f..8cb3996 100644 --- a/distribution_fe/app/(dashboard)/page.tsx +++ b/distribution_fe/app/(dashboard)/page.tsx @@ -1,8 +1,5 @@ +import { DashboardCanvas } from '@/components/dashboard/dashboard-canvas' + export default function DashboardPage() { - return ( -
-

Dashboard

-

Welcome to the Distribution ERP.

-
- ) + return } diff --git a/distribution_fe/components/dashboard/dashboard-canvas.tsx b/distribution_fe/components/dashboard/dashboard-canvas.tsx new file mode 100644 index 0000000..3f79ca3 --- /dev/null +++ b/distribution_fe/components/dashboard/dashboard-canvas.tsx @@ -0,0 +1,252 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { GridLayout, useContainerWidth, type LayoutItem } from 'react-grid-layout' +import 'react-grid-layout/css/styles.css' + +import { useAuth } from '@/contexts/auth-context' +import { dashboard, type WidgetLayoutItem } from '@/lib/api/dashboard' +import { WIDGET_REGISTRY } from '@/lib/widgets/registry' +import { ROLE_DEFAULT_LAYOUTS } from '@/lib/widgets/defaults' +import { getApiError } from '@/lib/api' +import { toast } from '@/components/ui/toast' +import { WidgetContainer } from './widget-container' +import { WidgetPicker } from './widget-picker' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { HugeiconsIcon } from '@hugeicons/react' +import { + PencilEdit01Icon, + Tick02Icon, + Cancel01Icon, + Add01Icon, + Refresh01Icon, +} from '@hugeicons/core-free-icons' + +// ─── Layout helpers ─────────────────────────────────────────────────────────── + +function toGridItems(items: WidgetLayoutItem[], editable: boolean): LayoutItem[] { + return items.map(item => { + const def = WIDGET_REGISTRY[item.widgetType] + return { + i: item.i, + x: item.x, + y: item.y, + w: item.w, + h: item.h, + minW: def?.minW ?? 2, + minH: def?.minH ?? 2, + ...(def?.maxW ? { maxW: def.maxW } : {}), + isDraggable: editable, + isResizable: editable, + } + }) +} + +function applyGridChanges(updated: readonly LayoutItem[], current: WidgetLayoutItem[]): WidgetLayoutItem[] { + const typeMap = Object.fromEntries(current.map(item => [item.i, item.widgetType])) + return updated + .filter(l => typeMap[l.i]) + .map(l => ({ i: l.i, widgetType: typeMap[l.i], x: l.x, y: l.y, w: l.w, h: l.h })) +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function DashboardCanvas() { + const { user } = useAuth() + const { width, containerRef } = useContainerWidth() + + const [savedLayout, setSavedLayout] = useState([]) + const [editingLayout, setEditingLayout] = useState([]) + const [isEditing, setIsEditing] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [pickerOpen, setPickerOpen] = useState(false) + + const roleDefault = ROLE_DEFAULT_LAYOUTS[user.role] ?? [] + + // ─── Load ────────────────────────────────────────────────────────────────── + + const loadPreferences = useCallback(async () => { + setIsLoading(true) + try { + const res = await dashboard.getPreferences() + setSavedLayout(res.layout) + } catch { + setSavedLayout(roleDefault) + } finally { + setIsLoading(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user.role]) + + useEffect(() => { loadPreferences() }, [loadPreferences]) + + // ─── Edit mode ───────────────────────────────────────────────────────────── + + function startEditing() { + setEditingLayout([...savedLayout]) + setIsEditing(true) + } + + function cancelEditing() { + setEditingLayout([]) + setIsEditing(false) + } + + async function saveLayout() { + setIsSaving(true) + try { + await dashboard.savePreferences(editingLayout) + setSavedLayout(editingLayout) + setIsEditing(false) + toast.add({ title: 'Dashboard saved', type: 'success' }) + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } finally { + setIsSaving(false) + } + } + + function resetToDefaults() { + setEditingLayout([...roleDefault]) + } + + // ─── Grid mutations ──────────────────────────────────────────────────────── + + function onGridChange(updated: readonly LayoutItem[]) { + if (!isEditing) return + setEditingLayout(prev => applyGridChanges(updated, prev)) + } + + function removeWidget(id: string) { + setEditingLayout(prev => prev.filter(item => item.i !== id)) + } + + function addWidget(widgetType: string) { + const def = WIDGET_REGISTRY[widgetType] + if (!def) return + const newItem: WidgetLayoutItem = { + i: `${widgetType}-${Date.now()}`, + widgetType, + x: 0, + y: Infinity, + w: def.defaultW, + h: def.defaultH, + } + setEditingLayout(prev => [...prev, newItem]) + } + + // ─── Render ──────────────────────────────────────────────────────────────── + + const activeLayout = isEditing ? editingLayout : savedLayout + + if (isLoading) { + return ( +
+
+
+ + +
+ +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Dashboard

+

+ {isEditing + ? 'Drag to reposition · Resize from the corner · Add or remove widgets' + : `Welcome back, ${user.fullName}`} +

+
+
+ {isEditing ? ( + <> + + + + + + ) : ( + + )} +
+
+ + {/* Grid */} + {activeLayout.length === 0 ? ( +
+

Your dashboard is empty

+

+ Click Customize, then Add widget to get started. +

+ {!isEditing && ( + + )} +
+ ) : ( +
+ + {activeLayout.map(item => ( +
+ removeWidget(item.i)} + /> +
+ ))} +
+
+ )} + + {/* Widget Picker */} + setPickerOpen(false)} + role={user.role} + currentLayout={editingLayout} + onAdd={addWidget} + /> +
+ ) +} diff --git a/distribution_fe/components/dashboard/widget-container.tsx b/distribution_fe/components/dashboard/widget-container.tsx new file mode 100644 index 0000000..a6f5bd3 --- /dev/null +++ b/distribution_fe/components/dashboard/widget-container.tsx @@ -0,0 +1,55 @@ +'use client' + +import { WIDGET_REGISTRY } from '@/lib/widgets/registry' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { Cancel01Icon, DragDropVerticalIcon } from '@hugeicons/core-free-icons' +import { cn } from '@/lib/utils' + +interface WidgetContainerProps { + widgetType: string + editMode: boolean + onRemove: () => void +} + +export function WidgetContainer({ widgetType, editMode, onRemove }: WidgetContainerProps) { + const def = WIDGET_REGISTRY[widgetType] + const Component = def?.component + + return ( + + +
+ {editMode && ( + + )} + {def?.label ?? widgetType} +
+ {editMode && ( + + )} +
+ + {Component ? ( + + ) : ( +

Unknown widget type: {widgetType}

+ )} +
+
+ ) +} diff --git a/distribution_fe/components/dashboard/widget-picker.tsx b/distribution_fe/components/dashboard/widget-picker.tsx new file mode 100644 index 0000000..20ad2ce --- /dev/null +++ b/distribution_fe/components/dashboard/widget-picker.tsx @@ -0,0 +1,76 @@ +'use client' + +import { getWidgetsForRole, type WidgetDefinition } from '@/lib/widgets/registry' +import type { Role } from '@/contexts/auth-context' +import type { WidgetLayoutItem } from '@/lib/api/dashboard' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from '@/components/ui/sheet' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +interface WidgetPickerProps { + open: boolean + onClose: () => void + role: Role + currentLayout: WidgetLayoutItem[] + onAdd: (widgetType: string) => void +} + +export function WidgetPicker({ open, onClose, role, currentLayout, onAdd }: WidgetPickerProps) { + const available = getWidgetsForRole(role) + const activeTypes = new Set(currentLayout.map(l => l.widgetType)) + + function handleAdd(def: WidgetDefinition) { + onAdd(def.type) + onClose() + } + + return ( + { if (!v) onClose() }}> + + + Add Widget + + Choose a widget to add to your dashboard. + + + +
+ {available.map(def => { + const alreadyAdded = activeTypes.has(def.type) + return ( +
+
+

{def.label}

+

{def.description}

+

+ Default size: {def.defaultW}×{def.defaultH} +

+
+ +
+ ) + })} +
+
+
+ ) +} diff --git a/distribution_fe/components/dashboard/widgets/active-rides-widget.tsx b/distribution_fe/components/dashboard/widgets/active-rides-widget.tsx new file mode 100644 index 0000000..60a5056 --- /dev/null +++ b/distribution_fe/components/dashboard/widgets/active-rides-widget.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { rides } from '@/lib/api/index' +import type { RideResponse } from '@/types/api' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' + +export function ActiveRidesWidget() { + const [data, setData] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const res = await rides.list({ status: 'Active', pageSize: 5, page: 1 }) + setData(res.items) + setTotal(res.total) + } catch { + // silent — widget shows empty state + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + if (loading) { + return ( +
+ + + + +
+ ) + } + + return ( +
+
+ {total} + active ride{total !== 1 ? 's' : ''} +
+
+ {data.length === 0 ? ( +

No active rides right now

+ ) : ( + data.map(ride => ( +
+
+ {ride.driverFullName} + {ride.routeName} · {ride.vehicleNumberPlate} +
+ {ride.rideType} +
+ )) + )} +
+
+ ) +} diff --git a/distribution_fe/components/dashboard/widgets/loading-sheets-widget.tsx b/distribution_fe/components/dashboard/widgets/loading-sheets-widget.tsx new file mode 100644 index 0000000..0a5686b --- /dev/null +++ b/distribution_fe/components/dashboard/widgets/loading-sheets-widget.tsx @@ -0,0 +1,82 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { loadingSheets } from '@/lib/api/index' +import type { LoadingSheetResponse } from '@/types/api' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' + +const STATUS_VARIANT: Record = { + Pending: 'outline', + DispatchConfirmed: 'secondary', + ReceiptConfirmed: 'default', + DiscrepancyFlagged: 'destructive', + Resolved: 'default', +} + +const STATUS_LABEL: Record = { + Pending: 'Pending', + DispatchConfirmed: 'Dispatched', + ReceiptConfirmed: 'Received', + DiscrepancyFlagged: 'Discrepancy', + Resolved: 'Resolved', +} + +export function LoadingSheetsWidget() { + const [data, setData] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const res = await loadingSheets.list({ status: 'Pending', pageSize: 5, page: 1 }) + setData(res.items) + setTotal(res.total) + } catch { + // silent + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + if (loading) { + return ( +
+ + + +
+ ) + } + + return ( +
+
+ {total} + pending sheet{total !== 1 ? 's' : ''} +
+
+ {data.length === 0 ? ( +

No pending loading sheets

+ ) : ( + data.map(sheet => ( +
+
+ {sheet.warehouseName} + + {sheet.items.length} item{sheet.items.length !== 1 ? 's' : ''} + +
+ + {STATUS_LABEL[sheet.status] ?? sheet.status} + +
+ )) + )} +
+
+ ) +} diff --git a/distribution_fe/components/dashboard/widgets/pending-pos-widget.tsx b/distribution_fe/components/dashboard/widgets/pending-pos-widget.tsx new file mode 100644 index 0000000..b72c94b --- /dev/null +++ b/distribution_fe/components/dashboard/widgets/pending-pos-widget.tsx @@ -0,0 +1,65 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { purchaseOrders } from '@/lib/api/index' +import type { PurchaseOrderResponse } from '@/types/api' +import { Skeleton } from '@/components/ui/skeleton' + +export function PendingPOsWidget() { + const [data, setData] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const res = await purchaseOrders.list({ status: 'Issued', pageSize: 5, page: 1 }) + setData(res.items) + setTotal(res.total) + } catch { + // silent + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + if (loading) { + return ( +
+ + + +
+ ) + } + + return ( +
+
+ {total} + pending order{total !== 1 ? 's' : ''} +
+
+ {data.length === 0 ? ( +

No pending purchase orders

+ ) : ( + data.map(po => ( +
+
+ {po.supplierName} + + {po.items.length} line{po.items.length !== 1 ? 's' : ''} + +
+ + Rs. {po.items.reduce((s, i) => s + i.quantity * i.unitCost, 0).toLocaleString('en-LK', { minimumFractionDigits: 0 })} + +
+ )) + )} +
+
+ ) +} diff --git a/distribution_fe/components/dashboard/widgets/recent-orders-widget.tsx b/distribution_fe/components/dashboard/widgets/recent-orders-widget.tsx new file mode 100644 index 0000000..2c8d12a --- /dev/null +++ b/distribution_fe/components/dashboard/widgets/recent-orders-widget.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { orders } from '@/lib/api/index' +import type { OrderResponse } from '@/types/api' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' + +const STATUS_VARIANT: Record = { + Pending: 'outline', + PartiallyFulfilled: 'secondary', + Fulfilled: 'default', + Cancelled: 'destructive', +} + +export function RecentOrdersWidget() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const res = await orders.list({ pageSize: 6, page: 1 }) + setData(res.items) + } catch { + // silent + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + if (loading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) + } + + return ( +
+ {data.length === 0 ? ( +

No orders yet

+ ) : ( +
+ {data.map(order => ( +
+
+ + #{order.id.slice(0, 8).toUpperCase()} + + + Rs. {order.totalAmount.toLocaleString('en-LK', { minimumFractionDigits: 2 })} + +
+ + {order.status} + +
+ ))} +
+ )} +
+ ) +} diff --git a/distribution_fe/components/dashboard/widgets/stock-alerts-widget.tsx b/distribution_fe/components/dashboard/widgets/stock-alerts-widget.tsx new file mode 100644 index 0000000..ee009b0 --- /dev/null +++ b/distribution_fe/components/dashboard/widgets/stock-alerts-widget.tsx @@ -0,0 +1,77 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { stock } from '@/lib/api/index' +import type { StockBalanceResponse } from '@/types/api' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' + +const LOW_STOCK_THRESHOLD = 10 + +export function StockAlertsWidget() { + const [alerts, setAlerts] = useState([]) + const [loading, setLoading] = useState(true) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const res = await stock.balances({ pageSize: 100, page: 1 }) + const low = res.items.filter(b => b.quantity <= LOW_STOCK_THRESHOLD) + setAlerts(low) + } catch { + // silent + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + if (loading) { + return ( +
+ + + + +
+ ) + } + + const outOfStock = alerts.filter(a => a.quantity === 0) + const lowStock = alerts.filter(a => a.quantity > 0) + + return ( +
+
+ {alerts.length} + + alert{alerts.length !== 1 ? 's' : ''} + {outOfStock.length > 0 && ( + ({outOfStock.length} out of stock) + )} + +
+
+ {alerts.length === 0 ? ( +

All stock levels are healthy

+ ) : ( + alerts.map(b => ( +
+
+ {b.itemName} + {b.locationType} +
+ + {b.quantity === 0 ? 'Out of stock' : `${b.quantity} left`} + +
+ )) + )} +
+
+ ) +} diff --git a/distribution_fe/components/dashboard/widgets/today-sales-widget.tsx b/distribution_fe/components/dashboard/widgets/today-sales-widget.tsx new file mode 100644 index 0000000..cddc5d2 --- /dev/null +++ b/distribution_fe/components/dashboard/widgets/today-sales-widget.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { sales } from '@/lib/api/index' +import type { SaleResponse } from '@/types/api' +import { Skeleton } from '@/components/ui/skeleton' + +export function TodaySalesWidget() { + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + + const fetchData = useCallback(async () => { + setLoading(true) + const today = new Date().toISOString().split('T')[0] + try { + const res = await sales.list({ from: today, to: today, pageSize: 50, page: 1 }) + setItems(res.items) + setTotal(res.total) + } catch { + // silent + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + if (loading) { + return ( +
+ +
+ + +
+
+ ) + } + + const revenue = items.reduce((sum, s) => sum + s.totalAmount, 0) + const completed = items.filter(s => s.status === 'Completed').length + const pending = items.filter(s => s.status === 'Pending').length + + return ( +
+
+ + Rs. {revenue.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + revenue today +
+
+
+ {total} + total +
+
+ {completed} + completed +
+
+ {pending} + pending +
+
+
+ ) +} diff --git a/distribution_fe/components/ui/card.tsx b/distribution_fe/components/ui/card.tsx new file mode 100644 index 0000000..3a65e25 --- /dev/null +++ b/distribution_fe/components/ui/card.tsx @@ -0,0 +1,63 @@ +import { cn } from '@/lib/utils' + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<'p'>) { + return ( +

+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( +

+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +

+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } diff --git a/distribution_fe/lib/api/dashboard.ts b/distribution_fe/lib/api/dashboard.ts new file mode 100644 index 0000000..330f13f --- /dev/null +++ b/distribution_fe/lib/api/dashboard.ts @@ -0,0 +1,31 @@ +import { api } from '@/lib/api' + +export interface WidgetLayoutItem { + i: string + widgetType: string + x: number + y: number + w: number + h: number +} + +export interface DashboardPreferencesResponse { + userId: string | null + role: string + isDefault: boolean + layout: WidgetLayoutItem[] +} + +export const dashboard = { + getPreferences: () => + api.get('/dashboard/preferences').then(r => r.data), + + savePreferences: (layout: WidgetLayoutItem[]) => + api.put('/dashboard/preferences', { layout }).then(r => r.data), + + getDefaults: (role: string) => + api.get(`/dashboard/defaults/${role}`).then(r => r.data), + + saveDefaults: (role: string, layout: WidgetLayoutItem[]) => + api.put(`/dashboard/defaults/${role}`, { layout }).then(r => r.data), +} diff --git a/distribution_fe/lib/api/index.ts b/distribution_fe/lib/api/index.ts index 397f23a..a8a9b52 100644 --- a/distribution_fe/lib/api/index.ts +++ b/distribution_fe/lib/api/index.ts @@ -10,6 +10,8 @@ export { rides, loadingSheets, deliveryTasks } from './operations' export { stock } from './stock' export { sales, orders, payments } from './sales' export { getPaginatedItems } from './paginated' +export { dashboard } from './dashboard' +export type { WidgetLayoutItem, DashboardPreferencesResponse } from './dashboard' export type { PageParams } from './master' export { getApiError } from '@/lib/api' diff --git a/distribution_fe/lib/widgets/defaults.ts b/distribution_fe/lib/widgets/defaults.ts new file mode 100644 index 0000000..26db42a --- /dev/null +++ b/distribution_fe/lib/widgets/defaults.ts @@ -0,0 +1,22 @@ +import type { WidgetLayoutItem } from '@/lib/api/dashboard' + +export const ROLE_DEFAULT_LAYOUTS: Record = { + admin: [ + { i: 'today-sales-1', widgetType: 'today-sales', x: 0, y: 0, w: 4, h: 4 }, + { i: 'active-rides-1', widgetType: 'active-rides', x: 4, y: 0, w: 4, h: 4 }, + { i: 'stock-alerts-1', widgetType: 'stock-alerts', x: 8, y: 0, w: 4, h: 4 }, + { i: 'pending-purchase-orders-1', widgetType: 'pending-purchase-orders', x: 0, y: 4, w: 4, h: 4 }, + { i: 'loading-sheets-1', widgetType: 'loading-sheets', x: 4, y: 4, w: 4, h: 4 }, + { i: 'recent-orders-1', widgetType: 'recent-orders', x: 0, y: 8, w: 8, h: 4 }, + ], + warehouse: [ + { i: 'active-rides-1', widgetType: 'active-rides', x: 0, y: 0, w: 6, h: 4 }, + { i: 'stock-alerts-1', widgetType: 'stock-alerts', x: 6, y: 0, w: 6, h: 4 }, + { i: 'loading-sheets-1', widgetType: 'loading-sheets', x: 0, y: 4, w: 6, h: 4 }, + { i: 'pending-purchase-orders-1', widgetType: 'pending-purchase-orders', x: 6, y: 4, w: 6, h: 4 }, + ], + sales: [ + { i: 'today-sales-1', widgetType: 'today-sales', x: 0, y: 0, w: 4, h: 4 }, + { i: 'recent-orders-1', widgetType: 'recent-orders', x: 4, y: 0, w: 8, h: 4 }, + ], +} diff --git a/distribution_fe/lib/widgets/registry.tsx b/distribution_fe/lib/widgets/registry.tsx new file mode 100644 index 0000000..919a2b5 --- /dev/null +++ b/distribution_fe/lib/widgets/registry.tsx @@ -0,0 +1,97 @@ +import type { ComponentType } from 'react' +import type { Role } from '@/contexts/auth-context' + +import { ActiveRidesWidget } from '@/components/dashboard/widgets/active-rides-widget' +import { TodaySalesWidget } from '@/components/dashboard/widgets/today-sales-widget' +import { PendingPOsWidget } from '@/components/dashboard/widgets/pending-pos-widget' +import { StockAlertsWidget } from '@/components/dashboard/widgets/stock-alerts-widget' +import { RecentOrdersWidget } from '@/components/dashboard/widgets/recent-orders-widget' +import { LoadingSheetsWidget } from '@/components/dashboard/widgets/loading-sheets-widget' + +export interface WidgetDefinition { + type: string + label: string + description: string + defaultW: number + defaultH: number + minW: number + minH: number + maxW?: number + allowedRoles: Role[] | '*' + component: ComponentType +} + +export const WIDGET_REGISTRY: Record = { + 'active-rides': { + type: 'active-rides', + label: 'Active Rides', + description: 'Live view of all currently active rides with driver and route info.', + defaultW: 4, + defaultH: 4, + minW: 3, + minH: 3, + allowedRoles: ['admin', 'warehouse'], + component: ActiveRidesWidget, + }, + 'today-sales': { + type: 'today-sales', + label: "Today's Sales", + description: "Total revenue and sales counts for today.", + defaultW: 4, + defaultH: 4, + minW: 2, + minH: 3, + allowedRoles: ['admin', 'sales'], + component: TodaySalesWidget, + }, + 'pending-purchase-orders': { + type: 'pending-purchase-orders', + label: 'Pending Purchase Orders', + description: 'Purchase orders that have been issued but not yet fully received.', + defaultW: 4, + defaultH: 4, + minW: 3, + minH: 3, + allowedRoles: ['admin', 'warehouse'], + component: PendingPOsWidget, + }, + 'stock-alerts': { + type: 'stock-alerts', + label: 'Stock Alerts', + description: 'Items at zero or critically low stock levels across all locations.', + defaultW: 4, + defaultH: 4, + minW: 3, + minH: 3, + allowedRoles: ['admin', 'warehouse'], + component: StockAlertsWidget, + }, + 'recent-orders': { + type: 'recent-orders', + label: 'Recent Orders', + description: 'Latest customer orders with status and value.', + defaultW: 5, + defaultH: 4, + minW: 4, + minH: 3, + allowedRoles: ['admin', 'sales'], + component: RecentOrdersWidget, + }, + 'loading-sheets': { + type: 'loading-sheets', + label: 'Pending Loading Sheets', + description: 'Loading sheets awaiting dispatch confirmation.', + defaultW: 4, + defaultH: 4, + minW: 3, + minH: 3, + allowedRoles: ['admin', 'warehouse'], + component: LoadingSheetsWidget, + }, +} + +export function getWidgetsForRole(role: Role): WidgetDefinition[] { + return Object.values(WIDGET_REGISTRY).filter(def => + def.allowedRoles === '*' || def.allowedRoles.includes(role) + ) +} diff --git a/distribution_fe/package-lock.json b/distribution_fe/package-lock.json index 77a5ce9..53662eb 100644 --- a/distribution_fe/package-lock.json +++ b/distribution_fe/package-lock.json @@ -19,6 +19,7 @@ "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", + "react-grid-layout": "^2.2.4", "react-hook-form": "^7.86.0", "shadcn": "^4.19.0", "tailwind-merge": "^3.6.0", @@ -5092,6 +5093,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "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.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -6969,7 +6976,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -8087,7 +8093,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -8212,6 +8217,38 @@ "react": "^19.2.4" } }, + "node_modules/react-draggable": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz", + "integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz", + "integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.1.3", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-hook-form": { "version": "7.86.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.86.0.tgz", @@ -8232,9 +8269,22 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/recast": { "version": "0.23.21", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", @@ -8310,6 +8360,12 @@ "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", "license": "MIT" }, + "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": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", diff --git a/distribution_fe/package.json b/distribution_fe/package.json index ca83e2c..5b780c9 100644 --- a/distribution_fe/package.json +++ b/distribution_fe/package.json @@ -23,6 +23,7 @@ "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", + "react-grid-layout": "^2.2.4", "react-hook-form": "^7.86.0", "shadcn": "^4.19.0", "tailwind-merge": "^3.6.0",