feat(manager): a mobile orders board for the managers at /manager

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 `<Sidebar />` 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.
This commit is contained in:
Dave 2026-10-26 23:09:01 +02:00
parent 55cc7fa2f6
commit b212d0fcd8
20 changed files with 2128 additions and 77 deletions

View file

@ -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 `<Sidebar />` 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.

101
docs/manager-orders.md Normal file
View file

@ -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 `<Sidebar />` 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=<order no|customer|company|rep>
&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.

View file

@ -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"
}
]
}

80
public/sw-manager.js Normal file
View file

@ -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;
})
);
});

View file

@ -82,7 +82,6 @@ import type { OrderItem, ItemStatus } from "@/lib/types";
const EMPTY_COUNTS: Record<TabGroup, number> = {
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.

View file

@ -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 (
<div className="min-h-screen grid place-items-center bg-background">
<div className="h-8 w-8 rounded-full border-[3px] border-primary border-t-transparent animate-spin" />
</div>
);
}
if (!user) return null;
return <>{children}</>;
}

View file

@ -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 (
<MobileAuthGate>
<main className="min-h-screen bg-background text-foreground antialiased">
{children}
</main>
</MobileAuthGate>
);
}

View file

@ -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<string, number>;
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<PipelineGroup>("new");
const [windowDays, setWindowDays] = useState<number>(7);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [data, setData] = useState<ManagerResponse | null>(null);
const [rows, setRows] = useState<ManagerHit[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [counts, setCounts] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
const [previewHit, setPreviewHit] = useState<SearchHit | null>(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 (
<div className="mx-auto max-w-md px-4 py-4 pb-24 space-y-4">
{/* ---------------------------------------------------------------- */}
{/* Header */}
{/* ---------------------------------------------------------------- */}
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight">Orders</h1>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
error
? "bg-amber-400 animate-pulse"
: loading
? "bg-muted-foreground/50"
: "bg-emerald-400"
}`}
aria-hidden="true"
/>
<span>
{error
? "Couldn't refresh"
: loading
? "Loading…"
: lastRefreshed
? `Updated ${timeAgo(lastRefreshed)}`
: "Loading…"}
</span>
</div>
</div>
<button
type="button"
onClick={refresh}
disabled={loading}
aria-label="Refresh"
className="grid h-10 w-10 shrink-0 place-items-center rounded-full border border-border/60 active:bg-muted disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
</header>
{/* ---------------------------------------------------------------- */}
{/* Pipeline tabs */}
{/* ---------------------------------------------------------------- */}
<div
className="flex gap-1 rounded-xl bg-muted/30 p-1"
role="tablist"
aria-label="Order pipeline"
>
{PIPELINE_GROUP_ORDER.map((key) => {
const visual = PIPELINE_GROUP_VISUAL[key];
const active = group === key;
const count = counts0[key];
return (
<button
key={key}
type="button"
role="tab"
aria-selected={active}
onClick={() => selectGroup(key)}
className={`flex-1 rounded-lg px-2 py-2 text-sm font-medium transition-colors ${
active
? "bg-background shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<span
className="block truncate"
style={active ? { color: visual.hex } : undefined}
>
{visual.label}
</span>
<span className="block text-xs tabular-nums text-muted-foreground">
{count === undefined ? "—" : count}
</span>
</button>
);
})}
</div>
{/* ---------------------------------------------------------------- */}
{/* Search */}
{/* ---------------------------------------------------------------- */}
<div className="relative">
<SearchIcon
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Order no, customer, company, rep…"
aria-label="Search orders"
className="h-11 w-full rounded-xl border bg-card pl-9 pr-9 text-base outline-none focus:ring-2 focus:ring-ring/40"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
aria-label="Clear search"
className="absolute right-2 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-full text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* ---------------------------------------------------------------- */}
{/* Completed window — only meaningful for the Completed bucket. */}
{/* ---------------------------------------------------------------- */}
{group === "completed" && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Settled in
</span>
<div className="flex gap-1 rounded-lg bg-muted/30 p-0.5">
{WINDOW_OPTIONS.map((days) => (
<button
key={days}
type="button"
onClick={() => selectWindow(days)}
aria-pressed={windowDays === days}
className={`rounded-md px-2.5 py-1 text-xs font-medium tabular-nums transition-colors ${
windowDays === days
? "bg-background shadow-sm"
: "text-muted-foreground"
}`}
>
{days}d
</button>
))}
</div>
</div>
)}
{/* ---------------------------------------------------------------- */}
{/* Scope line — the window must be visible, or a miss looks like a */}
{/* bug rather than an age-out. */}
{/* ---------------------------------------------------------------- */}
{data && (
<p className="text-xs text-muted-foreground">
{total} order{total === 1 ? "" : "s"}
{group === "completed" && ` settled in the last ${windowDays} days`}
{debouncedSearch && ` matching “${debouncedSearch}”`}
</p>
)}
{/* ---------------------------------------------------------------- */}
{/* List */}
{/* ---------------------------------------------------------------- */}
{loading && rows.length === 0 ? (
<div className="space-y-2">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="h-24 animate-pulse rounded-xl border bg-muted/30"
/>
))}
</div>
) : rows.length === 0 ? (
<div className="rounded-xl border border-border/60 bg-card/50 p-8 text-center">
<p className="text-sm font-medium">Nothing here</p>
<p className="mt-1 text-sm text-muted-foreground">
{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."}
</p>
</div>
) : (
<ul className="space-y-2">
{rows.map((hit) => (
<li key={hit.id}>
<button
type="button"
onClick={() => setPreviewHit(hit)}
className="w-full rounded-xl border bg-card p-3 text-left transition-colors active:bg-muted/60"
>
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-mono text-sm font-semibold">
{hit.title}
</span>
<span className="shrink-0 text-sm font-medium tabular-nums">
{statValue(hit, "Value (excl.)")}
</span>
</div>
{hit.subtitle && (
<p className="mt-0.5 truncate text-sm text-muted-foreground">
{hit.subtitle}
</p>
)}
{hit.statuses && hit.statuses.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{hit.statuses.slice(0, 5).map((chip) => (
<span
key={chip.label}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold text-white"
style={{ backgroundColor: chip.hex }}
>
{chip.label}
{chip.count > 1 && (
<span className="rounded-full bg-white/25 px-1 tabular-nums">
{chip.count}
</span>
)}
</span>
))}
{hit.statuses.length > 5 && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[11px] font-semibold text-muted-foreground">
+{hit.statuses.length - 5} more
</span>
)}
</div>
)}
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{statValue(hit, "Invoiced")} invoiced
</span>
{hit.invoices && hit.invoices.length > 0 ? (
<span className="truncate font-mono">
{hit.invoices[0].number}
{hit.invoices.length > 1 && ` +${hit.invoices.length - 1}`}
</span>
) : hit.deliveryNotes && hit.deliveryNotes.length > 0 ? (
<span className="truncate font-mono">
DN {hit.deliveryNotes[0]}
</span>
) : null}
</div>
</button>
</li>
))}
</ul>
)}
{page < totalPages && rows.length > 0 && (
<button
type="button"
onClick={loadMore}
disabled={loadingMore}
className="w-full rounded-xl border bg-card py-3 text-sm font-medium disabled:opacity-50"
>
{loadingMore ? "Loading…" : `Load more (${page} of ${totalPages})`}
</button>
)}
{/* ---------------------------------------------------------------- */}
{/* Preview popup — the same component the desk's search uses. */}
{/* ---------------------------------------------------------------- */}
<SearchPreviewDialog
hit={previewHit}
groupType="order"
groupLabel="Order"
onClose={() => setPreviewHit(null)}
/>
</div>
);
}

