From 478f1d1f9cff85f05ff12a892017a247df0a3800 Mon Sep 17 00:00:00 2001 From: Hardik Date: Sun, 31 May 2026 06:24:46 +0530 Subject: [PATCH] refactor(items): canonical detail route is /inventory/items/[id] - /inventory/items uses same ProductsTable as /admin/products - canManage driven by manage_products permission on both pages - /inventory/items/[id] is the canonical detail page (same content, breadcrumb back to /inventory/items) - /admin/products/[id] redirects to /inventory/items/[id] - All ProductsTable name links point to /inventory/items/[id] - Old items-table.tsx (cart-based browse) retired in favour of shared table Co-Authored-By: Claude Sonnet 4.6 --- App/app/(portal)/admin/products/[id]/page.tsx | 225 +---------------- .../admin/products/products-table.tsx | 2 +- .../inventory/items/[id]/item-price-chart.tsx | 21 ++ .../(portal)/inventory/items/[id]/page.tsx | 227 ++++++++++++++++++ App/app/(portal)/inventory/items/page.tsx | 93 +++---- 5 files changed, 280 insertions(+), 288 deletions(-) create mode 100644 App/app/(portal)/inventory/items/[id]/item-price-chart.tsx create mode 100644 App/app/(portal)/inventory/items/[id]/page.tsx diff --git a/App/app/(portal)/admin/products/[id]/page.tsx b/App/app/(portal)/admin/products/[id]/page.tsx index c597a4b..111f2aa 100644 --- a/App/app/(portal)/admin/products/[id]/page.tsx +++ b/App/app/(portal)/admin/products/[id]/page.tsx @@ -1,225 +1,8 @@ -import { auth } from "@/auth"; -import { db } from "@/lib/db"; -import { hasPermission } from "@/lib/permissions"; -import { notFound, redirect } from "next/navigation"; -import Link from "next/link"; -import { formatCurrency, formatDate } from "@/lib/utils"; -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 "@/components/inventory/site-select"; -import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -interface Props { - params: Promise<{ id: string }>; - searchParams: Promise<{ site?: string }>; -} +interface Props { params: Promise<{ id: string }> } -export async function generateMetadata({ params }: Props): Promise { +export default async function AdminProductDetailRedirect({ params }: Props) { 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 ProductDetailPage({ params, searchParams }: Props) { - const session = await auth(); - if (!session?.user) redirect("/login"); - if (!hasPermission(session.user.role, "manage_products")) redirect("/dashboard"); - - const { id } = await params; - const { site: siteId } = await searchParams; - const baseHref = `/admin/products/${id}`; - - const [product, sites] = await Promise.all([ - db.product.findUnique({ - where: { id }, - include: { - vendorPrices: { - include: { - vendor: { - select: { id: true, name: true, vendorId: true, isVerified: true, isActive: true, latitude: true, longitude: true }, - }, - }, - orderBy: { price: "asc" }, - }, - lastVendor: true, - inventory: { include: { site: { select: { id: true, name: true } } } }, - }, - }), - db.site.findMany({ where: { isActive: true, latitude: { not: null }, longitude: { not: null } }, select: { id: true, name: true, latitude: true, longitude: true } }), - ]); - - if (!product) notFound(); - - const canManage = session.user.role === "ADMIN"; - const selectedSite = siteId ? sites.find((s) => s.id === siteId) ?? null : null; - - const prices = product.vendorPrices.map((vp) => Number(vp.price)); - const minPrice = prices.length > 0 ? Math.min(...prices) : null; - const maxPrice = prices.length > 0 ? Math.max(...prices) : null; - - // Enrich vendors with distance from selected site - type EnrichedVp = typeof product.vendorPrices[0] & { distanceKm: number | null }; - const enriched: EnrichedVp[] = 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 }; - }); - - // Sort: if site selected, sort by distance first; otherwise by 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 Number(a.price) - Number(b.price); - }); - } - - const priceChartData = enriched.map((vp) => ({ - vendor: vp.vendor.name.length > 16 ? vp.vendor.name.slice(0, 14) + "…" : vp.vendor.name, - price: Number(vp.price), - })); - - return ( -
-
- Items - / - {product.name} -
- -
-
-
- {product.code} - - {product.isActive ? "Active" : "Inactive"} - -
-

{product.name}

- {product.description &&

{product.description}

} -
-
- - {canManage && } -
-
- - {/* Stats */} -
-
-

Vendors

-

{product.vendorPrices.length}

-
-
-

Lowest Price

-

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

-
-
-

Highest Price

-

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

-
-
-

Sites with stock

-

{product.inventory.length}

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

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

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

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

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

Stock by Site

-
- {product.inventory.map((inv) => ( - - {inv.site.name} - {Number(inv.quantity)} units - - ))} -
-
- )} -
- ); + redirect(`/inventory/items/${id}`); } diff --git a/App/app/(portal)/admin/products/products-table.tsx b/App/app/(portal)/admin/products/products-table.tsx index 74c0bb9..b0f0a11 100644 --- a/App/app/(portal)/admin/products/products-table.tsx +++ b/App/app/(portal)/admin/products/products-table.tsx @@ -135,7 +135,7 @@ export function ProductsTable({ {product.name} diff --git a/App/app/(portal)/inventory/items/[id]/item-price-chart.tsx b/App/app/(portal)/inventory/items/[id]/item-price-chart.tsx new file mode 100644 index 0000000..1ae402c --- /dev/null +++ b/App/app/(portal)/inventory/items/[id]/item-price-chart.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from "recharts"; +import { formatCurrency } from "@/lib/utils"; + +export function ItemPriceChart({ data }: { data: { vendor: string; price: number }[] }) { + return ( +
+

Price Comparison by Vendor

+ + + + + `₹${(v / 1000).toFixed(0)}k`} /> + [formatCurrency(v), "Price"]} /> + + + +
+ ); +} diff --git a/App/app/(portal)/inventory/items/[id]/page.tsx b/App/app/(portal)/inventory/items/[id]/page.tsx new file mode 100644 index 0000000..55c509b --- /dev/null +++ b/App/app/(portal)/inventory/items/[id]/page.tsx @@ -0,0 +1,227 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { notFound, redirect } from "next/navigation"; +import Link from "next/link"; +import { formatCurrency, formatDate } from "@/lib/utils"; +import { distanceKm, formatDistance } from "@/lib/geo"; +import { ToggleProductButton } from "@/app/(portal)/admin/products/product-form"; +import { AddToCartButton } from "@/components/inventory/add-to-cart-button"; +import { ItemPriceChart } from "./item-price-chart"; +import { SiteSelect } from "@/components/inventory/site-select"; +import type { Metadata } from "next"; + +interface Props { + params: Promise<{ id: string }>; + searchParams: Promise<{ site?: 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 ItemDetailPage({ params, searchParams }: Props) { + const session = await auth(); + if (!session?.user) redirect("/login"); + + const { id } = await params; + const { site: siteId } = await searchParams; + const baseHref = `/inventory/items/${id}`; + + const [product, sites] = await Promise.all([ + db.product.findUnique({ + where: { id }, + include: { + vendorPrices: { + include: { + vendor: { + select: { id: true, name: true, vendorId: true, isVerified: true, isActive: true, latitude: true, longitude: true }, + }, + }, + orderBy: { price: "asc" }, + }, + lastVendor: true, + inventory: { include: { site: { select: { id: true, name: true } } } }, + }, + }), + db.site.findMany({ + where: { isActive: true, latitude: { not: null }, longitude: { not: null } }, + select: { id: true, name: true, latitude: true, longitude: true }, + }), + ]); + + if (!product) notFound(); + + const canManage = hasPermission(session.user.role, "manage_products"); + const selectedSite = siteId ? sites.find((s) => s.id === siteId) ?? null : null; + + const prices = product.vendorPrices.map((vp) => Number(vp.price)); + const minPrice = prices.length > 0 ? Math.min(...prices) : null; + const maxPrice = prices.length > 0 ? Math.max(...prices) : null; + + type EnrichedVp = typeof product.vendorPrices[0] & { distanceKm: number | null }; + const enriched: EnrichedVp[] = 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 }; + }); + + 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 Number(a.price) - Number(b.price); + }); + } + + const priceChartData = enriched.map((vp) => ({ + vendor: vp.vendor.name.length > 16 ? vp.vendor.name.slice(0, 14) + "…" : vp.vendor.name, + price: Number(vp.price), + })); + + return ( +
+ {/* Breadcrumb */} +
+ Items + / + {product.name} +
+ + {/* Header */} +
+
+
+ {product.code} + + {product.isActive ? "Active" : "Inactive"} + +
+

{product.name}

+ {product.description &&

{product.description}

} +
+
+ + {canManage && } +
+
+ + {/* Stats */} +
+
+

Vendors

+

{product.vendorPrices.length}

+
+
+

Lowest Price

+

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

+
+
+

Highest Price

+

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

+
+
+

Sites with stock

+

{product.inventory.length}

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

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

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

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

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

Stock by Site

+
+ {product.inventory.map((inv) => ( + + {inv.site.name} + {Number(inv.quantity)} units + + ))} +
+
+ )} +
+ ); +} diff --git a/App/app/(portal)/inventory/items/page.tsx b/App/app/(portal)/inventory/items/page.tsx index 69cba20..99dd7be 100644 --- a/App/app/(portal)/inventory/items/page.tsx +++ b/App/app/(portal)/inventory/items/page.tsx @@ -1,82 +1,43 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; import { redirect } from "next/navigation"; -import { distanceKm } from "@/lib/geo"; -import { ItemsTable } from "./items-table"; +import { ProductsTable } from "@/app/(portal)/admin/products/products-table"; import type { Metadata } from "next"; -export const metadata: Metadata = { title: "Browse Items" }; +export const metadata: Metadata = { title: "Item Catalogue" }; -interface Props { - searchParams: Promise<{ siteId?: string }>; -} - -export default async function InventoryItemsPage({ searchParams }: Props) { +export default async function InventoryItemsPage() { const session = await auth(); if (!session?.user) redirect("/login"); - const { siteId } = await searchParams; + const products = await db.product.findMany({ + where: { isActive: true }, + orderBy: { name: "asc" }, + include: { + lastVendor: true, + _count: { select: { vendorPrices: true } }, + }, + }); - const [site, products, 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.product.findMany({ - where: { isActive: true }, - include: { - vendorPrices: { - where: { vendor: { isActive: true } }, - include: { - vendor: { - select: { id: true, name: true, isVerified: true, latitude: true, longitude: true }, - }, - }, - orderBy: { price: "asc" }, - }, - }, - orderBy: { name: "asc" }, - }), - db.site.findMany({ - where: { isActive: true }, - orderBy: { name: "asc" }, - select: { id: true, name: true, code: true }, - }), - ]); - - const items = products.map((p) => ({ - id: p.id, - code: p.code, - name: p.name, - description: p.description ?? "", - vendors: p.vendorPrices.map((vp) => { - let dist: number | null = null; - if (site?.latitude && site.longitude && vp.vendor.latitude && vp.vendor.longitude) { - dist = distanceKm(site.latitude, site.longitude, vp.vendor.latitude, vp.vendor.longitude); - } - return { - vendorId: vp.vendor.id, - vendorName: vp.vendor.name, - isVerified: vp.vendor.isVerified, - price: Number(vp.price), - distanceKm: dist, - }; - }), - })); + // canManage lets managers/admins see the Edit/Delete controls even from /inventory/items + const canManage = hasPermission(session.user.role, "manage_products"); return (
-
-

Browse Items

-

Search the catalogue and add items to your cart.

-
- ({ + id: p.id, + code: p.code, + name: p.name, + description: p.description ?? null, + lastPrice: p.lastPrice !== null ? Number(p.lastPrice) : null, + lastVendorName: p.lastVendor?.name ?? null, + updatedAt: p.updatedAt.toISOString(), + isActive: p.isActive, + vendorPriceCount: p._count.vendorPrices, + }))} />
);