Compare commits

..

2 Commits

Author SHA1 Message Date
Kalana B. Thilakarathna cd5acbab76 fix(dashboard): handle null responses in widget data fetching 2026-08-29 20:36:01 +05:30
Kalana B. Thilakarathna 29f181d5e1 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.
2026-08-29 16:51:25 +05:30
17 changed files with 1089 additions and 9 deletions
+3 -6
View File
@@ -1,8 +1,5 @@
import { DashboardCanvas } from '@/components/dashboard/dashboard-canvas'
export default function DashboardPage() {
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
<p className="text-muted-foreground">Welcome to the Distribution ERP.</p>
</div>
)
return <DashboardCanvas />
}
@@ -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<WidgetLayoutItem[]>([])
const [editingLayout, setEditingLayout] = useState<WidgetLayoutItem[]>([])
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 (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1.5">
<Skeleton className="h-7 w-28" />
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-9 w-24" />
</div>
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-48" />
))}
</div>
</div>
)
}
return (
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div>
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
<p className="text-sm text-muted-foreground">
{isEditing
? 'Drag to reposition · Resize from the corner · Add or remove widgets'
: `Welcome back, ${user.fullName}`}
</p>
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<Button variant="outline" size="sm" onClick={resetToDefaults} disabled={isSaving}>
<HugeiconsIcon icon={Refresh01Icon} size={14} strokeWidth={2} />
Reset
</Button>
<Button variant="outline" size="sm" onClick={() => setPickerOpen(true)} disabled={isSaving}>
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
Add widget
</Button>
<Button variant="outline" size="sm" onClick={cancelEditing} disabled={isSaving}>
<HugeiconsIcon icon={Cancel01Icon} size={14} strokeWidth={2} />
Cancel
</Button>
<Button size="sm" onClick={saveLayout} disabled={isSaving}>
<HugeiconsIcon icon={Tick02Icon} size={14} strokeWidth={2} />
{isSaving ? 'Saving…' : 'Save'}
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={startEditing}>
<HugeiconsIcon icon={PencilEdit01Icon} size={14} strokeWidth={2} />
Customize
</Button>
)}
</div>
</div>
{/* Grid */}
{activeLayout.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed py-20 text-center">
<p className="text-sm font-medium text-foreground">Your dashboard is empty</p>
<p className="mt-1 text-sm text-muted-foreground">
Click Customize, then Add widget to get started.
</p>
{!isEditing && (
<Button variant="outline" size="sm" className="mt-4" onClick={startEditing}>
<HugeiconsIcon icon={PencilEdit01Icon} size={14} strokeWidth={2} />
Customize
</Button>
)}
</div>
) : (
<div ref={containerRef}>
<GridLayout
width={width}
layout={toGridItems(activeLayout, isEditing)}
gridConfig={{ cols: 12, rowHeight: 80, margin: [12, 12] as const }}
dragConfig={{ handle: '.drag-handle' }}
resizeConfig={{ handles: ['se'] as const }}
onLayoutChange={onGridChange}
className="relative"
>
{activeLayout.map(item => (
<div key={item.i}>
<WidgetContainer
widgetType={item.widgetType}
editMode={isEditing}
onRemove={() => removeWidget(item.i)}
/>
</div>
))}
</GridLayout>
</div>
)}
{/* Widget Picker */}
<WidgetPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
role={user.role}
currentLayout={editingLayout}
onAdd={addWidget}
/>
</div>
)
}
@@ -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 (
<Card className={cn('flex h-full flex-col overflow-hidden transition-shadow', editMode && 'ring-1 ring-border')}>
<CardHeader className="flex-row items-center justify-between gap-2 pb-2 pt-3">
<div className="flex min-w-0 items-center gap-2">
{editMode && (
<span
className="drag-handle shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
aria-hidden="true"
>
<HugeiconsIcon icon={DragDropVerticalIcon} size={14} strokeWidth={2} />
</span>
)}
<CardTitle className="truncate">{def?.label ?? widgetType}</CardTitle>
</div>
{editMode && (
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={onRemove}
aria-label={`Remove ${def?.label ?? widgetType} widget`}
>
<HugeiconsIcon icon={Cancel01Icon} size={14} strokeWidth={2} />
</Button>
)}
</CardHeader>
<CardContent className="flex-1 overflow-hidden pt-0">
{Component ? (
<Component />
) : (
<p className="text-sm text-muted-foreground">Unknown widget type: {widgetType}</p>
)}
</CardContent>
</Card>
)
}
@@ -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 (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent side="right" className="flex w-full flex-col gap-0 sm:max-w-sm">
<SheetHeader className="px-4 pb-3 pt-4">
<SheetTitle>Add Widget</SheetTitle>
<SheetDescription>
Choose a widget to add to your dashboard.
</SheetDescription>
</SheetHeader>
<Separator />
<div className="flex flex-1 flex-col gap-1 overflow-auto p-4">
{available.map(def => {
const alreadyAdded = activeTypes.has(def.type)
return (
<div
key={def.type}
className="flex items-start justify-between gap-3 rounded-xl border p-3"
>
<div className="flex min-w-0 flex-col gap-0.5">
<p className="text-sm font-medium">{def.label}</p>
<p className="text-xs text-muted-foreground">{def.description}</p>
<p className="mt-1 text-xs text-muted-foreground">
Default size: {def.defaultW}&times;{def.defaultH}
</p>
</div>
<Button
size="sm"
variant={alreadyAdded ? 'outline' : 'default'}
className="mt-0.5 shrink-0"
onClick={() => handleAdd(def)}
>
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
{alreadyAdded ? 'Add another' : 'Add'}
</Button>
</div>
)
})}
</div>
</SheetContent>
</Sheet>
)
}
@@ -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<RideResponse[]>([])
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 ?? 0)
} catch {
// silent — widget shows empty state
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)
}
return (
<div className="flex h-full flex-col gap-3 overflow-hidden">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{total}</span>
<span className="text-sm text-muted-foreground">active ride{total !== 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-1.5 overflow-auto">
{data.length === 0 ? (
<p className="text-sm text-muted-foreground">No active rides right now</p>
) : (
data.map(ride => (
<div
key={ride.id}
className="flex items-center justify-between rounded-xl border px-3 py-2"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{ride.driverFullName}</span>
<span className="truncate text-xs text-muted-foreground">{ride.routeName} · {ride.vehicleNumberPlate}</span>
</div>
<Badge variant="secondary" className="ml-2 shrink-0">{ride.rideType}</Badge>
</div>
))
)}
</div>
</div>
)
}
@@ -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<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
Pending: 'outline',
DispatchConfirmed: 'secondary',
ReceiptConfirmed: 'default',
DiscrepancyFlagged: 'destructive',
Resolved: 'default',
}
const STATUS_LABEL: Record<string, string> = {
Pending: 'Pending',
DispatchConfirmed: 'Dispatched',
ReceiptConfirmed: 'Received',
DiscrepancyFlagged: 'Discrepancy',
Resolved: 'Resolved',
}
export function LoadingSheetsWidget() {
const [data, setData] = useState<LoadingSheetResponse[]>([])
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 ?? 0)
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)
}
return (
<div className="flex h-full flex-col gap-3 overflow-hidden">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{total}</span>
<span className="text-sm text-muted-foreground">pending sheet{total !== 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-1.5 overflow-auto">
{data.length === 0 ? (
<p className="text-sm text-muted-foreground">No pending loading sheets</p>
) : (
data.map(sheet => (
<div key={sheet.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{sheet.warehouseName}</span>
<span className="text-xs text-muted-foreground">
{sheet.items.length} item{sheet.items.length !== 1 ? 's' : ''}
</span>
</div>
<Badge variant={STATUS_VARIANT[sheet.status] ?? 'outline'} className="ml-2 shrink-0">
{STATUS_LABEL[sheet.status] ?? sheet.status}
</Badge>
</div>
))
)}
</div>
</div>
)
}
@@ -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<PurchaseOrderResponse[]>([])
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 ?? 0)
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)
}
return (
<div className="flex h-full flex-col gap-3 overflow-hidden">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{total}</span>
<span className="text-sm text-muted-foreground">pending order{total !== 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-1.5 overflow-auto">
{data.length === 0 ? (
<p className="text-sm text-muted-foreground">No pending purchase orders</p>
) : (
data.map(po => (
<div key={po.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{po.supplierName}</span>
<span className="text-xs text-muted-foreground">
{po.items.length} line{po.items.length !== 1 ? 's' : ''}
</span>
</div>
<span className="ml-2 shrink-0 text-sm font-semibold">
Rs.&nbsp;{po.items.reduce((s, i) => s + i.quantity * i.unitCost, 0).toLocaleString('en-LK', { minimumFractionDigits: 0 })}
</span>
</div>
))
)}
</div>
</div>
)
}
@@ -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<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
Pending: 'outline',
PartiallyFulfilled: 'secondary',
Fulfilled: 'default',
Cancelled: 'destructive',
}
export function RecentOrdersWidget() {
const [data, setData] = useState<OrderResponse[]>([])
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 (
<div className="flex flex-col gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)
}
return (
<div className="flex h-full flex-col gap-2 overflow-hidden">
{data.length === 0 ? (
<p className="text-sm text-muted-foreground">No orders yet</p>
) : (
<div className="flex flex-col gap-1.5 overflow-auto">
{data.map(order => (
<div key={order.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-mono text-xs text-muted-foreground">
#{order.id.slice(0, 8).toUpperCase()}
</span>
<span className="text-sm font-medium">
Rs.&nbsp;{order.totalAmount.toLocaleString('en-LK', { minimumFractionDigits: 2 })}
</span>
</div>
<Badge variant={STATUS_VARIANT[order.status] ?? 'outline'} className="ml-2 shrink-0">
{order.status}
</Badge>
</div>
))}
</div>
)}
</div>
)
}
@@ -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<StockBalanceResponse[]>([])
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 (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
)
}
const outOfStock = alerts.filter(a => a.quantity === 0)
const lowStock = alerts.filter(a => a.quantity > 0)
return (
<div className="flex h-full flex-col gap-3 overflow-hidden">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{alerts.length}</span>
<span className="text-sm text-muted-foreground">
alert{alerts.length !== 1 ? 's' : ''}
{outOfStock.length > 0 && (
<span className="ml-1 text-destructive">({outOfStock.length} out of stock)</span>
)}
</span>
</div>
<div className="flex flex-col gap-1.5 overflow-auto">
{alerts.length === 0 ? (
<p className="text-sm text-muted-foreground">All stock levels are healthy</p>
) : (
alerts.map(b => (
<div key={`${b.itemId}-${b.locationId}`} className="flex items-center justify-between rounded-xl border px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{b.itemName}</span>
<span className="text-xs text-muted-foreground capitalize">{b.locationType}</span>
</div>
<Badge
variant={b.quantity === 0 ? 'destructive' : 'outline'}
className="ml-2 shrink-0"
>
{b.quantity === 0 ? 'Out of stock' : `${b.quantity} left`}
</Badge>
</div>
))
)}
</div>
</div>
)
}
@@ -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<SaleResponse[]>([])
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 ?? 0)
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-32" />
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16" />
<Skeleton className="h-16" />
</div>
</div>
)
}
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 (
<div className="flex h-full flex-col gap-4">
<div className="flex flex-col">
<span className="text-3xl font-bold">
Rs.&nbsp;{revenue.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
<span className="text-sm text-muted-foreground">revenue today</span>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col rounded-xl border px-3 py-2">
<span className="text-xl font-bold">{total}</span>
<span className="text-xs text-muted-foreground">total</span>
</div>
<div className="flex flex-col rounded-xl border px-3 py-2">
<span className="text-xl font-bold text-green-600">{completed}</span>
<span className="text-xs text-muted-foreground">completed</span>
</div>
<div className="flex flex-col rounded-xl border px-3 py-2">
<span className="text-xl font-bold text-amber-500">{pending}</span>
<span className="text-xs text-muted-foreground">pending</span>
</div>
</div>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn('rounded-2xl border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn('flex flex-col gap-1 px-4 pt-4 pb-2', className)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="card-title"
className={cn('text-sm font-semibold leading-none', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="card-description"
className={cn('text-xs text-muted-foreground', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-4 pb-4', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-4 pb-4', className)}
{...props}
/>
)
}
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
+31
View File
@@ -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<DashboardPreferencesResponse>('/dashboard/preferences').then(r => r.data),
savePreferences: (layout: WidgetLayoutItem[]) =>
api.put<DashboardPreferencesResponse>('/dashboard/preferences', { layout }).then(r => r.data),
getDefaults: (role: string) =>
api.get<DashboardPreferencesResponse>(`/dashboard/defaults/${role}`).then(r => r.data),
saveDefaults: (role: string, layout: WidgetLayoutItem[]) =>
api.put<DashboardPreferencesResponse>(`/dashboard/defaults/${role}`, { layout }).then(r => r.data),
}
+2
View File
@@ -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'
+22
View File
@@ -0,0 +1,22 @@
import type { WidgetLayoutItem } from '@/lib/api/dashboard'
export const ROLE_DEFAULT_LAYOUTS: Record<string, WidgetLayoutItem[]> = {
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 },
],
}
+97
View File
@@ -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<string, WidgetDefinition> = {
'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)
)
}
+59 -3
View File
@@ -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",
+1
View File
@@ -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",