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:
parent
55cc7fa2f6
commit
b212d0fcd8
20 changed files with 2128 additions and 77 deletions
62
AGENTS.md
62
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 `<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
101
docs/manager-orders.md
Normal 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.
|
||||
31
public/manifest-manager.json
Normal file
31
public/manifest-manager.json
Normal 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
80
public/sw-manager.js
Normal 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;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
29
src/app/(mobile)/manager/auth-gate.tsx
Normal file
29
src/app/(mobile)/manager/auth-gate.tsx
Normal 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}</>;
|
||||
}
|
||||
42
src/app/(mobile)/manager/layout.tsx
Normal file
42
src/app/(mobile)/manager/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
513
src/app/(mobile)/manager/manager-orders-view.tsx
Normal file
513
src/app/(mobile)/manager/manager-orders-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/app/(mobile)/manager/page.tsx
Normal file
7
src/app/(mobile)/manager/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import ManagerOrdersView from "./manager-orders-view";
|
||||
|
||||
export default function ManagerOrdersPage() {
|
||||
return <ManagerOrdersView />;
|
||||
}
|
||||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
98
src/app/api/v1/manager-orders/route.ts
Normal file
98
src/app/api/v1/manager-orders/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
168
src/lib/item-status-ui.settled.test.ts
Normal file
168
src/lib/item-status-ui.settled.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
* ========================================================================== */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
74
src/lib/server/manager-orders-service.test.ts
Normal file
74
src/lib/server/manager-orders-service.test.ts
Normal 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}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
613
src/lib/server/manager-orders-service.ts
Normal file
613
src/lib/server/manager-orders-service.ts
Normal 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 };
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue