diff --git a/.audit-screenshots/fix-10-add-another-item.png b/.audit-screenshots/fix-10-add-another-item.png new file mode 100644 index 0000000..6b5f388 Binary files /dev/null and b/.audit-screenshots/fix-10-add-another-item.png differ diff --git a/.audit-screenshots/fix-10a-empty-state.png b/.audit-screenshots/fix-10a-empty-state.png new file mode 100644 index 0000000..c0af62f Binary files /dev/null and b/.audit-screenshots/fix-10a-empty-state.png differ diff --git a/.audit-screenshots/fix-10b-one-item.png b/.audit-screenshots/fix-10b-one-item.png new file mode 100644 index 0000000..ecae6a5 Binary files /dev/null and b/.audit-screenshots/fix-10b-one-item.png differ diff --git a/.playwright-mcp/console-2026-08-18T09-57-48-982Z.log b/.playwright-mcp/console-2026-08-18T09-57-48-982Z.log new file mode 100644 index 0000000..1648c3c --- /dev/null +++ b/.playwright-mcp/console-2026-08-18T09-57-48-982Z.log @@ -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 diff --git a/.playwright-mcp/page-2026-08-18T09-57-49-606Z.yml b/.playwright-mcp/page-2026-08-18T09-57-49-606Z.yml new file mode 100644 index 0000000..9ea1877 --- /dev/null +++ b/.playwright-mcp/page-2026-08-18T09-57-49-606Z.yml @@ -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] \ No newline at end of file diff --git a/docs/tv-dashboard.md b/docs/tv-dashboard.md index 8357720..522d8c3 100644 --- a/docs/tv-dashboard.md +++ b/docs/tv-dashboard.md @@ -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: diff --git a/src/app/(tv)/board/components/driver-stops-map.tsx b/src/app/(tv)/board/components/driver-stops-map.tsx new file mode 100644 index 0000000..1e0434b --- /dev/null +++ b/src/app/(tv)/board/components/driver-stops-map.tsx @@ -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: () => }, +); + +function MapSkeleton() { + return ( +
+
+ + Loading map… +
+
+ ); +} + +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(); + 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 ( +
+ +
+ ); +} diff --git a/src/app/(tv)/board/components/supplier-map.tsx b/src/app/(tv)/board/components/supplier-map.tsx deleted file mode 100644 index 136ada8..0000000 --- a/src/app/(tv)/board/components/supplier-map.tsx +++ /dev/null @@ -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: () => }, -); - -function MapSkeleton() { - return ( -
-
- - Loading map… -
-
- ); -} - -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 ( -
- -
- ); -} \ No newline at end of file diff --git a/src/app/(tv)/board/tv-view.tsx b/src/app/(tv)/board/tv-view.tsx index 3554a97..21fc2f7 100644 --- a/src/app/(tv)/board/tv-view.tsx +++ b/src/app/(tv)/board/tv-view.tsx @@ -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 (
@@ -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) ─────────── */} - {/* ── Suppliers + rep activity side-by-side (no-scroll row) ── */} + {/* ── Driver stops + rep activity side-by-side (no-scroll row) ── */}
- {/* Map + supplier stops (compact, no address column — map - shows location; address on file shown only as a - hover/title for accessibility). */} -
-
-
-

- - Suppliers to visit today -

-

