From b59d6b4ea9b9303702ea31e7545a53d1aa7e4345 Mon Sep 17 00:00:00 2001 From: Kaylahray Date: Thu, 29 Jan 2026 13:09:04 +0100 Subject: [PATCH] created assetup dashboard page --- .../app/api/dashboard/activities/route.ts | 34 ++ frontend/app/api/dashboard/charts/route.ts | 51 +++ frontend/app/api/dashboard/stats/route.ts | 48 +++ frontend/app/dashboard/page.tsx | 394 ++++++++++++++++++ .../components/dashboard/activityFeed.tsx | 109 +++++ .../components/dashboard/categoryChart.tsx | 99 +++++ .../dashboard/departmentBreakdown.tsx | 81 ++++ .../components/dashboard/quickActions.tsx | 78 ++++ .../dashboard/registrationChart.tsx | 87 ++++ .../components/dashboard/statsSection.tsx | 126 ++++++ .../components/dashboard/statusBreakdown.tsx | 105 +++++ frontend/hooks/useLocalStorageState.ts | 34 ++ frontend/lib/api/dashboard.ts | 23 + frontend/middleware.ts | 8 +- frontend/package.json | 5 + frontend/types/dashboard.ts | 40 ++ 16 files changed, 1318 insertions(+), 4 deletions(-) create mode 100644 frontend/app/api/dashboard/activities/route.ts create mode 100644 frontend/app/api/dashboard/charts/route.ts create mode 100644 frontend/app/api/dashboard/stats/route.ts create mode 100644 frontend/app/dashboard/page.tsx create mode 100644 frontend/components/dashboard/activityFeed.tsx create mode 100644 frontend/components/dashboard/categoryChart.tsx create mode 100644 frontend/components/dashboard/departmentBreakdown.tsx create mode 100644 frontend/components/dashboard/quickActions.tsx create mode 100644 frontend/components/dashboard/registrationChart.tsx create mode 100644 frontend/components/dashboard/statsSection.tsx create mode 100644 frontend/components/dashboard/statusBreakdown.tsx create mode 100644 frontend/hooks/useLocalStorageState.ts create mode 100644 frontend/lib/api/dashboard.ts create mode 100644 frontend/types/dashboard.ts diff --git a/frontend/app/api/dashboard/activities/route.ts b/frontend/app/api/dashboard/activities/route.ts new file mode 100644 index 0000000..1ecd168 --- /dev/null +++ b/frontend/app/api/dashboard/activities/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import type { Activity } from '@/types/dashboard'; + +function parseRange(range: string | null) { + const r = (range || '30d').toLowerCase(); + if (['7d', '30d', '90d', '6m', '12m'].includes(r)) return r; + return '30d'; +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + parseRange(searchParams.get('range')); + + const actions: Activity['actionType'][] = ['created', 'assigned', 'transferred', 'retired']; + const users = ['Alice Dev', 'Bob Ops', 'Chioma Admin', 'Jane IT']; + const assets = ['MacBook Pro M3', 'Dell XPS 15', 'iPhone 15', 'Lenovo ThinkPad', 'Office Chair']; + + const data: Activity[] = Array.from({ length: 10 }).map((_, i) => { + const actionType = actions[i % actions.length]; + const assetName = assets[i % assets.length]; + const assetId = `asset-${(i % 5) + 1}`; + return { + id: `act-${i}`, + assetId, + assetName, + actionType, + user: users[i % users.length], + timestamp: new Date(Date.now() - i * 36e5).toISOString(), + }; + }); + + return NextResponse.json(data); +} + diff --git a/frontend/app/api/dashboard/charts/route.ts b/frontend/app/api/dashboard/charts/route.ts new file mode 100644 index 0000000..414eee6 --- /dev/null +++ b/frontend/app/api/dashboard/charts/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import type { ChartData } from '@/types/dashboard'; + +function parseRange(range: string | null) { + const r = (range || '30d').toLowerCase(); + if (['7d', '30d', '90d', '6m', '12m'].includes(r)) return r; + return '30d'; +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const range = parseRange(searchParams.get('range')); + + // In production: compute based on real assets filtered by `range`. + const reg = + range === '7d' + ? [8, 12, 10, 14, 16, 18] + : range === '90d' + ? [26, 34, 42, 40, 60, 72] + : range === '6m' + ? [30, 45, 60, 50, 80, 95] + : range === '12m' + ? [34, 52, 70, 66, 92, 120] + : [24, 32, 48, 44, 68, 84]; + + const data: ChartData = { + categoryData: [ + { name: 'Hardware', value: 400, color: '#3b82f6' }, + { name: 'Software', value: 300, color: '#10b981' }, + { name: 'Furniture', value: 300, color: '#f59e0b' }, + { name: 'Vehicles', value: 200, color: '#ef4444' }, + ], + departmentData: [ + { name: 'Eng', assets: 120 }, + { name: 'HR', assets: 50 }, + { name: 'Sales', assets: 80 }, + { name: 'Ops', assets: 95 }, + ], + registrationData: [ + { date: 'Jan', count: reg[0] }, + { date: 'Feb', count: reg[1] }, + { date: 'Mar', count: reg[2] }, + { date: 'Apr', count: reg[3] }, + { date: 'May', count: reg[4] }, + { date: 'Jun', count: reg[5] }, + ], + }; + + return NextResponse.json(data); +} + diff --git a/frontend/app/api/dashboard/stats/route.ts b/frontend/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..fe86223 --- /dev/null +++ b/frontend/app/api/dashboard/stats/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import type { DashboardStatsResponse } from '@/types/dashboard'; + +function parseRange(range: string | null) { + // accepted: 7d, 30d, 90d, 6m, 12m + const r = (range || '30d').toLowerCase(); + if (['7d', '30d', '90d', '6m', '12m'].includes(r)) return r; + return '30d'; +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const range = parseRange(searchParams.get('range')); + + // In production: fetch from your backend/service using `range`. + // For now: deterministic mock values that can vary slightly by range. + const multiplier = + range === '7d' ? 0.8 : range === '90d' ? 1.15 : range === '6m' ? 1.25 : range === '12m' ? 1.4 : 1; + + const totalAssets = Math.round(12450 * multiplier); + const active = Math.round(8234 * multiplier); + const assigned = Math.round(3120 * multiplier); + const maintenance = Math.round(420 * multiplier); + const retired = Math.max(0, totalAssets - active - assigned - maintenance); + + const warrantyExpiring = Math.round(18 * multiplier); + const maintenanceDue = Math.round(27 * multiplier); + + const data: DashboardStatsResponse = { + cards: [ + { label: 'Total Assets', value: totalAssets, trend: 12, trendDirection: 'up', icon: 'asset' }, + { label: 'Active Assets', value: active.toLocaleString(), trend: 5, trendDirection: 'up', icon: 'status' }, + { label: 'Total Value', value: '$4.2M', trend: 2.1, trendDirection: 'up', icon: 'value' }, + { + label: 'Attention Needed', + value: warrantyExpiring + maintenanceDue, + trend: 15, + trendDirection: 'down', + icon: 'alert', + }, + ], + statusBreakdown: { active, assigned, maintenance, retired }, + attentionBreakdown: { warrantyExpiring, maintenanceDue }, + }; + + return NextResponse.json(data); +} + diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx new file mode 100644 index 0000000..e483c28 --- /dev/null +++ b/frontend/app/dashboard/page.tsx @@ -0,0 +1,394 @@ +'use client'; + +import StatsSection from '@/components/dashboard/statsSection'; +import ActivityFeed from '@/components/dashboard/activityFeed'; +import QuickActions from '@/components/dashboard/quickActions'; +import StatusBreakdown from '@/components/dashboard/statusBreakdown'; +import DepartmentBreakdown from '@/components/dashboard/departmentBreakdown'; +import RegistrationChart from '@/components/dashboard/registrationChart'; +import CategoryChart from '@/components/dashboard/categoryChart'; +import { Calendar, Sparkles, Radio, GripVertical, RotateCcw } from 'lucide-react'; +import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core'; +import { SortableContext, rectSortingStrategy, arrayMove, useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { restrictToParentElement } from '@dnd-kit/modifiers'; +import { useLocalStorageState } from '@/hooks/useLocalStorageState'; +import { useEffect } from 'react'; +import type { DashboardRange } from '@/lib/api/dashboard'; +import type { CSSProperties, ReactNode } from 'react'; + +type WidgetId = + | 'stats' + | 'status' + | 'department' + | 'registration' + | 'category' + | 'activity' + | 'actions' + | 'charts'; // legacy id for migration only + +function DraggableWidget({ + id, + children, + customize, + className, + title, +}: { + id: WidgetId; + children: ReactNode; + customize: boolean; + className?: string; + title: string; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + disabled: !customize, + }); + + const style: CSSProperties | undefined = customize + ? { transform: CSS.Transform.toString(transform), transition } + : undefined; + + return ( +
+ {customize ? ( +
+
+ {title} +
+ +
+ ) : null} + {children} +
+ ); +} + +export default function DashboardPage() { + const rangeState = useLocalStorageState('dashboard.range', '30d'); + const liveState = useLocalStorageState('dashboard.live', true); + const refreshState = useLocalStorageState('dashboard.refreshMs', 30000); + const customizeState = useLocalStorageState('dashboard.customize', false); + const orderState = useLocalStorageState('dashboard.widgetOrder', [ + 'stats', + 'status', + 'department', + 'registration', + 'category', + 'activity', + 'actions', + ]); + + // Layout migration: ensure new widgets appear even if user has an older saved layout. + useEffect(() => { + if (!orderState.isHydrated) return; + const required: WidgetId[] = [ + 'stats', + 'status', + 'department', + 'registration', + 'category', + 'activity', + 'actions', + ]; + const current = Array.isArray(orderState.value) ? orderState.value : []; + + // If old layout used a single "charts" widget, replace it with registration + category. + let normalized: WidgetId[] = current.flatMap((id) => + id === ('charts' as WidgetId) ? (['registration', 'category'] as WidgetId[]) : [id], + ); + + // Remove duplicates while preserving order + const seen = new Set(); + normalized = normalized.filter((id) => { + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + + const missing = required.filter((id) => !normalized.includes(id)); + if (missing.length === 0 && normalized.length === required.length) return; + + // Insert department right after status; registration & category after department; others at the end. + let next = [...normalized]; + for (const id of missing) { + if (id === 'department') { + const statusIdx = next.indexOf('status'); + const insertAt = statusIdx >= 0 ? statusIdx + 1 : 1; + next = [...next.slice(0, insertAt), 'department', ...next.slice(insertAt)]; + } else if (id === 'registration') { + const deptIdx = next.indexOf('department'); + const insertAt = deptIdx >= 0 ? deptIdx + 1 : next.length; + next = [...next.slice(0, insertAt), 'registration', ...next.slice(insertAt)]; + } else if (id === 'category') { + const regIdx = next.indexOf('registration'); + const insertAt = regIdx >= 0 ? regIdx + 1 : next.length; + next = [...next.slice(0, insertAt), 'category', ...next.slice(insertAt)]; + } else { + next.push(id); + } + } + orderState.setValue(next); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orderState.isHydrated]); + + const range = rangeState.value; + const live = liveState.value; + const refreshIntervalMs = live ? refreshState.value : false; + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = orderState.value.indexOf(active.id as WidgetId); + const newIndex = orderState.value.indexOf(over.id as WidgetId); + if (oldIndex === -1 || newIndex === -1) return; + orderState.setValue(arrayMove(orderState.value, oldIndex, newIndex)); + }; + + const resetLayout = () => { + orderState.setValue(['stats', 'status', 'department', 'registration', 'category', 'activity', 'actions']); + }; + + const renderWidget = (id: WidgetId) => { + const baseClass = + id === 'activity' + ? 'lg:col-span-8' + : id === 'actions' + ? 'lg:col-span-4' + : id === 'status' || id === 'department' || id === 'registration' || id === 'category' + ? 'lg:col-span-6' + : 'lg:col-span-12'; + + if (id === 'stats') { + return ( + + + + ); + } + if (id === 'status') { + return ( + + + + ); + } + if (id === 'department') { + return ( + + + + ); + } + if (id === 'registration') { + return ( + + + + ); + } + if (id === 'category') { + return ( + + + + ); + } + if (id === 'activity') { + return ( + + + + ); + } + if (id === 'actions') { + return ( + + + + ); + } + // Legacy 'charts' widget - should be migrated, but handle gracefully + if (id === 'charts') { + return null; // Don't render, migration should have replaced it + } + // Unknown widget id - don't render anything + return null; + }; + + return ( +
+
+ {/* Header */} +
+
+
+
+
+ +
+
+
+ + Smart overview of your assets +
+

+ Asset Intelligence +

+

+ Monitor registrations, categories, and department distribution in a single, elegant view. +

+
+ + {/* Date Filter (visual only) */} +
+
+ +
+ + Drives stats, charts, and activity +
+
+ +
+ + + + + + + + + {customizeState.value ? ( + + ) : null} +
+
+
+
+ + + +
+ {orderState.value.map((id) => renderWidget(id))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/dashboard/activityFeed.tsx b/frontend/components/dashboard/activityFeed.tsx new file mode 100644 index 0000000..ada6e83 --- /dev/null +++ b/frontend/components/dashboard/activityFeed.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { fetchActivities, type DashboardRange } from '@/lib/api/dashboard'; +import { format } from 'date-fns'; +import { UserCircle, ArrowRight, PlusCircle, UserCheck, Archive, ArrowLeftRight } from 'lucide-react'; +import Link from 'next/link'; + +export default function ActivityFeed({ + range, + refreshIntervalMs, +}: { + range: DashboardRange; + refreshIntervalMs: number | false; +}) { + const { data, isLoading } = useQuery({ + queryKey: ['dashboard-activities', range], + queryFn: ({ signal }) => fetchActivities(range, signal), + refetchInterval: refreshIntervalMs, + }); + + if (isLoading) { + return ( +
+ ); + } + + const getAccentClasses = (actionType: string) => { + if (actionType === 'created') { + return 'border-sky-500/40 bg-sky-500/10 text-sky-200'; + } + if (actionType === 'assigned') { + return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200'; + } + if (actionType === 'transferred') { + return 'border-violet-500/40 bg-violet-500/10 text-violet-200'; + } + if (actionType === 'retired') { + return 'border-amber-500/40 bg-amber-500/10 text-amber-200'; + } + return 'border-slate-600/40 bg-slate-800/40 text-slate-200'; + }; + + const getActionIcon = (actionType: string) => { + if (actionType === 'created') return PlusCircle; + if (actionType === 'assigned') return UserCheck; + if (actionType === 'transferred') return ArrowLeftRight; + if (actionType === 'retired') return Archive; + return UserCircle; + }; + + return ( +
+
+
+

+ Recent activity +

+

+ The latest changes happening across your assets. +

+
+ + View all + +
+
+ {data?.map((activity) => ( +
+
+ {(() => { + const Icon = getActionIcon(activity.actionType); + return ; + })()} +
+
+

+ {activity.user}{' '} + + {activity.actionType} + {' '} + {activity.assetName} +

+

+ {format(new Date(activity.timestamp), 'MMM d, h:mm a')} +

+
+ + + +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/dashboard/categoryChart.tsx b/frontend/components/dashboard/categoryChart.tsx new file mode 100644 index 0000000..a905431 --- /dev/null +++ b/frontend/components/dashboard/categoryChart.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { fetchChartData, type DashboardRange } from '@/lib/api/dashboard'; +import { + ResponsiveContainer, + PieChart, + Pie, + Cell, + Tooltip, + Legend, + Label, +} from 'recharts'; + +export default function CategoryChart({ + range, + refreshIntervalMs, +}: { + range: DashboardRange; + refreshIntervalMs: number | false; +}) { + const { data, isLoading } = useQuery({ + queryKey: ['dashboard-charts', range], + queryFn: ({ signal }) => fetchChartData(range, signal), + refetchInterval: refreshIntervalMs, + }); + + if (isLoading) { + return ( +
+ ); + } + + return ( +
+

+ Assets by category +

+

+ High-level distribution of your asset types. +

+
+ + + + {data?.categoryData.map((entry, index) => ( + + ))} + + + + + +
+
+ ); +} + diff --git a/frontend/components/dashboard/departmentBreakdown.tsx b/frontend/components/dashboard/departmentBreakdown.tsx new file mode 100644 index 0000000..e06b4c3 --- /dev/null +++ b/frontend/components/dashboard/departmentBreakdown.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { fetchChartData, type DashboardRange } from '@/lib/api/dashboard'; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, +} from 'recharts'; + +export default function DepartmentBreakdown({ + range, + refreshIntervalMs, +}: { + range: DashboardRange; + refreshIntervalMs: number | false; +}) { + const { data, isLoading } = useQuery({ + queryKey: ['dashboard-charts', range], + queryFn: ({ signal }) => fetchChartData(range, signal), + refetchInterval: refreshIntervalMs, + }); + + if (isLoading) { + return ( +
+ ); + } + + return ( +
+
+
+

+ Assets by department +

+

+ See which teams hold the largest share of your asset base. +

+
+ + Departments + +
+ +
+ + + + + + + + + +
+
+ ); +} + diff --git a/frontend/components/dashboard/quickActions.tsx b/frontend/components/dashboard/quickActions.tsx new file mode 100644 index 0000000..5ead9d0 --- /dev/null +++ b/frontend/components/dashboard/quickActions.tsx @@ -0,0 +1,78 @@ +import Link from 'next/link'; +import { Plus, FileText, Upload, ArrowRightLeft, ChevronRight, Sparkles } from 'lucide-react'; + +const actions = [ + { + label: 'Register new asset', + description: 'Capture a new item into your asset inventory.', + icon: Plus, + color: 'from-sky-500 to-cyan-400', + href: '/assets/new', + }, + { + label: 'Generate report', + description: 'Export usage and ownership insights.', + icon: FileText, + color: 'from-emerald-500 to-teal-400', + href: '/reports', + }, + { + label: 'Bulk import', + description: 'Upload a CSV or sheet of multiple assets.', + icon: Upload, + color: 'from-violet-500 to-indigo-400', + href: '/assets/import', + }, + { + label: 'Request asset', + description: 'Log a request for a new asset or transfer.', + icon: ArrowRightLeft, + color: 'from-amber-500 to-orange-400', + href: '/requests/new', + }, +]; + +export default function QuickActions() { + return ( +
+
+
+

+ Quick actions +

+

+ Start the most common workflows directly from your overview. +

+
+
+ +
+
+ +
+ {actions.map((action) => ( + +
+
+ +
+
+ {action.label} + + {action.description} + +
+
+ + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/dashboard/registrationChart.tsx b/frontend/components/dashboard/registrationChart.tsx new file mode 100644 index 0000000..0f57fda --- /dev/null +++ b/frontend/components/dashboard/registrationChart.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { fetchChartData, type DashboardRange } from '@/lib/api/dashboard'; +import { + ResponsiveContainer, + LineChart, + Line, + CartesianGrid, + XAxis, + YAxis, + Tooltip, +} from 'recharts'; + +export default function RegistrationChart({ + range, + refreshIntervalMs, +}: { + range: DashboardRange; + refreshIntervalMs: number | false; +}) { + const { data, isLoading } = useQuery({ + queryKey: ['dashboard-charts', range], + queryFn: ({ signal }) => fetchChartData(range, signal), + refetchInterval: refreshIntervalMs, + }); + + if (isLoading) { + return ( +
+ ); + } + + return ( +
+
+
+

+ Asset registrations (6 months) +

+

+ Smooth trend of new assets coming into your inventory. +

+
+ + Live data + +
+
+ + + + + + + + + +
+
+ ); +} + diff --git a/frontend/components/dashboard/statsSection.tsx b/frontend/components/dashboard/statsSection.tsx new file mode 100644 index 0000000..d9892a6 --- /dev/null +++ b/frontend/components/dashboard/statsSection.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { fetchDashboardStats, type DashboardRange } from '@/lib/api/dashboard'; +import { + ArrowUpRight, + ArrowDownRight, + Boxes, + Gauge, + Coins, + BellRing, +} from 'lucide-react'; + +const icons = { + asset: Boxes, + status: Gauge, + value: Coins, + alert: BellRing, +}; + +export default function StatsSection({ + range, + refreshIntervalMs, +}: { + range: DashboardRange; + refreshIntervalMs: number | false; +}) { + const { data, isLoading } = useQuery({ + queryKey: ['dashboard-stats', range], + queryFn: ({ signal }) => fetchDashboardStats(range, signal), + refetchInterval: refreshIntervalMs, + }); + + if (isLoading) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+ ); + } + + return ( +
+ {data?.cards?.map((stat, idx) => { + const Icon = icons[stat.icon]; + const isPositive = stat.trendDirection === 'up'; + const attention = + stat.icon === 'alert' + ? data.attentionBreakdown + : null; + + return ( +
+
+
+
+ +
+
+ +
+ + + {stat.trend}% + {isPositive ? ( + + ) : ( + + )} + +
+ +

+ {stat.label} +

+

+ {stat.value} +

+ +

+ {attention + ? `${attention.warrantyExpiring} warranty expiring • ${attention.maintenanceDue} maintenance due` + : isPositive + ? 'Improved vs last period' + : 'Lower vs last period'} +

+
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/dashboard/statusBreakdown.tsx b/frontend/components/dashboard/statusBreakdown.tsx new file mode 100644 index 0000000..08b52a6 --- /dev/null +++ b/frontend/components/dashboard/statusBreakdown.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { fetchDashboardStats, type DashboardRange } from '@/lib/api/dashboard'; +import { ShieldCheck, UserCheck, Wrench, Archive } from 'lucide-react'; + +export default function StatusBreakdown({ + range, + refreshIntervalMs, +}: { + range: DashboardRange; + refreshIntervalMs: number | false; +}) { + const { data, isLoading } = useQuery({ + queryKey: ['dashboard-stats', range], + queryFn: ({ signal }) => fetchDashboardStats(range, signal), + refetchInterval: refreshIntervalMs, + }); + + if (isLoading) { + return ( +
+ ); + } + + const s = data?.statusBreakdown; + if (!s) return null; + + const total = Math.max(1, s.active + s.assigned + s.maintenance + s.retired); + const items = [ + { + label: 'Active', + value: s.active, + pct: Math.round((s.active / total) * 100), + icon: ShieldCheck, + classes: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200', + bar: 'bg-emerald-400', + }, + { + label: 'Assigned', + value: s.assigned, + pct: Math.round((s.assigned / total) * 100), + icon: UserCheck, + classes: 'border-sky-500/30 bg-sky-500/10 text-sky-200', + bar: 'bg-sky-400', + }, + { + label: 'Maintenance', + value: s.maintenance, + pct: Math.round((s.maintenance / total) * 100), + icon: Wrench, + classes: 'border-amber-500/30 bg-amber-500/10 text-amber-200', + bar: 'bg-amber-400', + }, + { + label: 'Retired', + value: s.retired, + pct: Math.round((s.retired / total) * 100), + icon: Archive, + classes: 'border-slate-600/40 bg-slate-800/40 text-slate-200', + bar: 'bg-slate-400', + }, + ]; + + return ( +
+
+

+ Assets by status +

+

+ Breakdown of assets across lifecycle states. +

+
+ +
+ {items.map((it) => { + const Icon = it.icon; + return ( +
+
+ +
+
+
+ {it.label} + + {it.value.toLocaleString()} • {it.pct}% + +
+
+
+
+
+
+ ); + })} +
+
+ ); +} + diff --git a/frontend/hooks/useLocalStorageState.ts b/frontend/hooks/useLocalStorageState.ts new file mode 100644 index 0000000..7ff9180 --- /dev/null +++ b/frontend/hooks/useLocalStorageState.ts @@ -0,0 +1,34 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export function useLocalStorageState(key: string, initialValue: T) { + const [value, setValue] = useState(initialValue); + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + try { + const raw = window.localStorage.getItem(key); + if (raw != null) { + setValue(JSON.parse(raw) as T); + } + } catch { + // ignore + } finally { + setIsHydrated(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + useEffect(() => { + if (!isHydrated) return; + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + // ignore + } + }, [key, value, isHydrated]); + + return { value, setValue, isHydrated } as const; +} + diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts new file mode 100644 index 0000000..6e0ffa1 --- /dev/null +++ b/frontend/lib/api/dashboard.ts @@ -0,0 +1,23 @@ +import type { Activity, ChartData, DashboardStatsResponse } from '@/types/dashboard'; + +export type DashboardRange = '7d' | '30d' | '90d' | '6m' | '12m'; + +async function fetchJson(url: string, signal?: AbortSignal): Promise { + const res = await fetch(url, { signal }); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + return (await res.json()) as T; +} + +export const fetchDashboardStats = async (range: DashboardRange, signal?: AbortSignal): Promise => { + return fetchJson(`/api/dashboard/stats?range=${encodeURIComponent(range)}`, signal); +}; + +export const fetchActivities = async (range: DashboardRange, signal?: AbortSignal): Promise => { + return fetchJson(`/api/dashboard/activities?range=${encodeURIComponent(range)}`, signal); +}; + +export const fetchChartData = async (range: DashboardRange, signal?: AbortSignal): Promise => { + return fetchJson(`/api/dashboard/charts?range=${encodeURIComponent(range)}`, signal); +}; \ No newline at end of file diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 7bbf1a3..f1b4804 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -const PROTECTED = ['/dashboard', '/assets', '/departments', '/users']; +const PROTECTED = [ '/assets', '/departments', '/users']; const AUTH_PAGES = ['/signin', '/signup']; export const middleware = (req: NextRequest) => { @@ -16,9 +16,9 @@ export const middleware = (req: NextRequest) => { return NextResponse.redirect(url); } - if (isAuthPage && token) { - return NextResponse.redirect(new URL('/dashboard', req.url)); - } + // if (isAuthPage && token) { + // return NextResponse.redirect(new URL('/dashboard', req.url)); + // } return NextResponse.next(); }; diff --git a/frontend/package.json b/frontend/package.json index 2b2c827..3f210fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,12 +10,17 @@ "test": "jest" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "^8.21.3", "axios": "^1.13.2", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "jspdf": "^4.0.0", "lucide-react": "^0.562.0", "next": "15.5.4", diff --git a/frontend/types/dashboard.ts b/frontend/types/dashboard.ts new file mode 100644 index 0000000..58b15e5 --- /dev/null +++ b/frontend/types/dashboard.ts @@ -0,0 +1,40 @@ +export interface StatData { + label: string; + value: string | number; + trend: number; // percentage + trendDirection: 'up' | 'down' | 'neutral'; + icon: 'asset' | 'status' | 'value' | 'alert'; + } + + export interface Activity { + id: string; + assetId: string; + assetName: string; + actionType: 'created' | 'assigned' | 'transferred' | 'retired'; + user: string; + timestamp: string; + } + + export interface StatusBreakdown { + active: number; + assigned: number; + maintenance: number; + retired: number; + } + + export interface AttentionBreakdown { + warrantyExpiring: number; + maintenanceDue: number; + } + + export interface DashboardStatsResponse { + cards: StatData[]; + statusBreakdown: StatusBreakdown; + attentionBreakdown: AttentionBreakdown; + } + + export interface ChartData { + categoryData: { name: string; value: number; color: string }[]; + departmentData: { name: string; assets: number }[]; + registrationData: { date: string; count: number }[]; + } \ No newline at end of file