feat(vendor): pincode-based geocoding, vendor delete, and GST captcha auto-fill

- Replace manual lat/lng fields in the vendor form with a single
  Pincode field; location is auto-filled from the GST GSTIN lookup
- On save the Server Action geocodes the pincode via Nominatim and
  caches lat/lng in the DB so distance queries stay fast
- Add deleteVendor action: blocked by non-draft POs; nulls out draft
  PO vendorId and Product.lastVendorId; cascades ProductVendorPrice
- Add ConfirmDeleteButton shared component (inline two-step confirm)

Migration: 20260514091124_vendor_pincode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-14 17:07:43 +05:30
parent 2e6678f829
commit 392bad7549
7 changed files with 138 additions and 40 deletions

View file

@ -81,10 +81,11 @@ export default async function VendorDetailPage({ params }: Props) {
id: vendor.id,
name: vendor.name,
vendorId: vendor.vendorId,
address: (vendor as typeof vendor & { address?: string | null }).address ?? null,
gstin: (vendor as typeof vendor & { gstin?: string | null }).gstin ?? null,
address: vendor.address ?? null,
pincode: vendor.pincode ?? null,
gstin: vendor.gstin ?? null,
contactName: vendor.contactName,
contactMobile: (vendor as typeof vendor & { contactMobile?: string | null }).contactMobile ?? null,
contactMobile: vendor.contactMobile ?? null,
contactEmail: vendor.contactEmail,
isActive: vendor.isActive,
}} />

View file

@ -3,6 +3,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { geocodePincode } from "@/lib/geo";
import { z } from "zod";
import { revalidatePath } from "next/cache";
@ -12,14 +13,19 @@ 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(),
latitude: z.coerce.number().optional(),
longitude: z.coerce.number().optional(),
contactName: z.string().optional(),
contactMobile: z.string().optional(),
contactEmail: z.string().email("Invalid contact email").optional().or(z.literal("")),
});
async function resolveLatLng(pincode?: string) {
if (!pincode) return { latitude: null, longitude: null };
const coords = await geocodePincode(pincode);
return { latitude: coords?.lat ?? null, longitude: coords?.lng ?? null };
}
export async function createVendor(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) {
@ -30,9 +36,8 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
name: formData.get("name"),
vendorId: formData.get("vendorId") || undefined,
address: formData.get("address") || undefined,
pincode: formData.get("pincode") || undefined,
gstin: formData.get("gstin") || undefined,
latitude: formData.get("latitude") || undefined,
longitude: formData.get("longitude") || undefined,
contactName: formData.get("contactName") || undefined,
contactMobile: formData.get("contactMobile") || undefined,
contactEmail: formData.get("contactEmail") || undefined,
@ -45,14 +50,17 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
if (exists) return { error: "A vendor with that Vendor ID already exists" };
}
const { latitude, longitude } = await resolveLatLng(data.pincode);
await db.vendor.create({
data: {
name: data.name,
vendorId: data.vendorId ?? null,
address: data.address ?? null,
pincode: data.pincode ?? null,
gstin: data.gstin ?? null,
latitude: data.latitude ?? null,
longitude: data.longitude ?? null,
latitude,
longitude,
contactName: data.contactName ?? null,
contactMobile: data.contactMobile ?? null,
contactEmail: data.contactEmail || null,
@ -77,9 +85,8 @@ export async function updateVendor(formData: FormData): Promise<ActionResult> {
name: formData.get("name"),
vendorId: formData.get("vendorId") || undefined,
address: formData.get("address") || undefined,
pincode: formData.get("pincode") || undefined,
gstin: formData.get("gstin") || undefined,
latitude: formData.get("latitude") || undefined,
longitude: formData.get("longitude") || undefined,
contactName: formData.get("contactName") || undefined,
contactMobile: formData.get("contactMobile") || undefined,
contactEmail: formData.get("contactEmail") || undefined,
@ -92,15 +99,22 @@ export async function updateVendor(formData: FormData): Promise<ActionResult> {
if (conflict) return { error: "Another vendor already has that Vendor ID" };
}
const existing = await db.vendor.findUnique({ where: { id }, select: { pincode: true, latitude: true, longitude: true } });
const pincodeChanged = data.pincode !== (existing?.pincode ?? undefined);
const { latitude, longitude } = pincodeChanged
? 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: data.latitude ?? null,
longitude: data.longitude ?? null,
latitude,
longitude,
contactName: data.contactName ?? null,
contactMobile: data.contactMobile ?? null,
contactEmail: data.contactEmail || null,
@ -112,6 +126,26 @@ export async function updateVendor(formData: FormData): Promise<ActionResult> {
return { ok: true };
}
export async function deleteVendor(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) return { error: "Unauthorized" };
const blocked = await db.purchaseOrder.findFirst({
where: { vendorId: id, status: { not: "DRAFT" } },
});
if (blocked) return { error: "Cannot delete: vendor is referenced in submitted or active purchase orders." };
await db.$transaction(async (tx) => {
await tx.purchaseOrder.updateMany({ where: { vendorId: id, status: "DRAFT" }, data: { vendorId: null } });
await tx.product.updateMany({ where: { lastVendorId: id }, data: { lastVendorId: null } });
await tx.productVendorPrice.deleteMany({ where: { vendorId: id } });
await tx.vendor.delete({ where: { id } });
});
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")) {

View file

@ -4,6 +4,8 @@ import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import Link from "next/link";
import { AddVendorButton, EditVendorButton } from "./vendor-form";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { deleteVendor } from "./actions";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vendor Registry" };
@ -76,17 +78,21 @@ export default async function AdminVendorsPage() {
</span>
</td>
<td className="px-4 py-3">
<EditVendorButton vendor={{
id: vendor.id,
name: vendor.name,
vendorId: vendor.vendorId,
address: (vendor as typeof vendor & { address?: string | null }).address ?? null,
gstin: (vendor as typeof vendor & { gstin?: string | null }).gstin ?? null,
contactName: vendor.contactName,
contactMobile: (vendor as typeof vendor & { contactMobile?: string | null }).contactMobile ?? null,
contactEmail: vendor.contactEmail,
isActive: vendor.isActive,
}} />
<span className="flex items-center gap-3">
<EditVendorButton vendor={{
id: vendor.id,
name: vendor.name,
vendorId: vendor.vendorId,
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,
}} />
<ConfirmDeleteButton onDelete={deleteVendor.bind(null, vendor.id)} label={vendor.name} />
</span>
</td>
</tr>
))}

View file

@ -7,8 +7,8 @@ import { createVendor, updateVendor, toggleVendorActive } from "./actions";
type VendorRow = {
id: string; name: string; vendorId: string | null; address: string | null;
gstin: string | null; contactName: string | null; contactMobile: string | null;
contactEmail: string | null; isActive: boolean;
pincode: string | null; gstin: string | null; contactName: string | null;
contactMobile: string | null; contactEmail: string | null; isActive: boolean;
};
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";
@ -23,8 +23,7 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
const [name, setName] = useState(vendor?.name ?? "");
const [address, setAddress] = useState(vendor?.address ?? "");
const [lat, setLat] = useState("");
const [lng, setLng] = useState("");
const [pincode, setPincode] = useState(vendor?.pincode ?? "");
// CAPTCHA flow state
const [captchaStep, setCaptchaStep] = useState<"idle" | "loading" | "ready" | "verifying">("idle");
@ -41,7 +40,7 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
const res = await fetch("/api/gst/captcha");
const data = await res.json();
if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; }
setCaptchaB64(data.captchaB64);
setCaptchaB64(data.captchaBase64);
setSessionId(data.sessionId);
setCaptchaStep("ready");
} catch { setGstError("Failed to load CAPTCHA"); setCaptchaStep("idle"); }
@ -70,8 +69,7 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
// Fill form fields
setName(data.tradeName || data.legalName);
setAddress(data.address);
if (data.lat) setLat(String(data.lat));
if (data.lng) setLng(String(data.lng));
if (data.pincode) setPincode(data.pincode);
setGstSuccess(`${data.legalName}${data.status} since ${data.registrationDate}`);
setCaptchaStep("idle");
} catch { setGstError("Lookup failed"); setCaptchaStep("idle"); }
@ -168,15 +166,11 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
<textarea name="address" value={address} onChange={(e) => setAddress(e.target.value)} rows={2} className={INPUT} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Latitude <span className="text-neutral-400 font-normal">(auto from GSTIN)</span></label>
<input name="latitude" type="number" step="any" value={lat} onChange={(e) => setLat(e.target.value)} className={INPUT} placeholder="18.6117" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Longitude</label>
<input name="longitude" type="number" step="any" value={lng} onChange={(e) => setLng(e.target.value)} className={INPUT} placeholder="73.0059" />
</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>
</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} />
</div>
<div className="grid grid-cols-3 gap-3">

View file

@ -0,0 +1,60 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type ActionResult = { ok: true } | { error: string };
export function ConfirmDeleteButton({
onDelete,
label,
}: {
onDelete: () => Promise<ActionResult>;
label: string;
}) {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState("");
const [isPending, startTransition] = useTransition();
if (confirming) {
return (
<span className="inline-flex items-center gap-2 flex-wrap">
<span className="text-xs text-neutral-600 whitespace-nowrap">Delete {label}?</span>
<button
onClick={() =>
startTransition(async () => {
const result = await onDelete();
if ("error" in result) {
setError(result.error);
setConfirming(false);
} else {
router.refresh();
}
})
}
disabled={isPending}
className="text-xs font-medium text-danger-600 hover:text-danger-800 hover:underline disabled:opacity-50 whitespace-nowrap"
>
{isPending ? "Deleting…" : "Confirm"}
</button>
<button
onClick={() => { setConfirming(false); setError(""); }}
className="text-xs text-neutral-500 hover:underline whitespace-nowrap"
>
Cancel
</button>
{error && <span className="text-xs text-danger-600 block w-full">{error}</span>}
</span>
);
}
return (
<button
onClick={() => setConfirming(true)}
className="text-xs text-danger-500 hover:text-danger-700 hover:underline"
>
Delete
</button>
);
}

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Vendor" ADD COLUMN "pincode" TEXT;

View file

@ -108,6 +108,7 @@ model Vendor {
name String
vendorId String? @unique
address String?
pincode String?
gstin String?
contactName String?
contactMobile String?