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:
parent
6fa658df62
commit
d3f8242e4f
5 changed files with 535 additions and 67 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
114
src/app/api/v1/orders/sprint/counts/route.test.ts
Normal file
114
src/app/api/v1/orders/sprint/counts/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
213
src/components/order-sprint-process-modal.test.tsx
Normal file
213
src/components/order-sprint-process-modal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue