refactor(stock-orders): migrate list page to Server Component

Reads searchParams (status, page), validates both, calls
listStockPurchases via the admin PB, and renders the new
StockOrdersTable client component. Drops the legacy direct-PB
read path per AGENTS.md data-access discipline.

The old page.test.tsx is removed; its assertions are mirrored
in the new StockOrdersTable.test.tsx (next task).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 15:02:59 +02:00
parent fcb15a20e2
commit dedd5013b9
2 changed files with 70 additions and 535 deletions

View file

@ -1,225 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { cleanup, render, screen, fireEvent } from "@testing-library/react";
// ── Mocks ────────────────────────────────────────────────────────────────
//
// The page is a client component that reads PocketBase via
// `@/lib/pocketbase`. We mock both the `pocketbase` npm package
// (to keep EventSource out of jsdom) and the local helper module.
// Shared hoisted state so both mocks agree on what getList returns.
const { _state, _collectionMock, _resetState } = vi.hoisted(() => {
const state = {
items: [] as unknown[],
error: undefined as string | undefined,
};
const collectionMock = {
getList: vi.fn().mockImplementation(async () => ({
items: state.items,
totalItems: state.items.length,
page: 1,
perPage: 50,
totalPages: 1,
})),
getFullList: vi.fn().mockResolvedValue([]),
};
return {
_state: state,
_collectionMock: collectionMock,
_resetState: () => {
state.items.length = 0;
state.error = undefined;
collectionMock.getList.mockClear();
collectionMock.getList.mockImplementation(async () => ({
items: state.items,
totalItems: state.items.length,
page: 1,
perPage: 50,
totalPages: 1,
}));
},
};
});
vi.mock("pocketbase", () => ({
default: class MockPocketBase {
autoCancellation() {}
collection(_name: string) {
return _collectionMock;
}
},
}));
vi.mock("@/lib/pocketbase", () => ({
__esModule: true,
default: () => ({
collection: () => _collectionMock,
}),
getPocketBase: () => ({
collection: () => _collectionMock,
}),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}),
}));
// Stub the QuickDraftModal — it's already tested in
// `src/components/stock-orders/quick-draft-modal.test.tsx`. This test
// only cares that the page mounts the modal and toggles its `open`
// prop when the button is clicked.
vi.mock("@/components/stock-orders/quick-draft-modal", () => ({
QuickDraftModal: ({
open,
onOpenChange,
editPurchaseId,
}: {
open: boolean;
onOpenChange: (b: boolean) => void;
editPurchaseId?: string;
}) => (
<div
data-testid="quick-draft-modal"
data-open={open}
data-edit-purchase-id={editPurchaseId ?? ""}
>
<button
type="button"
data-testid="quick-draft-modal-close"
onClick={() => onOpenChange(false)}
>
Close
</button>
</div>
),
}));
import StockOrdersPage from "./page";
// ── Tests ────────────────────────────────────────────────────────────────
describe("StockOrdersPage — quick draft button", () => {
beforeEach(() => {
_resetState();
});
afterEach(() => {
cleanup();
});
it("renders a New Stock Item button in the page header", async () => {
render(<StockOrdersPage />);
const button = await screen.findByTestId("new-stock-item-button");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent(/new stock item/i);
});
it("mounts the QuickDraftModal (closed by default)", async () => {
render(<StockOrdersPage />);
const modal = await screen.findByTestId("quick-draft-modal");
expect(modal).toBeInTheDocument();
expect(modal.dataset.open).toBe("false");
});
it("opens the QuickDraftModal when the button is clicked", async () => {
render(<StockOrdersPage />);
const modal = await screen.findByTestId("quick-draft-modal");
expect(modal.dataset.open).toBe("false");
fireEvent.click(screen.getByTestId("new-stock-item-button"));
expect(modal.dataset.open).toBe("true");
});
it("closes the QuickDraftModal when the modal calls onOpenChange(false)", async () => {
render(<StockOrdersPage />);
const modal = await screen.findByTestId("quick-draft-modal");
fireEvent.click(screen.getByTestId("new-stock-item-button"));
expect(modal.dataset.open).toBe("true");
fireEvent.click(screen.getByTestId("quick-draft-modal-close"));
expect(modal.dataset.open).toBe("false");
});
});
describe("StockOrdersPage — edit draft button", () => {
beforeEach(() => {
_resetState();
});
afterEach(() => {
cleanup();
});
// Minimal row shape — the page reads id / status / purchase_number off
// the row, and the page itself derives `item_count` from a separate
// PB call. The helper fields below are kept so the mock's getList
// returns a row shape that satisfies the BaseRecord + StockPurchase
// interfaces without TS shouting during render.
function makeRow(overrides: {
id: string;
status: "draft" | "sent" | "received" | "cancelled";
purchase_number: string;
}) {
return {
id: overrides.id,
created: "2026-07-30 10:00:00.000Z",
updated: "2026-07-30 10:00:00.000Z",
collectionId: "stock_purchases",
collectionName: "stock_purchases",
purchase_number: overrides.purchase_number,
supplier: "supplier-1",
status: overrides.status,
notes: "",
email_error: "",
email_message_id: "",
item_count: 0,
expand: { supplier: { id: "supplier-1", suppliername: "Steel & Pipe Co" } },
};
}
it("renders an Edit button on draft rows", async () => {
_state.items.push(
makeRow({ id: "po-abc", status: "draft", purchase_number: "PO26000001STK" }),
);
render(<StockOrdersPage />);
const editButton = await screen.findByTestId("edit-draft-button");
expect(editButton).toBeInTheDocument();
expect(editButton).toHaveAttribute(
"aria-label",
"Edit draft PO26000001STK",
);
});
it("does NOT render an Edit button on non-draft rows", async () => {
_state.items.push(
makeRow({ id: "po-sent", status: "sent", purchase_number: "PO26000002STK" }),
);
render(<StockOrdersPage />);
// Wait for the table to render at least one row (the page filters by
// status "" → all, so the sent row will appear). Then assert no
// Edit button exists anywhere.
await screen.findByTestId("quick-draft-modal");
expect(screen.queryByTestId("edit-draft-button")).toBeNull();
});
it("clicking Edit opens the modal with editPurchaseId set to the row id", async () => {
_state.items.push(
makeRow({ id: "po-abc", status: "draft", purchase_number: "PO26000001STK" }),
);
render(<StockOrdersPage />);
const modal = await screen.findByTestId("quick-draft-modal");
expect(modal.dataset.open).toBe("false");
expect(modal.dataset.editPurchaseId).toBe("");
const editButton = await screen.findByTestId("edit-draft-button");
fireEvent.click(editButton);
expect(modal.dataset.open).toBe("true");
expect(modal.dataset.editPurchaseId).toBe("po-abc");
});
});

