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 ? (
+
+ ) : 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