View file

@ -0,0 +1,7 @@
"use client";
import ManagerOrdersView from "./manager-orders-view";
export default function ManagerOrdersPage() {
return <ManagerOrdersView />;
}

View file

@ -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",
];

View file

@ -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 });
}
}

View file

@ -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",

View file

@ -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";
}

View file

@ -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<string>();
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);
}
}
});
});

View file

@ -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<TabGroup, ItemStatus[]> = {
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<TabGroup, TabGroupVisual> = {
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<TabGroup, TabGroupVisual> = {
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<Record<ItemStatus, SettledDateSpec>> = {
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<PipelineGroup, ItemStatus[]> = {
// 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
* ========================================================================== */

View file

@ -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);
});
});

View file

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

View file

@ -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<PipelineGroup, number>;
/** 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<string, string[]>;
expiresAt: number;
};
const groupCache = new Map<string, GroupCacheEntry>();
type PartyCacheEntry = {
customers: Map<string, string>;
companies: Map<string, string>;
expiresAt: number;
};
let partyCache: PartyCacheEntry | null = null;
export function clearManagerOrdersCache(): void {
groupCache.clear();
partyCache = null;
}
/* ============================================================================
* PB helpers
* ========================================================================== */
type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;
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<Map<string, string[]>> {
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<string, string[]>();
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<Record<PipelineGroup, number>> {
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<PipelineGroup, number>;
}
/**
* 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<PartyCacheEntry> {
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<LightOrder[]> {
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<LightOrder>(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<Record<string, unknown>[]> {
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<Record<string, unknown>>(
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<Record<string, unknown>[]> {
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<Record<string, unknown>>({
// 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<string, unknown>[]): StatusChip[] {
const byLabel = new Map<string, StatusChip>();
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<ManagerOrdersData> {
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<string, Record<string, unknown>[]>();
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<string> {
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 };

View file

@ -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<string, unknown>, 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;

View file

@ -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. */