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:
parent
902bd5f048
commit
79897c5b06
9 changed files with 782 additions and 230 deletions
|
|
@ -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<Metadata> {
|
||||
const { id } = await params;
|
||||
|
|
@ -17,6 +16,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
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) {
|
||||
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<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",
|
||||
};
|
||||
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 (
|
||||
<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>
|
||||
<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.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"
|
||||
}`}>
|
||||
<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>
|
||||
|
|
@ -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,
|
||||
})),
|
||||
}} />
|
||||
</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>
|
||||
{/* 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">
|
||||
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Vendor Details</h2>
|
||||
<dl className="space-y-3 text-sm">
|
||||
{vendor.gstin && (
|
||||
<div>
|
||||
<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">{vendor.gstin}</dd>
|
||||
</div>
|
||||
)}
|
||||
{vendor.address && (
|
||||
<div>
|
||||
<dt className="text-neutral-500 text-xs uppercase tracking-wide font-medium mb-0.5">Address</dt>
|
||||
<dd className="text-neutral-900 whitespace-pre-wrap">{vendor.address}</dd>
|
||||
</div>
|
||||
)}
|
||||
{vendor.pincode && (
|
||||
<div>
|
||||
<dt className="text-neutral-500 text-xs uppercase tracking-wide font-medium mb-0.5">Pincode</dt>
|
||||
<dd className="font-mono text-neutral-900">{vendor.pincode}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Contacts */}
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-sm font-semibold text-neutral-900 mb-4">
|
||||
Contacts
|
||||
<span className="ml-2 text-neutral-400 font-normal">({vendor.contacts.length})</span>
|
||||
</h2>
|
||||
{vendor.contacts.length === 0 ? (
|
||||
<p className="text-sm text-neutral-400 italic">No contacts on record.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{vendor.contacts.map((c) => (
|
||||
<div key={c.id} className="flex items-start gap-3">
|
||||
<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">
|
||||
{c.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-neutral-900">{c.name}</span>
|
||||
{c.role && <span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">{c.role}</span>}
|
||||
{c.isPrimary && <span className="rounded bg-primary-100 px-1.5 py-0.5 text-xs text-primary-700 font-medium">Primary</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5 text-xs text-neutral-500">
|
||||
{c.mobile && <span>{c.mobile}</span>}
|
||||
{c.email && <a href={`mailto:${c.email}`} className="hover:text-primary-600 hover:underline">{c.email}</a>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
</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>
|
||||
{/* Items Supplied — searchable client component */}
|
||||
<VendorItemsTable items={items} />
|
||||
|
||||
{/* Recent POs */}
|
||||
{vendor.purchaseOrders.length > 0 && (
|
||||
|
|
@ -196,12 +181,8 @@ export default async function VendorDetailPage({ params }: Props) {
|
|||
{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-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>
|
||||
))}
|
||||
|
|
|
|||
95
App/pelagia-portal/app/(portal)/admin/vendors/[id]/vendor-items-table.tsx
vendored
Normal file
95
App/pelagia-portal/app/(portal)/admin/vendors/[id]/vendor-items-table.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<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) {
|
||||
if (!pincode) return { latitude: null, longitude: null };
|
||||
const coords = await geocodePincode(pincode);
|
||||
|
|
@ -38,9 +62,6 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
|
|||
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<ActionResult> {
|
|||
}
|
||||
|
||||
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<ActionResult> {
|
|||
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<ActionResult> {
|
|||
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<ActionResult> {
|
|||
? 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<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 };
|
||||
}
|
||||
|
|
@ -145,17 +183,3 @@ export async function deleteVendor(id: string): Promise<ActionResult> {
|
|||
revalidatePath("/admin/vendors");
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{vendor.contactName ?? "—"}
|
||||
{vendor.contactEmail && (
|
||||
<span className="block text-xs text-neutral-400">{vendor.contactEmail}</span>
|
||||
)}
|
||||
{vendor.contacts.length > 0 ? (
|
||||
<>
|
||||
<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 className="px-4 py-3 text-right text-neutral-600">
|
||||
{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,
|
||||
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,
|
||||
})),
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteVendor.bind(null, vendor.id)} label={vendor.name} />
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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<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 }) {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
{/* GSTIN + captcha flow */}
|
||||
{/* GSTIN lookup */}
|
||||
<div>
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
|
|
@ -100,56 +183,35 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
|
|||
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CAPTCHA challenge */}
|
||||
{captchaStep === "ready" && captchaB64 && (
|
||||
<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>
|
||||
<img
|
||||
src={`data:image/png;base64,${captchaB64}`}
|
||||
alt="CAPTCHA"
|
||||
className="rounded border border-neutral-200 bg-white"
|
||||
style={{ imageRendering: "pixelated", height: 48 }}
|
||||
/>
|
||||
<p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
|
||||
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
|
||||
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={captchaAnswer}
|
||||
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
|
||||
onChange={(e) => 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(); } }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<button type="button" 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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchCaptcha}
|
||||
className="text-xs text-neutral-500 hover:underline"
|
||||
>
|
||||
<button type="button" onClick={fetchCaptcha} className="text-xs text-neutral-500 hover:underline">
|
||||
New image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{captchaStep === "verifying" && (
|
||||
<p className="mt-1 text-xs text-neutral-500">Verifying with GST portal…</p>
|
||||
)}
|
||||
|
||||
{captchaStep === "verifying" && <p className="mt-1 text-xs text-neutral-500">Verifying…</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>}
|
||||
</div>
|
||||
|
||||
{/* Name + Vendor ID */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<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>
|
||||
|
||||
{/* Address + Pincode */}
|
||||
<div>
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<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 className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<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>
|
||||
{/* Contacts */}
|
||||
<ContactsEditor initial={vendor?.contacts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -206,14 +258,21 @@ export function AddVendorButton() {
|
|||
|
||||
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)}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<VendorFormFields />
|
||||
{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">
|
||||
<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="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>
|
||||
<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="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>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
|
|
@ -246,12 +305,17 @@ export function EditVendorButton({ vendor }: { vendor: VendorRow }) {
|
|||
<VendorFormFields vendor={vendor} />
|
||||
{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">
|
||||
<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"}
|
||||
</button>
|
||||
<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="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>
|
||||
<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="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>
|
||||
</form>
|
||||
|
|
|
|||
261
App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx
Normal file
261
App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
App/pelagia-portal/app/(portal)/inventory/items/page.tsx
Normal file
76
App/pelagia-portal/app/(portal)/inventory/items/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -104,6 +104,19 @@ model Account {
|
|||
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 {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
|
|
@ -111,15 +124,13 @@ model Vendor {
|
|||
address String?
|
||||
pincode String?
|
||||
gstin String?
|
||||
contactName String?
|
||||
contactMobile String?
|
||||
contactEmail String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
isVerified Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
contacts VendorContact[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
products Product[] @relation("ProductLastVendor")
|
||||
vendorPrices ProductVendorPrice[]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue