From 79897c5b06d830409f594d64be0ea8209f836dcf Mon Sep 17 00:00:00 2001 From: Hardik Date: Fri, 15 May 2026 00:50:39 +0530 Subject: [PATCH] feat(vendor): multiple contacts, vendor detail page, and table-based items catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/(portal)/admin/vendors/[id]/page.tsx | 193 ++++++------- .../admin/vendors/[id]/vendor-items-table.tsx | 95 +++++++ .../app/(portal)/admin/vendors/actions.ts | 106 ++++--- .../app/(portal)/admin/vendors/page.tsx | 27 +- .../(portal)/admin/vendors/vendor-form.tsx | 208 +++++++++----- .../(portal)/inventory/items/items-table.tsx | 261 ++++++++++++++++++ .../app/(portal)/inventory/items/page.tsx | 76 +++++ .../migration.sql | 29 ++ App/pelagia-portal/prisma/schema.prisma | 17 +- 9 files changed, 782 insertions(+), 230 deletions(-) create mode 100644 App/pelagia-portal/app/(portal)/admin/vendors/[id]/vendor-items-table.tsx create mode 100644 App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx create mode 100644 App/pelagia-portal/app/(portal)/inventory/items/page.tsx create mode 100644 App/pelagia-portal/prisma/migrations/20260514191638_vendor_contacts/migration.sql diff --git a/App/pelagia-portal/app/(portal)/admin/vendors/[id]/page.tsx b/App/pelagia-portal/app/(portal)/admin/vendors/[id]/page.tsx index 7974969..8e9de87 100644 --- a/App/pelagia-portal/app/(portal)/admin/vendors/[id]/page.tsx +++ b/App/pelagia-portal/app/(portal)/admin/vendors/[id]/page.tsx @@ -5,11 +5,10 @@ import { notFound, redirect } from "next/navigation"; import Link from "next/link"; import { formatCurrency, formatDate } from "@/lib/utils"; import { EditVendorButton } from "../vendor-form"; +import { VendorItemsTable } from "./vendor-items-table"; import type { Metadata } from "next"; -interface Props { - params: Promise<{ id: string }>; -} +interface Props { params: Promise<{ id: string }> } export async function generateMetadata({ params }: Props): Promise { const { id } = await params; @@ -17,6 +16,13 @@ export async function generateMetadata({ params }: Props): Promise { return { title: vendor?.name ?? "Vendor Detail" }; } +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", +}; + export default async function VendorDetailPage({ params }: Props) { const session = await auth(); if (!session?.user) redirect("/login"); @@ -27,6 +33,7 @@ export default async function VendorDetailPage({ params }: Props) { const vendor = await db.vendor.findUnique({ where: { id }, include: { + contacts: { orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }] }, vendorPrices: { include: { product: { select: { id: true, code: true, name: true, description: true, isActive: true } } }, orderBy: { updatedAt: "desc" }, @@ -41,12 +48,16 @@ export default async function VendorDetailPage({ params }: Props) { 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", - }; + const items = vendor.vendorPrices.map((vp) => ({ + id: vp.id, + productId: vp.product.id, + code: vp.product.code, + name: vp.product.name, + description: vp.product.description ?? "", + isActive: vp.product.isActive, + price: Number(vp.price), + updatedAt: vp.updatedAt.toISOString(), + })); return (
@@ -61,17 +72,11 @@ export default async function VendorDetailPage({ params }: Props) {
- {vendor.vendorId && ( - {vendor.vendorId} - )} - + {vendor.vendorId && {vendor.vendorId}} + {vendor.isVerified ? "Verified" : "Unverified"} - + {vendor.isActive ? "Active" : "Inactive"}
@@ -84,96 +89,76 @@ export default async function VendorDetailPage({ params }: Props) { address: vendor.address ?? null, pincode: vendor.pincode ?? null, gstin: vendor.gstin ?? null, - contactName: vendor.contactName, - contactMobile: vendor.contactMobile ?? null, - contactEmail: vendor.contactEmail, isActive: vendor.isActive, + contacts: vendor.contacts.map((c) => ({ + name: c.name, role: c.role ?? "", mobile: c.mobile ?? "", + email: c.email ?? "", isPrimary: c.isPrimary, + })), }} />
- {/* Vendor Info */} -
-

Vendor Details

-
- {(vendor as typeof vendor & { gstin?: string | null }).gstin && ( -
-
GSTIN
-
- {(vendor as typeof vendor & { gstin?: string | null }).gstin} -
+ {/* Vendor Info + Contacts */} +
+ {/* Details */} +
+

Vendor Details

+
+ {vendor.gstin && ( +
+
GSTIN
+
{vendor.gstin}
+
+ )} + {vendor.address && ( +
+
Address
+
{vendor.address}
+
+ )} + {vendor.pincode && ( +
+
Pincode
+
{vendor.pincode}
+
+ )} +
+
+ + {/* Contacts */} +
+

+ Contacts + ({vendor.contacts.length}) +

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

No contacts on record.

+ ) : ( +
+ {vendor.contacts.map((c) => ( +
+
+ {c.name.slice(0, 2).toUpperCase()} +
+
+
+ {c.name} + {c.role && {c.role}} + {c.isPrimary && Primary} +
+
+ {c.mobile && {c.mobile}} + {c.email && {c.email}} +
+
+
+ ))}
)} - {(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. -

- ) : ( - - - - - - - - - - - {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)}
- )} -
+ {/* Items Supplied — searchable client component */} + {/* Recent POs */} {vendor.purchaseOrders.length > 0 && ( @@ -196,12 +181,8 @@ export default async function VendorDetailPage({ params }: Props) { {po.poNumber} - - {STATUS_LABELS[po.status] ?? po.status} - - - {formatCurrency(Number(po.totalAmount))} - + {STATUS_LABELS[po.status] ?? po.status} + {formatCurrency(Number(po.totalAmount))} {formatDate(po.createdAt)} ))} diff --git a/App/pelagia-portal/app/(portal)/admin/vendors/[id]/vendor-items-table.tsx b/App/pelagia-portal/app/(portal)/admin/vendors/[id]/vendor-items-table.tsx new file mode 100644 index 0000000..daf8999 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/vendors/[id]/vendor-items-table.tsx @@ -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 ( +
+
+

+ Items Supplied + ({filtered.length}{query ? ` of ${items.length}` : ""}) +

+ {/* Search */} +
+ + 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 && ( + + )} +
+
+ + {items.length === 0 ? ( +

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

+ ) : filtered.length === 0 ? ( +

No items match "{query}"

+ ) : ( + + + + + + + + + + + {filtered.map((item) => ( + + + + + + + ))} + +
ItemCodeLast PriceUpdated
+ + {item.name} + + {item.description && ( + {item.description} + )} + {!item.isActive && inactive} + {item.code}{formatCurrency(item.price)}{formatDate(new Date(item.updatedAt))}
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/vendors/actions.ts b/App/pelagia-portal/app/(portal)/admin/vendors/actions.ts index 4693f49..7247043 100644 --- a/App/pelagia-portal/app/(portal)/admin/vendors/actions.ts +++ b/App/pelagia-portal/app/(portal)/admin/vendors/actions.ts @@ -9,17 +9,41 @@ import { revalidatePath } from "next/cache"; 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({ name: z.string().min(1, "Vendor name is required"), vendorId: z.string().optional(), address: z.string().optional(), pincode: 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[] = []; + 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) { if (!pincode) return { latitude: null, longitude: null }; const coords = await geocodePincode(pincode); @@ -38,9 +62,6 @@ export async function createVendor(formData: FormData): Promise { address: formData.get("address") || undefined, pincode: formData.get("pincode") || 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" }; @@ -51,6 +72,7 @@ export async function createVendor(formData: FormData): Promise { } const { latitude, longitude } = await resolveLatLng(data.pincode); + const contacts = parseContacts(formData); await db.vendor.create({ data: { @@ -61,10 +83,8 @@ export async function createVendor(formData: FormData): Promise { gstin: data.gstin ?? null, latitude, longitude, - contactName: data.contactName ?? null, - contactMobile: data.contactMobile ?? null, - contactEmail: data.contactEmail || null, isVerified: !!data.vendorId, + contacts: contacts.length > 0 ? { create: contacts } : undefined, }, }); @@ -87,9 +107,6 @@ export async function updateVendor(formData: FormData): Promise { address: formData.get("address") || undefined, pincode: formData.get("pincode") || 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" }; @@ -105,23 +122,44 @@ export async function updateVendor(formData: FormData): Promise { ? await resolveLatLng(data.pincode) : { latitude: existing?.latitude ?? null, longitude: existing?.longitude ?? null }; - await db.vendor.update({ - where: { id }, - data: { - name: data.name, - vendorId: data.vendorId ?? null, - address: data.address ?? null, - pincode: data.pincode ?? null, - gstin: data.gstin ?? null, - latitude, - longitude, - contactName: data.contactName ?? null, - contactMobile: data.contactMobile ?? null, - contactEmail: data.contactEmail || null, - isVerified: !!data.vendorId, - }, + const contacts = parseContacts(formData); + + await db.$transaction(async (tx) => { + await tx.vendor.update({ + where: { id }, + data: { + name: data.name, + vendorId: data.vendorId ?? null, + address: data.address ?? null, + pincode: data.pincode ?? null, + gstin: data.gstin ?? null, + latitude, + longitude, + 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 { + 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 }; } @@ -145,17 +183,3 @@ export async function deleteVendor(id: string): Promise { revalidatePath("/admin/vendors"); return { ok: true }; } - -export async function toggleVendorActive(vendorId: string): Promise { - 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 }; -} diff --git a/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx b/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx index 4c1a1ae..2fb8b69 100644 --- a/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx +++ b/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx @@ -17,7 +17,10 @@ export default async function AdminVendorsPage() { const vendors = await db.vendor.findMany({ orderBy: { name: "asc" }, - include: { _count: { select: { vendorPrices: true } } }, + include: { + _count: { select: { vendorPrices: true } }, + contacts: { orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }] }, + }, }); return ( @@ -55,10 +58,17 @@ export default async function AdminVendorsPage() { - {vendor.contactName ?? "—"} - {vendor.contactEmail && ( - {vendor.contactEmail} - )} + {vendor.contacts.length > 0 ? ( + <> + {vendor.contacts[0].name} + {vendor.contacts[0].email && ( + {vendor.contacts[0].email} + )} + {vendor.contacts.length > 1 && ( + +{vendor.contacts.length - 1} more + )} + + ) : "—"} {vendor._count.vendorPrices > 0 ? vendor._count.vendorPrices : } @@ -86,10 +96,11 @@ export default async function AdminVendorsPage() { address: vendor.address ?? null, pincode: vendor.pincode ?? null, gstin: vendor.gstin ?? null, - contactName: vendor.contactName, - contactMobile: vendor.contactMobile ?? null, - contactEmail: vendor.contactEmail, isActive: vendor.isActive, + contacts: vendor.contacts.map((c) => ({ + name: c.name, role: c.role ?? "", mobile: c.mobile ?? "", + email: c.email ?? "", isPrimary: c.isPrimary, + })), }} /> diff --git a/App/pelagia-portal/app/(portal)/admin/vendors/vendor-form.tsx b/App/pelagia-portal/app/(portal)/admin/vendors/vendor-form.tsx index 5bb93ac..a5ade6e 100644 --- a/App/pelagia-portal/app/(portal)/admin/vendors/vendor-form.tsx +++ b/App/pelagia-portal/app/(portal)/admin/vendors/vendor-form.tsx @@ -2,30 +2,123 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { Plus, Trash2 } from "lucide-react"; import { AdminDialog } from "@/components/ui/admin-dialog"; import { createVendor, updateVendor, toggleVendorActive } from "./actions"; +type ContactRow = { name: string; role: string; mobile: string; email: string; isPrimary: boolean }; + type VendorRow = { id: string; name: string; vendorId: string | null; address: string | null; - pincode: string | null; gstin: string | null; contactName: string | null; - contactMobile: string | null; contactEmail: string | null; isActive: boolean; + pincode: string | null; gstin: 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_SM = "w-full rounded border border-neutral-300 px-2 py-1.5 text-xs focus:border-primary-500 focus:outline-none"; type GstResult = { - legalName: string; tradeName: string; address: string; state: string; - pincode: string; gstin: string; status: string; businessType: string; - registrationDate: string; lat: number | null; lng: number | null; + legalName: string; tradeName: string; address: string; + pincode: string; gstin: string; status: string; registrationDate: string; }; +function ContactsEditor({ initial }: { initial?: ContactRow[] }) { + const blank = (): ContactRow => ({ name: "", role: "", mobile: "", email: "", isPrimary: false }); + const [rows, setRows] = useState(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 ( +
+
+ + +
+
+ {rows.map((row, i) => ( +
+
+ update(i, "name", e.target.value)} + placeholder="Name *" + className={INPUT_SM} + /> + update(i, "role", e.target.value)} + placeholder="Role (e.g. Sales)" + className={INPUT_SM} + /> +
+
+ update(i, "mobile", e.target.value)} + placeholder="Mobile" + className={INPUT_SM} + /> + update(i, "email", e.target.value)} + placeholder="Email" + type="email" + className={INPUT_SM} + /> +
+ +
+ + +
+
+ ))} +
+
+ ); +} + function VendorFormFields({ vendor }: { vendor?: VendorRow }) { const [gstin, setGstin] = useState(vendor?.gstin ?? ""); const [name, setName] = useState(vendor?.name ?? ""); const [address, setAddress] = useState(vendor?.address ?? ""); const [pincode, setPincode] = useState(vendor?.pincode ?? ""); - // CAPTCHA flow state const [captchaStep, setCaptchaStep] = useState<"idle" | "loading" | "ready" | "verifying">("idle"); const [captchaB64, setCaptchaB64] = useState(""); const [captchaAnswer, setCaptchaAnswer] = useState(""); @@ -56,17 +149,7 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) { body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }), }); const data: GstResult & { error?: string } = await res.json(); - if (data.error) { - 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 + if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; } setName(data.tradeName || data.legalName); setAddress(data.address); if (data.pincode) setPincode(data.pincode); @@ -77,10 +160,10 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) { return (
- {/* GSTIN + captcha flow */} + {/* GSTIN lookup */}
- - {/* CAPTCHA challenge */} {captchaStep === "ready" && captchaB64 && (
-

Enter the code shown in the image to verify with the GST portal:

- CAPTCHA +

Enter the code shown in the image:

+ CAPTCHA
- setCaptchaAnswer(e.target.value.replace(/\D/g, ""))} 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" autoFocus onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }} /> - -
)} - - {captchaStep === "verifying" && ( -

Verifying with GST portal…

- )} - + {captchaStep === "verifying" &&

Verifying…

} {gstError &&

{gstError}

} {gstSuccess &&

{gstSuccess}

}
+ {/* Name + Vendor ID */}
@@ -161,32 +223,22 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
+ {/* Address + Pincode */}