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[] = [
|
||||
{ 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[] = [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue