feat(berths): pre-flight duplicate check on bulk-add wizard

Bulk-adding berths previously failed at submit-time when any mooring
number in the range was already taken — admins had to mentally diff
the existing berth list against their seeded range and edit Step 2
rows out one-at-a-time. Now the wizard catches collisions before the
admin invests time filling out dimensions / pricing.

- `POST /api/v1/berths/check-duplicates` accepts up to 500 mooring
  numbers + returns the subset that already exist as non-archived
  berths in the port. Format validated against the canonical
  `^[A-Z]+\d+$` regex; permission `berths.import` (same as bulk-add).
- Wizard fires the check during the Step 1 → Step 2 transition. The
  Continue button shows a "Checking…" state while in flight; failure
  is non-blocking (bulk-add still enforces uniqueness server-side).
- Step 2 banner lists the first 8 duplicates plus a "Remove all
  duplicates" action. Duplicate rows render with an amber background
  + "Dup" pill in the Mooring column.
- Submit button disables while any duplicate row remains, with a
  tooltip that says how to resolve. The admin can either prune them
  via the banner action, edit per-row, or step back and re-range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:48:16 +02:00
parent d912f02b97
commit ca172fa2b8
2 changed files with 237 additions and 78 deletions

View File

@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
const checkSchema = z.object({
mooringNumbers: z
.array(z.string().regex(/^[A-Z]+\d+$/, 'Invalid mooring format'))
.min(1)
.max(500),
});
/**
* POST /api/v1/berths/check-duplicates
*
* Pre-flight duplicate check for the bulk-add wizard. Given an array of
* candidate mooring numbers, returns which of them already exist as
* non-archived berths in the port. Lets the wizard flag and prune
* collisions before the user fills out Step 2 dimensions, instead of
* surfacing the constraint violation at submit time.
*
* Format validation mirrors the CLAUDE.md canonical (`^[A-Z]+\d+$`).
* Archived berths are excluded — bulk-add re-using a previously-archived
* mooring number is a legitimate flow.
*
* Permission gating: `berths.import` (same scope as bulk-add itself).
*/
export const POST = withAuth(
withPermission('berths', 'import', async (req, ctx) => {
try {
const { mooringNumbers } = await parseBody(req, checkSchema);
// Dedup the input first so a wizard passing the same number twice
// doesn't cause an over-counted match. The wizard generates a
// contiguous range so dups in the input are unusual, but cheap to guard.
const unique = Array.from(new Set(mooringNumbers));
const existing = await db
.select({ mooringNumber: berths.mooringNumber })
.from(berths)
.where(
and(
eq(berths.portId, ctx.portId),
inArray(berths.mooringNumber, unique),
isNull(berths.archivedAt),
),
);
const duplicates = existing.map((r) => r.mooringNumber);
return NextResponse.json({
data: {
duplicates,
checked: unique.length,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -92,8 +92,12 @@ export function BulkAddBerthsWizard() {
// Step 2 state
const [rows, setRows] = useState<RowDraft[]>([]);
/** Mooring numbers already present in the port. Populated by the
* pre-flight check fired during Step 1 → Step 2 transition. */
const [duplicates, setDuplicates] = useState<Set<string>>(new Set());
const [checkingDups, setCheckingDups] = useState(false);
function handleGenerate() {
async function handleGenerate() {
const s = parseInt(rangeStart, 10);
const e = parseInt(rangeEnd, 10);
if (!Number.isFinite(s) || !Number.isFinite(e) || s < 0 || e < s) {
@@ -105,10 +109,44 @@ export function BulkAddBerthsWizard() {
return;
}
const seeded = genRange(letter, s, e).map((r) => ({ ...r, tenureType: tenure }));
// Pre-flight duplicate check: ask the server which of the generated
// mooring numbers already exist as non-archived berths in this port.
// Fail-open if the request errors (the bulk-add endpoint still
// enforces uniqueness server-side; the pre-flight is a UX nicety).
setCheckingDups(true);
try {
const res = await apiFetch<{ data: { duplicates: string[] } }>(
'/api/v1/berths/check-duplicates',
{
method: 'POST',
body: { mooringNumbers: seeded.map((r) => r.mooringNumber) },
},
);
setDuplicates(new Set(res.data.duplicates));
if (res.data.duplicates.length > 0) {
toast.warning(
`${res.data.duplicates.length} mooring number${
res.data.duplicates.length === 1 ? '' : 's'
} already exist in this port. They're flagged in Step 2.`,
);
}
} catch {
// Pre-flight failure is non-blocking: the user still proceeds and
// the server's uniqueness constraint catches collisions at submit.
setDuplicates(new Set());
} finally {
setCheckingDups(false);
}
setRows(seeded);
setStep('edit');
}
/** Drop any rows whose mooring number is a known duplicate. */
function removeAllDuplicates() {
setRows((prev) => prev.filter((r) => !duplicates.has(r.mooringNumber)));
}
function setRowField<K extends keyof RowDraft>(idx: number, key: K, value: RowDraft[K]) {
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [key]: value } : r)));
}
@@ -207,12 +245,23 @@ export function BulkAddBerthsWizard() {
{rangeStart} {letter}
{rangeEnd}).
</p>
<Button onClick={handleGenerate}>Continue </Button>
<Button onClick={handleGenerate} disabled={checkingDups}>
{checkingDups ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" aria-hidden />
Checking
</>
) : (
'Continue →'
)}
</Button>
</CardContent>
</Card>
);
}
const remainingDuplicates = rows.filter((r) => duplicates.has(r.mooringNumber));
return (
<Card>
<CardHeader>
@@ -223,6 +272,32 @@ export function BulkAddBerthsWizard() {
</CardDescription>
</CardHeader>
<CardContent>
{remainingDuplicates.length > 0 ? (
<div
role="alert"
className="mb-3 flex items-start justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<div>
<div className="font-medium">
{remainingDuplicates.length} duplicate{' '}
{remainingDuplicates.length === 1 ? 'mooring number' : 'mooring numbers'} found
</div>
<div className="mt-0.5 text-xs">
{remainingDuplicates
.slice(0, 8)
.map((r) => r.mooringNumber)
.join(', ')}
{remainingDuplicates.length > 8
? ` and ${remainingDuplicates.length - 8} more`
: ''}
. Submit will fail on these rows. Remove them or change the range.
</div>
</div>
<Button size="sm" variant="outline" onClick={removeAllDuplicates}>
Remove all duplicates
</Button>
</div>
) : null}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
@@ -308,81 +383,96 @@ export function BulkAddBerthsWizard() {
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={row.mooringNumber} className="border-b last:border-b-0">
<td className="py-1 pr-2 font-medium">{row.mooringNumber}</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.lengthFt}
onChange={(e) => setRowField(idx, 'lengthFt', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.widthFt}
onChange={(e) => setRowField(idx, 'widthFt', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.draftFt}
onChange={(e) => setRowField(idx, 'draftFt', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<Select
value={row.sidePontoon || '__none__'}
onValueChange={(v) =>
setRowField(idx, 'sidePontoon', v === '__none__' ? '' : v)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{sidePontoonOptions.filter(Boolean).map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.price}
onChange={(e) => setRowField(idx, 'price', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<CurrencySelect
value={row.priceCurrency || undefined}
onValueChange={(v) => setRowField(idx, 'priceCurrency', v)}
className="h-7 w-24 text-xs"
/>
</td>
<td className="py-1 pr-2">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => removeRow(idx)}
aria-label={`Remove ${row.mooringNumber}`}
>
<Trash2 className="size-3.5" aria-hidden />
</Button>
</td>
</tr>
))}
{rows.map((row, idx) => {
const isDup = duplicates.has(row.mooringNumber);
return (
<tr
key={row.mooringNumber}
className={
isDup ? 'border-b last:border-b-0 bg-amber-50/60' : 'border-b last:border-b-0'
}
>
<td className="py-1 pr-2 font-medium">
{row.mooringNumber}
{isDup ? (
<span className="ml-1 inline-flex items-center rounded-sm bg-amber-200/70 px-1 text-[10px] font-semibold uppercase tracking-wide text-amber-900">
Dup
</span>
) : null}
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.lengthFt}
onChange={(e) => setRowField(idx, 'lengthFt', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.widthFt}
onChange={(e) => setRowField(idx, 'widthFt', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.draftFt}
onChange={(e) => setRowField(idx, 'draftFt', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<Select
value={row.sidePontoon || '__none__'}
onValueChange={(v) =>
setRowField(idx, 'sidePontoon', v === '__none__' ? '' : v)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{sidePontoonOptions.filter(Boolean).map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.price}
onChange={(e) => setRowField(idx, 'price', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<CurrencySelect
value={row.priceCurrency || undefined}
onValueChange={(v) => setRowField(idx, 'priceCurrency', v)}
className="h-7 w-24 text-xs"
/>
</td>
<td className="py-1 pr-2">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => removeRow(idx)}
aria-label={`Remove ${row.mooringNumber}`}
>
<Trash2 className="size-3.5" aria-hidden />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -392,8 +482,13 @@ export function BulkAddBerthsWizard() {
Back
</Button>
<Button
disabled={mutation.isPending || rows.length === 0}
disabled={mutation.isPending || rows.length === 0 || remainingDuplicates.length > 0}
onClick={() => mutation.mutate()}
title={
remainingDuplicates.length > 0
? 'Resolve the duplicate mooring numbers before submitting.'
: undefined
}
>
{mutation.isPending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />