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 (