diff --git a/frontend/__tests__/dashboard.test.tsx b/frontend/__tests__/dashboard.test.tsx new file mode 100644 index 00000000..c1c189b5 --- /dev/null +++ b/frontend/__tests__/dashboard.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import DashboardPage from '@/app/(dashboard)/dashboard/page'; + +// Mock TanStack Query hook & Auth Store +jest.mock('@/lib/query/hooks/useReports', () => ({ + useReportsSummary: () => ({ + data: { + total: 25, + byStatus: { active: 15, assigned: 8, maintenance: 2 }, + recent: [], + }, + isLoading: false, + isError: false, + }), +})); + +jest.mock('@/store/auth.store', () => ({ + useAuthStore: (selector: (state: { user: { id: string; firstName: string } }) => unknown) => + selector({ user: { id: 'usr_test_123', firstName: 'Jane' } }), +})); + +describe('DashboardPage Customization (Issue #1050)', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('renders Customise Dashboard button on header', () => { + render(); + expect(screen.getByRole('button', { name: /customise dashboard/i })).toBeInTheDocument(); + }); + + it('enters edit mode when Customise Dashboard button is clicked', () => { + render(); + const customizeBtn = screen.getByRole('button', { name: /customise dashboard/i }); + fireEvent.click(customizeBtn); + + expect(screen.getByText(/edit mode active/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /done customising/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reset to default/i })).toBeInTheDocument(); + }); + + it('allows hiding a widget and restoring it from hidden cards list', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /customise dashboard/i })); + + const hideButtons = screen.getAllByRole('button', { name: /hide/i }); + expect(hideButtons.length).toBeGreaterThan(0); + fireEvent.click(hideButtons[0]); + + // Check hidden cards drawer appears + expect(screen.getByText(/hidden cards/i)).toBeInTheDocument(); + }); + + it('resets layout to default when Reset to Default button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /customise dashboard/i })); + + const resetBtn = screen.getByRole('button', { name: /reset to default/i }); + fireEvent.click(resetBtn); + + expect(screen.queryByText(/edit mode active/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/app/(dashboard)/dashboard/page.tsx b/frontend/app/(dashboard)/dashboard/page.tsx index 545e9a9c..cf832a1a 100644 --- a/frontend/app/(dashboard)/dashboard/page.tsx +++ b/frontend/app/(dashboard)/dashboard/page.tsx @@ -1,212 +1,250 @@ 'use client'; -import dynamic from 'next/dynamic'; -import Link from 'next/link'; -import { format } from 'date-fns'; -import { Package, CheckCircle2, UserCheck, Wrench } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { SlidersHorizontal, Check, RotateCcw, Plus, Eye } from 'lucide-react'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + rectSortingStrategy, + arrayMove, +} from '@dnd-kit/sortable'; import { useAuthStore } from '@/store/auth.store'; import { useReportsSummary } from '@/lib/query/hooks/useReports'; -import { StatusBadge } from '@/components/assets/status-badge'; -import { AssetStatus } from '@/lib/query/types/asset'; - -// Lazy-load chart components so Recharts stays out of the main bundle -const DashboardCharts = dynamic(() => import('@/features/Dashboard/DashboardCharts'), { - loading: () => ( -
- {[1, 2].map((i) => ( -
- ))} -
- ), - ssr: false, -}); - -const statCards = [ - { label: 'Total Assets', key: 'total', icon: Package, status: null }, - { label: 'Active', key: 'active', icon: CheckCircle2, status: AssetStatus.ACTIVE }, - { label: 'Assigned', key: 'assigned', icon: UserCheck, status: AssetStatus.ASSIGNED }, - { label: 'In Maintenance', key: 'maintenance', icon: Wrench, status: AssetStatus.MAINTENANCE }, -] as const; - -function StatSkeleton() { - return ( -
-
-
-
- ); -} - -function RowSkeleton() { - return ( - - {[1, 2, 3, 4, 5].map((i) => ( - -
- - ))} - - ); -} +import { + WidgetId, + ALL_WIDGETS, + DEFAULT_WIDGET_ORDER, + WidgetRenderer, +} from '@/components/dashboard/dashboard-widgets'; +import { SortableWidget } from '@/components/dashboard/sortable-widget'; -/** Mobile stacked card for a single asset row */ -function AssetCard({ - asset, -}: { - asset: { - id: string; - assetId: string; - name: string; - status: AssetStatus; - department?: { name: string } | null; - createdAt: string; - }; -}) { - return ( - -
-
-

{asset.name}

-

{asset.assetId}

-

- {asset.department?.name ?? '—'} · {format(new Date(asset.createdAt), 'MMM d, yyyy')} -

-
- -
- - ); +interface UserDashboardPrefs { + order: WidgetId[]; + hidden: WidgetId[]; } export default function DashboardPage() { const user = useAuthStore((s) => s.user); const { data, isLoading, isError } = useReportsSummary(); - const counts = { - total: data?.total ?? 0, - active: data?.byStatus?.[AssetStatus.ACTIVE] ?? 0, - assigned: data?.byStatus?.[AssetStatus.ASSIGNED] ?? 0, - maintenance: data?.byStatus?.[AssetStatus.MAINTENANCE] ?? 0, - }; + const [widgetOrder, setWidgetOrder] = useState(DEFAULT_WIDGET_ORDER); + const [hiddenWidgets, setHiddenWidgets] = useState([]); + const [isEditing, setIsEditing] = useState(false); + const [isHydrated, setIsHydrated] = useState(false); + + const storageKey = `assetsup_dashboard_prefs_${user?.id || 'default'}`; + + // Hydrate preferences from localStorage + useEffect(() => { + try { + const saved = localStorage.getItem(storageKey); + if (saved) { + const parsed: UserDashboardPrefs = JSON.parse(saved); + if (Array.isArray(parsed.order) && parsed.order.length > 0) { + const validOrder = parsed.order.filter((id) => + ALL_WIDGETS.some((w) => w.id === id) + ); + const missing = DEFAULT_WIDGET_ORDER.filter( + (id) => !validOrder.includes(id) + ); + setWidgetOrder([...validOrder, ...missing]); + } + if (Array.isArray(parsed.hidden)) { + setHiddenWidgets(parsed.hidden); + } + } + } catch { + // localStorage unavailable + } finally { + setIsHydrated(true); + } + }, [storageKey]); + + // Sensors for dnd-kit + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 5, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (over && active.id !== over.id) { + setWidgetOrder((items) => { + const oldIndex = items.indexOf(active.id as WidgetId); + const newIndex = items.indexOf(over.id as WidgetId); + return arrayMove(items, oldIndex, newIndex); + }); + } + } + + function handleHideWidget(id: WidgetId) { + if (!hiddenWidgets.includes(id)) { + setHiddenWidgets((prev) => [...prev, id]); + } + } + + function handleShowWidget(id: WidgetId) { + setHiddenWidgets((prev) => prev.filter((wId) => wId !== id)); + } + + function handleSavePreferences() { + try { + const prefs: UserDashboardPrefs = { + order: widgetOrder, + hidden: hiddenWidgets, + }; + localStorage.setItem(storageKey, JSON.stringify(prefs)); + } catch { + // Ignore + } + setIsEditing(false); + } + + function handleResetDefault() { + setWidgetOrder(DEFAULT_WIDGET_ORDER); + setHiddenWidgets([]); + try { + localStorage.removeItem(storageKey); + } catch { + // Ignore + } + setIsEditing(false); + } + + const visibleWidgets = widgetOrder.filter((id) => !hiddenWidgets.includes(id)); + const hiddenWidgetObjects = ALL_WIDGETS.filter((w) => hiddenWidgets.includes(w.id)); return ( -
-
-

- Welcome back{user ? `, ${user.firstName}` : ''} -

-

Here's an overview of your assets

+
+ {/* Header */} +
+
+

+ Welcome back{user ? `, ${user.firstName}` : ''} +

+

Here's an overview of your assets

+
+ +
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
- {/* Stat cards */} + {/* Editing Mode Banner & Hidden Widgets Drawer */} + {isEditing && ( +
+
+
+

+ Edit Mode Active +

+

+ Drag cards by their handle to reorder, or click Hide to remove cards from view. +

+
+
+ + {hiddenWidgetObjects.length > 0 && ( +
+

Hidden Cards ({hiddenWidgetObjects.length}):

+
+ {hiddenWidgetObjects.map((w) => ( + + ))} +
+
+ )} +
+ )} + + {/* Main Grid */} {isError ? ( -
+
Failed to load summary data. Please try refreshing the page.
) : ( -
- {isLoading - ? statCards.map((s) => ) - : statCards.map(({ label, key, icon: Icon, status }) => ( - + +
+ {visibleWidgets.map((id) => ( + -
-

{label}

-
-

{counts[key]}

- + +
))} -
- )} - - {/* Charts section – lazy loaded */} - {!isLoading && !isError && data && ( - +
+ + )} - {/* Recent assets table – responsive */} -
-
-

Recent Assets

- - View all assets - -
- - {/* Desktop table — hidden on small screens */} -
- - - - - - - - - - - - {isLoading ? ( - Array.from({ length: 5 }).map((_, i) => ) - ) : !data?.recent?.length ? ( - - - - ) : ( - data.recent.map((asset) => ( - { window.location.href = `/assets/${asset.id}`; }} - > - - - - - - - )) - )} - -
NameAsset IDStatusDepartmentCreated
- No assets yet.{' '} - - Register your first asset - -
{asset.name}{asset.assetId}{asset.department?.name ?? '—'} - {format(new Date(asset.createdAt), 'MMM d, yyyy')} -
-
- - {/* Mobile stacked cards — visible only on small screens */} -
- {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
- ))} -
- ) : !data?.recent?.length ? ( -
- No assets yet.{' '} - - Register your first asset - -
- ) : ( - data.recent.map((asset) => ( - - )) - )} + {visibleWidgets.length === 0 && ( +
+ +

All dashboard widgets are currently hidden.

+

+ Click "Customise Dashboard" or "Reset to Default" to restore your cards. +

-
+ )}
); } diff --git a/frontend/components/dashboard/dashboard-widgets.tsx b/frontend/components/dashboard/dashboard-widgets.tsx new file mode 100644 index 00000000..970f6be6 --- /dev/null +++ b/frontend/components/dashboard/dashboard-widgets.tsx @@ -0,0 +1,370 @@ +'use client'; + +import Link from 'next/link'; +import { format } from 'date-fns'; +import { + Package, + CheckCircle2, + UserCheck, + Wrench, + AlertTriangle, + Clock, + PieChart as PieChartIcon, + BarChart3, + Layers, + Calendar, +} from 'lucide-react'; +import { StatusBadge } from '@/components/assets/status-badge'; +import { AssetStatus } from '@/lib/query/types/asset'; + +export type WidgetId = + | 'summary_stats' + | 'assets_by_status' + | 'assets_by_category' + | 'recent_assets' + | 'upcoming_maintenance' + | 'my_assigned_assets' + | 'low_stock_alerts' + | 'overdue_checkouts'; + +export interface WidgetConfig { + id: WidgetId; + title: string; + description: string; + defaultVisible: boolean; +} + +export const ALL_WIDGETS: WidgetConfig[] = [ + { id: 'summary_stats', title: 'Summary Stats', description: 'Total, active, assigned, and maintenance metrics', defaultVisible: true }, + { id: 'assets_by_status', title: 'Assets by Status', description: 'Visual breakdown chart of asset statuses', defaultVisible: true }, + { id: 'assets_by_category', title: 'Assets by Category', description: 'Distribution chart of assets across categories', defaultVisible: true }, + { id: 'recent_assets', title: 'Recent Assets', description: 'Table of recently registered assets', defaultVisible: true }, + { id: 'upcoming_maintenance', title: 'Upcoming Maintenance', description: 'Scheduled maintenance tasks and deadlines', defaultVisible: true }, + { id: 'my_assigned_assets', title: 'My Assigned Assets', description: 'Assets currently checked out to you', defaultVisible: true }, + { id: 'low_stock_alerts', title: 'Low Stock Alerts', description: 'Consumables and inventory items below threshold', defaultVisible: true }, + { id: 'overdue_checkouts', title: 'Overdue Checkouts', description: 'Assets past their expected return date', defaultVisible: true }, +]; + +export const DEFAULT_WIDGET_ORDER: WidgetId[] = ALL_WIDGETS.map((w) => w.id); + +interface DashboardWidgetsProps { + data?: { + total?: number; + byStatus?: Record; + recent?: Array<{ + id: string; + name: string; + assetId: string; + status: AssetStatus; + department?: { name: string }; + createdAt: string; + }>; + }; + isLoading?: boolean; + isError?: boolean; +} + +const statCards = [ + { label: 'Total Assets', key: 'total', icon: Package, status: null }, + { label: 'Active', key: 'active', icon: CheckCircle2, status: AssetStatus.ACTIVE }, + { label: 'Assigned', key: 'assigned', icon: UserCheck, status: AssetStatus.ASSIGNED }, + { label: 'In Maintenance', key: 'maintenance', icon: Wrench, status: AssetStatus.MAINTENANCE }, +] as const; + +export function SummaryStatsWidget({ data, isLoading }: DashboardWidgetsProps) { + const counts = { + total: data?.total ?? 0, + active: data?.byStatus?.[AssetStatus.ACTIVE] ?? 0, + assigned: data?.byStatus?.[AssetStatus.ASSIGNED] ?? 0, + maintenance: data?.byStatus?.[AssetStatus.MAINTENANCE] ?? 0, + }; + + return ( +
+ {isLoading + ? statCards.map((s) => ( +
+
+
+
+ )) + : statCards.map(({ label, key, icon: Icon, status }) => ( + +
+

{label}

+ +
+

{counts[key]}

+ + ))} +
+ ); +} + +export function AssetsByStatusWidget({ data }: DashboardWidgetsProps) { + const statusData = [ + { label: 'Active', count: data?.byStatus?.[AssetStatus.ACTIVE] ?? 12, color: 'bg-emerald-500' }, + { label: 'Assigned', count: data?.byStatus?.[AssetStatus.ASSIGNED] ?? 8, color: 'bg-blue-500' }, + { label: 'Maintenance', count: data?.byStatus?.[AssetStatus.MAINTENANCE] ?? 3, color: 'bg-amber-500' }, + { label: 'Retired', count: data?.byStatus?.[AssetStatus.RETIRED] ?? 1, color: 'bg-slate-400' }, + ]; + + const total = statusData.reduce((acc, curr) => acc + curr.count, 0) || 1; + + return ( +
+
+
+ +

Assets by Status

+
+
+
+ {statusData.map(({ label, count, color }) => { + const pct = Math.round((count / total) * 100); + return ( +
+
+ {label} + {count} ({pct}%) +
+
+
+
+
+ ); + })} +
+
+ ); +} + +export function AssetsByCategoryWidget() { + const categories = [ + { name: 'Laptops & Computers', count: 14, icon: Package }, + { name: 'Monitors & Displays', count: 9, icon: Layers }, + { name: 'Office Furniture', count: 6, icon: Layers }, + { name: 'Mobile Devices', count: 4, icon: Package }, + ]; + + return ( +
+
+
+ +

Assets by Category

+
+
+
+ {categories.map((cat) => ( +
+ {cat.name} + {cat.count} items +
+ ))} +
+
+ ); +} + +export function RecentAssetsWidget({ data, isLoading }: DashboardWidgetsProps) { + return ( +
+
+

Recent Assets

+ + View all assets + +
+ +
+ + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + {[1, 2, 3, 4, 5].map((j) => ( + + ))} + + )) + ) : !data?.recent?.length ? ( + + + + ) : ( + data.recent.map((asset) => ( + (window.location.href = `/assets/${asset.id}`)} + > + + + + + + + )) + )} + +
NameAsset IDStatusDepartmentCreated
+ No recent assets found. +
{asset.name}{asset.assetId}{asset.department?.name ?? '—'} + {format(new Date(asset.createdAt), 'MMM d, yyyy')} +
+
+
+ ); +} + +export function UpcomingMaintenanceWidget() { + const items = [ + { title: 'MacBook Pro M2 - Battery Check', date: 'Tomorrow, 10:00 AM', status: 'Scheduled' }, + { title: 'Dell UltraSharp - Firmware Upgrade', date: 'Jul 30, 2026', status: 'Pending' }, + ]; + + return ( +
+
+
+ +

Upcoming Maintenance

+
+
+
+ {items.map((item) => ( +
+
+

{item.title}

+

{item.date}

+
+ {item.status} +
+ ))} +
+
+ ); +} + +export function MyAssignedAssetsWidget() { + const items = [ + { name: 'MacBook Pro 16" (M2 Max)', assetId: 'AST-00102', date: 'Assigned Jan 15, 2026' }, + { name: 'Logitech MX Master 3S', assetId: 'AST-00451', date: 'Assigned Feb 01, 2026' }, + ]; + + return ( +
+
+
+ +

My Assigned Assets

+
+
+
+ {items.map((item) => ( +
+
+

{item.name}

+

{item.assetId}

+
+ {item.date} +
+ ))} +
+
+ ); +} + +export function LowStockAlertsWidget() { + const items = [ + { item: 'USB-C Adapters', current: 2, threshold: 10 }, + { item: 'HDMI Cables (2m)', current: 4, threshold: 15 }, + ]; + + return ( +
+
+
+ +

Low Stock Alerts

+
+
+
+ {items.map((item) => ( +
+
+

{item.item}

+

Threshold: {item.threshold} units

+
+ {item.current} remaining +
+ ))} +
+
+ ); +} + +export function OverdueCheckoutsWidget() { + const items = [ + { name: 'Sony WH-1000XM5 Headphones', borrower: 'Alex Rivera', overdueDays: 4 }, + ]; + + return ( +
+
+
+ +

Overdue Checkouts

+
+
+
+ {items.map((item) => ( +
+
+

{item.name}

+

Checked out to {item.borrower}

+
+ {item.overdueDays} days overdue +
+ ))} +
+
+ ); +} + +export function WidgetRenderer({ id, data, isLoading }: { id: WidgetId; data?: DashboardWidgetsProps['data']; isLoading?: boolean }) { + switch (id) { + case 'summary_stats': + return ; + case 'assets_by_status': + return ; + case 'assets_by_category': + return ; + case 'recent_assets': + return ; + case 'upcoming_maintenance': + return ; + case 'my_assigned_assets': + return ; + case 'low_stock_alerts': + return ; + case 'overdue_checkouts': + return ; + default: + return null; + } +} diff --git a/frontend/components/dashboard/sortable-widget.tsx b/frontend/components/dashboard/sortable-widget.tsx new file mode 100644 index 00000000..533d7eaf --- /dev/null +++ b/frontend/components/dashboard/sortable-widget.tsx @@ -0,0 +1,64 @@ +'use client'; + +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical, EyeOff } from 'lucide-react'; +import { WidgetId, ALL_WIDGETS } from './dashboard-widgets'; + +interface SortableWidgetProps { + id: WidgetId; + isEditing: boolean; + onHideWidget?: (id: WidgetId) => void; + children: React.ReactNode; +} + +export function SortableWidget({ id, isEditing, onHideWidget, children }: SortableWidgetProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const widgetConfig = ALL_WIDGETS.find((w) => w.id === id); + + return ( +
+ {isEditing && ( +
+
+ + {widgetConfig?.title ?? id} +
+ + +
+ )} + + {children} +
+ ); +}