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 { 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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 { 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"
|
||||||
|
|
|
||||||
|
|
@ -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[] = [
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue