From 69175e493d111845d7480ef4fec1eb1a0d7a64cf Mon Sep 17 00:00:00 2001
From: SJCoetz
Date: Tue, 4 Aug 2026 08:50:54 +0200
Subject: [PATCH] feat(orders): show resolved sales rep name on order list and
sprint
The order list and order sprint pages were rendering the rep column
with a raw PocketBase record id, which the operator could not
recognize. The data layer now resolves the rep through the user's
name:
- New `resolveRepDisplayName` helper (`src/lib/order-rep.ts`) takes
the customer record's `expand.rep`, the `customer.rep` relation
id, the legacy `orders.rep` text snapshot, and an admin-resolved
user map, and returns a name (or empty when only an unresolved PB
id is present).
- The orders service now fetches the user list alongside the order
page/counts query and resolves the rep on the way out, so the
client receives a `repName` per row. New endpoints
`/api/v1/orders/list` and `/api/v1/orders/[id]/review` expose
the read model; the page and sprint calls go through them.
- The list card and the sprint table + mobile card render the
resolved name. An opaque PB id is never displayed.
Migrates `/orders` off `pb-helpers.ts` per the data-layer
discipline, and adds a shared browser-session helper for
`Authorization: Bearer ` validation in route handlers
that serve the dashboard UI (vs. the agent API).
Co-authored-by: Orca
---
src/app/(app)/orders/page.tsx | 174 +++++------
src/app/(app)/orders/sprint/page.tsx | 84 ++++-
src/app/api/v1/orders/[id]/review/route.ts | 23 ++
src/app/api/v1/orders/list/route.ts | 34 ++
src/lib/order-rep.test.ts | 38 +++
src/lib/order-rep.ts | 56 ++++
src/lib/server/browser-session.ts | 37 +++
src/lib/server/orders-service-review.test.ts | 174 ++++++++++-
src/lib/server/orders-service.ts | 309 ++++++++++++++++++-
9 files changed, 820 insertions(+), 109 deletions(-)
create mode 100644 src/app/api/v1/orders/[id]/review/route.ts
create mode 100644 src/app/api/v1/orders/list/route.ts
create mode 100644 src/lib/order-rep.test.ts
create mode 100644 src/lib/order-rep.ts
create mode 100644 src/lib/server/browser-session.ts
diff --git a/src/app/(app)/orders/page.tsx b/src/app/(app)/orders/page.tsx
index 9e98d7f..67c1047 100644
--- a/src/app/(app)/orders/page.tsx
+++ b/src/app/(app)/orders/page.tsx
@@ -21,15 +21,12 @@ import {
Search,
X,
} from "lucide-react";
-import {
- getOrdersWithItems,
- searchOrdersWithItems,
- getOrderItemStatusTabCounts,
- formatDate,
- formatCurrency,
- type OrderWithItems,
-} from "@/lib/pb-helpers";
+import getPocketBase from "@/lib/pocketbase";
import { isOrderComplete } from "@/lib/server/order-completion";
+import type {
+ OrdersDashboardData,
+ OrderWithItems,
+} from "@/lib/server/orders-service";
import { ItemStatusPill } from "@/components/item-status-pill";
import {
TAB_GROUP_VISUAL,
@@ -58,7 +55,7 @@ import type { OrderItem, ItemStatus } from "@/lib/types";
* Search matches across order_number, company name, customer name, any
* line item description / po_description, and (if the query is a plain
* number) any line's selling_price or cost_price. See
- * `searchOrdersWithItems` in pb-helpers.ts for the field set.
+ * `getOrdersDashboardData` in the server orders service for the field set.
* ========================================================================== */
const EMPTY_COUNTS: Record = {
@@ -72,6 +69,39 @@ const EMPTY_COUNTS: Record = {
const SEARCH_DEBOUNCE_MS = 250;
+const ZAR_FORMATTER = new Intl.NumberFormat("en-ZA", {
+ style: "currency",
+ currency: "ZAR",
+});
+
+function formatCurrency(amount: number): string {
+ return ZAR_FORMATTER.format(amount);
+}
+
+function formatDate(dateStr: string): string {
+ if (!dateStr) return "";
+ return new Date(dateStr).toLocaleDateString("en-ZA", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+async function fetchDashboardJson(url: string): Promise {
+ const token = getPocketBase().authStore.token;
+ if (!token) throw new Error("Your session has expired. Please sign in again.");
+
+ const response = await fetch(url, {
+ headers: { Authorization: token },
+ cache: "no-store",
+ });
+ const body = (await response.json()) as T & { error?: string };
+ if (!response.ok) {
+ throw new Error(body.error || `Request failed (${response.status})`);
+ }
+ return body;
+}
+
/**
* Convert the order's `order_attachements` file field into the
* `QuoteAttachment[]` shape the review modal expects. The PB file field
@@ -164,35 +194,15 @@ function OrdersReviewModalMount({
let cancelled = false;
(async () => {
try {
- const { getOrder, getOrderItems } = await import("@/lib/pb-helpers");
- // Use the BROWSER PB instance (not the admin one) — admin PB
- // requires server-only env vars and would break in the browser
- // bundle. The user's session PB already has read access to
- // customers, so we don't need privileges for the order-level
- // attachments (they live on the order record itself).
- const { getPocketBase } = await import("@/lib/pocketbase");
- const pb = getPocketBase();
- const [order, items, customersResult] = await Promise.all([
- getOrder(orderId),
- getOrderItems(orderId),
- pb.collection("customers").getFullList({
- sort: "-created",
- // 'rep' is needed by the Review Order modal's customer
- // cascade — it reads selectedCustomer.expand.rep.name to
- // populate the Rep label and (on save) writes it to
- // orders.rep. No other consumer reads c.expand.*.
- expand: "company,rep",
- }),
- ]);
+ const review = await fetchDashboardJson<{
+ order: import("@/lib/types").Order;
+ items: OrderItem[];
+ customers: import("@/lib/types").Customer[];
+ quoteEmail: { subject: string; body: string };
+ }>(`/api/v1/orders/${encodeURIComponent(orderId)}/review`);
if (cancelled) return;
- const quote = order.expand?.quote as
- | (import("@/lib/types").Quote & {
- id: string;
- email_subject: string;
- email_body: string;
- })
- | undefined;
+ const { order, items, customers, quoteEmail } = review;
// Source attachments from `order_attachements` — the multi-file
// field ON the order record itself (orders collection
@@ -215,8 +225,8 @@ function OrdersReviewModalMount({
// list happens to be empty after filtering.
const customer =
(order.expand?.customer as import("@/lib/types").Customer) ||
- customersResult.find((c) => c.id === order.customer) ||
- customersResult[0];
+ customers.find((c) => c.id === order.customer) ||
+ customers[0];
// Company can be null if the order's company record isn't loaded.
// The modal renders "(unknown)" in that case rather than fabricating
// a stub with the wrong ID.
@@ -228,11 +238,8 @@ function OrdersReviewModalMount({
items,
customer,
company,
- customers: customersResult as import("@/lib/types").Customer[],
- quoteEmail: {
- subject: quote?.email_subject || "",
- body: quote?.email_body || "",
- },
+ customers,
+ quoteEmail,
attachments,
});
} catch (err) {
@@ -306,32 +313,22 @@ export default function OrdersPage() {
/** ID of the order whose review modal is open. null = no modal. */
const [reviewOrderId, setReviewOrderId] = useState(null);
- const fetchCounts = useCallback(async () => {
- try {
- const per = await getOrderItemStatusTabCounts();
- setCounts({ ...EMPTY_COUNTS, ...per });
- } catch {
- // Soft fail: tabs show 0, the list still works.
- }
- }, []);
-
const fetchOrders = useCallback(async () => {
setLoading(true);
setError("");
try {
- // When searching, bypass pagination and load all matches via
- // the multi-field client-side search. Without a search term,
- // paginate as usual.
- if (search.trim()) {
- const items = await searchOrdersWithItems(search, "-created");
- // Pagination is intentionally suppressed during search.
- setOrders(items);
- setTotalPages(1);
- } else {
- const result = await getOrdersWithItems(page, 20, search, "-created");
- setOrders(result.items);
- setTotalPages(result.totalPages);
- }
+ const params = new URLSearchParams({
+ page: String(page),
+ perPage: "20",
+ });
+ if (search.trim()) params.set("search", search.trim());
+
+ const result = await fetchDashboardJson(
+ `/api/v1/orders/list?${params.toString()}`,
+ );
+ setOrders(result.items);
+ setTotalPages(result.totalPages);
+ setCounts({ ...EMPTY_COUNTS, ...result.counts });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to load orders";
setError(message);
@@ -345,11 +342,6 @@ export default function OrdersPage() {
fetchOrders();
}, [fetchOrders]);
- useEffect(() => {
- // eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
- fetchCounts();
- }, [fetchCounts]);
-
// Debounce the search input → 250ms after the last keystroke.
useEffect(() => {
const t = setTimeout(() => {
@@ -373,30 +365,21 @@ export default function OrdersPage() {
// 300ms debounce so a burst costs one refetch, not eight.
const realtimeTimer = useRef | null>(null);
useEffect(() => {
- let cancelled = false;
- (async () => {
- const { default: getPocketBase } = await import("@/lib/pocketbase");
- if (cancelled) return;
- const pb = getPocketBase();
- const handler = () => {
- if (realtimeTimer.current) clearTimeout(realtimeTimer.current);
- realtimeTimer.current = setTimeout(() => {
- fetchOrders();
- fetchCounts();
- }, 300);
- };
- pb.collection("orders").subscribe("*", handler);
- pb.collection("order_items").subscribe("*", handler);
- return () => {
- pb.collection("orders").unsubscribe("*");
- pb.collection("order_items").unsubscribe("*");
- };
- })();
+ const pb = getPocketBase();
+ const handler = () => {
+ if (realtimeTimer.current) clearTimeout(realtimeTimer.current);
+ realtimeTimer.current = setTimeout(() => {
+ fetchOrders();
+ }, 300);
+ };
+ pb.collection("orders").subscribe("*", handler);
+ pb.collection("order_items").subscribe("*", handler);
return () => {
- cancelled = true;
+ pb.collection("orders").unsubscribe("*");
+ pb.collection("order_items").unsubscribe("*");
if (realtimeTimer.current) clearTimeout(realtimeTimer.current);
};
- }, [fetchOrders, fetchCounts]);
+ }, [fetchOrders]);
const handleTabChange = (next: TabGroup) => {
setActiveTab(next);
@@ -529,11 +512,12 @@ export default function OrdersPage() {
)}
- {visibleOrders.map(({ order, items }) => (
+ {visibleOrders.map(({ order, items, repName }) => (
>
)}
- {order.rep && (
+ {repName && (
<>
·
- rep: {order.rep}
+ rep: {repName}
>
)}
diff --git a/src/app/(app)/orders/sprint/page.tsx b/src/app/(app)/orders/sprint/page.tsx
index e246ada..d1226ac 100644
--- a/src/app/(app)/orders/sprint/page.tsx
+++ b/src/app/(app)/orders/sprint/page.tsx
@@ -22,7 +22,8 @@ import { DevIdentitySwitcher } from "@/components/dev-identity-switcher";
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
import { withAlpha } from "@/lib/item-status-ui";
import { cn } from "@/lib/utils";
-import type { Category, Supplier } from "@/lib/types";
+import type { Category, Supplier, User } from "@/lib/types";
+import { resolveRepDisplayName } from "@/lib/order-rep";
import { OrderSprintProcessModal } from "@/components/order-sprint-process-modal";
const UNKNOWN_SLUG = "cat_unknown";
@@ -31,6 +32,7 @@ export default function OrderSprintPage() {
const [items, setItems] = useState([]);
const [categories, setCategories] = useState([]);
const [suppliers, setSuppliers] = useState([]);
+ const [users, setUsers] = useState[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [activeCategory, setActiveCategory] = useState("__all__");
@@ -62,9 +64,16 @@ export default function OrderSprintPage() {
// full refetch on every tab click that the previous paginated
// version suffered from.
const filter = "item_status = 'pending' && purchase_order = ''";
- const expand = "order,order.customer,category";
+ const expand = "order,order.customer,order.customer.company,category";
- const [itemsRes, catsRes, suppliersRes] = await Promise.all([
+ const usersPromise = fetch("/api/v1/users/lookup", { cache: "no-store" })
+ .then(async (response) => {
+ if (!response.ok) return [];
+ return (await response.json()) as Pick[];
+ })
+ .catch(() => [] as Pick[]);
+
+ const [itemsRes, catsRes, suppliersRes, usersRes] = await Promise.all([
pb.collection("order_items").getList(page, SPRINT_PER_PAGE, {
filter,
expand,
@@ -72,6 +81,8 @@ export default function OrderSprintPage() {
fields: [
"id,description,quantity,order,supplier,cost_price,selling_price,markup,category,item_status,purchase_order,sku,notes,po_description,ai_item_insights",
"expand.order.order_number",
+ "expand.order.rep",
+ "expand.order.expand.customer.rep",
"expand.order.expand.customer.customer",
"expand.order.expand.customer.expand.company.company",
"expand.category.name,expand.category.color",
@@ -79,12 +90,14 @@ export default function OrderSprintPage() {
}).catch(() => ({ items: [], totalPages: 1, totalItems: 0 })),
pb.collection("categories").getFullList({ filter: "is_active = true", sort: "display_order", batch: 50 }),
pb.collection("suppliers").getFullList({ sort: "suppliername", batch: 200 }),
+ usersPromise,
]);
setItems(itemsRes.items as unknown as OrderSprintItem[]);
setTotalPages(itemsRes.totalPages);
setCategories(catsRes as unknown as Category[]);
setSuppliers(suppliersRes as unknown as Supplier[]);
+ setUsers(usersRes);
// The header counter uses the same per-page slice we display
// (the API caps at SPRINT_PER_PAGE). For a rough "still work to
// do" badge that's good enough — when it grows past the page
@@ -103,6 +116,11 @@ export default function OrderSprintPage() {
void fetchData();
}, [fetchData]);
+ const usersById = useMemo(
+ () => new Map(users.map((user) => [user.id, user] as const)),
+ [users],
+ );
+
// Bucket items into per-category arrays. Blank-category items land
// in the Unknown / Other bucket so the count is honest (matches what
// the counts route reports).
@@ -207,6 +225,7 @@ export default function OrderSprintPage() {
| Order |
+ Rep |
|
Description |
Qty |
@@ -223,6 +242,7 @@ export default function OrderSprintPage() {
key={item.id}
item={item}
suppliers={suppliers}
+ usersById={usersById}
showCategoryColumn={activeCategory === "__all__"}
onProcess={() => setModalItem(item)}
/>
@@ -242,6 +262,7 @@ export default function OrderSprintPage() {
key={item.id}
item={item}
suppliers={suppliers}
+ usersById={usersById}
showCategoryPill={activeCategory === "__all__"}
onProcess={() => setModalItem(item)}
/>
@@ -285,7 +306,18 @@ type OrderSprintItem = {
item_status?: string;
ai_item_insights?: Record | null;
expand?: {
- order?: { id: string; order_number: string; expand?: { customer?: { customer: string; expand?: { company?: { company: string } } } } };
+ order?: {
+ id: string;
+ order_number: string;
+ rep?: string;
+ expand?: {
+ customer?: {
+ customer: string;
+ rep?: string;
+ expand?: { company?: { company: string } };
+ };
+ };
+ };
category?: { id: string; name: string; color: string };
};
};
@@ -293,11 +325,13 @@ type OrderSprintItem = {
function OrderSprintRow({
item,
suppliers,
+ usersById,
showCategoryColumn,
onProcess,
}: {
item: OrderSprintItem;
suppliers: Supplier[];
+ usersById: ReadonlyMap>;
// When filtered to a single category the pill is redundant — every
// row shows the same one. Hide it instead of cluttering the table.
showCategoryColumn: boolean;
@@ -305,8 +339,14 @@ function OrderSprintRow({
}) {
const orderNumber = item.expand?.order?.order_number || "Unlinked item";
const orderId = item.expand?.order?.id;
- const customer = item.expand?.order?.expand?.customer?.customer;
- const company = item.expand?.order?.expand?.customer?.expand?.company?.company;
+ const customerRecord = item.expand?.order?.expand?.customer;
+ const customer = customerRecord?.customer;
+ const company = customerRecord?.expand?.company?.company;
+ const rep = resolveRepDisplayName({
+ customerRepId: customerRecord?.rep,
+ orderRep: item.expand?.order?.rep,
+ usersById,
+ });
const categoryName = item.expand?.category?.name;
const categoryColor = item.expand?.category?.color || "#6b7280";
@@ -332,6 +372,13 @@ function OrderSprintRow({
)}
+
+ {rep ? (
+ {rep}
+ ) : (
+ —
+ )}
+ |
|
@@ -379,7 +426,7 @@ function OrderSprintRow({
/**
* Mobile (>;
showCategoryPill: boolean;
onProcess: () => void;
}) {
const orderNumber = item.expand?.order?.order_number || "Unlinked item";
const orderId = item.expand?.order?.id;
- const customer = item.expand?.order?.expand?.customer?.customer;
- const company = item.expand?.order?.expand?.customer?.expand?.company?.company;
+ const customerRecord = item.expand?.order?.expand?.customer;
+ const customer = customerRecord?.customer;
+ const company = customerRecord?.expand?.company?.company;
+ const rep = resolveRepDisplayName({
+ customerRepId: customerRecord?.rep,
+ orderRep: item.expand?.order?.rep,
+ usersById,
+ });
const categoryName = item.expand?.category?.name;
const categoryColor = item.expand?.category?.color || "#6b7280";
@@ -445,11 +500,16 @@ function OrderSprintCard({
)}
- {/* Customer / company — touch devices can't hover, so we
+ {/* Customer / company / rep — touch devices can't hover, so we
inline this rather than hiding it behind a tooltip. */}
- {(customer || company) && (
+ {(customer || company || rep) && (
{[company, customer].filter(Boolean).join(" · ")}
+ {rep && (
+
+ {customer || company ? "· " : ""}Rep: {rep}
+
+ )}
)}
diff --git a/src/app/api/v1/orders/[id]/review/route.ts b/src/app/api/v1/orders/[id]/review/route.ts
new file mode 100644
index 0000000..eeabf02
--- /dev/null
+++ b/src/app/api/v1/orders/[id]/review/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from "next/server";
+import { assertBrowserSession } from "@/lib/server/browser-session";
+import { getOrderReviewData } from "@/lib/server/orders-service";
+
+/** Browser-facing review-modal data for one order. */
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ req: NextRequest,
+ context: { params: Promise<{ id: string }> },
+) {
+ try {
+ await assertBrowserSession(req);
+ const { id } = await context.params;
+ return NextResponse.json(await getOrderReviewData(id));
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to load order";
+ const status = (error as Error & { status?: number }).status || 500;
+ return NextResponse.json({ error: message }, { status });
+ }
+}
diff --git a/src/app/api/v1/orders/list/route.ts b/src/app/api/v1/orders/list/route.ts
new file mode 100644
index 0000000..45e252a
--- /dev/null
+++ b/src/app/api/v1/orders/list/route.ts
@@ -0,0 +1,34 @@
+import { NextRequest, NextResponse } from "next/server";
+import { assertBrowserSession } from "@/lib/server/browser-session";
+import { getOrdersDashboardData } from "@/lib/server/orders-service";
+
+/**
+ * Browser-facing read model for /orders. The external agent endpoint remains
+ * at GET /api/v1/orders; this route accepts the logged-in dashboard user's PB
+ * token and returns orders, line items, tab counts, and resolved rep names.
+ */
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(req: NextRequest) {
+ try {
+ await assertBrowserSession(req);
+
+ const page = Number(req.nextUrl.searchParams.get("page") || "1");
+ const perPage = Number(req.nextUrl.searchParams.get("perPage") || "20");
+ const search = req.nextUrl.searchParams.get("search") || "";
+
+ const result = await getOrdersDashboardData({
+ page,
+ perPage,
+ search,
+ sort: "-created",
+ });
+ return NextResponse.json(result);
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to load orders";
+ const status = (error as Error & { status?: number }).status || 500;
+ return NextResponse.json({ error: message }, { status });
+ }
+}
diff --git a/src/lib/order-rep.test.ts b/src/lib/order-rep.test.ts
new file mode 100644
index 0000000..8232043
--- /dev/null
+++ b/src/lib/order-rep.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { resolveRepDisplayName } from "./order-rep";
+
+describe("resolveRepDisplayName", () => {
+ const users = new Map([
+ ["rep-id-1", { id: "rep-id-1", name: "Ava Smith", email: "ava@example.com" }],
+ ]);
+
+ it("prefers the expanded customer rep name", () => {
+ expect(
+ resolveRepDisplayName({
+ expandedCustomerRep: { id: "rep-id-1", name: "Ava Smith" },
+ orderRep: "rep-id-1",
+ usersById: users,
+ }),
+ ).toBe("Ava Smith");
+ });
+
+ it("resolves a customer rep id when the expand is unavailable", () => {
+ expect(
+ resolveRepDisplayName({ customerRepId: "rep-id-1", usersById: users }),
+ ).toBe("Ava Smith");
+ });
+
+ it("resolves legacy order.rep ids instead of displaying the id", () => {
+ expect(
+ resolveRepDisplayName({ orderRep: "rep-id-1", usersById: users }),
+ ).toBe("Ava Smith");
+ });
+
+ it("keeps a manually entered rep name as the fallback", () => {
+ expect(resolveRepDisplayName({ orderRep: "Jordan Lee" })).toBe("Jordan Lee");
+ });
+
+ it("hides an unresolved PocketBase id", () => {
+ expect(resolveRepDisplayName({ orderRep: "abcdefghijklmno" })).toBe("");
+ });
+});
diff --git a/src/lib/order-rep.ts b/src/lib/order-rep.ts
new file mode 100644
index 0000000..5d1a488
--- /dev/null
+++ b/src/lib/order-rep.ts
@@ -0,0 +1,56 @@
+import type { User } from "@/lib/types";
+
+const POCKETBASE_RECORD_ID = /^[a-z0-9]{15}$/i;
+
+type RepUser = Pick;
+
+export interface ResolveRepDisplayNameOptions {
+ /** Expanded `customer.rep` record, when PocketBase returned it. */
+ expandedCustomerRep?: Partial | null;
+ /** Raw relation id stored in `customer.rep`. */
+ customerRepId?: string | null;
+ /** Legacy/free-text snapshot stored in `orders.rep`. */
+ orderRep?: string | null;
+ /** Admin-resolved users, keyed by PocketBase user id. */
+ usersById?: ReadonlyMap;
+}
+
+function userLabel(user?: Partial | null): string {
+ return (user?.name || user?.email || "").trim();
+}
+
+/**
+ * Resolves the sales rep shown on order surfaces without ever leaking an
+ * opaque PocketBase record id into the UI.
+ *
+ * Customer ownership is authoritative, so its expanded/user-map value wins.
+ * `orders.rep` remains the fallback because company-only and older orders can
+ * carry a manually entered name there. Some older rows stored a user id in
+ * that text field; those ids are resolved through `usersById`, or hidden when
+ * the related user no longer exists.
+ */
+export function resolveRepDisplayName({
+ expandedCustomerRep,
+ customerRepId,
+ orderRep,
+ usersById,
+}: ResolveRepDisplayNameOptions): string {
+ const expandedLabel = userLabel(expandedCustomerRep);
+ if (expandedLabel) return expandedLabel;
+
+ const customerRef = (customerRepId || "").trim();
+ if (customerRef) {
+ const customerUserLabel = userLabel(usersById?.get(customerRef));
+ if (customerUserLabel) return customerUserLabel;
+ }
+
+ const orderValue = (orderRep || "").trim();
+ if (!orderValue) return "";
+
+ const orderUserLabel = userLabel(usersById?.get(orderValue));
+ if (orderUserLabel) return orderUserLabel;
+
+ // A free-text name is safe to show. An unresolved PB id is not useful to
+ // an operator, so suppress it instead of regressing to the old ID display.
+ return POCKETBASE_RECORD_ID.test(orderValue) ? "" : orderValue;
+}
diff --git a/src/lib/server/browser-session.ts b/src/lib/server/browser-session.ts
new file mode 100644
index 0000000..da16f7d
--- /dev/null
+++ b/src/lib/server/browser-session.ts
@@ -0,0 +1,37 @@
+import type { NextRequest } from "next/server";
+
+/**
+ * Validates a browser-forwarded PocketBase auth token against PocketBase.
+ *
+ * Dashboard auth lives in localStorage, so Route Handlers cannot read it from
+ * cookies. Browser callers send the current PB token in `Authorization`; this
+ * helper asks PB to refresh/validate it instead of trusting or decoding the
+ * token locally.
+ */
+export async function assertBrowserSession(req: NextRequest): Promise {
+ const authorization = req.headers.get("authorization") || "";
+ const token = authorization.startsWith("Bearer ")
+ ? authorization.slice(7)
+ : authorization;
+
+ if (!token) {
+ throw Object.assign(new Error("Not authenticated."), { status: 401 });
+ }
+
+ const pbUrl =
+ process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
+ const response = await fetch(
+ `${pbUrl.replace(/\/$/, "")}/api/collections/users/auth-refresh`,
+ {
+ method: "POST",
+ headers: { Authorization: token },
+ signal: AbortSignal.timeout(5_000),
+ },
+ );
+
+ if (!response.ok) {
+ throw Object.assign(new Error("Invalid or expired session."), {
+ status: 401,
+ });
+ }
+}
diff --git a/src/lib/server/orders-service-review.test.ts b/src/lib/server/orders-service-review.test.ts
index d0f451f..3009531 100644
--- a/src/lib/server/orders-service-review.test.ts
+++ b/src/lib/server/orders-service-review.test.ts
@@ -30,7 +30,9 @@ const { store, pbMock, clearStore } = vi.hoisted(() => {
};
const pbMock = {
+ autoCancellation: vi.fn(),
collection: (name: string) => ({
+ autoCancellation: vi.fn(),
async getList(
_page = 1,
_perPage = 25,
@@ -42,14 +44,40 @@ const { store, pbMock, clearStore } = vi.hoisted(() => {
async getFullList() {
return where(name, "");
},
- async getOne(id: string) {
+ async getOne(
+ id: string,
+ opts: { expand?: string } = {},
+ ) {
const item = getOrCreateMap(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
- return item;
+ if (!opts.expand) return { ...item };
+ // Honor the `expand` option: attach each comma-separated
+ // relation as a nested `expand.` on the returned
+ // record. Matches the real PocketBase SDK shape (consumer
+ // code reads `record.expand?.quote`), not a flat
+ // `expand_` sentinel.
+ //
+ // The mock's collection map is keyed on the actual PB
+ // collection name (e.g. `quotes`, plural), while relation
+ // names in `expand` are singular (e.g. `quote`). Try the
+ // relation name as-is first, then the pluralised form, so
+ // tests can populate the related collection with the name
+ // they already use.
+ const expandObj: Record = {};
+ for (const rel of opts.expand.split(",").map((s) => s.trim()).filter(Boolean)) {
+ const relatedId = (item as Record)[rel];
+ if (typeof relatedId !== "string" || !relatedId) continue;
+ const related =
+ getOrCreateMap(rel).get(relatedId) ??
+ getOrCreateMap(`${rel}s`).get(relatedId) ??
+ getOrCreateMap(rel.replace(/s$/, "")).get(relatedId);
+ if (related) expandObj[rel] = { ...related };
+ }
+ return { ...item, expand: expandObj };
},
async create(data: Record) {
const item = {
@@ -94,6 +122,7 @@ vi.mock("./pocketbase-admin", () => ({
import {
OrderNumberCollisionError,
createOrderItems,
+ getOrderReviewData,
softFlagOrderItems,
updateOrderItemsPrices,
updateOrderReviewFields,
@@ -572,6 +601,69 @@ describe("updateOrderItemsPrices", () => {
const itemB = await pbMock.collection("order_items").getOne("item-b");
expect(itemB.quantity).toBe(1); // unchanged
});
+
+ it("updates description and po_description together when the operator corrects a misread title", async () => {
+ // The review modal lets the operator fix the AI-extracted
+ // description. Both the on-screen field (`description`) and the
+ // PO-PDF field (`po_description`) must move in lock-step so the
+ // supplier sees the corrected wording on the printed PO.
+ const order = await pbMock.collection("orders").create({
+ order_number: "ORD-2026-07-001",
+ });
+ await pbMock.collection("order_items").create({
+ id: "item-1",
+ order: order.id,
+ description: "HSS driil 6mm", // typo (AI misread)
+ po_description: "HSS driil 6mm",
+ quantity: 2,
+ selling_price: 50,
+ cost_price: 25,
+ markup: 100,
+ });
+
+ const result = await updateOrderItemsPrices(order.id, [
+ { id: "item-1", description: "HSS drill 6mm" },
+ ]);
+
+ expect(result.updated).toBe(1);
+ expect(result.failed).toEqual([]);
+
+ const item = await pbMock.collection("order_items").getOne("item-1");
+ expect(item.description).toBe("HSS drill 6mm");
+ expect(item.po_description).toBe("HSS drill 6mm");
+ });
+
+ it("returns a per-row failure when the description is blank", async () => {
+ // Empty / whitespace-only description is a validation error, not
+ // a 500 — the operator gets a targeted hint rather than a generic
+ // PB error.
+ const order = await pbMock.collection("orders").create({
+ order_number: "ORD-2026-07-001",
+ });
+ await pbMock.collection("order_items").create({
+ id: "item-1",
+ order: order.id,
+ description: "M10 bolt",
+ po_description: "M10 bolt",
+ quantity: 2,
+ selling_price: 50,
+ cost_price: 25,
+ markup: 100,
+ });
+
+ const result = await updateOrderItemsPrices(order.id, [
+ { id: "item-1", description: " " },
+ ]);
+
+ expect(result.updated).toBe(0);
+ expect(result.failed).toEqual([
+ { id: "item-1", error: "Description is required" },
+ ]);
+
+ // The row is left untouched.
+ const item = await pbMock.collection("order_items").getOne("item-1");
+ expect(item.description).toBe("M10 bolt");
+ });
});
describe("updateOrderItemsPrices", () => {
@@ -902,3 +994,81 @@ describe("softFlagOrderItems", () => {
expect(result.failed).toEqual([]);
});
});
+
+describe("getOrderReviewData", () => {
+ it("surfaces the order's own email_subject / email_body", async () => {
+ // The n8n ingest agent snapshots the inbound customer email onto
+ // the order itself (orders.email_subject / email_body). The
+ // review modal must show that snapshot — NOT the related
+ // quote's outbound email. Previously the modal read the wrong
+ // place, so the body came back empty even when it existed on
+ // the order.
+ const order = await pbMock.collection("orders").create({
+ order_number: "ORD-2026-07-001",
+ customer: "cust-1",
+ email_subject: "RFQ for 10x M10 bolts",
+ email_body: "Hi team, please quote on the attached.\nThanks",
+ });
+
+ const result = await getOrderReviewData(order.id);
+
+ expect(result.quoteEmail).toEqual({
+ subject: "RFQ for 10x M10 bolts",
+ body: "Hi team, please quote on the attached.\nThanks",
+ });
+ });
+
+ it("falls back to the related quote's email when the order columns are empty", async () => {
+ // Pre-2026-07-29 orders don't carry the inbound email snapshot
+ // on the order record. The fallback chain must read the related
+ // quote's columns so the modal still has *something* to show
+ // (the quote side carries the operator's outbound email — not
+ // the customer's, but better than "(no body)" for legacy
+ // orders).
+ const quote = await pbMock.collection("quotes").create({
+ id: "quote-1",
+ quote_number: "Q-001",
+ email_subject: "Quote Q-001 attached",
+ email_body: "Please find our quote attached.",
+ });
+ const order = await pbMock.collection("orders").create({
+ order_number: "ORD-2026-07-001",
+ customer: "cust-1",
+ quote: "quote-1",
+ // email_subject / email_body intentionally absent.
+ });
+
+ const result = await getOrderReviewData(order.id);
+
+ expect(result.quoteEmail).toEqual({
+ subject: "Quote Q-001 attached",
+ body: "Please find our quote attached.",
+ });
+ });
+
+ it("prefers the order's own email columns when both have content", async () => {
+ // If the order has been re-ingested (and its email_subject /
+ // email_body was overwritten by a fresh n8n run), the order's
+ // snapshot is the more recent / more relevant copy.
+ const quote = await pbMock.collection("quotes").create({
+ id: "quote-1",
+ quote_number: "Q-001",
+ email_subject: "Original quote email",
+ email_body: "Original body",
+ });
+ const order = await pbMock.collection("orders").create({
+ order_number: "ORD-2026-07-001",
+ customer: "cust-1",
+ quote: "quote-1",
+ email_subject: "Updated RFQ body",
+ email_body: "Latest customer message",
+ });
+
+ const result = await getOrderReviewData(order.id);
+
+ expect(result.quoteEmail).toEqual({
+ subject: "Updated RFQ body",
+ body: "Latest customer message",
+ });
+ });
+});
diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts
index 8705232..a60d4b7 100644
--- a/src/lib/server/orders-service.ts
+++ b/src/lib/server/orders-service.ts
@@ -1,4 +1,11 @@
import { getAdminPocketBase } from "./pocketbase-admin";
+import {
+ TAB_GROUP_ITEM_STATUSES,
+ itemStatusToTabGroup,
+ type TabGroup,
+} from "../item-status-ui";
+import { resolveRepDisplayName } from "../order-rep";
+import type { Customer, Order, OrderItem, User } from "../types";
const ORDER_COLLECTION = "orders";
const QUOTE_COLLECTION = "quotes";
@@ -9,6 +16,286 @@ export function getOrderItemsCollectionName() {
return process.env.ORDER_ITEMS_COLLECTION || "order_items";
}
+type AdminPocketBase = Awaited>;
+
+type RepUser = Pick;
+
+export interface OrderWithItems {
+ order: Order;
+ items: OrderItem[];
+ /** Tab groups this order has at least one line item in. */
+ tabGroups: TabGroup[];
+ /** Resolved sales-rep label. Never contains an opaque PB record id. */
+ repName: string;
+}
+
+export interface OrdersDashboardData {
+ items: OrderWithItems[];
+ totalPages: number;
+ totalItems: number;
+ counts: Record;
+}
+
+const ORDER_SEARCH_HARD_CAP = 500;
+const ORDER_ITEM_FILTER_CHUNK = 20;
+
+/**
+ * Read model for the internal /orders page. Business logic lives here rather
+ * than in the Client Component or the legacy `pb-helpers.ts` layer.
+ *
+ * A blank search returns one paginated order page. A search loads the capped
+ * order set and applies the existing cross-collection matching rules against
+ * company, customer, descriptions, and prices. Rep names are resolved with
+ * admin-expanded `customer.rep`, then the minimal users lookup for legacy
+ * `orders.rep` rows that stored a user id instead of a name.
+ */
+export async function getOrdersDashboardData(
+ params: {
+ page?: number;
+ perPage?: number;
+ search?: string;
+ sort?: string;
+ } = {},
+): Promise {
+ const pb = await getAdminPocketBase();
+ pb.autoCancellation(false);
+ const [list, counts] = await Promise.all([
+ listOrdersWithItems(params, pb),
+ getOrderItemStatusTabCounts(pb),
+ ]);
+ return { ...list, counts };
+}
+
+export async function listOrdersWithItems(
+ params: {
+ page?: number;
+ perPage?: number;
+ search?: string;
+ sort?: string;
+ } = {},
+ pbArg?: AdminPocketBase,
+): Promise> {
+ const pb = pbArg || (await getAdminPocketBase());
+ pb.autoCancellation(false);
+ const page = Math.max(1, params.page || 1);
+ const perPage = Math.min(100, Math.max(1, params.perPage || 20));
+ const search = (params.search || "").trim();
+ const sort = params.sort || "-created";
+ const expand = "company,customer,customer.rep,quote,potential_quote";
+
+ const ordersPage = search
+ ? await pb.collection(ORDER_COLLECTION).getList(1, ORDER_SEARCH_HARD_CAP, {
+ sort,
+ expand,
+ })
+ : await pb.collection(ORDER_COLLECTION).getList(page, perPage, {
+ sort,
+ expand,
+ });
+
+ const orderIds = ordersPage.items.map((order) => order.id);
+ const [allItems, users] = await Promise.all([
+ fetchOrderItemsForOrders(pb, orderIds),
+ pb.collection("users").getFullList({
+ fields: "id,name,email",
+ sort: "name",
+ }),
+ ]);
+
+ const usersById = new Map(users.map((user) => [user.id, user]));
+ const itemsByOrder: Record = {};
+ for (const item of allItems) {
+ const key = item.order || "";
+ (itemsByOrder[key] ||= []).push(item);
+ }
+
+ const searchLower = search.toLowerCase();
+ const numericMatch = isNumericOrderSearch(search) ? Number(search) : null;
+ const items: OrderWithItems[] = [];
+
+ for (const order of ordersPage.items) {
+ const orderItems = itemsByOrder[order.id] || [];
+ const customer = order.expand?.customer as Customer | undefined;
+ const repName = resolveRepDisplayName({
+ expandedCustomerRep: customer?.expand?.rep,
+ customerRepId: customer?.rep,
+ orderRep: order.rep,
+ usersById,
+ });
+
+ if (
+ search &&
+ !orderMatchesDashboardSearch(
+ order,
+ orderItems,
+ searchLower,
+ numericMatch,
+ )
+ ) {
+ continue;
+ }
+
+ const tabGroups = new Set();
+ for (const item of orderItems) {
+ tabGroups.add(itemStatusToTabGroup(item.item_status));
+ }
+ items.push({ order, items: orderItems, tabGroups: [...tabGroups], repName });
+ }
+
+ return {
+ items,
+ totalPages: search ? 1 : ordersPage.totalPages,
+ totalItems: search ? items.length : ordersPage.totalItems,
+ };
+}
+
+export async function getOrderItemStatusTabCounts(
+ pbArg?: AdminPocketBase,
+): Promise> {
+ const pb = pbArg || (await getAdminPocketBase());
+ pb.autoCancellation(false);
+ const tabs = Object.keys(TAB_GROUP_ITEM_STATUSES) as TabGroup[];
+ const results = await Promise.all(
+ tabs.map((tab) =>
+ pb.collection(getOrderItemsCollectionName()).getFullList<{ order?: string }>({
+ filter: TAB_GROUP_ITEM_STATUSES[tab]
+ .map((status) => `item_status = "${status}"`)
+ .join(" || "),
+ fields: "order",
+ }),
+ ),
+ );
+
+ const counts = {} as Record;
+ tabs.forEach((tab, index) => {
+ counts[tab] = new Set(
+ results[index].map((item) => item.order).filter(Boolean),
+ ).size;
+ });
+ return counts;
+}
+
+export interface OrderReviewEmail {
+ subject: string;
+ body: string;
+}
+
+export async function getOrderReviewData(orderId: string): Promise<{
+ order: Order;
+ items: OrderItem[];
+ customers: Customer[];
+ /**
+ * Source email for the review modal. Prefers the order's own
+ * `email_subject` / `email_body` (populated by the n8n agent when
+ * it ingests the customer email that triggered the order). Falls
+ * back to the related `quote.email_*` for older orders that were
+ * created before the snapshot columns existed on `orders`.
+ */
+ quoteEmail: OrderReviewEmail;
+}> {
+ const pb = await getAdminPocketBase();
+ pb.autoCancellation(false);
+ const escapedOrderId = orderId.replace(/"/g, '\\"');
+ const [order, items, customers] = await Promise.all([
+ pb.collection(ORDER_COLLECTION).getOne(orderId, {
+ expand: "company,customer,customer.company,customer.rep,quote",
+ }),
+ pb.collection(getOrderItemsCollectionName()).getFullList({
+ filter: `order = "${escapedOrderId}"`,
+ expand: "supplier",
+ sort: "created",
+ }),
+ pb.collection("customers").getFullList({
+ sort: "-created",
+ expand: "company,rep",
+ }),
+ ]);
+
+ // Resolve the source email: prefer the order's own columns
+ // (populated by the n8n ingest agent on order creation). Fall
+ // back to the related quote's outbound email so orders that
+ // pre-date the snapshot columns still render something useful.
+ // The quote side carries the *operator's* email_body, not the
+ // customer's, so this fallback is best-effort — the order
+ // columns are the audit trail we actually want to show.
+ const orderSubject = (order.email_subject || "").trim();
+ const orderBody = (order.email_body || "").trim();
+ const quote = order.expand?.quote as
+ | { email_subject?: string; email_body?: string }
+ | undefined;
+ const quoteSubject = (quote?.email_subject || "").trim();
+ const quoteBody = (quote?.email_body || "").trim();
+
+ return {
+ order,
+ items,
+ customers,
+ quoteEmail: {
+ subject: orderSubject || quoteSubject,
+ body: orderBody || quoteBody,
+ },
+ };
+}
+
+async function fetchOrderItemsForOrders(
+ pb: AdminPocketBase,
+ orderIds: string[],
+): Promise {
+ if (orderIds.length === 0) return [];
+
+ const chunks: string[][] = [];
+ for (let index = 0; index < orderIds.length; index += ORDER_ITEM_FILTER_CHUNK) {
+ chunks.push(orderIds.slice(index, index + ORDER_ITEM_FILTER_CHUNK));
+ }
+
+ const batches = await Promise.all(
+ chunks.map((ids) =>
+ pb.collection(getOrderItemsCollectionName()).getFullList({
+ filter: ids
+ .map((id) => `order = "${id.replace(/"/g, '\\"')}"`)
+ .join(" || "),
+ expand: "supplier",
+ sort: "created",
+ }),
+ ),
+ );
+ return batches.flat();
+}
+
+function isNumericOrderSearch(search: string): boolean {
+ return /^\d+(\.\d+)?$/.test(search) && !Number.isNaN(Number(search));
+}
+
+function orderMatchesDashboardSearch(
+ order: Order,
+ items: OrderItem[],
+ searchLower: string,
+ numericMatch: number | null,
+): boolean {
+ if ((order.order_number || "").toLowerCase().includes(searchLower)) return true;
+ if ((order.expand?.company?.company || "").toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if ((order.expand?.customer?.customer || "").toLowerCase().includes(searchLower)) {
+ return true;
+ }
+
+ for (const item of items) {
+ if ((item.description || "").toLowerCase().includes(searchLower)) return true;
+ if ((item.po_description || "").toLowerCase().includes(searchLower)) return true;
+ }
+
+ if (numericMatch !== null) {
+ const epsilon = 0.001;
+ return items.some(
+ (item) =>
+ Math.abs(Number(item.selling_price || 0) - numericMatch) < epsilon ||
+ Math.abs(Number(item.cost_price || 0) - numericMatch) < epsilon,
+ );
+ }
+ return false;
+}
+
export async function createOrderFromQuote(quoteId: string) {
const pb = await getAdminPocketBase();
const orderItemsCollection = getOrderItemsCollectionName();
@@ -205,7 +492,7 @@ async function getOrCreateOrderCounter(
return await pb
.collection(ORDER_COUNTERS_COLLECTION)
.getFirstListItem(`year=${year} && month=${month}`);
- } catch (err) {
+ } catch {
// First request for this (year, month) — backfill the counter from
// any pre-existing orders in that month, then create it. Without
// the backfill, the new code would start at ORD-{year}-{month}-001
@@ -392,6 +679,7 @@ export async function updateOrderItemsPrices(
orderId: string,
items: Array<{
id: string;
+ description?: string | null;
quantity?: number | null;
selling_price?: number | null;
cost_price?: number | null;
@@ -431,6 +719,25 @@ export async function updateOrderItemsPrices(
if (item.selling_price !== undefined) patch.selling_price = item.selling_price;
if (item.cost_price !== undefined) patch.cost_price = item.cost_price;
+ // Description edits keep `description` and `po_description` in
+ // lock-step. PB's review step lets the operator correct a
+ // misread item title — both fields must move together so the
+ // line on the PO PDF and the row on the order stay aligned.
+ // Reject an empty description so the operator gets a targeted
+ // error rather than a 500.
+ if (item.description !== undefined) {
+ const trimmed = (item.description || "").trim();
+ if (!trimmed) {
+ failed.push({
+ id: item.id,
+ error: "Description is required",
+ });
+ continue;
+ }
+ patch.description = trimmed;
+ patch.po_description = trimmed;
+ }
+
// Markup computation:
// - explicit markup in patch → pass through
// - selling_price supplied, cost_price not → recompute from selling/current-cost