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:
parent
b66e1b6b6b
commit
69175e493d
9 changed files with 820 additions and 109 deletions
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
23
src/app/api/v1/orders/[id]/review/route.ts
Normal file
23
src/app/api/v1/orders/[id]/review/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/v1/orders/list/route.ts
Normal file
34
src/app/api/v1/orders/list/route.ts
Normal 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
38
src/lib/order-rep.test.ts
Normal 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
56
src/lib/order-rep.ts
Normal 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;
|
||||
}
|
||||
37
src/lib/server/browser-session.ts
Normal file
37
src/lib/server/browser-session.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue