pelagia-portal/App/app/(portal)/inventory/items/items-table.tsx
2026-05-18 23:18:58 +05:30

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>
);
}