feat(vendor): multiple contacts, vendor detail page, and table-based items catalog

VendorContact model
- New VendorContact table (name, role, mobile, email, isPrimary) with
  cascade delete from Vendor
- Old single contactName/Mobile/Email fields removed from Vendor model
- Migration: 20260514191638_vendor_contacts

Vendor form
- ContactsEditor: dynamic list of contact rows — add, remove, mark primary
- Each contact row: name, role, mobile, email, primary checkbox
- Serialised as contacts[i].field form fields; existing single-contact
  section removed

Vendor actions
- parseContacts() reads indexed contacts from FormData
- createVendor creates VendorContact rows in a nested write
- updateVendor deletes all contacts then re-creates them in a transaction

Vendor list page
- Contact column shows primary contact name + email; "+N more" badge

Vendor detail page
- Two-column layout: Vendor Details card + Contacts card
- Contacts displayed with avatar initials, role badge, primary badge
- VendorItemsTable client component: inline search (name, code,
  description), tabular layout with links to item detail

Inventory items page (/inventory/items)
- Rebuilt as a searchable table (ItemsTable client component)
- Columns: Item name/description, Code, Vendor count, Lowest price
- Click any row to inline-expand vendor sub-rows for that item
- Vendor sub-rows: vendor name, verified badge, price, distance (if site
  selected), + Cart button with "Added ✓" feedback
- Sort toggle (Distance / Price) shown in toolbar when a row is open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-15 00:50:39 +05:30
parent 902bd5f048
commit 79897c5b06
9 changed files with 782 additions and 230 deletions

View file

@ -5,11 +5,10 @@ import { notFound, redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils"; import { formatCurrency, formatDate } from "@/lib/utils";
import { EditVendorButton } from "../vendor-form"; import { EditVendorButton } from "../vendor-form";
import { VendorItemsTable } from "./vendor-items-table";
import type { Metadata } from "next"; import type { Metadata } from "next";
interface Props { interface Props { params: Promise<{ id: string }> }
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params; const { id } = await params;
@ -17,6 +16,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return { title: vendor?.name ?? "Vendor Detail" }; return { title: vendor?.name ?? "Vendor Detail" };
} }
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",
};
export default async function VendorDetailPage({ params }: Props) { export default async function VendorDetailPage({ params }: Props) {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
@ -27,6 +33,7 @@ export default async function VendorDetailPage({ params }: Props) {
const vendor = await db.vendor.findUnique({ const vendor = await db.vendor.findUnique({
where: { id }, where: { id },
include: { include: {
contacts: { orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }] },
vendorPrices: { vendorPrices: {
include: { product: { select: { id: true, code: true, name: true, description: true, isActive: true } } }, include: { product: { select: { id: true, code: true, name: true, description: true, isActive: true } } },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
@ -41,12 +48,16 @@ export default async function VendorDetailPage({ params }: Props) {
if (!vendor) notFound(); if (!vendor) notFound();
const STATUS_LABELS: Record<string, string> = { const items = vendor.vendorPrices.map((vp) => ({
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review", id: vp.id,
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment", productId: vp.product.id,
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", code: vp.product.code,
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending", name: vp.product.name,
}; description: vp.product.description ?? "",
isActive: vp.product.isActive,
price: Number(vp.price),
updatedAt: vp.updatedAt.toISOString(),
}));
return ( return (
<div className="max-w-5xl space-y-6"> <div className="max-w-5xl space-y-6">
@ -61,17 +72,11 @@ export default async function VendorDetailPage({ params }: Props) {
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-1">
{vendor.vendorId && ( {vendor.vendorId && <span className="font-mono text-xs text-neutral-500">{vendor.vendorId}</span>}
<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"}`}>
)}
<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"} {vendor.isVerified ? "Verified" : "Unverified"}
</span> </span>
<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.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
vendor.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
}`}>
{vendor.isActive ? "Active" : "Inactive"} {vendor.isActive ? "Active" : "Inactive"}
</span> </span>
</div> </div>
@ -84,96 +89,76 @@ export default async function VendorDetailPage({ params }: Props) {
address: vendor.address ?? null, address: vendor.address ?? null,
pincode: vendor.pincode ?? null, pincode: vendor.pincode ?? null,
gstin: vendor.gstin ?? null, gstin: vendor.gstin ?? null,
contactName: vendor.contactName,
contactMobile: vendor.contactMobile ?? null,
contactEmail: vendor.contactEmail,
isActive: vendor.isActive, isActive: vendor.isActive,
contacts: vendor.contacts.map((c) => ({
name: c.name, role: c.role ?? "", mobile: c.mobile ?? "",
email: c.email ?? "", isPrimary: c.isPrimary,
})),
}} /> }} />
</div> </div>
{/* Vendor Info */} {/* Vendor Info + Contacts */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Details */}
<div className="rounded-lg border border-neutral-200 bg-white p-6"> <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> <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"> <dl className="space-y-3 text-sm">
{(vendor as typeof vendor & { gstin?: string | null }).gstin && ( {vendor.gstin && (
<div> <div>
<dt className="text-neutral-500">GSTIN</dt> <dt className="text-neutral-500 text-xs uppercase tracking-wide font-medium mb-0.5">GSTIN</dt>
<dd className="font-mono text-neutral-900 tracking-wide"> <dd className="font-mono text-neutral-900 tracking-wide">{vendor.gstin}</dd>
{(vendor as typeof vendor & { gstin?: string | null }).gstin}
</dd>
</div> </div>
)} )}
{(vendor as typeof vendor & { address?: string | null }).address && ( {vendor.address && (
<div className="col-span-2"> <div>
<dt className="text-neutral-500">Address</dt> <dt className="text-neutral-500 text-xs uppercase tracking-wide font-medium mb-0.5">Address</dt>
<dd className="font-medium text-neutral-900 whitespace-pre-wrap"> <dd className="text-neutral-900 whitespace-pre-wrap">{vendor.address}</dd>
{(vendor as typeof vendor & { address?: string | null }).address}
</dd>
</div> </div>
)} )}
{vendor.contactName && ( {vendor.pincode && (
<div> <div>
<dt className="text-neutral-500">Contact</dt> <dt className="text-neutral-500 text-xs uppercase tracking-wide font-medium mb-0.5">Pincode</dt>
<dd className="font-medium text-neutral-900"> <dd className="font-mono text-neutral-900">{vendor.pincode}</dd>
{[
vendor.contactName,
(vendor as typeof vendor & { contactMobile?: string | null }).contactMobile,
vendor.contactEmail,
].filter(Boolean).join(" · ")}
</dd>
</div> </div>
)} )}
</dl> </dl>
</div> </div>
{/* Items Catalogue */} {/* Contacts */}
<div className="rounded-lg border border-neutral-200 bg-white p-6"> <div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4"> <h2 className="text-sm font-semibold text-neutral-900 mb-4">
Items Supplied Contacts
<span className="ml-2 text-neutral-400 font-normal">({vendor.vendorPrices.length})</span> <span className="ml-2 text-neutral-400 font-normal">({vendor.contacts.length})</span>
</h2> </h2>
{vendor.vendorPrices.length === 0 ? ( {vendor.contacts.length === 0 ? (
<p className="text-sm text-neutral-400 italic"> <p className="text-sm text-neutral-400 italic">No contacts on record.</p>
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"> <div className="space-y-3">
<thead> {vendor.contacts.map((c) => (
<tr className="border-b border-neutral-200"> <div key={c.id} className="flex items-start gap-3">
<th className="pb-2 text-left font-medium text-neutral-600">Item</th> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700">
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Code</th> {c.name.slice(0, 2).toUpperCase()}
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Last Price</th> </div>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Updated</th> <div className="min-w-0">
</tr> <div className="flex items-center gap-2">
</thead> <span className="font-medium text-sm text-neutral-900">{c.name}</span>
<tbody className="divide-y divide-neutral-100"> {c.role && <span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">{c.role}</span>}
{vendor.vendorPrices.map((vp) => ( {c.isPrimary && <span className="rounded bg-primary-100 px-1.5 py-0.5 text-xs text-primary-700 font-medium">Primary</span>}
<tr key={vp.id} className="hover:bg-neutral-50"> </div>
<td className="py-2.5 pr-4"> <div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5 text-xs text-neutral-500">
<Link {c.mobile && <span>{c.mobile}</span>}
href={`/admin/products/${vp.product.id}`} {c.email && <a href={`mailto:${c.email}`} className="hover:text-primary-600 hover:underline">{c.email}</a>}
className="font-medium text-primary-600 hover:underline" </div>
> </div>
{vp.product.name} </div>
</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> </div>
</table>
)} )}
</div> </div>
</div>
{/* Items Supplied — searchable client component */}
<VendorItemsTable items={items} />
{/* Recent POs */} {/* Recent POs */}
{vendor.purchaseOrders.length > 0 && ( {vendor.purchaseOrders.length > 0 && (
@ -196,12 +181,8 @@ export default async function VendorDetailPage({ params }: Props) {
{po.poNumber} {po.poNumber}
</Link> </Link>
</td> </td>
<td className="py-2.5 pl-4 text-neutral-600"> <td className="py-2.5 pl-4 text-neutral-600">{STATUS_LABELS[po.status] ?? po.status}</td>
{STATUS_LABELS[po.status] ?? po.status} <td className="py-2.5 pl-4 text-right text-neutral-900">{formatCurrency(Number(po.totalAmount))}</td>
</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> <td className="py-2.5 pl-4 text-right text-neutral-500">{formatDate(po.createdAt)}</td>
</tr> </tr>
))} ))}

View file

@ -0,0 +1,95 @@
"use client";
import { useState, useMemo } from "react";
import { Search, X } from "lucide-react";
import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils";
type Item = {
id: string;
productId: string;
code: string;
name: string;
description: string;
isActive: boolean;
price: number;
updatedAt: string;
};
export function VendorItemsTable({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.toLowerCase().trim();
if (!q) return items;
return items.filter(
(item) =>
item.name.toLowerCase().includes(q) ||
item.code.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q)
);
}, [items, query]);
return (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-neutral-900">
Items Supplied
<span className="ml-2 text-neutral-400 font-normal">({filtered.length}{query ? ` of ${items.length}` : ""})</span>
</h2>
{/* Search */}
<div className="relative w-60">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-neutral-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search items…"
className="w-full rounded-lg border border-neutral-200 py-1.5 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
{query && (
<button onClick={() => setQuery("")} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600">
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{items.length === 0 ? (
<p className="text-sm text-neutral-400 italic">
No items on record yet. Items are added automatically when a PO with this vendor is marked as paid.
</p>
) : filtered.length === 0 ? (
<p className="text-sm text-neutral-400 italic py-4 text-center">No items match "{query}"</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200">
<th className="pb-2 text-left font-medium text-neutral-600">Item</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4 whitespace-nowrap">Code</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Last Price</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{filtered.map((item) => (
<tr key={item.id} className="hover:bg-neutral-50">
<td className="py-2.5 pr-4">
<Link href={`/admin/products/${item.productId}`} className="font-medium text-primary-600 hover:underline">
{item.name}
</Link>
{item.description && (
<span className="block text-xs text-neutral-500 mt-0.5 line-clamp-1">{item.description}</span>
)}
{!item.isActive && <span className="ml-1 text-xs text-neutral-400 italic">inactive</span>}
</td>
<td className="py-2.5 pl-4 font-mono text-xs text-neutral-500">{item.code}</td>
<td className="py-2.5 pl-4 text-right font-medium text-neutral-900">{formatCurrency(item.price)}</td>
<td className="py-2.5 pl-4 text-right text-neutral-500">{formatDate(new Date(item.updatedAt))}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View file

@ -9,17 +9,41 @@ import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string }; type ActionResult = { ok: true } | { error: string };
const contactSchema = z.object({
name: z.string().min(1),
role: z.string().optional(),
mobile: z.string().optional(),
email: z.string().optional(),
isPrimary: z.boolean().default(false),
});
const vendorSchema = z.object({ const vendorSchema = z.object({
name: z.string().min(1, "Vendor name is required"), name: z.string().min(1, "Vendor name is required"),
vendorId: z.string().optional(), vendorId: z.string().optional(),
address: z.string().optional(), address: z.string().optional(),
pincode: z.string().optional(), pincode: z.string().optional(),
gstin: z.string().optional(), gstin: z.string().optional(),
contactName: z.string().optional(),
contactMobile: z.string().optional(),
contactEmail: z.string().email("Invalid contact email").optional().or(z.literal("")),
}); });
function parseContacts(formData: FormData) {
const contacts: z.infer<typeof contactSchema>[] = [];
let i = 0;
while (formData.has(`contacts[${i}].name`)) {
const name = (formData.get(`contacts[${i}].name`) as string).trim();
if (name) {
contacts.push({
name,
role: (formData.get(`contacts[${i}].role`) as string) || undefined,
mobile: (formData.get(`contacts[${i}].mobile`) as string) || undefined,
email: (formData.get(`contacts[${i}].email`) as string) || undefined,
isPrimary: formData.get(`contacts[${i}].isPrimary`) === "true",
});
}
i++;
}
return contacts;
}
async function resolveLatLng(pincode?: string) { async function resolveLatLng(pincode?: string) {
if (!pincode) return { latitude: null, longitude: null }; if (!pincode) return { latitude: null, longitude: null };
const coords = await geocodePincode(pincode); const coords = await geocodePincode(pincode);
@ -38,9 +62,6 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
address: formData.get("address") || undefined, address: formData.get("address") || undefined,
pincode: formData.get("pincode") || undefined, pincode: formData.get("pincode") || undefined,
gstin: formData.get("gstin") || undefined, gstin: formData.get("gstin") || undefined,
contactName: formData.get("contactName") || undefined,
contactMobile: formData.get("contactMobile") || undefined,
contactEmail: formData.get("contactEmail") || undefined,
}); });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
@ -51,6 +72,7 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
} }
const { latitude, longitude } = await resolveLatLng(data.pincode); const { latitude, longitude } = await resolveLatLng(data.pincode);
const contacts = parseContacts(formData);
await db.vendor.create({ await db.vendor.create({
data: { data: {
@ -61,10 +83,8 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
gstin: data.gstin ?? null, gstin: data.gstin ?? null,
latitude, latitude,
longitude, longitude,
contactName: data.contactName ?? null,
contactMobile: data.contactMobile ?? null,
contactEmail: data.contactEmail || null,
isVerified: !!data.vendorId, isVerified: !!data.vendorId,
contacts: contacts.length > 0 ? { create: contacts } : undefined,
}, },
}); });
@ -87,9 +107,6 @@ export async function updateVendor(formData: FormData): Promise<ActionResult> {
address: formData.get("address") || undefined, address: formData.get("address") || undefined,
pincode: formData.get("pincode") || undefined, pincode: formData.get("pincode") || undefined,
gstin: formData.get("gstin") || undefined, gstin: formData.get("gstin") || undefined,
contactName: formData.get("contactName") || undefined,
contactMobile: formData.get("contactMobile") || undefined,
contactEmail: formData.get("contactEmail") || undefined,
}); });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
@ -105,7 +122,10 @@ export async function updateVendor(formData: FormData): Promise<ActionResult> {
? await resolveLatLng(data.pincode) ? await resolveLatLng(data.pincode)
: { latitude: existing?.latitude ?? null, longitude: existing?.longitude ?? null }; : { latitude: existing?.latitude ?? null, longitude: existing?.longitude ?? null };
await db.vendor.update({ const contacts = parseContacts(formData);
await db.$transaction(async (tx) => {
await tx.vendor.update({
where: { id }, where: { id },
data: { data: {
name: data.name, name: data.name,
@ -115,13 +135,31 @@ export async function updateVendor(formData: FormData): Promise<ActionResult> {
gstin: data.gstin ?? null, gstin: data.gstin ?? null,
latitude, latitude,
longitude, longitude,
contactName: data.contactName ?? null,
contactMobile: data.contactMobile ?? null,
contactEmail: data.contactEmail || null,
isVerified: !!data.vendorId, isVerified: !!data.vendorId,
}, },
}); });
// Replace contacts wholesale
await tx.vendorContact.deleteMany({ where: { vendorId: id } });
if (contacts.length > 0) {
await tx.vendorContact.createMany({ data: contacts.map((c) => ({ ...c, vendorId: id })) });
}
});
revalidatePath("/admin/vendors");
revalidatePath(`/admin/vendors/${id}`);
return { ok: true };
}
export async function toggleVendorActive(vendorId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) {
return { error: "Unauthorized" };
}
const vendor = await db.vendor.findUnique({ where: { id: vendorId }, select: { isActive: true } });
if (!vendor) return { error: "Vendor not found" };
await db.vendor.update({ where: { id: vendorId }, data: { isActive: !vendor.isActive } });
revalidatePath("/admin/vendors"); revalidatePath("/admin/vendors");
return { ok: true }; return { ok: true };
} }
@ -145,17 +183,3 @@ export async function deleteVendor(id: string): Promise<ActionResult> {
revalidatePath("/admin/vendors"); revalidatePath("/admin/vendors");
return { ok: true }; return { ok: true };
} }
export async function toggleVendorActive(vendorId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) {
return { error: "Unauthorized" };
}
const vendor = await db.vendor.findUnique({ where: { id: vendorId }, select: { isActive: true } });
if (!vendor) return { error: "Vendor not found" };
await db.vendor.update({ where: { id: vendorId }, data: { isActive: !vendor.isActive } });
revalidatePath("/admin/vendors");
return { ok: true };
}

View file

@ -17,7 +17,10 @@ export default async function AdminVendorsPage() {
const vendors = await db.vendor.findMany({ const vendors = await db.vendor.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { _count: { select: { vendorPrices: true } } }, include: {
_count: { select: { vendorPrices: true } },
contacts: { orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }] },
},
}); });
return ( return (
@ -55,10 +58,17 @@ export default async function AdminVendorsPage() {
</Link> </Link>
</td> </td>
<td className="px-4 py-3 text-neutral-600"> <td className="px-4 py-3 text-neutral-600">
{vendor.contactName ?? "—"} {vendor.contacts.length > 0 ? (
{vendor.contactEmail && ( <>
<span className="block text-xs text-neutral-400">{vendor.contactEmail}</span> <span>{vendor.contacts[0].name}</span>
{vendor.contacts[0].email && (
<span className="block text-xs text-neutral-400">{vendor.contacts[0].email}</span>
)} )}
{vendor.contacts.length > 1 && (
<span className="block text-xs text-neutral-400">+{vendor.contacts.length - 1} more</span>
)}
</>
) : "—"}
</td> </td>
<td className="px-4 py-3 text-right text-neutral-600"> <td className="px-4 py-3 text-right text-neutral-600">
{vendor._count.vendorPrices > 0 ? vendor._count.vendorPrices : <span className="text-neutral-400"></span>} {vendor._count.vendorPrices > 0 ? vendor._count.vendorPrices : <span className="text-neutral-400"></span>}
@ -86,10 +96,11 @@ export default async function AdminVendorsPage() {
address: vendor.address ?? null, address: vendor.address ?? null,
pincode: vendor.pincode ?? null, pincode: vendor.pincode ?? null,
gstin: vendor.gstin ?? null, gstin: vendor.gstin ?? null,
contactName: vendor.contactName,
contactMobile: vendor.contactMobile ?? null,
contactEmail: vendor.contactEmail,
isActive: vendor.isActive, isActive: vendor.isActive,
contacts: vendor.contacts.map((c) => ({
name: c.name, role: c.role ?? "", mobile: c.mobile ?? "",
email: c.email ?? "", isPrimary: c.isPrimary,
})),
}} /> }} />
<ConfirmDeleteButton onDelete={deleteVendor.bind(null, vendor.id)} label={vendor.name} /> <ConfirmDeleteButton onDelete={deleteVendor.bind(null, vendor.id)} label={vendor.name} />
</span> </span>

View file

@ -2,30 +2,123 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Plus, Trash2 } from "lucide-react";
import { AdminDialog } from "@/components/ui/admin-dialog"; import { AdminDialog } from "@/components/ui/admin-dialog";
import { createVendor, updateVendor, toggleVendorActive } from "./actions"; import { createVendor, updateVendor, toggleVendorActive } from "./actions";
type ContactRow = { name: string; role: string; mobile: string; email: string; isPrimary: boolean };
type VendorRow = { type VendorRow = {
id: string; name: string; vendorId: string | null; address: string | null; id: string; name: string; vendorId: string | null; address: string | null;
pincode: string | null; gstin: string | null; contactName: string | null; pincode: string | null; gstin: string | null; isActive: boolean;
contactMobile: string | null; contactEmail: string | null; isActive: boolean; contacts: ContactRow[];
}; };
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const INPUT_SM = "w-full rounded border border-neutral-300 px-2 py-1.5 text-xs focus:border-primary-500 focus:outline-none";
type GstResult = { type GstResult = {
legalName: string; tradeName: string; address: string; state: string; legalName: string; tradeName: string; address: string;
pincode: string; gstin: string; status: string; businessType: string; pincode: string; gstin: string; status: string; registrationDate: string;
registrationDate: string; lat: number | null; lng: number | null;
}; };
function ContactsEditor({ initial }: { initial?: ContactRow[] }) {
const blank = (): ContactRow => ({ name: "", role: "", mobile: "", email: "", isPrimary: false });
const [rows, setRows] = useState<ContactRow[]>(initial?.length ? initial : [blank()]);
function update(i: number, field: keyof ContactRow, value: string | boolean) {
setRows(rows.map((r, idx) => idx === i ? { ...r, [field]: value } : r));
}
function setPrimary(i: number) {
setRows(rows.map((r, idx) => ({ ...r, isPrimary: idx === i })));
}
function remove(i: number) {
if (rows.length === 1) { setRows([blank()]); return; }
setRows(rows.filter((_, idx) => idx !== i));
}
return (
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-medium text-neutral-700">Contacts</label>
<button
type="button"
onClick={() => setRows([...rows, blank()])}
className="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700 font-medium"
>
<Plus className="h-3 w-3" /> Add contact
</button>
</div>
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="rounded-lg border border-neutral-200 bg-neutral-50 p-2 space-y-1.5">
<div className="grid grid-cols-2 gap-1.5">
<input
name={`contacts[${i}].name`}
value={row.name}
onChange={(e) => update(i, "name", e.target.value)}
placeholder="Name *"
className={INPUT_SM}
/>
<input
name={`contacts[${i}].role`}
value={row.role}
onChange={(e) => update(i, "role", e.target.value)}
placeholder="Role (e.g. Sales)"
className={INPUT_SM}
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<input
name={`contacts[${i}].mobile`}
value={row.mobile}
onChange={(e) => update(i, "mobile", e.target.value)}
placeholder="Mobile"
className={INPUT_SM}
/>
<input
name={`contacts[${i}].email`}
value={row.email}
onChange={(e) => update(i, "email", e.target.value)}
placeholder="Email"
type="email"
className={INPUT_SM}
/>
</div>
<input type="hidden" name={`contacts[${i}].isPrimary`} value={String(row.isPrimary)} />
<div className="flex items-center justify-between">
<label className="flex items-center gap-1.5 text-xs text-neutral-500 cursor-pointer">
<input
type="checkbox"
checked={row.isPrimary}
onChange={() => setPrimary(i)}
className="rounded border-neutral-300"
/>
Primary contact
</label>
<button
type="button"
onClick={() => remove(i)}
className="text-neutral-400 hover:text-danger-600"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
);
}
function VendorFormFields({ vendor }: { vendor?: VendorRow }) { function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
const [gstin, setGstin] = useState(vendor?.gstin ?? ""); const [gstin, setGstin] = useState(vendor?.gstin ?? "");
const [name, setName] = useState(vendor?.name ?? ""); const [name, setName] = useState(vendor?.name ?? "");
const [address, setAddress] = useState(vendor?.address ?? ""); const [address, setAddress] = useState(vendor?.address ?? "");
const [pincode, setPincode] = useState(vendor?.pincode ?? ""); const [pincode, setPincode] = useState(vendor?.pincode ?? "");
// CAPTCHA flow state
const [captchaStep, setCaptchaStep] = useState<"idle" | "loading" | "ready" | "verifying">("idle"); const [captchaStep, setCaptchaStep] = useState<"idle" | "loading" | "ready" | "verifying">("idle");
const [captchaB64, setCaptchaB64] = useState(""); const [captchaB64, setCaptchaB64] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState(""); const [captchaAnswer, setCaptchaAnswer] = useState("");
@ -56,17 +149,7 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }), body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
}); });
const data: GstResult & { error?: string } = await res.json(); const data: GstResult & { error?: string } = await res.json();
if (data.error) { if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; }
setGstError(data.error);
// Wrong captcha → allow retry with fresh captcha
if (data.error.includes("CAPTCHA") || data.error.includes("captcha")) {
setCaptchaStep("idle");
} else {
setCaptchaStep("idle");
}
return;
}
// Fill form fields
setName(data.tradeName || data.legalName); setName(data.tradeName || data.legalName);
setAddress(data.address); setAddress(data.address);
if (data.pincode) setPincode(data.pincode); if (data.pincode) setPincode(data.pincode);
@ -77,10 +160,10 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* GSTIN + captcha flow */} {/* GSTIN lookup */}
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1"> <label className="block text-xs font-medium text-neutral-700 mb-1">
GSTIN <span className="text-neutral-400 font-normal">(auto-fills name, address & location from GST portal)</span> GSTIN <span className="text-neutral-400 font-normal">(auto-fills name, address & location)</span>
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
@ -100,56 +183,35 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
{captchaStep === "loading" ? "Loading…" : "Look up"} {captchaStep === "loading" ? "Loading…" : "Look up"}
</button> </button>
</div> </div>
{/* CAPTCHA challenge */}
{captchaStep === "ready" && captchaB64 && ( {captchaStep === "ready" && captchaB64 && (
<div className="mt-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2"> <div className="mt-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
<p className="text-xs text-neutral-600">Enter the code shown in the image to verify with the GST portal:</p> <p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
<img <img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
src={`data:image/png;base64,${captchaB64}`} className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
alt="CAPTCHA"
className="rounded border border-neutral-200 bg-white"
style={{ imageRendering: "pixelated", height: 48 }}
/>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<input <input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
type="text"
inputMode="numeric"
maxLength={6}
value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))} onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
placeholder="6 digits" placeholder="6 digits"
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none" className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none"
autoFocus autoFocus
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
/> />
<button <button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6}
type="button" className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
onClick={submitSearch}
disabled={captchaAnswer.length !== 6}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50"
>
Verify Verify
</button> </button>
<button <button type="button" onClick={fetchCaptcha} className="text-xs text-neutral-500 hover:underline">
type="button"
onClick={fetchCaptcha}
className="text-xs text-neutral-500 hover:underline"
>
New image New image
</button> </button>
</div> </div>
</div> </div>
)} )}
{captchaStep === "verifying" && <p className="mt-1 text-xs text-neutral-500">Verifying</p>}
{captchaStep === "verifying" && (
<p className="mt-1 text-xs text-neutral-500">Verifying with GST portal</p>
)}
{gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>} {gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>} {gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
</div> </div>
{/* Name + Vendor ID */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor Name *</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Vendor Name *</label>
@ -161,32 +223,22 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
</div> </div>
</div> </div>
{/* Address + Pincode */}
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Registered Address</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Registered Address</label>
<textarea name="address" value={address} onChange={(e) => setAddress(e.target.value)} rows={2} className={INPUT} /> <textarea name="address" value={address} onChange={(e) => setAddress(e.target.value)} rows={2} className={INPUT} />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1"> <label className="block text-xs font-medium text-neutral-700 mb-1">
Pincode <span className="text-neutral-400 font-normal">(auto from GSTIN used to calculate distance from sites)</span> Pincode <span className="text-neutral-400 font-normal">(used for distance calculation)</span>
</label> </label>
<input name="pincode" value={pincode} onChange={(e) => setPincode(e.target.value.replace(/\D/g, "").slice(0, 6))} className={INPUT + " font-mono"} placeholder="400001" maxLength={6} /> <input name="pincode" value={pincode}
onChange={(e) => setPincode(e.target.value.replace(/\D/g, "").slice(0, 6))}
className={INPUT + " font-mono"} placeholder="400001" maxLength={6} />
</div> </div>
<div className="grid grid-cols-3 gap-3"> {/* Contacts */}
<div> <ContactsEditor initial={vendor?.contacts} />
<label className="block text-xs font-medium text-neutral-700 mb-1">Contact Name</label>
<input name="contactName" defaultValue={vendor?.contactName ?? ""} className={INPUT} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Mobile</label>
<input name="contactMobile" defaultValue={vendor?.contactMobile ?? ""} className={INPUT} placeholder="9876543210" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Email</label>
<input name="contactEmail" type="email" defaultValue={vendor?.contactEmail ?? ""} className={INPUT} />
</div>
</div>
</div> </div>
); );
} }
@ -206,14 +258,21 @@ export function AddVendorButton() {
return ( return (
<> <>
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700">+ Add Vendor</button> <button onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700">
+ Add Vendor
</button>
<AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}> <AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<VendorFormFields /> <VendorFormFields />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>} {error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button> <button type="button" onClick={() => setOpen(false)}
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Creating…" : "Create Vendor"}</button> className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
{pending ? "Creating…" : "Create Vendor"}
</button>
</div> </div>
</form> </form>
</AdminDialog> </AdminDialog>
@ -246,12 +305,17 @@ export function EditVendorButton({ vendor }: { vendor: VendorRow }) {
<VendorFormFields vendor={vendor} /> <VendorFormFields vendor={vendor} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>} {error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button type="button" onClick={handleToggle} className={`text-xs underline ${vendor.isActive ? "text-danger-600" : "text-success-600"}`}> <button type="button" onClick={handleToggle}
className={`text-xs underline ${vendor.isActive ? "text-danger-600" : "text-success-600"}`}>
{vendor.isActive ? "Deactivate" : "Activate"} {vendor.isActive ? "Deactivate" : "Activate"}
</button> </button>
<div className="flex gap-3"> <div className="flex gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button> <button type="button" onClick={() => setOpen(false)}
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button> className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
{pending ? "Saving…" : "Save"}
</button>
</div> </div>
</div> </div>
</form> </form>

View file

@ -0,0 +1,261 @@
"use client";
import { useState, useMemo } from "react";
import { Search, X, ChevronDown, ChevronRight, MapPin, Tag } from "lucide-react";
import { formatCurrency } from "@/lib/utils";
import { addToCart } from "@/lib/cart";
type VendorOption = {
vendorId: string;
vendorName: string;
isVerified: boolean;
price: number;
distanceKm: number | null;
};
type CatalogItem = {
id: string;
code: string;
name: string;
description: string;
vendors: VendorOption[];
};
function formatDist(km: number) {
return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(0)} km`;
}
export function ItemsTable({ items, hasSite }: { items: CatalogItem[]; hasSite: boolean }) {
const [query, setQuery] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<"distance" | "price">(hasSite ? "distance" : "price");
const [added, setAdded] = useState<Record<string, boolean>>({});
const filtered = useMemo(() => {
const q = query.toLowerCase().trim();
if (!q) return items;
return items.filter(
(item) =>
item.name.toLowerCase().includes(q) ||
item.code.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q)
);
}, [items, query]);
function getSortedVendors(vendors: VendorOption[]) {
const v = [...vendors];
if (sortBy === "distance") {
v.sort((a, b) => {
if (a.distanceKm !== null && b.distanceKm !== null) return a.distanceKm - b.distanceKm;
if (a.distanceKm !== null) return -1;
if (b.distanceKm !== null) return 1;
return a.price - b.price;
});
} else {
v.sort((a, b) => a.price - b.price);
}
return v;
}
function handleAdd(item: CatalogItem, vendor: VendorOption) {
addToCart({
productId: item.id,
name: item.name,
description: item.description || undefined,
quantity: 1,
unit: "pc",
unitPrice: vendor.price,
vendorId: vendor.vendorId,
vendorName: vendor.vendorName,
});
const key = `${item.id}-${vendor.vendorId}`;
setAdded((prev) => ({ ...prev, [key]: true }));
setTimeout(() => setAdded((prev) => ({ ...prev, [key]: false })), 1500);
}
function toggleRow(id: string) {
setExpandedId((prev) => (prev === id ? null : id));
}
return (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-neutral-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, code or description…"
className="w-full rounded-lg border border-neutral-200 py-2 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
{query && (
<button onClick={() => setQuery("")} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600">
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<span className="text-xs text-neutral-400">
{filtered.length} item{filtered.length !== 1 ? "s" : ""}
</span>
{/* Vendor sort toggle — only shown when an item is expanded */}
{expandedId && (
<div className="flex items-center gap-1 ml-auto text-xs">
<span className="text-neutral-500">Vendors sorted by:</span>
<button
onClick={() => setSortBy("distance")}
disabled={!hasSite}
title={!hasSite ? "Select a site first" : undefined}
className={`flex items-center gap-1 px-2 py-1 rounded font-medium transition-colors ${
sortBy === "distance" ? "bg-primary-100 text-primary-700" : "text-neutral-500 hover:bg-neutral-100"
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
<MapPin className="h-3 w-3" /> Distance
</button>
<button
onClick={() => setSortBy("price")}
className={`flex items-center gap-1 px-2 py-1 rounded font-medium transition-colors ${
sortBy === "price" ? "bg-primary-100 text-primary-700" : "text-neutral-500 hover:bg-neutral-100"
}`}
>
<Tag className="h-3 w-3" /> Price
</button>
</div>
)}
</div>
{/* Table */}
<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 w-6" />
<th className="px-4 py-3 text-left font-medium text-neutral-600">Item</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</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">From</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-neutral-400 italic">
{query ? `No items match "${query}"` : "No items in catalogue yet."}
</td>
</tr>
)}
{filtered.map((item) => {
const isOpen = expandedId === item.id;
const lowestPrice = item.vendors.length > 0 ? Math.min(...item.vendors.map((v) => v.price)) : null;
const sortedVendors = getSortedVendors(item.vendors);
return (
<>
{/* Item row */}
<tr
key={item.id}
className={`cursor-pointer border-b border-neutral-100 transition-colors ${isOpen ? "bg-primary-50" : "hover:bg-neutral-50"}`}
onClick={() => toggleRow(item.id)}
>
<td className="px-4 py-3 text-neutral-400">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</td>
<td className="px-4 py-3">
<span className="font-medium text-neutral-900">{item.name}</span>
{item.description && (
<span className="block text-xs text-neutral-500 mt-0.5 line-clamp-1">{item.description}</span>
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-500">{item.code}</td>
<td className="px-4 py-3 text-right text-neutral-600">
{item.vendors.length > 0 ? item.vendors.length : <span className="text-neutral-400"></span>}
</td>
<td className="px-4 py-3 text-right">
{lowestPrice !== null
? <span className="font-medium text-success-700">{formatCurrency(lowestPrice)}</span>
: <span className="text-neutral-400 italic text-xs">No price</span>}
</td>
</tr>
{/* Expanded vendor sub-rows */}
{isOpen && (
<tr key={`${item.id}-vendors`} className="border-b border-neutral-200">
<td colSpan={5} className="p-0">
{sortedVendors.length === 0 ? (
<div className="px-12 py-3 text-xs text-neutral-400 italic bg-neutral-50">
No vendors on record. Add this item manually in the PO form.
</div>
) : (
<table className="w-full text-sm bg-neutral-50">
<thead>
<tr className="border-b border-neutral-200">
<th className="px-12 py-2 text-left text-xs font-medium text-neutral-500 uppercase tracking-wide">Vendor</th>
<th className="px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase tracking-wide">Price</th>
{hasSite && <th className="px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase tracking-wide">Distance</th>}
<th className="px-4 py-2 w-24" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{sortedVendors.map((vendor, idx) => {
const key = `${item.id}-${vendor.vendorId}`;
const isFirst = idx === 0;
return (
<tr key={vendor.vendorId} className="hover:bg-white">
<td className="px-12 py-2.5">
<div className="flex items-center gap-2">
<span className="font-medium text-neutral-800">{vendor.vendorName}</span>
{vendor.isVerified && (
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
)}
{isFirst && sortBy === "distance" && vendor.distanceKm !== null && (
<span className="text-xs text-primary-600 font-medium"> Closest</span>
)}
{isFirst && sortBy === "price" && (
<span className="text-xs text-success-600 font-medium">Cheapest</span>
)}
</div>
</td>
<td className="px-4 py-2.5 text-right font-semibold text-neutral-900">
{formatCurrency(vendor.price)}
</td>
{hasSite && (
<td className="px-4 py-2.5 text-right text-xs text-neutral-500">
{vendor.distanceKm !== null ? formatDist(vendor.distanceKm) : "—"}
</td>
)}
<td className="px-4 py-2.5 text-right">
<button
onClick={(e) => { e.stopPropagation(); handleAdd(item, vendor); }}
className={`rounded px-2.5 py-1 text-xs font-semibold transition-colors ${
added[key]
? "bg-success-600 text-white"
: "bg-primary-600 text-white hover:bg-primary-700"
}`}
>
{added[key] ? "Added ✓" : "+ Cart"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
</div>
{filtered.length > 0 && (
<p className="text-xs text-neutral-400">
Click any row to see vendors. Prices shown are last known from paid POs.
</p>
)}
</div>
);
}

View file

@ -0,0 +1,76 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { distanceKm } from "@/lib/geo";
import { ItemsTable } from "./items-table";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Browse Items" };
export default async function InventoryItemsPage() {
const session = await auth();
if (!session?.user) redirect("/login");
const [user, products] = await Promise.all([
db.user.findUnique({
where: { id: session.user.id },
include: {
preferredSite: { select: { id: true, name: true, latitude: true, longitude: true } },
},
}),
db.product.findMany({
where: { isActive: true },
include: {
vendorPrices: {
where: { vendor: { isActive: true } },
include: {
vendor: {
select: { id: true, name: true, isVerified: true, latitude: true, longitude: true },
},
},
orderBy: { price: "asc" },
},
},
orderBy: { name: "asc" },
}),
]);
const site = user?.preferredSite ?? null;
const items = products.map((p) => ({
id: p.id,
code: p.code,
name: p.name,
description: p.description ?? "",
vendors: p.vendorPrices.map((vp) => {
let dist: number | null = null;
if (site?.latitude && site.longitude && vp.vendor.latitude && vp.vendor.longitude) {
dist = distanceKm(site.latitude, site.longitude, vp.vendor.latitude, vp.vendor.longitude);
}
return {
vendorId: vp.vendor.id,
vendorName: vp.vendor.name,
isVerified: vp.vendor.isVerified,
price: Number(vp.price),
distanceKm: dist,
};
}),
}));
return (
<div className="max-w-6xl">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Browse Items</h1>
<p className="mt-1 text-sm text-neutral-500">
Search the catalogue and add items to your cart.
{site ? (
<span className="ml-1 text-primary-600">Distances shown from {site.name}.</span>
) : (
<span className="ml-1 text-neutral-400">Select a site in the header to enable distance sorting.</span>
)}
</p>
</div>
<ItemsTable items={items} hasSite={!!site} />
</div>
);
}

View file

@ -0,0 +1,29 @@
/*
Warnings:
- You are about to drop the column `contactEmail` on the `Vendor` table. All the data in the column will be lost.
- You are about to drop the column `contactMobile` on the `Vendor` table. All the data in the column will be lost.
- You are about to drop the column `contactName` on the `Vendor` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Vendor" DROP COLUMN "contactEmail",
DROP COLUMN "contactMobile",
DROP COLUMN "contactName";
-- CreateTable
CREATE TABLE "VendorContact" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT,
"mobile" TEXT,
"email" TEXT,
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"vendorId" TEXT NOT NULL,
CONSTRAINT "VendorContact_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "VendorContact" ADD CONSTRAINT "VendorContact_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -104,6 +104,19 @@ model Account {
lineItems POLineItem[] lineItems POLineItem[]
} }
model VendorContact {
id String @id @default(cuid())
name String
role String?
mobile String?
email String?
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
vendorId String
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Cascade)
}
model Vendor { model Vendor {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@ -111,15 +124,13 @@ model Vendor {
address String? address String?
pincode String? pincode String?
gstin String? gstin String?
contactName String?
contactMobile String?
contactEmail String?
latitude Float? latitude Float?
longitude Float? longitude Float?
isVerified Boolean @default(false) isVerified Boolean @default(false)
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
contacts VendorContact[]
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
products Product[] @relation("ProductLastVendor") products Product[] @relation("ProductLastVendor")
vendorPrices ProductVendorPrice[] vendorPrices ProductVendorPrice[]