Files
website-secure/app/page.tsx
T
pm 9c14a19a87
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s
chore: upload parte 1 - logica
2026-06-01 15:35:53 +01:00

583 lines
24 KiB
TypeScript

"use client";
import { useState } from "react";
import useSWR from "swr";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Cpu,
Package,
AlertTriangle,
Euro,
ArrowRight,
TrendingUp,
FileText,
ExternalLink,
ClipboardList,
ShoppingCart,
} from "lucide-react";
import { useTableSort } from "@/hooks/use-table-sort";
import { SortableTableHead } from "@/components/sortable-table-head";
import { formatCurrency } from "@/lib/format";
import { toast } from "sonner";
import type { Component } from "@/lib/types";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
interface OrderRow {
id: number;
po_number: string;
status: string;
order_date: string;
due_date: string | null;
customer_id: number;
customer_name: string;
customer_code: string;
total_ordered: number;
total_shipped: number;
total_remaining: number;
total_value: number;
item_count: number;
}
interface DashboardData {
stats: {
total_components: number;
total_inventory_value: number;
low_stock_count: number;
total_products: number;
active_products: number;
draft_products: number;
active_orders: number;
in_production_orders: number;
confirmed_orders: number;
shipped_orders: number;
};
low_stock_items: Component[];
category_breakdown: { name: string; count: number; total_stock: number }[];
recent_orders: OrderRow[];
}
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
draft: { label: "Draft", variant: "secondary" },
confirmed: { label: "Confirmed", variant: "default" },
in_production: { label: "In Production", variant: "outline" },
shipped: { label: "Shipped", variant: "default" },
completed: { label: "Completed", variant: "default" },
cancelled: { label: "Cancelled", variant: "destructive" },
};
export default function DashboardPage() {
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [poQuantities, setPoQuantities] = useState<Record<number, number>>({});
const [generatingPo, setGeneratingPo] = useState(false);
const { data, isLoading } = useSWR<DashboardData>("/api/dashboard", fetcher);
const { sorted: sortedLowStock, sort: lowStockSort, toggleSort: toggleLowStockSort } = useTableSort(data?.low_stock_items ?? []);
const { sorted: sortedOrders, sort: ordersSort, toggleSort: toggleOrdersSort } = useTableSort(data?.recent_orders ?? []);
function toggleItem(id: number) {
setSelectedItems((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function toggleAll(items: Component[]) {
if (selectedItems.size === items.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(items.map((i) => i.id)));
}
}
async function handleGeneratePo(lowStockItems: Component[]) {
const selected = lowStockItems.filter((i) => selectedItems.has(i.id));
if (selected.length === 0) return;
setGeneratingPo(true);
try {
// Fetch suppliers to resolve IDs from names
const suppRes = await fetch("/api/suppliers");
const suppliers = await suppRes.json();
const supplierMap: Record<string, { id: number; name: string }> = {};
for (const s of Array.isArray(suppliers) ? suppliers : []) {
supplierMap[s.name] = { id: s.id, name: s.name };
}
// Group items by supplier
const grouped: Record<string, { supplier_id: number; supplier_name: string; items: Record<string, unknown>[] }> = {};
for (const item of selected) {
const supplierName = item.supplier || "Unknown Supplier";
if (!grouped[supplierName]) {
const sup = supplierMap[supplierName];
if (!sup) {
toast.error(`Supplier "${supplierName}" not found. Please add it first.`);
setGeneratingPo(false);
return;
}
grouped[supplierName] = {
supplier_id: sup.id,
supplier_name: sup.name,
items: [],
};
}
const qty = poQuantities[item.id] || Math.max(1, item.min_stock_level - item.stock_quantity);
grouped[supplierName].items.push({
component_id: item.id,
part_number: item.part_number,
component_name: item.name,
supplier_part_number: item.supplier_part_number || "",
quantity: qty,
unit_price: Number(item.unit_price) || 0,
});
}
const orders = Object.values(grouped);
const res = await fetch("/api/supplier-pos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orders }),
});
if (!res.ok) throw new Error("Failed to create POs");
const created = await res.json();
const count = Array.isArray(created) ? created.length : 1;
toast.success(`${count} purchase order(s) created in Draft status`);
setSelectedItems(new Set());
setPoQuantities({});
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to generate POs");
} finally {
setGeneratingPo(false);
}
}
const stats = data?.stats;
if (isLoading || !data) {
return (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Dashboard
</h1>
<p className="text-sm text-muted-foreground">
Overview of your component inventory and products
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardContent className="py-6">
<div className="h-16 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
return (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Dashboard
</h1>
<p className="text-sm text-muted-foreground">
Overview of your component inventory and products
</p>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
<Cpu className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">Total Components</p>
<p className="text-2xl font-bold">{stats.total_components}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-accent/10">
<Euro className="h-5 w-5 text-accent" />
</div>
<div>
<p className="text-sm text-muted-foreground">Inventory Value</p>
<p className="text-2xl font-bold font-mono">
{formatCurrency(stats.total_inventory_value)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-warning/10">
<AlertTriangle className="h-5 w-5 text-warning" />
</div>
<div>
<p className="text-sm text-muted-foreground">Low Stock Alerts</p>
<p className="text-2xl font-bold">
{stats.low_stock_count}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">Products</p>
<p className="text-2xl font-bold">
{stats.total_products}
{stats.active_products > 0 && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({stats.active_products} active)
</span>
)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
<ClipboardList className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">Active Orders</p>
<p className="text-2xl font-bold">{stats.active_orders}</p>
</div>
</CardContent>
</Card>
</div>
<div>
{/* Low Stock Alerts */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-warning" />
Low Stock Alerts
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href="/components?lowStock=true">
View All
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
</CardHeader>
<CardContent>
{sortedLowStock.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<TrendingUp className="h-8 w-8 text-accent mb-2" />
<p className="text-sm font-medium text-foreground">
All stock levels are healthy
</p>
<p className="text-xs text-muted-foreground">
No components are below their minimum stock level
</p>
</div>
) : (
<div className="space-y-3">
{/* Generate PO action bar */}
{selectedItems.size > 0 && (
<div className="flex items-center justify-between rounded-md border border-primary/20 bg-primary/5 px-4 py-2.5">
<span className="text-sm font-medium text-primary">
{selectedItems.size} component(s) selected
</span>
<Button
size="sm"
onClick={() => handleGeneratePo(data?.low_stock_items ?? [])}
disabled={generatingPo}
>
<ShoppingCart className="mr-1.5 h-3.5 w-3.5" />
{generatingPo ? "Generating..." : "Generate PO"}
</Button>
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={sortedLowStock.length > 0 && selectedItems.size === sortedLowStock.slice(0, 8).length}
onCheckedChange={() => toggleAll(sortedLowStock.slice(0, 8))}
/>
</TableHead>
<SortableTableHead column="part_number" label="Part Number" sort={lowStockSort} onToggle={toggleLowStockSort} />
<SortableTableHead column="name" label="Name" sort={lowStockSort} onToggle={toggleLowStockSort} />
<SortableTableHead column="supplier" label="Supplier" sort={lowStockSort} onToggle={toggleLowStockSort} />
<SortableTableHead column="stock_quantity" label="Stock" sort={lowStockSort} onToggle={toggleLowStockSort} className="text-right" />
<SortableTableHead column="min_stock_level" label="Min Level" sort={lowStockSort} onToggle={toggleLowStockSort} className="text-right" />
<TableHead>Level</TableHead>
<TableHead className="text-right w-[110px]">Order Qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedLowStock.slice(0, 8).map((item) => {
const pct =
item.min_stock_level > 0
? Math.min(
(item.stock_quantity / item.min_stock_level) * 100,
100
)
: 0;
const isSelected = selectedItems.has(item.id);
const deficit = Math.max(1, item.min_stock_level - item.stock_quantity);
return (
<TableRow key={item.id} className={isSelected ? "bg-primary/5" : ""}>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleItem(item.id)}
/>
</TableCell>
<TableCell className="font-mono text-xs font-medium">
{item.part_number}
</TableCell>
<TableCell className="font-medium text-sm">
{item.name}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{item.supplier || <span className="italic">No supplier</span>}
</TableCell>
<TableCell className="text-right font-semibold text-destructive">
{item.stock_quantity}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{item.min_stock_level}
</TableCell>
<TableCell className="w-[100px]">
<Progress
value={pct}
className="h-2 [&>div]:bg-destructive"
/>
</TableCell>
<TableCell className="text-right">
{isSelected ? (
<Input
type="number"
min={1}
className="h-7 w-24 text-xs font-mono text-right ml-auto"
value={poQuantities[item.id] ?? deficit}
onChange={(e) =>
setPoQuantities((prev) => ({
...prev,
[item.id]: Math.max(1, Number(e.target.value) || 1),
}))
}
/>
) : (
<span className="text-xs text-muted-foreground font-mono">{deficit}</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
{/* Purchase Orders Status */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4 text-primary" />
Purchase Order Status
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href="/customers">
View All
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
</CardHeader>
<CardContent>
{sortedOrders.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<ClipboardList className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm font-medium text-foreground">
No active purchase orders
</p>
<p className="text-xs text-muted-foreground">
Purchase orders from customers will appear here
</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="po_number" label="PO Number" sort={ordersSort} onToggle={toggleOrdersSort} />
<SortableTableHead column="customer_name" label="Customer" sort={ordersSort} onToggle={toggleOrdersSort} />
<SortableTableHead column="status" label="Status" sort={ordersSort} onToggle={toggleOrdersSort} />
<SortableTableHead column="order_date" label="Order Date" sort={ordersSort} onToggle={toggleOrdersSort} />
<SortableTableHead column="due_date" label="Due Date" sort={ordersSort} onToggle={toggleOrdersSort} />
<SortableTableHead column="total_value" label="Value" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
<SortableTableHead column="total_ordered" label="Ordered" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
<SortableTableHead column="total_shipped" label="Shipped" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
<SortableTableHead column="total_remaining" label="Remaining" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
<TableHead className="w-[140px]">Fulfillment</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{sortedOrders.map((order) => {
const ordered = Number(order.total_ordered);
const shipped = Number(order.total_shipped);
const remaining = Number(order.total_remaining);
const pct =
ordered > 0 ? Math.round((shipped / ordered) * 100) : 0;
const sc = statusConfig[order.status] || statusConfig.draft;
const isOverdue =
order.due_date &&
new Date(order.due_date) < new Date() &&
pct < 100;
return (
<TableRow key={order.id}>
<TableCell className="font-mono text-sm font-medium">
<Link
href={`/customers/${order.customer_id}/orders/${order.id}`}
className="flex items-center gap-1.5 text-primary hover:underline"
>
{order.po_number}
<ExternalLink className="h-3 w-3" />
</Link>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-sm font-medium">
{order.customer_name}
</span>
<span className="text-xs text-muted-foreground font-mono">
{order.customer_code}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={sc.variant} className="text-xs">
{sc.label}
</Badge>
</TableCell>
<TableCell className="text-sm">
{new Date(order.order_date).toLocaleDateString()}
</TableCell>
<TableCell className="text-sm">
{order.due_date ? (
<span
className={
isOverdue
? "font-medium text-destructive"
: "text-muted-foreground"
}
>
{new Date(order.due_date).toLocaleDateString()}
{isOverdue && " (overdue)"}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatCurrency(Number(order.total_value))}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{ordered}
</TableCell>
<TableCell className="text-right font-mono text-sm text-accent">
{shipped}
</TableCell>
<TableCell className="text-right font-mono text-sm font-semibold">
<span
className={
remaining > 0
? "text-warning"
: "text-accent"
}
>
{remaining}
</span>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="font-mono text-muted-foreground">
{shipped}/{ordered}
</span>
<span
className={
pct === 100
? "font-semibold text-accent"
: "text-muted-foreground"
}
>
{pct}%
</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
asChild
>
<Link
href={`/customers/${order.customer_id}/orders/${order.id}`}
>
<ExternalLink className="h-3.5 w-3.5" />
<span className="sr-only">Open order details</span>
</Link>
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}