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.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user