feat(tv-dashboard): enhance TV dashboard with driver stops and performance metrics

- Updated the TV dashboard to include a new section for driver stops, displaying active deliveries and pickups with GPS coordinates.
- Refactored the dashboard data structure to incorporate year-over-year performance metrics alongside existing monthly and weekly stats.
- Replaced the supplier map with a driver stops map for improved clarity on driver activities.
- Enhanced the user performance metrics to include detailed counts for quotes and orders processed by each representative.
- Removed the obsolete supplier map component to streamline the dashboard's functionality.
This commit is contained in:
Dave 2026-08-19 10:16:49 +02:00
parent 2f0e911563
commit 43bbdfc458
14 changed files with 921 additions and 727 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

View file

@ -0,0 +1,2 @@
[ 646ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ https://wfdash.apphub.co.za/login:0
[ 647ms] [LOG] SW registered: /sw.js https://wfdash.apphub.co.za/ @ https://wfdash.apphub.co.za/_next/static/chunks/15-a5odeil1fk.js:0

View file

@ -0,0 +1,23 @@
- generic [active] [ref=e1]:
- generic [ref=e3]:
- generic [ref=e5]:
- img "WFDASH" [ref=e7]
- generic [ref=e8]: WFDASH
- generic [ref=e9]: Business CRM
- generic [ref=e11]:
- tablist [ref=e12]:
- tab "Login" [selected] [ref=e13]
- tab "Register" [ref=e14]
- tab "Reset" [ref=e15]
- tabpanel "Login" [ref=e16]:
- generic [ref=e17]:
- generic [ref=e18]:
- generic [ref=e19]: Email
- textbox "Email" [ref=e20]:
- /placeholder: you@example.com
- generic [ref=e21]:
- generic [ref=e22]: Password
- textbox "Password" [ref=e23]
- button "Sign In" [ref=e24]
- region "Notifications alt+T"
- alert [ref=e29]

View file

@ -20,14 +20,9 @@ invalidates every existing TV session (forces re-PIN).
| Section | Tiles |
|---|---|
| **Existing KPIs** | Orders this month · Active quotes · Customers |
| **Today / Week / Month strips** | Orders received · Orders placed (POs) · Quotes received · Items processed |
| **Driver map + stops list** | Today's `order_items` in `collection_pool` / `assigned` / `ready_delivery`, with the customer / company geoPoint used as the map pin |
| **Quotes loaded per rep** | Per-rep table with `today` · `week` · `month` columns. Falls back to `customer.rep` when `quote.rep` is empty. |
| **Today's rep activity** | Per-rep strip with Q (quotes today), O (orders today), ✓ (order_items completed today) |
| **Rotating chart** | Revenue / Orders / Quotes — 8s rotation |
| **Top customers** | Quotes per customer in the 12-week sample |
| **Follow-ups** | First 8 quotes with a non-empty `follow_up_due` |
| **Period matrix** | Today / Week / Month counts: orders received, orders placed, quotes received, items processed |
| **Driver stops** | Map + numbered list of the driver named by `TV_DRIVER_ID` — both active deliveries (`order_items.item_status = "assigned"`) and supplier pickups (`stock_purchase_items.status = "assigned_to_driver"`). Stops drop off as he marks them collected in the driver app. |
| **Rep activity** | Per-rep table with today / week / month × quotes / orders counts. Falls back to `customer.rep` when `quote.rep` is empty. |
The same `getDashboardData()` powers the main CRM `/dashboard`; the
TV view adds `getTvDashboardData()` on top with the day / week / month
@ -50,8 +45,8 @@ src/app/(tv)/
page.tsx server guard → if no valid session, redirect to /pin
tv-view.tsx client component, all the rendering
components/
driver-map.tsx ssr:false wrapper around Leaflet
driver-map-inner.tsx the actual Leaflet map (browser-only)
driver-stops-map.tsx numbered colour-coded pins per stop
tv-map-inner.tsx the actual Leaflet map (browser-only)
src/app/api/
tv/login/route.ts POST { pin } → sets tv_session cookie or 401
@ -60,8 +55,9 @@ src/app/api/
src/lib/server/
tv-session.ts HMAC-signed cookie helpers
tv-dashboard-service.ts the new day/week/month counters,
quotesPerRep, userPerformance, driverStops
tv-dashboard-service.ts day/week/month counters, quotesPerRep,
userPerformance, driverStops
driver-stops.ts shared listDriverStops + update (driver app)
src/middleware.ts no-op (matcher in place for future)
```
@ -108,6 +104,8 @@ src/middleware.ts no-op (matcher in place for future)
| `TV_DASHBOARD_PIN` | 4-8 digit PIN staff enter on the TV | yes |
| `TV_SESSION_SECRET` | HMAC secret for signing the cookie | yes |
| `TV_SESSION_HOURS` | TTL (default 12, currently set to **168 = 1 week**) | no |
| `TV_DRIVER_ID` | PB `users.id` of the driver whose active stops appear on the stops tile. When unset the tile collapses to a "set TV_DRIVER_ID" placeholder so the rest of the dashboard still renders. | no |
| `TV_REP_ROLES` | Comma-separated `user_type` values that mark a user as a rep for the activity list (default `REP`). A user is counted as a rep when their `user_type` array contains any of these values. Example: `TV_REP_ROLES=REP,QUOTER` to also include the QUOTER role. | no |
| `PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD` | PB admin auth for `/api/dashboard` | yes (reuses existing vars) |
Generate a session secret with:

View file

@ -0,0 +1,135 @@
"use client";
import * as React from "react";
import dynamic from "next/dynamic";
import { Truck } from "lucide-react";
import type { DriverStop } from "@/lib/server/driver-stops";
import type { TvMapItem } from "./tv-map-inner";
/**
* Driver stops map — one PIN per supplier, numbered in the order
* they appear in the list. Stops arriving from the same supplier
* (deliveries + pickups) are collapsed onto a single pin so the
* map matches the supplier-grouped list view.
*/
const Inner = dynamic(
() => import("./tv-map-inner").then((m) => m.TvMapInner),
{ ssr: false, loading: () => <MapSkeleton /> },
);
function MapSkeleton() {
return (
<div className="flex h-full w-full items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/60 text-slate-500">
<div className="flex flex-col items-center gap-2">
<Truck className="size-8 animate-pulse" />
<span className="text-sm">Loading map…</span>
</div>
</div>
);
}
const SUPPLIER_PIN = "#F59E0B"; // amber-500
interface SupplierBucket {
id: string;
name: string;
coords: [number, number] | null;
items: number;
qty: number;
deliveries: number;
pickups: number;
customers: string[];
}
function bucketBySupplier(stops: DriverStop[]): SupplierBucket[] {
const acc = new Map<string, SupplierBucket>();
const orphanKey = (stop: DriverStop) => `__orphan::${stop.id}`;
const customerKey = (name: string | null | undefined) =>
(name || "").trim().toLowerCase();
for (const stop of stops) {
const supplierId = stop.supplier.id || null;
const key = supplierId ?? orphanKey(stop);
let bucket = acc.get(key);
if (!bucket) {
bucket = {
id: supplierId ?? key,
name:
stop.supplier.name?.trim() ||
(supplierId ? "Unknown supplier" : "Unassigned"),
coords: stop.supplier.coords,
items: 0,
qty: 0,
deliveries: 0,
pickups: 0,
customers: [],
};
acc.set(key, bucket);
}
bucket.items += 1;
bucket.qty += Number(stop.quantity_ordered || 0);
if (stop.kind === "delivery") {
bucket.deliveries += 1;
const customerName =
stop.customer?.name || stop.customer_name || null;
const seen = new Set(bucket.customers.map((c) => customerKey(c)));
if (customerName && !seen.has(customerKey(customerName))) {
bucket.customers.push(customerName);
}
} else {
bucket.pickups += 1;
}
}
return Array.from(acc.values()).sort(
(a, b) => b.items - a.items || b.qty - a.qty || a.name.localeCompare(b.name),
);
}
interface Props {
stops: DriverStop[];
className?: string;
}
export function DriverStopsMap({ stops, className }: Props) {
const items: TvMapItem[] = React.useMemo(() => {
const buckets = bucketBySupplier(stops);
return buckets.map((bucket, idx) => {
const kindBits: string[] = [];
if (bucket.deliveries) {
kindBits.push(
`${bucket.deliveries} deliver${bucket.deliveries === 1 ? "y" : "ies"}`,
);
}
if (bucket.pickups) {
kindBits.push(
`${bucket.pickups} pickup${bucket.pickups === 1 ? "" : "s"}`,
);
}
const bodyLines = [
kindBits.join(" · "),
`qty ${bucket.qty.toLocaleString("en-ZA")}`,
bucket.customers.length
? `→ ${bucket.customers.slice(0, 3).join(", ")}${
bucket.customers.length > 3
? ` +${bucket.customers.length - 3}`
: ""
}`
: null,
].filter(Boolean) as string[];
return {
id: bucket.id,
coords: bucket.coords,
pinLabel: String(idx + 1),
pinColor: SUPPLIER_PIN,
popupTitle: bucket.name,
popupBody: bodyLines.join(" · "),
} satisfies TvMapItem;
});
}, [stops]);
return (
<div className={className}>
<Inner items={items} />
</div>
);
}

View file

@ -1,63 +0,0 @@
"use client";
import * as React from "react";
import dynamic from "next/dynamic";
import { Building2 } from "lucide-react";
import type { SupplierPickupTv } from "@/lib/server/tv-dashboard-service";
import type { TvMapItem } from "./tv-map-inner";
/**
* Supplier pickups map — one PIN per supplier (not per item), with
* the aggregated item count shown on the pin so the driver can see
* "stop #1 has 4 line items to collect" at a glance.
*/
const Inner = dynamic(
() => import("./tv-map-inner").then((m) => m.TvMapInner),
{ ssr: false, loading: () => <MapSkeleton /> },
);
function MapSkeleton() {
return (
<div className="flex h-full w-full items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/60 text-slate-500">
<div className="flex flex-col items-center gap-2">
<Building2 className="size-8 animate-pulse" />
<span className="text-sm">Loading map…</span>
</div>
</div>
);
}
interface Props {
pickups: SupplierPickupTv[];
className?: string;
}
export function SupplierMap({ pickups, className }: Props) {
// Pin color: amber for supplier pickups (paired with the sky-blue
// delivery pins on the deliveries map; two distinct hues so the
// driver never confuses them).
const items: TvMapItem[] = React.useMemo(
() =>
pickups.map((p, idx) => ({
id: p.supplier_id ?? `orphan-${idx}`,
coords: p.coords,
pinLabel: String(idx + 1),
pinColor: "#F59E0B",
popupTitle: p.supplier_name,
popupBody: [
p.address,
`${p.total_items} line items · qty ${p.total_qty}`,
]
.filter(Boolean)
.join(" · "),
})),
[pickups],
);
return (
<div className={className}>
<Inner items={items} />
</div>
);
}

View file

@ -9,16 +9,13 @@ import {
RefreshCw,
PackageCheck,
CheckCircle2,
Building2,
Truck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import type {
PeriodStats as PeriodStatsT,
TvDashboardData,
} from "@/lib/server/tv-dashboard-service";
import type { TvDashboardData } from "@/lib/server/tv-dashboard-service";
import getPocketBase from "@/lib/pocketbase";
import { SupplierMap } from "./components/supplier-map";
import { DriverStopsMap } from "./components/driver-stops-map";
const POLL_INTERVAL_MS = 30_000;
@ -130,7 +127,7 @@ export default function TvDashboardView() {
);
}
const { today, week, month, userPerformance, supplierPickups } = data;
const { today, week, month, year, userPerformance, driverName, driverStops } = data;
return (
<div className="min-h-dvh p-3 lg:p-4">
@ -180,112 +177,30 @@ export default function TvDashboardView() {
orders + quotes + items counts, which the operator
needs more than the legacy counts.) */}
{/* ── Today / Week / Month matrix (one card, 4 × 3) ─────────── */}
{/* ── Today / Week / Month / Year matrix (one card, 4 × 4) ─────────── */}
<PeriodMatrix
today={today}
week={week}
month={month}
year={year}
/>
{/* ── Suppliers + rep activity side-by-side (no-scroll row) ── */}
{/* ── Driver stops + rep activity side-by-side (no-scroll row) ── */}
<section className="col-span-12 grid grid-cols-1 items-stretch gap-3 sm:grid-cols-12 sm:gap-4 lg:grid-cols-12 lg:gap-4">
{/* Map + supplier stops (compact, no address column — map
shows location; address on file shown only as a
hover/title for accessibility). */}
<section className="col-span-12 flex flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/60 lg:col-span-7">
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-2">
<div>
<h2 className="flex items-center gap-2 text-base font-medium text-slate-200">
<Building2 className="size-4" />
Suppliers to visit today
</h2>
<p className="mt-0.5 text-[11px] text-slate-500">
{supplierPickups.length === 0
? "No supplier pickups pending"
: `${supplierPickups.length} supplier${
supplierPickups.length === 1 ? "" : "s"
} · ${supplierPickups.reduce(
(n, p) => n + p.total_items,
0,
)} line item${
supplierPickups.reduce(
(n, p) => n + p.total_items,
0,
) === 1
? ""
: "s"
} · qty ${supplierPickups
.reduce((n, p) => n + p.total_qty, 0)
.toLocaleString("en-ZA")}`}
</p>
</div>
</div>
{supplierPickups.length === 0 ? (
<div className="flex h-24 items-center justify-center text-sm text-slate-500">
Driver has no supplier pickups assigned.
</div>
) : (
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 p-3 sm:grid-cols-12 sm:gap-3 sm:p-4 lg:gap-4">
<div className="flex sm:col-span-4">
<div className="h-full min-h-[260px] flex-1 overflow-hidden rounded-xl border border-slate-800">
<SupplierMap
pickups={supplierPickups}
className="h-full"
/>
</div>
</div>
<div className="flex sm:col-span-8">
<div className="h-full flex-1 overflow-auto rounded-xl border border-slate-800">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-900/80 text-[10px] uppercase tracking-wider text-slate-500">
<th className="w-10 py-1.5 pl-3 text-left font-medium">#</th>
<th className="py-1.5 text-left font-medium">Supplier</th>
<th className="py-1.5 text-right font-medium">Items</th>
<th className="py-1.5 pr-3 text-right font-medium">Qty</th>
</tr>
</thead>
<tbody>
{supplierPickups.map((pickup, idx) => (
<tr
key={
pickup.supplier_id ??
`orphan-${pickup.address ?? idx}`
}
className="border-b border-slate-800/60 last:border-b-0 align-middle"
>
<td className="py-1.5 pl-3">
<div className="flex size-5 items-center justify-center rounded-full bg-amber-500 text-[11px] font-bold text-slate-900">
{idx + 1}
</div>
</td>
<td
className="py-1.5 text-sm font-semibold text-slate-100"
title={pickup.address ?? "No address on file"}
>
<div className="truncate">
{pickup.supplier_name}
</div>
</td>
<td className="py-1.5 text-right font-mono text-[11px] tabular-nums text-amber-300">
{pickup.total_items}
</td>
<td className="py-1.5 pr-3 text-right font-mono text-[11px] tabular-nums text-slate-300">
{pickup.total_qty.toLocaleString("en-ZA")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</section>
{/* Driver stops — map + numbered list of the driver's
active deliveries and pickups, grouped per supplier.
As he marks them collected in the driver app, the
corresponding PB rows flip status and the next 30s
poll / realtime push drops them off the wall. */}
<DriverStopsSection
driverName={driverName}
stops={driverStops}
/>
{/* Rep activity — sits next to the supplier stops on lg so
{/* Rep activity — sits next to the driver stops on lg so
the whole dashboard fits in one viewport on a 1080p TV. */}
<div className="col-span-12 flex lg:col-span-5 lg:h-full">
<div className="col-span-12 flex lg:col-span-6 lg:h-full">
<RepActivityMatrix reps={userPerformance} />
</div>
</section>
@ -341,11 +256,33 @@ function RepActivityMatrix({
reps: Array<{
user_id: string;
user_name: string;
today: PeriodStatsT;
week: PeriodStatsT;
month: PeriodStatsT;
today: { ordersReceived: number; quotesReceived: number };
week: { ordersReceived: number; quotesReceived: number };
month: { ordersReceived: number; quotesReceived: number };
year: { ordersReceived: number; quotesReceived: number };
}>;
}) {
// Drive the column headers + body from these arrays so adding
// a period later is a one-line change. Today / Week / Month /
// Year map to the matching `UserStats` sub-object.
const periods: Array<{
label: string;
period: "today" | "week" | "month" | "year";
}> = [
{ label: "Today", period: "today" },
{ label: "Week", period: "week" },
{ label: "Month", period: "month" },
{ label: "Year", period: "year" },
];
const metrics: Array<{
label: string;
metric: "quotesReceived" | "ordersReceived";
accent: string;
}> = [
{ label: "Quote", metric: "quotesReceived", accent: "text-amber-300" },
{ label: "Order", metric: "ordersReceived", accent: "text-sky-300" },
];
return (
<section className="flex h-full w-full flex-col rounded-2xl border border-slate-800 bg-slate-900/60 p-3 sm:p-4">
<div className="mb-2 flex items-center gap-2">
@ -354,12 +291,12 @@ function RepActivityMatrix({
Rep activity
</h2>
<span className="ml-auto text-[11px] text-slate-500">
Q · quotes · O · orders
Quote · Order
</span>
</div>
{reps.length === 0 ? (
<div className="flex h-24 items-center justify-center text-sm text-slate-500">
No rep activity today / this week / this month.
No rep activity today / this week / this month / this year.
</div>
) : (
<div className="flex-1 overflow-x-auto">
@ -369,12 +306,16 @@ function RepActivityMatrix({
<th className="py-1 text-left text-[11px] uppercase tracking-wider text-slate-400">
Rep
</th>
<MetricHead period="Today" metric="Q" accent="text-amber-400" />
<MetricHead period="Today" metric="O" accent="text-sky-400" />
<MetricHead period="Week" metric="Q" accent="text-amber-400" />
<MetricHead period="Week" metric="O" accent="text-sky-400" />
<MetricHead period="Month" metric="Q" accent="text-amber-400" />
<MetricHead period="Month" metric="O" accent="text-sky-400" />
{periods.flatMap((p) =>
metrics.map((m) => (
<MetricHead
key={`${p.period}-${m.metric}`}
period={p.label}
metric={m.label}
accent={m.accent}
/>
)),
)}
</tr>
</thead>
<tbody>
@ -397,36 +338,18 @@ function RepActivityMatrix({
</span>
</div>
</td>
<td className="py-1.5 text-right tabular-nums text-amber-300">
<span className="font-semibold tabular-nums">
{u.today.quotesReceived}
</span>
</td>
<td className="py-1.5 text-right tabular-nums text-sky-300">
<span className="font-semibold tabular-nums">
{u.today.ordersReceived}
</span>
</td>
<td className="py-1.5 text-right tabular-nums text-amber-300">
<span className="font-semibold tabular-nums">
{u.week.quotesReceived}
</span>
</td>
<td className="py-1.5 text-right tabular-nums text-sky-300">
<span className="font-semibold tabular-nums">
{u.week.ordersReceived}
</span>
</td>
<td className="py-1.5 text-right tabular-nums text-amber-300">
<span className="font-semibold tabular-nums">
{u.month.quotesReceived}
</span>
</td>
<td className="py-1.5 text-right tabular-nums text-sky-300">
<span className="font-semibold tabular-nums">
{u.month.ordersReceived}
</span>
</td>
{periods.flatMap((p) =>
metrics.map((m) => (
<td
key={`${p.period}-${m.metric}`}
className={`py-1 text-right tabular-nums ${m.accent}`}
>
<span className="font-semibold tabular-nums text-xl tracking-tight sm:text-2xl">
{u[p.period][m.metric]}
</span>
</td>
)),
)}
</tr>
))}
</tbody>
@ -462,10 +385,12 @@ function PeriodMatrix({
today,
week,
month,
year,
}: {
today: PeriodStats;
week: PeriodStats;
month: PeriodStats;
year: PeriodStats;
}) {
const metrics: Array<{
key: keyof PeriodStats;
@ -482,6 +407,7 @@ function PeriodMatrix({
{ title: "Today", stats: today },
{ title: "This week", stats: week },
{ title: "This month", stats: month },
{ title: "This year", stats: year },
];
return (
@ -534,3 +460,212 @@ function formatNumber(n: number): string {
if (!Number.isFinite(n)) return "—";
return n.toLocaleString("en-ZA");
}
/* ────────────────────────────────────────────────────────────────────────
* DriverStopsSection
*
* One tile = one driver's live workload, grouped per supplier.
* A "stop" is any active delivery or pickup keyed to the driver;
* deliveries and pickups from the same supplier are collapsed into
* a single row so the office sees "what work is at supplier X"
* without scanning duplicate rows. The map shows one pin per
* supplier at the supplier's coordinates.
*
* When `TV_DRIVER_ID` is unset the whole tile collapses to a
* short banner rather than rendering an empty map — that way a
* freshly-deployed TV without env wiring still shows the rest
* of the dashboard instead of a 7-column hole.
* ──────────────────────────────────────────────────────────────────────── */
interface SupplierGroup {
/** PB id of the supplier, or `null` for orphan rows (no supplier
* linked on the underlying item). */
supplierId: string | null;
supplierName: string;
address: string | null;
coords: [number, number] | null;
deliveries: number;
pickups: number;
totalItems: number;
totalQty: number;
/** Distinct customer destinations for delivery rows in this
* group. Empty for pure-pickup suppliers. */
customers: string[];
}
function groupStopsBySupplier(
raw: TvDashboardData["driverStops"],
): SupplierGroup[] {
const acc = new Map<string, SupplierGroup>();
const orphanKey = (stop: TvDashboardData["driverStops"][number]) =>
`__orphan::${stop.id}`;
const customerKey = (name: string | null | undefined) =>
(name || "").trim().toLowerCase();
for (const stop of raw) {
const supplierId = stop.supplier.id || null;
const key = supplierId ?? orphanKey(stop);
let bucket = acc.get(key);
if (!bucket) {
bucket = {
supplierId,
supplierName:
stop.supplier.name?.trim() ||
(supplierId ? "Unknown supplier" : "Unassigned"),
address: null,
coords: stop.supplier.coords,
deliveries: 0,
pickups: 0,
totalItems: 0,
totalQty: 0,
customers: [],
};
acc.set(key, bucket);
}
bucket.totalItems += 1;
bucket.totalQty += Number(stop.quantity_ordered || 0);
if (stop.kind === "delivery") {
bucket.deliveries += 1;
const customerName =
stop.customer?.name || stop.customer_name || null;
const seen = new Set(bucket.customers.map((c) => customerKey(c)));
if (customerName && !seen.has(customerKey(customerName))) {
bucket.customers.push(customerName);
}
} else {
bucket.pickups += 1;
}
}
return Array.from(acc.values()).sort(
(a, b) =>
b.totalItems - a.totalItems ||
b.totalQty - a.totalQty ||
a.supplierName.localeCompare(b.supplierName),
);
}
function DriverStopsSection({
driverName,
stops,
}: {
driverName: string | null;
stops: TvDashboardData["driverStops"];
}) {
// No driver configured — render a compact placeholder so the
// grid stays balanced when the env var is missing.
if (!driverName) {
return (
<section className="col-span-12 flex items-center gap-3 rounded-2xl border border-dashed border-slate-800 bg-slate-900/30 px-4 py-3 text-sm text-slate-500 lg:col-span-6">
<Truck className="size-4 shrink-0" />
<span>
Driver stops section is hidden — set{" "}
<code className="rounded bg-slate-800 px-1 py-0.5 font-mono text-[11px] text-slate-300">
TV_DRIVER_ID
</code>{" "}
in the server env to enable it.
</span>
</section>
);
}
const groups = groupStopsBySupplier(stops);
const totalQty = groups.reduce((n, g) => n + g.totalQty, 0);
const totalItems = groups.reduce((n, g) => n + g.totalItems, 0);
return (
<section className="col-span-12 flex flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/60 lg:col-span-6">
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-2">
<div>
<h2 className="flex items-center gap-2 text-base font-medium text-slate-200">
<Truck className="size-4" />
{driverName}&apos;s stops
</h2>
<p className="mt-0.5 text-[11px] text-slate-500">
{groups.length === 0 ? (
"Nothing on the run right now"
) : (
<>
{groups.length} supplier
{groups.length === 1 ? "" : "s"} · {totalItems} line item
{totalItems === 1 ? "" : "s"} · qty{" "}
{totalQty.toLocaleString("en-ZA")}
</>
)}
</p>
</div>
</div>
{groups.length === 0 ? (
<div className="flex h-24 items-center justify-center text-sm text-slate-500">
{driverName} has no active stops. As soon as a row is
assigned the wall will pick it up.
</div>
) : (
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 p-3 sm:grid-cols-12 sm:gap-3 sm:p-4 lg:gap-4">
<div className="flex sm:col-span-4">
<div className="h-full min-h-[260px] flex-1 overflow-hidden rounded-xl border border-slate-800">
<DriverStopsMap stops={stops} className="h-full" />
</div>
</div>
<div className="flex sm:col-span-8">
<div className="h-full flex-1 overflow-auto rounded-xl border border-slate-800">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-900/80 text-[10px] uppercase tracking-wider text-slate-500">
<th className="w-10 py-1.5 pl-3 text-left font-medium">#</th>
<th className="py-1.5 text-left font-medium">Supplier</th>
<th className="py-1.5 text-right font-medium">Delivery</th>
<th className="py-1.5 text-right font-medium">Pickup</th>
<th className="py-1.5 pr-3 text-right font-medium">Qty</th>
</tr>
</thead>
<tbody>
{groups.map((group, idx) => {
const customerSub = group.customers.length
? `→ ${group.customers.slice(0, 3).join(", ")}${
group.customers.length > 3
? ` +${group.customers.length - 3}`
: ""
}`
: null;
return (
<tr
key={group.supplierId ?? `orphan-${idx}`}
className="border-b border-slate-800/60 last:border-b-0 align-middle"
>
<td className="py-1.5 pl-3">
<div className="flex size-5 items-center justify-center rounded-full bg-amber-500 text-[11px] font-bold text-slate-900">
{idx + 1}
</div>
</td>
<td
className="py-1.5 text-sm font-semibold text-slate-100"
title={group.address ?? "No address on file"}
>
<div className="truncate">
{group.supplierName}
</div>
{customerSub && (
<div className="truncate text-[10px] font-normal text-slate-500">
{customerSub}
</div>
)}
</td>
<td className="py-1.5 text-right font-mono text-[11px] tabular-nums text-sky-300">
{group.deliveries || ""}
</td>
<td className="py-1.5 text-right font-mono text-[11px] tabular-nums text-amber-300">
{group.pickups || ""}
</td>
<td className="py-1.5 pr-3 text-right font-mono text-[11px] tabular-nums text-slate-300">
{group.totalQty.toLocaleString("en-ZA")}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</section>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -365,10 +365,12 @@ export function QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) {
<div className="space-y-2">
<div className="flex items-center justify-between">
<FieldLabel icon={ListOrdered} label="Quote Items" />
<Button type="button" variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
Add Item
</Button>
{items.length === 0 && (
<Button type="button" variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
Add Item
</Button>
)}
</div>
{items.length === 0 ? (
@ -377,20 +379,40 @@ export function QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) {
No items yet. Click &quot;Add Item&quot; to start.
</div>
) : (
/*
* Each card is followed by its own "Add another item" button.
* The operator can keep typing line after line without ever
* scrolling back to the top of the items section — the next
* button is always immediately below the last card they
* touched. The ghost + dashed-border styling keeps it from
* being mistaken for a submit/finish affordance.
*/
<div className="space-y-3">
{items.map((item) => (
<QuoteFormItemCard
key={item.id}
item={item}
onChange={(patch) =>
setItems((current) =>
current.map((row) => (row.id === item.id ? { ...row, ...patch } : row))
)
}
onRemove={() =>
setItems((current) => current.filter((row) => row.id !== item.id))
}
/>
<Fragment key={item.id}>
<QuoteFormItemCard
item={item}
onChange={(patch) =>
setItems((current) =>
current.map((row) => (row.id === item.id ? { ...row, ...patch } : row))
)
}
onRemove={() =>
setItems((current) => current.filter((row) => row.id !== item.id))
}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addItem}
className="h-9 w-full border border-dashed text-muted-foreground hover:text-foreground"
aria-label="Add another item"
>
<Plus className="mr-1 h-3 w-3" />
Add another item
</Button>
</Fragment>
))}
</div>
)}

View file

@ -483,8 +483,7 @@ export async function fetchQuoteItemCounts(
): Promise<Record<string, { total: number; processed: number }>> {
if (quoteIds.length === 0) return {};
const pb = getPocketBase();
const escape = (id: string) => id.replace(/"/g, '\\"');
const safeIds = quoteIds.map(escape);
const safeIds = quoteIds.map((id) => id.replace(/"/g, '\\"'));
type Slim = { id?: string; quote: string; is_processed: boolean };
const fetchSlim = (filter: string) =>
@ -493,21 +492,20 @@ export async function fetchQuoteItemCounts(
fields: "id,quote,is_processed",
});
let items: Slim[] = [];
try {
items = await fetchSlim(
`quote IN (${safeIds.map((id) => `"${id}"`).join(",")})`,
);
} catch {
// Older PB without `IN` — chunk through OR-chains. Each chunk
// is well under the filter-length limit so the same fallback
// `fetchQuoteItemsForQuotes` uses works here.
for (let i = 0; i < safeIds.length; i += QUOTE_FILTER_CHUNK) {
const batch = safeIds.slice(i, i + QUOTE_FILTER_CHUNK);
const orChain = batch.map((id) => `quote = "${id}"`).join(" || ");
items.push(...(await fetchSlim(orChain)));
}
// Build the chunked filters first, then issue them in parallel. PB
// accepts the same number of concurrent reads without rate-limiting
// for a small page-worth of chunks, and `Promise.all` collapses
// wall-clock to the slowest chunk instead of the sum. Sequential
// was used historically because the IN-then-fallback code path
// bailed early on success; with the always-OR-chain strategy
// parallel is strictly better and the chunks are decoupled.
const chunks: string[] = [];
for (let i = 0; i < safeIds.length; i += QUOTE_FILTER_CHUNK) {
const batch = safeIds.slice(i, i + QUOTE_FILTER_CHUNK);
chunks.push(batch.map((id) => `quote = "${id}"`).join(" || "));
}
const perChunk = await Promise.all(chunks.map(fetchSlim));
const items: Slim[] = perChunk.flat();
const counts: Record<string, { total: number; processed: number }> = {};
for (const item of items) {

View file

@ -440,6 +440,44 @@ describe("searchQuotes — pipeline", () => {
expect(result.hits[0].score).toBeGreaterThan(result.hits[1].score);
});
it("chunks candidate-id fetches into <=80-id OR-chains (no single large filter)", async () => {
// Build a candidate set larger than ID_FILTER_CHUNK_SIZE (80) so
// the chunkedFilterFetch path is exercised. Each candidate must
// surface as a separate `quotes` fetch with its filter string in
// pieces — a regression that forgets to chunk will surface here
// as a single mega-filter and a single round-trip.
const candidates = Array.from({ length: 250 }, (_, i) =>
makeQuote({ id: `q${i.toString().padStart(3, "0")}`, quote_number: `ACME-${i}`, customer: "", company: "" }),
);
setStore(candidates.map((fields) => ({ collection: "quotes" as const, fields })));
await searchQuotes({ query: "ACME" });
// The final "fetch candidates with expand" call writes a filter
// per chunk of 80. With 250 candidates we'd expect 4 chunks
// (ceil(250/80)=4). Assert that EVERY final-fetch filter is
// small enough to stay clear of PB's ~3.5 KB cap.
const finalQuoteCalls = getCalls().filter(
(c) =>
c.collection === "quotes" &&
c.filter !== undefined &&
c.filter.includes("ACME".toLowerCase()) === false &&
c.filter.startsWith("id =") === false,
);
void finalQuoteCalls;
const expandCalls = getCalls().filter((c) => {
if (c.collection !== "quotes" || !c.filter) return false;
// The expand-fetch filter is always an `id = "..." || id = "..."`
// OR-chain (no `~`).
return c.filter.startsWith("id =");
});
expect(expandCalls.length).toBeGreaterThan(1);
for (const call of expandCalls) {
// Each chunk is bounded by ~80 ids × ~25 chars = ~2.0 KB.
expect(call.filter!.length).toBeLessThan(3_500);
}
});
it("fills perTab with the tab each hit would land on", async () => {
setStore([
{ collection: "quotes", fields: makeQuote({ id: "q-new", quote_number: "ACME-1", status: "new" }) },

View file

@ -70,11 +70,15 @@ import type { Quote, QuoteItem } from "@/lib/types";
* Constants
* ========================================================================== */
/** Hard cap on how many quotes we score. Live PB has ~8.5k; the page
* shows the top 50 to the operator. Anything beyond the cap is
* unreachable from this search — the office can fall back to the
* tab-filtered list with no query if they need to scroll history. */
const SEARCH_CANDIDATE_CAP = 8500;
/** Hard cap on how many quotes we score. The pushdown phase
* already produces a small candidate set (typically dozens, ~200
* in the worst live case), and we only ever paginate the top 50
* to the page. Mirrors `ORDER_SEARCH_HARD_CAP = 500` in
* `orders-service.ts`. The cap also bounds how many chunks the
* parallel chunked-filter fan-out can produce — at 80 IDs/chunk
* a 500 cap produces ~7 chunks per fanout, which is well within
* PB's per-burst concurrency. */
const SEARCH_CANDIDATE_CAP = 500;
/** Default page size returned by the search endpoint. Page numbering
* follows the same 1-indexed convention as the regular list route. */
@ -404,12 +408,12 @@ function withTab(base: string, tab: QuoteTabKey | undefined): string {
/** Dedupe + validate an ID list. Drops empty / non-string values so
* a stray empty `quote` relation from PB doesn't poison the IN
* filter. Cap-defensive: if upstream returned >5000 IDs, return
* the first 5000 to keep the OR-chain well under PB's filter
* length limit. The 5000 limit is comfortably above what "instance"
* or any other realistic query should hit (operator-side memory
* notes from August 2026 put the worst real query at ~600 hits). */
function dedupeIds(ids: (string | undefined | null)[], cap = 5000): string[] {
* filter. Cap matches `SEARCH_CANDIDATE_CAP` — if upstream returns
* more, return the first N we will actually score. */
function dedupeIds(
ids: (string | undefined | null)[],
cap = SEARCH_CANDIDATE_CAP,
): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const id of ids) {
@ -422,23 +426,69 @@ function dedupeIds(ids: (string | undefined | null)[], cap = 5000): string[] {
return out;
}
/** Build an OR-chain of `id = "<id>"` clauses. Avoids the PB `IN`
* filter entirely because some PB versions reject `IN` with very
* long lists; OR-chains of N <5000 are reliable across versions. */
/** Safe ceiling on the number of ids in one inline `id = "..." || ...`
* filter (or `customer = "..." || ...`). The orders service measured
* live PB at this exact number:
*
* "A filter with 107 ids (~3.5 KB) is rejected with HTTP 400
* ('Something went wrong while processing your request.') while
* 106 ids pass."
*
* We sit comfortably below that so the query string's other params
* (sort, expand, perPage) can't push a chunk over the edge either.
* Mirrors `ID_FILTER_CHUNK_SIZE` in `orders-service.ts`. */
const ID_FILTER_CHUNK_SIZE = 80;
/** Build an OR-chain of `id = "<id>"` clauses for ONE chunk. Don't
* pass >ID_FILTER_CHUNK_SIZE ids to this — use `chunkedFilterFetch`
* which chunks + parallelizes. */
function idEqualsChain(ids: string[]): string {
return ids.map((id) => `id = "${escapeForPbFilter(id)}"`).join(" || ");
return ids
.map((id) => `id = "${escapeForPbFilter(id)}"`)
.join(" || ");
}
/** Build an OR-chain of `customer = "<id>"` / `company = "<id>"`
* clauses for header-side joins. Same OR-chain rationale as
* `idEqualsChain`. */
function relationEqualsChain(customerIds: string[], companyIds: string[]): string {
* clauses for ONE chunk — same chunking rationale as `idEqualsChain`. */
function relationEqualsChain(
customerIds: string[],
companyIds: string[],
): string {
const parts: string[] = [];
for (const id of customerIds) parts.push(`customer = "${escapeForPbFilter(id)}"`);
for (const id of companyIds) parts.push(`company = "${escapeForPbFilter(id)}"`);
for (const id of customerIds)
parts.push(`customer = "${escapeForPbFilter(id)}"`);
for (const id of companyIds)
parts.push(`company = "${escapeForPbFilter(id)}"`);
return parts.join(" || ");
}
/** Generic chunked fetch — runs the same `pbCall(opts)` against an
* array of ids split into chunks of ID_FILTER_CHUNK_SIZE, in
* parallel. Returns the merged result set. PB only sorts within
* a chunk so callers that want a globally sorted result must
* re-sort (mirrors `fetchOrdersFilteredByIds` in orders-service).
*
* Why chunk at all: live PB rejects inline OR-chain filters above
* ~3.5 KB with HTTP 400 (orders-service.ts:412). Without chunking,
* a "the" / "bolt" / other common-token search on the live quote
* collection silently returns nothing because the filter blows up
* before scoring even starts. */
async function chunkedFilterFetch<T>(
ids: string[],
buildFilter: (chunk: string[]) => string,
pbCall: (filter: string) => Promise<T[]>,
): Promise<T[]> {
if (ids.length === 0) return [];
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += ID_FILTER_CHUNK_SIZE) {
chunks.push(ids.slice(i, i + ID_FILTER_CHUNK_SIZE));
}
const results = await Promise.all(
chunks.map(async (chunk) => pbCall(buildFilter(chunk))),
);
return results.flat();
}
/** Minimal quote projection used during the pushdown phase. We only
* need the IDs to dedupe + feed the next roundtrip; the expanded
* rows come back in the final fetch. Trimmed to absolute minimum
@ -493,17 +543,48 @@ async function fetchCandidateIds(
]);
const customerIds = matchedCustomers.map((c) => c.id);
const companyIds = matchedCompanies.map((c) => c.id);
// Pushdown on `customer = ... || company = ...` would otherwise blow
// past PB's inline-filter length cap on common tokens (live PB rejects
// >~3.5 KB). Chunk + parallelize via the shared helper.
const headerQuotes =
customerIds.length + companyIds.length > 0
? await pb.collection("quotes").getFullList<{
id: string;
status?: string;
is_template?: boolean;
delivery_note?: string;
}>({
filter: withTab(relationEqualsChain(customerIds, companyIds), tab),
fields: QUOTE_ID_FIELDS,
batch: 500,
? await chunkedFilterFetch(
customerIds,
(chunk) =>
withTab(relationEqualsChain(chunk, []), tab),
(filter) =>
pb.collection("quotes").getFullList<{
id: string;
status?: string;
is_template?: boolean;
delivery_note?: string;
}>({
filter,
fields: QUOTE_ID_FIELDS,
batch: 500,
}),
).then(async (firstHalf) => {
// We merged the two relations into one chunked fetch — run the
// company side as a second pass, then combine.
const secondHalf =
companyIds.length > 0
? await chunkedFilterFetch(
companyIds,
(chunk) => withTab(relationEqualsChain([], chunk), tab),
(filter) =>
pb.collection("quotes").getFullList<{
id: string;
status?: string;
is_template?: boolean;
delivery_note?: string;
}>({
filter,
fields: QUOTE_ID_FIELDS,
batch: 500,
}),
)
: [];
return [...firstHalf, ...secondHalf];
})
: [];
@ -520,18 +601,26 @@ async function fetchCandidateIds(
batch: 1000,
});
const itemQuoteIds = dedupeIds(matchedItems.map((m) => m.quote));
// Same chunking concern as the header-pushdown: a popular token
// can match thousands of items, producing a large parent-quote
// set that would 400 PB if sent as a single OR-chain.
const itemQuotes =
itemQuoteIds.length > 0
? await pb.collection("quotes").getFullList<{
id: string;
status?: string;
is_template?: boolean;
delivery_note?: string;
}>({
filter: withTab(idEqualsChain(itemQuoteIds), tab),
fields: QUOTE_ID_FIELDS,
batch: 500,
})
? await chunkedFilterFetch(
itemQuoteIds,
(chunk) => withTab(idEqualsChain(chunk), tab),
(filter) =>
pb.collection("quotes").getFullList<{
id: string;
status?: string;
is_template?: boolean;
delivery_note?: string;
}>({
filter,
fields: QUOTE_ID_FIELDS,
batch: 500,
}),
)
: [];
// 5. Dedupe across all three sources. The set is the union of
@ -590,15 +679,23 @@ export async function searchQuotes(
// 2. Fetch the candidate quotes WITH expand so the scorer can
// read the customer / company name (pushdown step only
// produced IDs).
// produced IDs). Chunked for the same reason as
// `fetchCandidateIds` — a popular query easily produces a
// candidate set of >100 quotes, which would 400 PB inline
// without chunking.
const candidates =
candidateIds.length > 0
? await pb.collection("quotes").getFullList<Quote>({
filter: idEqualsChain(candidateIds),
fields: QUOTE_SEARCH_FIELDS,
expand: QUOTE_SEARCH_EXPAND,
batch: 500,
})
? await chunkedFilterFetch(
candidateIds,
(chunk) => idEqualsChain(chunk),
(filter) =>
pb.collection("quotes").getFullList<Quote>({
filter,
fields: QUOTE_SEARCH_FIELDS,
expand: QUOTE_SEARCH_EXPAND,
batch: 500,
}),
)
: [];
// 3. Items for the candidates (chunked IN under the hood).

View file

@ -1,5 +1,6 @@
import { getAdminPocketBase } from "./pocketbase-admin";
import { getDashboardData, type DashboardData } from "./dashboard-service";
import { listDriverStops, type DriverStop } from "./driver-stops";
/* ============================================================================
* TV dashboard read model.
@ -13,8 +14,10 @@ import { getDashboardData, type DashboardData } from "./dashboard-service";
* - **quotes per rep** — `{ name, today, week, month }[]`
* - **user performance** — per-rep totals (quotes, orders, items
* completed) for the day
* - **driver stops** — today's active deliveries with GPS coords
* so the TV can drop a map pin per stop
* - **driver stops** — Joachim's (or whoever `TV_DRIVER_ID` names)
* active deliveries + pickups, GPS-tagged so the TV can drop a
* pin per stop. As he marks them collected via the driver app
* they drop off the next 30s poll / realtime push.
*
* We piggy-back on the existing `getDashboardData()` for the
* historical stuff and bolt the new sections on via Promise.all.
@ -43,15 +46,10 @@ const PROCESSED_ITEM_STATUSES = [
"stock_verified",
];
// Driver map: only `item_status = "assigned"` rows. See
// `loadDriverStops` for the rationale.
//
// Pickups: `collection_pool` (office booked, no driver yet) and
// `assigned_to_driver` (driver owes the pickup). One pin per
// supplier, items grouped beneath.
const PICKUP_STATUSES = ["collection_pool", "assigned_to_driver"];
const STOCK_PURCHASE_ITEMS_COLLECTION =
process.env.STOCK_PURCHASE_ITEMS_COLLECTION || "stock_purchase_items";
// Driver map: only `item_status = "assigned"` rows. The driver
// stops service (`./driver-stops.ts`) is the source of truth for
// which rows qualify as a driver's active workload — we re-use it
// here so the TV and the driver app never drift in scope.
export interface PeriodStats {
ordersReceived: number;
@ -71,44 +69,22 @@ export interface RepQuoteCount {
export interface UserPerformance {
user_id: string;
user_name: string;
today: PeriodStats;
week: PeriodStats;
month: PeriodStats;
/** Quote count (from `quotes.created`) per period. */
today: UserStats;
week: UserStats;
month: UserStats;
year: UserStats;
}
export interface DriverStopTv {
id: string;
description: string;
customer_name: string | null;
company_name: string | null;
/** [lat, lng] in Leaflet order. `null` when the customer row has no
* geoPoint set — the map just skips the marker. */
coords: [number, number] | null;
quantity: number;
item_status: string;
order_number: string | null;
}
export interface SupplierPickupItemTv {
id: string;
description: string;
quantity: number;
purchase_number: string | null;
item_status: string;
}
export interface SupplierPickupTv {
/** `null` when the row has no supplier relation set (orphan). */
supplier_id: string | null;
supplier_name: string;
/** [lat, lng] in Leaflet order. `null` when the supplier has no
* geoPoint — the map just skips the marker for that supplier. */
coords: [number, number] | null;
address: string;
/** Aggregated across all pickup rows at this supplier. */
total_items: number;
total_qty: number;
items: SupplierPickupItemTv[];
/**
* Per-rep counters the TV's rep activity tile renders. Narrower
* than `PeriodStats` — only quotes and orders. Order-item
* processing is *not* attributed to reps (the office doesn't
* care which rep's items got actioned by the warehouse / driver).
*/
export interface UserStats {
ordersReceived: number;
quotesReceived: number;
}
export interface TvDashboardData {
@ -118,10 +94,18 @@ export interface TvDashboardData {
today: PeriodStats;
week: PeriodStats;
month: PeriodStats;
year: PeriodStats;
quotesPerRep: RepQuoteCount[];
userPerformance: UserPerformance[];
driverStops: DriverStopTv[];
supplierPickups: SupplierPickupTv[];
/** The driver whose active stops appear on the TV (resolved from
* `TV_DRIVER_ID`). `null` when the env var is unset — the TV
* then hides the stops section entirely. */
driverName: string | null;
/** Active deliveries + pickups for `driverName`. Empty array
* when no driver is configured. The shape is the same
* `DriverStop` type the driver app consumes, so a stop's
* appearance can't drift between the two surfaces. */
driverStops: DriverStop[];
/** When the snapshot was built — useful for "Updated Xs ago". */
generatedAt: string;
}
@ -159,40 +143,64 @@ function startOfMonthUtc(now: Date): Date {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
}
function startOfYearUtc(now: Date): Date {
return new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
}
function isoOf(d: Date): string {
return d.toISOString();
}
/** Escape a string for embedding inside a PB filter literal
* (`"..."`). Mirrors the helper in `driver-stops.ts`. */
function escapeForBucketFilter(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Parse `TV_REP_ROLES` into an uppercase, deduped allow-list of
* `user_type` values that mark a PB user as a "rep" for the TV
* activity list. Defaults to `["REP"]` so a fresh deployment
* without the env var still produces a sensibly-filtered list.
*
* Accepts comma- or whitespace-separated values; case-insensitive.
* An empty / unset var falls back to the default.
*/
function parseRepRoles(raw: string | undefined | null): string[] {
const fallback = ["REP"];
if (!raw) return fallback;
const parts = raw
.split(/[,\s]+/)
.map((v) => v.trim().toUpperCase())
.filter(Boolean);
return parts.length > 0 ? Array.from(new Set(parts)) : fallback;
}
/* ──────────────────────────────────────────────────────────────────────────
* Counters
* ──────────────────────────────────────────────────────────────────────── */
/**
* Count distinct `order_number` (or `quote_number`) values inside the
* window. Pulls a generous batch (500) and dedupes client-side — PB
* has no native DISTINCT in its filter syntax, so this is the only
* way. The 500 cap matches the rest of the dashboard's bounds.
* Count PB rows matching `created >= sinceIso`. Returns PB's own
* `totalItems` (computed server-side) via a `perPage=1` query, so
* the count is exact regardless of how many rows are in the
* window. The previous `countDistinctSince` had a 500-row cap
* that silently under-reported the year column (always 500).
*
* `field` is the dedupe key (`order_number` for orders, `quote_number`
* for quotes). Empty / missing keys are grouped together as
* "(unnumbered)".
* Note: `orders` and `quotes` are 1-row-per-order / 1-row-per-
* quote, so `totalItems` equals distinct order_number / quote_number
* counts. If that ever changes, switch to a paginated dedupe here.
*/
async function countDistinctSince(
async function countSince(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
collection: string,
sinceIso: string,
field: string,
): Promise<number> {
const result = await pb.collection(collection).getList(1, 500, {
const result = await pb.collection(collection).getList(1, 1, {
filter: `created >= "${sinceIso}"`,
fields: `id,${field}`,
fields: "id",
});
const seen = new Set<string>();
for (const row of result.items as Array<Record<string, unknown>>) {
const v = row[field];
seen.add(typeof v === "string" && v.length > 0 ? v : "__unnumbered__");
}
return seen.size;
return result.totalItems || 0;
}
/**
@ -320,30 +328,41 @@ async function loadUserPerformance(
const monthStartIso = isoOf(startOfMonthUtc(now));
const weekStartIso = isoOf(startOfWeekUtc(now));
const todayIso = isoOf(since24hAgo(now));
const yearStartIso = isoOf(startOfYearUtc(now));
// Pre-populate every named user on the team so the TV always
// shows who's on the roster — even on a quiet day, and even if
// the rep's `user_type` is empty / `ADMIN` (e.g. Gary). Test
// Pre-populate the rep roster from the `users` collection. Test
// accounts are filtered via email-pattern heuristics so QA /
// agent rows don't leak onto the wall.
const blank = (): UserPerformance => ({
user_id: "",
user_name: "",
today: { ordersReceived: 0, ordersPlaced: 0, quotesReceived: 0, itemsProcessed: 0 },
week: { ordersReceived: 0, ordersPlaced: 0, quotesReceived: 0, itemsProcessed: 0 },
month: { ordersReceived: 0, ordersPlaced: 0, quotesReceived: 0, itemsProcessed: 0 },
});
const userById = new Map<string, UserPerformance>();
// agent rows don't leak onto the wall. The rep-vs-not-rep
// gate is `TV_REP_ROLES` (comma-separated `user_type` values;
// default `REP`). A user is treated as a rep when their
// `user_type` array intersects with this allow-list — admin,
// driver, finance, etc. all get dropped unless explicitly
// listed.
const repRoles = parseRepRoles(process.env.TV_REP_ROLES);
if (repRoles.length === 0) {
// Should be unreachable (default is "REP") but guard so
// a misconfigured env doesn't silently re-include everyone.
return [];
}
const isTestEmail = (email: string) =>
/@test\.local$/i.test(email) || /@example\./i.test(email);
const isRep = (userType: unknown): boolean => {
if (!Array.isArray(userType)) return false;
return userType.some(
(v) => typeof v === "string" && repRoles.includes(v.toUpperCase()),
);
};
const userById = new Map<string, UserPerformance>();
const repIds: string[] = [];
try {
const users = await pb.collection(USERS_COLLECTION).getList(1, 500, {
fields: "id,name,email,accont_status",
fields: "id,name,email,user_type,accont_status",
});
for (const u of users.items as Array<{
id: string;
name?: string;
email?: string;
user_type?: string[] | null;
// PB schema spells this field as `accont_status` (sic, three
// c's). When the field is missing we treat the user as
// active — older records predate the field and we don't want
@ -357,131 +376,105 @@ async function loadUserPerformance(
// `accont_status === false` ⇒ account deactivated ⇒ skip.
// Undefined / true ⇒ active ⇒ include.
if (u.accont_status === false) continue;
const row = blank();
row.user_id = u.id;
row.user_name = name;
userById.set(u.id, row);
if (!isRep(u.user_type)) continue;
userById.set(u.id, {
user_id: u.id,
user_name: name,
today: { ordersReceived: 0, quotesReceived: 0 },
week: { ordersReceived: 0, quotesReceived: 0 },
month: { ordersReceived: 0, quotesReceived: 0 },
year: { ordersReceived: 0, quotesReceived: 0 },
});
repIds.push(u.id);
}
} catch {
// Fall through — activity-only path still produces a list.
// Fall through — empty roster ⇒ empty result.
}
// One fetch per data type covering the whole month. Bucket each
// row into today/week/month client-side. Saves 6 PB queries
// versus fetching each window separately. Month is the widest
// window we display, so anything in the month set auto-buckets
// into one of today/week/month.
const [quotesMonth, ordersMonth, itemsMonth] = await Promise.all([
pb.collection(QUOTES_COLLECTION).getList(1, 500, {
filter: `created >= "${monthStartIso}"`,
fields: "id,rep,customer,created",
}),
pb.collection(ORDERS_COLLECTION).getList(1, 500, {
filter: `created >= "${monthStartIso}"`,
fields: "id,rep,customer,created",
}),
pb
.collection(process.env.ORDER_ITEMS_COLLECTION || "order_items")
.getList(1, 500, {
filter: `updated >= "${monthStartIso}" && (${PROCESSED_ITEM_STATUSES.map(
(s) => `item_status = "${s}"`,
).join(" || ")})`,
fields: "order,updated",
expand: "order.rep",
}),
]);
if (repIds.length === 0) return [];
// Customer rep fallback — same 2-pass workaround as before.
const customerIdsNeeded = Array.from(
new Set(
[
...(quotesMonth.items as Array<{ rep?: string; customer?: string }>),
...(ordersMonth.items as Array<{ rep?: string; customer?: string }>),
]
.filter((r) => !r.rep && r.customer)
.map((r) => r.customer)
.filter(Boolean) as string[],
),
);
const customerRepById = new Map<string, string>();
if (customerIdsNeeded.length > 0) {
const customers = await pb.collection("customers").getList(1, customerIdsNeeded.length, {
filter: customerIdsNeeded.map((id) => `id = "${id}"`).join(" || "),
fields: "id,rep",
});
for (const c of customers.items as Array<{ id: string; rep?: string }>) {
if (c.rep) customerRepById.set(c.id, c.rep);
// Per-rep exact counts via PB's `totalItems` field. We hit PB
// with `perPage=1` so it returns the count without shipping
// any rows. Three conditions per rep per window:
//
// - Quotes: customer.rep fallback only. The `quotes` collection
// has NO `rep` column — the operator runs the rep attribution
// through `customer.rep`. So `(customer.rep="X" || ...)` is
// the only path that counts anything.
// - Orders: direct rep match + customer.rep fallback. Orders
// have a `rep` column AND the customer fallback works.
//
// Cost: 4 windows × ~1.5 collections × N reps. For 10 reps
// that's ~60 quick count queries — fits well inside the 30s
// poll budget. Previously the same data was fetched as a
// 500-row sample with a 500-row cap, which is why the year
// column was reporting 500 (the literal cap).
const windows = [
{ label: "today", since: todayIso },
{ label: "week", since: weekStartIso },
{ label: "month", since: monthStartIso },
{ label: "year", since: yearStartIso },
] as const;
type PeriodKey = "today" | "week" | "month" | "year";
const queries: Promise<void>[] = [];
for (const repId of repIds) {
const safeRepId = escapeForBucketFilter(repId);
for (const w of windows) {
// Quotes — `quotes` has no `rep` column, so customer.rep
// is the only rep attribution path available.
queries.push(
pb
.collection("quotes")
.getList(1, 1, {
filter: `customer.rep="${safeRepId}" && created >= "${w.since}"`,
fields: "id",
})
.then((r) => {
const row = userById.get(repId);
if (row) row[w.label as PeriodKey].quotesReceived += r.totalItems;
}),
);
// Orders — direct rep match.
queries.push(
pb
.collection("orders")
.getList(1, 1, {
filter: `rep="${safeRepId}" && created >= "${w.since}"`,
fields: "id",
})
.then((r) => {
const row = userById.get(repId);
if (row) row[w.label as PeriodKey].ordersReceived += r.totalItems;
}),
);
// Orders — customer.rep fallback for orders with no rep.
queries.push(
pb
.collection("orders")
.getList(1, 1, {
filter: `customer.rep="${safeRepId}" && (rep="" || rep=null) && created >= "${w.since}"`,
fields: "id",
})
.then((r) => {
const row = userById.get(repId);
if (row) row[w.label as PeriodKey].ordersReceived += r.totalItems;
}),
);
}
}
await Promise.all(queries);
/**
* Bucket helper — given a record's rep id (resolved through the
* fallback chain) and an ISO timestamp, bump the count on the
* matching period slots. Skips reps not in our roster (test /
* agent accounts) and orphan records with no resolvable rep.
*/
const bucket = (
rep: { rep?: string; customer?: string } | null | undefined,
ts: string,
metric: "quotesReceived" | "ordersReceived" | "itemsProcessed",
) => {
const repId = rep?.rep || (rep?.customer ? customerRepById.get(rep.customer) : undefined);
if (!repId) return;
const row = userById.get(repId);
if (!row) return;
if (ts >= todayIso) {
row.today[metric] += 1;
}
if (ts >= weekStartIso) {
row.week[metric] += 1;
}
if (ts >= monthStartIso) {
row.month[metric] += 1;
}
};
for (const q of quotesMonth.items as unknown as Array<{
rep?: string;
customer?: string;
created: string;
}>) {
bucket(q, q.created, "quotesReceived");
}
for (const o of ordersMonth.items as unknown as Array<{
rep?: string;
customer?: string;
created: string;
}>) {
bucket(o, o.created, "ordersReceived");
}
for (const it of itemsMonth.items as Array<{
updated?: string;
expand?: { order?: { rep?: string } };
}>) {
const orderRep = it.expand?.order;
const repId = orderRep?.rep;
const ts = it.updated || "";
// Items inherit rep from the parent order directly (orders may
// not have the customer relation projected in this query).
if (repId) {
const row = userById.get(repId);
if (row && ts) {
if (ts >= todayIso) row.today.itemsProcessed += 1;
if (ts >= weekStartIso) row.week.itemsProcessed += 1;
if (ts >= monthStartIso) row.month.itemsProcessed += 1;
}
}
}
// Sum today + week + month activity into a single rank. Reps
// with zero activity across all windows are dropped — a quiet
// roster is implicit (the operators without activity aren't on
// the wall today). Sort is highest to lowest, then alphabetical
// for ties so the order is stable run-to-run.
// Sum today + week + month + year activity into a single rank.
// Reps with zero activity across all windows are dropped — a
// quiet roster is implicit (the operators without activity
// aren't on the wall today). Sort is highest to lowest, then
// alphabetical for ties so the order is stable run-to-run.
const totalActivity = (r: UserPerformance) =>
Object.values(r.today).reduce((a, b) => a + b, 0) +
Object.values(r.week).reduce((a, b) => a + b, 0) +
Object.values(r.month).reduce((a, b) => a + b, 0);
Object.values(r.month).reduce((a, b) => a + b, 0) +
Object.values(r.year).reduce((a, b) => a + b, 0);
return Array.from(userById.values())
.filter((r) => totalActivity(r) > 0)
.sort(
@ -492,238 +485,52 @@ async function loadUserPerformance(
}
/* ──────────────────────────────────────────────────────────────────────────
* Driver stops — today's active deliveries
* Driver stops — Joachim's active deliveries + pickups
*
* Scoped to a single driver via `TV_DRIVER_ID`. The TV is wired to
* one driver because the wall display is meant to track that
* driver's progress live: as he marks rows collected via the
* driver app, the corresponding PB rows flip status and the
* next 30s poll / realtime push drops them off the wall.
*
* The driver app's `listDriverStops` is the source of truth for
* which rows qualify — we re-use it here so the TV's definition
* of "active" cannot drift from the driver's view.
* ──────────────────────────────────────────────────────────────────────── */
async function loadDriverStops(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
now: Date,
): Promise<DriverStopTv[]> {
// Restrict to `item_status = "assigned"` only — the live
// workload the driver is currently out delivering. The earlier
// OR fallback (`assigned_at = null && updated >= today`)
// accidentally pulled cancelled / delivery_note / pending /
// etc. by matching any-status when assigned_at was empty;
// dropping that branch keeps the TV accurate to "what's out
// the door now".
void now;
const items = await pb
.collection(process.env.ORDER_ITEMS_COLLECTION || "order_items")
.getList(1, 200, {
filter: `item_status = "assigned"`,
sort: "assigned_at",
expand: "order,order.customer,order.company",
});
const out: DriverStopTv[] = [];
for (const raw of items.items as unknown as Array<{
id: string;
description?: string;
quantity?: number;
item_status?: string;
expand?: {
order?: {
order_number?: string;
expand?: {
customer?: {
customer?: string;
coordinates?: { lon?: number; lat?: number } | null;
};
company?: {
company?: string;
coordinates?: { lon?: number; lat?: number } | null;
};
};
};
};
}>) {
const order = raw.expand?.order;
const customer = order?.expand?.customer;
const company = order?.expand?.company;
// Prefer customer coordinates (delivery is to the customer site);
// fall back to the company coordinates so a missing customer
// geoPoint doesn't drop the pin entirely.
const coord =
customer?.coordinates?.lon != null &&
customer?.coordinates?.lat != null &&
typeof customer.coordinates.lon === "number" &&
typeof customer.coordinates.lat === "number" &&
!(customer.coordinates.lon === 0 && customer.coordinates.lat === 0)
? ([
customer.coordinates.lat,
customer.coordinates.lon,
] as [number, number])
: company?.coordinates?.lon != null &&
company?.coordinates?.lat != null &&
typeof company.coordinates.lon === "number" &&
typeof company.coordinates.lat === "number" &&
!(company.coordinates.lon === 0 && company.coordinates.lat === 0)
? ([
company.coordinates.lat,
company.coordinates.lon,
] as [number, number])
: null;
out.push({
id: raw.id,
description: raw.description || "(no description)",
customer_name: customer?.customer || null,
company_name: company?.company || null,
coords: coord,
quantity: Number(raw.quantity ?? 0),
item_status: raw.item_status || "pending",
order_number: order?.order_number || null,
});
}
return out;
interface DriverStopsResult {
driverName: string | null;
stops: DriverStop[];
}
/* ──────────────────────────────────────────────────────────────────────────
* Supplier pickups — places the driver needs to visit to collect
* stock from suppliers. Grouped per supplier so multiple line
* items at the same supplier collapse into one pin.
* ──────────────────────────────────────────────────────────────────────── */
async function loadSupplierPickups(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
): Promise<SupplierPickupTv[]> {
const ors = PICKUP_STATUSES.map((s) => `status = "${s}"`).join(" || ");
// Pass 1: active pickup items + parent purchase number for display.
const rows = await pb
.collection(STOCK_PURCHASE_ITEMS_COLLECTION)
.getList(1, 500, {
filter: `(${ors})`,
fields: "id,description,quantity,status,purchase,assigned_at",
});
const purchaseIds = Array.from(
new Set(
(rows.items as Array<{ purchase?: string }>)
.map((r) => r.purchase)
.filter(Boolean),
),
);
// Pass 2: parent purchases — just the supplier id + PO number.
// `expand: "supplier"` here returns nothing reliable on this PB,
// so we resolve suppliers via a third pass.
const purchaseById = new Map<
string,
{
purchase_number: string | null;
supplier_id: string | null;
}
>();
if (purchaseIds.length > 0) {
const purchases = await pb.collection("stock_purchases").getList(1, purchaseIds.length, {
filter: purchaseIds.map((id) => `id = "${id}"`).join(" || "),
fields: "id,purchase_number,supplier",
});
for (const p of purchases.items as Array<{
id: string;
purchase_number?: string;
supplier?: string;
}>) {
purchaseById.set(p.id, {
purchase_number: p.purchase_number || null,
supplier_id: p.supplier || null,
});
}
async function loadDriverStops(): Promise<DriverStopsResult> {
const drvId = process.env.TV_DRIVER_ID?.trim();
if (!drvId) {
// No driver configured — header renders the section empty
// and the hide-on-empty branch in `tv-view.tsx` covers it.
return { driverName: null, stops: [] };
}
// Pass 3: suppliers — name, address, coordinates. Text-format
// coordinates; we parse "lat, lng" and reject Null Island.
const supplierIds = Array.from(
new Set(
Array.from(purchaseById.values())
.map((p) => p.supplier_id)
.filter(Boolean) as string[],
),
);
const supplierById = new Map<
string,
{ name: string; address: string; coords: [number, number] | null }
>();
if (supplierIds.length > 0) {
const suppliers = await pb.collection("suppliers").getList(1, supplierIds.length, {
filter: supplierIds.map((id) => `id = "${id}"`).join(" || "),
fields: "id,suppliername,supplieraddress,coordinates",
const { items } = await listDriverStops({
drv_id: drvId,
perPage: 200,
page: 1,
});
let driverName: string | null = null;
try {
const pb = await getAdminPocketBase();
const user = await pb.collection(USERS_COLLECTION).getOne(drvId, {
fields: "id,name",
});
for (const s of suppliers.items as Array<{
id: string;
suppliername?: string;
supplieraddress?: string;
coordinates?: string | null;
}>) {
const coordText = s.coordinates || null;
let coords: [number, number] | null = null;
if (typeof coordText === "string" && coordText.trim()) {
const parts = coordText.split(",").map((p) => Number(p.trim()));
if (
parts.length === 2 &&
Number.isFinite(parts[0]) &&
Number.isFinite(parts[1]) &&
!(parts[0] === 0 && parts[1] === 0)
) {
coords = [parts[0], parts[1]] as [number, number];
}
}
supplierById.set(s.id, {
name: s.suppliername || "Unknown supplier",
address: s.supplieraddress || "",
coords,
});
}
driverName = (user as { name?: string }).name?.trim() || drvId;
} catch {
// Driver id missing or stale — fall back to the raw id so the
// TV still shows something vs hiding the section entirely.
driverName = drvId;
}
// Pass 4: group items by their parent purchase's supplier.
const acc = new Map<string, SupplierPickupTv>();
for (const r of rows.items as unknown as Array<{
id: string;
description?: string;
quantity?: number;
status?: string;
purchase?: string;
}>) {
const purchase = r.purchase ? purchaseById.get(r.purchase) : null;
const supplierId = purchase?.supplier_id || null;
// Stable key per supplier, with an orphan fallback so any
// purchase missing the supplier still surfaces on the list.
const key = supplierId || `__orphan__::${r.purchase || ""}`;
let bucket = acc.get(key);
if (!bucket) {
const sup = supplierId ? supplierById.get(supplierId) : null;
bucket = {
supplier_id: supplierId,
supplier_name: sup?.name || "Unassigned",
coords: sup?.coords || null,
address: sup?.address || "",
total_items: 0,
total_qty: 0,
items: [],
};
acc.set(key, bucket);
}
bucket.total_items += 1;
bucket.total_qty += Number(r.quantity ?? 0);
bucket.items.push({
id: r.id,
description: r.description || "(no description)",
quantity: Number(r.quantity ?? 0),
purchase_number: purchase?.purchase_number || null,
item_status: r.status || "collection_pool",
});
}
// Largest supplier first (most items to collect), then by qty.
return Array.from(acc.values()).sort(
(a, b) =>
b.total_items - a.total_items ||
b.total_qty - a.total_qty ||
a.supplier_name.localeCompare(b.supplier_name),
);
return { driverName, stops: items };
}
/* ──────────────────────────────────────────────────────────────────────────
@ -739,16 +546,17 @@ async function loadPeriodStats(
): Promise<PeriodStats> {
const [ordersReceived, ordersPlaced, quotesReceived, itemsProcessed] =
await Promise.all([
// Distinct order_number — ERP imports can stamp the same
// number on dozens of rows; an "order" to the operator is
// one order_number, not one row.
countDistinctSince(pb, ORDERS_COLLECTION, sinceIso, "order_number"),
// Total orders in window. `orders` is 1 row per order so
// totalItems equals the distinct-order count directly.
countSince(pb, ORDERS_COLLECTION, sinceIso),
// Distinct purchase_number, filtered to POs that were actually
// placed with the supplier (not drafts, not cancelled).
// `mail_failed` is included so the operator sees a PO they
// might want to retry.
// might want to retry. Kept distinct because stock_purchases
// can have multiple rows per PO.
countDistinctStockPurchasesPlaced(pb, sinceIso),
countDistinctSince(pb, QUOTES_COLLECTION, sinceIso, "quote_number"),
// Total quotes in window. `quotes` is 1 row per quote.
countSince(pb, QUOTES_COLLECTION, sinceIso),
countItemsUpdatedWithStatus(
pb,
sinceIso,
@ -805,19 +613,19 @@ export async function getTvDashboardData(
today,
week,
month,
year,
quotesPerRep,
userPerformance,
driverStops,
supplierPickups,
driverResult,
] = await Promise.all([
getDashboardData(now, pb),
loadPeriodStats(pb, isoOf(since24hAgo(now))),
loadPeriodStats(pb, isoOf(startOfWeekUtc(now))),
loadPeriodStats(pb, isoOf(startOfMonthUtc(now))),
loadPeriodStats(pb, isoOf(startOfYearUtc(now))),
loadQuotesPerRep(pb, now),
loadUserPerformance(pb, now),
loadDriverStops(pb, now),
loadSupplierPickups(pb),
loadDriverStops(),
]);
return {
@ -825,10 +633,11 @@ export async function getTvDashboardData(
today,
week,
month,
year,
quotesPerRep,
userPerformance,
driverStops,
supplierPickups,
driverName: driverResult.driverName,
driverStops: driverResult.stops,
generatedAt: now.toISOString(),
};
}