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:
parent
469b2c0460
commit
0c556a0513
1 changed files with 302 additions and 0 deletions
302
src/app/(app)/stock-orders/StockOrdersTable.tsx
Normal file
302
src/app/(app)/stock-orders/StockOrdersTable.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue