feat(vessels): editable custom code when creating a vessel

- Add Vessel dialog now shows an editable Code field pre-filled with the
  next auto-generated code (e.g. SITE-004) — user can change it freely
- Edit Vessel dialog keeps the code read-only (changing codes on existing
  data would break PO references)
- createVessel action: uses submitted code if provided, auto-generates if
  left blank, and validates uniqueness before saving

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-30 04:14:03 +05:30
parent 0d17672ea9
commit a1b77d8b00
4 changed files with 48 additions and 12 deletions

View file

@ -11,6 +11,7 @@ type ActionResult = { ok: true } | { error: string };
const vesselSchema = z.object({
name: z.string().min(1, "Vessel name is required"),
code: z.string().optional(),
siteId: z.string().optional(),
});
@ -22,12 +23,23 @@ export async function createVessel(formData: FormData): Promise<ActionResult> {
const parsed = vesselSchema.safeParse({
name: formData.get("name"),
code: (formData.get("code") as string).trim() || undefined,
siteId: (formData.get("siteId") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const existingCodes = await db.vessel.findMany({ select: { code: true } });
const code = nextId("SITE", existingCodes.map((v) => v.code));
let code: string;
if (parsed.data.code) {
// User supplied a custom code — validate uniqueness
const conflict = await db.vessel.findUnique({ where: { code: parsed.data.code } });
if (conflict) return { error: `Code "${parsed.data.code}" is already in use by another vessel.` };
code = parsed.data.code;
} else {
// Auto-generate next available code
code = nextId("SITE", existingCodes.map((v) => v.code));
}
await db.vessel.create({ data: { name: parsed.data.name, code, siteId: parsed.data.siteId ?? null } });
revalidatePath("/admin/vessels");

View file

@ -3,6 +3,7 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { VesselsTable } from "./vessels-table";
import { nextId } from "@/lib/id-generators";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vessel Management" };
@ -21,8 +22,11 @@ export default async function AdminVesselsPage() {
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
const suggestedCode = nextId("SITE", vessels.map((v) => v.code));
return (
<VesselsTable
suggestedCode={suggestedCode}
vessels={vessels.map((v) => ({
id: v.id,
code: v.code,

View file

@ -17,15 +17,35 @@ type VesselRow = {
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";
function VesselFormFields({ vessel, sites }: { vessel?: VesselRow; sites: SiteOption[] }) {
function VesselFormFields({
vessel,
sites,
suggestedCode,
}: {
vessel?: VesselRow;
sites: SiteOption[];
suggestedCode?: string;
}) {
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>
<label className="block text-xs font-medium text-neutral-700 mb-1">Code *</label>
{vessel ? (
/* Editing: show code read-only — code changes on existing data would break references */
<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>
) : (
/* Creating: editable, pre-filled with next available code */
<input
name="code"
required
defaultValue={suggestedCode}
placeholder="e.g. SITE-001"
className={`${INPUT} font-mono`}
/>
)}
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel Name *</label>
<input name="name" defaultValue={vessel?.name} required className={INPUT} />
@ -45,7 +65,7 @@ function VesselFormFields({ vessel, sites }: { vessel?: VesselRow; sites: SiteOp
);
}
export function AddVesselButton({ sites }: { sites: SiteOption[] }) {
export function AddVesselButton({ sites, suggestedCode }: { sites: SiteOption[]; suggestedCode?: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
@ -68,7 +88,7 @@ export function AddVesselButton({ sites }: { sites: SiteOption[] }) {
</button>
<AdminDialog title="Add Vessel" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<VesselFormFields sites={sites} />
<VesselFormFields sites={sites} 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

@ -62,7 +62,7 @@ function VesselActionsMenu({ vessel, sites }: { vessel: VesselRow; sites: SiteOp
);
}
export function VesselsTable({ vessels, sites }: { vessels: VesselRow[]; sites: SiteOption[] }) {
export function VesselsTable({ vessels, sites, suggestedCode }: { vessels: VesselRow[]; sites: SiteOption[]; suggestedCode?: string }) {
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
useTableControls<VesselRow>({
rows: vessels,
@ -87,7 +87,7 @@ export function VesselsTable({ vessels, sites }: { vessels: VesselRow[]; sites:
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Vessel Management</h1>
<AddVesselButton sites={sites} />
<AddVesselButton sites={sites} suggestedCode={suggestedCode} />
</div>
<TableControls