diff --git a/src/app/(app)/stock-orders/page.test.tsx b/src/app/(app)/stock-orders/page.test.tsx
deleted file mode 100644
index 5a0af73..0000000
--- a/src/app/(app)/stock-orders/page.test.tsx
+++ /dev/null
@@ -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;
- }) => (
-
-
-
- ),
-}));
-
-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();
- 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();
- 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();
- 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();
- 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();
- 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();
- // 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();
- 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");
- });
-});
\ No newline at end of file
diff --git a/src/app/(app)/stock-orders/page.tsx b/src/app/(app)/stock-orders/page.tsx
index 639b6b5..317c182 100644
--- a/src/app/(app)/stock-orders/page.tsx
+++ b/src/app/(app)/stock-orders/page.tsx
@@ -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 `` 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>["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 = {
- 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([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState("");
- const [status, setStatus] = useState("");
- const [page, setPage] = useState(1);
- const [totalPages, setTotalPages] = useState(1);
- const [quickDraftOpen, setQuickDraftOpen] = useState(false);
- const [editingPurchaseId, setEditingPurchaseId] = useState(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(
- 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>;
+ 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 (
+
+
Stock Orders
+
+ Failed to load stock orders:{" "}
+ {err instanceof Error ? err.message : "unknown error"}
+
+
+ );
+ }
return (
@@ -160,188 +84,24 @@ export default function StockOrdersPage() {
-
-
-
+
+
+ + New stock order
+
- {/* Status filter — one tab per StockPurchaseStatus + "All". */}
-
- {STATUS_TABS.map((tab) => {
- const active = status === tab.value;
- return (
-
- );
- })}
-
-
- {loading ? (
-
-
-
- ) : error ? (
-
-
-
- {error}
-
-
- ) : rows.length === 0 ? (
-
-
-
-
- {status
- ? `No ${STATUS_LABEL[status].toLowerCase()} stock orders.`
- : "No stock orders yet."}
-
-
-
-
- ) : (
-
-
-
-
-
- Number
- Supplier
- Status
- Items
- Created
-
-
-
-
- {rows.map((row) => {
- const supplier = row.expand?.supplier;
- return (
- router.push(`/stock-orders/${row.id}`)}
- >
-
- {row.purchase_number}
-
-
- {supplier?.suppliername ?? (
-
- Unknown supplier
-
- )}
-
-
-
- {STATUS_LABEL[row.status]}
-
-
-
- {row.item_count}
-
-
- {formatDate(row.created)}
-
-
-
- {row.status === "draft" && (
-
- )}
-
-
-
-
- );
- })}
-
-
-
-
- )}
-
- {/* Pagination — mirrors the /orders list-page pattern. */}
- {totalPages > 1 && (
-
-
-
- Page {page} of {totalPages}
-
-
-
- )}
-
- {
- setQuickDraftOpen(next);
- if (!next) setEditingPurchaseId(null);
- }}
- editPurchaseId={editingPurchaseId ?? undefined}
+
);
-}
+}
\ No newline at end of file