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:
64
src/app/api/v1/berths/check-duplicates/route.ts
Normal file
64
src/app/api/v1/berths/check-duplicates/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user