633 lines
24 KiB
TypeScript
633 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import useSWR from "swr";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Search,
|
|
History,
|
|
ShoppingCart,
|
|
ArrowUpCircle,
|
|
ArrowDownCircle,
|
|
RotateCcw,
|
|
Euro,
|
|
Package,
|
|
TrendingUp,
|
|
Download,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Printer,
|
|
ExternalLink,
|
|
FileDown,
|
|
} from "lucide-react";
|
|
import { useTableSort } from "@/hooks/use-table-sort";
|
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
|
import { formatCurrency } from "@/lib/format";
|
|
import { generateLabelPdf } from "@/lib/generate-label-pdf";
|
|
import { toast } from "sonner";
|
|
import type { StockTransaction } from "@/lib/types";
|
|
|
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
|
|
const PAGE_SIZE = 25;
|
|
|
|
const typeConfig: Record<
|
|
string,
|
|
{ label: string; color: string; icon: typeof ShoppingCart }
|
|
> = {
|
|
purchase: { label: "Purchase", color: "bg-accent text-accent-foreground", icon: ShoppingCart },
|
|
adjustment: { label: "Adjustment", color: "bg-primary text-primary-foreground", icon: ArrowUpCircle },
|
|
return: { label: "Return", color: "bg-warning text-warning-foreground", icon: RotateCcw },
|
|
consumption: { label: "Consumption", color: "bg-destructive text-destructive-foreground", icon: ArrowDownCircle },
|
|
};
|
|
|
|
export default function StockHistoryPage() {
|
|
const [search, setSearch] = useState("");
|
|
const [typeFilter, setTypeFilter] = useState("all");
|
|
const [page, setPage] = useState(0);
|
|
|
|
// PO detail dialog state
|
|
const [poDialogOpen, setPoDialogOpen] = useState(false);
|
|
const [poDetail, setPoDetail] = useState<Record<string, unknown> | null>(null);
|
|
const [poLoading, setPoLoading] = useState(false);
|
|
|
|
async function handleOpenPo(poNumber: string) {
|
|
setPoLoading(true);
|
|
setPoDialogOpen(true);
|
|
try {
|
|
// Find PO by number from list
|
|
const listRes = await fetch("/api/supplier-pos");
|
|
const allPos = await listRes.json();
|
|
const match = (Array.isArray(allPos) ? allPos : []).find(
|
|
(p: Record<string, unknown>) => p.po_number === poNumber
|
|
);
|
|
if (!match) {
|
|
toast.error("Purchase order not found");
|
|
setPoDialogOpen(false);
|
|
return;
|
|
}
|
|
// Fetch full detail
|
|
const detailRes = await fetch(`/api/supplier-pos/${match.id}`);
|
|
const detail = await detailRes.json();
|
|
setPoDetail(detail);
|
|
} catch {
|
|
toast.error("Failed to load purchase order");
|
|
setPoDialogOpen(false);
|
|
} finally {
|
|
setPoLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleDownloadPoPdf() {
|
|
if (!poDetail) return;
|
|
try {
|
|
const { generateSupplierPoPdf } = await import("@/lib/generate-supplier-po-pdf");
|
|
const doc = generateSupplierPoPdf({
|
|
po_number: String(poDetail.po_number),
|
|
date: String(poDetail.created_at),
|
|
status: String(poDetail.status),
|
|
supplier_name: String(poDetail.supplier_name),
|
|
supplier_code: String(poDetail.supplier_code),
|
|
supplier_email: poDetail.supplier_email as string | null,
|
|
supplier_phone: poDetail.supplier_phone as string | null,
|
|
supplier_address: poDetail.supplier_address as string | null,
|
|
supplier_contact: poDetail.supplier_contact as string | null,
|
|
supplier_country: poDetail.supplier_country as string | null,
|
|
supplier_currency: poDetail.supplier_currency as string | null,
|
|
shipping_cost: Number(poDetail.shipping_cost) || 0,
|
|
notes: poDetail.notes as string | null,
|
|
items: (Array.isArray(poDetail.items) ? poDetail.items : []).map((item: Record<string, unknown>) => ({
|
|
part_number: String(item.part_number || ""),
|
|
component_name: String(item.component_name || ""),
|
|
supplier_part_number: String(item.supplier_part_number || ""),
|
|
quantity: Number(item.quantity),
|
|
unit_price: Number(item.unit_price),
|
|
})),
|
|
});
|
|
doc.save(`${poDetail.po_number}.pdf`);
|
|
} catch {
|
|
toast.error("Failed to generate PO PDF");
|
|
}
|
|
}
|
|
|
|
// Label printing state
|
|
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
|
|
const [labelPartNumber, setLabelPartNumber] = useState("");
|
|
const [labelQuantity, setLabelQuantity] = useState<number>(1);
|
|
const [printingLabel, setPrintingLabel] = useState(false);
|
|
|
|
function openLabelDialog(partNumber: string, defaultQty: number) {
|
|
setLabelPartNumber(partNumber);
|
|
setLabelQuantity(defaultQty);
|
|
setLabelDialogOpen(true);
|
|
}
|
|
|
|
async function handlePrintLabel() {
|
|
if (!labelPartNumber) return;
|
|
setPrintingLabel(true);
|
|
try {
|
|
const doc = await generateLabelPdf({
|
|
partNumber: labelPartNumber,
|
|
quantity: labelQuantity,
|
|
});
|
|
doc.save(`label-${labelPartNumber}.pdf`);
|
|
toast.success("Label PDF generated");
|
|
setLabelDialogOpen(false);
|
|
} catch {
|
|
toast.error("Failed to generate label");
|
|
} finally {
|
|
setPrintingLabel(false);
|
|
}
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
if (search) params.set("search", search);
|
|
if (typeFilter && typeFilter !== "all") params.set("type", typeFilter);
|
|
params.set("limit", String(PAGE_SIZE));
|
|
params.set("offset", String(page * PAGE_SIZE));
|
|
|
|
const { data } = useSWR<{
|
|
transactions: StockTransaction[];
|
|
total: number;
|
|
}>(`/api/stock?${params.toString()}`, fetcher, {
|
|
keepPreviousData: true,
|
|
});
|
|
|
|
const transactions = data?.transactions || [];
|
|
const total = data?.total || 0;
|
|
const { sorted: sortedTransactions, sort, toggleSort } = useTableSort(transactions);
|
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
|
|
|
// Summary stats
|
|
const stats = useMemo(() => {
|
|
if (!transactions.length)
|
|
return {
|
|
totalTransactions: total,
|
|
purchaseCount: 0,
|
|
totalSpent: 0,
|
|
avgOrderSize: 0,
|
|
};
|
|
|
|
const purchases = transactions.filter(
|
|
(t) => t.transaction_type === "purchase"
|
|
);
|
|
const totalSpent = purchases.reduce(
|
|
(sum, t) => sum + Number(t.total_cost || 0),
|
|
0
|
|
);
|
|
|
|
return {
|
|
totalTransactions: total,
|
|
purchaseCount: purchases.length,
|
|
totalSpent,
|
|
avgOrderSize:
|
|
purchases.length > 0 ? totalSpent / purchases.length : 0,
|
|
};
|
|
}, [transactions, total]);
|
|
|
|
function handleExportCSV() {
|
|
if (!transactions.length) return;
|
|
|
|
const headers = [
|
|
"Date",
|
|
"Type",
|
|
"Part Number",
|
|
"Component",
|
|
"Category",
|
|
"Quantity",
|
|
"Unit Cost",
|
|
"Total Cost",
|
|
"Supplier",
|
|
"Purchase Order",
|
|
"Notes",
|
|
];
|
|
|
|
const rows = transactions.map((t) => [
|
|
new Date(t.created_at).toLocaleString(),
|
|
t.transaction_type,
|
|
t.part_number || "",
|
|
`"${t.component_name || ""}"`,
|
|
t.category_name || "",
|
|
t.quantity,
|
|
t.unit_cost ?? "",
|
|
t.total_cost ?? "",
|
|
t.supplier || "",
|
|
t.supplier_order_ref || "",
|
|
`"${(t.notes || "").replace(/"/g, '""')}"`,
|
|
]);
|
|
|
|
const csv = [headers, ...rows].map((r) => r.join(",")).join("\n");
|
|
const blob = new Blob([csv], { type: "text/csv" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `stock-history-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// Reset page when filters change
|
|
function handleSearchChange(val: string) {
|
|
setSearch(val);
|
|
setPage(0);
|
|
}
|
|
|
|
function handleTypeChange(val: string) {
|
|
setTypeFilter(val);
|
|
setPage(0);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
|
Purchase History
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Track all stock transactions, purchases, and inventory changes
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" onClick={handleExportCSV} disabled={!transactions.length}>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
Export CSV
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
Total Transactions
|
|
</CardTitle>
|
|
<History className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold">
|
|
{stats.totalTransactions.toLocaleString()}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
Purchases (This Page)
|
|
</CardTitle>
|
|
<Package className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold">{stats.purchaseCount}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
Total Spent (This Page)
|
|
</CardTitle>
|
|
<Euro className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold font-mono">
|
|
{formatCurrency(stats.totalSpent)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
Avg Purchase Cost
|
|
</CardTitle>
|
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold font-mono">
|
|
{formatCurrency(stats.avgOrderSize)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative flex-1 min-w-[240px] max-w-md">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search by component name or part number..."
|
|
value={search}
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<Select value={typeFilter} onValueChange={handleTypeChange}>
|
|
<SelectTrigger className="w-[200px]">
|
|
<SelectValue placeholder="All Types" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Types</SelectItem>
|
|
<SelectItem value="purchase">Purchases</SelectItem>
|
|
<SelectItem value="adjustment">Adjustments</SelectItem>
|
|
<SelectItem value="return">Returns</SelectItem>
|
|
<SelectItem value="consumption">Consumption</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Transactions Table */}
|
|
<div className="rounded-lg border bg-card">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<SortableTableHead column="created_at" label="Date" sort={sort} onToggle={toggleSort} className="w-[160px]" />
|
|
<SortableTableHead column="transaction_type" label="Type" sort={sort} onToggle={toggleSort} className="w-[120px]" />
|
|
<SortableTableHead column="part_number" label="Part Number" sort={sort} onToggle={toggleSort} className="w-[130px]" />
|
|
<SortableTableHead column="component_name" label="Component" sort={sort} onToggle={toggleSort} />
|
|
<SortableTableHead column="quantity" label="Qty" sort={sort} onToggle={toggleSort} className="text-right" />
|
|
<SortableTableHead column="supplier" label="Supplier" sort={sort} onToggle={toggleSort} />
|
|
<SortableTableHead column="supplier_order_ref" label="Purchase Order" sort={sort} onToggle={toggleSort} />
|
|
<TableHead className="w-[50px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sortedTransactions.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={8}
|
|
className="text-center py-12 text-muted-foreground"
|
|
>
|
|
{search || typeFilter !== "all"
|
|
? "No transactions match your filters."
|
|
: "No stock transactions yet. Use the \"Add Stock\" button on a component to record purchases."}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
sortedTransactions.map((txn) => {
|
|
const config = typeConfig[txn.transaction_type] || typeConfig.purchase;
|
|
const IconComponent = config.icon;
|
|
return (
|
|
<TableRow key={txn.id}>
|
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
|
{new Date(txn.created_at).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
})}
|
|
<br />
|
|
<span className="text-muted-foreground/60">
|
|
{new Date(txn.created_at).toLocaleTimeString("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge className={`${config.color} text-xs gap-1`}>
|
|
<IconComponent className="h-3 w-3" />
|
|
{config.label}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs font-medium">
|
|
{txn.part_number || "-"}
|
|
</TableCell>
|
|
<TableCell className="font-medium text-sm">
|
|
{txn.component_name || "-"}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono text-sm">
|
|
<span
|
|
className={
|
|
txn.transaction_type === "consumption"
|
|
? "text-destructive"
|
|
: "text-accent"
|
|
}
|
|
>
|
|
{txn.transaction_type === "consumption" ? "-" : "+"}
|
|
{Number(txn.quantity).toLocaleString()}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{txn.supplier || "-"}
|
|
</TableCell>
|
|
<TableCell className="!text-left">
|
|
<div className="text-left">
|
|
{txn.supplier_order_ref ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenPo(txn.supplier_order_ref!)}
|
|
className="inline-flex items-center gap-1 font-mono text-xs text-primary hover:underline cursor-pointer"
|
|
>
|
|
{txn.supplier_order_ref}
|
|
<ExternalLink className="h-3 w-3" />
|
|
</button>
|
|
) : (
|
|
<span className="text-muted-foreground">-</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{txn.part_number && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
title="Print Label"
|
|
onClick={() => openLabelDialog(txn.part_number || "", Number(txn.quantity))}
|
|
>
|
|
<Printer className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-muted-foreground">
|
|
Showing {page * PAGE_SIZE + 1}-
|
|
{Math.min((page + 1) * PAGE_SIZE, total)} of{" "}
|
|
{total.toLocaleString()} transactions
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
disabled={page === 0}
|
|
>
|
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground px-2">
|
|
Page {page + 1} of {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
disabled={page >= totalPages - 1}
|
|
>
|
|
Next
|
|
<ChevronRight className="ml-1 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* PO Detail Dialog */}
|
|
<Dialog open={poDialogOpen} onOpenChange={setPoDialogOpen}>
|
|
<DialogContent className="sm:max-w-[700px] max-h-[85vh] overflow-y-auto">
|
|
{poLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<p className="text-sm text-muted-foreground">Loading purchase order...</p>
|
|
</div>
|
|
) : poDetail ? (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-3">
|
|
{String(poDetail.po_number)}
|
|
<Badge variant="outline" className="text-xs capitalize">
|
|
{String(poDetail.status || "").replace(/_/g, " ")}
|
|
</Badge>
|
|
</DialogTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
Supplier: <span className="font-medium text-foreground">{String(poDetail.supplier_name)}</span>
|
|
{" | "}
|
|
{new Date(String(poDetail.created_at)).toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" })}
|
|
</p>
|
|
</DialogHeader>
|
|
|
|
<div className="rounded-lg border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Part Number</TableHead>
|
|
<TableHead>Component</TableHead>
|
|
<TableHead className="text-right">Qty</TableHead>
|
|
<TableHead className="text-right">Unit Price</TableHead>
|
|
<TableHead className="text-right">Total</TableHead>
|
|
<TableHead className="text-right">Received</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(Array.isArray(poDetail.items) ? poDetail.items : []).map((item: Record<string, unknown>, idx: number) => (
|
|
<TableRow key={idx}>
|
|
<TableCell className="font-mono text-xs">{String(item.part_number || "")}</TableCell>
|
|
<TableCell className="text-sm">{String(item.component_name || "")}</TableCell>
|
|
<TableCell className="text-right font-mono">{Number(item.quantity)}</TableCell>
|
|
<TableCell className="text-right font-mono">{formatCurrency(Number(item.unit_price))}</TableCell>
|
|
<TableCell className="text-right font-mono font-semibold">
|
|
{formatCurrency(Number(item.quantity) * Number(item.unit_price))}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono">
|
|
{Number(item.received_quantity)}/{Number(item.quantity)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{poDetail.notes && (
|
|
<div className="text-sm text-muted-foreground">
|
|
<span className="font-medium text-foreground">Notes:</span> {String(poDetail.notes)}
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter className="flex-row gap-2 sm:justify-between">
|
|
<Button variant="outline" size="sm" onClick={handleDownloadPoPdf}>
|
|
<FileDown className="mr-2 h-3.5 w-3.5" />
|
|
Download PDF
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setPoDialogOpen(false)}>Close</Button>
|
|
</DialogFooter>
|
|
</>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Print Label Dialog */}
|
|
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
|
|
<DialogContent className="sm:max-w-[400px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Print Label</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
{/* Label preview */}
|
|
<div className="rounded-md border bg-background p-3 flex items-center gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs text-muted-foreground">Code:</p>
|
|
<p className="font-mono text-sm font-bold truncate">{labelPartNumber}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-xs text-muted-foreground">Quantity:</p>
|
|
<p className="font-mono text-sm font-bold">{labelQuantity}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="label-qty">Quantity</Label>
|
|
<Input
|
|
id="label-qty"
|
|
type="number"
|
|
min={1}
|
|
value={labelQuantity}
|
|
onChange={(e) => setLabelQuantity(Math.max(1, Number(e.target.value) || 1))}
|
|
className="font-mono"
|
|
/>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
Label size: 49mm x 12mm. The QR code will contain: {labelPartNumber},{labelQuantity}
|
|
</p>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setLabelDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handlePrintLabel} disabled={printingLabel}>
|
|
<Printer className="mr-2 h-4 w-4" />
|
|
{printingLabel ? "Generating..." : "Download Label PDF"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|