From 562ff9e9b6e5b7c771a6495dc6b4d90f7612c899 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 26 Mar 2026 09:22:04 +0800 Subject: [PATCH] fix(dashboard): error state, role-gate revenue/chart to ADMIN, remove tasks card, skeleton loaders; add dashboard.spec.ts --- app/(app)/dashboard/page.tsx | 268 +++++++++++++++-------------------- e2e/dashboard.spec.ts | 52 +++++++ 2 files changed, 163 insertions(+), 157 deletions(-) create mode 100644 e2e/dashboard.spec.ts diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 9a7e423..cf33d22 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -2,23 +2,17 @@ import { useQuery, useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { Users, Wifi, FileText, DollarSign, Ticket, CheckSquare, TrendingUp, Database, RefreshCw } from "lucide-react"; +import { Users, Wifi, FileText, DollarSign, Ticket, TrendingUp, Database, RefreshCw, AlertCircle } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Badge } from "@/components/ui/Badge"; import { formatCurrency, formatDateTime } from "@/lib/utils"; +import { useAuthStore } from "@/lib/auth-store"; import { toast } from "sonner"; import api from "@/lib/api"; import type { DashboardSummary, PaginatedResponse, Ticket as TicketType } from "@/types"; import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, } from "recharts"; interface KpiCardProps { @@ -46,44 +40,67 @@ function KpiCard({ title, value, icon: Icon, color, subtitle, href }: KpiCardPro

{value}

{subtitle &&

{subtitle}

} - {href && ( -
- )} + {href &&
} ); } +function KpiSkeleton() { + return ( + + +
+
+
+
+
+
+ + + ); +} + +function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+ +

{message}

+ +
+ ); +} + const priorityVariant: Record = { - URGENT: "danger", - urgent: "danger", - HIGH: "warning", - high: "warning", - NORMAL: "default", - normal: "default", - MEDIUM: "default", - medium: "default", - LOW: "muted", - low: "muted", + URGENT: "danger", urgent: "danger", + HIGH: "warning", high: "warning", + NORMAL: "default", normal: "default", + MEDIUM: "default", medium: "default", + LOW: "muted", low: "muted", }; -// Build mock time-series data from revenue for the chart function buildChartData(stats: DashboardSummary | undefined) { if (!stats) return []; - // Create a simple 2-month comparison from revenue data const now = new Date(); const thisMonth = now.toLocaleString("default", { month: "short" }); const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1).toLocaleString("default", { month: "short" }); return [ - { month: lastMonth, revenue: stats.revenue.lastMonth, clients: stats.subscribers.total }, - { month: thisMonth, revenue: stats.revenue.thisMonth, clients: stats.subscribers.active }, + { month: lastMonth, revenue: stats.revenue.lastMonth }, + { month: thisMonth, revenue: stats.revenue.thisMonth }, ]; } export default function DashboardPage() { const router = useRouter(); + const user = useAuthStore((s) => s.user); + const isAdmin = user?.roles?.some(r => r.toLowerCase() === "admin" || r.toLowerCase() === "super_admin"); - const { data: stats, isLoading: statsLoading, refetch: refetchStats } = useQuery({ + const { + data: stats, + isLoading: statsLoading, + isError: statsError, + refetch: refetchStats, + } = useQuery({ queryKey: ["dashboard-summary"], queryFn: async () => { const res = await api.get("/api/v1/dashboard/summary"); @@ -91,7 +108,11 @@ export default function DashboardPage() { }, }); - const { data: ticketsData, refetch: refetchTickets } = useQuery>({ + const { + data: ticketsData, + isLoading: ticketsLoading, + refetch: refetchTickets, + } = useQuery>({ queryKey: ["recent-tickets"], queryFn: async () => { const res = await api.get>("/api/v1/tickets?page=1&limit=5"); @@ -106,7 +127,7 @@ export default function DashboardPage() { refetchStats(); refetchTickets(); }, - onError: (err: { response?: { data?: { message?: string } } }) => { + onError: (err: any) => { toast.error(err?.response?.data?.message || "Seed failed"); }, }); @@ -117,99 +138,60 @@ export default function DashboardPage() { return (
-
+ {/* Header */} +

Dashboard

Overview of your ISP operations

-
- - - - - + {isAdmin && ( + + )} + + +
{/* KPI Cards */} {statsLoading ? (
- {[1, 2, 3, 4].map((i) => ( - - -
- - - ))} + {[1,2,3,4].map(i => )}
+ ) : statsError ? ( + + + ) : (
- - - - + + + {isAdmin && ( + 0 ? "+" : ""}${stats.revenue.growth.toFixed(1)}% vs last month` - : "vs last month" - } - href="/payments" - /> + : "vs last month"} href="/payments" /> + )}
)} {/* Secondary stats */} -
- router.push("/tickets?status=OPEN")} - > +
+ router.push("/tickets")}>
@@ -220,10 +202,8 @@ export default function DashboardPage() {
- router.push("/tickets?status=IN_PROGRESS")} - > + router.push("/tickets")}>
@@ -234,24 +214,10 @@ export default function DashboardPage() {
- router.push("/tasks")} - > - -
- -
-
-

Pending Tasks

-

{stats?.tasks.pending ?? 0}

-
-
-
- {/* Revenue Chart */} - {!statsLoading && ( + {/* Revenue Chart — admin only */} + {isAdmin && !statsLoading && ( Revenue Overview @@ -262,33 +228,17 @@ export default function DashboardPage() { - `₱${(v / 1000).toFixed(0)}k`} /> + `₱${(v/1000).toFixed(0)}k`} /> formatCurrency(Number(v))} /> - + ) : (

Revenue data will appear once payments are recorded.

-
)} @@ -299,34 +249,38 @@ export default function DashboardPage() { Recent Tickets - + - {recentTickets.length === 0 ? ( + {ticketsLoading ? ( +
+ {[1,2,3].map(i => ( +
+
+
+
+
+
+
+ ))} +
+ ) : recentTickets.length === 0 ? (
No tickets yet
) : (
{recentTickets.map((ticket) => ( -
router.push("/tickets")} - > + onClick={() => router.push("/tickets")}>

{ticket.subject}

- {ticket.client - ? `${ticket.client.firstName} ${ticket.client.lastName}` - : "No client"}{" "} - • {formatDateTime(ticket.createdAt)} + {ticket.client ? `${ticket.client.firstName} ${ticket.client.lastName}` : "No client"} + {" • "}{formatDateTime(ticket.createdAt)}

- - {ticket.priority} - + {ticket.priority} {ticket.status}
diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts new file mode 100644 index 0000000..55e9add --- /dev/null +++ b/e2e/dashboard.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('Dashboard', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('dashboard page loads with correct title', async ({ page }) => { + await expect(page.locator('h1:has-text("Dashboard")')).toBeVisible(); + await expect(page.locator('text=Overview of your ISP operations')).toBeVisible(); + }); + + test('KPI cards render after loading', async ({ page }) => { + // Wait for skeletons to disappear + await page.waitForSelector('.skeleton', { state: 'detached', timeout: 15000 }).catch(() => {}); + await expect(page.locator('text=Total Clients')).toBeVisible(); + await expect(page.locator('text=Active Subscriptions')).toBeVisible(); + await expect(page.locator('text=Overdue Invoices')).toBeVisible(); + }); + + test('revenue card and chart only visible to admin', async ({ page }) => { + // Admin login — revenue card should show + await page.waitForSelector('.skeleton', { state: 'detached', timeout: 15000 }).catch(() => {}); + await expect(page.locator('text=Monthly Revenue')).toBeVisible(); + await expect(page.locator('text=Revenue Overview')).toBeVisible(); + }); + + test('recent tickets section renders', async ({ page }) => { + await expect(page.locator('text=Recent Tickets')).toBeVisible(); + await expect(page.locator('text=View all →')).toBeVisible(); + }); + + test('refresh button works without crash', async ({ page }) => { + await page.click('button:has-text("Refresh")'); + // Should not crash — page still has dashboard title + await expect(page.locator('h1:has-text("Dashboard")')).toBeVisible(); + }); + + test('New Client button navigates to clients', async ({ page }) => { + await page.click('button:has-text("+ New Client")'); + await expect(page).toHaveURL(/\/clients/); + }); + + test('KPI card click navigates correctly', async ({ page }) => { + await page.waitForSelector('.skeleton', { state: 'detached', timeout: 15000 }).catch(() => {}); + // Click Total Clients card + const clientsCard = page.locator('text=Total Clients').first(); + await clientsCard.click(); + await expect(page).toHaveURL(/\/clients/); + }); +});