feat(admin): auto-generate structured IDs for users, vendors, accounts and cost centres

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>
This commit is contained in:
Hardik 2026-05-27 15:02:50 +05:30
parent 49ba6e8be5
commit a2c35d0a93
16 changed files with 125 additions and 57 deletions

View file

@ -13,15 +13,15 @@ type AccountRow = {
isActive: boolean;
};
function AccountFormFields({ account }: { account?: AccountRow }) {
function AccountFormFields({ account, suggestedCode }: { account?: AccountRow; suggestedCode?: string }) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Account Code *</label>
<input name="code" defaultValue={account?.code} required
<input name="code" defaultValue={account?.code ?? suggestedCode} required
className="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"
placeholder="e.g. OPS-001" />
placeholder="e.g. ACC-001" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Account Name *</label>
@ -38,7 +38,7 @@ function AccountFormFields({ account }: { account?: AccountRow }) {
);
}
export function AddAccountButton() {
export function AddAccountButton({ suggestedCode }: { suggestedCode?: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
@ -61,7 +61,7 @@ export function AddAccountButton() {
</button>
<AdminDialog title="Add Account" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<AccountFormFields />
<AccountFormFields suggestedCode={suggestedCode} />
{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 pt-1">
<button type="button" onClick={() => setOpen(false)}

View file

@ -9,7 +9,7 @@ import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const accountSchema = z.object({
code: z.string().min(1, "Account code is required"),
code: z.string().min(1, "Account code is required").regex(/^[A-Z0-9]+-\d+$/i, "Code must be in format PREFIX-NUMBER (e.g. ACC-001)"),
name: z.string().min(1, "Account name is required"),
description: z.string().optional(),
});

View file

@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { AddAccountButton, EditAccountButton } from "./account-form";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { deleteAccount } from "./actions";
import { nextId } from "@/lib/id-generators";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Account Management" };
@ -17,11 +18,13 @@ export default async function AdminAccountsPage() {
const accounts = await db.account.findMany({ orderBy: { code: "asc" } });
const suggestedCode = nextId("ACC", accounts.map((a) => a.code));
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Account Management</h1>
<AddAccountButton />
<AddAccountButton suggestedCode={suggestedCode} />
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">

View file

@ -7,11 +7,11 @@ import { z } from "zod";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";
import type { Role } from "@prisma/client";
import { ROLE_PREFIX, nextId } from "@/lib/id-generators";
type ActionResult = { ok: true } | { error: string };
const userSchema = z.object({
employeeId: z.string().min(1, "Employee ID is required"),
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
role: z.enum(["TECHNICAL", "MANNING", "ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"]),
@ -25,7 +25,6 @@ export async function createUser(formData: FormData): Promise<ActionResult> {
}
const parsed = userSchema.safeParse({
employeeId: formData.get("employeeId"),
name: formData.get("name"),
email: formData.get("email"),
role: formData.get("role"),
@ -37,14 +36,22 @@ export async function createUser(formData: FormData): Promise<ActionResult> {
if (!data.password) return { error: "Password is required for new users" };
const exists = await db.user.findFirst({
where: { OR: [{ email: data.email }, { employeeId: data.employeeId }] },
where: { email: data.email },
});
if (exists) return { error: "A user with that email or employee ID already exists" };
if (exists) return { error: "A user with that email already exists" };
// Auto-generate employeeId based on role prefix
const prefix = ROLE_PREFIX[data.role];
const existingIds = await db.user.findMany({
where: { role: data.role as Role },
select: { employeeId: true },
});
const employeeId = nextId(prefix, existingIds.map((u) => u.employeeId));
const passwordHash = await bcrypt.hash(data.password, 12);
await db.user.create({
data: {
employeeId: data.employeeId,
employeeId,
name: data.name,
email: data.email,
role: data.role as Role,
@ -66,7 +73,6 @@ export async function updateUser(formData: FormData): Promise<ActionResult> {
if (!id) return { error: "User ID is required" };
const parsed = userSchema.safeParse({
employeeId: formData.get("employeeId"),
name: formData.get("name"),
email: formData.get("email"),
role: formData.get("role"),
@ -79,14 +85,13 @@ export async function updateUser(formData: FormData): Promise<ActionResult> {
where: {
AND: [
{ id: { not: id } },
{ OR: [{ email: data.email }, { employeeId: data.employeeId }] },
{ email: data.email },
],
},
});
if (conflict) return { error: "Another user already has that email or employee ID" };
if (conflict) return { error: "Another user already has that email" };
const updateData: Parameters<typeof db.user.update>[0]["data"] = {
employeeId: data.employeeId,
name: data.name,
email: data.email,
role: data.role as Role,

View file

@ -27,20 +27,29 @@ const ROLES = [
function UserFormFields({ user }: { user?: UserRow }) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Employee ID *</label>
<input name="employeeId" defaultValue={user?.employeeId} required
className="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" />
{user ? (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Employee ID</label>
<p className="font-mono text-sm text-neutral-700 px-3 py-2 bg-neutral-50 rounded-lg border border-neutral-200">{user.employeeId}</p>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Role *</label>
<select name="role" defaultValue={user.role} required
className="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">
{ROLES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
</div>
) : (
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Role *</label>
<select name="role" defaultValue={user?.role ?? "TECHNICAL"} required
<select name="role" defaultValue="TECHNICAL" required
className="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">
{ROLES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
</div>
)}
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Full Name *</label>
<input name="name" defaultValue={user?.name} required

View file

@ -19,7 +19,7 @@ const contactSchema = z.object({
const vendorSchema = z.object({
name: z.string().min(1, "Vendor name is required"),
vendorId: z.string().optional(),
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(),

View file

@ -6,6 +6,7 @@ import Link from "next/link";
import { AddVendorButton, EditVendorButton } from "./vendor-form";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { deleteVendor } from "./actions";
import { nextId } from "@/lib/id-generators";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vendor Registry" };
@ -23,11 +24,13 @@ export default async function AdminVendorsPage() {
},
});
const suggestedVendorId = nextId("VND", vendors.map((v) => v.vendorId));
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Vendor Registry</h1>
<AddVendorButton />
<AddVendorButton suggestedId={suggestedVendorId} />
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">

View file

@ -113,7 +113,7 @@ function ContactsEditor({ initial }: { initial?: ContactRow[] }) {
);
}
function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
function VendorFormFields({ vendor, suggestedVendorId }: { vendor?: VendorRow; suggestedVendorId?: string }) {
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
const [name, setName] = useState(vendor?.name ?? "");
const [address, setAddress] = useState(vendor?.address ?? "");
@ -219,7 +219,7 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor ID <span className="text-neutral-400 font-normal">(leave blank if unverified)</span></label>
<input name="vendorId" defaultValue={vendor?.vendorId ?? ""} className={INPUT} placeholder="VND-0042" />
<input name="vendorId" defaultValue={vendor?.vendorId ?? suggestedVendorId ?? ""} className={INPUT} placeholder="VND-001" />
</div>
</div>
@ -243,7 +243,7 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
);
}
export function AddVendorButton() {
export function AddVendorButton({ suggestedId }: { suggestedId?: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
@ -264,7 +264,7 @@ export function AddVendorButton() {
</button>
<AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<VendorFormFields />
<VendorFormFields suggestedVendorId={suggestedId} />
{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)}

View file

@ -5,6 +5,7 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { nextId } from "@/lib/id-generators";
type ActionResult = { ok: true } | { error: string };
@ -23,7 +24,10 @@ export async function createVessel(formData: FormData): Promise<ActionResult> {
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
await db.vessel.create({ data: { name: parsed.data.name } });
const existingCodes = await db.vessel.findMany({ select: { code: true } });
const code = nextId("SITE", existingCodes.map((v) => v.code));
await db.vessel.create({ data: { name: parsed.data.name, code } });
revalidatePath("/admin/vessels");
return { ok: true };
}

View file

@ -28,6 +28,7 @@ export default async function AdminVesselsPage() {
<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">Code</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3"></th>
@ -36,6 +37,7 @@ export default async function AdminVesselsPage() {
<tbody className="divide-y divide-neutral-100">
{vessels.map((vessel) => (
<tr key={vessel.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{vessel.code}</td>
<td className="px-4 py-3 font-medium text-neutral-900">{vessel.name}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
@ -49,6 +51,7 @@ export default async function AdminVesselsPage() {
<EditVesselButton vessel={{
id: vessel.id,
name: vessel.name,
code: vessel.code,
isActive: vessel.isActive,
}} />
<ConfirmDeleteButton onDelete={deleteVessel.bind(null, vessel.id)} label={vessel.name} />
@ -58,7 +61,7 @@ export default async function AdminVesselsPage() {
))}
{vessels.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-neutral-400">No cost centres yet.</td>
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">No cost centres yet.</td>
</tr>
)}
</tbody>

View file

@ -8,12 +8,19 @@ import { createVessel, updateVessel, toggleVesselActive } from "./actions";
type VesselRow = {
id: string;
name: string;
code: string;
isActive: boolean;
};
function VesselFormFields({ vessel }: { vessel?: VesselRow }) {
return (
<div className="space-y-3">
{vessel && (
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Code</label>
<p className="font-mono text-sm text-neutral-700 px-3 py-2 bg-neutral-50 rounded-lg border border-neutral-200">{vessel.code}</p>
</div>
)}
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Cost Centre Name *</label>
<input name="name" defaultValue={vessel?.name} required

21
App/lib/id-generators.ts Normal file
View file

@ -0,0 +1,21 @@
export const ROLE_PREFIX: Record<string, string> = {
TECHNICAL: "TCH",
MANNING: "MAN",
ACCOUNTS: "ACC",
MANAGER: "MGR",
SUPERUSER: "SUP",
AUDITOR: "AUD",
ADMIN: "ADM",
};
/** Find max existing number for prefix and return prefix-(max+1), zero-padded to 3 digits */
export function nextId(prefix: string, existingIds: (string | null | undefined)[]): string {
const re = new RegExp(`^${prefix}-(\\d+)$`, "i");
let max = 0;
for (const id of existingIds) {
if (!id) continue;
const m = id.match(re);
if (m) max = Math.max(max, parseInt(m[1], 10));
}
return `${prefix}-${String(max + 1).padStart(3, "0")}`;
}

View file

@ -0,0 +1,12 @@
ALTER TABLE "Vessel" ADD COLUMN "code" TEXT;
WITH numbered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS n
FROM "Vessel"
)
UPDATE "Vessel" SET "code" = 'SITE-' || LPAD(n::text, 3, '0')
FROM numbered
WHERE "Vessel".id = numbered.id;
ALTER TABLE "Vessel" ALTER COLUMN "code" SET NOT NULL;
ALTER TABLE "Vessel" ADD CONSTRAINT "Vessel_code_key" UNIQUE ("code");

View file

@ -109,6 +109,7 @@ model Site {
model Vessel {
id String @id @default(cuid())
name String
code String @unique
isActive Boolean @default(true)
siteId String?

View file

@ -157,25 +157,25 @@ async function main() {
});
// ─── Vessels (Cost Centres) ──────────────────────────────────────────────────
const findOrCreateVessel = async (name: string, siteId: string) => {
const findOrCreateVessel = async (name: string, siteId: string, code: string) => {
const vessel = await db.vessel.findFirst({ where: { name } });
if (vessel) {
return db.vessel.update({ where: { id: vessel.id }, data: { siteId } });
}
return db.vessel.create({ data: { name, siteId } });
return db.vessel.create({ data: { name, code, siteId } });
};
const mvStar = await findOrCreateVessel("MV Pelagia Star", siteBOM.id);
const mvWind = await findOrCreateVessel("MV Aegean Wind", siteJNP.id);
const mvPoseidon = await findOrCreateVessel("MV Poseidon", siteKDL.id);
const mvNereid = await findOrCreateVessel("MV Nereid", siteCHE.id);
const mvThetis = await findOrCreateVessel("MV Thetis", siteKOC.id);
const mvTriton = await findOrCreateVessel("MV Triton", siteVIZ.id);
const mvAmphitrite = await findOrCreateVessel("MV Amphitrite", siteHAL.id);
const mvProteus = await findOrCreateVessel("MV Proteus", sitePAR.id);
const mvGalatea = await findOrCreateVessel("MV Galatea", siteMNG.id);
const mvCallisto = await findOrCreateVessel("MV Callisto", siteGOA.id);
await findOrCreateVessel("MV Doris", siteCHE.id);
const mvStar = await findOrCreateVessel("MV Pelagia Star", siteBOM.id, "SITE-001");
const mvWind = await findOrCreateVessel("MV Aegean Wind", siteJNP.id, "SITE-002");
const mvPoseidon = await findOrCreateVessel("MV Poseidon", siteKDL.id, "SITE-003");
const mvNereid = await findOrCreateVessel("MV Nereid", siteCHE.id, "SITE-004");
const mvThetis = await findOrCreateVessel("MV Thetis", siteKOC.id, "SITE-005");
const mvTriton = await findOrCreateVessel("MV Triton", siteVIZ.id, "SITE-006");
const mvAmphitrite = await findOrCreateVessel("MV Amphitrite", siteHAL.id, "SITE-007");
const mvProteus = await findOrCreateVessel("MV Proteus", sitePAR.id, "SITE-008");
const mvGalatea = await findOrCreateVessel("MV Galatea", siteMNG.id, "SITE-009");
const mvCallisto = await findOrCreateVessel("MV Callisto", siteGOA.id, "SITE-010");
await findOrCreateVessel("MV Doris", siteCHE.id, "SITE-011");
// ─── Accounts ────────────────────────────────────────────────────────────────
const accTechOps = await db.account.upsert({

View file

@ -117,22 +117,22 @@
- site edit does not work? location updates but does not save
-- WAIT SAVING WORKS, THE BUTTON JUST SHOWS SAVING FOR SOME REASON
- pincode to geocode in bg. works on initial save
- confirm button while deleting
- confirm button while deleting [DONE]
- vessel edit does not save?
- 0 percent gst does not work [DONE]
- who assigns vendor?
- email notification take you to po
- po word appears twice in subject
- email notification take you to po [DONE]
- po word appears twice in subject [DONE]
- notification overflows on phone for manager [DONE]
- gst edit does not show strikethrough update
- submitted for review notification not needed for submitter
- show detail of po in email
- gst edit does not show strikethrough update [DONE]
- submitted for review notification not needed for submitter [DONE]
- show detail of po in email [DONE]
- accounts did not get notification
- in manager/submitter notes show name of person
- Confirming... button appears as soon as I click Process Payment as accounts
- phantom product prices updated by accounts in PO history
- notify manager for receipt and partial receipt
- in manager/submitter notes show name of person [DONE]
- Confirming... button appears as soon as I click Process Payment as accounts [DONE]
- phantom product prices updated by accounts in PO history [DONE]
- notify manager for receipt and partial receipt [DONE]
- allow submitter to close partially closed po [DONE]
- partial payment / advance payment for accounts
- please confirm receipt - only for submitter not for manager
- rename My Purchase Orders to Closed Purchase Orders
- partial payment / advance payment for accounts [DONE]
- please confirm receipt - only for submitter not for manager [DONE]
- rename My Purchase Orders to Closed Purchase Orders [DONE]