diff --git a/App/pelagia-portal/app/(portal)/inventory/vendors/page.tsx b/App/pelagia-portal/app/(portal)/inventory/vendors/page.tsx new file mode 100644 index 0000000..38d7379 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/inventory/vendors/page.tsx @@ -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 ( +
+
+

Vendors

+

Browse vendors and their distance from your working site.

+
+ +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/inventory/vendors/vendors-table.tsx b/App/pelagia-portal/app/(portal)/inventory/vendors/vendors-table.tsx new file mode 100644 index 0000000..fe27608 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/inventory/vendors/vendors-table.tsx @@ -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 ( +
+ {/* Site selector */} + {sites.length > 0 && ( +
+ + + + {currentSiteId && ( + Distances shown from selected site + )} +
+ )} + + {/* Toolbar */} +
+
+ + 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 && ( + + )} +
+ + {filtered.length} vendor{filtered.length !== 1 ? "s" : ""} + +
+ Sort by: + + +
+
+ + {/* Table */} +
+ + + + + + + + {hasSite && } + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((vendor, idx) => ( + + + + + + {hasSite && ( + + )} + + ))} + +
VendorGSTINContactItemsDistance
+ {query ? `No vendors match "${query}"` : "No active vendors on record."} +
+
+ {vendor.name} + {vendor.isVerified && ( + Verified + )} + {idx === 0 && sortBy === "distance" && vendor.distanceKm !== null && ( + ★ Closest + )} +
+ {vendor.address && ( + {vendor.address} + )} +
+ {vendor.gstin ?? } + + {vendor.primaryContact ? ( + <> + {vendor.primaryContact.name} + {vendor.primaryContact.mobile && ( + {vendor.primaryContact.mobile} + )} + + ) : } + + {vendor.itemCount > 0 ? vendor.itemCount : } + + {vendor.distanceKm !== null + ? {formatDistance(vendor.distanceKm)} + : No location} +
+
+ + {filtered.length > 0 && hasSite && ( +

+ Distances are straight-line from site to vendor location (based on pincode). +

+ )} +
+ ); +} diff --git a/App/pelagia-portal/components/layout/sidebar.tsx b/App/pelagia-portal/components/layout/sidebar.tsx index 10813b3..39bdaa6 100644 --- a/App/pelagia-portal/components/layout/sidebar.tsx +++ b/App/pelagia-portal/components/layout/sidebar.tsx @@ -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[] = [