diff --git a/src/app/(app)/dashboard2/page.tsx b/src/app/(app)/dashboard2/page.tsx new file mode 100644 index 0000000..3621b16 --- /dev/null +++ b/src/app/(app)/dashboard2/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + try { + const pb = getPocketBase(); + const pbToken = pb.authStore?.token; + const headers: Record = {}; + 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 ( +
+ Loading your dashboard… +
+ ); + } + + const { quotesLoaded, quoteVolumeTrend, outstandingQuotes, outstandingOrders, monthRevenue } = data; + + return ( +
+
+

My Dashboard

+

+ Your quotes, outstanding work, and this month’s numbers. +

+
+ + {/* Row 1: quotes-loaded KPIs */} +
+ + + +
+ + {/* Row 2: trend + outstanding lists */} +
+ + ({ label: weekLabel(d.weekStart), values: [d.count] }))} + series={[{ label: "Quotes", color: CHART.orange }]} + height={180} + /> + + + + ({ id: q.id, primary: q.quote_number, secondary: q.customer_name }))} + emptyLabel="No outstanding quotes" + onClick={(id) => router.push(`/quotes/${id}`)} + /> + + + + ({ id: o.id, primary: o.order_number, secondary: o.customer_name }))} + emptyLabel="No outstanding orders" + onClick={(id) => router.push(`/orders/${id}`)} + /> + +
+ + {/* Row 3: this month's money */} +
+ + + + + + {monthRevenue.markupPct === null ? ( +
+ +

No orders yet this month

+
+ ) : ( + + )} +
+
+
+ ); +} + +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 ( + +
+
{label}
+
+ +
+
+
+ {value.toLocaleString()} +
+
+ {positive ? "+" : ""} + {delta}% vs previous +
+
+ ); +} + +function RepList({ + items, + emptyLabel, + onClick, +}: { + items: { id: string; primary: string; secondary: string | null }[]; + emptyLabel: string; + onClick: (id: string) => void; +}) { + if (items.length === 0) { + return ( +
+ +

{emptyLabel}

+
+ ); + } + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} \ No newline at end of file