221 lines
7.1 KiB
TypeScript
221 lines
7.1 KiB
TypeScript
import { useState, useMemo, useCallback } from "react";
|
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
import { Search, Plus, Pencil, Trash2, Package, Star } from "lucide-react";
|
|
import { GridTable } from "#/components/GridTable";
|
|
import type { GridTableColumn } from "#/components/GridTable";
|
|
import { Button, buttonVariants } from "#/components/ui";
|
|
import { useListStockBatches, useRemoveStockBatch, trpc } from "shared-react";
|
|
|
|
interface StockRow {
|
|
id: number;
|
|
product: { id: number; name: string; brand: string };
|
|
arrived: string;
|
|
batch_no: string;
|
|
mfg: string;
|
|
expiry: string;
|
|
rack: { id: number; name: string } | null;
|
|
distributor: { id: number; agency: string } | null;
|
|
quantity: number;
|
|
is_default: boolean;
|
|
}
|
|
|
|
function daysUntil(expiry: string): number {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const exp = new Date(expiry + "T00:00:00");
|
|
return Math.ceil((exp.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
function fmtDate(d: string): string {
|
|
const date = new Date(d + "T00:00:00");
|
|
return date.toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" });
|
|
}
|
|
|
|
function daysBadge(days: number) {
|
|
if (days < 0) return <span className="inline-block px-2 py-0.5 rounded-full text-[11px] font-semibold bg-red-50 text-red-600 line-through">Expired</span>;
|
|
if (days <= 30) return <span className="inline-block px-2 py-0.5 rounded-full text-[11px] font-semibold bg-red-50 text-red-600">{days} days</span>;
|
|
if (days <= 90) return <span className="inline-block px-2 py-0.5 rounded-full text-[11px] font-semibold bg-amber-50 text-amber-600">{days} days</span>;
|
|
return <span className="inline-block px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-50 text-emerald-600">{days} days</span>;
|
|
}
|
|
|
|
function makeColumns(
|
|
onDelete: (row: StockRow) => void,
|
|
): GridTableColumn<StockRow>[] {
|
|
return [
|
|
{
|
|
id: "product",
|
|
header: "Product",
|
|
cell: ({ row }) => (
|
|
<div>
|
|
<div className="font-semibold text-[13px]">{row.product.name}</div>
|
|
<div className="text-xs text-slate-500 mt-px">{row.product.brand}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: "arrived",
|
|
header: "Arrived Date",
|
|
cell: ({ row }) => <span className="text-[13px] whitespace-nowrap">{fmtDate(row.arrived)}</span>,
|
|
},
|
|
{
|
|
id: "batch_no",
|
|
header: "Batch No",
|
|
cell: ({ row }) => (
|
|
<span className="text-xs font-mono whitespace-nowrap">{row.batch_no}</span>
|
|
),
|
|
},
|
|
{
|
|
id: "quantity",
|
|
header: "Qty",
|
|
size: 60,
|
|
cell: ({ row }) => (
|
|
<span className="font-semibold text-[13px]">{row.quantity}</span>
|
|
),
|
|
},
|
|
{
|
|
id: "is_default",
|
|
header: "Default",
|
|
size: 60,
|
|
cell: ({ row }) => (
|
|
<span title={row.is_default ? "Default batch" : ""}>
|
|
{row.is_default ? (
|
|
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
|
) : (
|
|
<Star className="w-4 h-4 text-slate-300" />
|
|
)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "mfg",
|
|
header: "Manufacture Date",
|
|
cell: ({ row }) => <span className="text-[13px] whitespace-nowrap">{fmtDate(row.mfg)}</span>,
|
|
},
|
|
{
|
|
id: "expiry",
|
|
header: "Expiry Date",
|
|
cell: ({ row }) => {
|
|
const days = daysUntil(row.expiry);
|
|
return (
|
|
<span className={`text-[13px] whitespace-nowrap ${days < 0 ? "text-red-600 font-medium" : days <= 30 ? "text-amber-600 font-medium" : ""}`}>
|
|
{fmtDate(row.expiry)}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "days",
|
|
header: "Days Remaining",
|
|
cell: ({ row }) => daysBadge(daysUntil(row.expiry)),
|
|
},
|
|
{
|
|
id: "rack",
|
|
header: "Rack",
|
|
cell: ({ row }) => <span className="text-[13px]">{row.rack?.name || "—"}</span>,
|
|
},
|
|
{
|
|
id: "distributor",
|
|
header: "Distributor",
|
|
cell: ({ row }) => <span className="text-[13px] text-slate-600">{row.distributor?.agency || "—"}</span>,
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "Actions",
|
|
size: 90,
|
|
cell: ({ row }) => (
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Link
|
|
to="/stock/$id"
|
|
params={{ id: row.id.toString() }}
|
|
className="text-[10px] font-medium text-blue-600 hover:underline whitespace-nowrap"
|
|
>
|
|
View
|
|
</Link>
|
|
<Link to="/stock/add" search={{ id: row.id }}>
|
|
<Button variant="ghost-blue" size="icon" aria-label={`Edit ${row.batch_no}`} type="button">
|
|
<Pencil className="w-[15px] h-[15px]" />
|
|
</Button>
|
|
</Link>
|
|
<Button variant="ghost-red" size="icon" aria-label={`Delete ${row.batch_no}`} onClick={() => onDelete(row)}>
|
|
<Trash2 className="w-[15px] h-[15px]" />
|
|
</Button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
}
|
|
|
|
export const Route = createFileRoute("/stock/")({
|
|
component: StockIndexPage,
|
|
staticData: {
|
|
title: "Stock Batches",
|
|
subtitle: "Track inventory batches by expiry and rack location",
|
|
},
|
|
});
|
|
|
|
function StockIndexPage() {
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const { data: batches, isLoading, error } = useListStockBatches();
|
|
const removeMutation = useRemoveStockBatch();
|
|
const utils = trpc.useUtils();
|
|
|
|
const handleDelete = useCallback(
|
|
(row: StockRow) => {
|
|
if (!confirm(`Delete batch ${row.batch_no}?`)) return;
|
|
removeMutation.mutate(
|
|
{ id: row.id },
|
|
{ onSuccess: () => utils.stock.list.invalidate() },
|
|
);
|
|
},
|
|
[removeMutation, utils],
|
|
);
|
|
|
|
const columns = useMemo(() => makeColumns(handleDelete), [handleDelete]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = searchQuery.toLowerCase().trim();
|
|
if (!q) return batches ?? [];
|
|
return (batches ?? []).filter((b) => {
|
|
const text = `${b.product.name} ${b.product.brand} ${b.batch_no} ${b.rack?.name || ""} ${b.distributor?.agency || ""}`;
|
|
return text.toLowerCase().includes(q);
|
|
});
|
|
}, [searchQuery, batches]);
|
|
|
|
if (isLoading) return <div className="text-sm text-slate-600 py-8">Loading stock batches...</div>;
|
|
if (error) return <div className="text-sm text-red-600 py-8">Failed to load stock batches.</div>;
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-5 flex-wrap">
|
|
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-[480px] px-3.5 py-2 bg-white rounded-md border border-slate-200 transition-all duration-200 focus-within:border-blue-600 focus-within:shadow-[0_0_0_3px_rgba(37,99,235,0.1)]">
|
|
<Search className="w-4 h-4 text-slate-600 shrink-0" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search by product, brand, batch, or rack..."
|
|
className="bg-transparent border-none outline-none text-sm text-slate-900 w-full placeholder:text-slate-400"
|
|
/>
|
|
</div>
|
|
<Link to="/stock/add" className={buttonVariants({ variant: "primary" })}>
|
|
<Plus className="w-[15px] h-[15px]" />
|
|
Add Stock
|
|
</Link>
|
|
</div>
|
|
|
|
<GridTable
|
|
columns={columns}
|
|
data={filtered}
|
|
emptyState={
|
|
<div className="flex flex-col items-center py-16 px-6 text-slate-600">
|
|
<Package className="w-12 h-12 mb-4 opacity-40" />
|
|
<h3 className="text-base font-semibold text-slate-900 mb-1.5">No stock batches found</h3>
|
|
<p className="text-sm">
|
|
{searchQuery ? "No batches match your search." : "Add your first stock batch to get started."}
|
|
</p>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|