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 <noreply@anthropic.com>
This commit is contained in:
parent
2c364f95e5
commit
478f1d1f9c
5 changed files with 280 additions and 288 deletions
|
|
@ -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<Metadata> {
|
||||
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 (
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<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>
|
||||
|
||||
<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 }} />
|
||||
{canManage && <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>
|
||||
|
||||
{/* Price chart */}
|
||||
{priceChartData.length > 1 && <ItemPriceChart data={priceChartData} />}
|
||||
|
||||
{/* Site filter for distance */}
|
||||
{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. Updated automatically when a PO is marked as paid.</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>
|
||||
|
||||
{/* Inventory by site */}
|
||||
{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>
|
||||
);
|
||||
redirect(`/inventory/items/${id}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export function ProductsTable({
|
|||
<tr key={product.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/products/${product.id}`}
|
||||
href={`/inventory/items/${product.id}`}
|
||||
className="font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{product.name}
|
||||
|
|
|
|||
21
App/app/(portal)/inventory/items/[id]/item-price-chart.tsx
Normal file
21
App/app/(portal)/inventory/items/[id]/item-price-chart.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p className="text-sm font-semibold text-neutral-900 mb-4">Price Comparison by Vendor</p>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={data} margin={{ left: 8, right: 16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="vendor" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `₹${(v / 1000).toFixed(0)}k`} />
|
||||
<Tooltip formatter={(v: number) => [formatCurrency(v), "Price"]} />
|
||||
<Bar dataKey="price" fill="#2563eb" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
App/app/(portal)/inventory/items/[id]/page.tsx
Normal file
227
App/app/(portal)/inventory/items/[id]/page.tsx
Normal file
|
|
@ -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<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 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 (
|
||||
<div className="max-w-6xl space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<Link href="/inventory/items" className="hover:text-neutral-700">Items</Link>
|
||||
<span>/</span>
|
||||
<span className="text-neutral-900 font-medium">{product.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<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 }} />
|
||||
{canManage && <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>
|
||||
|
||||
{/* Price chart */}
|
||||
{priceChartData.length > 1 && <ItemPriceChart data={priceChartData} />}
|
||||
|
||||
{/* Site filter */}
|
||||
{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. Updated automatically when a PO is marked as paid.</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>
|
||||
|
||||
{/* Stock by site */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 [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({
|
||||
const products = await db.product.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, code: true },
|
||||
}),
|
||||
]);
|
||||
include: {
|
||||
lastVendor: true,
|
||||
_count: { select: { vendorPrices: 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 (
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Browse Items</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">Search the catalogue and add items to your cart.</p>
|
||||
</div>
|
||||
<ItemsTable
|
||||
items={items}
|
||||
hasSite={!!site}
|
||||
sites={sites}
|
||||
currentSiteId={siteId ?? null}
|
||||
<ProductsTable
|
||||
canManage={canManage}
|
||||
products={products.map((p) => ({
|
||||
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,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue