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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user