From bca6a32c0d7438e2d9e1e60a668dfdab63f00fae Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 29 Jul 2026 20:18:57 +0200 Subject: [PATCH] feat(buyer-tasks): page body with tabs, cards, mark-ready flow Co-authored-by: Orca --- .../buyer-tasks/buyer-tasks-page.test.tsx | 79 +++++++++ .../buyer-tasks/buyer-tasks-page.tsx | 152 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 src/components/buyer-tasks/buyer-tasks-page.test.tsx create mode 100644 src/components/buyer-tasks/buyer-tasks-page.tsx diff --git a/src/components/buyer-tasks/buyer-tasks-page.test.tsx b/src/components/buyer-tasks/buyer-tasks-page.test.tsx new file mode 100644 index 0000000..aca0bcf --- /dev/null +++ b/src/components/buyer-tasks/buyer-tasks-page.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { BuyerTasksPage } from "./buyer-tasks-page"; + +const sampleGroup = { + groupId: "order-1::sup-1", + orderId: "order-1", + orderNumber: "ORD-001", + supplierId: "sup-1", + supplierName: "Steel Co", + customerName: "J. Smith", + companyName: "Acme", + items: [ + { id: "item-1", description: "HSS drill", quantity: 2, costPrice: 25, sellingPrice: 50 }, + ], + itemCount: 1, + totalCost: 50, + purchaseOrderId: null, + poStatus: null, + step: "needs_po" as const, +}; + +describe("BuyerTasksPage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("loads groups on mount", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ groups: [sampleGroup] }), { status: 200 }) + ); + render(); + await waitFor(() => { + expect(screen.getByText("ORD-001")).toBeInTheDocument(); + }); + }); + + it("switches tabs", async () => { + vi.spyOn(global, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ groups: [sampleGroup] }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ groups: [] }), { status: 200 }) + ); + render(); + await waitFor(() => { + expect(screen.getByText("ORD-001")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("buyer-task-tab-po_draft")); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("tab=po_draft"), + expect.anything() + ); + }); + }); + + it("calls generate-po when the action button is clicked", async () => { + vi.spyOn(global, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ groups: [sampleGroup] }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ purchaseOrderId: "po-new" }), { status: 200 }) + ); + render(); + await waitFor(() => { + expect(screen.getByText("ORD-001")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("button", { name: /generate po/i })); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/v1/buyer-tasks/generate-po", + expect.objectContaining({ method: "POST" }) + ); + }); + }); +}); \ No newline at end of file diff --git a/src/components/buyer-tasks/buyer-tasks-page.tsx b/src/components/buyer-tasks/buyer-tasks-page.tsx new file mode 100644 index 0000000..6097af1 --- /dev/null +++ b/src/components/buyer-tasks/buyer-tasks-page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { BuyerTaskTabs, type BuyerTaskTab } from "./buyer-task-tabs"; +import { BuyerTaskGroupCard } from "./buyer-task-group-card"; +import { MarkReadyModal } from "./mark-ready-modal"; +import type { BuyerTaskGroup } from "@/lib/server/buyer-tasks-service"; + +const EMPTY_COUNTS: Record = { + needs_po: 0, + po_draft: 0, + po_sent: 0, +}; + +export function BuyerTasksPage() { + const [tab, setTab] = useState("needs_po"); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [busyGroupId, setBusyGroupId] = useState(null); + const [markReadyFor, setMarkReadyFor] = useState(null); + + const fetchTab = useCallback(async (t: BuyerTaskTab) => { + setLoading(true); + try { + const res = await fetch(`/api/v1/buyer-tasks?tab=${t}`, { method: "GET" }); + if (!res.ok) throw new Error(`Failed to load (${res.status})`); + const data = await res.json(); + setGroups(data.groups); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to load buyer tasks"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchTab(tab); + }, [tab, fetchTab]); + + // Fetch counts for all tabs after the active tab so the tab strip + // shows accurate counts without round-tripping when the user switches. + const [counts, setCounts] = useState>(EMPTY_COUNTS); + useEffect(() => { + (async () => { + const results = await Promise.all( + (["needs_po", "po_draft", "po_sent"] as BuyerTaskTab[]).map(async (t) => { + try { + const res = await fetch(`/api/v1/buyer-tasks?tab=${t}`, { method: "GET" }); + if (!res.ok) return [t, 0] as const; + const data = await res.json(); + return [t, data.groups.length] as const; + } catch { + return [t, 0] as const; + } + }) + ); + setCounts(Object.fromEntries(results) as Record); + })(); + }, []); + + async function handleAction(group: BuyerTaskGroup) { + if (group.step === "needs_po") { + setBusyGroupId(group.groupId); + try { + const res = await fetch("/api/v1/buyer-tasks/generate-po", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + orderId: group.orderId, + supplierId: group.supplierId, + items: group.items.map((i) => ({ + id: i.id, + costPrice: i.costPrice, + quantity: i.quantity, + })), + }), + }); + if (!res.ok) throw new Error(`Failed to generate PO (${res.status})`); + toast.success("PO generated"); + await fetchTab(tab); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to generate PO"); + } finally { + setBusyGroupId(null); + } + } else if (group.step === "po_draft") { + // Send PO uses the shared modal — handled in Task 13. + toast.info("Send PO: wired in Task 13"); + } else if (group.step === "po_sent") { + setMarkReadyFor(group); + } + } + + async function handleMarkReadyConfirm() { + if (!markReadyFor) return; + const group = markReadyFor; + setMarkReadyFor(null); + setBusyGroupId(group.groupId); + try { + const res = await fetch("/api/v1/buyer-tasks/mark-ready", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + orderId: group.orderId, + supplierId: group.supplierId, + items: group.items.map((i) => ({ id: i.id })), + }), + }); + if (!res.ok) throw new Error(`Failed to mark ready (${res.status})`); + toast.success("Marked ready for collection"); + await fetchTab(tab); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to mark ready"); + } finally { + setBusyGroupId(null); + } + } + + return ( +
+ + {loading && groups.length === 0 ? ( +
+ +
+ ) : groups.length === 0 ? ( +

+ Nothing here. Items appear here once a buyer has processed them. +

+ ) : ( +
+ {groups.map((group) => ( + + ))} +
+ )} + setMarkReadyFor(null)} + busy={false} + /> +
+ ); +} \ No newline at end of file