317 lines
14 KiB
TypeScript
317 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, Fragment, useEffect } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { Search, X, ChevronDown, ChevronRight, MapPin, Tag } from "lucide-react";
|
|
import { formatCurrency } from "@/lib/utils";
|
|
import { addToCart } from "@/lib/cart";
|
|
|
|
type VendorOption = {
|
|
vendorId: string;
|
|
vendorName: string;
|
|
isVerified: boolean;
|
|
price: number;
|
|
distanceKm: number | null;
|
|
};
|
|
|
|
type CatalogItem = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
description: string;
|
|
vendors: VendorOption[];
|
|
};
|
|
|
|
function formatDist(km: number) {
|
|
return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(0)} km`;
|
|
}
|
|
|
|
type SiteOption = { id: string; name: string; code: string };
|
|
|
|
export function ItemsTable({
|
|
items,
|
|
hasSite,
|
|
sites = [],
|
|
currentSiteId = null,
|
|
}: {
|
|
items: CatalogItem[];
|
|
hasSite: boolean;
|
|
sites?: SiteOption[];
|
|
currentSiteId?: string | null;
|
|
}) {
|
|
const router = useRouter();
|
|
const [query, setQuery] = useState("");
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const [sortBy, setSortBy] = useState<"distance" | "price">(hasSite ? "distance" : "price");
|
|
const [added, setAdded] = useState<Record<string, boolean>>({});
|
|
|
|
// Reset sort to distance whenever the selected site changes
|
|
useEffect(() => {
|
|
setSortBy(currentSiteId ? "distance" : "price");
|
|
}, [currentSiteId]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.toLowerCase().trim();
|
|
if (!q) return items;
|
|
return items.filter(
|
|
(item) =>
|
|
item.name.toLowerCase().includes(q) ||
|
|
item.code.toLowerCase().includes(q) ||
|
|
item.description.toLowerCase().includes(q)
|
|
);
|
|
}, [items, query]);
|
|
|
|
function getSortedVendors(vendors: VendorOption[]) {
|
|
const v = [...vendors];
|
|
if (sortBy === "distance") {
|
|
v.sort((a, b) => {
|
|
if (a.distanceKm !== null && b.distanceKm !== null) return a.distanceKm - b.distanceKm;
|
|
if (a.distanceKm !== null) return -1;
|
|
if (b.distanceKm !== null) return 1;
|
|
return a.price - b.price;
|
|
});
|
|
} else {
|
|
v.sort((a, b) => a.price - b.price);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function handleAdd(item: CatalogItem, vendor: VendorOption) {
|
|
addToCart({
|
|
productId: item.id,
|
|
name: item.name,
|
|
description: item.description || undefined,
|
|
quantity: 1,
|
|
unit: "pc",
|
|
unitPrice: vendor.price,
|
|
vendorId: vendor.vendorId,
|
|
vendorName: vendor.vendorName,
|
|
});
|
|
const key = `${item.id}-${vendor.vendorId}`;
|
|
setAdded((prev) => ({ ...prev, [key]: true }));
|
|
setTimeout(() => setAdded((prev) => ({ ...prev, [key]: false })), 1500);
|
|
}
|
|
|
|
function toggleRow(id: string) {
|
|
setExpandedId((prev) => (prev === id ? null : id));
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Site selector */}
|
|
{sites.length > 0 && (
|
|
<div className="flex items-center gap-3 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
|
<MapPin className="h-4 w-4 text-neutral-400 shrink-0" />
|
|
<label className="text-sm font-medium text-neutral-700 whitespace-nowrap">Working Site</label>
|
|
<select
|
|
value={currentSiteId ?? ""}
|
|
onChange={(e) => {
|
|
const id = e.target.value;
|
|
router.push(id ? `/inventory/items?siteId=${id}` : "/inventory/items");
|
|
}}
|
|
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
|
>
|
|
<option value="">No site selected — distances hidden</option>
|
|
{sites.map((s) => (
|
|
<option key={s.id} value={s.id}>{s.name} ({s.code})</option>
|
|
))}
|
|
</select>
|
|
{currentSiteId && (
|
|
<span className="text-xs text-primary-600">Distances shown from selected site</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-neutral-400" />
|
|
<input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search by name, code or description…"
|
|
className="w-full rounded-lg border border-neutral-200 py-2 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
|
/>
|
|
{query && (
|
|
<button onClick={() => setQuery("")} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600">
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-neutral-400">
|
|
{filtered.length} item{filtered.length !== 1 ? "s" : ""}
|
|
</span>
|
|
{/* Vendor sort toggle — only shown when an item is expanded */}
|
|
{expandedId && (
|
|
<div className="flex items-center gap-1 ml-auto text-xs">
|
|
<span className="text-neutral-500">Vendors sorted by:</span>
|
|
<button
|
|
onClick={() => setSortBy("distance")}
|
|
disabled={!hasSite}
|
|
title={!hasSite ? "Select a site first" : undefined}
|
|
className={`flex items-center gap-1 px-2 py-1 rounded font-medium transition-colors ${
|
|
sortBy === "distance" ? "bg-primary-100 text-primary-700" : "text-neutral-500 hover:bg-neutral-100"
|
|
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
|
>
|
|
<MapPin className="h-3 w-3" /> Distance
|
|
</button>
|
|
<button
|
|
onClick={() => setSortBy("price")}
|
|
className={`flex items-center gap-1 px-2 py-1 rounded font-medium transition-colors ${
|
|
sortBy === "price" ? "bg-primary-100 text-primary-700" : "text-neutral-500 hover:bg-neutral-100"
|
|
}`}
|
|
>
|
|
<Tag className="h-3 w-3" /> Price
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-medium text-neutral-600 w-6" />
|
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Item</th>
|
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th>
|
|
<th className="px-4 py-3 text-right font-medium text-neutral-600">Vendors</th>
|
|
<th className="px-4 py-3 text-right font-medium text-neutral-600">From</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-10 text-center text-neutral-400 italic">
|
|
{query ? `No items match "${query}"` : "No items in catalogue yet."}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{filtered.map((item) => {
|
|
const isOpen = expandedId === item.id;
|
|
const lowestPrice = item.vendors.length > 0 ? Math.min(...item.vendors.map((v) => v.price)) : null;
|
|
const sortedVendors = getSortedVendors(item.vendors);
|
|
|
|
// Cheapest and closest are independent of current sort order
|
|
const minPrice = sortedVendors.length > 1 ? Math.min(...sortedVendors.map((v) => v.price)) : null;
|
|
const closestVendorId = hasSite
|
|
? (sortedVendors.filter((v) => v.distanceKm !== null).sort((a, b) => a.distanceKm! - b.distanceKm!)[0]?.vendorId ?? null)
|
|
: null;
|
|
|
|
return (
|
|
<Fragment key={item.id}>
|
|
{/* Item row */}
|
|
<tr
|
|
className={`cursor-pointer border-b border-neutral-100 transition-colors ${isOpen ? "bg-primary-50" : "hover:bg-neutral-50"}`}
|
|
onClick={() => toggleRow(item.id)}
|
|
>
|
|
<td className="px-4 py-3 text-neutral-400">
|
|
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="font-medium text-neutral-900">{item.name}</span>
|
|
{item.description && (
|
|
<span className="block text-xs text-neutral-500 mt-0.5 line-clamp-1">{item.description}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-xs text-neutral-500">{item.code}</td>
|
|
<td className="px-4 py-3 text-right text-neutral-600">
|
|
{item.vendors.length > 0 ? item.vendors.length : <span className="text-neutral-400">—</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
{lowestPrice !== null
|
|
? <span className="font-medium text-success-700">{formatCurrency(lowestPrice)}</span>
|
|
: <span className="text-neutral-400 italic text-xs">No price</span>}
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Expanded vendor sub-rows */}
|
|
{isOpen && (
|
|
<tr key={`${item.id}-vendors`} className="border-b border-neutral-200">
|
|
<td colSpan={5} className="p-0">
|
|
{sortedVendors.length === 0 ? (
|
|
<div className="px-12 py-3 text-xs text-neutral-400 italic bg-neutral-50">
|
|
No vendors on record. Add this item manually in the PO form.
|
|
</div>
|
|
) : (
|
|
<table className="w-full text-sm bg-neutral-50">
|
|
<thead>
|
|
<tr className="border-b border-neutral-200">
|
|
<th className="px-12 py-2 text-left text-xs font-medium text-neutral-500 uppercase tracking-wide">Vendor</th>
|
|
<th className="px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase tracking-wide">Price</th>
|
|
{hasSite && <th className="px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase tracking-wide">Distance</th>}
|
|
<th className="px-4 py-2 w-24" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-neutral-100">
|
|
{sortedVendors.map((vendor) => {
|
|
const key = `${item.id}-${vendor.vendorId}`;
|
|
const isCheapest = minPrice !== null && vendor.price === minPrice;
|
|
const isClosest = closestVendorId !== null && vendor.vendorId === closestVendorId;
|
|
return (
|
|
<tr key={vendor.vendorId} className="hover:bg-white">
|
|
<td className="px-12 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
href={`/inventory/vendors/${vendor.vendorId}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="font-medium text-neutral-800 hover:text-primary-600 hover:underline"
|
|
>
|
|
{vendor.vendorName}
|
|
</Link>
|
|
{vendor.isVerified && (
|
|
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
|
|
)}
|
|
{isClosest && (
|
|
<span className="text-xs text-primary-600 font-medium">★ Closest</span>
|
|
)}
|
|
{isCheapest && (
|
|
<span className="text-xs text-success-600 font-medium">Cheapest</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right font-semibold text-neutral-900">
|
|
{formatCurrency(vendor.price)}
|
|
</td>
|
|
{hasSite && (
|
|
<td className="px-4 py-2.5 text-right text-xs text-neutral-500">
|
|
{vendor.distanceKm !== null ? formatDist(vendor.distanceKm) : "—"}
|
|
</td>
|
|
)}
|
|
<td className="px-4 py-2.5 text-right">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleAdd(item, vendor); }}
|
|
className={`rounded px-2.5 py-1 text-xs font-semibold transition-colors ${
|
|
added[key]
|
|
? "bg-success-600 text-white"
|
|
: "bg-primary-600 text-white hover:bg-primary-700"
|
|
}`}
|
|
>
|
|
{added[key] ? "Added ✓" : "+ Cart"}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{filtered.length > 0 && (
|
|
<p className="text-xs text-neutral-400">
|
|
Click any row to see vendors. Prices shown are last known from paid POs.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|