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: () =>
- {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")}`} -
-| # | -Supplier | -Items | -Qty | -
|---|---|---|---|
|
-
- {idx + 1}
-
- |
-
-
- {pickup.supplier_name}
-
- |
- - {pickup.total_items} - | -- {pickup.total_qty.toLocaleString("en-ZA")} - | -
+ TV_DRIVER_ID
+ {" "}
+ in the server env to enable it.
+
+ + {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")} + > + )} +
+| # | +Supplier | +Delivery | +Pickup | +Qty | +
|---|---|---|---|---|
|
+
+ {idx + 1}
+
+ |
+
+
+ {group.supplierName}
+
+ {customerSub && (
+
+ {customerSub}
+
+ )}
+ |
+ + {group.deliveries || ""} + | ++ {group.pickups || ""} + | ++ {group.totalQty.toLocaleString("en-ZA")} + | +
({ - 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