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

@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { issueToken } from '@/lib/services/supplemental-forms.service';
import {
getTokenForResend,
issueToken,
listTokensForInterest,
} from '@/lib/services/supplemental-forms.service';
import { sendEmail } from '@/lib/email';
import { env } from '@/lib/env';
import { getPortEmailConfig } from '@/lib/services/port-config';
@@ -13,28 +17,56 @@ import { getPortEmailConfig } from '@/lib/services/port-config';
* Auth: requires `interests.edit` so any rep working the deal can fire it.
* Generates a one-shot token + emails the client the public form URL.
*/
/**
* GET — list past issuances for the interest. Lets the rep see when each
* token was generated + which one is currently active, so they can choose
* Resend (re-email the existing token) over Regenerate (mint a fresh one)
* when the same client is still working through the existing form.
*/
export const GET = withAuth(
withPermission('interests', 'view', async (_req, ctx, params) => {
try {
const rows = await listTokensForInterest(ctx.portId, params.id!);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('interests', 'edit', async (req: NextRequest, ctx, params) => {
try {
const interestId = params.id as string;
// Two-step UX: rep can generate a link without firing the email
// (so they can copy + share manually through WhatsApp etc.), then
// come back and send the templated email separately. Default stays
// `true` for back-compat. The body is optional — older callers
// POST with no body and still trigger the email.
// Three flows surface here today:
// 1. New token + email (default).
// 2. New token, no email (rep copies + shares manually).
// 3. Resend an existing token via email (no new token minted).
// `tokenId` in the body opts into (3); otherwise (1)/(2) apply
// based on `sendEmail`. Older callers POST with no body and still
// trigger (1).
let shouldSendEmail = true;
let resendTokenId: string | null = null;
try {
const body = (await req.clone().json()) as { sendEmail?: boolean };
const body = (await req.clone().json()) as {
sendEmail?: boolean;
tokenId?: string;
};
if (typeof body?.sendEmail === 'boolean') shouldSendEmail = body.sendEmail;
if (typeof body?.tokenId === 'string' && body.tokenId.length > 0) {
resendTokenId = body.tokenId;
}
} catch {
// No JSON body — keep the default.
}
const result = await issueToken({
interestId,
portId: ctx.portId,
issuedBy: ctx.userId,
});
const result = resendTokenId
? await getTokenForResend(ctx.portId, interestId, resendTokenId)
: await issueToken({
interestId,
portId: ctx.portId,
issuedBy: ctx.userId,
});
// §1.4: prefer the per-port supplemental_form_url (typically the
// marketing site's hosted form) when configured; otherwise fall
@@ -45,7 +77,11 @@ export const POST = withAuth(
? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}`
: `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
if (shouldSendEmail && result.clientEmail) {
// Resend implies "email me again" — the rep clicked the action with
// intent. Force the email path on for resends regardless of the
// `sendEmail` body flag.
const willSendEmail = resendTokenId ? true : shouldSendEmail;
if (willSendEmail && result.clientEmail) {
const html = `
<p>Hello ${escapeHtml(result.clientName)},</p>
<p>Before we draft your Expression of Interest, we need to confirm a few details.
@@ -76,7 +112,8 @@ export const POST = withAuth(
data: {
link,
expiresAt: result.expiresAt.toISOString(),
emailSent: shouldSendEmail && !!result.clientEmail,
emailSent: willSendEmail && !!result.clientEmail,
resent: !!resendTokenId,
},
});
} catch (error) {

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

View File

@@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { ClipboardCopy, Mail } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, ClipboardCopy, Clock, Mail, Send } from 'lucide-react';
import { formatDistanceToNowStrict } from 'date-fns';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -23,9 +24,20 @@ interface IssueResponse {
link: string;
expiresAt: string;
emailSent: boolean;
resent?: boolean;
};
}
interface TokenHistoryRow {
id: string;
token: string;
createdAt: string;
expiresAt: string;
consumedAt: string | null;
issuedBy: string | null;
expired: boolean;
}
/**
* One-click "Request more info" action. Fires the supplemental-info-
* request endpoint, which emails the client a public form pre-filled
@@ -37,23 +49,39 @@ interface IssueResponse {
* sense before the signed EOI freezes the data into the contract path.
*/
export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props) {
const qc = useQueryClient();
const [link, setLink] = useState<string | null>(null);
// History query — the latest 20 issuances. Refetched after every
// mutation so the rep sees the just-generated row appear immediately.
const history = useQuery({
queryKey: ['supplemental-info', interestId, 'history'],
queryFn: () =>
apiFetch<{ data: TokenHistoryRow[] }>(
`/api/v1/interests/${interestId}/supplemental-info-request`,
),
enabled: eoiStatus !== 'signed',
staleTime: 30_000,
});
const mutation = useMutation({
mutationFn: (vars: { sendEmail: boolean }) =>
mutationFn: (vars: { sendEmail: boolean; tokenId?: string }) =>
apiFetch<IssueResponse>(`/api/v1/interests/${interestId}/supplemental-info-request`, {
method: 'POST',
body: vars,
}),
onSuccess: (res) => {
setLink(res.data.link);
if (res.data.emailSent) {
if (res.data.resent) {
toast.success('Email re-sent using the existing link.');
} else if (res.data.emailSent) {
toast.success('Email sent. Link also shown below for sharing manually.');
} else {
toast.message(
'Link generated. Click "Send by email" to mail it, or copy it to share manually.',
);
}
void qc.invalidateQueries({ queryKey: ['supplemental-info', interestId, 'history'] });
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Failed to generate the form link.'),
@@ -61,6 +89,14 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
if (eoiStatus === 'signed') return null;
// Pick the latest unconsumed + unexpired token, if any. That's the
// candidate for "Resend" — the rep wants the same link to land in the
// client's inbox again. Older or consumed tokens stay in history but
// can't be resent (consumed = form already submitted; expired = link
// dead).
const tokens = history.data?.data ?? [];
const resendableToken = tokens.find((t) => !t.consumedAt && !t.expired) ?? null;
return (
<Card>
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` because it
@@ -83,17 +119,35 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
onClick={() => mutation.mutate({ sendEmail: false })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Generating…' : link ? 'Regenerate link' : 'Generate link'}
{mutation.isPending
? 'Generating…'
: resendableToken
? 'Regenerate link'
: 'Generate link'}
</Button>
<Button
type="button"
size="sm"
variant={resendableToken ? 'outline' : 'default'}
onClick={() => mutation.mutate({ sendEmail: true })}
disabled={mutation.isPending}
>
<Mail className="mr-1.5 size-3.5" aria-hidden />
{link ? 'Send by email' : 'Generate + email'}
{resendableToken ? 'New link + email' : 'Generate + email'}
</Button>
{resendableToken ? (
<Button
type="button"
size="sm"
variant="default"
onClick={() => mutation.mutate({ sendEmail: true, tokenId: resendableToken.id })}
disabled={mutation.isPending}
title="Re-email the existing active link to the client. No new token is created."
>
<Send className="mr-1.5 size-3.5" aria-hidden />
Resend current
</Button>
) : null}
{link ? (
<>
<Input value={link} readOnly className="h-8 text-xs font-mono flex-1 min-w-[260px]" />
@@ -112,6 +166,41 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
</>
) : null}
</div>
{/* Issuance history — every past supplemental link for this
interest, newest first. Lets the rep see whether a previous
link is still outstanding (so they can Resend rather than
mint a fresh one) and confirm whether the client ever
submitted. Hidden when the list is empty. */}
{tokens.length > 0 ? (
<div className="space-y-1 border-t pt-2">
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Issuance history
</div>
<ul className="divide-y">
{tokens.map((t) => {
const created = new Date(t.createdAt);
const status = t.consumedAt
? { label: 'Submitted', tone: 'text-emerald-700', icon: CheckCircle2 }
: t.expired
? { label: 'Expired', tone: 'text-muted-foreground', icon: Clock }
: { label: 'Active', tone: 'text-amber-700', icon: Clock };
const StatusIcon = status.icon;
return (
<li key={t.id} className="flex items-center justify-between gap-2 py-1.5 text-xs">
<div className="flex items-center gap-1.5">
<StatusIcon className={`size-3 ${status.tone}`} aria-hidden />
<span className={`font-medium ${status.tone}`}>{status.label}</span>
<span className="text-muted-foreground">
{formatDistanceToNowStrict(created, { addSuffix: true })}
</span>
</div>
</li>
);
})}
</ul>
</div>
) : null}
</CardContent>
</Card>
);

View File

@@ -102,6 +102,92 @@ export interface PrefillData {
* `consumed: true` when it's already been used so the form can render
* a friendly "already submitted" state.
*/
// ─── List + fetch by id (rep-facing history) ────────────────────────────────
export interface SupplementalTokenHistoryRow {
id: string;
token: string;
createdAt: string;
expiresAt: string;
consumedAt: string | null;
issuedBy: string | null;
/** True when expiresAt is in the past and the token hasn't been consumed. */
expired: boolean;
}
/**
* Lists supplemental-info-request issuances for an interest, newest first.
* Used by the rep-facing "issuance history" surface on the interest page —
* shows when each token was generated, whether it's been consumed, and
* lets the rep re-send the latest active token without minting a fresh
* one.
*/
export async function listTokensForInterest(
portId: string,
interestId: string,
): Promise<SupplementalTokenHistoryRow[]> {
const { desc } = await import('drizzle-orm');
const rows = await db
.select()
.from(supplementalFormTokens)
.where(
and(
eq(supplementalFormTokens.portId, portId),
eq(supplementalFormTokens.interestId, interestId),
),
)
.orderBy(desc(supplementalFormTokens.createdAt))
.limit(20);
const now = Date.now();
return rows.map((r) => ({
id: r.id,
token: r.token,
createdAt: r.createdAt.toISOString(),
expiresAt: r.expiresAt.toISOString(),
consumedAt: r.consumedAt ? r.consumedAt.toISOString() : null,
issuedBy: r.issuedBy,
expired: !r.consumedAt && r.expiresAt.getTime() < now,
}));
}
/**
* Resolve a specific token id back into a snapshot suitable for re-emailing.
* Reuses the same shape that issueToken returns so the calling route can
* fire `sendEmail` without per-call divergence. Throws NotFoundError when
* the token doesn't exist or is cross-port (mirrors the
* enumeration-prevention behaviour on the issue path).
*/
export async function getTokenForResend(
portId: string,
interestId: string,
tokenId: string,
): Promise<{
token: string;
expiresAt: Date;
clientEmail: string | null;
clientName: string;
}> {
const row = await db.query.supplementalFormTokens.findFirst({
where: and(
eq(supplementalFormTokens.id, tokenId),
eq(supplementalFormTokens.portId, portId),
eq(supplementalFormTokens.interestId, interestId),
),
});
if (!row) throw new NotFoundError('supplemental token');
const client = await db.query.clients.findFirst({ where: eq(clients.id, row.clientId) });
if (!client) throw new NotFoundError('client');
const emailContact = await db.query.clientContacts.findFirst({
where: and(eq(clientContacts.clientId, client.id), eq(clientContacts.channel, 'email')),
});
return {
token: row.token,
expiresAt: row.expiresAt,
clientEmail: emailContact?.value ?? null,
clientName: client.fullName ?? client.id,
};
}
export async function loadByToken(token: string): Promise<PrefillData | null> {
const row = await db.query.supplementalFormTokens.findFirst({
where: eq(supplementalFormTokens.token, token),