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 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-31 06:28:37 +05:30
parent 478f1d1f9c
commit 6351eaa5e9
4 changed files with 228 additions and 11 deletions

View file

@ -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 }> } interface Props {
params: Promise<{ id: string }>;
export default async function AdminProductDetailRedirect({ params }: Props) { searchParams: Promise<{ site?: string }>;
const { id } = await params; }
redirect(`/inventory/items/${id}`);
export async function generateMetadata({ params }: Props): Promise<Metadata> {
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 (
<div className="max-w-6xl space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Link href="/admin/products" className="hover:text-neutral-700">Items</Link>
<span>/</span>
<span className="text-neutral-900 font-medium">{product.name}</span>
</div>
{/* Header with edit controls */}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="font-mono text-xs text-neutral-500">{product.code}</span>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${product.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
{product.isActive ? "Active" : "Inactive"}
</span>
</div>
<h1 className="text-2xl font-semibold text-neutral-900">{product.name}</h1>
{product.description && <p className="mt-1 text-sm text-neutral-500">{product.description}</p>}
</div>
<div className="flex gap-2 items-start">
<AddToCartButton item={{ productId: product.id, name: product.name, description: product.description ?? undefined, unit: "pc", unitPrice: minPrice ?? 0 }} />
<EditProductButton
product={{ id: product.id, code: product.code, name: product.name, description: product.description, isActive: product.isActive }}
/>
<ToggleProductButton product={{ id: product.id, code: product.code, name: product.name, description: product.description, isActive: product.isActive }} />
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Vendors</p>
<p className="text-2xl font-semibold text-neutral-900">{product.vendorPrices.length}</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Lowest Price</p>
<p className="text-2xl font-semibold text-success-700">{minPrice !== null ? formatCurrency(minPrice) : "—"}</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Highest Price</p>
<p className="text-2xl font-semibold text-neutral-900">{maxPrice !== null ? formatCurrency(maxPrice) : "—"}</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Sites with stock</p>
<p className="text-2xl font-semibold text-neutral-900">{product.inventory.length}</p>
</div>
</div>
{priceChartData.length > 1 && <ItemPriceChart data={priceChartData} />}
{sites.length > 0 && (
<SiteSelect
sites={sites.map((s) => ({ id: s.id, name: s.name }))}
currentSiteId={siteId ?? null}
baseHref={baseHref}
paramKey="site"
/>
)}
{/* Vendors table */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">
Available From
<span className="ml-2 text-neutral-400 font-normal">({product.vendorPrices.length} vendor{product.vendorPrices.length !== 1 ? "s" : ""})</span>
{selectedSite && <span className="ml-2 text-primary-600 font-normal text-xs">sorted by distance from {selectedSite.name}</span>}
</h2>
{enriched.length === 0 ? (
<p className="text-sm text-neutral-400 italic">No vendor pricing on record yet.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200">
<th className="pb-2 text-left font-medium text-neutral-600">Vendor</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Verified</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Price</th>
{selectedSite && <th className="pb-2 text-right font-medium text-neutral-600 pl-4">Distance</th>}
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Updated</th>
<th className="pb-2 pl-4" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{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 (
<tr key={vp.id} className="hover:bg-neutral-50">
<td className="py-2.5 pr-4">
<Link href={`/admin/vendors/${vp.vendor.id}`} className="font-medium text-primary-600 hover:underline">{vp.vendor.name}</Link>
{!vp.vendor.isActive && <span className="ml-2 text-xs text-neutral-400 italic">inactive</span>}
</td>
<td className="py-2.5 pl-4">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${vp.vendor.isVerified ? "bg-success-100 text-success-700" : "bg-warning-100 text-warning-700"}`}>
{vp.vendor.isVerified ? "Verified" : "Unverified"}
</span>
</td>
<td className="py-2.5 pl-4 text-right">
<span className={`font-semibold ${isCheapest ? "text-success-700" : "text-neutral-900"}`}>{formatCurrency(price)}</span>
{isCheapest && !selectedSite && <span className="ml-1.5 text-xs text-success-600">lowest</span>}
</td>
{selectedSite && (
<td className="py-2.5 pl-4 text-right">
{vp.distanceKm !== null
? <span className={isClosest ? "font-semibold text-primary-700" : "text-neutral-600"}>{formatDistance(vp.distanceKm)}{isClosest ? " ★" : ""}</span>
: <span className="text-neutral-400 italic text-xs">No location</span>}
</td>
)}
<td className="py-2.5 pl-4 text-right text-neutral-500">{formatDate(vp.updatedAt)}</td>
<td className="py-2.5 pl-4">
<AddToCartButton
item={{ productId: product.id, name: product.name, description: product.description ?? undefined, unit: "pc", unitPrice: price, vendorId: vp.vendor.id, vendorName: vp.vendor.name }}
className="text-xs text-primary-600 hover:underline font-medium whitespace-nowrap"
/>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{product.inventory.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Stock by Site</h2>
<div className="flex flex-wrap gap-3">
{product.inventory.map((inv) => (
<Link key={inv.id} href={`/admin/sites/${inv.site.id}`}
className="rounded-lg border border-neutral-200 px-4 py-2 text-sm hover:bg-neutral-50">
<span className="font-medium text-neutral-900">{inv.site.name}</span>
<span className="ml-2 text-neutral-500">{Number(inv.quantity)} units</span>
</Link>
))}
</div>
</div>
)}
</div>
);
} }

View file

@ -25,6 +25,7 @@ export default async function AdminProductsPage() {
return ( return (
<ProductsTable <ProductsTable
canManage={canManage} canManage={canManage}
detailBase="/admin/products"
products={products.map((p) => ({ products={products.map((p) => ({
id: p.id, id: p.id,
code: p.code, code: p.code,

View file

@ -67,9 +67,11 @@ function ProductActionsMenu({ product }: { product: ProductRow }) {
export function ProductsTable({ export function ProductsTable({
products, products,
canManage, canManage,
detailBase = "/inventory/items",
}: { }: {
products: ProductRow[]; products: ProductRow[];
canManage: boolean; canManage: boolean;
detailBase?: string;
}) { }) {
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } = const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
useTableControls<ProductRow>({ useTableControls<ProductRow>({
@ -135,7 +137,7 @@ export function ProductsTable({
<tr key={product.id} className="hover:bg-neutral-50"> <tr key={product.id} className="hover:bg-neutral-50">
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link <Link
href={`/inventory/items/${product.id}`} href={`${detailBase}/${product.id}`}
className="font-medium text-primary-600 hover:underline" className="font-medium text-primary-600 hover:underline"
> >
{product.name} {product.name}

View file

@ -1,11 +1,9 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils"; import { formatCurrency, formatDate } from "@/lib/utils";
import { distanceKm, formatDistance } from "@/lib/geo"; 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 { AddToCartButton } from "@/components/inventory/add-to-cart-button";
import { ItemPriceChart } from "./item-price-chart"; import { ItemPriceChart } from "./item-price-chart";
import { SiteSelect } from "@/components/inventory/site-select"; import { SiteSelect } from "@/components/inventory/site-select";
@ -54,7 +52,6 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
if (!product) notFound(); if (!product) notFound();
const canManage = hasPermission(session.user.role, "manage_products");
const selectedSite = siteId ? sites.find((s) => s.id === siteId) ?? null : null; const selectedSite = siteId ? sites.find((s) => s.id === siteId) ?? null : null;
const prices = product.vendorPrices.map((vp) => Number(vp.price)); const prices = product.vendorPrices.map((vp) => Number(vp.price));
@ -107,7 +104,6 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
</div> </div>
<div className="flex gap-2 items-start"> <div className="flex gap-2 items-start">
<AddToCartButton item={{ productId: product.id, name: product.name, description: product.description ?? undefined, unit: "pc", unitPrice: minPrice ?? 0 }} /> <AddToCartButton item={{ productId: product.id, name: product.name, description: product.description ?? undefined, unit: "pc", unitPrice: minPrice ?? 0 }} />
{canManage && <ToggleProductButton product={{ id: product.id, code: product.code, name: product.name, description: product.description, isActive: product.isActive }} />}
</div> </div>
</div> </div>