diff --git a/src/app/api/v1/berths/check-duplicates/route.ts b/src/app/api/v1/berths/check-duplicates/route.ts
new file mode 100644
index 00000000..0af1a436
--- /dev/null
+++ b/src/app/api/v1/berths/check-duplicates/route.ts
@@ -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);
+ }
+ }),
+);
diff --git a/src/components/admin/bulk-add-berths-wizard.tsx b/src/components/admin/bulk-add-berths-wizard.tsx
index df6d3f3a..3b0e9613 100644
--- a/src/components/admin/bulk-add-berths-wizard.tsx
+++ b/src/components/admin/bulk-add-berths-wizard.tsx
@@ -92,8 +92,12 @@ export function BulkAddBerthsWizard() {
// Step 2 state
const [rows, setRows] = useState([]);
+ /** Mooring numbers already present in the port. Populated by the
+ * pre-flight check fired during Step 1 → Step 2 transition. */
+ const [duplicates, setDuplicates] = useState>(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(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}).
-
+
);
}
+ const remainingDuplicates = rows.filter((r) => duplicates.has(r.mooringNumber));
+
return (
@@ -223,6 +272,32 @@ export function BulkAddBerthsWizard() {
+ {remainingDuplicates.length > 0 ? (
+
+
+
+ {remainingDuplicates.length} duplicate{' '}
+ {remainingDuplicates.length === 1 ? 'mooring number' : 'mooring numbers'} found
+
+
+ {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.
+
+
+
+
+ ) : null}
@@ -308,81 +383,96 @@ export function BulkAddBerthsWizard() {
- {rows.map((row, idx) => (
-
- | {row.mooringNumber} |
-
- setRowField(idx, 'lengthFt', e.target.value)}
- />
- |
-
- setRowField(idx, 'widthFt', e.target.value)}
- />
- |
-
- setRowField(idx, 'draftFt', e.target.value)}
- />
- |
-
-
- |
-
- setRowField(idx, 'price', e.target.value)}
- />
- |
-
- setRowField(idx, 'priceCurrency', v)}
- className="h-7 w-24 text-xs"
- />
- |
-
-
- |
-
- ))}
+ {rows.map((row, idx) => {
+ const isDup = duplicates.has(row.mooringNumber);
+ return (
+
+ |
+ {row.mooringNumber}
+ {isDup ? (
+
+ Dup
+
+ ) : null}
+ |
+
+ setRowField(idx, 'lengthFt', e.target.value)}
+ />
+ |
+
+ setRowField(idx, 'widthFt', e.target.value)}
+ />
+ |
+
+ setRowField(idx, 'draftFt', e.target.value)}
+ />
+ |
+
+
+ |
+
+ setRowField(idx, 'price', e.target.value)}
+ />
+ |
+
+ setRowField(idx, 'priceCurrency', v)}
+ className="h-7 w-24 text-xs"
+ />
+ |
+
+
+ |
+
+ );
+ })}
@@ -392,8 +482,13 @@ export function BulkAddBerthsWizard() {
← Back