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:
parent
fcb15a20e2
commit
dedd5013b9
2 changed files with 70 additions and 535 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue