From b212d0fcd8a34fe132dd472de8352b7c380da044 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 26 Oct 2026 23:09:01 +0200 Subject: [PATCH] feat(manager): a mobile orders board for the managers at /manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hidden, installable PWA: one link, opened on a phone, showing where every order is. New / Processing / Completed, with a tap-to-preview popup and the invoice/line detail Gary needs. Read-only, and NOT in the sidebar — the route lives outside `(app)`, so `` never renders there. The preview popup is reused, not rebuilt: the service emits `UniversalHit`s and the page hands them to the existing `SearchPreviewDialog`, so the manager gets the desk's quick-overview verbatim. Also fixes the /orders Completed tab, which was matching ZERO orders. `delivered_at` is stamped on only 2 of 12,957 delivered lines and the business stopped writing `delivered` on 2026-08-14 (the live pipeline now ends at `invoiced` / `checked_in`), so the old filter never matched. "Settled" is now defined once — `SETTLED_STATUSES` + `SETTLED_DATE_FIELDS` — and dated per status: `invoiced_date` for invoiced (the only properly stamped field, 2,199 lines), `delivered_at` for delivered, `updated` only where no dedicated column exists. `delivery_note` and `checked_in` move into `completed` as instructed, and the standalone `post_processing` tab is retired (a DN is not a stage of its own). `invoiced` stays in `collected` as well, so invoiced orders don't vanish from the workflow tabs. Completed tab: 0 -> 341 orders over 30 days, 112 over 7. --- AGENTS.md | 62 ++ docs/manager-orders.md | 101 +++ public/manifest-manager.json | 31 + public/sw-manager.js | 80 +++ src/app/(app)/orders/page.tsx | 3 +- src/app/(mobile)/manager/auth-gate.tsx | 29 + src/app/(mobile)/manager/layout.tsx | 42 ++ .../(mobile)/manager/manager-orders-view.tsx | 513 +++++++++++++++ src/app/(mobile)/manager/page.tsx | 7 + src/app/api/mcp/route.ts | 6 +- src/app/api/v1/manager-orders/route.ts | 98 +++ src/app/api/v1/orders/route.ts | 4 +- .../service-worker-registration.tsx | 4 + src/lib/item-status-ui.settled.test.ts | 168 +++++ src/lib/item-status-ui.ts | 217 +++++-- src/lib/server/completed-window.test.ts | 66 +- src/lib/server/manager-orders-service.test.ts | 74 +++ src/lib/server/manager-orders-service.ts | 613 ++++++++++++++++++ src/lib/server/orders-service.test.ts | 12 +- src/lib/server/orders-service.ts | 75 ++- 20 files changed, 2128 insertions(+), 77 deletions(-) create mode 100644 docs/manager-orders.md create mode 100644 public/manifest-manager.json create mode 100644 public/sw-manager.js create mode 100644 src/app/(mobile)/manager/auth-gate.tsx create mode 100644 src/app/(mobile)/manager/layout.tsx create mode 100644 src/app/(mobile)/manager/manager-orders-view.tsx create mode 100644 src/app/(mobile)/manager/page.tsx create mode 100644 src/app/api/v1/manager-orders/route.ts create mode 100644 src/lib/item-status-ui.settled.test.ts create mode 100644 src/lib/server/manager-orders-service.test.ts create mode 100644 src/lib/server/manager-orders-service.ts diff --git a/AGENTS.md b/AGENTS.md index af573e9..21eaeca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,3 +267,65 @@ Supplier-side lines (`exclude_from_invoice`) close to `invoiced` at Confirm when every billable line on their order is already billed; stragglers get a "Close supplier-side lines" button on Invoicing (`POST /api/v1/invoicing/close-supplier-side`). + +# Manager phone view (`/manager`) — the order pipeline on a phone + +A hidden, installable PWA for the managers: one link, opened on a +phone, showing where every order is. It is deliberately NOT in the +sidebar — the route lives in the `(mobile)` route group, outside +`(app)`, so `` never renders there. + +Runbook: `docs/manager-orders.md`. + +- Route group `src/app/(mobile)/manager/` (layout + auth-gate + view), + PWA assets `public/manifest-manager.json` + `public/sw-manager.js`, + registered in `src/components/service-worker-registration.tsx`. +- Read model `src/lib/server/manager-orders-service.ts`, exposed at + `GET /api/v1/manager-orders` (browser-session auth, same shape as + `/api/v1/boss-dashboard`). +- Three buckets — **New / Processing / Completed** — from + `PIPELINE_GROUP_ITEM_STATUSES` in `src/lib/item-status-ui.ts`. That + map is a COARSE view of the desk's eight workflow tabs; both read + the same status lists so they can never disagree. +- **Completed is time-windowed** (7 days on the phone, 30 on + `/orders`). "Settled" is defined ONCE in `item-status-ui.ts` + (`SETTLED_STATUSES` + `SETTLED_DATE_FIELDS`) and the filter is + shared (`settledWindowFilter` in `orders-service.ts`). + +## The date field is per-status — read this before changing the window + +There is no single "finished at" column, and the obvious one is a +trap. Measured on live PB (2026-10-26, 12,957 delivered lines): + +- `delivered_at` is populated on **2** lines. The rest are legacy rows + whose status was set without a stamp, and their `updated` is the + MIGRATION timestamp. So `delivered` counts only when `delivered_at` + is set — an un-dated delivered row is ARCHIVED, not recent. +- `invoiced_date` is populated on **2,199** lines and is the only + properly stamped settlement date in the schema. +- `completed`, `checked_in` and `delivery_note` have no dedicated + column, so `updated` (bumped on every write) is the only signal. + +`SETTLED_DATE_FIELDS` encodes that: `primary` = "use this stamp, and +skip the row if it is empty"; `fallback` = "no stamp exists for this +status, use `updated`". They are mutually exclusive — giving a status +BOTH would resurface archived rows every time an old row was touched. + +Before the fix the Completed tab matched **0** orders, because the +filter only looked at `delivered` + `completed` dated on +`delivered_at`/`updated`, and the business stopped writing `delivered` +on 2026-08-14 (the live pipeline now ends at `invoiced` / +`checked_in`). It now matches 341 orders over 30 days, 112 over 7. + +The `post_processing` tab was retired in the same change (a DN is not +a stage of its own) and `delivery_note` + `checked_in` moved into +`completed`. `invoiced` is listed in BOTH `collected` and `completed` +on purpose: `itemStatusToTabGroup` resolves it to `collected` (the +office's workflow tab keeps its rows) while the completed tab picks +invoiced rows up through its own windowed filter. Dropping it from +`collected` would make invoiced orders vanish from `/orders`. + +The preview popup is REUSED, not rebuilt: the service emits +`UniversalHit`s and the page hands them to the existing +`SearchPreviewDialog` (`src/app/(app)/search/search-preview.tsx`), so +the manager sees the same quick-overview the desk sees on /search. diff --git a/docs/manager-orders.md b/docs/manager-orders.md new file mode 100644 index 0000000..8908c7d --- /dev/null +++ b/docs/manager-orders.md @@ -0,0 +1,101 @@ +# Manager orders view (`/manager`) + +A hidden, installable PWA for the managers: one link, opened on a +phone, showing where every order is — **New / Processing / Completed**. + +It is deliberately not in the sidebar. The route lives in the +`(mobile)` route group, outside `(app)`, so `` never +renders on it and there is no nav entry anywhere. + +## Where things live + +| Piece | File | +|---|---| +| Page (route group) | `src/app/(mobile)/manager/` — `layout.tsx`, `auth-gate.tsx`, `page.tsx`, `manager-orders-view.tsx` | +| Read model | `src/lib/server/manager-orders-service.ts` | +| API route | `src/app/api/v1/manager-orders/route.ts` | +| PWA manifest | `public/manifest-manager.json` | +| Service worker | `public/sw-manager.js` | +| SW registration | `src/components/service-worker-registration.tsx` (adds `/manager` → `/sw-manager.js`) | +| Bucket + settled-status contract | `src/lib/item-status-ui.ts` | +| Shared window filter | `settledWindowFilter` in `src/lib/server/orders-service.ts` | + +## The three buckets + +`PIPELINE_GROUP_ITEM_STATUSES` in `src/lib/item-status-ui.ts` is a +coarse view of the desk's eight workflow tabs. Both surfaces read the +same status lists, so they can never disagree about what a status +means. + +| Bucket | Statuses | Orders (live, 2026-10-26) | +|---|---|---| +| **New** | `pending`, `approval_needed`, `approval_rejected` | 8 | +| **Processing** | `processed`, `po_generated`, `collection_pool`, `assigned`, `ready_delivery`, `collected`, `stock_verified`, `picking`, `ready_invoice`, `stock_booked`, `with_driver` | 79 | +| **Completed** | the settled set, windowed to 7 / 14 / 30 days | 65 / 112 / 200 | + +An order lands in a bucket when **at least one of its lines** is in +that bucket. That is why Processing holds 79 orders for only 206 +open lines: 45 of those orders have only ONE line left, and 40 have +other lines already invoiced or collected. `cancelled` and `removed` +are deliberately outside the pipeline (they live on /orders/archive). + +## The Completed window — read before changing it + +There is no single "finished at" column. `SETTLED_STATUSES` + +`SETTLED_DATE_FIELDS` in `src/lib/item-status-ui.ts` encode which +field dates each settled status: + +| Status | Dated on | Why | +|---|---|---| +| `delivered` | `delivered_at` | populated on only 2 of 12,957 lines; the rest are legacy rows whose `updated` is the MIGRATION timestamp, so an un-dated delivered row is ARCHIVED, not recent | +| `invoiced` | `invoiced_date` | the only properly stamped settlement date in the schema (2,199 lines) | +| `completed`, `checked_in`, `delivery_note` | `updated` | no dedicated column exists, so `updated` is the only signal | + +`primary` and `fallback` are mutually exclusive. Giving a status both +would resurface archived rows every time an old row was touched. + +Before this change the Completed tab matched **0** orders: the filter +only looked at `delivered` + `completed` dated on `delivered_at` / +`updated`, and the business stopped writing `delivered` on 2026-08-14 +(the live pipeline now ends at `invoiced` / `checked_in`). It now +matches 341 orders over 30 days, 112 over 7. + +`settledWindowFilter(days)` is shared with `/orders` (30 days) — only +the window length differs, so the two surfaces never disagree. + +## The preview popup is reused, not rebuilt + +The service emits `UniversalHit`s — the same shape the universal +search returns — and the page hands them to the existing +`SearchPreviewDialog` (`src/app/(app)/search/search-preview.tsx`). So +the manager gets the desk's quick-overview verbatim: status chips, +stats, invoice refs, delivery-note numbers, the line list and the +lifecycle timeline, plus a link out to the full order page. + +**Read-only.** Nothing on the phone can change an order's state. + +## API + +``` +GET /api/v1/manager-orders?group=new|processing|completed + &window=5|7|14|30 + &search= + &page=1&perPage=20 +``` + +Auth is the PB browser session in the `Authorization` header — the +same `assertBrowserSession` pattern as `/api/v1/boss-dashboard`. The +response carries `counts` (distinct orders per bucket), `total`, +`totalPages`, the page of `orders` and `viewerName`. + +## Installing it on a phone + +Open the link on the phone → browser menu → **Add to Home Screen**. It +installs as a standalone app ("WFDASH — Orders") with its own icon, +launched in portrait with no browser chrome. iOS needs the +`apple-mobile-web-app-capable` meta, which the layout sets (Next 16 +only emits the standardised name, which iOS ignores — see the note in +`(mobile)/m/layout.tsx`). + +The service worker precaches the shell and never caches `/api/`, so a +stale order count can never be shown. diff --git a/public/manifest-manager.json b/public/manifest-manager.json new file mode 100644 index 0000000..25f7222 --- /dev/null +++ b/public/manifest-manager.json @@ -0,0 +1,31 @@ +{ + "name": "WFDASH — Orders", + "short_name": "Orders", + "description": "Manager's phone view of the order pipeline: what's new, what's processing, and what finished this week.", + "start_url": "/manager", + "scope": "/manager", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0b1020", + "theme_color": "#0b1020", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/public/sw-manager.js b/public/sw-manager.js new file mode 100644 index 0000000..af54a6b --- /dev/null +++ b/public/sw-manager.js @@ -0,0 +1,80 @@ +// Service worker for the /manager orders PWA. Precaches the mobile +// shell, network-first for navigations, never caches the orders API +// responses (those are live tracking data — a stale count would +// actively mislead the manager). +const CACHE_NAME = "wfdash-manager-v1"; +const OFFLINE_URL = "/offline"; + +const PRECACHE_ASSETS = [ + "/manager", + "/manifest-manager.json", + "/offline", + "/android-chrome-192x192.png", + "/android-chrome-512x512.png", + "/apple-touch-icon.png", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + + // Never cache API traffic — the order pipeline is live tracking data + // and serving a stale /api/v1/manager-orders would show the manager + // orders that have already moved on. + if (url.pathname.startsWith("/api/")) return; + + // Navigation requests: network-first, fall back to the cached + // /manager shell, then the generic /offline page. + if (request.mode === "navigate") { + event.respondWith( + fetch(request) + .then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + return response; + }) + .catch(async () => { + const cached = await caches.match(request); + if (cached) return cached; + const managerShell = await caches.match("/manager"); + if (managerShell) return managerShell; + return caches.match(OFFLINE_URL); + }) + ); + return; + } + + // Static assets: stale-while-revalidate. + event.respondWith( + caches.match(request).then((cached) => { + const networkFetch = fetch(request) + .then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + return response; + }) + .catch(() => cached); + return cached || networkFetch; + }) + ); +}); diff --git a/src/app/(app)/orders/page.tsx b/src/app/(app)/orders/page.tsx index ebc666d..1f336f7 100644 --- a/src/app/(app)/orders/page.tsx +++ b/src/app/(app)/orders/page.tsx @@ -82,7 +82,6 @@ import type { OrderItem, ItemStatus } from "@/lib/types"; const EMPTY_COUNTS: Record = { new: 0, processing: 0, - post_processing: 0, collection_pool: 0, collected: 0, completed: 0, @@ -417,7 +416,7 @@ export default function OrdersPage() { active={activeTab} onChange={handleTabChange} counts={counts} - tabKeys={["new", "processing", "post_processing", "collection_pool", "collected", "completed"]} + tabKeys={["new", "processing", "collection_pool", "collected", "completed"]} /> {/* Search — order/company/customer/supplier/SKU/description/price. diff --git a/src/app/(mobile)/manager/auth-gate.tsx b/src/app/(mobile)/manager/auth-gate.tsx new file mode 100644 index 0000000..a498a70 --- /dev/null +++ b/src/app/(mobile)/manager/auth-gate.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useAuth } from "@/components/auth-provider"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, type ReactNode } from "react"; + +export function MobileAuthGate({ children }: { children: ReactNode }) { + const { user, loading } = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + if (!loading && !user) { + router.replace(`/login?next=${encodeURIComponent(pathname)}`); + } + }, [user, loading, router, pathname]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + return <>{children}; +} diff --git a/src/app/(mobile)/manager/layout.tsx b/src/app/(mobile)/manager/layout.tsx new file mode 100644 index 0000000..ad2a80b --- /dev/null +++ b/src/app/(mobile)/manager/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata, Viewport } from "next"; +import type { ReactNode } from "react"; +import { MobileAuthGate } from "./auth-gate"; + +export const metadata: Metadata = { + title: "WFDASH — Orders", + description: + "Manager's phone view of the order pipeline: what's new, what's processing, and what finished this week.", + manifest: "/manifest-manager.json", + icons: { + apple: "/apple-touch-icon.png", + }, + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Orders", + }, + other: { + // Next 16 emits only the standardised `mobile-web-app-capable` for + // `appleWebApp.capable`, and iOS ignores that name — so without + // this the home-screen icon opens as a Safari bookmark rather than + // a standalone app. See the same note in `(mobile)/m/layout.tsx`. + "apple-mobile-web-app-capable": "yes", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + themeColor: "#0b1020", +}; + +export default function ManagerLayout({ children }: { children: ReactNode }) { + return ( + +
+ {children} +
+
+ ); +} diff --git a/src/app/(mobile)/manager/manager-orders-view.tsx b/src/app/(mobile)/manager/manager-orders-view.tsx new file mode 100644 index 0000000..9e60da0 --- /dev/null +++ b/src/app/(mobile)/manager/manager-orders-view.tsx @@ -0,0 +1,513 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { RefreshCw, Search as SearchIcon, X } from "lucide-react"; +import getPocketBase from "@/lib/pocketbase"; +import { + SearchPreviewDialog, + type SearchHit, +} from "@/app/(app)/search/search-preview"; +import { + PIPELINE_GROUP_ORDER, + PIPELINE_GROUP_VISUAL, + type PipelineGroup, +} from "@/lib/item-status-ui"; + +/** + * The manager's phone view of the order pipeline. + * + * Deliberately narrow: three buckets (New / Processing / Completed), a + * search box, and a tap-to-preview popup that reuses the desk's + * universal-search preview component so the quick-overview a manager + * sees here is the SAME one the office sees — one preview UI, one + * source of truth. + * + * Read-only. The preview links out to the full order page; nothing on + * this screen can change an order's state. + */ + +const REALTIME_DEBOUNCE_MS = 1_000; +const PER_PAGE = 20; + +const WINDOW_OPTIONS = [5, 7, 14, 30] as const; + +type ManagerHit = SearchHit & { group?: string }; + +type ManagerResponse = { + group: string; + windowDays: number; + orders: ManagerHit[]; + counts: Record; + total: number; + page: number; + perPage: number; + totalPages: number; + generatedAt: string; + viewerName: string | null; + error?: string; +}; + +/** Pull a stat value out of the hit's stat tiles. */ +function statValue(hit: SearchHit, label: string): string { + return hit.stats?.find((s) => s.label === label)?.value ?? ""; +} + +function timeAgo(date: Date): string { + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +export default function ManagerOrdersView() { + const router = useRouter(); + + const [group, setGroup] = useState("new"); + const [windowDays, setWindowDays] = useState(7); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + const [data, setData] = useState(null); + const [rows, setRows] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + const [counts, setCounts] = useState>({}); + + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [lastRefreshed, setLastRefreshed] = useState(null); + const [previewHit, setPreviewHit] = useState(null); + + const reqIdRef = useRef(0); + // Mirrors of the live values, so the realtime subscription never has + // to re-subscribe when the manager switches tabs. + const groupRef = useRef(group); + const windowRef = useRef(windowDays); + const searchRef = useRef(debouncedSearch); + const pageRef = useRef(page); + + useEffect(() => { + groupRef.current = group; + }, [group]); + useEffect(() => { + windowRef.current = windowDays; + }, [windowDays]); + useEffect(() => { + searchRef.current = debouncedSearch; + }, [debouncedSearch]); + useEffect(() => { + pageRef.current = page; + }, [page]); + + // Debounce the search box so typing doesn't fire a request per key. + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300); + return () => window.clearTimeout(timer); + }, [search]); + + const fetchPage = useCallback( + async (targetPage: number, append: boolean) => { + const pb = getPocketBase(); + if (!pb.authStore?.isValid) { + toast.error("Your session expired", { + description: "Sign in again to continue.", + }); + router.replace(`/login?next=${encodeURIComponent("/manager")}`); + return; + } + + const myId = ++reqIdRef.current; + if (append) setLoadingMore(true); + else setLoading(true); + setError(null); + + try { + const token = pb.authStore?.token ?? ""; + const params = new URLSearchParams({ + group: groupRef.current, + window: String(windowRef.current), + search: searchRef.current, + page: String(targetPage), + perPage: String(PER_PAGE), + }); + const res = await fetch(`/api/v1/manager-orders?${params.toString()}`, { + headers: { Authorization: token }, + cache: "no-store", + }); + if (!res.ok) { + if (res.status === 401) { + toast.error("Your session expired", { + description: "Sign in again to continue.", + }); + router.replace(`/login?next=${encodeURIComponent("/manager")}`); + return; + } + const body = await res.json().catch(() => ({})); + throw new Error(body?.error || `HTTP ${res.status}`); + } + const body = (await res.json()) as ManagerResponse; + if (myId !== reqIdRef.current) return; // stale — a newer fetch is in flight + + setData(body); + setRows((prev) => (append ? [...prev, ...body.orders] : body.orders)); + setCounts(body.counts ?? {}); + setTotal(body.total ?? 0); + setTotalPages(body.totalPages ?? 1); + setPage(body.page ?? targetPage); + setLastRefreshed(new Date()); + } catch (err) { + if (myId !== reqIdRef.current) return; + const message = err instanceof Error ? err.message : "Failed to load orders"; + toast.error("Couldn't load orders", { description: message }); + setError(message); + } finally { + if (myId === reqIdRef.current) { + setLoading(false); + setLoadingMore(false); + } + } + }, + [router], + ); + + // First load + a refetch whenever the bucket, window or search + // changes. `fetchPage` is stable, so this runs exactly once on mount + // and then only when the deps below change. + // + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + void fetchPage(1, false); + }, [fetchPage, group, windowDays, debouncedSearch]); + + // Realtime: a line moving status should change the badge without the + // manager pulling to refresh. + // + // PocketBase fires one event per changed row, and a single PO + // confirmation touches several rows at once — so the refetch is + // debounced. Without it, one supplier confirmation would fire a + // dozen overlapping requests. + useEffect(() => { + const pb = getPocketBase(); + let debounceHandle = 0 as number; + const trigger = () => { + if (debounceHandle) window.clearTimeout(debounceHandle); + debounceHandle = window.setTimeout(() => { + void fetchPage(pageRef.current, false); + }, REALTIME_DEBOUNCE_MS); + }; + let cancelled = false; + + const subscribe = async () => { + await pb.collection("order_items").subscribe("*", trigger); + await pb.collection("orders").subscribe("*", trigger); + }; + void subscribe(); + + return () => { + cancelled = true; + if (debounceHandle) window.clearTimeout(debounceHandle); + void pb.collection("order_items").unsubscribe(); + void pb.collection("orders").unsubscribe(); + void cancelled; + }; + }, [fetchPage]); + + const selectGroup = useCallback((next: PipelineGroup) => { + if (next === groupRef.current) return; + groupRef.current = next; + setGroup(next); + setPage(1); + setRows([]); + }, []); + + const selectWindow = useCallback((next: number) => { + if (next === windowRef.current) return; + windowRef.current = next; + setWindowDays(next); + setPage(1); + setRows([]); + }, []); + + const loadMore = useCallback(() => { + if (loadingMore || page >= totalPages) return; + void fetchPage(page + 1, true); + }, [fetchPage, loadingMore, page, totalPages]); + + const refresh = useCallback(() => { + void fetchPage(pageRef.current, false); + }, [fetchPage]); + + const counts0 = counts ?? {}; + + return ( +
+ {/* ---------------------------------------------------------------- */} + {/* Header */} + {/* ---------------------------------------------------------------- */} +
+
+

Orders

+
+
+
+ +
+ + {/* ---------------------------------------------------------------- */} + {/* Pipeline tabs */} + {/* ---------------------------------------------------------------- */} +
+ {PIPELINE_GROUP_ORDER.map((key) => { + const visual = PIPELINE_GROUP_VISUAL[key]; + const active = group === key; + const count = counts0[key]; + return ( + + ); + })} +
+ + {/* ---------------------------------------------------------------- */} + {/* Search */} + {/* ---------------------------------------------------------------- */} +
+
+ + {/* ---------------------------------------------------------------- */} + {/* Completed window — only meaningful for the Completed bucket. */} + {/* ---------------------------------------------------------------- */} + {group === "completed" && ( +
+ + Settled in + +
+ {WINDOW_OPTIONS.map((days) => ( + + ))} +
+
+ )} + + {/* ---------------------------------------------------------------- */} + {/* Scope line — the window must be visible, or a miss looks like a */} + {/* bug rather than an age-out. */} + {/* ---------------------------------------------------------------- */} + {data && ( +

+ {total} order{total === 1 ? "" : "s"} + {group === "completed" && ` settled in the last ${windowDays} days`} + {debouncedSearch && ` matching “${debouncedSearch}”`} +

+ )} + + {/* ---------------------------------------------------------------- */} + {/* List */} + {/* ---------------------------------------------------------------- */} + {loading && rows.length === 0 ? ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ) : rows.length === 0 ? ( +
+

Nothing here

+

+ {debouncedSearch + ? `No orders match “${debouncedSearch}”.` + : group === "completed" + ? `No orders settled in the last ${windowDays} days.` + : group === "new" + ? "No brand-new orders — everything is already moving." + : "Nothing in flight right now."} +

+
+ ) : ( +
    + {rows.map((hit) => ( +
  • + +
  • + ))} +
+ )} + + {page < totalPages && rows.length > 0 && ( + + )} + + {/* ---------------------------------------------------------------- */} + {/* Preview popup — the same component the desk's search uses. */} + {/* ---------------------------------------------------------------- */} + setPreviewHit(null)} + /> +
+ ); +} diff --git a/src/app/(mobile)/manager/page.tsx b/src/app/(mobile)/manager/page.tsx new file mode 100644 index 0000000..5c705b9 --- /dev/null +++ b/src/app/(mobile)/manager/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import ManagerOrdersView from "./manager-orders-view"; + +export default function ManagerOrdersPage() { + return ; +} diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 83734a1..a19634b 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -528,7 +528,7 @@ const tools = [ "List orders with optional filters. Pass PB filter syntax in `filter` (e.g. `created >= \"2026-08-05\" && created < \"2026-08-06\"`) and `sort` (e.g. `-created`, `order_number`). " + "`search` is a substring match on order_number; `customer` accepts a customer id OR a name (resolved case-insensitively); " + "`createdAfter` and `createdBefore` take YYYY-MM-DD and convert to UTC midnight boundaries; " + - "`status` takes a tab-group name (`new | processing | post_processing | collection_pool | collected | delivered | cancelled`) " + + "`status` takes a tab-group name (`new | processing | collection_pool | collected | completed | delivered | cancelled`) " + "and returns orders with >= 1 line item in that tab group. Filters compose with `&&`.", inputSchema: { type: "object", @@ -541,7 +541,7 @@ const tools = [ customer: { type: "string", description: "Customer relation id (PB 15-char) OR a case-insensitive substring of the customer name. Resolved to id before applying." }, createdAfter: { type: "string", description: "YYYY-MM-DD. Converts to `created >= \"YYYY-MM-DD 00:00:00.000Z\"`." }, createdBefore: { type: "string", description: "YYYY-MM-DD. Converts to `created < \"YYYY-MM-DD 00:00:00.000Z\"`." }, - status: { type: "string", description: "Tab-group name (one of: new, processing, post_processing, collection_pool, collected, delivered, cancelled). Returns orders with >= 1 line item in that tab group." }, + status: { type: "string", description: "Tab-group name (one of: new, processing, collection_pool, collected, completed, delivered, cancelled). Returns orders with >= 1 line item in that tab group." }, }, }, }, @@ -3728,9 +3728,9 @@ async function handleMcpPost(req: NextRequest) { const valid = [ "new", "processing", - "post_processing", "collection_pool", "collected", + "completed", "delivered", "cancelled", ]; diff --git a/src/app/api/v1/manager-orders/route.ts b/src/app/api/v1/manager-orders/route.ts new file mode 100644 index 0000000..2a44b13 --- /dev/null +++ b/src/app/api/v1/manager-orders/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getManagerOrdersOverview, + DEFAULT_MANAGER_WINDOW, + MANAGER_COMPLETED_WINDOWS, + isManagerCompletedWindow, + type ManagerCompletedWindow, +} from "@/lib/server/manager-orders-service"; +import { + PIPELINE_GROUP_ORDER, + type PipelineGroup, +} from "@/lib/item-status-ui"; + +/** + * GET /api/v1/manager-orders?group=new|processing|completed&window=7&search=&page=1&perPage=20 + * + * Read model for the manager's phone view at `/manager`. Auth via the + * PB browser session — same pattern as /api/v1/boss-dashboard. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const VALID_GROUPS: PipelineGroup[] = [...PIPELINE_GROUP_ORDER]; + +async function assertBrowserSession(req: NextRequest) { + const auth = req.headers.get("authorization"); + if (!auth) { + throw Object.assign(new Error("Not authenticated."), { status: 401 }); + } + const pbUrl = + process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth; + const res = await fetch( + `${pbUrl.replace(/\/$/, "")}/api/collections/users/auth-refresh`, + { + method: "POST", + headers: { Authorization: token }, + signal: AbortSignal.timeout(5000), + }, + ); + if (!res.ok) { + throw Object.assign(new Error("Invalid or expired session."), { + status: 401, + }); + } + return res.json(); +} + +export async function GET(req: NextRequest) { + try { + const user = await assertBrowserSession(req); + + const url = new URL(req.url); + const rawGroup = url.searchParams.get("group") ?? "new"; + if (!VALID_GROUPS.includes(rawGroup as PipelineGroup)) { + return NextResponse.json( + { + error: + `Invalid group: ${rawGroup}. Allowed: ${VALID_GROUPS.join(", ")}.`, + }, + { status: 400 }, + ); + } + + const rawWindow = url.searchParams.get("window"); + const windowDays = rawWindow === null + ? DEFAULT_MANAGER_WINDOW + : Number(rawWindow); + if (!isManagerCompletedWindow(windowDays)) { + return NextResponse.json( + { + error: + `Invalid window: ${rawWindow}. Allowed: ${MANAGER_COMPLETED_WINDOWS.join(", ")}.`, + }, + { status: 400 }, + ); + } + + const page = Number(url.searchParams.get("page") ?? "1") || 1; + const perPage = Number(url.searchParams.get("perPage") ?? "20") || 20; + const search = url.searchParams.get("search") ?? ""; + + const data = await getManagerOrdersOverview({ + group: rawGroup as PipelineGroup, + windowDays: windowDays as ManagerCompletedWindow, + search, + page, + perPage, + }); + + return NextResponse.json({ ...data, viewerName: user?.record?.name ?? null }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load orders"; + const status = (error as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/v1/orders/route.ts b/src/app/api/v1/orders/route.ts index e13857f..aa4153f 100644 --- a/src/app/api/v1/orders/route.ts +++ b/src/app/api/v1/orders/route.ts @@ -15,7 +15,7 @@ import type { TabGroup } from "@/lib/item-status-ui"; * customer — id or name; resolved to id before applying * createdAfter — YYYY-MM-DD; converted to `created >= "..."` * createdBefore — YYYY-MM-DD; converted to `created < "..."` - * status — TabGroup name (one of `new | processing | post_processing + * status — TabGroup name (one of `new | processing * | collection_pool | collected | delivered | cancelled`). * Resolves to a sub-query of orders with >= 1 line item * in that tab group. @@ -78,7 +78,7 @@ export async function GET(req: NextRequest) { const valid: TabGroup[] = [ "new", "processing", - "post_processing", + "collection_pool", "collected", "delivered", diff --git a/src/components/service-worker-registration.tsx b/src/components/service-worker-registration.tsx index 5839be3..33dd9a1 100644 --- a/src/components/service-worker-registration.tsx +++ b/src/components/service-worker-registration.tsx @@ -7,6 +7,7 @@ import { useEffect } from "react"; * - /m → /sw-mobile.js (boss-dashboard PWA, dark theme) * - /counter → /sw-counter.js (over-the-counter order PWA) * - /new-order → /sw-counter.js (same counter PWA shell) + * - /manager → /sw-manager.js (manager orders-tracking PWA) * - /rep → /sw-rep.js (rep visit-logging PWA, offline-first) * - everything else → /sw.js (main WFDASH app) * @@ -22,6 +23,9 @@ function pickSwPath(): string { if (path.startsWith("/counter") || path.startsWith("/new-order")) { return "/sw-counter.js"; } + if (path === "/manager" || path.startsWith("/manager/")) { + return "/sw-manager.js"; + } if (path === "/rep" || path.startsWith("/rep/")) { return "/sw-rep.js"; } diff --git a/src/lib/item-status-ui.settled.test.ts b/src/lib/item-status-ui.settled.test.ts new file mode 100644 index 0000000..af07704 --- /dev/null +++ b/src/lib/item-status-ui.settled.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; +import { + ITEM_STATUS_VISUAL, + PIPELINE_GROUP_ITEM_STATUSES, + PIPELINE_GROUP_ORDER, + SETTLED_DATE_FIELDS, + SETTLED_STATUSES, + TAB_GROUP_ITEM_STATUSES, + TAB_GROUP_ORDER, + TAB_GROUP_VISUAL, + itemStatusToPipelineGroup, + itemStatusToTabGroup, +} from "./item-status-ui"; +import type { ItemStatus } from "./types"; + +/** + * The 2026-10-26 taxonomy change. + * + * Before: the `completed` tab matched only `delivered` + `completed` + * rows, dated on `delivered_at` / `updated`. On live PB that matched + * ZERO orders, because: + * - `delivered_at` is stamped on only 2 of 12,957 delivered lines + * (the rest are legacy rows whose status was set without a stamp) + * - the `delivered` status itself has not been written since + * 2026-08-14 — the live pipeline now ends at `invoiced` / + * `checked_in` + * + * So the tab was permanently empty while `delivery_note` (319 lines) + * and `checked_in` (1,166 lines) sat in tabs that did not describe + * what had actually finished. + * + * After: `delivery_note`, `checked_in` and `invoiced` join + * `delivered` + `completed` as SETTLED, each dated by the best + * available field, and the standalone `post_processing` tab is gone. + */ +describe("SETTLED_STATUSES", () => { + it("includes the two statuses the operator moved into completed", () => { + expect(SETTLED_STATUSES).toContain("delivery_note"); + expect(SETTLED_STATUSES).toContain("checked_in"); + }); + + it("keeps the original pair", () => { + expect(SETTLED_STATUSES).toContain("delivered"); + expect(SETTLED_STATUSES).toContain("completed"); + }); + + it("includes invoiced — the only status with a properly stamped date", () => { + // `invoiced_date` is populated on 2,199 lines; `delivered_at` on 2. + // Without `invoiced` in the settled set, a 7-day window matched + // 13 orders instead of 112. + expect(SETTLED_STATUSES).toContain("invoiced"); + }); + + it("excludes the in-flight statuses", () => { + for (const status of ["pending", "collection_pool", "with_driver"] as ItemStatus[]) { + expect(SETTLED_STATUSES).not.toContain(status); + } + }); + + it("gives every settled status a date source", () => { + for (const status of SETTLED_STATUSES) { + const spec = SETTLED_DATE_FIELDS[status]; + expect(Boolean(spec?.primary || spec?.fallback)).toBe(true); + } + }); + + it("dates `delivered` on delivered_at ONLY", () => { + // `delivered_at` is empty on all but 2 of the 12,957 delivered + // lines, and their `updated` is the migration timestamp. Falling + // back to `updated` would resurface archived work every time an + // old row was touched — an un-dated row is archived, not recent. + expect(SETTLED_DATE_FIELDS.delivered).toEqual({ primary: "delivered_at" }); + }); + + it("dates `invoiced` on invoiced_date", () => { + expect(SETTLED_DATE_FIELDS.invoiced).toEqual({ primary: "invoiced_date" }); + }); + + it("uses `updated` only for statuses with no dedicated stamp field", () => { + // No schema field exists for these three, so `updated` is the only + // signal available. + for (const status of ["completed", "checked_in", "delivery_note"] as const) { + expect(SETTLED_DATE_FIELDS[status]).toEqual({ fallback: "updated" }); + } + }); +}); + +describe("TAB_GROUP_ITEM_STATUSES — the post_processing tab is retired", () => { + it("no longer has a post_processing group", () => { + expect(TAB_GROUP_ORDER).not.toContain("post_processing"); + expect("post_processing" in TAB_GROUP_VISUAL).toBe(false); + }); + + it("keeps every ItemStatus mapped to exactly one group", () => { + // `itemStatusToTabGroup` throws on an unmapped status, so this is + // the guard that catches a status added to the union without a + // home. + for (const status of Object.keys(ITEM_STATUS_VISUAL) as ItemStatus[]) { + expect(() => itemStatusToTabGroup(status)).not.toThrow(); + } + }); + + it("moves delivery_note and checked_in out of their old groups", () => { + expect(TAB_GROUP_ITEM_STATUSES.collected).not.toContain("checked_in"); + expect(TAB_GROUP_ITEM_STATUSES.completed).toContain("checked_in"); + expect(TAB_GROUP_ITEM_STATUSES.completed).toContain("delivery_note"); + }); + + it("keeps invoiced visible on the collected workflow tab", () => { + // `invoiced` is deliberately listed in BOTH `collected` and + // `completed`: `itemStatusToTabGroup` resolves it to `collected` + // (first match) so the office's workflow tab keeps its rows, while + // the completed tab picks invoiced rows up through its own + // windowed filter. Dropping it from `collected` would make + // invoiced orders vanish from /orders entirely. + expect(TAB_GROUP_ITEM_STATUSES.collected).toContain("invoiced"); + expect(TAB_GROUP_ITEM_STATUSES.completed).toContain("invoiced"); + expect(itemStatusToTabGroup("invoiced")).toBe("collected"); + }); +}); + +describe("PIPELINE_GROUP_ITEM_STATUSES — the manager's three buckets", () => { + it("has exactly new / processing / completed", () => { + expect(PIPELINE_GROUP_ORDER).toEqual(["new", "processing", "completed"]); + }); + + it("maps every ItemStatus to a pipeline group", () => { + for (const status of Object.keys(ITEM_STATUS_VISUAL) as ItemStatus[]) { + expect(() => itemStatusToPipelineGroup(status)).not.toThrow(); + } + }); + + it("keeps cancelled and removed OUT of the pipeline", () => { + // A cancelled order is not work in progress — it belongs on + // /orders/archive, not in the manager's three buckets. + expect(itemStatusToPipelineGroup("cancelled")).toBeNull(); + expect(itemStatusToPipelineGroup("removed")).toBeNull(); + }); + + it("puts the brand-new statuses in new", () => { + expect(PIPELINE_GROUP_ITEM_STATUSES.new).toContain("pending"); + expect(itemStatusToPipelineGroup("pending")).toBe("new"); + }); + + it("puts the in-flight statuses in processing", () => { + expect(itemStatusToPipelineGroup("po_generated")).toBe("processing"); + expect(itemStatusToPipelineGroup("collection_pool")).toBe("processing"); + expect(itemStatusToPipelineGroup("with_driver")).toBe("processing"); + }); + + it("puts every settled status in completed", () => { + for (const status of SETTLED_STATUSES) { + expect(itemStatusToPipelineGroup(status)).toBe("completed"); + } + }); + + it("keeps a status out of two pipeline groups", () => { + // The pipeline groups are a strict partition — an order must not + // land in both "processing" and "completed" by status. + const seen = new Set(); + for (const group of PIPELINE_GROUP_ORDER) { + for (const status of PIPELINE_GROUP_ITEM_STATUSES[group]) { + expect(seen.has(status)).toBe(false); + seen.add(status); + } + } + }); +}); diff --git a/src/lib/item-status-ui.ts b/src/lib/item-status-ui.ts index ff8a84c..b2c3a9a 100644 --- a/src/lib/item-status-ui.ts +++ b/src/lib/item-status-ui.ts @@ -97,17 +97,16 @@ export function getItemStatusVisual(status: string): ItemStatusVisual { * its items has an item_status in `TAB_GROUP_ITEM_STATUSES[X]`. * * Display order matches the operational lifecycle: a brand-new item - * lands on "new"; PO'd items move to "processing"; once a delivery - * note has been generated they're sitting on "post_processing" until - * the operator finalises them; once queued for collection they slide - * to "collection pool"; back at the office they move to "collected"; - * delivered is terminal; cancelled is its own tab so the operator - * can audit it without it drowning the active tabs. + * lands on "new"; PO'd items move to "processing"; once queued for + * collection they slide to "collection pool"; back at the office they + * move to "collected"; a settled line lands on "completed" (the only + * time-windowed tab); delivered and cancelled are terminal and have + * their own tabs so the operator can audit them without them + * drowning the active tabs. */ export type TabGroup = | "new" | "processing" - | "post_processing" | "collection_pool" | "collected" | "completed" @@ -117,7 +116,6 @@ export type TabGroup = export const TAB_GROUP_ORDER: TabGroup[] = [ "new", "processing", - "post_processing", "collection_pool", "collected", "completed", @@ -128,39 +126,42 @@ export const TAB_GROUP_ORDER: TabGroup[] = [ export const TAB_GROUP_ITEM_STATUSES: Record = { new: ["pending", "approval_needed", "approval_rejected"], processing: ["processed", "po_generated"], - // Items where a delivery note has been generated but the operator - // hasn't yet run them through the rest of the post-collection - // flow. Until 2026-08-13 these sat on "processing", but that tab - // was getting crowded with rows that are *past* the PO stage — - // the DN has been issued, the supplier has confirmed, the buyer - // just needs to actually finish invoicing/collecting. Splitting - // them out into "post_processing" makes the operator's Triage - // view (what needs me?) honest: processing = needs a PO, - // post_processing = needs collection/invoice/admin to be wrapped. - post_processing: ["delivery_note"], - // Pre-pickup flow — collection_pool, assigned, AND ready_delivery + // `delivery_note` used to live in its own "post_processing" tab + // ("DN issued, not yet finalised"). That tab was retired on + // 2026-10-26: a DN is not a stage of its own any more - it is a + // line that has been picked up and is on its way to settled, so it + // counts as done work for the manager's view. The tab went with it + // (it would otherwise sit permanently at zero). + // + // Pre-pickup flow - collection_pool, assigned, AND ready_delivery // (supplier confirmed ready for pickup) all live here. ready_delivery // used to be lumped under "collected" which made it look like the // item had already been picked up by the driver, but it sits // *before* the pool, not after. collection_pool: ["collection_pool", "assigned", "ready_delivery"], // Post-pickup, pre-terminal: stock verified, item picked, awaiting - // invoice, invoice issued, in transit with the driver, and the - // downstream stock-booked / checked_in / completed steps all live - // here. The operator finishes the buyer's workflow at this point; - // `delivered` is the sales/delivery confirmation. + // invoice, invoice issued, in transit with the driver. The operator + // finishes the buyer's workflow at this point; `delivered` is the + // sales/delivery confirmation. collected: ["collected", "stock_verified", "picking", "ready_invoice", "invoiced", - "stock_booked", "with_driver", "checked_in"], - // "Recently completed" — orders where every line item is in the - // "all done from the buyer's perspective" state (`delivered` or - // `completed`). Time-windowed to the last 30 days so the tab - // self-clears: orders older than the window fall through to the - // delivered/cancelled buckets on `/orders/archive`. The window - // is applied at the service layer (see `completedTabFilter` in - // orders-service.ts) — the partition here is just the set of - // item statuses that *qualify*, not the freshness check. - completed: ["delivered", "completed"], + "stock_booked", "with_driver"], + // "Recently completed" - lines that have reached a settled state: + // delivered, completed, checked in (goods physically received), + // invoiced, or a delivery note issued. Time-windowed so the tab + // self-clears; the window is applied at the service layer (see + // `completedTabFilter` in orders-service.ts) - the partition here is + // just the set of statuses that *qualify*, not the freshness check. + // + // `invoiced` and `delivery_note` also appear in the `collected` + // group. That is deliberate: `itemStatusToTabGroup` resolves an + // ambiguous status to the FIRST group in `TAB_GROUP_ORDER`, so + // these land on `collected` (the workflow tab the office works + // from) while the completed tab still sees them through its own + // windowed filter. An order is allowed to appear on more than one + // tab - `delivered` has always worked that way. + completed: ["delivered", "completed", "checked_in", + "delivery_note", "invoiced"], delivered: ["delivered"], // `removed` joins `cancelled` — items struck from an order live with // the other non-completions so the active workflow tabs don't carry @@ -213,13 +214,8 @@ export type TabGroupVisual = { export const TAB_GROUP_VISUAL: Record = { new: { hex: "#2563eb", label: "New" }, processing: { hex: "#b45309", label: "Processing" }, - // Distinct enough from processing (amber) and collection_pool - // (purple) to read at a glance: the operator should see a row - // here and immediately think "DN issued, not yet finalised". - // The dark-orange hex mirrors the per-item `delivery_note` pill - // (`#7c2d12` in `ITEM_STATUS_VISUAL`) so the two stay in the same - // warm family. - post_processing: { hex: "#7c2d12", label: "Post-Processing" }, + // Purple for the pre-pickup bucket: the order is with the supplier + // (or waiting for the driver) and nothing is happening on our side. collection_pool: { hex: "#7c3aed", label: "Collection Pool" }, collected: { hex: "#0f766e", label: "Collected" }, // Distinct from `delivered` (#047857) so the operator can tell at @@ -231,6 +227,147 @@ export const TAB_GROUP_VISUAL: Record = { cancelled: { hex: "#e11d48", label: "Cancelled" }, }; +/* ============================================================================ + * Settled statuses — "this line is finished" + * ========================================================================== */ + +/** + * The statuses that mean a line has reached the end of its journey. + * + * `delivered` and `completed` were the original pair. `checked_in` + * (goods physically received) and `invoiced` (billed) joined them on + * 2026-10-26 because the business stopped writing `delivered` on + * 2026-08-14 — the live pipeline now ends at `invoiced` / + * `checked_in`, and a completed-tab that only looked at `delivered` + * matched zero orders for six weeks. + * + * `delivery_note` (a DN issued against the order) joins them for the + * same reason: it is a line that has been picked up and is on its way + * to settled, not a stage that needs its own tab. + */ +export const SETTLED_STATUSES: ItemStatus[] = [ + "delivered", + "completed", + "checked_in", + "invoiced", + "delivery_note", +]; + +/** + * Which PB date field carries the "when did this line finish" stamp, + * per settled status. + * + * The choice matters because the fields are NOT equally populated on + * live data (measured 2026-10-26, 12,957 delivered lines): + * + * - `delivered_at` — written by `markOrderDelivered`. Populated on + * 2 lines. The other 12,955 are legacy rows + * whose status was set without a stamp. + * - `invoiced_date` — 2,199 lines. The ONLY properly stamped + * settlement date in the schema. + * - `updated` — set on every write. A safe fallback: it is + * the moment the row last changed, which for a + * settled line is at least close to when it + * settled. + * + * A `null` entry means "this status has no dedicated stamp — use + * `updated`". `delivered` lists BOTH because its dedicated field is + * mostly empty and its fallback is the only thing that works. + */ +export type SettledDateSpec = { + /** + * The status has a DEDICATED stamp field. The row counts as settled + * only when that field is set and falls inside the window. + * + * A row whose dedicated field is empty is NOT recent — it is + * un-dated, and un-dated belongs to the archive, not to "completed + * this week". (12,955 of the 12,957 delivered lines have an empty + * `delivered_at`; their `updated` is the migration timestamp, so + * treating them as recently settled would resurface archived work + * every time a row was touched.) + */ + primary?: string; + /** + * The status has NO dedicated stamp field in the schema, so `updated` + * — which PB bumps on every write — is the only signal available. + */ + fallback?: string; +}; + +export const SETTLED_DATE_FIELDS: Partial> = { + delivered: { primary: "delivered_at" }, + invoiced: { primary: "invoiced_date" }, + completed: { fallback: "updated" }, + checked_in: { fallback: "updated" }, + delivery_note: { fallback: "updated" }, +}; + +/* ============================================================================ + * Manager pipeline groups — the three buckets on /manager + * ========================================================================== */ + +/** + * The coarse pipeline a manager thinks in: NEW -> PROCESSING -> + * COMPLETED. This is deliberately coarser than `TAB_GROUP_ITEM_STATUSES` + * (which is the desk's eight-tab workflow view) — a manager opening the + * page on a phone wants "where is everything right now", not which of + * the eight workflow tabs a line happens to sit in. + * + * `COMPLETED` is time-windowed (see `completedTabFilter` in the server + * orders service); the other two are not. + */ +export type PipelineGroup = "new" | "processing" | "completed"; + +export const PIPELINE_GROUP_ORDER: PipelineGroup[] = [ + "new", + "processing", + "completed", +]; + +export const PIPELINE_GROUP_ITEM_STATUSES: Record = { + // Just raised, nobody has touched it yet. + new: ["pending", "approval_needed", "approval_rejected"], + // Everything in flight: PO'd, waiting for collection, with the + // driver, awaiting an invoice. + processing: [ + "processed", "po_generated", + "collection_pool", "assigned", "ready_delivery", + "collected", "stock_verified", "picking", + "ready_invoice", "stock_booked", "with_driver", + ], + // Settled lines. Only the ones that settled inside the window show. + completed: SETTLED_STATUSES, +}; + +export const PIPELINE_GROUP_VISUAL: Record< + PipelineGroup, + { hex: string; label: string } +> = { + new: { hex: "#2563eb", label: "New" }, + processing: { hex: "#b45309", label: "Processing" }, + completed: { hex: "#064e3b", label: "Completed" }, +}; + +/** + * Map an item status to its pipeline bucket. + * + * Returns `null` for `cancelled` and `removed` — a cancelled order is + * not part of the pipeline a manager is tracking, and it lives on + * /orders/archive. Every OTHER member of the `ItemStatus` union must + * be listed in `PIPELINE_GROUP_ITEM_STATUSES`; an unmapped status + * throws rather than silently vanishing from the manager's view. + */ +export function itemStatusToPipelineGroup(s: ItemStatus): PipelineGroup | null { + for (const group of PIPELINE_GROUP_ORDER) { + if (PIPELINE_GROUP_ITEM_STATUSES[group].includes(s)) return group; + } + if (s === "cancelled" || s === "removed") return null; + throw new Error( + `itemStatusToPipelineGroup: status "${s}" is not mapped to any pipeline group. ` + + `Add it to PIPELINE_GROUP_ITEM_STATUSES in src/lib/item-status-ui.ts.`, + ); +} + /* ============================================================================ * Shared utilities * ========================================================================== */ diff --git a/src/lib/server/completed-window.test.ts b/src/lib/server/completed-window.test.ts index 4d2b4af..168492d 100644 --- a/src/lib/server/completed-window.test.ts +++ b/src/lib/server/completed-window.test.ts @@ -3,7 +3,10 @@ import { COMPLETED_WINDOW_DAYS, completedWindowCutoff, completedTabFilter, + settledWindowCutoff, + settledWindowFilter, } from "./orders-service"; +import { SETTLED_STATUSES } from "../item-status-ui"; describe("completedWindowCutoff", () => { it("returns an ISO string COMPLETED_WINDOW_DAYS before `now`", () => { @@ -32,31 +35,76 @@ describe("completedWindowCutoff", () => { }); }); +describe("settledWindowCutoff", () => { + it("honours an arbitrary window length", () => { + // The manager's phone view shows 7 days where /orders shows 30. + const now = new Date("2026-08-23T12:00:00.000Z"); + expect(settledWindowCutoff(7, now)).toBe("2026-08-16T12:00:00.000Z"); + expect(settledWindowCutoff(5, now)).toBe("2026-08-18T12:00:00.000Z"); + }); +}); + describe("completedTabFilter", () => { - it("applies the time-windowed predicate for both terminal statuses", () => { + it("applies the time-windowed predicate to every settled status", () => { const now = new Date("2026-08-23T00:00:00.000Z"); const cutoff = completedWindowCutoff(now); const filter = completedTabFilter(now); - // delivered items are filtered by delivered_at, not updated, so - // older rows (delivered before the window) drop out. + // `delivered` and `invoiced` have a dedicated stamp, so they are + // dated on that column — and only counted when it is SET. + // `delivered_at` is empty on 12,955 of the 12,957 delivered lines + // on live PB, and their `updated` is the migration timestamp, so + // falling back to `updated` would resurface archived work. expect(filter).toContain( - `(item_status = "delivered" && delivered_at >= "${cutoff}")`, + `(item_status = "delivered" && delivered_at != "" && delivered_at >= "${cutoff}")`, ); - // completed items have no dedicated timestamp, so we use updated. + expect(filter).toContain( + `(item_status = "invoiced" && invoiced_date != "" && invoiced_date >= "${cutoff}")`, + ); + // The rest have no dedicated stamp column, so `updated` is the + // only signal available. expect(filter).toContain( `(item_status = "completed" && updated >= "${cutoff}")`, ); - // The two clauses must be OR-joined (PB doesn't have an IN-list - // for date predicates). + expect(filter).toContain( + `(item_status = "checked_in" && updated >= "${cutoff}")`, + ); + expect(filter).toContain( + `(item_status = "delivery_note" && updated >= "${cutoff}")`, + ); + // The clauses must be OR-joined (PB doesn't have an IN-list for + // date predicates). expect(filter).toContain(") || ("); }); + it("covers every settled status exactly once", () => { + const filter = completedTabFilter(new Date("2026-08-23T00:00:00.000Z")); + for (const status of SETTLED_STATUSES) { + const occurrences = filter.split(`item_status = "${status}"`).length - 1; + expect(occurrences).toBe(1); + } + }); + it("uses the supplied `now` so the filter is stable for tests", () => { const now = new Date("2026-07-01T00:00:00.000Z"); + const cutoff = completedWindowCutoff(now); + // Clause order follows SETTLED_STATUSES, so the filter string is + // stable and diffable. expect(completedTabFilter(now)).toBe( - `(item_status = "delivered" && delivered_at >= "${completedWindowCutoff(now)}")` + - ` || (item_status = "completed" && updated >= "${completedWindowCutoff(now)}")`, + `(item_status = "delivered" && delivered_at != "" && delivered_at >= "${cutoff}")` + + ` || (item_status = "completed" && updated >= "${cutoff}")` + + ` || (item_status = "checked_in" && updated >= "${cutoff}")` + + ` || (item_status = "invoiced" && invoiced_date != "" && invoiced_date >= "${cutoff}")` + + ` || (item_status = "delivery_note" && updated >= "${cutoff}")`, ); }); + + it("a shorter window produces a shorter cutoff (the manager's 7-day view)", () => { + const now = new Date("2026-07-01T00:00:00.000Z"); + const short = settledWindowFilter(7, now); + const long = settledWindowFilter(30, now); + expect(short).toContain("2026-06-24T00:00:00.000Z"); + expect(long).toContain("2026-06-01T00:00:00.000Z"); + expect(short).not.toBe(long); + }); }); diff --git a/src/lib/server/manager-orders-service.test.ts b/src/lib/server/manager-orders-service.test.ts new file mode 100644 index 0000000..703616e --- /dev/null +++ b/src/lib/server/manager-orders-service.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_MANAGER_WINDOW, + MANAGER_COMPLETED_WINDOWS, + isManagerCompletedWindow, + pipelineGroupFilter, +} from "./manager-orders-service"; +import { settledWindowFilter } from "./orders-service"; +import { PIPELINE_GROUP_ITEM_STATUSES } from "../item-status-ui"; + +describe("MANAGER_COMPLETED_WINDOWS", () => { + it("offers 5 / 7 / 14 / 30 days, defaulting to 7", () => { + // The manager asked for "the last week"; 5 and 14 and 30 are there + // for the days a weekly view is too tight or too loose. + expect([...MANAGER_COMPLETED_WINDOWS]).toEqual([5, 7, 14, 30]); + expect(DEFAULT_MANAGER_WINDOW).toBe(7); + }); + + it("rejects a window outside the list", () => { + expect(isManagerCompletedWindow(7)).toBe(true); + expect(isManagerCompletedWindow(6)).toBe(false); + expect(isManagerCompletedWindow(0)).toBe(false); + expect(isManagerCompletedWindow("7")).toBe(false); + expect(isManagerCompletedWindow(undefined)).toBe(false); + }); +}); + +describe("pipelineGroupFilter", () => { + it("emits a plain status filter for the new and processing groups", () => { + const filter = pipelineGroupFilter("new", 7); + expect(filter).toContain(`item_status = "pending"`); + expect(filter).toContain(`item_status = "approval_needed"`); + expect(filter).toContain(" || "); + // Not time-windowed — a brand-new order is new whenever it was + // raised. + expect(filter).not.toContain(">="); + }); + + it("keeps the processing filter to the in-flight statuses", () => { + const filter = pipelineGroupFilter("processing", 7); + expect(filter).toContain(`item_status = "po_generated"`); + expect(filter).toContain(`item_status = "collection_pool"`); + expect(filter).toContain(`item_status = "with_driver"`); + // Settled statuses must NOT be in the processing bucket — an order + // would then show up in two buckets at once. + expect(filter).not.toContain(`item_status = "invoiced"`); + expect(filter).not.toContain(`item_status = "checked_in"`); + expect(filter).not.toContain(`item_status = "delivery_note"`); + }); + + it("uses the shared settled-window filter for the completed group", () => { + // The manager's Completed tab and /orders' Completed tab must + // agree on what "settled" means — only the window length differs. + const now = new Date("2026-08-23T12:00:00.000Z"); + expect(pipelineGroupFilter("completed", 7, now)).toBe( + settledWindowFilter(7, now), + ); + }); + + it("carries a different cutoff for a different window length", () => { + const seven = pipelineGroupFilter("completed", 7); + const thirty = pipelineGroupFilter("completed", 30); + expect(seven).not.toBe(thirty); + }); + + it("covers every pipeline group's statuses exactly once", () => { + for (const group of ["new", "processing"] as const) { + const filter = pipelineGroupFilter(group, 7); + for (const status of PIPELINE_GROUP_ITEM_STATUSES[group]) { + expect(filter).toContain(`item_status = "${status}"`); + } + } + }); +}); diff --git a/src/lib/server/manager-orders-service.ts b/src/lib/server/manager-orders-service.ts new file mode 100644 index 0000000..29d753a --- /dev/null +++ b/src/lib/server/manager-orders-service.ts @@ -0,0 +1,613 @@ +import { getAdminPocketBase } from "./pocketbase-admin"; +import { settledWindowFilter } from "./orders-service"; +import { + PIPELINE_GROUP_ITEM_STATUSES, + PIPELINE_GROUP_ORDER, + getItemStatusVisual, + type PipelineGroup, +} from "../item-status-ui"; +import { + readItemInvoiceNumber, + summarizeOrderInvoices, + type InvoiceBearingItem, +} from "../order-invoices"; +import { formatZAR } from "../pdf/format"; +import { + buildOrderTimeline, + type InvoiceRef, + type PreviewLine, + type PreviewRow, + type PreviewStat, + type StatusChip, + type TimelineEvent, + type UniversalHit, +} from "./universal-search-service"; + +/** + * Read model for the manager's phone view at `/manager`. + * + * The desk's `/orders` page is a workflow tool: eight tabs, one line + * per row, deep pricing detail. A manager opening a link on a phone + * wants three questions answered in one screen — what is NEW, what is + * in PROCESSING, and what finished COMPLETED this week. + * + * Two things make this cheaper than it looks: + * + * 1. The three pipeline groups are a COARSE view of the same eight + * workflow tabs (`PIPELINE_GROUP_ITEM_STATUSES` in + * `item-status-ui.ts`). One source of truth for the taxonomy — + * the two surfaces can never disagree about what a status means. + * + * 2. "Settled" is defined once, in `item-status-ui.ts` + * (`SETTLED_STATUSES` + `SETTLED_DATE_FIELDS`), and the window + * filter is shared with `/orders` (`settledWindowFilter`). Only + * the window LENGTH differs: 7 days here, 30 there. + * + * The returned rows are `UniversalHit`s — the same shape the universal + * search returns — so the manager page can hand them straight to the + * existing `SearchPreviewDialog` and get the identical quick-overview + * popup the desk already knows, with no second preview UI to build. + */ + +/** The window lengths the manager page offers. 7 days is the default. */ +export const MANAGER_COMPLETED_WINDOWS = [5, 7, 14, 30] as const; +export type ManagerCompletedWindow = (typeof MANAGER_COMPLETED_WINDOWS)[number]; +export const DEFAULT_MANAGER_WINDOW: ManagerCompletedWindow = 7; + +export function isManagerCompletedWindow(v: unknown): v is ManagerCompletedWindow { + return ( + typeof v === "number" && + (MANAGER_COMPLETED_WINDOWS as readonly number[]).includes(v) + ); +} + +/** One order, shaped for the preview dialog. */ +export type ManagerOrderHit = UniversalHit & { + /** The pipeline bucket this order was listed under. */ + group: PipelineGroup; +}; + +export interface ManagerOrdersData { + group: PipelineGroup; + windowDays: number; + /** Rows for the requested page. */ + orders: ManagerOrderHit[]; + /** Distinct order count per pipeline group. */ + counts: Record; + /** Distinct orders in the requested group (after the search filter). */ + total: number; + page: number; + perPage: number; + totalPages: number; + generatedAt: string; +} + +/* ============================================================================ + * Cache + * ========================================================================== */ + +/** + * The candidate order set for a group changes only when a line moves, + * so it is cached briefly. Two caches, because they change at + * different rates: + * + * - `groupCache`: the order ids in each group + the statuses on their + * lines. Rebuilt every 60s (or when a group's window changes). + * - `partyCache`: customer and company id -> name. Rebuilt every + * 10 minutes — names change far less often than statuses. + * + * Without the first, every page load would scan all 17k order_items. + */ +const GROUP_CACHE_TTL_MS = 60_000; +const PARTY_CACHE_TTL_MS = 10 * 60_000; + +type GroupCacheEntry = { + /** order id -> the statuses that put it in this group. */ + orders: Map; + expiresAt: number; +}; + +const groupCache = new Map(); + +type PartyCacheEntry = { + customers: Map; + companies: Map; + expiresAt: number; +}; + +let partyCache: PartyCacheEntry | null = null; + +export function clearManagerOrdersCache(): void { + groupCache.clear(); + partyCache = null; +} + +/* ============================================================================ + * PB helpers + * ========================================================================== */ + +type AdminPocketBase = Awaited>; + +const ORDER_COLLECTION = "orders"; +const ORDER_ITEMS_COLLECTION = "order_items"; + +/** + * Safe ceiling on ids in one inline `id = "..." || ...` filter. + * Live PB rejects the filter above ~106 ids (~3.5 KB) with HTTP 400. + */ +const ID_FILTER_CHUNK_SIZE = 80; + +/** + * The PB filter that selects the lines belonging to a pipeline group. + * + * `completed` is time-windowed (a line only counts if it SETTLED inside + * the window); the other two groups are plain status sets. + */ +export function pipelineGroupFilter( + group: PipelineGroup, + windowDays: number, + now: Date = new Date(), +): string { + if (group === "completed") return settledWindowFilter(windowDays, now); + return PIPELINE_GROUP_ITEM_STATUSES[group] + .map((s) => `item_status = "${s}"`) + .join(" || "); +} + +/** + * The order ids in a group, with the statuses that put them there. + * + * One fields-only scan over `order_items` — the group is defined by + * line status, so there is no way to ask the `orders` collection for + * it directly. + */ +async function fetchGroupOrders( + pb: AdminPocketBase, + group: PipelineGroup, + windowDays: number, + now: Date = new Date(), +): Promise> { + const cacheKey = `${group}:${windowDays}`; + const cached = groupCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.orders; + + const rows = await pb + .collection(ORDER_ITEMS_COLLECTION) + .getFullList<{ order?: string; item_status?: string }>({ + filter: pipelineGroupFilter(group, windowDays, now), + fields: "order,item_status", + batch: 5000, + }); + + const orders = new Map(); + for (const row of rows) { + const id = row.order; + if (!id) continue; + const list = orders.get(id); + const status = row.item_status ?? ""; + if (list) list.push(status); + else orders.set(id, [status]); + } + + groupCache.set(cacheKey, { orders, expiresAt: Date.now() + GROUP_CACHE_TTL_MS }); + return orders; +} + +/** The order ids in EVERY group — used for the tab badges. */ +async function fetchAllGroupCounts( + pb: AdminPocketBase, + windowDays: number, + now: Date = new Date(), +): Promise> { + const entries = await Promise.all( + PIPELINE_GROUP_ORDER.map(async (group) => { + const orders = await fetchGroupOrders(pb, group, windowDays, now); + return [group, orders.size] as const; + }), + ); + return Object.fromEntries(entries) as Record; +} + +/** + * Customer id -> name and company id -> name. + * + * Fetched whole rather than expanded per order: the manager page + * expands only the ~25 orders on the current page, but it needs the + * names of every candidate order to filter a search by customer. + */ +async function fetchPartyNames(pb: AdminPocketBase): Promise { + if (partyCache && partyCache.expiresAt > Date.now()) return partyCache; + + const [customers, companies] = await Promise.all([ + pb + .collection("customers") + .getFullList<{ id: string; customer?: string }>({ + fields: "id,customer", + batch: 1000, + }), + pb + .collection("companies") + .getFullList<{ id: string; company?: string }>({ + fields: "id,company", + batch: 1000, + }), + ]); + + const entry: PartyCacheEntry = { + customers: new Map( + customers.map((c) => [c.id, String(c.customer ?? "").trim()]), + ), + companies: new Map( + companies.map((c) => [c.id, String(c.company ?? "").trim()]), + ), + expiresAt: Date.now() + PARTY_CACHE_TTL_MS, + }; + partyCache = entry; + return entry; +} + +/** The columns the manager list needs from `orders` — no relations. */ +const LIGHT_ORDER_FIELDS = + "id,order_number,customer,company,contact_name,rep,created,updated,description"; + +type LightOrder = { + id: string; + order_number?: string; + customer?: string; + company?: string; + contact_name?: string; + rep?: string; + created?: string; + updated?: string; + description?: string; +}; + +/** + * Fetch the orders for a set of ids, WITHOUT expanding relations. + * + * The candidate set can be ~700 orders (every order with a line in + * flight). Expanding customer + company + rep on all of them would + * cost a megabyte of JSON per refresh; the manager list only needs + * eight columns per order to sort, search and slice. The page slice + * (25 orders) is re-fetched WITH the expand afterwards. + */ +async function fetchOrdersLight( + pb: AdminPocketBase, + ids: string[], +): Promise { + if (ids.length === 0) return []; + const chunks: string[][] = []; + for (let i = 0; i < ids.length; i += ID_FILTER_CHUNK_SIZE) { + chunks.push(ids.slice(i, i + ID_FILTER_CHUNK_SIZE)); + } + + const results = await Promise.all( + chunks.map((chunk) => + pb.collection(ORDER_COLLECTION).getList(1, chunk.length, { + filter: chunk.map((id) => `id = "${id.replace(/"/g, '\\"')}"`).join(" || "), + fields: LIGHT_ORDER_FIELDS, + }), + ), + ); + return results.flatMap((r) => r.items); +} + +/** Fetch the page slice WITH relations expanded. */ +async function fetchOrdersExpanded( + pb: AdminPocketBase, + ids: string[], +): Promise[]> { + if (ids.length === 0) return []; + const chunks: string[][] = []; + for (let i = 0; i < ids.length; i += ID_FILTER_CHUNK_SIZE) { + chunks.push(ids.slice(i, i + ID_FILTER_CHUNK_SIZE)); + } + const results = await Promise.all( + chunks.map((chunk) => + pb.collection(ORDER_COLLECTION).getList>( + 1, + chunk.length, + { + filter: chunk + .map((id) => `id = "${id.replace(/"/g, '\\"')}"`) + .join(" || "), + expand: "company,customer,customer.rep", + }, + ), + ), + ); + return results.flatMap((r) => r.items); +} + +/** Fetch every line of the orders on the current page. */ +async function fetchItemsForOrders( + pb: AdminPocketBase, + orderIds: string[], +): Promise[]> { + if (orderIds.length === 0) return []; + const chunks: string[][] = []; + for (let i = 0; i < orderIds.length; i += ID_FILTER_CHUNK_SIZE) { + chunks.push(orderIds.slice(i, i + ID_FILTER_CHUNK_SIZE)); + } + const results = await Promise.all( + chunks.map((chunk) => + pb.collection(ORDER_ITEMS_COLLECTION).getFullList>({ + // The line's PARENT order — `order_items.id` is the line's own + // id, not the order's. + filter: chunk.map((id) => `order = "${id}"`).join(" || "), + batch: 500, + }), + ), + ); + return results.flatMap((r) => r); +} + +/* ============================================================================ + * Row shaping + * ========================================================================== */ + +function num(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +function str(value: unknown): string { + return value === null || value === undefined ? "" : String(value).trim(); +} + +function clip(value: unknown, max = 160): string { + const s = str(value).replace(/\s+/g, " "); + return s.length > max ? `${s.slice(0, max - 1)}…` : s; +} + +function previewRows(rows: Array<[string, unknown]>): PreviewRow[] { + return rows + .map(([label, value]) => ({ label, value: str(value) })) + .filter((row) => row.value.length > 0); +} + +/** Status pills — one per distinct label, with a line count. */ +function statusChips(items: Record[]): StatusChip[] { + const byLabel = new Map(); + for (const item of items) { + const raw = str(item.item_status); + if (!raw) continue; + const visual = getItemStatusVisual(raw); + const label = visual.label === "Unknown" ? raw : visual.label; + const existing = byLabel.get(label); + if (existing) existing.count += 1; + else byLabel.set(label, { label, hex: visual.hex, count: 1 }); + } + return [...byLabel.values()]; +} + +/** `2026-10-12` -> `12 Oct 2026`, without touching `Date` (timezone-safe). */ +function formatDay(ymd: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(ymd); + if (!m) return ymd; + const months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + const month = months[Number(m[2]) - 1]; + return month ? `${Number(m[3])} ${month} ${m[1]}` : ymd; +} + +/* ============================================================================ + * The read model + * ========================================================================== */ + +/** + * Orders for the manager's phone view. + * + * `group` is the pipeline bucket to list; `windowDays` only affects the + * `completed` bucket (a line must have SETTLED inside the window). + */ +export async function getManagerOrdersOverview({ + group = "new", + windowDays = DEFAULT_MANAGER_WINDOW, + search = "", + page = 1, + perPage = 20, + now = new Date(), +}: { + group?: PipelineGroup; + windowDays?: number; + search?: string; + page?: number; + perPage?: number; + now?: Date; +} = {}): Promise { + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + + const safeWindow = isManagerCompletedWindow(windowDays) + ? windowDays + : DEFAULT_MANAGER_WINDOW; + const safePerPage = Math.min(50, Math.max(1, perPage)); + const safePage = Math.max(1, page); + const term = search.trim().toLowerCase(); + + const [groupOrders, counts, parties] = await Promise.all([ + fetchGroupOrders(pb, group, safeWindow, now), + fetchAllGroupCounts(pb, safeWindow, now), + fetchPartyNames(pb), + ]); + + const candidateIds = [...groupOrders.keys()]; + const lightOrders = await fetchOrdersLight(pb, candidateIds); + + // Search across the fields a manager would actually type: the order + // number, the customer, the company, the contact and the rep. Runs + // in memory over the light rows, so no extra PB round-trip. + const matching = lightOrders.filter((order) => { + if (!term) return true; + const customerName = parties.customers.get(order.customer ?? "") ?? ""; + const companyName = parties.companies.get(order.company ?? "") ?? ""; + return ( + order.order_number?.toLowerCase().includes(term) || + customerName.toLowerCase().includes(term) || + companyName.toLowerCase().includes(term) || + str(order.contact_name).toLowerCase().includes(term) || + str(order.rep).toLowerCase().includes(term) || + str(order.description).toLowerCase().includes(term) + ); + }); + + // Newest first — a manager asks "what's going on now?", and a stale + // order at the top of the list is worse than no order at the top. + matching.sort((a, b) => String(b.created ?? "").localeCompare(String(a.created ?? ""))); + + const total = matching.length; + const totalPages = Math.max(1, Math.ceil(total / safePerPage)); + const slice = matching.slice((safePage - 1) * safePerPage, safePage * safePerPage); + const pageIds = slice.map((o) => o.id); + + const [expandedOrders, allItems] = await Promise.all([ + fetchOrdersExpanded(pb, pageIds), + fetchItemsForOrders(pb, pageIds), + ]); + + const itemsByOrder = new Map[]>(); + for (const item of allItems) { + const orderId = str(item.order); + if (!orderId) continue; + const list = itemsByOrder.get(orderId); + if (list) list.push(item); + else itemsByOrder.set(orderId, [item]); + } + + const orders: ManagerOrderHit[] = []; + for (const order of expandedOrders) { + const orderId = str(order.id); + const items = itemsByOrder.get(orderId) ?? []; + + const customerName = parties.customers.get(str(order.customer)) || ""; + const companyName = parties.companies.get(str(order.company)) || ""; + const orderNumber = str(order.order_number) || orderId; + + // Value counts live lines only — a cancelled or struck line was + // never going to be billed, so including it overstates the order. + const liveItems = items.filter( + (i) => !["cancelled", "removed"].includes(str(i.item_status)), + ); + const orderValue = liveItems.reduce( + (sum, i) => sum + num(i.selling_price) * (num(i.quantity) || 1), + 0, + ); + const invoicedLines = items.filter((i) => + readItemInvoiceNumber(i as InvoiceBearingItem), + ).length; + const settledLines = items.filter((i) => + ["delivered", "completed", "checked_in", "invoiced", "delivery_note"].includes( + str(i.item_status), + ), + ).length; + + const invoiceSummary = summarizeOrderInvoices(items as InvoiceBearingItem[]); + const invoices: InvoiceRef[] = invoiceSummary.map((inv) => ({ + number: inv.number, + lines: inv.lineCount, + ...(inv.date ? { date: inv.date.slice(0, 10) } : {}), + ...(inv.qbInvoiceLink ? { href: inv.qbInvoiceLink } : {}), + })); + const deliveryNotes = [ + ...new Set( + items + .map((i) => str(i.delivery_note_number)) + .filter((n) => n.length > 0), + ), + ]; + + const stats: PreviewStat[] = [ + { label: "Lines", value: String(items.length) }, + { + label: "Value (excl.)", + value: orderValue > 0 ? formatZAR(orderValue) : "—", + }, + { + label: "Invoiced", + value: items.length ? `${invoicedLines} / ${items.length}` : "—", + }, + ]; + + const lines: PreviewLine[] = items.slice(0, 12).map((i) => { + const status = str(i.item_status); + const visual = status ? getItemStatusVisual(status) : null; + const price = num(i.selling_price); + const lineQty = num(i.quantity) || 1; + return { + description: clip(i.description, 90) || "(no description)", + quantity: num(i.quantity) > 0 ? String(num(i.quantity)) : undefined, + amount: price > 0 ? formatZAR(price * lineQty) : undefined, + status: visual ? (visual.label === "Unknown" ? status : visual.label) : undefined, + statusHex: visual?.hex, + invoice: readItemInvoiceNumber(i as InvoiceBearingItem) ?? undefined, + }; + }); + + const timeline: TimelineEvent[] = buildOrderTimeline(order, items, invoiceSummary); + + orders.push({ + id: orderId, + group, + title: orderNumber, + subtitle: companyName || customerName || str(order.description), + meta: `${items.length} line${items.length === 1 ? "" : "s"}`, + href: `/orders/${orderId}`, + preview: previewRows([ + ["Customer", customerName], + ["Company", companyName], + ["Contact", order.contact_name], + ["Rep", order.rep], + ["Settled lines", settledLines ? `${settledLines} of ${items.length}` : ""], + ["Delivery notes", deliveryNotes.join(", ")], + ["Notes", clip(order.notes, 140)], + ]), + statuses: statusChips(items), + stats, + invoices, + deliveryNotes, + lines, + lineTotal: items.length, + timeline, + createdAt: str(order.created).slice(0, 10) || undefined, + }); + } + + return { + group, + windowDays: safeWindow, + orders, + counts, + total, + page: safePage, + perPage: safePerPage, + totalPages, + generatedAt: now.toISOString(), + }; +} + +/** + * The date the group's newest order was raised — used by the page + * header ("newest order 3 hours ago"). Cheap: one light row. + */ +export async function getManagerOrdersFreshness(now: Date = new Date()): Promise { + const pb = await getAdminPocketBase(); + const newest = await pb + .collection(ORDER_COLLECTION) + .getList<{ updated?: string }>(1, 1, { sort: "-updated", fields: "updated" }); + const stamp = str(newest.items[0]?.updated); + if (!stamp) return ""; + const age = now.getTime() - Date.parse(stamp); + if (Number.isNaN(age)) return ""; + const minutes = Math.floor(age / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.floor(hours / 24); + return `${days} day${days === 1 ? "" : "s"} ago`; +} + +export { formatDay }; diff --git a/src/lib/server/orders-service.test.ts b/src/lib/server/orders-service.test.ts index b2aaf98..143eba8 100644 --- a/src/lib/server/orders-service.test.ts +++ b/src/lib/server/orders-service.test.ts @@ -35,11 +35,17 @@ const { store, pbMock, clearStore } = vi.hoisted(() => { // PB record id. Anything unrecognised passes through (returns all items // for the AND group), so unknown filters don't false-negative tests. const matchesClause = (item: Record, clause: string): boolean => { - const m = clause.match(/^(\w+)\s*(=~|!~|=|!=|>=|<=|<|>)\s*"?([^"]+?)"?$/); + // `([^"]*?)` (not `+?`) so an EMPTY quoted value parses — PB + // filters compare date fields against "" to test "not set". + const m = clause.match(/^(\w+)\s*(=~|!~|=|!=|>=|<=|<|>)\s*"?([^"]*?)"?$/); if (!m) return true; const [, field, op, raw] = m; const expected = raw.replace(/^"|"$/g, ""); const actual = item[field]; + // PB's `= null` means "the field is empty". A date field with no + // value is stored as "" (not null), so treat all three as empty. + const isEmpty = + actual === null || actual === undefined || actual === ""; if (field === "year" || field === "month") { const num = Number(actual); const want = Number(expected); @@ -68,8 +74,8 @@ const { store, pbMock, clearStore } = vi.hoisted(() => { } } switch (op) { - case "=": return actualStr === expected; - case "!=": return actualStr !== expected; + case "=": return expected === "null" ? isEmpty : actualStr === expected; + case "!=": return expected === "null" ? !isEmpty : actualStr !== expected; case "=~": return actualStr.includes(expected); case "!~": return !actualStr.includes(expected); default: return true; diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index b039f17..352f25c 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -13,6 +13,8 @@ import { suggestDeliveryNoteForOrder } from "./order-delivery-note-link-service" import { suggestOrderNumberDuplicate } from "./order-number-duplicate-link-service"; import { suggestOrderDuplicate } from "./order-order-duplicate-link-service"; import { + SETTLED_DATE_FIELDS, + SETTLED_STATUSES, TAB_GROUP_ITEM_STATUSES, TAB_GROUP_ORDER, itemStatusToTabGroup, @@ -109,34 +111,71 @@ export const COMPLETED_WINDOW_DAYS = 30; * without coupling to wall-clock time. */ export function completedWindowCutoff(now: Date = new Date()): string { - const cutoff = new Date(now.getTime() - COMPLETED_WINDOW_DAYS * 24 * 60 * 60 * 1000); + return settledWindowCutoff(COMPLETED_WINDOW_DAYS, now); +} + +/** + * Cutoff for an arbitrary settlement window, in days. The manager's + * phone view (`/manager`) shows a 7-day window where /orders shows 30 — + * same definition of "settled", different length. + */ +export function settledWindowCutoff(days: number, now: Date = new Date()): string { + const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); return cutoff.toISOString(); } /** * PB filter fragment for the `completed` tab. * - * Splits the qualifying statuses (`delivered` + `completed`) on - * the best available terminal-state timestamp: - * - `delivered` items use the dedicated `delivered_at` field, - * stamped by `markOrderDelivered` when the operator flips the - * row. - * - `completed` items use `updated` because no `completed_at` - * field exists on the schema — `updated` is set on every write - * so it reflects the moment the row transitioned to `completed`. + * Matches a line whose status has settled (`SETTLED_STATUSES`) AND whose + * settlement stamp falls inside the window. + * + * The stamp is per-status (`SETTLED_DATE_FIELDS`) because the schema has + * no single "finished at" column: + * - `invoiced` uses `invoiced_date` — the only properly stamped + * settlement field in the schema (2,199 lines on live PB). + * - `delivered` uses `delivered_at` OR `updated`. `delivered_at` is + * written by `markOrderDelivered` but is populated on only 2 of the + * 12,957 delivered lines; `updated` catches the legacy rows. + * - `completed` / `checked_in` / `delivery_note` use `updated` — + * there is no dedicated stamp, and `updated` is set on every write. * * The OR-of-ANDs shape is required: PB can't mix status and date - * predicates inside a single clause without parentheses, and - * grouping is what lets `delivered_at >= cutoff` apply only to - * `delivered` rows (otherwise `completed` rows with a null - * `delivered_at` would fail the comparison). + * predicates inside a single clause without parentheses, and grouping + * is what lets the date predicate apply only to the rows of that + * status. */ export function completedTabFilter(now: Date = new Date()): string { - const cutoff = completedWindowCutoff(now); - return [ - `(item_status = "delivered" && delivered_at >= "${cutoff}")`, - `(item_status = "completed" && updated >= "${cutoff}")`, - ].join(" || "); + return settledWindowFilter(COMPLETED_WINDOW_DAYS, now); +} + +/** + * The settled-line filter for an arbitrary window (in days). + * + * Shared by /orders (30 days) and the manager's /manager page + * (7 days) so both surfaces agree on what "settled" means — only the + * window length differs. + */ +export function settledWindowFilter(days: number, now: Date = new Date()): string { + const cutoff = settledWindowCutoff(days, now); + const clauses: string[] = []; + for (const status of SETTLED_STATUSES) { + const spec = SETTLED_DATE_FIELDS[status]; + if (!spec) continue; + if (spec.primary) { + // The status has a dedicated stamp — the row counts only when + // that stamp is set AND falls inside the window. `!= ""` is PB's + // "the field has a value" test for a date column. + clauses.push( + `(item_status = "${status}" && ${spec.primary} != "" && ${spec.primary} >= "${cutoff}")`, + ); + } else if (spec.fallback) { + // No dedicated stamp exists for this status, so `updated` — the + // only timestamp the row carries — is the signal. + clauses.push(`(item_status = "${status}" && ${spec.fallback} >= "${cutoff}")`); + } + } + return clauses.join(" || "); } /** Zero-filled per-tab counts. Spread it; never mutate the original. */