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:
parent
e887502e27
commit
dacd688ebe
3 changed files with 280 additions and 1 deletions
79
App/pelagia-portal/app/(portal)/inventory/vendors/page.tsx
vendored
Normal file
79
App/pelagia-portal/app/(portal)/inventory/vendors/page.tsx
vendored
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
App/pelagia-portal/app/(portal)/inventory/vendors/vendors-table.tsx
vendored
Normal file
198
App/pelagia-portal/app/(portal)/inventory/vendors/vendors-table.tsx
vendored
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -41,11 +41,13 @@ const NAV_ITEMS: NavItem[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const INVENTORY_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/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
||||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
||||||
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, 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: "/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[] = [
|
const ADMIN_ITEMS: NavItem[] = [
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue