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 (
+
+
+ {/* ---------------------------------------------------------------- */}
+ {/* 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}”`}
+
+ {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."}
+
+ );
+}
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