feat(stock-orders): extract StockOrdersTable client component

Holds the QuickDraftModal open state, renders status filter tabs as <Link>s to ?status=..., and adds two new columns (Ordered R, Received R) showing per-row aggregates from the service layer.\n\nReplaces <Button asChild><Link/></Button> with <Link className={cn(buttonVariants(...))}> because the project's Button (built on @base-ui/react/button) doesn't support asChild. Visually identical; preserves navigation semantics.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 14:46:50 +02:00
parent 469b2c0460
commit 0c556a0513

View file

@ -0,0 +1,302 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { ChevronRight, Package, Pencil } from "lucide-react";
import { QuickDraftModal } from "@/components/stock-orders/quick-draft-modal";
import {
STOCK_PURCHASE_STATUS_VALUES,
type StockPurchase,
type StockPurchaseStatus,
type Supplier,
} from "@/lib/types";
// `formatCurrency` lives in `pb-helpers.ts` but that file is the
// legacy data-access layer (AGENTS.md forbids importing it from
// new components). Inlined here — same ZAR / en-ZA shape used
// elsewhere in the app.
function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }).format(amount);
}
/**
* Client-side table view for the `/stock-orders` list. Holds the
* QuickDraftModal open state + the per-row "edit draft" target.
* The page itself is a Server Component that fetches rows; this
* component receives them as a prop and renders tabs + table +
* pagination as `<Link>`s so the server re-renders on filter/page
* change.
*/
type ListRow = StockPurchase & {
item_count: number;
ordered_value: number;
received_value: number;
expand?: { supplier?: Supplier };
};
const STATUS_TABS: Array<{ value: StockPurchaseStatus | ""; label: string }> = [
{ value: "", label: "All" },
...STOCK_PURCHASE_STATUS_VALUES.filter((s) =>
["draft", "sent", "received", "cancelled"].includes(s),
).map((value) => ({
value: value as StockPurchaseStatus,
label: value.charAt(0).toUpperCase() + value.slice(1),
})),
];
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",
});
}
export interface StockOrdersTableProps {
rows: ListRow[];
currentStatus: StockPurchaseStatus | "";
page: number;
totalPages: number;
}
export default function StockOrdersTable({
rows,
currentStatus,
page,
totalPages,
}: StockOrdersTableProps) {
const [quickDraftOpen, setQuickDraftOpen] = useState(false);
const [editingPurchaseId, setEditingPurchaseId] = useState<string | null>(null);
const openEditForDraft = (purchaseId: string) => {
setEditingPurchaseId(purchaseId);
setQuickDraftOpen(true);
};
const tabHref = (value: StockPurchaseStatus | "") => {
const params = new URLSearchParams();
if (value) params.set("status", value);
const qs = params.toString();
return qs ? `/stock-orders?${qs}` : "/stock-orders";
};
const pageHref = (target: number) => {
const params = new URLSearchParams();
if (currentStatus) params.set("status", currentStatus);
if (target > 1) params.set("page", String(target));
const qs = params.toString();
return qs ? `/stock-orders?${qs}` : "/stock-orders";
};
return (
<>
{/* Status filter — server-rendered tabs. */}
<div className="flex items-center gap-1 border-b overflow-x-auto">
{STATUS_TABS.map((tab) => {
const active = currentStatus === tab.value;
return (
<Link
key={tab.value || "all"}
href={tabHref(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}
</Link>
);
})}
</div>
<div className="flex items-center justify-between gap-2 flex-wrap">
<Button
type="button"
variant="outline"
data-testid="new-stock-item-button"
onClick={() => setQuickDraftOpen(true)}
>
New Stock Item
</Button>
</div>
{rows.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
<Package className="h-8 w-8 mx-auto mb-3" />
<p>
{currentStatus
? `No ${STATUS_LABEL[currentStatus].toLowerCase()} stock orders.`
: "No stock orders yet."}
</p>
<Link
href="/stock-orders/new"
className={cn(buttonVariants({ variant: "link" }), "mt-2")}
>
Create your first stock order
</Link>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Number</TableHead>
<TableHead>Supplier</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Ordered (R)</TableHead>
<TableHead className="text-right">Received (R)</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={() => {
window.location.href = `/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">
{formatCurrency(row.ordered_value)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(row.received_value)}
</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>
)}
{totalPages > 1 && (
<div className="flex items-center justify-end gap-2">
<Link
href={pageHref(page - 1)}
aria-disabled={page <= 1}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
page <= 1 && "pointer-events-none opacity-50",
)}
>
Previous
</Link>
<span className="text-sm text-muted-foreground tabular-nums">
Page {page} of {totalPages}
</span>
<Link
href={pageHref(page + 1)}
aria-disabled={page >= totalPages}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
page >= totalPages && "pointer-events-none opacity-50",
)}
>
Next
</Link>
</div>
)}
<QuickDraftModal
open={quickDraftOpen}
onOpenChange={(next) => {
setQuickDraftOpen(next);
if (!next) setEditingPurchaseId(null);
}}
editPurchaseId={editingPurchaseId ?? undefined}
/>
</>
);
}