feat(inventory): vendor browse page with site selector for TECH/MANNING/SUPERUSER

Adds /inventory/vendors with distance-sorted vendor list and URL-param
site selector (?siteId=). Wires Items + Vendors + Cart into sidebar for
TECH/MANNING/SUPERUSER roles; MANAGER keeps admin management views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-15 12:01:29 +05:30
parent e887502e27
commit dacd688ebe
3 changed files with 280 additions and 1 deletions

View file

@ -0,0 +1,79 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { distanceKm } from "@/lib/geo";
import { VendorsTable } from "./vendors-table";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vendors" };
interface Props {
searchParams: Promise<{ siteId?: string }>;
}
export default async function InventoryVendorsPage({ searchParams }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
const { siteId } = await searchParams;
const [site, vendors, sites] = await Promise.all([
siteId
? db.site.findUnique({
where: { id: siteId, isActive: true },
select: { id: true, name: true, latitude: true, longitude: true },
})
: Promise.resolve(null),
db.vendor.findMany({
where: { isActive: true },
include: {
contacts: {
where: { isPrimary: true },
take: 1,
},
_count: { select: { vendorPrices: true } },
},
orderBy: { name: "asc" },
}),
db.site.findMany({
where: { isActive: true },
orderBy: { name: "asc" },
select: { id: true, name: true, code: true },
}),
]);
const rows = vendors.map((v) => {
let dist: number | null = null;
if (site?.latitude && site.longitude && v.latitude && v.longitude) {
dist = distanceKm(site.latitude, site.longitude, v.latitude, v.longitude);
}
return {
id: v.id,
name: v.name,
vendorId: v.vendorId ?? null,
gstin: v.gstin ?? null,
address: v.address ?? null,
isVerified: v.isVerified,
itemCount: v._count.vendorPrices,
primaryContact: v.contacts[0]
? { name: v.contacts[0].name, mobile: v.contacts[0].mobile ?? null }
: null,
distanceKm: dist,
};
});
return (
<div className="max-w-6xl">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Vendors</h1>
<p className="mt-1 text-sm text-neutral-500">Browse vendors and their distance from your working site.</p>
</div>
<VendorsTable
vendors={rows}
hasSite={!!site}
sites={sites}
currentSiteId={siteId ?? null}
/>
</div>
);
}

View file

@ -0,0 +1,198 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Search, X, MapPin, Tag } from "lucide-react";
import { formatDistance } from "@/lib/geo";
type VendorRow = {
id: string;
name: string;
vendorId: string | null;
gstin: string | null;
address: string | null;
isVerified: boolean;
itemCount: number;
primaryContact: { name: string; mobile: string | null } | null;
distanceKm: number | null;
};
type SiteOption = { id: string; name: string; code: string };
export function VendorsTable({
vendors,
hasSite,
sites = [],
currentSiteId = null,
}: {
vendors: VendorRow[];
hasSite: boolean;
sites?: SiteOption[];
currentSiteId?: string | null;
}) {
const router = useRouter();
const [query, setQuery] = useState("");
const [sortBy, setSortBy] = useState<"distance" | "name">(hasSite ? "distance" : "name");
const filtered = useMemo(() => {
const q = query.toLowerCase().trim();
const list = q
? vendors.filter(
(v) =>
v.name.toLowerCase().includes(q) ||
(v.gstin && v.gstin.toLowerCase().includes(q)) ||
(v.address && v.address.toLowerCase().includes(q))
)
: vendors;
return [...list].sort((a, b) => {
if (sortBy === "distance") {
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.name.localeCompare(b.name);
});
}, [vendors, query, sortBy]);
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/vendors?siteId=${id}` : "/inventory/vendors");
}}
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, GSTIN or address…"
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} vendor{filtered.length !== 1 ? "s" : ""}
</span>
<div className="flex items-center gap-1 ml-auto text-xs">
<span className="text-neutral-500">Sort 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("name")}
className={`flex items-center gap-1 px-2 py-1 rounded font-medium transition-colors ${
sortBy === "name" ? "bg-primary-100 text-primary-700" : "text-neutral-500 hover:bg-neutral-100"
}`}
>
<Tag className="h-3 w-3" /> Name
</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">Vendor</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">GSTIN</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Contact</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items</th>
{hasSite && <th className="px-4 py-3 text-right font-medium text-neutral-600">Distance</th>}
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{filtered.length === 0 && (
<tr>
<td colSpan={hasSite ? 5 : 4} className="px-4 py-10 text-center text-neutral-400 italic">
{query ? `No vendors match "${query}"` : "No active vendors on record."}
</td>
</tr>
)}
{filtered.map((vendor, idx) => (
<tr key={vendor.id} className="hover:bg-neutral-50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-medium text-neutral-900">{vendor.name}</span>
{vendor.isVerified && (
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
)}
{idx === 0 && sortBy === "distance" && vendor.distanceKm !== null && (
<span className="text-xs text-primary-600 font-medium"> Closest</span>
)}
</div>
{vendor.address && (
<span className="block text-xs text-neutral-400 mt-0.5 line-clamp-1">{vendor.address}</span>
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-500">
{vendor.gstin ?? <span className="text-neutral-300"></span>}
</td>
<td className="px-4 py-3 text-neutral-600">
{vendor.primaryContact ? (
<>
<span>{vendor.primaryContact.name}</span>
{vendor.primaryContact.mobile && (
<span className="block text-xs text-neutral-400">{vendor.primaryContact.mobile}</span>
)}
</>
) : <span className="text-neutral-300"></span>}
</td>
<td className="px-4 py-3 text-right text-neutral-600">
{vendor.itemCount > 0 ? vendor.itemCount : <span className="text-neutral-400"></span>}
</td>
{hasSite && (
<td className="px-4 py-3 text-right text-sm text-neutral-600">
{vendor.distanceKm !== null
? <span className={idx === 0 ? "font-semibold text-primary-700" : ""}>{formatDistance(vendor.distanceKm)}</span>
: <span className="text-neutral-300 italic text-xs">No location</span>}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{filtered.length > 0 && hasSite && (
<p className="text-xs text-neutral-400">
Distances are straight-line from site to vendor location (based on pincode).
</p>
)}
</div>
);
}

View file

@ -41,11 +41,13 @@ const NAV_ITEMS: NavItem[] = [
];
const INVENTORY_ITEMS: NavItem[] = [
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER", "ADMIN"] },
{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER", "ADMIN"] },
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["MANAGER", "SUPERUSER", "TECHNICAL", "MANNING"] },
];
const ADMIN_ITEMS: NavItem[] = [