diff --git a/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx b/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx index 7b28ffd..6f8c56f 100644 --- a/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx +++ b/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx @@ -8,6 +8,7 @@ import { distanceKm, formatDistance } from "@/lib/geo"; import { ToggleProductButton } from "../product-form"; import { AddToCartButton } from "@/components/inventory/add-to-cart-button"; import { ItemPriceChart } from "./item-price-chart"; +import { SiteSelect } from "@/app/(portal)/inventory/items/[id]/site-select"; import type { Metadata } from "next"; interface Props { @@ -28,6 +29,7 @@ export default async function ProductDetailPage({ params, searchParams }: Props) const { id } = await params; const { site: siteId } = await searchParams; + const baseHref = `/admin/products/${id}`; const [product, sites] = await Promise.all([ db.product.findUnique({ @@ -131,20 +133,14 @@ export default async function ProductDetailPage({ params, searchParams }: Props) {priceChartData.length > 1 && } {/* Site filter for distance */} -
- Sort by distance from site: -
- -
- {selectedSite && ( - Clear - )} -
+ {sites.length > 0 && ( + ({ id: s.id, name: s.name }))} + currentSiteId={siteId ?? null} + baseHref={baseHref} + paramKey="site" + /> + )} {/* Vendors table */}
diff --git a/App/pelagia-portal/app/(portal)/inventory/items/[id]/page.tsx b/App/pelagia-portal/app/(portal)/inventory/items/[id]/page.tsx new file mode 100644 index 0000000..16a16c7 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/inventory/items/[id]/page.tsx @@ -0,0 +1,218 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { notFound, redirect } from "next/navigation"; +import Link from "next/link"; +import { formatCurrency, formatDate } from "@/lib/utils"; +import { distanceKm, formatDistance } from "@/lib/geo"; +import { AddToCartButton } from "@/components/inventory/add-to-cart-button"; +import { ItemPriceChart } from "@/app/(portal)/admin/products/[id]/item-price-chart"; +import { SiteSelect } from "./site-select"; +import type { Metadata } from "next"; + +interface Props { + params: Promise<{ id: string }>; + searchParams: Promise<{ siteId?: string }>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + const product = await db.product.findUnique({ where: { id }, select: { name: true } }); + return { title: product?.name ?? "Item Detail" }; +} + +export default async function InventoryItemDetailPage({ params, searchParams }: Props) { + const session = await auth(); + if (!session?.user) redirect("/login"); + + const { id } = await params; + const { siteId } = await searchParams; + + const [product, sites] = await Promise.all([ + db.product.findUnique({ + where: { id, isActive: true }, + include: { + vendorPrices: { + where: { vendor: { isActive: true } }, + include: { + vendor: { + select: { id: true, name: true, isVerified: true, isActive: true, latitude: true, longitude: true }, + }, + }, + orderBy: { price: "asc" }, + }, + inventory: { include: { site: { select: { id: true, name: true, code: true } } } }, + }, + }), + db.site.findMany({ + where: { isActive: true, latitude: { not: null }, longitude: { not: null } }, + orderBy: { name: "asc" }, + select: { id: true, name: true, latitude: true, longitude: true }, + }), + ]); + + if (!product) notFound(); + + const selectedSite = siteId ? (sites.find((s) => s.id === siteId) ?? null) : null; + + const enriched = product.vendorPrices.map((vp) => { + let dist: number | null = null; + if (selectedSite?.latitude && selectedSite.longitude && vp.vendor.latitude && vp.vendor.longitude) { + dist = distanceKm(selectedSite.latitude, selectedSite.longitude, vp.vendor.latitude, vp.vendor.longitude); + } + return { ...vp, distanceKm: dist, price: Number(vp.price) }; + }); + + if (selectedSite) { + enriched.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; + }); + } + + const prices = enriched.map((v) => v.price); + const minPrice = prices.length > 0 ? Math.min(...prices) : null; + const maxPrice = prices.length > 0 ? Math.max(...prices) : null; + + const priceChartData = enriched.map((vp) => ({ + vendor: vp.vendor.name.length > 16 ? vp.vendor.name.slice(0, 14) + "…" : vp.vendor.name, + price: vp.price, + })); + + const baseHref = `/inventory/items/${id}`; + + return ( +
+ {/* Breadcrumb */} +
+ Items + / + {product.name} +
+ + {/* Header */} +
+
+
+ {product.code} +
+

{product.name}

+ {product.description &&

{product.description}

} +
+ {minPrice !== null && ( + + )} +
+ + {/* Stats */} +
+
+

Vendors

+

{product.vendorPrices.length}

+
+
+

Lowest Price

+

{minPrice !== null ? formatCurrency(minPrice) : "—"}

+
+
+

Highest Price

+

{maxPrice !== null ? formatCurrency(maxPrice) : "—"}

+
+
+ + {/* Price chart */} + {priceChartData.length > 1 && } + + {/* Site filter */} + {sites.length > 0 && ( + ({ id: s.id, name: s.name }))} + currentSiteId={siteId ?? null} + baseHref={baseHref} + /> + )} + + {/* Vendors table */} +
+

+ Available From + ({enriched.length} vendor{enriched.length !== 1 ? "s" : ""}) + {selectedSite && sorted by distance from {selectedSite.name}} +

+ {enriched.length === 0 ? ( +

No vendor pricing on record. Updated automatically when a PO is marked as paid.

+ ) : ( + + + + + + + {selectedSite && } + + + + + {enriched.map((vp, idx) => { + const isCheapest = minPrice !== null && vp.price === minPrice && enriched.length > 1; + const isClosest = selectedSite !== null && idx === 0 && vp.distanceKm !== null; + return ( + + + + + {selectedSite && ( + + )} + + + + ); + })} + +
VendorVerifiedPriceDistanceUpdated +
+ + {vp.vendor.name} + + + + {vp.vendor.isVerified ? "Verified" : "Unverified"} + + + {formatCurrency(vp.price)} + {isCheapest && !selectedSite && lowest} + + {vp.distanceKm !== null + ? {formatDistance(vp.distanceKm)}{isClosest ? " ★" : ""} + : No location} + {formatDate(vp.updatedAt)} + +
+ )} +
+ + {/* Stock by site */} + {product.inventory.length > 0 && ( +
+

Stock on Hand

+
+ {product.inventory.map((inv) => ( +
+ {inv.site.name} + ({inv.site.code}) + {Number(inv.quantity)} units +
+ ))} +
+
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/inventory/items/[id]/site-select.tsx b/App/pelagia-portal/app/(portal)/inventory/items/[id]/site-select.tsx new file mode 100644 index 0000000..798d0bb --- /dev/null +++ b/App/pelagia-portal/app/(portal)/inventory/items/[id]/site-select.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { MapPin } from "lucide-react"; + +type SiteOption = { id: string; name: string }; + +export function SiteSelect({ + sites, + currentSiteId, + baseHref, + paramKey = "siteId", +}: { + sites: SiteOption[]; + currentSiteId: string | null; + baseHref: string; + paramKey?: string; +}) { + const router = useRouter(); + return ( +
+ + Sort by distance from: + + {currentSiteId && ( + + )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx b/App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx index ba80a74..857f78c 100644 --- a/App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx +++ b/App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } 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"; @@ -199,7 +200,13 @@ export function ItemsTable({ {isOpen ? : } - {item.name} + e.stopPropagation()} + className="font-medium text-neutral-900 hover:text-primary-600 hover:underline" + > + {item.name} + {item.description && ( {item.description} )} @@ -241,7 +248,13 @@ export function ItemsTable({
- {vendor.vendorName} + e.stopPropagation()} + className="font-medium text-neutral-800 hover:text-primary-600 hover:underline" + > + {vendor.vendorName} + {vendor.isVerified && ( Verified )} diff --git a/App/pelagia-portal/app/(portal)/inventory/vendors/[id]/page.tsx b/App/pelagia-portal/app/(portal)/inventory/vendors/[id]/page.tsx new file mode 100644 index 0000000..184c08c --- /dev/null +++ b/App/pelagia-portal/app/(portal)/inventory/vendors/[id]/page.tsx @@ -0,0 +1,132 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { notFound, redirect } from "next/navigation"; +import Link from "next/link"; +import { formatCurrency, formatDate } from "@/lib/utils"; +import { VendorItemsTable } from "./vendor-items-table"; +import type { Metadata } from "next"; + +interface Props { params: Promise<{ id: string }> } + +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + const vendor = await db.vendor.findUnique({ where: { id }, select: { name: true } }); + return { title: vendor?.name ?? "Vendor Detail" }; +} + +export default async function InventoryVendorDetailPage({ params }: Props) { + const session = await auth(); + if (!session?.user) redirect("/login"); + + const { id } = await params; + + const vendor = await db.vendor.findUnique({ + where: { id, isActive: true }, + include: { + contacts: { orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }] }, + vendorPrices: { + include: { product: { select: { id: true, code: true, name: true, description: true, isActive: true } } }, + orderBy: { updatedAt: "desc" }, + }, + }, + }); + + if (!vendor) notFound(); + + const items = vendor.vendorPrices.map((vp) => ({ + id: vp.id, + productId: vp.product.id, + code: vp.product.code, + name: vp.product.name, + description: vp.product.description ?? "", + isActive: vp.product.isActive, + price: Number(vp.price), + updatedAt: vp.updatedAt.toISOString(), + })); + + return ( +
+ {/* Breadcrumb */} +
+ Vendors + / + {vendor.name} +
+ + {/* Header */} +
+
+ {vendor.vendorId && {vendor.vendorId}} + + {vendor.isVerified ? "Verified" : "Unverified"} + +
+

{vendor.name}

+
+ + {/* Details + Contacts */} +
+
+

Vendor Details

+
+ {vendor.gstin && ( +
+
GSTIN
+
{vendor.gstin}
+
+ )} + {vendor.address && ( +
+
Address
+
{vendor.address}
+
+ )} + {vendor.pincode && ( +
+
Pincode
+
{vendor.pincode}
+
+ )} + {!vendor.gstin && !vendor.address && !vendor.pincode && ( +

No additional details on record.

+ )} +
+
+ +
+

+ Contacts + ({vendor.contacts.length}) +

+ {vendor.contacts.length === 0 ? ( +

No contacts on record.

+ ) : ( +
+ {vendor.contacts.map((c) => ( +
+
+ {c.name.slice(0, 2).toUpperCase()} +
+
+
+ {c.name} + {c.role && {c.role}} + {c.isPrimary && Primary} +
+
+ {c.mobile && {c.mobile}} + {c.email && {c.email}} +
+
+
+ ))} +
+ )} +
+
+ + {/* Items supplied */} + +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/inventory/vendors/[id]/vendor-items-table.tsx b/App/pelagia-portal/app/(portal)/inventory/vendors/[id]/vendor-items-table.tsx new file mode 100644 index 0000000..d80a4e9 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/inventory/vendors/[id]/vendor-items-table.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Search, X } from "lucide-react"; +import Link from "next/link"; +import { formatCurrency, formatDate } from "@/lib/utils"; +import { AddToCartButton } from "@/components/inventory/add-to-cart-button"; + +type Item = { + id: string; + productId: string; + code: string; + name: string; + description: string; + isActive: boolean; + price: number; + updatedAt: string; +}; + +export function VendorItemsTable({ items }: { items: Item[] }) { + const [query, setQuery] = useState(""); + + 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]); + + return ( +
+
+

+ Items Supplied + ({filtered.length}{query ? ` of ${items.length}` : ""}) +

+
+ + setQuery(e.target.value)} + placeholder="Search items…" + className="w-full rounded-lg border border-neutral-200 py-1.5 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" + /> + {query && ( + + )} +
+
+ + {items.length === 0 ? ( +

+ No items on record yet. Updated automatically when a PO with this vendor is marked as paid. +

+ ) : filtered.length === 0 ? ( +

No items match "{query}"

+ ) : ( + + + + + + + + + + + {filtered.map((item) => ( + + + + + + + + ))} + +
ItemCodePriceUpdated +
+ + {item.name} + + {item.description && ( + {item.description} + )} + {item.code}{formatCurrency(item.price)}{formatDate(new Date(item.updatedAt))} + +
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/inventory/vendors/vendors-table.tsx b/App/pelagia-portal/app/(portal)/inventory/vendors/vendors-table.tsx index fe27608..5eeaeb5 100644 --- a/App/pelagia-portal/app/(portal)/inventory/vendors/vendors-table.tsx +++ b/App/pelagia-portal/app/(portal)/inventory/vendors/vendors-table.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import { Search, X, MapPin, Tag } from "lucide-react"; import { formatDistance } from "@/lib/geo"; @@ -147,7 +148,9 @@ export function VendorsTable({
- {vendor.name} + + {vendor.name} + {vendor.isVerified && ( Verified )}