chore: upload parte 1 - logica
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user