feat: add /dashboard2 rep-scoped dashboard page
This commit is contained in:
parent
afb396b03b
commit
23050e3dc6
1 changed files with 291 additions and 0 deletions
291
src/app/(app)/dashboard2/page.tsx
Normal file
291
src/app/(app)/dashboard2/page.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue