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:
parent
2e6678f829
commit
392bad7549
7 changed files with 138 additions and 40 deletions
|
|
@ -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,
|
||||
}} />
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
60
App/pelagia-portal/components/ui/confirm-delete-button.tsx
Normal file
60
App/pelagia-portal/components/ui/confirm-delete-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Vendor" ADD COLUMN "pincode" TEXT;
|
||||
|
|
@ -108,6 +108,7 @@ model Vendor {
|
|||
name String
|
||||
vendorId String? @unique
|
||||
address String?
|
||||
pincode String?
|
||||
gstin String?
|
||||
contactName String?
|
||||
contactMobile String?
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue