feat(catalog): vendor & item detail pages; enable for MANAGER role

Permissions:
- Add manage_products to MANAGER (alongside existing manage_vendors)

Sidebar:
- Add Items link for MANAGER under main nav (alongside Vendors)

Vendor list (/admin/vendors):
- Name is now a link to /admin/vendors/[id]
- Show item count column

Vendor detail (/admin/vendors/[id]):
- Vendor info card (GSTIN, address, contact)
- Items Supplied table: name (links to item detail), code, last price, updated
- Recent Purchase Orders table

Item list (/admin/products):
- Name is now a link to /admin/products/[id]
- Show vendor count column; reorder columns (name first)
- Add/Toggle buttons shown only for ADMIN

Item detail (/admin/products/[id]):
- Price summary cards (vendor count, lowest price, highest price)
- Available From table: vendor (links to vendor detail), vendor ID,
  verified badge, price (lowest highlighted in green), last updated

Both detail pages cross-link to each other.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-11 04:25:30 +05:30
parent f4e0d8ae63
commit 1c7d0b8901
6 changed files with 451 additions and 34 deletions

View file

@ -0,0 +1,171 @@
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 { ToggleProductButton } from "../product-form";
import type { Metadata } from "next";
interface Props {
params: Promise<{ id: 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 ProductDetailPage({ params }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_products")) redirect("/dashboard");
const { id } = await params;
const product = await db.product.findUnique({
where: { id },
include: {
vendorPrices: {
include: { vendor: { select: { id: true, name: true, vendorId: true, isVerified: true, isActive: true } } },
orderBy: { price: "asc" },
},
lastVendor: true,
},
});
if (!product) notFound();
const canManage = session.user.role === "ADMIN";
// Price stats
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;
return (
<div className="max-w-5xl 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 */}
<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>
{canManage && (
<ToggleProductButton product={{
id: product.id,
code: product.code,
name: product.name,
description: product.description,
isActive: product.isActive,
}} />
)}
</div>
{/* Price summary */}
{product.vendorPrices.length > 0 && (
<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>
)}
{/* Vendors that carry this item */}
<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>
</h2>
{product.vendorPrices.length === 0 ? (
<p className="text-sm text-neutral-400 italic">
No vendor pricing on record yet. Prices are recorded automatically when a PO containing this item 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">Vendor ID</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>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Last Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{product.vendorPrices.map((vp) => {
const price = Number(vp.price);
const isCheapest = minPrice !== null && price === minPrice && product.vendorPrices.length > 1;
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 font-mono text-xs text-neutral-500">
{vp.vendor.vendorId ?? <span className="italic text-neutral-400">Pending</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 && (
<span className="ml-1.5 text-xs text-success-600">lowest</span>
)}
</td>
<td className="py-2.5 pl-4 text-right text-neutral-500">{formatDate(vp.updatedAt)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
}

View file

@ -2,11 +2,12 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { formatDate } from "@/lib/utils"; import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils";
import { AddProductButton, ToggleProductButton } from "./product-form"; import { AddProductButton, ToggleProductButton } from "./product-form";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Product Catalogue" }; export const metadata: Metadata = { title: "Item Catalogue" };
export default async function AdminProductsPage() { export default async function AdminProductsPage() {
const session = await auth(); const session = await auth();
@ -14,53 +15,67 @@ export default async function AdminProductsPage() {
if (!hasPermission(session.user.role, "manage_products")) redirect("/dashboard"); if (!hasPermission(session.user.role, "manage_products")) redirect("/dashboard");
const products = await db.product.findMany({ const products = await db.product.findMany({
orderBy: { code: "asc" }, orderBy: { name: "asc" },
include: { lastVendor: true }, include: {
lastVendor: true,
_count: { select: { vendorPrices: true } },
},
}); });
const canManage = hasPermission(session.user.role, "manage_products") && session.user.role === "ADMIN";
return ( return (
<div> <div>
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Product Catalogue</h1> <h1 className="text-2xl font-semibold text-neutral-900">Item Catalogue</h1>
<AddProductButton /> {canManage && <AddProductButton />}
</div> </div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> <div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200"> <thead className="bg-neutral-50 border-b border-neutral-200">
<tr> <tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Description</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Description</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Vendors</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Last Price</th> <th className="px-4 py-3 text-right font-medium text-neutral-600">Last Price</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Last Vendor</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Last Vendor</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Updated</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Updated</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3"></th> {canManage && <th className="px-4 py-3"></th>}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-neutral-100"> <tbody className="divide-y divide-neutral-100">
{products.length === 0 && ( {products.length === 0 && (
<tr> <tr>
<td colSpan={8} className="px-4 py-8 text-center text-neutral-400"> <td colSpan={canManage ? 9 : 8} className="px-4 py-8 text-center text-neutral-400">
No products yet. Add the first product to start building the catalogue. No items yet. Items are added automatically when a PO is marked as paid.
</td> </td>
</tr> </tr>
)} )}
{products.map((product) => ( {products.map((product) => (
<tr key={product.id} className="hover:bg-neutral-50"> <tr key={product.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{product.code}</td> <td className="px-4 py-3">
<td className="px-4 py-3 font-medium text-neutral-900">{product.name}</td> <Link
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate"> href={`/admin/products/${product.id}`}
{product.description ?? <span className="italic"></span>} className="font-medium text-primary-600 hover:underline"
>
{product.name}
</Link>
</td> </td>
<td className="px-4 py-3 text-right font-mono text-xs text-neutral-700"> <td className="px-4 py-3 font-mono text-xs text-neutral-500">{product.code}</td>
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">
{product.description ?? <span className="italic text-neutral-400"></span>}
</td>
<td className="px-4 py-3 text-right text-neutral-600">
{product._count.vendorPrices > 0
? product._count.vendorPrices
: <span className="text-neutral-400"></span>}
</td>
<td className="px-4 py-3 text-right text-neutral-700">
{product.lastPrice !== null {product.lastPrice !== null
? Number(product.lastPrice).toLocaleString("en-IN", { ? formatCurrency(Number(product.lastPrice))
style: "currency",
currency: "INR",
maximumFractionDigits: 2,
})
: <span className="text-neutral-400 italic"></span>} : <span className="text-neutral-400 italic"></span>}
</td> </td>
<td className="px-4 py-3 text-neutral-600"> <td className="px-4 py-3 text-neutral-600">
@ -74,15 +89,17 @@ export default async function AdminProductsPage() {
{product.isActive ? "Active" : "Inactive"} {product.isActive ? "Active" : "Inactive"}
</span> </span>
</td> </td>
<td className="px-4 py-3"> {canManage && (
<ToggleProductButton product={{ <td className="px-4 py-3">
id: product.id, <ToggleProductButton product={{
code: product.code, id: product.id,
name: product.name, code: product.code,
description: product.description, name: product.name,
isActive: product.isActive, description: product.description,
}} /> isActive: product.isActive,
</td> }} />
</td>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -90,7 +107,7 @@ export default async function AdminProductsPage() {
</div> </div>
<p className="mt-3 text-xs text-neutral-400"> <p className="mt-3 text-xs text-neutral-400">
Last Price and Last Vendor are read-only updated automatically when a PO is marked as paid. Items and vendor prices are updated automatically when a PO is marked as paid.
</p> </p>
</div> </div>
); );

View file

@ -0,0 +1,213 @@
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 { EditVendorButton } from "../vendor-form";
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 VendorDetailPage({ params }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_vendors")) redirect("/dashboard");
const { id } = await params;
const vendor = await db.vendor.findUnique({
where: { id },
include: {
vendorPrices: {
include: { product: { select: { id: true, code: true, name: true, description: true, isActive: true } } },
orderBy: { updatedAt: "desc" },
},
purchaseOrders: {
select: { id: true, poNumber: true, status: true, totalAmount: true, createdAt: true },
orderBy: { createdAt: "desc" },
take: 10,
},
},
});
if (!vendor) notFound();
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending",
};
return (
<div className="max-w-5xl space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Link href="/admin/vendors" className="hover:text-neutral-700">Vendors</Link>
<span>/</span>
<span className="text-neutral-900 font-medium">{vendor.name}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<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>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
vendor.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
}`}>
{vendor.isActive ? "Active" : "Inactive"}
</span>
</div>
<h1 className="text-2xl font-semibold text-neutral-900">{vendor.name}</h1>
</div>
<EditVendorButton vendor={{
id: vendor.id,
name: vendor.name,
vendorId: vendor.vendorId,
address: (vendor as typeof vendor & { address?: string | null }).address ?? null,
gstin: (vendor as typeof vendor & { gstin?: string | null }).gstin ?? null,
contactName: vendor.contactName,
contactMobile: (vendor as typeof vendor & { contactMobile?: string | null }).contactMobile ?? null,
contactEmail: vendor.contactEmail,
isActive: vendor.isActive,
}} />
</div>
{/* Vendor Info */}
<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="grid grid-cols-2 gap-x-8 gap-y-3 text-sm">
{(vendor as typeof vendor & { gstin?: string | null }).gstin && (
<div>
<dt className="text-neutral-500">GSTIN</dt>
<dd className="font-mono text-neutral-900 tracking-wide">
{(vendor as typeof vendor & { gstin?: string | null }).gstin}
</dd>
</div>
)}
{(vendor as typeof vendor & { address?: string | null }).address && (
<div className="col-span-2">
<dt className="text-neutral-500">Address</dt>
<dd className="font-medium text-neutral-900 whitespace-pre-wrap">
{(vendor as typeof vendor & { address?: string | null }).address}
</dd>
</div>
)}
{vendor.contactName && (
<div>
<dt className="text-neutral-500">Contact</dt>
<dd className="font-medium text-neutral-900">
{[
vendor.contactName,
(vendor as typeof vendor & { contactMobile?: string | null }).contactMobile,
vendor.contactEmail,
].filter(Boolean).join(" · ")}
</dd>
</div>
)}
</dl>
</div>
{/* Items Catalogue */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">
Items Supplied
<span className="ml-2 text-neutral-400 font-normal">({vendor.vendorPrices.length})</span>
</h2>
{vendor.vendorPrices.length === 0 ? (
<p className="text-sm text-neutral-400 italic">
No items on record yet. Items are added automatically when a PO with this vendor 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">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">Last Price</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{vendor.vendorPrices.map((vp) => (
<tr key={vp.id} className="hover:bg-neutral-50">
<td className="py-2.5 pr-4">
<Link
href={`/admin/products/${vp.product.id}`}
className="font-medium text-primary-600 hover:underline"
>
{vp.product.name}
</Link>
{vp.product.description && (
<span className="block text-xs text-neutral-500 mt-0.5">{vp.product.description}</span>
)}
{!vp.product.isActive && (
<span className="ml-2 text-xs text-neutral-400 italic">inactive</span>
)}
</td>
<td className="py-2.5 pl-4 font-mono text-xs text-neutral-500">{vp.product.code}</td>
<td className="py-2.5 pl-4 text-right font-medium text-neutral-900">
{formatCurrency(Number(vp.price))}
</td>
<td className="py-2.5 pl-4 text-right text-neutral-500">{formatDate(vp.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Recent POs */}
{vendor.purchaseOrders.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">Recent Purchase Orders</h2>
<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">PO Number</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Status</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Amount</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{vendor.purchaseOrders.map((po) => (
<tr key={po.id} className="hover:bg-neutral-50">
<td className="py-2.5 pr-4">
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:underline">
{po.poNumber}
</Link>
</td>
<td className="py-2.5 pl-4 text-neutral-600">
{STATUS_LABELS[po.status] ?? po.status}
</td>
<td className="py-2.5 pl-4 text-right text-neutral-900">
{formatCurrency(Number(po.totalAmount))}
</td>
<td className="py-2.5 pl-4 text-right text-neutral-500">{formatDate(po.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -2,18 +2,21 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link";
import { AddVendorButton, EditVendorButton } from "./vendor-form"; import { AddVendorButton, EditVendorButton } from "./vendor-form";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vendor Management" }; export const metadata: Metadata = { title: "Vendor Registry" };
export default async function AdminVendorsPage() { export default async function AdminVendorsPage() {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_vendors")) redirect("/dashboard"); if (!hasPermission(session.user.role, "manage_vendors")) redirect("/dashboard");
const vendors = await db.vendor.findMany({ orderBy: { name: "asc" } }); const vendors = await db.vendor.findMany({
orderBy: { name: "asc" },
include: { _count: { select: { vendorPrices: true } } },
});
return ( return (
<div> <div>
@ -29,6 +32,7 @@ export default async function AdminVendorsPage() {
<th className="px-4 py-3 text-left font-medium text-neutral-600">Vendor ID</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Vendor ID</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Contact</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Contact</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Verified</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Verified</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3"></th> <th className="px-4 py-3"></th>
@ -40,13 +44,23 @@ export default async function AdminVendorsPage() {
<td className="px-4 py-3 font-mono text-xs text-neutral-600"> <td className="px-4 py-3 font-mono text-xs text-neutral-600">
{vendor.vendorId ?? <span className="text-warning-700 italic">Pending</span>} {vendor.vendorId ?? <span className="text-warning-700 italic">Pending</span>}
</td> </td>
<td className="px-4 py-3 font-medium text-neutral-900">{vendor.name}</td> <td className="px-4 py-3">
<Link
href={`/admin/vendors/${vendor.id}`}
className="font-medium text-primary-600 hover:underline"
>
{vendor.name}
</Link>
</td>
<td className="px-4 py-3 text-neutral-600"> <td className="px-4 py-3 text-neutral-600">
{vendor.contactName ?? "—"} {vendor.contactName ?? "—"}
{vendor.contactEmail && ( {vendor.contactEmail && (
<span className="block text-xs text-neutral-400">{vendor.contactEmail}</span> <span className="block text-xs text-neutral-400">{vendor.contactEmail}</span>
)} )}
</td> </td>
<td className="px-4 py-3 text-right text-neutral-600">
{vendor._count.vendorPrices > 0 ? vendor._count.vendorPrices : <span className="text-neutral-400"></span>}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${ <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 ? "bg-success-100 text-success-700" : "bg-warning-100 text-warning-700"

View file

@ -36,6 +36,7 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] }, { href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
{ href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "ACCOUNTS", "AUDITOR", "ADMIN"] }, { href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "ACCOUNTS", "AUDITOR", "ADMIN"] },
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS"] }, { href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER"] },
]; ];
const ADMIN_ITEMS: NavItem[] = [ const ADMIN_ITEMS: NavItem[] = [

View file

@ -36,6 +36,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"view_analytics", "view_analytics",
"export_reports", "export_reports",
"manage_vendors", "manage_vendors",
"manage_products",
], ],
SUPERUSER: [ SUPERUSER: [
"create_po", "create_po",