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:
parent
f4e0d8ae63
commit
1c7d0b8901
6 changed files with 451 additions and 34 deletions
171
App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx
Normal file
171
App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,11 +2,12 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
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 type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Product Catalogue" };
|
||||
export const metadata: Metadata = { title: "Item Catalogue" };
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
const session = await auth();
|
||||
|
|
@ -14,53 +15,67 @@ export default async function AdminProductsPage() {
|
|||
if (!hasPermission(session.user.role, "manage_products")) redirect("/dashboard");
|
||||
|
||||
const products = await db.product.findMany({
|
||||
orderBy: { code: "asc" },
|
||||
include: { lastVendor: true },
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
lastVendor: true,
|
||||
_count: { select: { vendorPrices: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const canManage = hasPermission(session.user.role, "manage_products") && session.user.role === "ADMIN";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Product Catalogue</h1>
|
||||
<AddProductButton />
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Item Catalogue</h1>
|
||||
{canManage && <AddProductButton />}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<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">Code</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-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">Status</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
{canManage && <th className="px-4 py-3"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{products.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-neutral-400">
|
||||
No products yet. Add the first product to start building the catalogue.
|
||||
<td colSpan={canManage ? 9 : 8} className="px-4 py-8 text-center text-neutral-400">
|
||||
No items yet. Items are added automatically when a PO is marked as paid.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{products.map((product) => (
|
||||
<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 font-medium text-neutral-900">{product.name}</td>
|
||||
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">
|
||||
{product.description ?? <span className="italic">—</span>}
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/products/${product.id}`}
|
||||
className="font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
</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
|
||||
? Number(product.lastPrice).toLocaleString("en-IN", {
|
||||
style: "currency",
|
||||
currency: "INR",
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
? formatCurrency(Number(product.lastPrice))
|
||||
: <span className="text-neutral-400 italic">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
|
|
@ -74,15 +89,17 @@ export default async function AdminProductsPage() {
|
|||
{product.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ToggleProductButton product={{
|
||||
id: product.id,
|
||||
code: product.code,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
isActive: product.isActive,
|
||||
}} />
|
||||
</td>
|
||||
{canManage && (
|
||||
<td className="px-4 py-3">
|
||||
<ToggleProductButton product={{
|
||||
id: product.id,
|
||||
code: product.code,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
isActive: product.isActive,
|
||||
}} />
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -90,7 +107,7 @@ export default async function AdminProductsPage() {
|
|||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
213
App/pelagia-portal/app/(portal)/admin/vendors/[id]/page.tsx
vendored
Normal file
213
App/pelagia-portal/app/(portal)/admin/vendors/[id]/page.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,18 +2,21 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { AddVendorButton, EditVendorButton } from "./vendor-form";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Vendor Management" };
|
||||
export const metadata: Metadata = { title: "Vendor Registry" };
|
||||
|
||||
export default async function AdminVendorsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
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 (
|
||||
<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">Name</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">Status</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">
|
||||
{vendor.vendorId ?? <span className="text-warning-700 italic">Pending</span>}
|
||||
</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">
|
||||
{vendor.contactName ?? "—"}
|
||||
{vendor.contactEmail && (
|
||||
<span className="block text-xs text-neutral-400">{vendor.contactEmail}</span>
|
||||
)}
|
||||
</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">
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
|
||||
{ href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "ACCOUNTS", "AUDITOR", "ADMIN"] },
|
||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS"] },
|
||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER"] },
|
||||
];
|
||||
|
||||
const ADMIN_ITEMS: NavItem[] = [
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"view_analytics",
|
||||
"export_reports",
|
||||
"manage_vendors",
|
||||
"manage_products",
|
||||
],
|
||||
SUPERUSER: [
|
||||
"create_po",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue