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 <PB token>` validation in route handlers
that serve the dashboard UI (vs. the agent API).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-04 08:50:54 +02:00
parent b66e1b6b6b
commit 69175e493d
9 changed files with 820 additions and 109 deletions

View file

@ -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<TabGroup, number> = {
@ -72,6 +69,39 @@ const EMPTY_COUNTS: Record<TabGroup, number> = {
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<T>(url: string): Promise<T> {
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<string | null>(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<OrdersDashboardData>(
`/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<ReturnType<typeof setTimeout> | 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() {
</p>
)}
<div className="space-y-3">
{visibleOrders.map(({ order, items }) => (
{visibleOrders.map(({ order, items, repName }) => (
<OrderCard
key={order.id}
order={order}
items={items}
repName={repName}
isExpanded={expanded.has(order.id)}
showAll={showAllByOrder.has(order.id)}
activeTab={activeTab}
@ -592,6 +576,7 @@ export default function OrdersPage() {
function OrderCard({
order,
items,
repName,
isExpanded,
showAll,
activeTab,
@ -602,6 +587,7 @@ function OrderCard({
}: {
order: import("@/lib/types").Order;
items: OrderItem[];
repName: string;
isExpanded: boolean;
showAll: boolean;
activeTab: TabGroup;
@ -758,11 +744,11 @@ function OrderCard({
</span>
</>
)}
{order.rep && (
{repName && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground text-sm">
rep: {order.rep}
rep: {repName}
</span>
</>
)}

View file

@ -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<OrderSprintItem[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [users, setUsers] = useState<Pick<User, "id" | "name" | "email">[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>("__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<User, "id" | "name" | "email">[];
})
.catch(() => [] as Pick<User, "id" | "name" | "email">[]);
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() {
<thead>
<tr className="border-b bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<th className="text-left p-2.5 font-medium">Order</th>
<th className="text-left p-2.5 font-medium">Rep</th>
<th className="p-2.5 w-10" aria-label="AI item history" />
<th className="text-left p-2.5 font-medium">Description</th>
<th className="text-right p-2.5 font-medium w-16">Qty</th>
@ -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<string, unknown> | 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<string, Pick<User, "id" | "name" | "email">>;
// 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({
)}
</div>
</td>
<td className="p-2.5 align-top">
{rep ? (
<span className="text-xs font-medium">{rep}</span>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</td>
<td className="p-2.5 align-top" aria-label="AI item history">
<AiItemHistoryTrigger itemId={item.id} insights={item.ai_item_insights ?? null} />
</td>
@ -379,7 +426,7 @@ function OrderSprintRow({
/**
* Mobile (<md) card layout for the same data the desktop table
* shows. Each card stacks the order number, customer/company,
* shows. Each card stacks the order number, customer/company/rep,
* description, supplier, category, and action button vertically.
*
* Notes:
@ -388,7 +435,7 @@ function OrderSprintRow({
* - The ⓘ (AiItemHistoryTrigger) sits next to the description
* rather than in its own cell — saves vertical space on
* narrow screens.
* - Touch users can't hover, so we inline the customer/company
* - Touch users can't hover, so we inline the customer/company/rep
* info that the desktop table keeps in a hover tooltip.
* - The category pill is hidden when the page is filtered to a
* single category — every card would show the same pill.
@ -396,18 +443,26 @@ function OrderSprintRow({
function OrderSprintCard({
item,
suppliers,
usersById,
showCategoryPill,
onProcess,
}: {
item: OrderSprintItem;
suppliers: Supplier[];
usersById: ReadonlyMap<string, Pick<User, "id" | "name" | "email">>;
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({
)}
</div>
{/* 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) && (
<div className="text-[11px] text-muted-foreground truncate">
{[company, customer].filter(Boolean).join(" · ")}
{rep && (
<span className={cn((customer || company) && "ml-2")}>
{customer || company ? "· " : ""}Rep: {rep}
</span>
)}
</div>
)}

View file

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

View file

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

38
src/lib/order-rep.test.ts Normal file
View file

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

56
src/lib/order-rep.ts Normal file
View file

@ -0,0 +1,56 @@
import type { User } from "@/lib/types";
const POCKETBASE_RECORD_ID = /^[a-z0-9]{15}$/i;
type RepUser = Pick<User, "id" | "name" | "email">;
export interface ResolveRepDisplayNameOptions {
/** Expanded `customer.rep` record, when PocketBase returned it. */
expandedCustomerRep?: Partial<RepUser> | 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<string, RepUser>;
}
function userLabel(user?: Partial<RepUser> | 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;
}

View file

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

View file

@ -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.<name>` on the returned
// record. Matches the real PocketBase SDK shape (consumer
// code reads `record.expand?.quote`), not a flat
// `expand_<name>` 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<string, unknown> = {};
for (const rel of opts.expand.split(",").map((s) => s.trim()).filter(Boolean)) {
const relatedId = (item as Record<string, unknown>)[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<string, unknown>) {
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",
});
});
});

View file

@ -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<ReturnType<typeof getAdminPocketBase>>;
type RepUser = Pick<User, "id" | "name" | "email">;
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<TabGroup, number>;
}
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<OrdersDashboardData> {
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<Omit<OrdersDashboardData, "counts">> {
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<Order>(1, ORDER_SEARCH_HARD_CAP, {
sort,
expand,
})
: await pb.collection(ORDER_COLLECTION).getList<Order>(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<RepUser>({
fields: "id,name,email",
sort: "name",
}),
]);
const usersById = new Map(users.map((user) => [user.id, user]));
const itemsByOrder: Record<string, OrderItem[]> = {};
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<TabGroup>();
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<Record<TabGroup, number>> {
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<TabGroup, number>;
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<Order>(orderId, {
expand: "company,customer,customer.company,customer.rep,quote",
}),
pb.collection(getOrderItemsCollectionName()).getFullList<OrderItem>({
filter: `order = "${escapedOrderId}"`,
expand: "supplier",
sort: "created",
}),
pb.collection("customers").getFullList<Customer>({
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<OrderItem[]> {
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<OrderItem>({
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