diff --git a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts index f5959670..4f323759 100644 --- a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts +++ b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts @@ -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 = `
Hello ${escapeHtml(result.clientName)},
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) {
diff --git a/src/components/admin/bulk-add-berths-wizard.tsx b/src/components/admin/bulk-add-berths-wizard.tsx
index 85b10cf5..a7a30119 100644
--- a/src/components/admin/bulk-add-berths-wizard.tsx
+++ b/src/components/admin/bulk-add-berths-wizard.tsx
@@ -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
+ Any uppercase letter sequence. Common ports use A-E; mark a custom letter when
+ expanding to F+ or letter-pairs like AA.
+
- Length (ft)
+ Length ({dimUnit})
- Width (ft)
+ Width ({dimUnit})
- Draft (ft)
+ Draft ({dimUnit})
Side pontoon
diff --git a/src/components/interests/supplemental-info-request-button.tsx b/src/components/interests/supplemental-info-request-button.tsx
index 2eb98d50..fc7e63ac 100644
--- a/src/components/interests/supplemental-info-request-button.tsx
+++ b/src/components/interests/supplemental-info-request-button.tsx
@@ -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
+ {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 (
+
+