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:
parent
0d17672ea9
commit
a1b77d8b00
4 changed files with 48 additions and 12 deletions
|
|
@ -11,6 +11,7 @@ type ActionResult = { ok: true } | { error: string };
|
||||||
|
|
||||||
const vesselSchema = z.object({
|
const vesselSchema = z.object({
|
||||||
name: z.string().min(1, "Vessel name is required"),
|
name: z.string().min(1, "Vessel name is required"),
|
||||||
|
code: z.string().optional(),
|
||||||
siteId: z.string().optional(),
|
siteId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -22,12 +23,23 @@ export async function createVessel(formData: FormData): Promise<ActionResult> {
|
||||||
|
|
||||||
const parsed = vesselSchema.safeParse({
|
const parsed = vesselSchema.safeParse({
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
|
code: (formData.get("code") as string).trim() || undefined,
|
||||||
siteId: (formData.get("siteId") as string) || undefined,
|
siteId: (formData.get("siteId") as string) || undefined,
|
||||||
});
|
});
|
||||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
|
||||||
const existingCodes = await db.vessel.findMany({ select: { code: true } });
|
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 } });
|
await db.vessel.create({ data: { name: parsed.data.name, code, siteId: parsed.data.siteId ?? null } });
|
||||||
revalidatePath("/admin/vessels");
|
revalidatePath("/admin/vessels");
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { VesselsTable } from "./vessels-table";
|
import { VesselsTable } from "./vessels-table";
|
||||||
|
import { nextId } from "@/lib/id-generators";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Vessel Management" };
|
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 } }),
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const suggestedCode = nextId("SITE", vessels.map((v) => v.code));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VesselsTable
|
<VesselsTable
|
||||||
|
suggestedCode={suggestedCode}
|
||||||
vessels={vessels.map((v) => ({
|
vessels={vessels.map((v) => ({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
code: v.code,
|
code: v.code,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{vessel && (
|
<div>
|
||||||
<div>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Code *</label>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Code</label>
|
{vessel ? (
|
||||||
<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>
|
/* Editing: show code read-only — code changes on existing data would break references */
|
||||||
</div>
|
<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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel Name *</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel Name *</label>
|
||||||
<input name="name" defaultValue={vessel?.name} required className={INPUT} />
|
<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 router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
@ -68,7 +88,7 @@ export function AddVesselButton({ sites }: { sites: SiteOption[] }) {
|
||||||
</button>
|
</button>
|
||||||
<AdminDialog title="Add Vessel" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Add Vessel" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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>}
|
{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)}
|
||||||
|
|
|
||||||
|
|
@ -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 } =
|
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||||
useTableControls<VesselRow>({
|
useTableControls<VesselRow>({
|
||||||
rows: vessels,
|
rows: vessels,
|
||||||
|
|
@ -87,7 +87,7 @@ export function VesselsTable({ vessels, sites }: { vessels: VesselRow[]; sites:
|
||||||
<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">Vessel Management</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">Vessel Management</h1>
|
||||||
<AddVesselButton sites={sites} />
|
<AddVesselButton sites={sites} suggestedCode={suggestedCode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TableControls
|
<TableControls
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue