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:
parent
49ba6e8be5
commit
a2c35d0a93
16 changed files with 125 additions and 57 deletions
|
|
@ -13,15 +13,15 @@ type AccountRow = {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function AccountFormFields({ account }: { account?: AccountRow }) {
|
function AccountFormFields({ account, suggestedCode }: { account?: AccountRow; suggestedCode?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Account Code *</label>
|
<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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Account Name *</label>
|
<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 router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
@ -61,7 +61,7 @@ export function AddAccountButton() {
|
||||||
</button>
|
</button>
|
||||||
<AdminDialog title="Add Account" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Add Account" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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>}
|
{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">
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
<button type="button" onClick={() => setOpen(false)}
|
<button type="button" onClick={() => setOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { revalidatePath } from "next/cache";
|
||||||
type ActionResult = { ok: true } | { error: string };
|
type ActionResult = { ok: true } | { error: string };
|
||||||
|
|
||||||
const accountSchema = z.object({
|
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"),
|
name: z.string().min(1, "Account name is required"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||||
import { AddAccountButton, EditAccountButton } from "./account-form";
|
import { AddAccountButton, EditAccountButton } from "./account-form";
|
||||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||||
import { deleteAccount } from "./actions";
|
import { deleteAccount } from "./actions";
|
||||||
|
import { nextId } from "@/lib/id-generators";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Account Management" };
|
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 accounts = await db.account.findMany({ orderBy: { code: "asc" } });
|
||||||
|
|
||||||
|
const suggestedCode = nextId("ACC", accounts.map((a) => a.code));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Account Management</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">Account Management</h1>
|
||||||
<AddAccountButton />
|
<AddAccountButton suggestedCode={suggestedCode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ import { z } from "zod";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
import { ROLE_PREFIX, nextId } from "@/lib/id-generators";
|
||||||
|
|
||||||
type ActionResult = { ok: true } | { error: string };
|
type ActionResult = { ok: true } | { error: string };
|
||||||
|
|
||||||
const userSchema = z.object({
|
const userSchema = z.object({
|
||||||
employeeId: z.string().min(1, "Employee ID is required"),
|
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
email: z.string().email("Invalid email"),
|
email: z.string().email("Invalid email"),
|
||||||
role: z.enum(["TECHNICAL", "MANNING", "ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"]),
|
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({
|
const parsed = userSchema.safeParse({
|
||||||
employeeId: formData.get("employeeId"),
|
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
email: formData.get("email"),
|
email: formData.get("email"),
|
||||||
role: formData.get("role"),
|
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" };
|
if (!data.password) return { error: "Password is required for new users" };
|
||||||
|
|
||||||
const exists = await db.user.findFirst({
|
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);
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
await db.user.create({
|
await db.user.create({
|
||||||
data: {
|
data: {
|
||||||
employeeId: data.employeeId,
|
employeeId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
role: data.role as Role,
|
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" };
|
if (!id) return { error: "User ID is required" };
|
||||||
|
|
||||||
const parsed = userSchema.safeParse({
|
const parsed = userSchema.safeParse({
|
||||||
employeeId: formData.get("employeeId"),
|
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
email: formData.get("email"),
|
email: formData.get("email"),
|
||||||
role: formData.get("role"),
|
role: formData.get("role"),
|
||||||
|
|
@ -79,14 +85,13 @@ export async function updateUser(formData: FormData): Promise<ActionResult> {
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{ id: { not: id } },
|
{ 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"] = {
|
const updateData: Parameters<typeof db.user.update>[0]["data"] = {
|
||||||
employeeId: data.employeeId,
|
|
||||||
name: data.name,
|
name: data.name,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
role: data.role as Role,
|
role: data.role as Role,
|
||||||
|
|
|
||||||
|
|
@ -27,20 +27,29 @@ const ROLES = [
|
||||||
function UserFormFields({ user }: { user?: UserRow }) {
|
function UserFormFields({ user }: { user?: UserRow }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{user ? (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Employee ID *</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Employee ID</label>
|
||||||
<input name="employeeId" defaultValue={user?.employeeId} required
|
<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>
|
||||||
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" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Role *</label>
|
<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={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">
|
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>)}
|
{ROLES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Role *</label>
|
||||||
|
<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>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Full Name *</label>
|
||||||
<input name="name" defaultValue={user?.name} required
|
<input name="name" defaultValue={user?.name} required
|
||||||
|
|
|
||||||
2
App/app/(portal)/admin/vendors/actions.ts
vendored
2
App/app/(portal)/admin/vendors/actions.ts
vendored
|
|
@ -19,7 +19,7 @@ const contactSchema = z.object({
|
||||||
|
|
||||||
const vendorSchema = z.object({
|
const vendorSchema = z.object({
|
||||||
name: z.string().min(1, "Vendor name is required"),
|
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(),
|
address: z.string().optional(),
|
||||||
pincode: z.string().optional(),
|
pincode: z.string().optional(),
|
||||||
gstin: z.string().optional(),
|
gstin: z.string().optional(),
|
||||||
|
|
|
||||||
5
App/app/(portal)/admin/vendors/page.tsx
vendored
5
App/app/(portal)/admin/vendors/page.tsx
vendored
|
|
@ -6,6 +6,7 @@ import Link from "next/link";
|
||||||
import { AddVendorButton, EditVendorButton } from "./vendor-form";
|
import { AddVendorButton, EditVendorButton } from "./vendor-form";
|
||||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||||
import { deleteVendor } from "./actions";
|
import { deleteVendor } from "./actions";
|
||||||
|
import { nextId } from "@/lib/id-generators";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Vendor Registry" };
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Vendor Registry</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">Vendor Registry</h1>
|
||||||
<AddVendorButton />
|
<AddVendorButton suggestedId={suggestedVendorId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -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 [gstin, setGstin] = useState(vendor?.gstin ?? "");
|
||||||
const [name, setName] = useState(vendor?.name ?? "");
|
const [name, setName] = useState(vendor?.name ?? "");
|
||||||
const [address, setAddress] = useState(vendor?.address ?? "");
|
const [address, setAddress] = useState(vendor?.address ?? "");
|
||||||
|
|
@ -219,7 +219,7 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -243,7 +243,7 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddVendorButton() {
|
export function AddVendorButton({ suggestedId }: { suggestedId?: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
@ -264,7 +264,7 @@ export function AddVendorButton() {
|
||||||
</button>
|
</button>
|
||||||
<AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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>}
|
{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">
|
<div className="flex justify-end gap-3">
|
||||||
<button type="button" onClick={() => setOpen(false)}
|
<button type="button" onClick={() => setOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { nextId } from "@/lib/id-generators";
|
||||||
|
|
||||||
type ActionResult = { ok: true } | { error: string };
|
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" };
|
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");
|
revalidatePath("/admin/vessels");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export default async function AdminVesselsPage() {
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||||
<tr>
|
<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">Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
||||||
<th className="px-4 py-3"></th>
|
<th className="px-4 py-3"></th>
|
||||||
|
|
@ -36,6 +37,7 @@ export default async function AdminVesselsPage() {
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
{vessels.map((vessel) => (
|
{vessels.map((vessel) => (
|
||||||
<tr key={vessel.id} className="hover:bg-neutral-50">
|
<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 font-medium text-neutral-900">{vessel.name}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
<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={{
|
<EditVesselButton vessel={{
|
||||||
id: vessel.id,
|
id: vessel.id,
|
||||||
name: vessel.name,
|
name: vessel.name,
|
||||||
|
code: vessel.code,
|
||||||
isActive: vessel.isActive,
|
isActive: vessel.isActive,
|
||||||
}} />
|
}} />
|
||||||
<ConfirmDeleteButton onDelete={deleteVessel.bind(null, vessel.id)} label={vessel.name} />
|
<ConfirmDeleteButton onDelete={deleteVessel.bind(null, vessel.id)} label={vessel.name} />
|
||||||
|
|
@ -58,7 +61,7 @@ export default async function AdminVesselsPage() {
|
||||||
))}
|
))}
|
||||||
{vessels.length === 0 && (
|
{vessels.length === 0 && (
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,19 @@ import { createVessel, updateVessel, toggleVesselActive } from "./actions";
|
||||||
type VesselRow = {
|
type VesselRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
code: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function VesselFormFields({ vessel }: { vessel?: VesselRow }) {
|
function VesselFormFields({ vessel }: { vessel?: VesselRow }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Cost Centre Name *</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Cost Centre Name *</label>
|
||||||
<input name="name" defaultValue={vessel?.name} required
|
<input name="name" defaultValue={vessel?.name} required
|
||||||
|
|
|
||||||
21
App/lib/id-generators.ts
Normal file
21
App/lib/id-generators.ts
Normal 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")}`;
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -109,6 +109,7 @@ model Site {
|
||||||
model Vessel {
|
model Vessel {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
code String @unique
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
siteId String?
|
siteId String?
|
||||||
|
|
|
||||||
|
|
@ -157,25 +157,25 @@ async function main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Vessels (Cost Centres) ──────────────────────────────────────────────────
|
// ─── 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 } });
|
const vessel = await db.vessel.findFirst({ where: { name } });
|
||||||
if (vessel) {
|
if (vessel) {
|
||||||
return db.vessel.update({ where: { id: vessel.id }, data: { siteId } });
|
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 mvStar = await findOrCreateVessel("MV Pelagia Star", siteBOM.id, "SITE-001");
|
||||||
const mvWind = await findOrCreateVessel("MV Aegean Wind", siteJNP.id);
|
const mvWind = await findOrCreateVessel("MV Aegean Wind", siteJNP.id, "SITE-002");
|
||||||
const mvPoseidon = await findOrCreateVessel("MV Poseidon", siteKDL.id);
|
const mvPoseidon = await findOrCreateVessel("MV Poseidon", siteKDL.id, "SITE-003");
|
||||||
const mvNereid = await findOrCreateVessel("MV Nereid", siteCHE.id);
|
const mvNereid = await findOrCreateVessel("MV Nereid", siteCHE.id, "SITE-004");
|
||||||
const mvThetis = await findOrCreateVessel("MV Thetis", siteKOC.id);
|
const mvThetis = await findOrCreateVessel("MV Thetis", siteKOC.id, "SITE-005");
|
||||||
const mvTriton = await findOrCreateVessel("MV Triton", siteVIZ.id);
|
const mvTriton = await findOrCreateVessel("MV Triton", siteVIZ.id, "SITE-006");
|
||||||
const mvAmphitrite = await findOrCreateVessel("MV Amphitrite", siteHAL.id);
|
const mvAmphitrite = await findOrCreateVessel("MV Amphitrite", siteHAL.id, "SITE-007");
|
||||||
const mvProteus = await findOrCreateVessel("MV Proteus", sitePAR.id);
|
const mvProteus = await findOrCreateVessel("MV Proteus", sitePAR.id, "SITE-008");
|
||||||
const mvGalatea = await findOrCreateVessel("MV Galatea", siteMNG.id);
|
const mvGalatea = await findOrCreateVessel("MV Galatea", siteMNG.id, "SITE-009");
|
||||||
const mvCallisto = await findOrCreateVessel("MV Callisto", siteGOA.id);
|
const mvCallisto = await findOrCreateVessel("MV Callisto", siteGOA.id, "SITE-010");
|
||||||
await findOrCreateVessel("MV Doris", siteCHE.id);
|
await findOrCreateVessel("MV Doris", siteCHE.id, "SITE-011");
|
||||||
|
|
||||||
// ─── Accounts ────────────────────────────────────────────────────────────────
|
// ─── Accounts ────────────────────────────────────────────────────────────────
|
||||||
const accTechOps = await db.account.upsert({
|
const accTechOps = await db.account.upsert({
|
||||||
|
|
|
||||||
|
|
@ -117,22 +117,22 @@
|
||||||
- site edit does not work? location updates but does not save
|
- site edit does not work? location updates but does not save
|
||||||
-- WAIT SAVING WORKS, THE BUTTON JUST SHOWS SAVING FOR SOME REASON
|
-- WAIT SAVING WORKS, THE BUTTON JUST SHOWS SAVING FOR SOME REASON
|
||||||
- pincode to geocode in bg. works on initial save
|
- pincode to geocode in bg. works on initial save
|
||||||
- confirm button while deleting
|
- confirm button while deleting [DONE]
|
||||||
- vessel edit does not save?
|
- vessel edit does not save?
|
||||||
- 0 percent gst does not work [DONE]
|
- 0 percent gst does not work [DONE]
|
||||||
- who assigns vendor?
|
- who assigns vendor?
|
||||||
- email notification take you to po
|
- email notification take you to po [DONE]
|
||||||
- po word appears twice in subject
|
- po word appears twice in subject [DONE]
|
||||||
- notification overflows on phone for manager [DONE]
|
- notification overflows on phone for manager [DONE]
|
||||||
- gst edit does not show strikethrough update
|
- gst edit does not show strikethrough update [DONE]
|
||||||
- submitted for review notification not needed for submitter
|
- submitted for review notification not needed for submitter [DONE]
|
||||||
- show detail of po in email
|
- show detail of po in email [DONE]
|
||||||
- accounts did not get notification
|
- accounts did not get notification
|
||||||
- in manager/submitter notes show name of person
|
- in manager/submitter notes show name of person [DONE]
|
||||||
- Confirming... button appears as soon as I click Process Payment as accounts
|
- Confirming... button appears as soon as I click Process Payment as accounts [DONE]
|
||||||
- phantom product prices updated by accounts in PO history
|
- phantom product prices updated by accounts in PO history [DONE]
|
||||||
- notify manager for receipt and partial receipt
|
- notify manager for receipt and partial receipt [DONE]
|
||||||
- allow submitter to close partially closed po [DONE]
|
- allow submitter to close partially closed po [DONE]
|
||||||
- partial payment / advance payment for accounts
|
- partial payment / advance payment for accounts [DONE]
|
||||||
- please confirm receipt - only for submitter not for manager
|
- please confirm receipt - only for submitter not for manager [DONE]
|
||||||
- rename My Purchase Orders to Closed Purchase Orders
|
- rename My Purchase Orders to Closed Purchase Orders [DONE]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue