Files
pn-new-crm/src/components/admin/bulk-add-berths-wizard.tsx
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

569 lines
22 KiB
TypeScript

'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<DockLetter>('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<RowDraft[]>([]);
/** Mooring numbers already present in the port. Populated by the
* pre-flight check fired during Step 1 → Step 2 transition. */
const [duplicates, setDuplicates] = useState<Set<string>>(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<K extends keyof RowDraft>(idx: number, key: K, value: RowDraft[K]) {
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [key]: value } : r)));
}
function applyToAll<K extends keyof RowDraft>(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 (
<Card>
<CardHeader>
<CardTitle>Step 1 - Sequence</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div className="space-y-1">
<Label>Dock letter</Label>
{/* 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. */}
<div className="flex flex-wrap items-center gap-1">
{COMMON_DOCK_LETTERS.map((l) => (
<Button
key={l}
type="button"
size="sm"
variant={letter === l ? 'default' : 'outline'}
className="h-9 w-9 p-0 font-mono"
onClick={() => setLetter(l)}
>
{l}
</Button>
))}
<Input
value={(COMMON_DOCK_LETTERS as readonly string[]).includes(letter) ? '' : letter}
onChange={(e) => 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"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Any uppercase letter sequence. Common ports use A-E; mark a custom letter when
expanding to F+ or letter-pairs like AA.
</p>
</div>
<div className="space-y-1">
<Label>Range start</Label>
<Input value={rangeStart} onChange={(e) => setRangeStart(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Range end</Label>
<Input value={rangeEnd} onChange={(e) => setRangeEnd(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Tenure (all rows)</Label>
<Select
value={tenure}
onValueChange={(v) => setTenure(v as 'permanent' | 'fixed_term')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="permanent">Permanent</SelectItem>
<SelectItem value="fixed_term">Fixed term</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
Will generate {Math.max(0, parseInt(rangeEnd, 10) - parseInt(rangeStart, 10) + 1)} rows
(e.g. {letter}
{rangeStart} {letter}
{rangeEnd}).
</p>
<Button onClick={handleGenerate} disabled={checkingDups}>
{checkingDups ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" aria-hidden />
Checking
</>
) : (
'Continue →'
)}
</Button>
</CardContent>
</Card>
);
}
const remainingDuplicates = rows.filter((r) => duplicates.has(r.mooringNumber));
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Step 2 - Fill in each row</CardTitle>
<CardDescription>
Per-row dimensions, pricing, pontoon. Use the &ldquo;Apply to all&rdquo; inputs in the
header to copy a value down every row at once.
</CardDescription>
</div>
{/* 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. */}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDimUnit(dimUnit === 'ft' ? 'm' : 'ft')}
aria-label={`Switch dimension entry to ${dimUnit === 'ft' ? 'metres' : 'feet'}`}
title={`Entering dimensions in ${dimUnit === 'ft' ? 'feet' : 'metres'} - click to switch`}
className="font-mono text-xs"
>
{dimUnit}
</Button>
</div>
</CardHeader>
<CardContent>
{remainingDuplicates.length > 0 ? (
<div
role="alert"
className="mb-3 flex items-start justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<div>
<div className="font-medium">
{remainingDuplicates.length} duplicate{' '}
{remainingDuplicates.length === 1 ? 'mooring number' : 'mooring numbers'} found
</div>
<div className="mt-0.5 text-xs">
{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.
</div>
</div>
<Button size="sm" variant="outline" onClick={removeAllDuplicates}>
Remove all duplicates
</Button>
</div>
) : null}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th scope="col" className="py-2 pr-2">
Mooring
</th>
<th scope="col" className="py-2 pr-2">
Length ({dimUnit})
</th>
<th scope="col" className="py-2 pr-2">
Width ({dimUnit})
</th>
<th scope="col" className="py-2 pr-2">
Draft ({dimUnit})
</th>
<th scope="col" className="py-2 pr-2">
Side pontoon
</th>
<th scope="col" className="py-2 pr-2">
Price
</th>
<th scope="col" className="py-2 pr-2">
Currency
</th>
<th scope="col" className="py-2 pr-2" />
</tr>
<tr className="border-b bg-muted/30">
<td className="py-1 pr-2 text-[10px] text-muted-foreground">apply to all </td>
{(
[
['lengthFt', 'number'],
['widthFt', 'number'],
['draftFt', 'number'],
] as const
).map(([k, type]) => (
<td key={k} className="py-1 pr-2">
<Input
className="h-7 text-xs"
type={type}
onBlur={(e) => {
if (e.target.value) applyToAll(k, e.target.value);
}}
placeholder="all"
/>
</td>
))}
<td className="py-1 pr-2">
<Select
onValueChange={(v) => applyToAll('sidePontoon', v === '__none__' ? '' : v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="all" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">(none)</SelectItem>
{sidePontoonOptions.filter(Boolean).map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
onBlur={(e) => {
if (e.target.value) applyToAll('price', e.target.value);
}}
placeholder="all"
/>
</td>
<td className="py-1 pr-2">
<CurrencySelect
value={undefined}
onValueChange={(v) => applyToAll('priceCurrency', v)}
className="h-7 text-xs"
/>
</td>
<td />
</tr>
</thead>
<tbody>
{rows.map((row, idx) => {
const isDup = duplicates.has(row.mooringNumber);
return (
<tr
key={row.mooringNumber}
className={
isDup ? 'border-b last:border-b-0 bg-amber-50/60' : 'border-b last:border-b-0'
}
>
<td className="py-1 pr-2 font-medium">
{row.mooringNumber}
{isDup ? (
<span className="ml-1 inline-flex items-center rounded-sm bg-amber-200/70 px-1 text-[10px] font-semibold uppercase tracking-wide text-amber-900">
Dup
</span>
) : null}
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.lengthFt}
onChange={(e) => setRowField(idx, 'lengthFt', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.widthFt}
onChange={(e) => setRowField(idx, 'widthFt', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.draftFt}
onChange={(e) => setRowField(idx, 'draftFt', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<Select
value={row.sidePontoon || '__none__'}
onValueChange={(v) =>
setRowField(idx, 'sidePontoon', v === '__none__' ? '' : v)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> - </SelectItem>
{sidePontoonOptions.filter(Boolean).map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 text-xs"
type="number"
value={row.price}
onChange={(e) => setRowField(idx, 'price', e.target.value)}
/>
</td>
<td className="py-1 pr-2">
<CurrencySelect
value={row.priceCurrency || undefined}
onValueChange={(v) => setRowField(idx, 'priceCurrency', v)}
className="h-7 w-24 text-xs"
/>
</td>
<td className="py-1 pr-2">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => removeRow(idx)}
aria-label={`Remove ${row.mooringNumber}`}
>
<Trash2 className="size-3.5" aria-hidden />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-4 flex items-center justify-between">
<Button variant="ghost" onClick={() => setStep('sequence')} disabled={mutation.isPending}>
Back
</Button>
<Button
disabled={mutation.isPending || rows.length === 0 || remainingDuplicates.length > 0}
onClick={() => mutation.mutate()}
title={
remainingDuplicates.length > 0
? 'Resolve the duplicate mooring numbers before submitting.'
: undefined
}
>
{mutation.isPending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
`Create ${rows.length} berths`
)}
</Button>
</div>
</CardContent>
</Card>
);
}