feat(inventory): item & vendor detail pages at /inventory/items/[id] and /inventory/vendors/[id]
- New /inventory/items/[id]: vendor price table with distance sorting via SiteSelect client component (?siteId= URL param), price chart, stock by site, Add to Cart per vendor - New /inventory/vendors/[id]: contacts panel + searchable items table with Add to Cart, links back to /inventory/items/[id] - SiteSelect: reusable client component (useRouter.push, configurable param key) used by both inventory and admin detail pages - items-table: item names link to /inventory/items/[id]; vendor names in expanded rows link to /inventory/vendors/[id] - vendors-table: vendor names link to /inventory/vendors/[id] - Fix admin product detail page: replace illegal Server Component onChange handler with SiteSelect client component Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8e57381b06
commit
8750a459f5
7 changed files with 527 additions and 17 deletions
|
|
@ -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 && <ItemPriceChart data={priceChartData} />}
|
||||
|
||||
{/* Site filter for distance */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-neutral-700">Sort by distance from site:</span>
|
||||
<form method="get">
|
||||
<select name="site" onChange={(e) => { (e.target.form as HTMLFormElement).submit(); }}
|
||||
defaultValue={siteId ?? ""}
|
||||
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm focus:outline-none">
|
||||
<option value="">Price (cheapest first)</option>
|
||||
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</form>
|
||||
{selectedSite && (
|
||||
<Link href={`/admin/products/${id}`} className="text-sm text-neutral-500 hover:underline">Clear</Link>
|
||||
{sites.length > 0 && (
|
||||
<SiteSelect
|
||||
sites={sites.map((s) => ({ id: s.id, name: s.name }))}
|
||||
currentSiteId={siteId ?? null}
|
||||
baseHref={baseHref}
|
||||
paramKey="site"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vendors table */}
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
|
|
|
|||
218
App/pelagia-portal/app/(portal)/inventory/items/[id]/page.tsx
Normal file
218
App/pelagia-portal/app/(portal)/inventory/items/[id]/page.tsx
Normal file
|
|
@ -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<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 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 (
|
||||
<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>
|
||||
</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>
|
||||
{minPrice !== null && (
|
||||
<AddToCartButton
|
||||
item={{ productId: product.id, name: product.name, description: product.description ?? undefined, unit: "pc", unitPrice: minPrice }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 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>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">({enriched.length} vendor{enriched.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. 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 isCheapest = minPrice !== null && vp.price === minPrice && enriched.length > 1;
|
||||
const isClosest = selectedSite !== null && idx === 0 && vp.distanceKm !== null;
|
||||
return (
|
||||
<tr key={vp.id} className="hover:bg-neutral-50">
|
||||
<td className="py-2.5 pr-4">
|
||||
<Link href={`/inventory/vendors/${vp.vendor.id}`} className="font-medium text-primary-600 hover:underline">
|
||||
{vp.vendor.name}
|
||||
</Link>
|
||||
</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(vp.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: vp.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 on Hand</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.inventory.map((inv) => (
|
||||
<div key={inv.id} className="rounded-lg border border-neutral-200 px-4 py-2 text-sm">
|
||||
<span className="font-medium text-neutral-900">{inv.site.name}</span>
|
||||
<span className="ml-1 text-xs text-neutral-500">({inv.site.code})</span>
|
||||
<span className="ml-2 text-neutral-600 font-semibold">{Number(inv.quantity)} units</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPin className="h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-neutral-700 whitespace-nowrap">Sort by distance from:</span>
|
||||
<select
|
||||
value={currentSiteId ?? ""}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
router.push(id ? `${baseHref}?${paramKey}=${id}` : baseHref);
|
||||
}}
|
||||
className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
>
|
||||
<option value="">Price (cheapest first)</option>
|
||||
{sites.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{currentSiteId && (
|
||||
<button
|
||||
onClick={() => router.push(baseHref)}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 hover:underline"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-neutral-900">{item.name}</span>
|
||||
<Link
|
||||
href={`/inventory/items/${item.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium text-neutral-900 hover:text-primary-600 hover:underline"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
{item.description && (
|
||||
<span className="block text-xs text-neutral-500 mt-0.5 line-clamp-1">{item.description}</span>
|
||||
)}
|
||||
|
|
@ -241,7 +248,13 @@ export function ItemsTable({
|
|||
<tr key={vendor.vendorId} className="hover:bg-white">
|
||||
<td className="px-12 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-neutral-800">{vendor.vendorName}</span>
|
||||
<Link
|
||||
href={`/inventory/vendors/${vendor.vendorId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium text-neutral-800 hover:text-primary-600 hover:underline"
|
||||
>
|
||||
{vendor.vendorName}
|
||||
</Link>
|
||||
{vendor.isVerified && (
|
||||
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
|
||||
)}
|
||||
|
|
|
|||
132
App/pelagia-portal/app/(portal)/inventory/vendors/[id]/page.tsx
vendored
Normal file
132
App/pelagia-portal/app/(portal)/inventory/vendors/[id]/page.tsx
vendored
Normal file
|
|
@ -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<Metadata> {
|
||||
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 (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<Link href="/inventory/vendors" className="hover:text-neutral-700">Vendors</Link>
|
||||
<span>/</span>
|
||||
<span className="text-neutral-900 font-medium">{vendor.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
{vendor.vendorId && <span className="font-mono text-xs text-neutral-500">{vendor.vendorId}</span>}
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${vendor.isVerified ? "bg-success-100 text-success-700" : "bg-warning-100 text-warning-700"}`}>
|
||||
{vendor.isVerified ? "Verified" : "Unverified"}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">{vendor.name}</h1>
|
||||
</div>
|
||||
|
||||
{/* Details + Contacts */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Vendor Details</h2>
|
||||
<dl className="space-y-3 text-sm">
|
||||
{vendor.gstin && (
|
||||
<div>
|
||||
<dt className="text-neutral-500 text-xs uppercase tracking-wide font-medium mb-0.5">GSTIN</dt>
|
||||
<dd className="font-mono text-neutral-900 tracking-wide">{vendor.gstin}</dd>
|
||||
</div>
|
||||
)}
|
||||
{vendor.address && (
|
||||
<div>
|
||||
<dt className="text-neutral-500 text-xs uppercase tracking-wide font-medium mb-0.5">Address</dt>
|
||||
<dd className="text-neutral-900 whitespace-pre-wrap">{vendor.address}</dd>
|
||||
</div>
|
||||
)}
|
||||
{vendor.pincode && (
|
||||
<div>
|
||||
<dt className="text-neutral-500 text-xs uppercase tracking-wide font-medium mb-0.5">Pincode</dt>
|
||||
<dd className="font-mono text-neutral-900">{vendor.pincode}</dd>
|
||||
</div>
|
||||
)}
|
||||
{!vendor.gstin && !vendor.address && !vendor.pincode && (
|
||||
<p className="text-neutral-400 italic">No additional details on record.</p>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-sm font-semibold text-neutral-900 mb-4">
|
||||
Contacts
|
||||
<span className="ml-2 text-neutral-400 font-normal">({vendor.contacts.length})</span>
|
||||
</h2>
|
||||
{vendor.contacts.length === 0 ? (
|
||||
<p className="text-sm text-neutral-400 italic">No contacts on record.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{vendor.contacts.map((c) => (
|
||||
<div key={c.id} className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700">
|
||||
{c.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-neutral-900">{c.name}</span>
|
||||
{c.role && <span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">{c.role}</span>}
|
||||
{c.isPrimary && <span className="rounded bg-primary-100 px-1.5 py-0.5 text-xs text-primary-700 font-medium">Primary</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5 text-xs text-neutral-500">
|
||||
{c.mobile && <span>{c.mobile}</span>}
|
||||
{c.email && <a href={`mailto:${c.email}`} className="hover:text-primary-600 hover:underline">{c.email}</a>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items supplied */}
|
||||
<VendorItemsTable items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
App/pelagia-portal/app/(portal)/inventory/vendors/[id]/vendor-items-table.tsx
vendored
Normal file
101
App/pelagia-portal/app/(portal)/inventory/vendors/[id]/vendor-items-table.tsx
vendored
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">
|
||||
Items Supplied
|
||||
<span className="ml-2 text-neutral-400 font-normal">({filtered.length}{query ? ` of ${items.length}` : ""})</span>
|
||||
</h2>
|
||||
<div className="relative w-60">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-neutral-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => 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 && (
|
||||
<button onClick={() => setQuery("")} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-neutral-400 italic">
|
||||
No items on record yet. Updated automatically when a PO with this vendor is marked as paid.
|
||||
</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-sm text-neutral-400 italic py-4 text-center">No items match "{query}"</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">Item</th>
|
||||
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Code</th>
|
||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Price</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">
|
||||
{filtered.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-neutral-50">
|
||||
<td className="py-2.5 pr-4">
|
||||
<Link href={`/inventory/items/${item.productId}`} className="font-medium text-primary-600 hover:underline">
|
||||
{item.name}
|
||||
</Link>
|
||||
{item.description && (
|
||||
<span className="block text-xs text-neutral-500 mt-0.5 line-clamp-1">{item.description}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 pl-4 font-mono text-xs text-neutral-500">{item.code}</td>
|
||||
<td className="py-2.5 pl-4 text-right font-semibold text-neutral-900">{formatCurrency(item.price)}</td>
|
||||
<td className="py-2.5 pl-4 text-right text-neutral-500">{formatDate(new Date(item.updatedAt))}</td>
|
||||
<td className="py-2.5 pl-4">
|
||||
<AddToCartButton
|
||||
item={{ productId: item.productId, name: item.name, description: item.description || undefined, unit: "pc", unitPrice: item.price }}
|
||||
className="text-xs text-primary-600 hover:underline font-medium whitespace-nowrap"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
<tr key={vendor.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-neutral-900">{vendor.name}</span>
|
||||
<Link href={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
||||
{vendor.name}
|
||||
</Link>
|
||||
{vendor.isVerified && (
|
||||
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue