feat: add /dashboard2 rep-scoped dashboard page

This commit is contained in:
Dave 2026-09-01 01:36:53 +02:00
parent afb396b03b
commit 23050e3dc6

View file

@ -0,0 +1,291 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
FileText,
ShoppingCart,
CalendarDays,
TrendingUp,
Gauge,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { CHART, withAlpha } from "@/components/charts/palette";
import { DashboardCard } from "@/components/charts/dashboard-card";
import { BarChart } from "@/components/charts/bar-chart";
import { RadialGauge } from "@/components/charts/radial-gauge";
import type { RepDashboardData } from "@/lib/server/rep-dashboard-service";
function weekLabel(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-ZA", { day: "numeric", month: "short" });
}
function deltaPct(current: number, previous: number): number {
if (previous === 0) return current > 0 ? 100 : 0;
return Math.round(((current - previous) / previous) * 100);
}
function formatZar(value: number): string {
return new Intl.NumberFormat("en-ZA", {
style: "currency",
currency: "ZAR",
maximumFractionDigits: 0,
}).format(value);
}
const REALTIME_DEBOUNCE_MS = 1_000;
export default function RepDashboardPage() {
const router = useRouter();
const [data, setData] = useState<RepDashboardData | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
try {
const pb = getPocketBase();
const pbToken = pb.authStore?.token;
const headers: Record<string, string> = {};
if (pbToken) headers.Authorization = pbToken;
const response = await fetch("/api/v1/rep-dashboard", {
headers,
cache: "no-store",
});
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error || `Rep dashboard failed (HTTP ${response.status})`);
}
setData((await response.json()) as RepDashboardData);
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to load rep dashboard");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchData();
}, [fetchData]);
useEffect(() => {
const pb = getPocketBase();
let handle = 0;
const trigger = () => {
if (handle) window.clearTimeout(handle);
handle = window.setTimeout(() => void fetchData(), REALTIME_DEBOUNCE_MS);
};
const subscribe = async () => {
await pb.collection("quotes").subscribe("*", trigger);
await pb.collection("orders").subscribe("*", trigger);
await pb.collection("order_items").subscribe("*", trigger);
};
void subscribe();
return () => {
if (handle) window.clearTimeout(handle);
void pb.collection("quotes").unsubscribe("*");
void pb.collection("orders").unsubscribe("*");
void pb.collection("order_items").unsubscribe("*");
};
}, [fetchData]);
if (loading || !data) {
return (
<div className="py-16 text-center text-muted-foreground">
Loading your dashboard…
</div>
);
}
const { quotesLoaded, quoteVolumeTrend, outstandingQuotes, outstandingOrders, monthRevenue } = data;
return (
<div className="space-y-5">
<div>
<h1 className="text-2xl font-bold tracking-tight">My Dashboard</h1>
<p className="text-muted-foreground text-sm">
Your quotes, outstanding work, and this month&rsquo;s numbers.
</p>
</div>
{/* Row 1: quotes-loaded KPIs */}
<div className="grid gap-3 grid-cols-1 sm:grid-cols-3">
<RepKpiCard
label="Quotes today"
value={quotesLoaded.today}
delta={deltaPct(quotesLoaded.today, quotesLoaded.yesterday)}
icon={CalendarDays}
accentColor={CHART.cyan}
/>
<RepKpiCard
label="Quotes this week"
value={quotesLoaded.thisWeek}
delta={deltaPct(quotesLoaded.thisWeek, quotesLoaded.lastWeek)}
icon={FileText}
accentColor={CHART.pink}
/>
<RepKpiCard
label="Quotes this month"
value={quotesLoaded.thisMonth}
delta={deltaPct(quotesLoaded.thisMonth, quotesLoaded.lastMonth)}
icon={TrendingUp}
accentColor={CHART.orange}
/>
</div>
{/* Row 2: trend + outstanding lists */}
<div className="grid gap-3 lg:grid-cols-3">
<DashboardCard title="Quote volume trend" subtitle="8-week view" icon={TrendingUp} accentColor={CHART.orange}>
<BarChart
data={quoteVolumeTrend.map((d) => ({ label: weekLabel(d.weekStart), values: [d.count] }))}
series={[{ label: "Quotes", color: CHART.orange }]}
height={180}
/>
</DashboardCard>
<DashboardCard
title="Outstanding quotes"
subtitle={`${outstandingQuotes.total} need attention`}
icon={FileText}
accentColor={CHART.pink}
>
<RepList
items={outstandingQuotes.items.map((q) => ({ id: q.id, primary: q.quote_number, secondary: q.customer_name }))}
emptyLabel="No outstanding quotes"
onClick={(id) => router.push(`/quotes/${id}`)}
/>
</DashboardCard>
<DashboardCard
title="Outstanding orders"
subtitle={`${outstandingOrders.total} need attention`}
icon={ShoppingCart}
accentColor={CHART.gold}
>
<RepList
items={outstandingOrders.items.map((o) => ({ id: o.id, primary: o.order_number, secondary: o.customer_name }))}
emptyLabel="No outstanding orders"
onClick={(id) => router.push(`/orders/${id}`)}
/>
</DashboardCard>
</div>
{/* Row 3: this month's money */}
<div className="grid gap-3 lg:grid-cols-2">
<DashboardCard title="Selling vs Cost" subtitle="This month" icon={ShoppingCart} accentColor={CHART.green}>
<BarChart
data={[{ label: "This month", values: [monthRevenue.selling, monthRevenue.cost] }]}
series={[
{ label: "Selling", color: CHART.green },
{ label: "Cost", color: CHART.muted },
]}
height={180}
/>
</DashboardCard>
<DashboardCard title="Markup %" subtitle="This month" icon={Gauge} accentColor={CHART.purple}>
{monthRevenue.markupPct === null ? (
<div className="py-8 text-center text-muted-foreground">
<CheckCircle className="h-7 w-7 mx-auto mb-1.5" style={{ color: CHART.green }} />
<p className="text-sm">No orders yet this month</p>
</div>
) : (
<RadialGauge
value={monthRevenue.cost}
total={monthRevenue.selling}
color={CHART.purple}
label="Markup"
sublabel={`${formatZar(monthRevenue.selling)} selling · ${formatZar(monthRevenue.cost)} cost`}
centerLabelOverride={`${monthRevenue.markupPct.toFixed(1)}%`}
size={180}
/>
)}
</DashboardCard>
</div>
</div>
);
}
function RepKpiCard({
label,
value,
delta,
icon: Icon,
accentColor,
}: {
label: string;
value: number;
delta: number;
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
accentColor: string;
}) {
const positive = delta >= 0;
return (
<DashboardCard accentColor={accentColor} padding="p-4">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{label}</div>
<div
className="grid h-7 w-7 place-items-center rounded-md shrink-0"
style={{ background: withAlpha(accentColor, 14), color: accentColor }}
>
<Icon className="h-3.5 w-3.5" style={{ color: accentColor }} />
</div>
</div>
<div className="text-2xl font-bold tracking-tight tabular-nums" style={{ fontFamily: "var(--font-jetbrains-mono), monospace" }}>
{value.toLocaleString()}
</div>
<div className="mt-0.5 text-[11px] font-semibold" style={{ color: positive ? CHART.green : CHART.red }}>
{positive ? "+" : ""}
{delta}% vs previous
</div>
</DashboardCard>
);
}
function RepList({
items,
emptyLabel,
onClick,
}: {
items: { id: string; primary: string; secondary: string | null }[];
emptyLabel: string;
onClick: (id: string) => void;
}) {
if (items.length === 0) {
return (
<div className="py-6 text-center text-muted-foreground">
<AlertCircle className="h-7 w-7 mx-auto mb-1.5 opacity-50" />
<p className="text-sm">{emptyLabel}</p>
</div>
);
}
return (
<div className="space-y-1.5">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onClick(item.id)}
className="flex w-full items-center gap-2.5 rounded-md border p-2 text-left transition-colors hover:bg-muted/40"
style={{ borderColor: "var(--border)" }}
>
<Avatar className="h-7 w-7 shrink-0">
<AvatarFallback className="text-[10px] font-semibold">
{(item.secondary || "?").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{item.primary}</div>
<div className="text-xs text-muted-foreground truncate">{item.secondary || "—"}</div>
</div>
<Badge variant="outline" className="text-[10px] shrink-0">Open</Badge>
</button>
))}
</div>
);
}