Files
ERP_Distribution/distribution_fe/components/dashboard/dashboard-canvas.tsx
T
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

253 lines
9.0 KiB
TypeScript

'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>
)
}