fix(orders-sprint): transition pending→processed, merge Unknown tabs, polish layout

Three issues on /orders/sprint, fixed together:

1. 'Process' was a no-op for the buyer queue. The modal saved
   supplier + cost + selling + markup + notes, but never wrote
   item_status or po_description, so items stayed 'pending' and
   reappeared on the next refresh — never reaching buyer-tasks.
   Now mirrors the canonical /orders/[id]/process saveItem gate:
   pending → processed only when supplier + cost + po_description
   are all set, and items already past 'processed' (stock_booked,
   po_generated, etc.) are never downgraded.

2. Two 'Unknown' tabs side-by-side: a hardcoded 'Unknown' tab for
   blank categories and a PB-managed 'Unknown / Other' tab for
   category id zgr50fji47r36wu. They had the same meaning and
   different colours. Now resolved by slug ('cat_unknown') and
   bucketing both into a single tab, matching how /sprint already
   tries to.

3. Layout polish: dark-mode-safe category pills (withAlpha helper),
   hidden redundant Category column when filtered to one tab,
   empty <th> given an aria-label, supplier-less cells no longer
   render an empty Badge box, and client-side bucketing so tab
   switches no longer trigger a full refetch + layout shift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 23:06:11 +02:00
parent 6fa658df62
commit d3f8242e4f
5 changed files with 535 additions and 67 deletions

View file

