'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'; // Common dock-letter shorthand. Wizard accepts any uppercase letter // sequence matching the canonical mooring regex (`^[A-Z]+$`) - these // five are the most-frequently-used; reps add new ones via the // "Custom" input below. const COMMON_DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const; // The custom flow widens DockLetter beyond the shortlist; any uppercase // string the rep types is valid as long as it matches the canonical // `^[A-Z]+$` letter portion of a mooring number. type DockLetter = string; 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'); // Unit the rep is entering dims in. Persisted only for the wizard's // lifetime; the underlying `RowDraft` always stores the value as a // raw string in this unit. Conversion to canonical feet happens // once at submit (1 m = 3.28084 ft). const [dimUnit, setDimUnit] = useState<'ft' | 'm'>('ft'); const FT_PER_M = 3.28084; const inputToFt = (v: string): number | undefined => { if (!v) return undefined; const n = Number(v); if (!Number.isFinite(n)) return undefined; return dimUnit === 'm' ? n * FT_PER_M : n; }; // 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() { // Validate the dock letter - must be one or more uppercase letters per // the canonical mooring regex. Custom-input path normalises to upper // already, but guard against an empty input. if (!letter || !/^[A-Z]+$/.test(letter)) { toast.error('Dock letter must be one or more uppercase letters (e.g. A, B, AA).'); return; } 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: inputToFt(r.lengthFt), widthFt: inputToFt(r.widthFt), draftFt: inputToFt(r.draftFt), 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.
{/* Common dock letters as quick-pick chips; "Custom…" reveals a free-text input for ports whose dock layout extends beyond A-E (rare but supported). Canonical mooring regex is `^[A-Z]+\d+$`, so any uppercase letter sequence is valid as the prefix. */}
{COMMON_DOCK_LETTERS.map((l) => ( ))} setLetter(e.target.value.toUpperCase().replace(/[^A-Z]/g, ''))} placeholder="Other…" aria-label="Custom dock letter" maxLength={4} className="h-9 w-20 font-mono" />

Any uppercase letter sequence. Common ports use A-E; mark a custom letter when expanding to F+ or letter-pairs like AA.

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.
{/* Dimension-unit toggle. The wizard stores values as-entered; conversion to canonical feet (1 m = 3.28084 ft) happens once at submit. Switching mid-edit leaves existing inputs as numeric strings - the rep is responsible for re-entering if the unit interpretation just changed under them. */}
{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 ({dimUnit}) Width ({dimUnit}) Draft ({dimUnit}) 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" />
); }