'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'; 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([]); 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) => ( ))}
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" /> { 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()) } />
); }