From 6351eaa5e9a0ff47c3b31ea39dfe123ddeb10044 Mon Sep 17 00:00:00 2001 From: Hardik Date: Sun, 31 May 2026 06:28:37 +0530 Subject: [PATCH] feat(items): separate editable/read-only detail pages, same as vendors /admin/products/[id] requires manage_products, shows Edit + Toggle /inventory/items/[id] accessible to all, cart only, no edit controls ProductsTable gains detailBase prop so both list pages link correctly. Co-Authored-By: Claude Sonnet 4.6 --- App/app/(portal)/admin/products/[id]/page.tsx | 230 +++++++++++++++++- App/app/(portal)/admin/products/page.tsx | 1 + .../admin/products/products-table.tsx | 4 +- .../(portal)/inventory/items/[id]/page.tsx | 4 - 4 files changed, 228 insertions(+), 11 deletions(-) diff --git a/App/app/(portal)/admin/products/[id]/page.tsx b/App/app/(portal)/admin/products/[id]/page.tsx index 111f2aa..9ef538f 100644 --- a/App/app/(portal)/admin/products/[id]/page.tsx +++ b/App/app/(portal)/admin/products/[id]/page.tsx @@ -1,8 +1,226 @@ -import { redirect } from "next/navigation"; +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, EditProductButton } from "../product-form"; +import { AddToCartButton } from "@/components/inventory/add-to-cart-button"; +import { ItemPriceChart } from "@/app/(portal)/inventory/items/[id]/item-price-chart"; +import { SiteSelect } from "@/components/inventory/site-select"; +import type { Metadata } from "next"; -interface Props { params: Promise<{ id: string }> } - -export default async function AdminProductDetailRedirect({ params }: Props) { - const { id } = await params; - redirect(`/inventory/items/${id}`); +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 AdminProductDetailPage({ 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 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 with edit controls */} +
+
+
+ {product.code} + + {product.isActive ? "Active" : "Inactive"} + +
+

{product.name}

+ {product.description &&

{product.description}

} +
+
+ + + +
+
+ + {/* Stats */} +
+
+

Vendors

+

{product.vendorPrices.length}

+
+
+

Lowest Price

+

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

+
+
+

Highest Price

+

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

+
+
+

Sites with stock

+

{product.inventory.length}

+
+
+ + {priceChartData.length > 1 && } + + {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.

+ ) : ( + + + + + + + {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)} + +
+ )} +
+ + {product.inventory.length > 0 && ( +
+

Stock by Site

+
+ {product.inventory.map((inv) => ( + + {inv.site.name} + {Number(inv.quantity)} units + + ))} +
+
+ )} +
+ ); } diff --git a/App/app/(portal)/admin/products/page.tsx b/App/app/(portal)/admin/products/page.tsx index cdeb17b..a2facc4 100644 --- a/App/app/(portal)/admin/products/page.tsx +++ b/App/app/(portal)/admin/products/page.tsx @@ -25,6 +25,7 @@ export default async function AdminProductsPage() { return ( ({ id: p.id, code: p.code, diff --git a/App/app/(portal)/admin/products/products-table.tsx b/App/app/(portal)/admin/products/products-table.tsx index b0f0a11..e6e1090 100644 --- a/App/app/(portal)/admin/products/products-table.tsx +++ b/App/app/(portal)/admin/products/products-table.tsx @@ -67,9 +67,11 @@ function ProductActionsMenu({ product }: { product: ProductRow }) { export function ProductsTable({ products, canManage, + detailBase = "/inventory/items", }: { products: ProductRow[]; canManage: boolean; + detailBase?: string; }) { const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } = useTableControls({ @@ -135,7 +137,7 @@ export function ProductsTable({ {product.name} diff --git a/App/app/(portal)/inventory/items/[id]/page.tsx b/App/app/(portal)/inventory/items/[id]/page.tsx index 55c509b..2bff472 100644 --- a/App/app/(portal)/inventory/items/[id]/page.tsx +++ b/App/app/(portal)/inventory/items/[id]/page.tsx @@ -1,11 +1,9 @@ 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"; @@ -54,7 +52,6 @@ export default async function ItemDetailPage({ params, searchParams }: Props) { 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)); @@ -107,7 +104,6 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
- {canManage && }