583 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|