feat(tv-dashboard): enhance TV dashboard with driver stops and performance metrics
- Updated the TV dashboard to include a new section for driver stops, displaying active deliveries and pickups with GPS coordinates. - Refactored the dashboard data structure to incorporate year-over-year performance metrics alongside existing monthly and weekly stats. - Replaced the supplier map with a driver stops map for improved clarity on driver activities. - Enhanced the user performance metrics to include detailed counts for quotes and orders processed by each representative. - Removed the obsolete supplier map component to streamline the dashboard's functionality.
This commit is contained in:
parent
2f0e911563
commit
43bbdfc458
14 changed files with 921 additions and 727 deletions
BIN
.audit-screenshots/fix-10-add-another-item.png
Normal file
BIN
.audit-screenshots/fix-10-add-another-item.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
BIN
.audit-screenshots/fix-10a-empty-state.png
Normal file
BIN
.audit-screenshots/fix-10a-empty-state.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
BIN
.audit-screenshots/fix-10b-one-item.png
Normal file
BIN
.audit-screenshots/fix-10b-one-item.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
2
.playwright-mcp/console-2026-08-18T09-57-48-982Z.log
Normal file
2
.playwright-mcp/console-2026-08-18T09-57-48-982Z.log
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[ 646ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ https://wfdash.apphub.co.za/login:0
|
||||
[ 647ms] [LOG] SW registered: /sw.js https://wfdash.apphub.co.za/ @ https://wfdash.apphub.co.za/_next/static/chunks/15-a5odeil1fk.js:0
|
||||
23
.playwright-mcp/page-2026-08-18T09-57-49-606Z.yml
Normal file
23
.playwright-mcp/page-2026-08-18T09-57-49-606Z.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e5]:
|
||||
- img "WFDASH" [ref=e7]
|
||||
- generic [ref=e8]: WFDASH
|
||||
- generic [ref=e9]: Business CRM
|
||||
- generic [ref=e11]:
|
||||
- tablist [ref=e12]:
|
||||
- tab "Login" [selected] [ref=e13]
|
||||
- tab "Register" [ref=e14]
|
||||
- tab "Reset" [ref=e15]
|
||||
- tabpanel "Login" [ref=e16]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]: Email
|
||||
- textbox "Email" [ref=e20]:
|
||||
- /placeholder: you@example.com
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]: Password
|
||||
- textbox "Password" [ref=e23]
|
||||
- button "Sign In" [ref=e24]
|
||||
- region "Notifications alt+T"
|
||||
- alert [ref=e29]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
135
src/app/(tv)/board/components/driver-stops-map.tsx
Normal file
135
src/app/(tv)/board/components/driver-stops-map.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Truck } from "lucide-react";
|
||||
import type { DriverStop } from "@/lib/server/driver-stops";
|
||||
import type { TvMapItem } from "./tv-map-inner";
|
||||
|
||||
/**
|
||||
* Driver stops map — one PIN per supplier, numbered in the order
|
||||
* they appear in the list. Stops arriving from the same supplier
|
||||
* (deliveries + pickups) are collapsed onto a single pin so the
|
||||
* map matches the supplier-grouped list view.
|
||||
*/
|
||||
|
||||
const Inner = dynamic(
|
||||
() => import("./tv-map-inner").then((m) => m.TvMapInner),
|
||||
{ ssr: false, loading: () => <MapSkeleton /> },
|
||||
);
|
||||
|
||||
function MapSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/60 text-slate-500">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Truck className="size-8 animate-pulse" />
|
||||
<span className="text-sm">Loading map…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SUPPLIER_PIN = "#F59E0B"; // amber-500
|
||||
|
||||
interface SupplierBucket {
|
||||
id: string;
|
||||
name: string;
|
||||
coords: [number, number] | null;
|
||||
items: number;
|
||||
qty: number;
|
||||
deliveries: number;
|
||||
pickups: number;
|
||||
customers: string[];
|
||||
}
|
||||
|
||||
function bucketBySupplier(stops: DriverStop[]): SupplierBucket[] {
|
||||
const acc = new Map<string, SupplierBucket>();
|
||||
const orphanKey = (stop: DriverStop) => `__orphan::${stop.id}`;
|
||||
const customerKey = (name: string | null | undefined) =>
|
||||
(name || "").trim().toLowerCase();
|
||||
for (const stop of stops) {
|
||||
const supplierId = stop.supplier.id || null;
|
||||
const key = supplierId ?? orphanKey(stop);
|
||||
let bucket = acc.get(key);
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
id: supplierId ?? key,
|
||||
name:
|
||||
stop.supplier.name?.trim() ||
|
||||
(supplierId ? "Unknown supplier" : "Unassigned"),
|
||||
coords: stop.supplier.coords,
|
||||
items: 0,
|
||||
qty: 0,
|
||||
deliveries: 0,
|
||||
pickups: 0,
|
||||
customers: [],
|
||||
};
|
||||
acc.set(key, bucket);
|
||||
}
|
||||
bucket.items += 1;
|
||||
bucket.qty += Number(stop.quantity_ordered || 0);
|
||||
if (stop.kind === "delivery") {
|
||||
bucket.deliveries += 1;
|
||||
const customerName =
|
||||
stop.customer?.name || stop.customer_name || null;
|
||||
const seen = new Set(bucket.customers.map((c) => customerKey(c)));
|
||||
if (customerName && !seen.has(customerKey(customerName))) {
|
||||
bucket.customers.push(customerName);
|
||||
}
|
||||
} else {
|
||||
bucket.pickups += 1;
|
||||
}
|
||||
}
|
||||
return Array.from(acc.values()).sort(
|
||||
(a, b) => b.items - a.items || b.qty - a.qty || a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stops: DriverStop[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DriverStopsMap({ stops, className }: Props) {
|
||||
const items: TvMapItem[] = React.useMemo(() => {
|
||||
const buckets = bucketBySupplier(stops);
|
||||
return buckets.map((bucket, idx) => {
|
||||
const kindBits: string[] = [];
|
||||
if (bucket.deliveries) {
|
||||
kindBits.push(
|
||||
`${bucket.deliveries} deliver${bucket.deliveries === 1 ? "y" : "ies"}`,
|
||||
);
|
||||
}
|
||||
if (bucket.pickups) {
|
||||
kindBits.push(
|
||||
`${bucket.pickups} pickup${bucket.pickups === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
const bodyLines = [
|
||||
kindBits.join(" · "),
|
||||
`qty ${bucket.qty.toLocaleString("en-ZA")}`,
|
||||
bucket.customers.length
|
||||
? `→ ${bucket.customers.slice(0, 3).join(", ")}${
|
||||
bucket.customers.length > 3
|
||||
? ` +${bucket.customers.length - 3}`
|
||||
: ""
|
||||
}`
|
||||
: null,
|
||||
].filter(Boolean) as string[];
|
||||
return {
|
||||
id: bucket.id,
|
||||
coords: bucket.coords,
|
||||
pinLabel: String(idx + 1),
|
||||
pinColor: SUPPLIER_PIN,
|
||||
popupTitle: bucket.name,
|
||||
popupBody: bodyLines.join(" · "),
|
||||
} satisfies TvMapItem;
|
||||
});
|
||||
}, [stops]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Inner items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Building2 } from "lucide-react";
|
||||
import type { SupplierPickupTv } from "@/lib/server/tv-dashboard-service";
|
||||
import type { TvMapItem } from "./tv-map-inner";
|
||||
|
||||
/**
|
||||
* Supplier pickups map — one PIN per supplier (not per item), with
|
||||
* the aggregated item count shown on the pin so the driver can see
|
||||
* "stop #1 has 4 line items to collect" at a glance.
|
||||
*/
|
||||
|
||||
const Inner = dynamic(
|
||||
() => import("./tv-map-inner").then((m) => m.TvMapInner),
|
||||
{ ssr: false, loading: () => <MapSkeleton /> },
|
||||
);
|
||||
|
||||
function MapSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/60 text-slate-500">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Building2 className="size-8 animate-pulse" />
|
||||
<span className="text-sm">Loading map…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pickups: SupplierPickupTv[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SupplierMap({ pickups, className }: Props) {
|
||||
// Pin color: amber for supplier pickups (paired with the sky-blue
|
||||
// delivery pins on the deliveries map; two distinct hues so the
|
||||
// driver never confuses them).
|
||||
const items: TvMapItem[] = React.useMemo(
|
||||
() =>
|
||||
pickups.map((p, idx) => ({
|
||||
id: p.supplier_id ?? `orphan-${idx}`,
|
||||
coords: p.coords,
|
||||
pinLabel: String(idx + 1),
|
||||
pinColor: "#F59E0B",
|
||||
popupTitle: p.supplier_name,
|
||||
popupBody: [
|
||||
p.address,
|
||||
`${p.total_items} line items · qty ${p.total_qty}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
})),
|
||||
[pickups],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Inner items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,16 +9,13 @@ import {
|
|||
RefreshCw,
|
||||
PackageCheck,
|
||||
CheckCircle2,
|
||||
Building2,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
PeriodStats as PeriodStatsT,
|
||||
TvDashboardData,
|
||||
} from "@/lib/server/tv-dashboard-service";
|
||||
import type { TvDashboardData } from "@/lib/server/tv-dashboard-service";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import { SupplierMap } from "./components/supplier-map";
|
||||
import { DriverStopsMap } from "./components/driver-stops-map";
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
|
|
@ -130,7 +127,7 @@ export default function TvDashboardView() {
|
|||
);
|
||||
}
|
||||
|
||||
const { today, week, month, userPerformance, supplierPickups } = data;
|
||||
const { today, week, month, year, userPerformance, driverName, driverStops } = data;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh p-3 lg:p-4">
|
||||
|
|
@ -180,112 +177,30 @@ export default function TvDashboardView() {
|
|||
orders + quotes + items counts, which the operator
|
||||
needs more than the legacy counts.) */}
|
||||
|
||||
{/* ── Today / Week / Month matrix (one card, 4 × 3) ─────────── */}
|
||||
{/* ── Today / Week / Month / Year matrix (one card, 4 × 4) ─────────── */}
|
||||
<PeriodMatrix
|
||||
today={today}
|
||||
week={week}
|
||||
month={month}
|
||||
year={year}
|
||||
/>
|
||||
|
||||
{/* ── Suppliers + rep activity side-by-side (no-scroll row) ── */}
|
||||
{/* ── Driver stops + rep activity side-by-side (no-scroll row) ── */}
|
||||
<section className="col-span-12 grid grid-cols-1 items-stretch gap-3 sm:grid-cols-12 sm:gap-4 lg:grid-cols-12 lg:gap-4">
|
||||
{/* Map + supplier stops (compact, no address column — map
|
||||
shows location; address on file shown only as a
|
||||
hover/title for accessibility). */}
|
||||
<section className="col-span-12 flex flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/60 lg:col-span-7">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-2">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-medium text-slate-200">
|
||||
<Building2 className="size-4" />
|
||||
Suppliers to visit today
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">
|
||||
{supplierPickups.length === 0
|
||||
? "No supplier pickups pending"
|
||||
: `${supplierPickups.length} supplier${
|
||||
supplierPickups.length === 1 ? "" : "s"
|
||||
} · ${supplierPickups.reduce(
|
||||
(n, p) => n + p.total_items,
|
||||
0,
|
||||
)} line item${
|
||||
supplierPickups.reduce(
|
||||
(n, p) => n + p.total_items,
|
||||
0,
|
||||
) === 1
|
||||
? ""
|
||||
: "s"
|
||||
} · qty ${supplierPickups
|
||||
.reduce((n, p) => n + p.total_qty, 0)
|
||||
.toLocaleString("en-ZA")}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{supplierPickups.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-slate-500">
|
||||
Driver has no supplier pickups assigned.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 p-3 sm:grid-cols-12 sm:gap-3 sm:p-4 lg:gap-4">
|
||||
<div className="flex sm:col-span-4">
|
||||
<div className="h-full min-h-[260px] flex-1 overflow-hidden rounded-xl border border-slate-800">
|
||||
<SupplierMap
|
||||
pickups={supplierPickups}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex sm:col-span-8">
|
||||
<div className="h-full flex-1 overflow-auto rounded-xl border border-slate-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/80 text-[10px] uppercase tracking-wider text-slate-500">
|
||||
<th className="w-10 py-1.5 pl-3 text-left font-medium">#</th>
|
||||
<th className="py-1.5 text-left font-medium">Supplier</th>
|
||||
<th className="py-1.5 text-right font-medium">Items</th>
|
||||
<th className="py-1.5 pr-3 text-right font-medium">Qty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{supplierPickups.map((pickup, idx) => (
|
||||
<tr
|
||||
key={
|
||||
pickup.supplier_id ??
|
||||
`orphan-${pickup.address ?? idx}`
|
||||
}
|
||||
className="border-b border-slate-800/60 last:border-b-0 align-middle"
|
||||
>
|
||||
<td className="py-1.5 pl-3">
|
||||
<div className="flex size-5 items-center justify-center rounded-full bg-amber-500 text-[11px] font-bold text-slate-900">
|
||||
{idx + 1}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 text-sm font-semibold text-slate-100"
|
||||
title={pickup.address ?? "No address on file"}
|
||||
>
|
||||
<div className="truncate">
|
||||
{pickup.supplier_name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono text-[11px] tabular-nums text-amber-300">
|
||||
{pickup.total_items}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-[11px] tabular-nums text-slate-300">
|
||||
{pickup.total_qty.toLocaleString("en-ZA")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{/* Driver stops — map + numbered list of the driver's
|
||||
active deliveries and pickups, grouped per supplier.
|
||||
As he marks them collected in the driver app, the
|
||||
corresponding PB rows flip status and the next 30s
|
||||
poll / realtime push drops them off the wall. */}
|
||||
<DriverStopsSection
|
||||
driverName={driverName}
|
||||
stops={driverStops}
|
||||
/>
|
||||
|
||||
{/* Rep activity — sits next to the supplier stops on lg so
|
||||
|
||||
{/* Rep activity — sits next to the driver stops on lg so
|
||||
the whole dashboard fits in one viewport on a 1080p TV. */}
|
||||
<div className="col-span-12 flex lg:col-span-5 lg:h-full">
|
||||
<div className="col-span-12 flex lg:col-span-6 lg:h-full">
|
||||
<RepActivityMatrix reps={userPerformance} />
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -341,11 +256,33 @@ function RepActivityMatrix({
|
|||
reps: Array<{
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
today: PeriodStatsT;
|
||||
week: PeriodStatsT;
|
||||
month: PeriodStatsT;
|
||||
today: { ordersReceived: number; quotesReceived: number };
|
||||
week: { ordersReceived: number; quotesReceived: number };
|
||||
month: { ordersReceived: number; quotesReceived: number };
|
||||
year: { ordersReceived: number; quotesReceived: number };
|
||||
}>;
|
||||
}) {
|
||||
// Drive the column headers + body from these arrays so adding
|
||||
// a period later is a one-line change. Today / Week / Month /
|
||||
// Year map to the matching `UserStats` sub-object.
|
||||
const periods: Array<{
|
||||
label: string;
|
||||
period: "today" | "week" | "month" | "year";
|
||||
}> = [
|
||||
{ label: "Today", period: "today" },
|
||||
{ label: "Week", period: "week" },
|
||||
{ label: "Month", period: "month" },
|
||||
{ label: "Year", period: "year" },
|
||||
];
|
||||
const metrics: Array<{
|
||||
label: string;
|
||||
metric: "quotesReceived" | "ordersReceived";
|
||||
accent: string;
|
||||
}> = [
|
||||
{ label: "Quote", metric: "quotesReceived", accent: "text-amber-300" },
|
||||
{ label: "Order", metric: "ordersReceived", accent: "text-sky-300" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="flex h-full w-full flex-col rounded-2xl border border-slate-800 bg-slate-900/60 p-3 sm:p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
|
|
@ -354,12 +291,12 @@ function RepActivityMatrix({
|
|||
Rep activity
|
||||
</h2>
|
||||
<span className="ml-auto text-[11px] text-slate-500">
|
||||
Q · quotes · O · orders
|
||||
Quote · Order
|
||||
</span>
|
||||
</div>
|
||||
{reps.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-slate-500">
|
||||
No rep activity today / this week / this month.
|
||||
No rep activity today / this week / this month / this year.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
|
|
@ -369,12 +306,16 @@ function RepActivityMatrix({
|
|||
<th className="py-1 text-left text-[11px] uppercase tracking-wider text-slate-400">
|
||||
Rep
|
||||
</th>
|
||||
<MetricHead period="Today" metric="Q" accent="text-amber-400" />
|
||||
<MetricHead period="Today" metric="O" accent="text-sky-400" />
|
||||
<MetricHead period="Week" metric="Q" accent="text-amber-400" />
|
||||
<MetricHead period="Week" metric="O" accent="text-sky-400" />
|
||||
<MetricHead period="Month" metric="Q" accent="text-amber-400" />
|
||||
<MetricHead period="Month" metric="O" accent="text-sky-400" />
|
||||
{periods.flatMap((p) =>
|
||||
metrics.map((m) => (
|
||||
<MetricHead
|
||||
key={`${p.period}-${m.metric}`}
|
||||
period={p.label}
|
||||
metric={m.label}
|
||||
accent={m.accent}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -397,36 +338,18 @@ function RepActivityMatrix({
|
|||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums text-amber-300">
|
||||
<span className="font-semibold tabular-nums">
|
||||
{u.today.quotesReceived}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums text-sky-300">
|
||||
<span className="font-semibold tabular-nums">
|
||||
{u.today.ordersReceived}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums text-amber-300">
|
||||
<span className="font-semibold tabular-nums">
|
||||
{u.week.quotesReceived}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums text-sky-300">
|
||||
<span className="font-semibold tabular-nums">
|
||||
{u.week.ordersReceived}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums text-amber-300">
|
||||
<span className="font-semibold tabular-nums">
|
||||
{u.month.quotesReceived}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums text-sky-300">
|
||||
<span className="font-semibold tabular-nums">
|
||||
{u.month.ordersReceived}
|
||||
</span>
|
||||
</td>
|
||||
{periods.flatMap((p) =>
|
||||
metrics.map((m) => (
|
||||
<td
|
||||
key={`${p.period}-${m.metric}`}
|
||||
className={`py-1 text-right tabular-nums ${m.accent}`}
|
||||
>
|
||||
<span className="font-semibold tabular-nums text-xl tracking-tight sm:text-2xl">
|
||||
{u[p.period][m.metric]}
|
||||
</span>
|
||||
</td>
|
||||
)),
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -462,10 +385,12 @@ function PeriodMatrix({
|
|||
today,
|
||||
week,
|
||||
month,
|
||||
year,
|
||||
}: {
|
||||
today: PeriodStats;
|
||||
week: PeriodStats;
|
||||
month: PeriodStats;
|
||||
year: PeriodStats;
|
||||
}) {
|
||||
const metrics: Array<{
|
||||
key: keyof PeriodStats;
|
||||
|
|
@ -482,6 +407,7 @@ function PeriodMatrix({
|
|||
{ title: "Today", stats: today },
|
||||
{ title: "This week", stats: week },
|
||||
{ title: "This month", stats: month },
|
||||
{ title: "This year", stats: year },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -534,3 +460,212 @@ function formatNumber(n: number): string {
|
|||
if (!Number.isFinite(n)) return "—";
|
||||
return n.toLocaleString("en-ZA");
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────
|
||||
* DriverStopsSection
|
||||
*
|
||||
* One tile = one driver's live workload, grouped per supplier.
|
||||
* A "stop" is any active delivery or pickup keyed to the driver;
|
||||
* deliveries and pickups from the same supplier are collapsed into
|
||||
* a single row so the office sees "what work is at supplier X"
|
||||
* without scanning duplicate rows. The map shows one pin per
|
||||
* supplier at the supplier's coordinates.
|
||||
*
|
||||
* When `TV_DRIVER_ID` is unset the whole tile collapses to a
|
||||
* short banner rather than rendering an empty map — that way a
|
||||
* freshly-deployed TV without env wiring still shows the rest
|
||||
* of the dashboard instead of a 7-column hole.
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
interface SupplierGroup {
|
||||
/** PB id of the supplier, or `null` for orphan rows (no supplier
|
||||
* linked on the underlying item). */
|
||||
supplierId: string | null;
|
||||
supplierName: string;
|
||||
address: string | null;
|
||||
coords: [number, number] | null;
|
||||
deliveries: number;
|
||||
pickups: number;
|
||||
totalItems: number;
|
||||
totalQty: number;
|
||||
/** Distinct customer destinations for delivery rows in this
|
||||
* group. Empty for pure-pickup suppliers. */
|
||||
customers: string[];
|
||||
}
|
||||
|
||||
function groupStopsBySupplier(
|
||||
raw: TvDashboardData["driverStops"],
|
||||
): SupplierGroup[] {
|
||||
const acc = new Map<string, SupplierGroup>();
|
||||
const orphanKey = (stop: TvDashboardData["driverStops"][number]) =>
|
||||
`__orphan::${stop.id}`;
|
||||
const customerKey = (name: string | null | undefined) =>
|
||||
(name || "").trim().toLowerCase();
|
||||
for (const stop of raw) {
|
||||
const supplierId = stop.supplier.id || null;
|
||||
const key = supplierId ?? orphanKey(stop);
|
||||
let bucket = acc.get(key);
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
supplierId,
|
||||
supplierName:
|
||||
stop.supplier.name?.trim() ||
|
||||
(supplierId ? "Unknown supplier" : "Unassigned"),
|
||||
address: null,
|
||||
coords: stop.supplier.coords,
|
||||
deliveries: 0,
|
||||
pickups: 0,
|
||||
totalItems: 0,
|
||||
totalQty: 0,
|
||||
customers: [],
|
||||
};
|
||||
acc.set(key, bucket);
|
||||
}
|
||||
bucket.totalItems += 1;
|
||||
bucket.totalQty += Number(stop.quantity_ordered || 0);
|
||||
if (stop.kind === "delivery") {
|
||||
bucket.deliveries += 1;
|
||||
const customerName =
|
||||
stop.customer?.name || stop.customer_name || null;
|
||||
const seen = new Set(bucket.customers.map((c) => customerKey(c)));
|
||||
if (customerName && !seen.has(customerKey(customerName))) {
|
||||
bucket.customers.push(customerName);
|
||||
}
|
||||
} else {
|
||||
bucket.pickups += 1;
|
||||
}
|
||||
}
|
||||
return Array.from(acc.values()).sort(
|
||||
(a, b) =>
|
||||
b.totalItems - a.totalItems ||
|
||||
b.totalQty - a.totalQty ||
|
||||
a.supplierName.localeCompare(b.supplierName),
|
||||
);
|
||||
}
|
||||
|
||||
function DriverStopsSection({
|
||||
driverName,
|
||||
stops,
|
||||
}: {
|
||||
driverName: string | null;
|
||||
stops: TvDashboardData["driverStops"];
|
||||
}) {
|
||||
// No driver configured — render a compact placeholder so the
|
||||
// grid stays balanced when the env var is missing.
|
||||
if (!driverName) {
|
||||
return (
|
||||
<section className="col-span-12 flex items-center gap-3 rounded-2xl border border-dashed border-slate-800 bg-slate-900/30 px-4 py-3 text-sm text-slate-500 lg:col-span-6">
|
||||
<Truck className="size-4 shrink-0" />
|
||||
<span>
|
||||
Driver stops section is hidden — set{" "}
|
||||
<code className="rounded bg-slate-800 px-1 py-0.5 font-mono text-[11px] text-slate-300">
|
||||
TV_DRIVER_ID
|
||||
</code>{" "}
|
||||
in the server env to enable it.
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupStopsBySupplier(stops);
|
||||
const totalQty = groups.reduce((n, g) => n + g.totalQty, 0);
|
||||
const totalItems = groups.reduce((n, g) => n + g.totalItems, 0);
|
||||
|
||||
return (
|
||||
<section className="col-span-12 flex flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900/60 lg:col-span-6">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-2">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-medium text-slate-200">
|
||||
<Truck className="size-4" />
|
||||
{driverName}'s stops
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">
|
||||
{groups.length === 0 ? (
|
||||
"Nothing on the run right now"
|
||||
) : (
|
||||
<>
|
||||
{groups.length} supplier
|
||||
{groups.length === 1 ? "" : "s"} · {totalItems} line item
|
||||
{totalItems === 1 ? "" : "s"} · qty{" "}
|
||||
{totalQty.toLocaleString("en-ZA")}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{groups.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-slate-500">
|
||||
{driverName} has no active stops. As soon as a row is
|
||||
assigned the wall will pick it up.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 p-3 sm:grid-cols-12 sm:gap-3 sm:p-4 lg:gap-4">
|
||||
<div className="flex sm:col-span-4">
|
||||
<div className="h-full min-h-[260px] flex-1 overflow-hidden rounded-xl border border-slate-800">
|
||||
<DriverStopsMap stops={stops} className="h-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex sm:col-span-8">
|
||||
<div className="h-full flex-1 overflow-auto rounded-xl border border-slate-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/80 text-[10px] uppercase tracking-wider text-slate-500">
|
||||
<th className="w-10 py-1.5 pl-3 text-left font-medium">#</th>
|
||||
<th className="py-1.5 text-left font-medium">Supplier</th>
|
||||
<th className="py-1.5 text-right font-medium">Delivery</th>
|
||||
<th className="py-1.5 text-right font-medium">Pickup</th>
|
||||
<th className="py-1.5 pr-3 text-right font-medium">Qty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((group, idx) => {
|
||||
const customerSub = group.customers.length
|
||||
? `→ ${group.customers.slice(0, 3).join(", ")}${
|
||||
group.customers.length > 3
|
||||
? ` +${group.customers.length - 3}`
|
||||
: ""
|
||||
}`
|
||||
: null;
|
||||
return (
|
||||
<tr
|
||||
key={group.supplierId ?? `orphan-${idx}`}
|
||||
className="border-b border-slate-800/60 last:border-b-0 align-middle"
|
||||
>
|
||||
<td className="py-1.5 pl-3">
|
||||
<div className="flex size-5 items-center justify-center rounded-full bg-amber-500 text-[11px] font-bold text-slate-900">
|
||||
{idx + 1}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 text-sm font-semibold text-slate-100"
|
||||
title={group.address ?? "No address on file"}
|
||||
>
|
||||
<div className="truncate">
|
||||
{group.supplierName}
|
||||
</div>
|
||||
{customerSub && (
|
||||
<div className="truncate text-[10px] font-normal text-slate-500">
|
||||
{customerSub}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono text-[11px] tabular-nums text-sky-300">
|
||||
{group.deliveries || ""}
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono text-[11px] tabular-nums text-amber-300">
|
||||
{group.pickups || ""}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-[11px] tabular-nums text-slate-300">
|
||||
{group.totalQty.toLocaleString("en-ZA")}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -365,10 +365,12 @@ export function QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) {
|
|||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel icon={ListOrdered} label="Quote Items" />
|
||||
<Button type="button" variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add Item
|
||||
</Button>
|
||||
{items.length === 0 && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
|
|
@ -377,20 +379,40 @@ export function QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) {
|
|||
No items yet. Click "Add Item" to start.
|
||||
</div>
|
||||
) : (
|
||||
/*
|
||||
* Each card is followed by its own "Add another item" button.
|
||||
* The operator can keep typing line after line without ever
|
||||
* scrolling back to the top of the items section — the next
|
||||
* button is always immediately below the last card they
|
||||
* touched. The ghost + dashed-border styling keeps it from
|
||||
* being mistaken for a submit/finish affordance.
|
||||
*/
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<QuoteFormItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onChange={(patch) =>
|
||||
setItems((current) =>
|
||||
current.map((row) => (row.id === item.id ? { ...row, ...patch } : row))
|
||||
)
|
||||
}
|
||||
onRemove={() =>
|
||||
setItems((current) => current.filter((row) => row.id !== item.id))
|
||||
}
|
||||
/>
|
||||
<Fragment key={item.id}>
|
||||
<QuoteFormItemCard
|
||||
item={item}
|
||||
onChange={(patch) =>
|
||||
setItems((current) =>
|
||||
current.map((row) => (row.id === item.id ? { ...row, ...patch } : row))
|
||||
)
|
||||
}
|
||||
onRemove={() =>
|
||||
setItems((current) => current.filter((row) => row.id !== item.id))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addItem}
|
||||
className="h-9 w-full border border-dashed text-muted-foreground hover:text-foreground"
|
||||
aria-label="Add another item"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add another item
|
||||
</Button>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -483,8 +483,7 @@ export async function fetchQuoteItemCounts(
|
|||
): Promise<Record<string, { total: number; processed: number }>> {
|
||||
if (quoteIds.length === 0) return {};
|
||||
const pb = getPocketBase();
|
||||
const escape = (id: string) => id.replace(/"/g, '\\"');
|
||||
const safeIds = quoteIds.map(escape);
|
||||
const safeIds = quoteIds.map((id) => id.replace(/"/g, '\\"'));
|
||||
|
||||
type Slim = { id?: string; quote: string; is_processed: boolean };
|
||||
const fetchSlim = (filter: string) =>
|
||||
|
|
@ -493,21 +492,20 @@ export async function fetchQuoteItemCounts(
|
|||
fields: "id,quote,is_processed",
|
||||
});
|
||||
|
||||
let items: Slim[] = [];
|
||||
try {
|
||||
items = await fetchSlim(
|
||||
`quote IN (${safeIds.map((id) => `"${id}"`).join(",")})`,
|
||||
);
|
||||
} catch {
|
||||
// Older PB without `IN` — chunk through OR-chains. Each chunk
|
||||
// is well under the filter-length limit so the same fallback
|
||||
// `fetchQuoteItemsForQuotes` uses works here.
|
||||
for (let i = 0; i < safeIds.length; i += QUOTE_FILTER_CHUNK) {
|
||||
const batch = safeIds.slice(i, i + QUOTE_FILTER_CHUNK);
|
||||
const orChain = batch.map((id) => `quote = "${id}"`).join(" || ");
|
||||
items.push(...(await fetchSlim(orChain)));
|
||||
}
|
||||
// Build the chunked filters first, then issue them in parallel. PB
|
||||
// accepts the same number of concurrent reads without rate-limiting
|
||||
// for a small page-worth of chunks, and `Promise.all` collapses
|
||||
// wall-clock to the slowest chunk instead of the sum. Sequential
|
||||
// was used historically because the IN-then-fallback code path
|
||||
// bailed early on success; with the always-OR-chain strategy
|
||||
// parallel is strictly better and the chunks are decoupled.
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < safeIds.length; i += QUOTE_FILTER_CHUNK) {
|
||||
const batch = safeIds.slice(i, i + QUOTE_FILTER_CHUNK);
|
||||
chunks.push(batch.map((id) => `quote = "${id}"`).join(" || "));
|
||||
}
|
||||
const perChunk = await Promise.all(chunks.map(fetchSlim));
|
||||
const items: Slim[] = perChunk.flat();
|
||||
|
||||
const counts: Record<string, { total: number; processed: number }> = {};
|
||||
for (const item of items) {
|
||||
|
|
|
|||
|
|
@ -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" }) },
|
||||
|
|
|
|||
|
|
@ -70,11 +70,15 @@ import type { Quote, QuoteItem } from "@/lib/types";
|
|||
* Constants
|
||||
* ========================================================================== */
|
||||
|
||||
/** Hard cap on how many quotes we score. Live PB has ~8.5k; the page
|
||||
* shows the top 50 to the operator. Anything beyond the cap is
|
||||
* unreachable from this search — the office can fall back to the
|
||||
* tab-filtered list with no query if they need to scroll history. */
|
||||
const SEARCH_CANDIDATE_CAP = 8500;
|
||||
/** Hard cap on how many quotes we score. The pushdown phase
|
||||
* already produces a small candidate set (typically dozens, ~200
|
||||
* in the worst live case), and we only ever paginate the top 50
|
||||
* to the page. Mirrors `ORDER_SEARCH_HARD_CAP = 500` in
|
||||
* `orders-service.ts`. The cap also bounds how many chunks the
|
||||
* parallel chunked-filter fan-out can produce — at 80 IDs/chunk
|
||||
* a 500 cap produces ~7 chunks per fanout, which is well within
|
||||
* PB's per-burst concurrency. */
|
||||
const SEARCH_CANDIDATE_CAP = 500;
|
||||
|
||||
/** Default page size returned by the search endpoint. Page numbering
|
||||
* follows the same 1-indexed convention as the regular list route. */
|
||||
|
|
@ -404,12 +408,12 @@ function withTab(base: string, tab: QuoteTabKey | undefined): string {
|
|||
|
||||
/** Dedupe + validate an ID list. Drops empty / non-string values so
|
||||
* a stray empty `quote` relation from PB doesn't poison the IN
|
||||
* filter. Cap-defensive: if upstream returned >5000 IDs, return
|
||||
* the first 5000 to keep the OR-chain well under PB's filter
|
||||
* length limit. The 5000 limit is comfortably above what "instance"
|
||||
* or any other realistic query should hit (operator-side memory
|
||||
* notes from August 2026 put the worst real query at ~600 hits). */
|
||||
function dedupeIds(ids: (string | undefined | null)[], cap = 5000): string[] {
|
||||
* filter. Cap matches `SEARCH_CANDIDATE_CAP` — if upstream returns
|
||||
* more, return the first N we will actually score. */
|
||||
function dedupeIds(
|
||||
ids: (string | undefined | null)[],
|
||||
cap = SEARCH_CANDIDATE_CAP,
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of ids) {
|
||||
|
|
@ -422,23 +426,69 @@ function dedupeIds(ids: (string | undefined | null)[], cap = 5000): string[] {
|
|||
return out;
|
||||
}
|
||||
|
||||
/** Build an OR-chain of `id = "<id>"` clauses. Avoids the PB `IN`
|
||||
* filter entirely because some PB versions reject `IN` with very
|
||||
* long lists; OR-chains of N <5000 are reliable across versions. */
|
||||
/** Safe ceiling on the number of ids in one inline `id = "..." || ...`
|
||||
* filter (or `customer = "..." || ...`). The orders service measured
|
||||
* live PB at this exact number:
|
||||
*
|
||||
* "A filter with 107 ids (~3.5 KB) is rejected with HTTP 400
|
||||
* ('Something went wrong while processing your request.') while
|
||||
* 106 ids pass."
|
||||
*
|
||||
* We sit comfortably below that so the query string's other params
|
||||
* (sort, expand, perPage) can't push a chunk over the edge either.
|
||||
* Mirrors `ID_FILTER_CHUNK_SIZE` in `orders-service.ts`. */
|
||||
const ID_FILTER_CHUNK_SIZE = 80;
|
||||
|
||||
/** Build an OR-chain of `id = "<id>"` clauses for ONE chunk. Don't
|
||||
* pass >ID_FILTER_CHUNK_SIZE ids to this — use `chunkedFilterFetch`
|
||||
* which chunks + parallelizes. */
|
||||
function idEqualsChain(ids: string[]): string {
|
||||
return ids.map((id) => `id = "${escapeForPbFilter(id)}"`).join(" || ");
|
||||
return ids
|
||||
.map((id) => `id = "${escapeForPbFilter(id)}"`)
|
||||
.join(" || ");
|
||||
}
|
||||
|
||||
/** Build an OR-chain of `customer = "<id>"` / `company = "<id>"`
|
||||
* clauses for header-side joins. Same OR-chain rationale as
|
||||
* `idEqualsChain`. */
|
||||
function relationEqualsChain(customerIds: string[], companyIds: string[]): string {
|
||||
* clauses for ONE chunk — same chunking rationale as `idEqualsChain`. */
|
||||
function relationEqualsChain(
|
||||
customerIds: string[],
|
||||
companyIds: string[],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
for (const id of customerIds) parts.push(`customer = "${escapeForPbFilter(id)}"`);
|
||||
for (const id of companyIds) parts.push(`company = "${escapeForPbFilter(id)}"`);
|
||||
for (const id of customerIds)
|
||||
parts.push(`customer = "${escapeForPbFilter(id)}"`);
|
||||
for (const id of companyIds)
|
||||
parts.push(`company = "${escapeForPbFilter(id)}"`);
|
||||
return parts.join(" || ");
|
||||
}
|
||||
|
||||
/** Generic chunked fetch — runs the same `pbCall(opts)` against an
|
||||
* array of ids split into chunks of ID_FILTER_CHUNK_SIZE, in
|
||||
* parallel. Returns the merged result set. PB only sorts within
|
||||
* a chunk so callers that want a globally sorted result must
|
||||
* re-sort (mirrors `fetchOrdersFilteredByIds` in orders-service).
|
||||
*
|
||||
* Why chunk at all: live PB rejects inline OR-chain filters above
|
||||
* ~3.5 KB with HTTP 400 (orders-service.ts:412). Without chunking,
|
||||
* a "the" / "bolt" / other common-token search on the live quote
|
||||
* collection silently returns nothing because the filter blows up
|
||||
* before scoring even starts. */
|
||||
async function chunkedFilterFetch<T>(
|
||||
ids: string[],
|
||||
buildFilter: (chunk: string[]) => string,
|
||||
pbCall: (filter: string) => Promise<T[]>,
|
||||
): Promise<T[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < ids.length; i += ID_FILTER_CHUNK_SIZE) {
|
||||
chunks.push(ids.slice(i, i + ID_FILTER_CHUNK_SIZE));
|
||||
}
|
||||
const results = await Promise.all(
|
||||
chunks.map(async (chunk) => pbCall(buildFilter(chunk))),
|
||||
);
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
/** Minimal quote projection used during the pushdown phase. We only
|
||||
* need the IDs to dedupe + feed the next roundtrip; the expanded
|
||||
* rows come back in the final fetch. Trimmed to absolute minimum
|
||||
|
|
@ -493,17 +543,48 @@ async function fetchCandidateIds(
|
|||
]);
|
||||
const customerIds = matchedCustomers.map((c) => c.id);
|
||||
const companyIds = matchedCompanies.map((c) => c.id);
|
||||
// Pushdown on `customer = ... || company = ...` would otherwise blow
|
||||
// past PB's inline-filter length cap on common tokens (live PB rejects
|
||||
// >~3.5 KB). Chunk + parallelize via the shared helper.
|
||||
const headerQuotes =
|
||||
customerIds.length + companyIds.length > 0
|
||||
? await pb.collection("quotes").getFullList<{
|
||||
id: string;
|
||||
status?: string;
|
||||
is_template?: boolean;
|
||||
delivery_note?: string;
|
||||
}>({
|
||||
filter: withTab(relationEqualsChain(customerIds, companyIds), tab),
|
||||
fields: QUOTE_ID_FIELDS,
|
||||
batch: 500,
|
||||
? await chunkedFilterFetch(
|
||||
customerIds,
|
||||
(chunk) =>
|
||||
withTab(relationEqualsChain(chunk, []), tab),
|
||||
(filter) =>
|
||||
pb.collection("quotes").getFullList<{
|
||||
id: string;
|
||||
status?: string;
|
||||
is_template?: boolean;
|
||||
delivery_note?: string;
|
||||
}>({
|
||||
filter,
|
||||
fields: QUOTE_ID_FIELDS,
|
||||
batch: 500,
|
||||
}),
|
||||
).then(async (firstHalf) => {
|
||||
// We merged the two relations into one chunked fetch — run the
|
||||
// company side as a second pass, then combine.
|
||||
const secondHalf =
|
||||
companyIds.length > 0
|
||||
? await chunkedFilterFetch(
|
||||
companyIds,
|
||||
(chunk) => withTab(relationEqualsChain([], chunk), tab),
|
||||
(filter) =>
|
||||
pb.collection("quotes").getFullList<{
|
||||
id: string;
|
||||
status?: string;
|
||||
is_template?: boolean;
|
||||
delivery_note?: string;
|
||||
}>({
|
||||
filter,
|
||||
fields: QUOTE_ID_FIELDS,
|
||||
batch: 500,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
return [...firstHalf, ...secondHalf];
|
||||
})
|
||||
: [];
|
||||
|
||||
|
|
@ -520,18 +601,26 @@ async function fetchCandidateIds(
|
|||
batch: 1000,
|
||||
});
|
||||
const itemQuoteIds = dedupeIds(matchedItems.map((m) => m.quote));
|
||||
// Same chunking concern as the header-pushdown: a popular token
|
||||
// can match thousands of items, producing a large parent-quote
|
||||
// set that would 400 PB if sent as a single OR-chain.
|
||||
const itemQuotes =
|
||||
itemQuoteIds.length > 0
|
||||
? await pb.collection("quotes").getFullList<{
|
||||
id: string;
|
||||
status?: string;
|
||||
is_template?: boolean;
|
||||
delivery_note?: string;
|
||||
}>({
|
||||
filter: withTab(idEqualsChain(itemQuoteIds), tab),
|
||||
fields: QUOTE_ID_FIELDS,
|
||||
batch: 500,
|
||||
})
|
||||
? await chunkedFilterFetch(
|
||||
itemQuoteIds,
|
||||
(chunk) => withTab(idEqualsChain(chunk), tab),
|
||||
(filter) =>
|
||||
pb.collection("quotes").getFullList<{
|
||||
id: string;
|
||||
status?: string;
|
||||
is_template?: boolean;
|
||||
delivery_note?: string;
|
||||
}>({
|
||||
filter,
|
||||
fields: QUOTE_ID_FIELDS,
|
||||
batch: 500,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
|
||||
// 5. Dedupe across all three sources. The set is the union of
|
||||
|
|
@ -590,15 +679,23 @@ export async function searchQuotes(
|
|||
|
||||
// 2. Fetch the candidate quotes WITH expand so the scorer can
|
||||
// read the customer / company name (pushdown step only
|
||||
// produced IDs).
|
||||
// produced IDs). Chunked for the same reason as
|
||||
// `fetchCandidateIds` — a popular query easily produces a
|
||||
// candidate set of >100 quotes, which would 400 PB inline
|
||||
// without chunking.
|
||||
const candidates =
|
||||
candidateIds.length > 0
|
||||
? await pb.collection("quotes").getFullList<Quote>({
|
||||
filter: idEqualsChain(candidateIds),
|
||||
fields: QUOTE_SEARCH_FIELDS,
|
||||
expand: QUOTE_SEARCH_EXPAND,
|
||||
batch: 500,
|
||||
})
|
||||
? await chunkedFilterFetch(
|
||||
candidateIds,
|
||||
(chunk) => idEqualsChain(chunk),
|
||||
(filter) =>
|
||||
pb.collection("quotes").getFullList<Quote>({
|
||||
filter,
|
||||
fields: QUOTE_SEARCH_FIELDS,
|
||||
expand: QUOTE_SEARCH_EXPAND,
|
||||
batch: 500,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
|
||||
// 3. Items for the candidates (chunked IN under the hood).
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import { getDashboardData, type DashboardData } from "./dashboard-service";
|
||||
import { listDriverStops, type DriverStop } from "./driver-stops";
|
||||
|
||||
/* ============================================================================
|
||||
* TV dashboard read model.
|
||||
|
|
@ -13,8 +14,10 @@ import { getDashboardData, type DashboardData } from "./dashboard-service";
|
|||
* - **quotes per rep** — `{ name, today, week, month }[]`
|
||||
* - **user performance** — per-rep totals (quotes, orders, items
|
||||
* completed) for the day
|
||||
* - **driver stops** — today's active deliveries with GPS coords
|
||||
* so the TV can drop a map pin per stop
|
||||
* - **driver stops** — Joachim's (or whoever `TV_DRIVER_ID` names)
|
||||
* active deliveries + pickups, GPS-tagged so the TV can drop a
|
||||
* pin per stop. As he marks them collected via the driver app
|
||||
* they drop off the next 30s poll / realtime push.
|
||||
*
|
||||
* We piggy-back on the existing `getDashboardData()` for the
|
||||
* historical stuff and bolt the new sections on via Promise.all.
|
||||
|
|
@ -43,15 +46,10 @@ const PROCESSED_ITEM_STATUSES = [
|
|||
"stock_verified",
|
||||
];
|
||||
|
||||
// Driver map: only `item_status = "assigned"` rows. See
|
||||
// `loadDriverStops` for the rationale.
|
||||
//
|
||||
// Pickups: `collection_pool` (office booked, no driver yet) and
|
||||
// `assigned_to_driver` (driver owes the pickup). One pin per
|
||||
// supplier, items grouped beneath.
|
||||
const PICKUP_STATUSES = ["collection_pool", "assigned_to_driver"];
|
||||
const STOCK_PURCHASE_ITEMS_COLLECTION =
|
||||
process.env.STOCK_PURCHASE_ITEMS_COLLECTION || "stock_purchase_items";
|
||||
// Driver map: only `item_status = "assigned"` rows. The driver
|
||||
// stops service (`./driver-stops.ts`) is the source of truth for
|
||||
// which rows qualify as a driver's active workload — we re-use it
|
||||
// here so the TV and the driver app never drift in scope.
|
||||
|
||||
export interface PeriodStats {
|
||||
ordersReceived: number;
|
||||
|
|
@ -71,44 +69,22 @@ export interface RepQuoteCount {
|
|||
export interface UserPerformance {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
today: PeriodStats;
|
||||
week: PeriodStats;
|
||||
month: PeriodStats;
|
||||
/** Quote count (from `quotes.created`) per period. */
|
||||
today: UserStats;
|
||||
week: UserStats;
|
||||
month: UserStats;
|
||||
year: UserStats;
|
||||
}
|
||||
|
||||
export interface DriverStopTv {
|
||||
id: string;
|
||||
description: string;
|
||||
customer_name: string | null;
|
||||
company_name: string | null;
|
||||
/** [lat, lng] in Leaflet order. `null` when the customer row has no
|
||||
* geoPoint set — the map just skips the marker. */
|
||||
coords: [number, number] | null;
|
||||
quantity: number;
|
||||
item_status: string;
|
||||
order_number: string | null;
|
||||
}
|
||||
|
||||
export interface SupplierPickupItemTv {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
purchase_number: string | null;
|
||||
item_status: string;
|
||||
}
|
||||
|
||||
export interface SupplierPickupTv {
|
||||
/** `null` when the row has no supplier relation set (orphan). */
|
||||
supplier_id: string | null;
|
||||
supplier_name: string;
|
||||
/** [lat, lng] in Leaflet order. `null` when the supplier has no
|
||||
* geoPoint — the map just skips the marker for that supplier. */
|
||||
coords: [number, number] | null;
|
||||
address: string;
|
||||
/** Aggregated across all pickup rows at this supplier. */
|
||||
total_items: number;
|
||||
total_qty: number;
|
||||
items: SupplierPickupItemTv[];
|
||||
/**
|
||||
* Per-rep counters the TV's rep activity tile renders. Narrower
|
||||
* than `PeriodStats` — only quotes and orders. Order-item
|
||||
* processing is *not* attributed to reps (the office doesn't
|
||||
* care which rep's items got actioned by the warehouse / driver).
|
||||
*/
|
||||
export interface UserStats {
|
||||
ordersReceived: number;
|
||||
quotesReceived: number;
|
||||
}
|
||||
|
||||
export interface TvDashboardData {
|
||||
|
|
@ -118,10 +94,18 @@ export interface TvDashboardData {
|
|||
today: PeriodStats;
|
||||
week: PeriodStats;
|
||||
month: PeriodStats;
|
||||
year: PeriodStats;
|
||||
quotesPerRep: RepQuoteCount[];
|
||||
userPerformance: UserPerformance[];
|
||||
driverStops: DriverStopTv[];
|
||||
supplierPickups: SupplierPickupTv[];
|
||||
/** The driver whose active stops appear on the TV (resolved from
|
||||
* `TV_DRIVER_ID`). `null` when the env var is unset — the TV
|
||||
* then hides the stops section entirely. */
|
||||
driverName: string | null;
|
||||
/** Active deliveries + pickups for `driverName`. Empty array
|
||||
* when no driver is configured. The shape is the same
|
||||
* `DriverStop` type the driver app consumes, so a stop's
|
||||
* appearance can't drift between the two surfaces. */
|
||||
driverStops: DriverStop[];
|
||||
/** When the snapshot was built — useful for "Updated Xs ago". */
|
||||
generatedAt: string;
|
||||
}
|
||||
|
|
@ -159,40 +143,64 @@ function startOfMonthUtc(now: Date): Date {
|
|||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
}
|
||||
|
||||
function startOfYearUtc(now: Date): Date {
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
|
||||
}
|
||||
|
||||
function isoOf(d: Date): string {
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
/** Escape a string for embedding inside a PB filter literal
|
||||
* (`"..."`). Mirrors the helper in `driver-stops.ts`. */
|
||||
function escapeForBucketFilter(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `TV_REP_ROLES` into an uppercase, deduped allow-list of
|
||||
* `user_type` values that mark a PB user as a "rep" for the TV
|
||||
* activity list. Defaults to `["REP"]` so a fresh deployment
|
||||
* without the env var still produces a sensibly-filtered list.
|
||||
*
|
||||
* Accepts comma- or whitespace-separated values; case-insensitive.
|
||||
* An empty / unset var falls back to the default.
|
||||
*/
|
||||
function parseRepRoles(raw: string | undefined | null): string[] {
|
||||
const fallback = ["REP"];
|
||||
if (!raw) return fallback;
|
||||
const parts = raw
|
||||
.split(/[,\s]+/)
|
||||
.map((v) => v.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? Array.from(new Set(parts)) : fallback;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Counters
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Count distinct `order_number` (or `quote_number`) values inside the
|
||||
* window. Pulls a generous batch (500) and dedupes client-side — PB
|
||||
* has no native DISTINCT in its filter syntax, so this is the only
|
||||
* way. The 500 cap matches the rest of the dashboard's bounds.
|
||||
* Count PB rows matching `created >= sinceIso`. Returns PB's own
|
||||
* `totalItems` (computed server-side) via a `perPage=1` query, so
|
||||
* the count is exact regardless of how many rows are in the
|
||||
* window. The previous `countDistinctSince` had a 500-row cap
|
||||
* that silently under-reported the year column (always 500).
|
||||
*
|
||||
* `field` is the dedupe key (`order_number` for orders, `quote_number`
|
||||
* for quotes). Empty / missing keys are grouped together as
|
||||
* "(unnumbered)".
|
||||
* Note: `orders` and `quotes` are 1-row-per-order / 1-row-per-
|
||||
* quote, so `totalItems` equals distinct order_number / quote_number
|
||||
* counts. If that ever changes, switch to a paginated dedupe here.
|
||||
*/
|
||||
async function countDistinctSince(
|
||||
async function countSince(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
collection: string,
|
||||
sinceIso: string,
|
||||
field: string,
|
||||
): Promise<number> {
|
||||
const result = await pb.collection(collection).getList(1, 500, {
|
||||
const result = await pb.collection(collection).getList(1, 1, {
|
||||
filter: `created >= "${sinceIso}"`,
|
||||
fields: `id,${field}`,
|
||||
fields: "id",
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
for (const row of result.items as Array<Record<string, unknown>>) {
|
||||
const v = row[field];
|
||||
seen.add(typeof v === "string" && v.length > 0 ? v : "__unnumbered__");
|
||||
}
|
||||
return seen.size;
|
||||
return result.totalItems || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -320,30 +328,41 @@ async function loadUserPerformance(
|
|||
const monthStartIso = isoOf(startOfMonthUtc(now));
|
||||
const weekStartIso = isoOf(startOfWeekUtc(now));
|
||||
const todayIso = isoOf(since24hAgo(now));
|
||||
const yearStartIso = isoOf(startOfYearUtc(now));
|
||||
|
||||
// Pre-populate every named user on the team so the TV always
|
||||
// shows who's on the roster — even on a quiet day, and even if
|
||||
// the rep's `user_type` is empty / `ADMIN` (e.g. Gary). Test
|
||||
// Pre-populate the rep roster from the `users` collection. Test
|
||||
// accounts are filtered via email-pattern heuristics so QA /
|
||||
// agent rows don't leak onto the wall.
|
||||
const blank = (): UserPerformance => ({
|
||||
user_id: "",
|
||||
user_name: "",
|
||||
today: { ordersReceived: 0, ordersPlaced: 0, quotesReceived: 0, itemsProcessed: 0 },
|
||||
week: { ordersReceived: 0, ordersPlaced: 0, quotesReceived: 0, itemsProcessed: 0 },
|
||||
month: { ordersReceived: 0, ordersPlaced: 0, quotesReceived: 0, itemsProcessed: 0 },
|
||||
});
|
||||
const userById = new Map<string, UserPerformance>();
|
||||
// agent rows don't leak onto the wall. The rep-vs-not-rep
|
||||
// gate is `TV_REP_ROLES` (comma-separated `user_type` values;
|
||||
// default `REP`). A user is treated as a rep when their
|
||||
// `user_type` array intersects with this allow-list — admin,
|
||||
// driver, finance, etc. all get dropped unless explicitly
|
||||
// listed.
|
||||
const repRoles = parseRepRoles(process.env.TV_REP_ROLES);
|
||||
if (repRoles.length === 0) {
|
||||
// Should be unreachable (default is "REP") but guard so
|
||||
// a misconfigured env doesn't silently re-include everyone.
|
||||
return [];
|
||||
}
|
||||
const isTestEmail = (email: string) =>
|
||||
/@test\.local$/i.test(email) || /@example\./i.test(email);
|
||||
const isRep = (userType: unknown): boolean => {
|
||||
if (!Array.isArray(userType)) return false;
|
||||
return userType.some(
|
||||
(v) => typeof v === "string" && repRoles.includes(v.toUpperCase()),
|
||||
);
|
||||
};
|
||||
const userById = new Map<string, UserPerformance>();
|
||||
const repIds: string[] = [];
|
||||
try {
|
||||
const users = await pb.collection(USERS_COLLECTION).getList(1, 500, {
|
||||
fields: "id,name,email,accont_status",
|
||||
fields: "id,name,email,user_type,accont_status",
|
||||
});
|
||||
for (const u of users.items as Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
user_type?: string[] | null;
|
||||
// PB schema spells this field as `accont_status` (sic, three
|
||||
// c's). When the field is missing we treat the user as
|
||||
// active — older records predate the field and we don't want
|
||||
|
|
@ -357,131 +376,105 @@ async function loadUserPerformance(
|
|||
// `accont_status === false` ⇒ account deactivated ⇒ skip.
|
||||
// Undefined / true ⇒ active ⇒ include.
|
||||
if (u.accont_status === false) continue;
|
||||
const row = blank();
|
||||
row.user_id = u.id;
|
||||
row.user_name = name;
|
||||
userById.set(u.id, row);
|
||||
if (!isRep(u.user_type)) continue;
|
||||
userById.set(u.id, {
|
||||
user_id: u.id,
|
||||
user_name: name,
|
||||
today: { ordersReceived: 0, quotesReceived: 0 },
|
||||
week: { ordersReceived: 0, quotesReceived: 0 },
|
||||
month: { ordersReceived: 0, quotesReceived: 0 },
|
||||
year: { ordersReceived: 0, quotesReceived: 0 },
|
||||
});
|
||||
repIds.push(u.id);
|
||||
}
|
||||
} catch {
|
||||
// Fall through — activity-only path still produces a list.
|
||||
// Fall through — empty roster ⇒ empty result.
|
||||
}
|
||||
|
||||
// One fetch per data type covering the whole month. Bucket each
|
||||
// row into today/week/month client-side. Saves 6 PB queries
|
||||
// versus fetching each window separately. Month is the widest
|
||||
// window we display, so anything in the month set auto-buckets
|
||||
// into one of today/week/month.
|
||||
const [quotesMonth, ordersMonth, itemsMonth] = await Promise.all([
|
||||
pb.collection(QUOTES_COLLECTION).getList(1, 500, {
|
||||
filter: `created >= "${monthStartIso}"`,
|
||||
fields: "id,rep,customer,created",
|
||||
}),
|
||||
pb.collection(ORDERS_COLLECTION).getList(1, 500, {
|
||||
filter: `created >= "${monthStartIso}"`,
|
||||
fields: "id,rep,customer,created",
|
||||
}),
|
||||
pb
|
||||
.collection(process.env.ORDER_ITEMS_COLLECTION || "order_items")
|
||||
.getList(1, 500, {
|
||||
filter: `updated >= "${monthStartIso}" && (${PROCESSED_ITEM_STATUSES.map(
|
||||
(s) => `item_status = "${s}"`,
|
||||
).join(" || ")})`,
|
||||
fields: "order,updated",
|
||||
expand: "order.rep",
|
||||
}),
|
||||
]);
|
||||
if (repIds.length === 0) return [];
|
||||
|
||||
// Customer rep fallback — same 2-pass workaround as before.
|
||||
const customerIdsNeeded = Array.from(
|
||||
new Set(
|
||||
[
|
||||
...(quotesMonth.items as Array<{ rep?: string; customer?: string }>),
|
||||
...(ordersMonth.items as Array<{ rep?: string; customer?: string }>),
|
||||
]
|
||||
.filter((r) => !r.rep && r.customer)
|
||||
.map((r) => r.customer)
|
||||
.filter(Boolean) as string[],
|
||||
),
|
||||
);
|
||||
const customerRepById = new Map<string, string>();
|
||||
if (customerIdsNeeded.length > 0) {
|
||||
const customers = await pb.collection("customers").getList(1, customerIdsNeeded.length, {
|
||||
filter: customerIdsNeeded.map((id) => `id = "${id}"`).join(" || "),
|
||||
fields: "id,rep",
|
||||
});
|
||||
for (const c of customers.items as Array<{ id: string; rep?: string }>) {
|
||||
if (c.rep) customerRepById.set(c.id, c.rep);
|
||||
// Per-rep exact counts via PB's `totalItems` field. We hit PB
|
||||
// with `perPage=1` so it returns the count without shipping
|
||||
// any rows. Three conditions per rep per window:
|
||||
//
|
||||
// - Quotes: customer.rep fallback only. The `quotes` collection
|
||||
// has NO `rep` column — the operator runs the rep attribution
|
||||
// through `customer.rep`. So `(customer.rep="X" || ...)` is
|
||||
// the only path that counts anything.
|
||||
// - Orders: direct rep match + customer.rep fallback. Orders
|
||||
// have a `rep` column AND the customer fallback works.
|
||||
//
|
||||
// Cost: 4 windows × ~1.5 collections × N reps. For 10 reps
|
||||
// that's ~60 quick count queries — fits well inside the 30s
|
||||
// poll budget. Previously the same data was fetched as a
|
||||
// 500-row sample with a 500-row cap, which is why the year
|
||||
// column was reporting 500 (the literal cap).
|
||||
const windows = [
|
||||
{ label: "today", since: todayIso },
|
||||
{ label: "week", since: weekStartIso },
|
||||
{ label: "month", since: monthStartIso },
|
||||
{ label: "year", since: yearStartIso },
|
||||
] as const;
|
||||
type PeriodKey = "today" | "week" | "month" | "year";
|
||||
|
||||
const queries: Promise<void>[] = [];
|
||||
for (const repId of repIds) {
|
||||
const safeRepId = escapeForBucketFilter(repId);
|
||||
for (const w of windows) {
|
||||
// Quotes — `quotes` has no `rep` column, so customer.rep
|
||||
// is the only rep attribution path available.
|
||||
queries.push(
|
||||
pb
|
||||
.collection("quotes")
|
||||
.getList(1, 1, {
|
||||
filter: `customer.rep="${safeRepId}" && created >= "${w.since}"`,
|
||||
fields: "id",
|
||||
})
|
||||
.then((r) => {
|
||||
const row = userById.get(repId);
|
||||
if (row) row[w.label as PeriodKey].quotesReceived += r.totalItems;
|
||||
}),
|
||||
);
|
||||
// Orders — direct rep match.
|
||||
queries.push(
|
||||
pb
|
||||
.collection("orders")
|
||||
.getList(1, 1, {
|
||||
filter: `rep="${safeRepId}" && created >= "${w.since}"`,
|
||||
fields: "id",
|
||||
})
|
||||
.then((r) => {
|
||||
const row = userById.get(repId);
|
||||
if (row) row[w.label as PeriodKey].ordersReceived += r.totalItems;
|
||||
}),
|
||||
);
|
||||
// Orders — customer.rep fallback for orders with no rep.
|
||||
queries.push(
|
||||
pb
|
||||
.collection("orders")
|
||||
.getList(1, 1, {
|
||||
filter: `customer.rep="${safeRepId}" && (rep="" || rep=null) && created >= "${w.since}"`,
|
||||
fields: "id",
|
||||
})
|
||||
.then((r) => {
|
||||
const row = userById.get(repId);
|
||||
if (row) row[w.label as PeriodKey].ordersReceived += r.totalItems;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(queries);
|
||||
|
||||
/**
|
||||
* Bucket helper — given a record's rep id (resolved through the
|
||||
* fallback chain) and an ISO timestamp, bump the count on the
|
||||
* matching period slots. Skips reps not in our roster (test /
|
||||
* agent accounts) and orphan records with no resolvable rep.
|
||||
*/
|
||||
const bucket = (
|
||||
rep: { rep?: string; customer?: string } | null | undefined,
|
||||
ts: string,
|
||||
metric: "quotesReceived" | "ordersReceived" | "itemsProcessed",
|
||||
) => {
|
||||
const repId = rep?.rep || (rep?.customer ? customerRepById.get(rep.customer) : undefined);
|
||||
if (!repId) return;
|
||||
const row = userById.get(repId);
|
||||
if (!row) return;
|
||||
if (ts >= todayIso) {
|
||||
row.today[metric] += 1;
|
||||
}
|
||||
if (ts >= weekStartIso) {
|
||||
row.week[metric] += 1;
|
||||
}
|
||||
if (ts >= monthStartIso) {
|
||||
row.month[metric] += 1;
|
||||
}
|
||||
};
|
||||
|
||||
for (const q of quotesMonth.items as unknown as Array<{
|
||||
rep?: string;
|
||||
customer?: string;
|
||||
created: string;
|
||||
}>) {
|
||||
bucket(q, q.created, "quotesReceived");
|
||||
}
|
||||
for (const o of ordersMonth.items as unknown as Array<{
|
||||
rep?: string;
|
||||
customer?: string;
|
||||
created: string;
|
||||
}>) {
|
||||
bucket(o, o.created, "ordersReceived");
|
||||
}
|
||||
for (const it of itemsMonth.items as Array<{
|
||||
updated?: string;
|
||||
expand?: { order?: { rep?: string } };
|
||||
}>) {
|
||||
const orderRep = it.expand?.order;
|
||||
const repId = orderRep?.rep;
|
||||
const ts = it.updated || "";
|
||||
// Items inherit rep from the parent order directly (orders may
|
||||
// not have the customer relation projected in this query).
|
||||
if (repId) {
|
||||
const row = userById.get(repId);
|
||||
if (row && ts) {
|
||||
if (ts >= todayIso) row.today.itemsProcessed += 1;
|
||||
if (ts >= weekStartIso) row.week.itemsProcessed += 1;
|
||||
if (ts >= monthStartIso) row.month.itemsProcessed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sum today + week + month activity into a single rank. Reps
|
||||
// with zero activity across all windows are dropped — a quiet
|
||||
// roster is implicit (the operators without activity aren't on
|
||||
// the wall today). Sort is highest to lowest, then alphabetical
|
||||
// for ties so the order is stable run-to-run.
|
||||
// Sum today + week + month + year activity into a single rank.
|
||||
// Reps with zero activity across all windows are dropped — a
|
||||
// quiet roster is implicit (the operators without activity
|
||||
// aren't on the wall today). Sort is highest to lowest, then
|
||||
// alphabetical for ties so the order is stable run-to-run.
|
||||
const totalActivity = (r: UserPerformance) =>
|
||||
Object.values(r.today).reduce((a, b) => a + b, 0) +
|
||||
Object.values(r.week).reduce((a, b) => a + b, 0) +
|
||||
Object.values(r.month).reduce((a, b) => a + b, 0);
|
||||
Object.values(r.month).reduce((a, b) => a + b, 0) +
|
||||
Object.values(r.year).reduce((a, b) => a + b, 0);
|
||||
return Array.from(userById.values())
|
||||
.filter((r) => totalActivity(r) > 0)
|
||||
.sort(
|
||||
|
|
@ -492,238 +485,52 @@ async function loadUserPerformance(
|
|||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Driver stops — today's active deliveries
|
||||
* Driver stops — Joachim's active deliveries + pickups
|
||||
*
|
||||
* Scoped to a single driver via `TV_DRIVER_ID`. The TV is wired to
|
||||
* one driver because the wall display is meant to track that
|
||||
* driver's progress live: as he marks rows collected via the
|
||||
* driver app, the corresponding PB rows flip status and the
|
||||
* next 30s poll / realtime push drops them off the wall.
|
||||
*
|
||||
* The driver app's `listDriverStops` is the source of truth for
|
||||
* which rows qualify — we re-use it here so the TV's definition
|
||||
* of "active" cannot drift from the driver's view.
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
async function loadDriverStops(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
now: Date,
|
||||
): Promise<DriverStopTv[]> {
|
||||
// Restrict to `item_status = "assigned"` only — the live
|
||||
// workload the driver is currently out delivering. The earlier
|
||||
// OR fallback (`assigned_at = null && updated >= today`)
|
||||
// accidentally pulled cancelled / delivery_note / pending /
|
||||
// etc. by matching any-status when assigned_at was empty;
|
||||
// dropping that branch keeps the TV accurate to "what's out
|
||||
// the door now".
|
||||
void now;
|
||||
const items = await pb
|
||||
.collection(process.env.ORDER_ITEMS_COLLECTION || "order_items")
|
||||
.getList(1, 200, {
|
||||
filter: `item_status = "assigned"`,
|
||||
sort: "assigned_at",
|
||||
expand: "order,order.customer,order.company",
|
||||
});
|
||||
|
||||
const out: DriverStopTv[] = [];
|
||||
for (const raw of items.items as unknown as Array<{
|
||||
id: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
item_status?: string;
|
||||
expand?: {
|
||||
order?: {
|
||||
order_number?: string;
|
||||
expand?: {
|
||||
customer?: {
|
||||
customer?: string;
|
||||
coordinates?: { lon?: number; lat?: number } | null;
|
||||
};
|
||||
company?: {
|
||||
company?: string;
|
||||
coordinates?: { lon?: number; lat?: number } | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>) {
|
||||
const order = raw.expand?.order;
|
||||
const customer = order?.expand?.customer;
|
||||
const company = order?.expand?.company;
|
||||
|
||||
// Prefer customer coordinates (delivery is to the customer site);
|
||||
// fall back to the company coordinates so a missing customer
|
||||
// geoPoint doesn't drop the pin entirely.
|
||||
const coord =
|
||||
customer?.coordinates?.lon != null &&
|
||||
customer?.coordinates?.lat != null &&
|
||||
typeof customer.coordinates.lon === "number" &&
|
||||
typeof customer.coordinates.lat === "number" &&
|
||||
!(customer.coordinates.lon === 0 && customer.coordinates.lat === 0)
|
||||
? ([
|
||||
customer.coordinates.lat,
|
||||
customer.coordinates.lon,
|
||||
] as [number, number])
|
||||
: company?.coordinates?.lon != null &&
|
||||
company?.coordinates?.lat != null &&
|
||||
typeof company.coordinates.lon === "number" &&
|
||||
typeof company.coordinates.lat === "number" &&
|
||||
!(company.coordinates.lon === 0 && company.coordinates.lat === 0)
|
||||
? ([
|
||||
company.coordinates.lat,
|
||||
company.coordinates.lon,
|
||||
] as [number, number])
|
||||
: null;
|
||||
|
||||
out.push({
|
||||
id: raw.id,
|
||||
description: raw.description || "(no description)",
|
||||
customer_name: customer?.customer || null,
|
||||
company_name: company?.company || null,
|
||||
coords: coord,
|
||||
quantity: Number(raw.quantity ?? 0),
|
||||
item_status: raw.item_status || "pending",
|
||||
order_number: order?.order_number || null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
interface DriverStopsResult {
|
||||
driverName: string | null;
|
||||
stops: DriverStop[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Supplier pickups — places the driver needs to visit to collect
|
||||
* stock from suppliers. Grouped per supplier so multiple line
|
||||
* items at the same supplier collapse into one pin.
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
async function loadSupplierPickups(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
): Promise<SupplierPickupTv[]> {
|
||||
const ors = PICKUP_STATUSES.map((s) => `status = "${s}"`).join(" || ");
|
||||
|
||||
// Pass 1: active pickup items + parent purchase number for display.
|
||||
const rows = await pb
|
||||
.collection(STOCK_PURCHASE_ITEMS_COLLECTION)
|
||||
.getList(1, 500, {
|
||||
filter: `(${ors})`,
|
||||
fields: "id,description,quantity,status,purchase,assigned_at",
|
||||
});
|
||||
|
||||
const purchaseIds = Array.from(
|
||||
new Set(
|
||||
(rows.items as Array<{ purchase?: string }>)
|
||||
.map((r) => r.purchase)
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
// Pass 2: parent purchases — just the supplier id + PO number.
|
||||
// `expand: "supplier"` here returns nothing reliable on this PB,
|
||||
// so we resolve suppliers via a third pass.
|
||||
const purchaseById = new Map<
|
||||
string,
|
||||
{
|
||||
purchase_number: string | null;
|
||||
supplier_id: string | null;
|
||||
}
|
||||
>();
|
||||
if (purchaseIds.length > 0) {
|
||||
const purchases = await pb.collection("stock_purchases").getList(1, purchaseIds.length, {
|
||||
filter: purchaseIds.map((id) => `id = "${id}"`).join(" || "),
|
||||
fields: "id,purchase_number,supplier",
|
||||
});
|
||||
for (const p of purchases.items as Array<{
|
||||
id: string;
|
||||
purchase_number?: string;
|
||||
supplier?: string;
|
||||
}>) {
|
||||
purchaseById.set(p.id, {
|
||||
purchase_number: p.purchase_number || null,
|
||||
supplier_id: p.supplier || null,
|
||||
});
|
||||
}
|
||||
async function loadDriverStops(): Promise<DriverStopsResult> {
|
||||
const drvId = process.env.TV_DRIVER_ID?.trim();
|
||||
if (!drvId) {
|
||||
// No driver configured — header renders the section empty
|
||||
// and the hide-on-empty branch in `tv-view.tsx` covers it.
|
||||
return { driverName: null, stops: [] };
|
||||
}
|
||||
|
||||
// Pass 3: suppliers — name, address, coordinates. Text-format
|
||||
// coordinates; we parse "lat, lng" and reject Null Island.
|
||||
const supplierIds = Array.from(
|
||||
new Set(
|
||||
Array.from(purchaseById.values())
|
||||
.map((p) => p.supplier_id)
|
||||
.filter(Boolean) as string[],
|
||||
),
|
||||
);
|
||||
const supplierById = new Map<
|
||||
string,
|
||||
{ name: string; address: string; coords: [number, number] | null }
|
||||
>();
|
||||
if (supplierIds.length > 0) {
|
||||
const suppliers = await pb.collection("suppliers").getList(1, supplierIds.length, {
|
||||
filter: supplierIds.map((id) => `id = "${id}"`).join(" || "),
|
||||
fields: "id,suppliername,supplieraddress,coordinates",
|
||||
const { items } = await listDriverStops({
|
||||
drv_id: drvId,
|
||||
perPage: 200,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
let driverName: string | null = null;
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
const user = await pb.collection(USERS_COLLECTION).getOne(drvId, {
|
||||
fields: "id,name",
|
||||
});
|
||||
for (const s of suppliers.items as Array<{
|
||||
id: string;
|
||||
suppliername?: string;
|
||||
supplieraddress?: string;
|
||||
coordinates?: string | null;
|
||||
}>) {
|
||||
const coordText = s.coordinates || null;
|
||||
let coords: [number, number] | null = null;
|
||||
if (typeof coordText === "string" && coordText.trim()) {
|
||||
const parts = coordText.split(",").map((p) => Number(p.trim()));
|
||||
if (
|
||||
parts.length === 2 &&
|
||||
Number.isFinite(parts[0]) &&
|
||||
Number.isFinite(parts[1]) &&
|
||||
!(parts[0] === 0 && parts[1] === 0)
|
||||
) {
|
||||
coords = [parts[0], parts[1]] as [number, number];
|
||||
}
|
||||
}
|
||||
supplierById.set(s.id, {
|
||||
name: s.suppliername || "Unknown supplier",
|
||||
address: s.supplieraddress || "",
|
||||
coords,
|
||||
});
|
||||
}
|
||||
driverName = (user as { name?: string }).name?.trim() || drvId;
|
||||
} catch {
|
||||
// Driver id missing or stale — fall back to the raw id so the
|
||||
// TV still shows something vs hiding the section entirely.
|
||||
driverName = drvId;
|
||||
}
|
||||
|
||||
// Pass 4: group items by their parent purchase's supplier.
|
||||
const acc = new Map<string, SupplierPickupTv>();
|
||||
for (const r of rows.items as unknown as Array<{
|
||||
id: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
status?: string;
|
||||
purchase?: string;
|
||||
}>) {
|
||||
const purchase = r.purchase ? purchaseById.get(r.purchase) : null;
|
||||
const supplierId = purchase?.supplier_id || null;
|
||||
// Stable key per supplier, with an orphan fallback so any
|
||||
// purchase missing the supplier still surfaces on the list.
|
||||
const key = supplierId || `__orphan__::${r.purchase || ""}`;
|
||||
let bucket = acc.get(key);
|
||||
if (!bucket) {
|
||||
const sup = supplierId ? supplierById.get(supplierId) : null;
|
||||
bucket = {
|
||||
supplier_id: supplierId,
|
||||
supplier_name: sup?.name || "Unassigned",
|
||||
coords: sup?.coords || null,
|
||||
address: sup?.address || "",
|
||||
total_items: 0,
|
||||
total_qty: 0,
|
||||
items: [],
|
||||
};
|
||||
acc.set(key, bucket);
|
||||
}
|
||||
bucket.total_items += 1;
|
||||
bucket.total_qty += Number(r.quantity ?? 0);
|
||||
bucket.items.push({
|
||||
id: r.id,
|
||||
description: r.description || "(no description)",
|
||||
quantity: Number(r.quantity ?? 0),
|
||||
purchase_number: purchase?.purchase_number || null,
|
||||
item_status: r.status || "collection_pool",
|
||||
});
|
||||
}
|
||||
|
||||
// Largest supplier first (most items to collect), then by qty.
|
||||
return Array.from(acc.values()).sort(
|
||||
(a, b) =>
|
||||
b.total_items - a.total_items ||
|
||||
b.total_qty - a.total_qty ||
|
||||
a.supplier_name.localeCompare(b.supplier_name),
|
||||
);
|
||||
return { driverName, stops: items };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -739,16 +546,17 @@ async function loadPeriodStats(
|
|||
): Promise<PeriodStats> {
|
||||
const [ordersReceived, ordersPlaced, quotesReceived, itemsProcessed] =
|
||||
await Promise.all([
|
||||
// Distinct order_number — ERP imports can stamp the same
|
||||
// number on dozens of rows; an "order" to the operator is
|
||||
// one order_number, not one row.
|
||||
countDistinctSince(pb, ORDERS_COLLECTION, sinceIso, "order_number"),
|
||||
// Total orders in window. `orders` is 1 row per order so
|
||||
// totalItems equals the distinct-order count directly.
|
||||
countSince(pb, ORDERS_COLLECTION, sinceIso),
|
||||
// Distinct purchase_number, filtered to POs that were actually
|
||||
// placed with the supplier (not drafts, not cancelled).
|
||||
// `mail_failed` is included so the operator sees a PO they
|
||||
// might want to retry.
|
||||
// might want to retry. Kept distinct because stock_purchases
|
||||
// can have multiple rows per PO.
|
||||
countDistinctStockPurchasesPlaced(pb, sinceIso),
|
||||
countDistinctSince(pb, QUOTES_COLLECTION, sinceIso, "quote_number"),
|
||||
// Total quotes in window. `quotes` is 1 row per quote.
|
||||
countSince(pb, QUOTES_COLLECTION, sinceIso),
|
||||
countItemsUpdatedWithStatus(
|
||||
pb,
|
||||
sinceIso,
|
||||
|
|
@ -805,19 +613,19 @@ export async function getTvDashboardData(
|
|||
today,
|
||||
week,
|
||||
month,
|
||||
year,
|
||||
quotesPerRep,
|
||||
userPerformance,
|
||||
driverStops,
|
||||
supplierPickups,
|
||||
driverResult,
|
||||
] = await Promise.all([
|
||||
getDashboardData(now, pb),
|
||||
loadPeriodStats(pb, isoOf(since24hAgo(now))),
|
||||
loadPeriodStats(pb, isoOf(startOfWeekUtc(now))),
|
||||
loadPeriodStats(pb, isoOf(startOfMonthUtc(now))),
|
||||
loadPeriodStats(pb, isoOf(startOfYearUtc(now))),
|
||||
loadQuotesPerRep(pb, now),
|
||||
loadUserPerformance(pb, now),
|
||||
loadDriverStops(pb, now),
|
||||
loadSupplierPickups(pb),
|
||||
loadDriverStops(),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
|
@ -825,10 +633,11 @@ export async function getTvDashboardData(
|
|||
today,
|
||||
week,
|
||||
month,
|
||||
year,
|
||||
quotesPerRep,
|
||||
userPerformance,
|
||||
driverStops,
|
||||
supplierPickups,
|
||||
driverName: driverResult.driverName,
|
||||
driverStops: driverResult.stops,
|
||||
generatedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue