From 709ef350ff7b106a5e2d0c1492282dcf6ebd9052 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 15:45:06 +0200 Subject: [PATCH] feat(bulk-berths): 2-step wizard for new-port setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 per PRE-DEPLOY-PLAN § 1.4.13. Service: bulkAddBerths(portId, inputs, meta) — input-level dedup catches in-batch duplicates, then a single SELECT against existing port rows rejects with ConflictError on first collision. All inserts in one round-trip; audit log + realtime alert. Validator: bulkAddBerthsSchema with min(1) max(500) per call. Route: POST /api/v1/berths/bulk-add gated on berths.create. Wizard UI (/[portSlug]/admin/berths/bulk-add): Step 1 — dock letter A-E, range start+end mooring numbers, tenure default. Generates N empty rows. Step 2 — editable table with per-row dimensions / pontoon / pricing. "Apply to all" inputs in the header row copy a value down every row at once (covers the "every row is 40ft × 15ft at €125k" case in two clicks). Per-row remove button. Drag-fill deferred. Server-side mooring uniqueness check is canonical; client-side dedup is a pre-flight courtesy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[portSlug]/admin/berths/bulk-add/page.tsx | 14 + src/app/api/v1/berths/bulk-add/route.ts | 32 ++ .../admin/bulk-add-berths-wizard.tsx | 394 ++++++++++++++++++ src/lib/services/berths.service.ts | 74 ++++ src/lib/validators/berths.ts | 11 + 5 files changed, 525 insertions(+) create mode 100644 src/app/(dashboard)/[portSlug]/admin/berths/bulk-add/page.tsx create mode 100644 src/app/api/v1/berths/bulk-add/route.ts create mode 100644 src/components/admin/bulk-add-berths-wizard.tsx diff --git a/src/app/(dashboard)/[portSlug]/admin/berths/bulk-add/page.tsx b/src/app/(dashboard)/[portSlug]/admin/berths/bulk-add/page.tsx new file mode 100644 index 00000000..71bf9547 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/berths/bulk-add/page.tsx @@ -0,0 +1,14 @@ +import { PageHeader } from '@/components/shared/page-header'; +import { BulkAddBerthsWizard } from '@/components/admin/bulk-add-berths-wizard'; + +export default function BulkAddBerthsPage() { + return ( +
+ + +
+ ); +} diff --git a/src/app/api/v1/berths/bulk-add/route.ts b/src/app/api/v1/berths/bulk-add/route.ts new file mode 100644 index 00000000..3d9b8339 --- /dev/null +++ b/src/app/api/v1/berths/bulk-add/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { bulkAddBerths } from '@/lib/services/berths.service'; +import { bulkAddBerthsSchema } from '@/lib/validators/berths'; + +/** + * POST /api/v1/berths/bulk-add + * + * Bulk-insert berths for new-port setup. Cap of 500 rows per call; + * the service rejects with ConflictError on first duplicate (within + * the input array or against existing port rows). Wizard UI lives at + * /[portSlug]/admin/berths/bulk-add. + */ +export const POST = withAuth( + withPermission('berths', 'create', async (req, ctx) => { + try { + const input = await parseBody(req, bulkAddBerthsSchema); + const result = await bulkAddBerths(ctx.portId, input.berths, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: result }); + } 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 new file mode 100644 index 00000000..a7bd1671 --- /dev/null +++ b/src/components/admin/bulk-add-berths-wizard.tsx @@ -0,0 +1,394 @@ +'use client'; + +/** + * Two-step wizard for bulk-creating berths during new-port setup. + * + * Step 1: pick the dock letter, the range (start..end mooring number), + * and the genuinely-standard defaults (tenure, status). Generates one + * empty row per mooring in the range. + * + * Step 2: editable table of the generated rows. Reps fill in per-row + * dimensions / pontoon / pricing. "Apply to all" inputs at the top + * of each column copy a value down. Validation is inline. + * + * Per PRE-DEPLOY-PLAN § 1.4.13. Drag-fill is a stretch — left as a + * follow-up; keyboard-friendly "Apply to all" covers most of the + * speed win without the complexity. + */ + +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { useParams, useRouter } from 'next/navigation'; +import { Loader2, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; + +const DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const; +type DockLetter = (typeof DOCK_LETTERS)[number]; + +const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', ''] as const; + +interface RowDraft { + mooringNumber: string; + area: string; + status: 'available'; + tenureType: 'permanent' | 'fixed_term'; + lengthFt: string; + widthFt: string; + draftFt: string; + sidePontoon: string; + price: string; + priceCurrency: string; +} + +function genRange(letter: DockLetter, start: number, end: number): RowDraft[] { + const out: RowDraft[] = []; + for (let i = start; i <= end; i += 1) { + out.push({ + mooringNumber: `${letter}${i}`, + area: letter, + status: 'available', + tenureType: 'permanent', + lengthFt: '', + widthFt: '', + draftFt: '', + sidePontoon: '', + price: '', + priceCurrency: 'USD', + }); + } + return out; +} + +export function BulkAddBerthsWizard() { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + const router = useRouter(); + + const [step, setStep] = useState<'sequence' | 'edit'>('sequence'); + + // Step 1 state + const [letter, setLetter] = useState('A'); + const [rangeStart, setRangeStart] = useState('1'); + const [rangeEnd, setRangeEnd] = useState('10'); + const [tenure, setTenure] = useState<'permanent' | 'fixed_term'>('permanent'); + + // Step 2 state + const [rows, setRows] = useState([]); + + function handleGenerate() { + const s = parseInt(rangeStart, 10); + const e = parseInt(rangeEnd, 10); + if (!Number.isFinite(s) || !Number.isFinite(e) || s < 0 || e < s) { + toast.error('Invalid range'); + return; + } + if (e - s > 499) { + toast.error('Cap is 500 berths per batch.'); + return; + } + const seeded = genRange(letter, s, e).map((r) => ({ ...r, tenureType: tenure })); + setRows(seeded); + setStep('edit'); + } + + function setRowField(idx: number, key: K, value: RowDraft[K]) { + setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [key]: value } : r))); + } + + function applyToAll(key: K, value: RowDraft[K]) { + setRows((prev) => prev.map((r) => ({ ...r, [key]: value }))); + } + + function removeRow(idx: number) { + setRows((prev) => prev.filter((_, i) => i !== idx)); + } + + const mutation = useMutation({ + mutationFn: async () => { + const payload = { + berths: rows.map((r) => ({ + mooringNumber: r.mooringNumber, + area: r.area, + status: r.status, + tenureType: r.tenureType, + lengthFt: r.lengthFt ? Number(r.lengthFt) : undefined, + widthFt: r.widthFt ? Number(r.widthFt) : undefined, + draftFt: r.draftFt ? Number(r.draftFt) : undefined, + price: r.price ? Number(r.price) : undefined, + priceCurrency: r.priceCurrency || undefined, + sidePontoon: r.sidePontoon || undefined, + })), + }; + const res = await apiFetch<{ data: { inserted: number } }>('/api/v1/berths/bulk-add', { + method: 'POST', + body: payload, + }); + return res.data; + }, + onSuccess: (data) => { + toast.success(`Created ${data.inserted} berths`); + router.push(`/${portSlug}/berths`); + }, + onError: (err) => toastError(err), + }); + + if (step === 'sequence') { + return ( + + + Step 1 — Sequence + + Pick the dock letter and the mooring-number range. Tenure + status apply to every row; + everything else (dimensions, pricing, pontoon) is filled per row in Step 2. + + + +
+
+ + +
+
+ + setRangeStart(e.target.value)} /> +
+
+ + setRangeEnd(e.target.value)} /> +
+
+ + +
+
+

+ Will generate {Math.max(0, parseInt(rangeEnd, 10) - parseInt(rangeStart, 10) + 1)} rows + (e.g. {letter} + {rangeStart} … {letter} + {rangeEnd}). +

+ +
+
+ ); + } + + return ( + + + Step 2 — Fill in each row + + Per-row dimensions, pricing, pontoon. Use the “Apply to all” inputs in the + header to copy a value down every row at once. + + + +
+ + + + + + + + + + + + + + {( + [ + ['lengthFt', 'number'], + ['widthFt', 'number'], + ['draftFt', 'number'], + ] as const + ).map(([k, type]) => ( + + ))} + + + + + + + {rows.map((row, idx) => ( + + + + + + + + + + + ))} + +
MooringLength (ft)Width (ft)Draft (ft)Side pontoonPriceCurrency +
apply to all → + { + if (e.target.value) applyToAll(k, e.target.value); + }} + placeholder="all" + /> + + + + { + if (e.target.value) applyToAll('price', e.target.value); + }} + placeholder="all" + /> + + { + if (e.target.value) applyToAll('priceCurrency', e.target.value.toUpperCase()); + }} + placeholder="all" + /> + +
{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', e.target.value.toUpperCase()) + } + /> + + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts index e2d3d2e9..d6a7cdc2 100644 --- a/src/lib/services/berths.service.ts +++ b/src/lib/services/berths.service.ts @@ -594,6 +594,80 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta: return berth!; } +// ─── Bulk add ─────────────────────────────────────────────────────────────── + +export async function bulkAddBerths( + portId: string, + inputs: CreateBerthInput[], + meta: AuditMeta, +): Promise<{ inserted: number; ids: string[] }> { + // Input-level dedup: catch fat-finger duplicates in the wizard before + // hitting the unique index. + const seenMoorings = new Set(); + for (const row of inputs) { + if (seenMoorings.has(row.mooringNumber)) { + throw new ConflictError(`Duplicate mooring number "${row.mooringNumber}" in input`); + } + seenMoorings.add(row.mooringNumber); + } + + const moorings = inputs.map((r) => r.mooringNumber); + const existing = await db + .select({ mooringNumber: berths.mooringNumber }) + .from(berths) + .where(and(eq(berths.portId, portId), inArray(berths.mooringNumber, moorings))); + if (existing.length > 0) { + throw new ConflictError( + `Mooring numbers already exist in this port: ${existing.map((r) => r.mooringNumber).join(', ')}`, + ); + } + + const defaultCurrency = await getPortBerthsDefaultCurrency(portId); + const values = inputs.map((row) => ({ + portId, + mooringNumber: row.mooringNumber, + area: row.area, + status: row.status ?? 'available', + lengthFt: row.lengthFt?.toString(), + lengthM: row.lengthM?.toString(), + widthFt: row.widthFt?.toString(), + widthM: row.widthM?.toString(), + draftFt: row.draftFt?.toString(), + draftM: row.draftM?.toString(), + price: row.price?.toString(), + priceCurrency: row.priceCurrency ?? defaultCurrency, + tenureType: row.tenureType ?? 'permanent', + mooringType: row.mooringType, + powerCapacity: row.powerCapacity?.toString(), + voltage: row.voltage?.toString(), + access: row.access, + bowFacing: row.bowFacing, + sidePontoon: row.sidePontoon, + })); + + const inserted = await db.insert(berths).values(values).returning({ id: berths.id }); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'berth', + entityId: 'bulk', + newValue: { count: inserted.length, mooringNumbers: moorings }, + metadata: { type: 'bulk_add' }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'system:alert', { + alertType: 'berth:bulk_created', + message: `${inserted.length} berths added`, + severity: 'info', + }); + + return { inserted: inserted.length, ids: inserted.map((r) => r.id) }; +} + // ─── Delete ───────────────────────────────────────────────────────────────── export async function deleteBerth(id: string, portId: string, meta: AuditMeta) { diff --git a/src/lib/validators/berths.ts b/src/lib/validators/berths.ts index 7a0778b1..e3ae6aa3 100644 --- a/src/lib/validators/berths.ts +++ b/src/lib/validators/berths.ts @@ -27,6 +27,17 @@ export const createBerthSchema = z.object({ export type CreateBerthInput = z.infer; +// ─── Bulk Add Berths ───────────────────────────────────────────────────────── + +export const bulkAddBerthsSchema = z.object({ + /** Per-row create payloads. Each row must carry a unique mooringNumber; + * the service rejects with ConflictError on first duplicate (either + * within the input array OR against an existing port row). */ + berths: z.array(createBerthSchema).min(1).max(500), +}); + +export type BulkAddBerthsInput = z.infer; + // ─── Update Berth ───────────────────────────────────────────────────────────── export const updateBerthSchema = z.object({