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