chore: upload parte 1 - logica
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s

This commit is contained in:
2026-06-01 15:35:53 +01:00
parent a63625d228
commit 9c14a19a87
127 changed files with 22255 additions and 0 deletions
+632
View File
@@ -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>
);
}