'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'; import { useVocabulary } from '@/hooks/use-vocabulary'; import { CurrencySelect } from '@/components/shared/currency-select'; const DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const; type DockLetter = (typeof DOCK_LETTERS)[number]; 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(); // Canonical, admin-editable side-pontoon vocabulary (per-port overrides // honoured). Falls back to BERTH_SIDE_PONTOON_OPTIONS defaults when the // /api/v1/vocabularies request hasn't resolved yet. const sidePontoonOptions = useVocabulary('berth_side_pontoon_options'); 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([]); /** 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); async 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 })); // 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))); } 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}).

); } const remainingDuplicates = rows.filter((r) => duplicates.has(r.mooringNumber)); 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. {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}
{( [ ['lengthFt', 'number'], ['widthFt', 'number'], ['draftFt', 'number'], ] as const ).map(([k, type]) => ( ))} {rows.map((row, idx) => { const isDup = duplicates.has(row.mooringNumber); return ( ); })}
Mooring Length (ft) Width (ft) Draft (ft) Side pontoon Price Currency
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" /> applyToAll('priceCurrency', v)} className="h-7 text-xs" />
{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" />
); }