feat(uat-batch): Groups D + E — wizard polish + supplemental-info history

D24 + D25 + E26 from the 2026-05-21 plan. All three shipped.

Shipped now:
  D24  BulkAddBerthsWizard ft/m toggle. Step 2 header gets a small
       monospaced ft/m button that flips the dimension entry unit
       wizard-wide. Cell values stay as-typed; on submit a single
       `inputToFt(v)` helper converts m→ft (1 m = 3.28084 ft) before
       posting the canonical feet payload. Column headers update
       Length/Width/Draft labels to reflect the active unit.
  D25  BulkAddBerthsWizard dock-letter expansion. Replaced the
       Select-of-A–E with a chip group + free-text "Other…" input.
       Common letters (A-E) are quick-pick chips; reps can type any
       uppercase letter sequence (AA, BB, F, …) for ports whose dock
       layout extends past the five-letter shortlist. New
       `handleGenerate` validation rejects empty / non-uppercase
       inputs with a toast. Custom-input path uppercases + strips
       non-letters as the rep types so the canonical
       `^[A-Z]+\d+$` mooring regex always matches.
  E26  Supplemental-info Regenerate / Resend / history.
       Service: new `listTokensForInterest(portId, interestId)`
       returns the latest 20 issuances with expired/consumed flags;
       new `getTokenForResend(portId, interestId, tokenId)` snapshots
       a specific token back into the issue-shape so the route can
       re-email without minting a fresh token.
       Route: GET lists the issuances (gated on `interests.view`);
       POST accepts an optional `tokenId` for the Resend branch
       (forces `sendEmail=true` since the rep clicked with intent)
       and returns `resent: true/false` on the success payload.
       UI: button card now shows three actions — Generate /
       Regenerate link, Generate + email (or "New link + email"
       when a usable token exists), and Resend current (only when
       there's an active unconsumed unexpired token). Issuance
       history list shows Active / Submitted / Expired per row.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:30:22 +02:00
parent 991e2223c7
commit 431375d794
4 changed files with 322 additions and 45 deletions

View File

@@ -38,8 +38,15 @@ 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];
// 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;
@@ -83,6 +90,18 @@ export function BulkAddBerthsWizard() {
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');
@@ -98,6 +117,13 @@ export function BulkAddBerthsWizard() {
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) {
@@ -167,9 +193,9 @@ export function BulkAddBerthsWizard() {
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,
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,
@@ -202,18 +228,37 @@ export function BulkAddBerthsWizard() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div className="space-y-1">
<Label>Dock letter</Label>
<Select value={letter} onValueChange={(v) => setLetter(v as DockLetter)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DOCK_LETTERS.map((l) => (
<SelectItem key={l} value={l}>
{l}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 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>
@@ -265,11 +310,31 @@ export function BulkAddBerthsWizard() {
return (
<Card>
<CardHeader>
<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 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 ? (
@@ -306,13 +371,13 @@ export function BulkAddBerthsWizard() {
Mooring
</th>
<th scope="col" className="py-2 pr-2">
Length (ft)
Length ({dimUnit})
</th>
<th scope="col" className="py-2 pr-2">
Width (ft)
Width ({dimUnit})
</th>
<th scope="col" className="py-2 pr-2">
Draft (ft)
Draft ({dimUnit})
</th>
<th scope="col" className="py-2 pr-2">
Side pontoon