- {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")}`} -

-
-
- {supplierPickups.length === 0 ? ( -
- Driver has no supplier pickups assigned. -
- ) : ( -
-
-
- -
-
-
-
- - - - - - - - - - - {supplierPickups.map((pickup, idx) => ( - - - - - - - ))} - -
#SupplierItemsQty
-
- {idx + 1} -
-
-
- {pickup.supplier_name} -
-
- {pickup.total_items} - - {pickup.total_qty.toLocaleString("en-ZA")} -
-
-
-
- )} -
+ {/* 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. */} + - {/* 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. */} -
+
@@ -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 (
@@ -354,12 +291,12 @@ function RepActivityMatrix({ Rep activity - Q · quotes · O · orders + Quote · Order
{reps.length === 0 ? (
- No rep activity today / this week / this month. + No rep activity today / this week / this month / this year.
) : (
@@ -369,12 +306,16 @@ function RepActivityMatrix({ Rep - - - - - - + {periods.flatMap((p) => + metrics.map((m) => ( + + )), + )} @@ -397,36 +338,18 @@ function RepActivityMatrix({
- - - {u.today.quotesReceived} - - - - - {u.today.ordersReceived} - - - - - {u.week.quotesReceived} - - - - - {u.week.ordersReceived} - - - - - {u.month.quotesReceived} - - - - - {u.month.ordersReceived} - - + {periods.flatMap((p) => + metrics.map((m) => ( + + + {u[p.period][m.metric]} + + + )), + )} ))} @@ -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(); + 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 ( +
+ + + Driver stops section is hidden — set{" "} + + TV_DRIVER_ID + {" "} + in the server env to enable it. + +
+ ); + } + + 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 ( +
+
+
+

+ + {driverName}'s stops +

+

+ {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")} + + )} +

+
+
+ {groups.length === 0 ? ( +
+ {driverName} has no active stops. As soon as a row is + assigned the wall will pick it up. +
+ ) : ( +
+
+
+ +
+
+
+
+ + + + + + + + + + + + {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 ( + + + + + + + + ); + })} + +
#SupplierDeliveryPickupQty
+
+ {idx + 1} +
+
+
+ {group.supplierName} +
+ {customerSub && ( +
+ {customerSub} +
+ )} +
+ {group.deliveries || ""} + + {group.pickups || ""} + + {group.totalQty.toLocaleString("en-ZA")} +
+
+
+
+ )} +
+ ); +} diff --git a/src/components/quote-form.tsx b/src/components/quote-form.tsx index be40b66..fa12bfe 100644 --- a/src/components/quote-form.tsx +++ b/src/components/quote-form.tsx @@ -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) {
- + {items.length === 0 && ( + + )}
{items.length === 0 ? ( @@ -377,20 +379,40 @@ export function QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) { No items yet. Click "Add Item" to start.
) : ( + /* + * 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. + */
{items.map((item) => ( - - setItems((current) => - current.map((row) => (row.id === item.id ? { ...row, ...patch } : row)) - ) - } - onRemove={() => - setItems((current) => current.filter((row) => row.id !== item.id)) - } - /> + + + setItems((current) => + current.map((row) => (row.id === item.id ? { ...row, ...patch } : row)) + ) + } + onRemove={() => + setItems((current) => current.filter((row) => row.id !== item.id)) + } + /> + + ))}
)} diff --git a/src/lib/pb-helpers.ts b/src/lib/pb-helpers.ts index 426e74f..74d2c8f 100644 --- a/src/lib/pb-helpers.ts +++ b/src/lib/pb-helpers.ts @@ -483,8 +483,7 @@ export async function fetchQuoteItemCounts( ): Promise> { 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 = {}; for (const item of items) { diff --git a/src/lib/server/quotes-search-service.test.ts b/src/lib/server/quotes-search-service.test.ts index ff84413..36ec604 100644 --- a/src/lib/server/quotes-search-service.test.ts +++ b/src/lib/server/quotes-search-service.test.ts @@ -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" }) }, diff --git a/src/lib/server/quotes-search-service.ts b/src/lib/server/quotes-search-service.ts index 5ed32ed..7869448 100644 --- a/src/lib/server/quotes-search-service.ts +++ b/src/lib/server/quotes-search-service.ts @@ -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(); 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 = ""` 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 = ""` 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 = ""` / `company = ""` - * 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( + ids: string[], + buildFilter: (chunk: string[]) => string, + pbCall: (filter: string) => Promise, +): Promise { + 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({ - filter: idEqualsChain(candidateIds), - fields: QUOTE_SEARCH_FIELDS, - expand: QUOTE_SEARCH_EXPAND, - batch: 500, - }) + ? await chunkedFilterFetch( + candidateIds, + (chunk) => idEqualsChain(chunk), + (filter) => + pb.collection("quotes").getFullList({ + filter, + fields: QUOTE_SEARCH_FIELDS, + expand: QUOTE_SEARCH_EXPAND, + batch: 500, + }), + ) : []; // 3. Items for the candidates (chunked IN under the hood). diff --git a/src/lib/server/tv-dashboard-service.ts b/src/lib/server/tv-dashboard-service.ts index 2812e3a..e411f66 100644 --- a/src/lib/server/tv-dashboard-service.ts +++ b/src/lib/server/tv-dashboard-service.ts @@ -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>, collection: string, sinceIso: string, - field: string, ): Promise { - 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(); - for (const row of result.items as Array>) { - 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(); + // 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(); + 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(); - 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[] = []; + 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>, - now: Date, -): Promise { - // 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>, -): Promise { - 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 { + 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(); - 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 { 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(), }; } \ No newline at end of file