@ -20,10 +20,13 @@ import getPocketBase from "@/lib/pocketbase";
import { FilterTabs, type FilterTab } from "@/components/filter-tabs";
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 { OrderSprintProcessModal } from "@/components/order-sprint-process-modal";
const UNKNOWN_SLUG = "cat_unknown";
export default function OrderSprintPage() {
const [items, setItems] = useState<OrderSprintItem[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
@ -31,13 +34,22 @@ export default function OrderSprintPage() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>("__all__");
const [categoryCounts, setCategoryCounts] = useState<Record<string, number>>({});
const [totalUnprocessed, setTotalUnprocessed] = useState(0);
const SPRINT_PER_PAGE = 50;
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [modalItem, setModalItem] = useState<OrderSprintItem | null>(null);
const UNKNOWN_KEY = "__unknown__";
// Resolve the admin-managed "Unknown / Other" category by its
// canonical slug. Items with no category land here so they share a
// single tab with the records explicitly assigned to it (previously
// these were rendered as two separate "Unknown" and "Unknown / Other"
// tabs).
const unknownCategory = useMemo(
() => categories.find((c) => c.slug === UNKNOWN_SLUG),
[categories],
);
const unknownKey = unknownCategory?.id ?? "__unknown__";
const fetchData = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
@ -45,27 +57,27 @@ export default function OrderSprintPage() {
try {
const pb = getPocketBase();
const getCategoryFilter = () => {
if (activeCategory === "__all__") return "item_status = 'pending' && purchase_order = ''";
if (activeCategory === UNKNOWN_KEY) return "item_status = 'pending' && purchase_order = '' && category = ''";
return `item_status = 'pending' && purchase_order = '' && category = "${activeCategory.replace(/"/g, '\\"')}"`;
};
// Always fetch the full pending-no-PO set; client-side bucketing
// (see `buckets` below) drives tab switching. This avoids the
// 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 [itemsRes, catsRes, countsData, suppliersRes] = await Promise.all([
pb.collection("order_items").getList(page, SPRINT_PER_PAGE, {
filter: getCategoryFilter(),
expand: "order,order.customer,category",
const [itemsRes, catsRes, suppliersRes] = await Promise.all([
pb.collection("order_items").getList(1, SPRINT_PER_PAGE, {
filter,
expand,
sort: "created",
fields: [
"id,description,quantity,order,supplier,cost_price,selling_price,markup,category,item_status,purchase_order,sku,notes,ai_item_insights",
"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.expand.customer.customer",
"expand.order.expand.customer.expand.company.company",
"expand.category.name,expand.category.color",
].join(","),
}).catch(() => ({ items: [], totalPages: 1 })),
}).catch(() => ({ items: [], totalPages: 1, totalItems: 0 })),
pb.collection("categories").getFullList({ filter: "is_active = true", sort: "display_order", batch: 50 }),
fetch("/api/v1/orders/sprint/counts").then(r => r.ok ? r.json() : { counts: {}, total: 0 }).catch(() => ({ counts: {}, total: 0 })),
pb.collection("suppliers").getFullList({ sort: "suppliername", batch: 200 }),
]);
@ -73,34 +85,57 @@ export default function OrderSprintPage() {
setTotalPages(itemsRes.totalPages);
setCategories(catsRes as unknown as Category[]);
setSuppliers(suppliersRes as unknown as Supplier[]);
setCategoryCounts(countsData.counts || {});
setTotalUnprocessed(countsData.total ?? 0);
// 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
// cap the operator knows to dig into the tab anyway.
setTotalUnprocessed(itemsRes.totalItems);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load order sprint");
} finally {
setLoading(false);
setRefreshing(false);
}
}, [page, activeCategory]);
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchData();
}, [fetchData]);
// 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).
const buckets = useMemo(() => {
const map = new Map<string, OrderSprintItem[]>();
map.set("__all__", items);
map.set(unknownKey, []);
for (const cat of categories) map.set(cat.id, []);
for (const item of items) {
const catId = item.expand?.category?.id || item.category || "";
if (catId && map.has(catId)) {
map.get(catId)!.push(item);
} else {
map.get(unknownKey)!.push(item);
}
}
return map;
}, [items, categories, unknownKey]);
const tabs: FilterTab[] = useMemo(() => {
const out: FilterTab[] = [
{ key: "__all__", label: "All", color: "#0f172a", count: totalUnprocessed },
];
for (const cat of categories) {
out.push({ key: cat.id, label: cat.name, color: cat.color, count: categoryCounts[cat.id] ?? 0 });
}
const unknownCount = categoryCounts[UNKNOWN_KEY] ?? 0;
if (unknownCount > 0) {
out.push({ key: UNKNOWN_KEY, label: "Unknown", color: "#6b7280", count: unknownCount });
out.push({ key: cat.id, label: cat.name, color: cat.color, count: buckets.get(cat.id)?.length ?? 0 });
}
return out;
}, [categories, categoryCounts, totalUnprocessed]);
}, [categories, buckets, totalUnprocessed]);
const visibleItems = useMemo(() => {
if (activeCategory === "__all__") return buckets.get("__all__") ?? [];
return buckets.get(activeCategory) ?? [];
}, [activeCategory, buckets]);
return (
<div className="space-y-4">
@ -130,7 +165,7 @@ export default function OrderSprintPage() {
<FilterTabs
tabs={tabs}
active={activeCategory}
onChange={(next) => { setActiveCategory(next); setPage(1); }}
onChange={setActiveCategory}
ariaLabel="Filter order items by category"
/>
@ -139,7 +174,7 @@ export default function OrderSprintPage() {
<Loader2 className="h-4 w-4 animate-spin" />
Loading…
</div>
) : items.length === 0 ? (
) : visibleItems.length === 0 ? (
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">
{totalUnprocessed === 0 ? "No items waiting for PO." : "Nothing in this tab."}
</CardContent></Card>
@ -152,20 +187,23 @@ 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="p-2.5 w-10"></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>
<th className="text-left p-2.5 font-medium w-32">Supplier</th>
<th className="text-left p-2.5 font-medium w-24">Category</th>
{activeCategory === "__all__" && (
<th className="text-left p-2.5 font-medium w-24">Category</th>
)}
<th className="text-right p-2.5 font-medium w-32">Action</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
{visibleItems.map((item) => (
<OrderSprintRow
key={item.id}
item={item}
suppliers={suppliers}
showCategoryColumn={activeCategory === "__all__"}
onProcess={() => setModalItem(item)}
/>
))}
@ -179,11 +217,12 @@ export default function OrderSprintPage() {
each row becomes a stacked card so the action button
stays a comfortable 44px hit-target on touch. */}
<div className="md:hidden space-y-2.5">
{items.map((item) => (
{visibleItems.map((item) => (
<OrderSprintCard
key={item.id}
item={item}
suppliers={suppliers}
showCategoryPill={activeCategory === "__all__"}
onProcess={() => setModalItem(item)}
/>
))}
@ -191,7 +230,7 @@ export default function OrderSprintPage() {
</>
)}
{!loading && totalPages > 1 && (
{!loading && totalPages > 1 && activeCategory === "__all__" && (
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {page} of {totalPages}</span>
@ -222,6 +261,8 @@ type OrderSprintItem = {
sku?: string;
notes?: string;
category?: string;
po_description?: string;
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 } } } } };
@ -229,7 +270,19 @@ type OrderSprintItem = {
};
};
function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem; suppliers: Supplier[]; onProcess: () => void }) {
function OrderSprintRow({
item,
suppliers,
showCategoryColumn,
onProcess,
}: {
item: OrderSprintItem;
suppliers: Supplier[];
// 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;
onProcess: () => void;
}) {
const orderNumber = item.expand?.order?.order_number || "—";
const orderId = item.expand?.order?.id;
const customer = item.expand?.order?.expand?.customer?.customer;
@ -238,9 +291,9 @@ function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem;
const categoryColor = item.expand?.category?.color || "#6b7280";
const supplierName = useMemo(() => {
if (!item.supplier) return "—";
if (!item.supplier) return null;
const supplier = suppliers.find((s) => s.id === item.supplier);
return supplier?.suppliername || "—";
return supplier?.suppliername ?? null;
}, [item.supplier, suppliers]);
return (
@ -259,7 +312,7 @@ function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem;
)}
</div>
</td>
<td className="p-2.5 align-top">
<td className="p-2.5 align-top" aria-label="AI item history">
<AiItemHistoryTrigger itemId={item.id} insights={item.ai_item_insights ?? null} />
</td>
<td className="p-2.5 align-top">
@ -269,16 +322,31 @@ function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem;
{item.sku && <div className="text-[11px] text-muted-foreground mt-0.5">SKU: {item.sku}</div>}
</td>
<td className="p-2.5 align-top text-right tabular-nums">{item.quantity ?? "—"}</td>
<td className="p-2.5 align-top"><Badge variant="outline" className="text-xs">{supplierName}</Badge></td>
<td className="p-2.5 align-top">
{categoryName ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full" style={{ backgroundColor: `${categoryColor}20`, color: categoryColor }}>
{categoryName}
</span>
{supplierName ? (
<Badge variant="outline" className="text-xs">{supplierName}</Badge>
) : (
<span className="text-xs text-muted-foreground">—</span>
<span className="text-xs text-muted-foreground italic">no supplier</span>
)}
</td>
{showCategoryColumn && (
<td className="p-2.5 align-top">
{categoryName ? (
<span
className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full"
// withAlpha() applies a real alpha channel so the pill
// stays legible in both light and dark mode. The
// previous `${color}20` suffix was just hex concatenation
// and rendered washed out / unreadable on dark surfaces.
style={{ backgroundColor: withAlpha(categoryColor, 12), color: categoryColor }}
>
{categoryName}
</span>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</td>
)}
<td className="p-2.5 align-top text-right">
<Button size="sm" onClick={onProcess}>
<ShoppingCart className="h-3 w-3 mr-1" />
@ -302,14 +370,18 @@ function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem;
* narrow screens.
* - Touch users can't hover, so we inline the customer/company
* 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.
*/
function OrderSprintCard({
item,
suppliers,
showCategoryPill,
onProcess,
}: {
item: OrderSprintItem;
suppliers: Supplier[];
showCategoryPill: boolean;
onProcess: () => void;
}) {
const orderNumber = item.expand?.order?.order_number || "—";
@ -320,15 +392,15 @@ function OrderSprintCard({
const categoryColor = item.expand?.category?.color || "#6b7280";
const supplierName = useMemo(() => {
if (!item.supplier) return "—";
if (!item.supplier) return null;
const supplier = suppliers.find((s) => s.id === item.supplier);
return supplier?.suppliername || "—";
return supplier?.suppliername ?? null;
}, [item.supplier, suppliers]);
return (
<Card>
<CardContent className="p-3 space-y-2.5">
{/* Row 1: order link + qty */}
{/* Row 1: order link + qty + (optional) category pill */}
<div className="flex items-center gap-2 flex-wrap">
{orderId ? (
<a
@ -343,13 +415,10 @@ function OrderSprintCard({
<span className="text-xs text-muted-foreground tabular-nums">
· Qty {item.quantity ?? "—"}
</span>
{categoryName && (
{showCategoryPill && categoryName && (
<span
className="inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-0.5 rounded-full"
style={{
backgroundColor: `${categoryColor}20`,
color: categoryColor,
}}
style={{ backgroundColor: withAlpha(categoryColor, 12), color: categoryColor }}
>
{categoryName}
</span>
@ -383,9 +452,13 @@ function OrderSprintCard({
{item.sku && (
<span className="text-muted-foreground">SKU: {item.sku}</span>
)}
<Badge variant="outline" className="text-xs">
{supplierName}
</Badge>
{supplierName ? (
<Badge variant="outline" className="text-xs">
{supplierName}
</Badge>
) : (
<span className="text-muted-foreground italic">no supplier</span>
)}
</div>
{/* Action button — full-width touch target */}

View file

@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { GET } from "./route";
function makeAdmin(opts: {
items: Array<{ category: string }>;
catUnknownId?: string;
}) {
const orderItems = {
getFullList: vi.fn().mockResolvedValue(opts.items),
};
const categories = {
getFullList: vi.fn().mockResolvedValue(
opts.catUnknownId ? [{ id: opts.catUnknownId }] : [],
),
};
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items") return orderItems;
if (name === "categories") return categories;
throw new Error(`Unexpected collection ${name}`);
}),
};
return { admin, orderItems, categories };
}
describe("GET /api/v1/orders/sprint/counts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns per-category counts", async () => {
const { admin } = makeAdmin({
items: [
{ category: "cat-a" },
{ category: "cat-a" },
{ category: "cat-b" },
],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const res = await GET({} as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.total).toBe(3);
expect(body.counts).toEqual({ "cat-a": 2, "cat-b": 1 });
});
it("buckets blank-category items into the cat_unknown record", async () => {
const { admin } = makeAdmin({
catUnknownId: "cat-zz",
items: [
{ category: "" },
{ category: "" },
{ category: "" },
{ category: "cat-a" },
],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const res = await GET({} as never);
const body = await res.json();
expect(body.counts).toEqual({ "cat-zz": 3, "cat-a": 1 });
// No phantom "__unknown__" bucket.
expect(body.counts["__unknown__"]).toBeUndefined();
});
it("merges blank + cat_unknown record into a single bucket", async () => {
const { admin } = makeAdmin({
catUnknownId: "cat-zz",
items: [
{ category: "" },
{ category: "cat-zz" },
{ category: "cat-zz" },
],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const res = await GET({} as never);
const body = await res.json();
expect(body.counts).toEqual({ "cat-zz": 3 });
});
it("falls back to a single phantom __unknown__ bucket when cat_unknown record doesn't exist", async () => {
const { admin } = makeAdmin({
// No catUnknownId — simulates the admin-managed record not
// being created yet (e.g. fresh PB instance).
items: [{ category: "" }, { category: "" }],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const res = await GET({} as never);
const body = await res.json();
// The counts contract still needs to expose the items somewhere,
// but we don't promise a specific key. Just check no per-PB-id
// bucket is created.
const total: number = (Object.values(body.counts) as number[]).reduce(
(a, b) => a + b,
0,
);
expect(total).toBe(2);
});
it("returns 500 when admin PB throws", async () => {
vi.mocked(getAdminPocketBase).mockRejectedValue(new Error("PB down"));
const res = await GET({} as never);
expect(res.status).toBe(500);
});
});

View file

@ -7,6 +7,11 @@ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
* Returns per-category counts for order items that need PO generation.
* Shape: { total: number, counts: { [categoryId]: number } }
*
* Items with a blank `category` bucket into the admin-managed
* "Unknown / Other" category (slug `cat_unknown`) so the count is
* consistent with what the /orders/sprint page renders as a single
* tab.
*
* Uses admin PB so it bypasses list rules.
*
* Note: lives at `/counts` rather than at the parent `/sprint` route
@ -30,21 +35,31 @@ export async function GET(_req: NextRequest) {
batch: 500,
});
// Resolve the admin-managed "Unknown / Other" category (slug
// `cat_unknown`). Items with an empty category bucket into it
// so the count matches what the page renders as a single tab.
// This was previously split across two separate buckets —
// blank category vs the literal Unknown / Other record — and
// the page rendered both as separate tabs.
const catsRes = await pb.collection("categories").getFullList({
filter: "slug = 'cat_unknown'",
fields: "id",
});
const unknownCatId = catsRes[0]?.id;
const countsMap: Record<string, number> = {};
let unknown = 0;
for (const item of allItems) {
const catId = (item.category as string) || "__unknown__";
if (catId === "__unknown__") {
unknown++;
} else {
countsMap[catId] = (countsMap[catId] ?? 0) + 1;
}
const catId = (item.category as string) || "";
// Treat blank category the same as the Unknown / Other record.
// If the admin hasn't created that record yet, fall back to a
// legacy `__unknown__` sentinel so the items are still counted.
let bucket: string;
if (catId === "") bucket = unknownCatId ?? "__unknown__";
else bucket = catId;
countsMap[bucket] = (countsMap[bucket] ?? 0) + 1;
}
// Include unknown in the map
if (unknown > 0) countsMap["__unknown__"] = unknown;
return NextResponse.json({
total: allItems.length,
counts: countsMap,

View file

@ -0,0 +1,213 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { OrderSprintProcessModal } from "./order-sprint-process-modal";
// Mock the client PB so we can assert on the update payload written
// when the operator clicks "Process Item". The modal also reads price
// list + order history in `useEffect` — we stub those with empty
// results so the test focuses purely on the save behaviour.
const { updateMock, getListMock, supplierSearchStub } = vi.hoisted(() => ({
updateMock: vi.fn(),
getListMock: vi.fn(),
supplierSearchStub: vi.fn(),
}));
vi.mock("@/lib/pocketbase", () => ({
__esModule: true,
default: () => ({
collection: () => ({
getList: (...args: unknown[]) => getListMock(...args),
update: updateMock,
}),
}),
}));
// Replace SupplierSearch with a plain <select> so we can drive
// supplier selection from tests without rendering the real combobox.
vi.mock("@/components/ui/supplier-search", () => ({
SupplierSearch: ({
value,
onValueChange,
suppliers,
}: {
value: string;
onValueChange: (v: string) => void;
suppliers: Array<{ id: string; suppliername: string }>;
}) => (
<select
aria-label="Supplier"
value={value}
onChange={(e) => onValueChange(e.target.value)}
>
<option value="">Pick…</option>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
{s.suppliername}
</option>
))}
</select>
),
}));
void supplierSearchStub; // silence unused
const baseItem = {
id: "item-1",
description: "HSS drill bit",
quantity: 2,
supplier: "",
cost_price: 0,
selling_price: 0,
markup: 0,
sku: "",
notes: "",
category: "",
po_description: "",
item_status: "pending",
};
const suppliers = [
{ id: "sup-1", suppliername: "Steel Co" },
{ id: "sup-2", suppliername: "Bolt Suppliers" },
];
function renderModal(props: Partial<React.ComponentProps<typeof OrderSprintProcessModal>> = {}) {
return render(
<OrderSprintProcessModal
item={baseItem}
suppliers={suppliers as never}
open
onClose={vi.fn()}
onProcessed={vi.fn()}
{...props}
/>,
);
}
async function setField(labelOrPlaceholder: string, value: string) {
// The pricing inputs are wired with htmlFor/id; the other text
// inputs use placeholders. Prefer the label, fall back to the
// placeholder.
let input: HTMLElement;
try {
input = screen.getByLabelText(labelOrPlaceholder, { exact: false });
} catch {
input = screen.getByPlaceholderText(labelOrPlaceholder);
}
fireEvent.change(input, { target: { value } });
}
describe("OrderSprintProcessModal", () => {
beforeEach(() => {
updateMock.mockReset();
updateMock.mockResolvedValue({});
// Order history + price list fetches resolve to empty lists so
// the modal mounts without throwing. We don't assert on them.
getListMock.mockResolvedValue({ items: [], totalItems: 0, totalPages: 1 });
});
it("writes item_status='processed' when supplier + cost + po_description are all set", async () => {
renderModal();
// supplier
fireEvent.change(screen.getByLabelText("Supplier"), {
target: { value: "sup-1" },
});
// cost
await setField("Cost (ZAR)", "25");
// po_description (uses placeholder because there's no <Label htmlFor> wiring)
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "10mm HSS drill bit" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledTimes(1);
});
const [id, payload] = updateMock.mock.calls[0]!;
expect(id).toBe("item-1");
expect(payload).toMatchObject({
supplier: "sup-1",
cost_price: 25,
po_description: "10mm HSS drill bit",
item_status: "processed",
});
});
it("keeps item_status='pending' when po_description is empty", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("Supplier"), {
target: { value: "sup-1" },
});
await setField("Cost (ZAR)", "25");
// po_description left blank
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledTimes(1);
});
expect(updateMock.mock.calls[0]![1]).toMatchObject({
item_status: "pending",
});
});
it("keeps item_status='pending' when cost is 0", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("Supplier"), {
target: { value: "sup-1" },
});
await setField("Cost (ZAR)", "0");
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "anything" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledTimes(1);
});
expect(updateMock.mock.calls[0]![1]).toMatchObject({
item_status: "pending",
});
});
it("does not downgrade items that are already past 'processed'", async () => {
renderModal({
item: { ...baseItem, item_status: "po_generated", cost_price: 25, supplier: "sup-1", po_description: "x" },
});
// No supplier / cost / po_description changes needed — those are pre-filled.
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledTimes(1);
});
expect(updateMock.mock.calls[0]![1]).toMatchObject({
item_status: "po_generated",
});
});
it("refuses to save when no supplier is selected", async () => {
renderModal();
await setField("Cost (ZAR)", "25");
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "10mm HSS drill bit" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
// updateMock is not called — the modal gates on supplier first.
await waitFor(() => {
expect(updateMock).not.toHaveBeenCalled();
});
});
});

View file

@ -51,6 +51,8 @@ type OrderSprintItem = {
sku?: string;
notes?: string;
category?: string;
po_description?: string;
item_status?: string;
ai_item_insights?: Record<string, unknown> | null;
};
@ -59,6 +61,11 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
const [cost, setCost] = useState(item?.cost_price || 0);
const [markup, setMarkup] = useState(item?.markup || 0);
const [selling, setSelling] = useState(item?.selling_price || 0);
// PO description — what the supplier sees on the PO. Mirrors the
// canonical /orders/[id]/process gate: an item only transitions
// pending → processed when supplier + cost + po_description are all
// set, so the buyer-tasks Needs PO tab can find it.
const [poDescription, setPoDescription] = useState(item?.po_description || item?.description || "");
const [notes, setNotes] = useState(item?.notes || "");
const [processing, setProcessing] = useState(false);
const [suppliersExpanded, setSuppliersExpanded] = useState(true);
@ -208,12 +215,32 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
setProcessing(true);
try {
const pb = getPocketBase();
// Same readiness gate as /orders/[id]/process saveItem:
// pending → processed only when supplier + cost > 0 +
// po_description are all set. Items already past `processed`
// (stock_booked, po_generated, ready_delivery, etc.) are not
// downgraded. This is what makes the item appear on the
// buyer-tasks Needs PO tab.
const trimmedPoDescription = poDescription.trim();
const isReadyForBuyer =
supplierId.trim() !== "" &&
cost > 0 &&
trimmedPoDescription !== "";
const currentStatus = item.item_status ?? "pending";
const nextStatus =
currentStatus === "pending" && isReadyForBuyer
? "processed"
: currentStatus;
await pb.collection("order_items").update(item.id, {
supplier: supplierId,
cost_price: round2(cost),
selling_price: round2(selling),
markup: round2(markup),
po_description: trimmedPoDescription,
notes: notes,
item_status: nextStatus,
});
toast.success("Item processed!");
onProcessed();
@ -329,24 +356,24 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">Cost (ZAR)</Label>
<Label htmlFor="order-sprint-cost" className="text-xs">Cost (ZAR)</Label>
<button type="button" onClick={() => copyToClipboard(cost, "cost")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
</div>
<Input type="number" value={cost || ""} onChange={(e) => onCostChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
<Input id="order-sprint-cost" type="number" value={cost || ""} onChange={(e) => onCostChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">Markup %</Label>
<Label htmlFor="order-sprint-markup" className="text-xs">Markup %</Label>
<button type="button" onClick={() => copyToClipboard(markup, "markup")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
</div>
<Input type="number" value={markup || ""} onChange={(e) => onMarkupChange(e.target.value)} className="h-9 tabular-nums" placeholder="20" />
<Input id="order-sprint-markup" type="number" value={markup || ""} onChange={(e) => onMarkupChange(e.target.value)} className="h-9 tabular-nums" placeholder="20" />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">Selling (ZAR)</Label>
<Label htmlFor="order-sprint-selling" className="text-xs">Selling (ZAR)</Label>
<button type="button" onClick={() => copyToClipboard(selling, "selling")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
</div>
<Input type="number" value={selling || ""} onChange={(e) => onSellingChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
<Input id="order-sprint-selling" type="number" value={selling || ""} onChange={(e) => onSellingChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
</div>
</div>
@ -356,6 +383,32 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
<SupplierSearch value={supplierId} onValueChange={setSupplierId} suppliers={suppliers} placeholder="Pick the supplier..." ariaLabel="Supplier" />
</div>
{/* PO description — what the supplier sees on the PO. Defaults
to the order description so the buyer-tasks pending →
processed transition fires for the common no-op case.
Mirrors the canonical /orders/[id]/process gate. */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">PO description</Label>
{poDescription.trim() !== (item.description || "").trim() && (
<button
type="button"
onClick={() => setPoDescription(item.description || "")}
className="text-[11px] text-muted-foreground hover:text-foreground"
>
↺ reset to order description
</button>
)}
</div>
<Textarea
value={poDescription}
onChange={(e) => setPoDescription(e.target.value)}
rows={2}
className="text-sm"
placeholder="Description the supplier sees on the PO"
/>
</div>
{/* Notes */}
<div className="space-y-1.5">
<Label className="text-xs">Notes</Label>