Users: employeeId auto-generated from role prefix (TCH/MAN/ACC/MGR/SUP/AUD/ADM) followed by next sequential number; shown read-only in edit form, removed from create form. Cost Centres: new code field (SITE-001 ...) added to Vessel model with migration + backfill; auto-generated on create, read-only in edit. Vendors and Accounts: code/vendorId inputs pre-filled with the next suggested ID (VND-001, ACC-001) from the server page; user can override with any PREFIX-NUMBER format, validated by regex. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
196 lines
6.7 KiB
TypeScript
196 lines
6.7 KiB
TypeScript
"use server";
|
|
|
|
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";
|
|
|
|
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().regex(/^[A-Z0-9]+-\d+$/i, "Vendor ID must be in format PREFIX-NUMBER (e.g. VND-001)").optional(),
|
|
address: z.string().optional(),
|
|
pincode: z.string().optional(),
|
|
gstin: z.string().optional(),
|
|
});
|
|
|
|
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);
|
|
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")) {
|
|
return { error: "Unauthorized" };
|
|
}
|
|
|
|
const parsed = vendorSchema.safeParse({
|
|
name: formData.get("name"),
|
|
vendorId: formData.get("vendorId") || undefined,
|
|
address: formData.get("address") || undefined,
|
|
pincode: formData.get("pincode") || undefined,
|
|
gstin: formData.get("gstin") || undefined,
|
|
});
|
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
|
|
|
const data = parsed.data;
|
|
if (data.vendorId) {
|
|
const exists = await db.vendor.findUnique({ where: { vendorId: data.vendorId } });
|
|
if (exists) return { error: "A vendor with that Vendor ID already exists" };
|
|
}
|
|
|
|
const { latitude, longitude } = await resolveLatLng(data.pincode);
|
|
const contacts = parseContacts(formData);
|
|
|
|
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,
|
|
longitude,
|
|
isVerified: !!data.vendorId,
|
|
contacts: contacts.length > 0 ? { create: contacts } : undefined,
|
|
},
|
|
});
|
|
|
|
revalidatePath("/admin/vendors");
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function updateVendor(formData: FormData): Promise<ActionResult> {
|
|
const session = await auth();
|
|
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) {
|
|
return { error: "Unauthorized" };
|
|
}
|
|
|
|
const id = formData.get("id") as string;
|
|
if (!id) return { error: "Vendor ID is required" };
|
|
|
|
const parsed = vendorSchema.safeParse({
|
|
name: formData.get("name"),
|
|
vendorId: formData.get("vendorId") || undefined,
|
|
address: formData.get("address") || undefined,
|
|
pincode: formData.get("pincode") || undefined,
|
|
gstin: formData.get("gstin") || undefined,
|
|
});
|
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
|
|
|
const data = parsed.data;
|
|
if (data.vendorId) {
|
|
const conflict = await db.vendor.findFirst({ where: { vendorId: data.vendorId, id: { not: id } } });
|
|
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 };
|
|
|
|
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 };
|
|
}
|
|
|
|
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." };
|
|
|
|
try {
|
|
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 } });
|
|
},
|
|
{ timeout: 30000 },
|
|
);
|
|
} catch (err: unknown) {
|
|
const code = (err as { code?: string })?.code;
|
|
if (code === "P2028") {
|
|
return { error: "Delete timed out — please try again." };
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
revalidatePath("/admin/vendors");
|
|
return { ok: true };
|
|
}
|