29f181d5e1
- 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.
253 lines
9.0 KiB
TypeScript
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>
|
|
)
|
|
}
|