View file

@ -1,153 +1,77 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Loader2, Plus, AlertCircle, Package, ChevronRight, Boxes, Pencil } from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { QuickDraftModal } from "@/components/stock-orders/quick-draft-modal";
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { listStockPurchases } from "@/lib/server/stock-purchases-service";
import {
STOCK_PURCHASE_STATUS_VALUES,
type StockPurchase,
type StockPurchaseStatus,
type Supplier,
} from "@/lib/types";
import StockOrdersTable from "./StockOrdersTable";
/**
* /stock-orders — list view for pure stock purchases (v2 stock-orders
* flow, no customer order attached). Mirrors the /orders list-page
* shape (table + status filter + "+ New" button) but stays simpler:
* no expand-on-click rows, no item-status tabs (status here is the
* purchase-level state, not the per-item lifecycle).
* /stock-orders — server-rendered list view for pure stock
* purchases (v2 stock-orders flow, no customer order attached).
*
* Data flow:
* - Reads go through the user-side PB instance (matches the rest
* of the app — the browser page auths as the logged-in user).
* - Writes (create / send / receive) go through /api/v1/stock/...
* so the service-layer test suite can drive them with a mock PB.
* The user-side PB has `@request.auth.id != ''` rules on these
* collections, so an admin-PB call from a Next.js route is
* necessary to bypass the rule check.
* Migrated from a client component (which read PB directly from the
* browser) to a Server Component per AGENTS.md:
*
* - Reads search params from the App Router prop.
* - Calls `listStockPurchases(adminPb, ...)` directly — same shape
* the `/api/v1/stock-purchases/list` route exposes, without the
* HTTP round-trip when rendering server-side.
* - Hands the result to `<StockOrdersTable>` for the interactive
* bits (modal + edit-draft).
*
* Status and page are validated before reaching the service so a
* stray URL param can't reach PB.
*/
type ListRow = StockPurchase & {
item_count: number;
type ListRow = Awaited<ReturnType<typeof listStockPurchases>>["items"][number] & {
expand?: { supplier?: Supplier };
};
const STATUS_TABS: Array<{ value: StockPurchaseStatus | ""; label: string }> = [
{ value: "", label: "All" },
{ value: "draft", label: "Draft" },
{ value: "sent", label: "Sent" },
{ value: "received", label: "Received" },
{ value: "cancelled", label: "Cancelled" },
];
const STATUS_VARIANT: Record<
StockPurchaseStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
draft: "outline",
sending: "secondary",
sent: "secondary",
mail_failed: "destructive",
received: "default",
cancelled: "destructive",
};
const STATUS_LABEL: Record<StockPurchaseStatus, string> = {
draft: "Draft",
sending: "Sending",
sent: "Sent",
mail_failed: "Mail failed",
received: "Received",
cancelled: "Cancelled",
};
function formatDate(s: string): string {
if (!s) return "";
const d = new Date(s);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
});
function parseStatus(raw: string | string[] | undefined): StockPurchaseStatus | "" {
const value = Array.isArray(raw) ? raw[0] : raw;
if (!value) return "";
return STOCK_PURCHASE_STATUS_VALUES.includes(value as StockPurchaseStatus)
? (value as StockPurchaseStatus)
: "";
}
export default function StockOrdersPage() {
const router = useRouter();
const [rows, setRows] = useState<ListRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [status, setStatus] = useState<StockPurchaseStatus | "">("");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [quickDraftOpen, setQuickDraftOpen] = useState(false);
const [editingPurchaseId, setEditingPurchaseId] = useState<string | null>(null);
function parsePage(raw: string | string[] | undefined): number {
const value = Array.isArray(raw) ? raw[0] : raw;
const n = Number(value ?? "1");
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1;
}
const openEditForDraft = (purchaseId: string) => {
setEditingPurchaseId(purchaseId);
setQuickDraftOpen(true);
};
export default async function StockOrdersPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const params = await searchParams;
const status = parseStatus(params.status);
const page = parsePage(params.page);
const fetchRows = useCallback(async () => {
setLoading(true);
setError("");
try {
const pb = getPocketBase();
// Validate status against the known enum values so a stray
// URL param doesn't reach PB. The `~` filter on the
// purchase_number is a no-op when empty.
const filter = status ? `status = "${status}"` : "";
const result = await pb.collection("stock_purchases").getList<ListRow>(
page,
50,
{
filter,
sort: "-created",
expand: "supplier",
}
);
// Per-row line count — same approach as the service layer.
const withCounts: ListRow[] = [];
for (const row of result.items) {
const items = await pb
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${row.id}"`, fields: "id" });
withCounts.push({ ...row, item_count: items.length });
}
setRows(withCounts);
setTotalPages(result.totalPages);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to load stock orders";
setError(message);
} finally {
setLoading(false);
}
}, [page, status]);
useEffect(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps -- data fetch on dep change
fetchRows();
}, [fetchRows]);
const handleStatusChange = (next: StockPurchaseStatus | "") => {
setStatus(next);
setPage(1);
};
let result: Awaited<ReturnType<typeof listStockPurchases>>;
try {
const pb = await getAdminPocketBase();
result = await listStockPurchases(pb, { status, page, perPage: 50 });
} catch (err) {
// Surface the failure inline rather than redirect to a global
// error boundary — operators need the URL context to retry.
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">Stock Orders</h1>
<p className="text-destructive text-sm">
Failed to load stock orders:{" "}
{err instanceof Error ? err.message : "unknown error"}
</p>
</div>
);
}
return (
<div className="space-y-6">
@ -160,188 +84,24 @@ export default function StockOrdersPage() {
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => router.push("/stock-orders/reorder")}
data-od-id="stock-orders-reorder-link"
<Link
href="/stock-orders/reorder"
className={cn(buttonVariants({ variant: "outline" }))}
>
<Boxes className="mr-2 h-4 w-4" />
Reorder from low stock
</Button>
<Button
type="button"
variant="outline"
data-testid="new-stock-item-button"
onClick={() => setQuickDraftOpen(true)}
>
New Stock Item
</Button>
<Button onClick={() => router.push("/stock-orders/new")}>
<Plus className="mr-2 h-4 w-4" />
New stock order
</Button>
</Link>
<Link href="/stock-orders/new" className={cn(buttonVariants())}>
+ New stock order
</Link>
</div>
</div>
{/* Status filter — one tab per StockPurchaseStatus + "All". */}
<div className="flex items-center gap-1 border-b overflow-x-auto">
{STATUS_TABS.map((tab) => {
const active = status === tab.value;
return (
<button
key={tab.value || "all"}
type="button"
onClick={() => handleStatusChange(tab.value)}
className={
"px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 -mb-px transition-colors " +
(active
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground")
}
>
{tab.label}
</button>
);
})}
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<Card>
<CardContent className="py-8 text-center">
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-destructive" />
<p className="text-sm text-destructive">{error}</p>
</CardContent>
</Card>
) : rows.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
<Package className="h-8 w-8 mx-auto mb-3" />
<p>
{status
? `No ${STATUS_LABEL[status].toLowerCase()} stock orders.`
: "No stock orders yet."}
</p>
<Button
variant="link"
className="mt-2"
onClick={() => router.push("/stock-orders/new")}
>
Create your first stock order
</Button>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Number</TableHead>
<TableHead>Supplier</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Items</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const supplier = row.expand?.supplier;
return (
<TableRow
key={row.id}
className="cursor-pointer"
onClick={() => router.push(`/stock-orders/${row.id}`)}
>
<TableCell className="font-medium">
{row.purchase_number}
</TableCell>
<TableCell>
{supplier?.suppliername ?? (
<span className="text-muted-foreground italic">
Unknown supplier
</span>
)}
</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANT[row.status]}>
{STATUS_LABEL[row.status]}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{row.item_count}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(row.created)}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
{row.status === "draft" && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
openEditForDraft(row.id);
}}
aria-label={`Edit draft ${row.purchase_number}`}
title={`Edit ${row.purchase_number}`}
data-testid="edit-draft-button"
>
<Pencil className="h-4 w-4" />
</Button>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Pagination — mirrors the /orders list-page pattern. */}
{totalPages > 1 && (
<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 tabular-nums">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</Button>
</div>
)}
<QuickDraftModal
open={quickDraftOpen}
onOpenChange={(next) => {
setQuickDraftOpen(next);
if (!next) setEditingPurchaseId(null);
}}
editPurchaseId={editingPurchaseId ?? undefined}
<StockOrdersTable
rows={result.items as ListRow[]}
currentStatus={status}
page={result.page}
totalPages={result.totalPages}
/>
</div>
);
}
}