From 1c7d0b8901375eac83bf4ce4efb5edcefc6996d5 Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 11 May 2026 04:25:30 +0530 Subject: [PATCH] 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 --- .../app/(portal)/admin/products/[id]/page.tsx | 171 ++++++++++++++ .../app/(portal)/admin/products/page.tsx | 77 ++++--- .../app/(portal)/admin/vendors/[id]/page.tsx | 213 ++++++++++++++++++ .../app/(portal)/admin/vendors/page.tsx | 22 +- .../components/layout/sidebar.tsx | 1 + App/pelagia-portal/lib/permissions.ts | 1 + 6 files changed, 451 insertions(+), 34 deletions(-) create mode 100644 App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx create mode 100644 App/pelagia-portal/app/(portal)/admin/vendors/[id]/page.tsx diff --git a/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx b/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx new file mode 100644 index 0000000..117643b --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx @@ -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 { + 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 ( +
+ {/* Breadcrumb */} +
+ Items + / + {product.name} +
+ + {/* Header */} +
+
+
+ {product.code} + + {product.isActive ? "Active" : "Inactive"} + +
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} +
+ {canManage && ( + + )} +
+ + {/* Price summary */} + {product.vendorPrices.length > 0 && ( +
+
+

Vendors

+

{product.vendorPrices.length}

+
+
+

Lowest Price

+

+ {minPrice !== null ? formatCurrency(minPrice) : "—"} +

+
+
+

Highest Price

+

+ {maxPrice !== null ? formatCurrency(maxPrice) : "—"} +

+
+
+ )} + + {/* Vendors that carry this item */} +
+

+ Available From + ({product.vendorPrices.length} vendor{product.vendorPrices.length !== 1 ? "s" : ""}) +

+ {product.vendorPrices.length === 0 ? ( +

+ No vendor pricing on record yet. Prices are recorded automatically when a PO containing this item is marked as paid. +

+ ) : ( + + + + + + + + + + + + {product.vendorPrices.map((vp) => { + const price = Number(vp.price); + const isCheapest = minPrice !== null && price === minPrice && product.vendorPrices.length > 1; + return ( + + + + + + + + ); + })} + +
VendorVendor IDVerifiedPriceLast Updated
+ + {vp.vendor.name} + + {!vp.vendor.isActive && ( + inactive + )} + + {vp.vendor.vendorId ?? Pending} + + + {vp.vendor.isVerified ? "Verified" : "Unverified"} + + + + {formatCurrency(price)} + + {isCheapest && ( + lowest + )} + {formatDate(vp.updatedAt)}
+ )} +
+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/products/page.tsx b/App/pelagia-portal/app/(portal)/admin/products/page.tsx index f3dc8aa..2edd002 100644 --- a/App/pelagia-portal/app/(portal)/admin/products/page.tsx +++ b/App/pelagia-portal/app/(portal)/admin/products/page.tsx @@ -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 (
-

Product Catalogue

- +

Item Catalogue

+ {canManage && }
- + + - + {canManage && } {products.length === 0 && ( - )} {products.map((product) => ( - - - - + + + - + {canManage && ( + + )} ))} @@ -90,7 +107,7 @@ export default async function AdminProductsPage() {

- 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.

); diff --git a/App/pelagia-portal/app/(portal)/admin/vendors/[id]/page.tsx b/App/pelagia-portal/app/(portal)/admin/vendors/[id]/page.tsx new file mode 100644 index 0000000..804807a --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/vendors/[id]/page.tsx @@ -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 { + 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 = { + 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 ( +
+ {/* Breadcrumb */} +
+ Vendors + / + {vendor.name} +
+ + {/* Header */} +
+
+
+ {vendor.vendorId && ( + {vendor.vendorId} + )} + + {vendor.isVerified ? "Verified" : "Unverified"} + + + {vendor.isActive ? "Active" : "Inactive"} + +
+

{vendor.name}

+
+ +
+ + {/* Vendor Info */} +
+

Vendor Details

+
+ {(vendor as typeof vendor & { gstin?: string | null }).gstin && ( +
+
GSTIN
+
+ {(vendor as typeof vendor & { gstin?: string | null }).gstin} +
+
+ )} + {(vendor as typeof vendor & { address?: string | null }).address && ( +
+
Address
+
+ {(vendor as typeof vendor & { address?: string | null }).address} +
+
+ )} + {vendor.contactName && ( +
+
Contact
+
+ {[ + vendor.contactName, + (vendor as typeof vendor & { contactMobile?: string | null }).contactMobile, + vendor.contactEmail, + ].filter(Boolean).join(" · ")} +
+
+ )} +
+
+ + {/* Items Catalogue */} +
+

+ Items Supplied + ({vendor.vendorPrices.length}) +

+ {vendor.vendorPrices.length === 0 ? ( +

+ No items on record yet. Items are added automatically when a PO with this vendor is marked as paid. +

+ ) : ( +
Code NameCode DescriptionVendors Last Price Last Vendor Updated Status
- 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.
{product.code}{product.name} - {product.description ?? } + + + {product.name} + + {product.code} + {product.description ?? } + + {product._count.vendorPrices > 0 + ? product._count.vendorPrices + : } + {product.lastPrice !== null - ? Number(product.lastPrice).toLocaleString("en-IN", { - style: "currency", - currency: "INR", - maximumFractionDigits: 2, - }) + ? formatCurrency(Number(product.lastPrice)) : } @@ -74,15 +89,17 @@ export default async function AdminProductsPage() { {product.isActive ? "Active" : "Inactive"} - - + +
+ + + + + + + + + + {vendor.vendorPrices.map((vp) => ( + + + + + + + ))} + +
ItemCodeLast PriceUpdated
+ + {vp.product.name} + + {vp.product.description && ( + {vp.product.description} + )} + {!vp.product.isActive && ( + inactive + )} + {vp.product.code} + {formatCurrency(Number(vp.price))} + {formatDate(vp.updatedAt)}
+ )} +
+ + {/* Recent POs */} + {vendor.purchaseOrders.length > 0 && ( +
+

Recent Purchase Orders

+ + + + + + + + + + + {vendor.purchaseOrders.map((po) => ( + + + + + + + ))} + +
PO NumberStatusAmountDate
+ + {po.poNumber} + + + {STATUS_LABELS[po.status] ?? po.status} + + {formatCurrency(Number(po.totalAmount))} + {formatDate(po.createdAt)}
+
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx b/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx index 44ea03b..7f4bf4f 100644 --- a/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx +++ b/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx @@ -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 (
@@ -29,6 +32,7 @@ export default async function AdminVendorsPage() { Vendor ID Name Contact + Items Verified Status @@ -40,13 +44,23 @@ export default async function AdminVendorsPage() { {vendor.vendorId ?? Pending} - {vendor.name} + + + {vendor.name} + + {vendor.contactName ?? "—"} {vendor.contactEmail && ( {vendor.contactEmail} )} + + {vendor._count.vendorPrices > 0 ? vendor._count.vendorPrices : } + = { "view_analytics", "export_reports", "manage_vendors", + "manage_products", ], SUPERUSER: [ "create_po",