294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import useSWR from "swr";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
ClipboardCheck,
|
|
Download,
|
|
Search,
|
|
Package,
|
|
} from "lucide-react";
|
|
import { useTableSort } from "@/hooks/use-table-sort";
|
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
|
import type { Customer, Product, Component } from "@/lib/types";
|
|
|
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
|
|
export default function StockTakePage() {
|
|
const { data: customers = [] } = useSWR<Customer[]>("/api/customers", fetcher);
|
|
const { data: products = [] } = useSWR<Product[]>("/api/products", fetcher);
|
|
|
|
const [source, setSource] = useState("all");
|
|
const [selectedProducts, setSelectedProducts] = useState<Set<number>>(new Set());
|
|
const [queryKey, setQueryKey] = useState<string | null>(null);
|
|
|
|
const { data: results, isLoading } = useSWR<Component[]>(
|
|
queryKey,
|
|
fetcher
|
|
);
|
|
|
|
const { sorted, sort, toggleSort } = useTableSort(results ?? []);
|
|
|
|
function handleGenerate() {
|
|
const params = new URLSearchParams();
|
|
if (source !== "all") params.set("source", source);
|
|
if (selectedProducts.size > 0) {
|
|
params.set("productIds", Array.from(selectedProducts).join(","));
|
|
}
|
|
setQueryKey(`/api/stock-take?${params.toString()}`);
|
|
}
|
|
|
|
function toggleProduct(id: number) {
|
|
setSelectedProducts((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function handleExportCSV() {
|
|
if (!sorted || sorted.length === 0) return;
|
|
|
|
const headers = ["Part Number", "Name", "Shelf Location", "Current Stock"];
|
|
const rows = sorted.map((c) => [
|
|
c.part_number,
|
|
c.name,
|
|
c.shelf_location || "",
|
|
String(c.stock_quantity ?? 0),
|
|
]);
|
|
|
|
const csvContent = [
|
|
headers.join(","),
|
|
...rows.map((row) =>
|
|
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")
|
|
),
|
|
].join("\n");
|
|
|
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
const date = new Date().toISOString().slice(0, 10);
|
|
link.download = `stock-take-${date}.csv`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
const hasResults = results && results.length > 0;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
|
Stock Take
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Select components by source and product to generate a stock take list
|
|
</p>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Search className="h-4 w-4" />
|
|
Filters
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Choose which components to include in your stock take
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-6">
|
|
{/* Source Filter */}
|
|
<div className="flex flex-col gap-2">
|
|
<Label>Source</Label>
|
|
<Select value={source} onValueChange={setSource}>
|
|
<SelectTrigger className="w-full max-w-xs">
|
|
<SelectValue placeholder="All Sources" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Sources</SelectItem>
|
|
<SelectItem value="standard">Standard</SelectItem>
|
|
{customers.map((c) => (
|
|
<SelectItem key={c.id} value={`customer-${c.id}`}>
|
|
{c.code} - {c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Product Filter */}
|
|
<div className="flex flex-col gap-2">
|
|
<Label>Products</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Select products to only include components from their BOM. Leave empty for all components.
|
|
</p>
|
|
{products.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground italic">No products available</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
|
|
{products.map((p) => (
|
|
<label
|
|
key={p.id}
|
|
className="flex items-center gap-2 rounded-md border px-3 py-2 cursor-pointer hover:bg-accent transition-colors"
|
|
>
|
|
<Checkbox
|
|
checked={selectedProducts.has(p.id)}
|
|
onCheckedChange={() => toggleProduct(p.id)}
|
|
/>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Package className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
|
<span className="text-sm font-medium truncate">{p.name}</span>
|
|
<span className="text-xs text-muted-foreground flex-shrink-0">{p.sku}</span>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Button onClick={handleGenerate} disabled={isLoading}>
|
|
<ClipboardCheck className="h-4 w-4 mr-2" />
|
|
{isLoading ? "Loading..." : "Generate Stock Take"}
|
|
</Button>
|
|
{selectedProducts.size > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSelectedProducts(new Set())}
|
|
>
|
|
Clear products
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Results */}
|
|
{queryKey && (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<ClipboardCheck className="h-4 w-4" />
|
|
Stock Take Results
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{isLoading
|
|
? "Loading..."
|
|
: `${sorted.length} component${sorted.length !== 1 ? "s" : ""} found`}
|
|
</CardDescription>
|
|
</div>
|
|
{hasResults && (
|
|
<Button variant="outline" size="sm" onClick={handleExportCSV}>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
Export CSV
|
|
</Button>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<SortableTableHead column="part_number" label="Part Number" sort={sort} onToggle={toggleSort} />
|
|
<SortableTableHead column="name" label="Name" sort={sort} onToggle={toggleSort} />
|
|
<SortableTableHead column="category_name" label="Category" sort={sort} onToggle={toggleSort} />
|
|
<TableHead>Source</TableHead>
|
|
<SortableTableHead column="shelf_location" label="Shelf Location" sort={sort} onToggle={toggleSort} />
|
|
<SortableTableHead column="stock_quantity" label="Current Stock" sort={sort} onToggle={toggleSort} className="text-right" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
|
Loading components...
|
|
</TableCell>
|
|
</TableRow>
|
|
) : sorted.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
|
No components match the selected filters
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
sorted.map((comp) => (
|
|
<TableRow key={comp.id}>
|
|
<TableCell className="font-mono text-xs">
|
|
{comp.part_number}
|
|
</TableCell>
|
|
<TableCell className="font-medium">
|
|
{comp.name}
|
|
</TableCell>
|
|
<TableCell>
|
|
{comp.category_name ? (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{comp.category_name}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{comp.customer_supplied ? (
|
|
<Badge variant="outline" className="text-xs border-primary/30 text-primary">
|
|
{comp.customer_name || "Customer"}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">Standard</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
{comp.shelf_location || "-"}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono tabular-nums">
|
|
{comp.stock_quantity ?? 0}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|