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
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -88,7 +88,7 @@ export function AddBerthToInterestDialog({
checked={choice === 'exploring'}
title="Just exploring"
description="The berth is being considered or covered by the EOI bundle, but not pitched specifically."
consequence="This berth stays marked “Available” on the public map the link is internal only."
consequence="This berth stays marked “Available” on the public map - the link is internal only."
icon={<EyeOff className="size-4" aria-hidden />}
/>
</RadioGroup>

View File

@@ -413,11 +413,11 @@ export function BerthRecommenderPanel({
const [amenityFilters, setAmenityFilters] = useState<AmenityFilters>({});
const [showAll, setShowAll] = useState(false);
const [pendingBerth, setPendingBerth] = useState<Recommendation | null>(null);
// Area-letter filter chips above the list let reps narrow to a
// Area-letter filter - chips above the list let reps narrow to a
// single pier (e.g. "show me only A-row matches"). Client-side over
// the already-fetched result set; no service change required.
const [selectedAreas, setSelectedAreas] = useState<string[]>([]);
// Collapse state defaults to collapsed when the deal already has at
// Collapse state - defaults to collapsed when the deal already has at
// least one linked berth (recommender becomes a "browse more options"
// tool rather than the primary surface). Reps can manually expand any
// time. Header click toggles.
@@ -432,7 +432,7 @@ export function BerthRecommenderPanel({
const { data, isFetching, refetch } = useQuery({
queryKey,
// Skip the network call when collapsed no point fetching options
// Skip the network call when collapsed - no point fetching options
// the rep won't see. Re-fires automatically on expand.
enabled: hasDimensions && !collapsed,
queryFn: () =>
@@ -443,7 +443,7 @@ export function BerthRecommenderPanel({
// oversize-cap so berths well beyond the strict feasibility window
// surface. Without that second bump the user could end up staring
// at "no berths match" when the test data only had oversized rows
// exactly the case in our seeded demo port.
// - exactly the case in our seeded demo port.
...(showAll ? { topN: 999, maxOversizePct: 1000 } : {}),
...(Object.keys(amenityFilters).length > 0 ? { amenityFilters } : {}),
},

View File

@@ -32,7 +32,7 @@ const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const [open, setOpen] = useState(false);
// Closed / archived deals don't get a pulse UX would be confusing.
// Closed / archived deals don't get a pulse - UX would be confusing.
if (interest.archivedAt || interest.outcome) return null;
const health = computeDealHealth(interest);

View File

@@ -58,7 +58,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [signedAt, setSignedAt] = useState(() => new Date().toISOString().slice(0, 10));
// `null` means "rep hasn't touched the list yet show the
// `null` means "rep hasn't touched the list yet - show the
// derived-from-interest seed". Once edited (add/remove/change),
// the explicit array takes over. Avoids a setState-in-effect that
// the React Compiler bans.
@@ -66,7 +66,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
const [notes, setNotes] = useState('');
// Fetched on open to power the default title:
// "External EOI <Client> <berth range> YYYY-MM-DD". Without
// "External EOI - <Client> - <berth range> - YYYY-MM-DD". Without
// this the file lands as just "External EOI - <date>" which is
// unscannable in any list when a port has multiple deals closing on
// the same day. Also drives auto-fill on signatory rows tagged
@@ -83,7 +83,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
staleTime: 60_000,
});
// Compute the effective signatory list when the rep hasn't touched
// Compute the effective signatory list - when the rep hasn't touched
// anything, seed from the interest's client. Once they edit, the
// explicit override takes over.
const signatories: SignatoryRow[] = useMemo(() => {
@@ -118,7 +118,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
if (clientName) parts.push(clientName);
if (berthLabel) parts.push(berthLabel);
parts.push(date);
return parts.join(' ');
return parts.join(' - ');
}, [interestData, berthsData, signedAt]);
const mutation = useMutation<{ data?: { stageChanged?: boolean } }, Error, void>({
@@ -175,7 +175,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Upload externally-signed EOI</DialogTitle>
<DialogDescription>

View File

@@ -46,7 +46,7 @@ interface InlineStagePickerProps {
* inline prereq view that lets them link a yacht and proceed in one
* flow instead of bouncing them out to the form. */
currentYachtId?: string | null;
/** Client owning the interest scopes the inline yacht-picker so the
/** Client owning the interest - scopes the inline yacht-picker so the
* rep only sees yachts that actually belong to this lead. */
clientId?: string;
}
@@ -80,7 +80,7 @@ export function InlineStagePicker({
// When a user picks a stage that isn't a legal next step (and has the
// override permission), the popover transitions into a confirm view
// that asks for a reason before committing. Reasons are not exposed
// for legal transitions they're stored as audit-log notes on the
// for legal transitions - they're stored as audit-log notes on the
// interest's history, accessible via the activity timeline.
const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null);
const [overrideReason, setOverrideReason] = useState('');
@@ -160,7 +160,7 @@ export function InlineStagePicker({
const isOverride = !canTransitionStage(stage, next);
if (isOverride && canOverride) {
// Switch into the confirm view rather than firing the mutation
// immediately overrides bypass the transition guard so a reason
// immediately - overrides bypass the transition guard so a reason
// is genuinely useful for the audit trail.
setOverrideTarget(next);
setOverrideReason('');
@@ -207,7 +207,7 @@ export function InlineStagePicker({
),
);
// After unlinking, the canTransition table might no longer flag this
// as an override re-evaluate just in case.
// as an override - re-evaluate just in case.
const isOverride = !canTransitionStage(stage, target);
mutation.mutate({
next: target,
@@ -286,7 +286,7 @@ export function InlineStagePicker({
onClick={(e) => stopPropagation && e.stopPropagation()}
>
{yachtPrereqTarget ? (
// F23: inline yacht-prereq view only reached when the rep
// F23: inline yacht-prereq view - only reached when the rep
// picked a non-Enquiry stage without a yacht linked. Surfaces
// a yacht-picker right inside the popover so they can fix
// the prereq and move the stage in one flow.
@@ -394,7 +394,7 @@ export function InlineStagePicker({
</div>
</div>
) : (
// Default view: just the stage list. No upfront textarea
// Default view: just the stage list. No upfront textarea -
// earlier UX put a "Reason (optional)…" field at the top
// which read as visually noisy for the >90% of changes that
// are normal transitions and never get a reason attached.
@@ -416,7 +416,7 @@ export function InlineStagePicker({
blockedByPermission
? `Override required (you don't have permission)`
: isOverride
? 'Non-standard transition confirm step required'
? 'Non-standard transition - confirm step required'
: undefined
}
className={cn(
@@ -425,7 +425,7 @@ export function InlineStagePicker({
isCurrent && 'font-medium',
)}
>
{/* Colored chip (mirrors the inline stage badge) turns
{/* Colored chip (mirrors the inline stage badge) - turns
the picker into a visual scan rather than just a list. */}
<span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
@@ -440,7 +440,7 @@ export function InlineStagePicker({
) : isCurrent ? (
<Check className="size-3.5 text-muted-foreground" aria-hidden />
) : isOverride && canOverride ? (
// F22: was ⚑ unicode glyph replaced with a Lucide
// F22: was ⚑ unicode glyph - replaced with a Lucide
// icon to match the rest of the visual system.
<AlertTriangle
className="size-3.5 text-amber-600"

View File

@@ -28,12 +28,12 @@ interface CompetingInterest {
/**
* Surfaces when one of the interest's linked berths is sold or under offer
* to a different deal. We don't block the rep from proceeding (the user
* explicitly wanted v1 to still let the deal advance the assumption is
* explicitly wanted v1 to still let the deal advance - the assumption is
* that the rep is aware and treating the current deal as a fallback if
* the other one falls through), but the banner makes the conflict visible
* so they aren't surprised when the rules engine flags it.
*
* Fires only for active (non-archived, non-closed) interests banners on
* Fires only for active (non-archived, non-closed) interests - banners on
* lost deals are noise.
*/
export function InterestBerthStatusBanner({
@@ -74,7 +74,7 @@ export function InterestBerthStatusBanner({
});
if (archivedAt || interestOutcome) return null;
// The banner is most useful before the rep is committed to the deal
// The banner is most useful before the rep is committed to the deal -
// once contract is in motion, the conflict is moot.
if (interestPipelineStage === 'contract') return null;

View File

@@ -74,7 +74,7 @@ const SOURCE_LABELS: Record<string, string> = {
/**
* Toggleable columns for the InterestList ColumnPicker. `actions` and
* `clientName` are intentionally omitted from this list actions is a
* `clientName` are intentionally omitted from this list - actions is a
* row-control column that should never be hidden, and clientName is the
* primary entity identifier (a row with no name has no useful purpose).
*/
@@ -90,7 +90,7 @@ export const INTEREST_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
/**
* Columns hidden by default for users who haven't customised their view.
* Keep the busy `desiredSize` and `eoiStatus` collapsed by default
* Keep the busy `desiredSize` and `eoiStatus` collapsed by default -
* power-users can turn them back on via the column picker.
*/
export const INTEREST_DEFAULT_HIDDEN: string[] = ['desiredSize', 'eoiStatus'];
@@ -122,7 +122,7 @@ export function getInterestColumns({
return (
<div className="flex items-center gap-1.5 min-w-0">
{/* Client cell on the Interests list links to the INTEREST detail
not the client page. Users browsing the interest list want
- not the client page. Users browsing the interest list want
the deal context, not the underlying client. The interest
detail header has its own "Client page" deep-link if the rep
actually wants the client surface. */}

View File

@@ -83,7 +83,7 @@ interface ContactLogEntry {
updatedAt: string;
}
/** Quick-template seeds drop a starting structure into the summary so reps
/** Quick-template seeds - drop a starting structure into the summary so reps
* spend their typing on the substance, not the scaffolding. */
const TEMPLATE_SEEDS: Record<
Template,
@@ -125,7 +125,7 @@ const CHANNEL_META: Record<Channel, { label: string; icon: ChannelIcon; tone: st
/**
* Per-interaction contact log. Sales reps log every email / call /
* WhatsApp / meeting touch with the client here so the team has a
* structured history of "what was the last conversation about" not
* structured history of "what was the last conversation about" - not
* just the bare "last contact 8d ago" timestamp on the interest.
*
* Each entry can optionally schedule a follow-up that auto-creates a
@@ -305,7 +305,7 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
// ─── Compose / edit sheet ───────────────────────────────────────────────────
// Exported for §1.4 interest-detail-header.tsx mounts this sheet
// Exported for §1.4 - interest-detail-header.tsx mounts this sheet
// directly via a "Log contact" quick-action button (sibling to the
// Email / Call / WhatsApp pills) so the rep doesn't have to navigate
// to the Contact log tab first.
@@ -362,7 +362,7 @@ function ComposeDialogBody({
const voice = useVoiceTranscription();
// Append committed transcript chunks into the summary as the rep speaks.
// We diff against the previous final transcript so we only append the new
// tail otherwise the entire transcript gets re-pasted on every event.
// tail - otherwise the entire transcript gets re-pasted on every event.
const previousFinalRef = useRef<string>('');
useEffect(() => {
const prev = previousFinalRef.current;
@@ -385,7 +385,7 @@ function ComposeDialogBody({
const seed = TEMPLATE_SEEDS[t];
setChannel(seed.channel);
setDirection(seed.direction);
// Don't clobber if the rep already typed something append a divider
// Don't clobber if the rep already typed something - append a divider
// so the template scaffolds the *next* block.
setSummary((cur) => (cur.trim().length === 0 ? seed.summary : `${cur}\n\n${seed.summary}`));
setTemplateUsed(t);

View File

@@ -22,6 +22,10 @@ import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upl
import { MarkExternallySignedDialog } from '@/components/interests/mark-externally-signed-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import {
CancelDocumentDialog,
type CancelMode,
} from '@/components/documents/cancel-document-dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -55,6 +59,8 @@ interface DocumentSigner {
signingOrder: number;
status: string;
signedAt?: string | null;
invitedAt?: string | null;
signingUrl?: string | null;
}
const STATUS_LABELS = DOCUMENT_STATUS_LABELS;
@@ -75,13 +81,13 @@ const ACTIVE_STATUSES = DOCUMENT_STATUS_ACTIVE;
/**
* Dedicated Contract workspace tab. Mirrors the EOI tab pattern but
* for sales contracts. Contracts differ from EOIs in that there's no
* standard Documenso template each contract is drafted custom per
* standard Documenso template - each contract is drafted custom per
* deal. So the active flows are:
*
* 1. **Upload paper-signed copy** the signed contract was handled
* 1. **Upload paper-signed copy** - the signed contract was handled
* outside the system; rep uploads the PDF for the record.
*
* 2. **Upload draft for Documenso signing** rep uploads the PDF
* 2. **Upload draft for Documenso signing** - rep uploads the PDF
* draft, configures signers + signing order + signature field
* placement, then sends via Documenso. (Recipient configurator
* and field-placement UI are the bigger pieces; for v1 a default
@@ -213,7 +219,7 @@ function ActiveContractCard({
onUploadSigned: () => void;
}) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const { dialog: confirmDialog } = useConfirmation();
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'],
@@ -226,11 +232,24 @@ function ActiveContractCard({
const totalCount = signers.length;
const allSigned = totalCount > 0 && signedCount === totalCount;
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }),
onSuccess: () => {
mutationFn: (params: { cancelMode: CancelMode; reason: string }) =>
apiFetch(`/api/v1/documents/${doc.id}/cancel`, {
method: 'POST',
body: {
cancelMode: params.cancelMode,
...(params.reason ? { reason: params.reason } : {}),
},
}),
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success('Contract cancelled.');
toast.success(
vars.cancelMode === 'keep_remote'
? 'Contract cancelled. Envelope kept on Documenso for audit.'
: 'Contract cancelled.',
);
setCancelDialogOpen(false);
},
onError: (err) => toastError(err),
});
@@ -309,7 +328,8 @@ function ActiveContractCard({
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" aria-hidden />
Reminders are rate-limited (max once per 7 days per signer).
Manual reminders are rate-limited by Documenso (max once per 7 days per signer). Automatic
follow-ups run on the configured cadence and are not throttled by us.
</p>
<div className="flex items-center gap-1">
<Button
@@ -327,14 +347,7 @@ function ActiveContractCard({
variant="ghost"
size="sm"
disabled={cancelMutation.isPending}
onClick={async () => {
const ok = await confirm({
title: 'Cancel contract',
description: 'Signers will no longer be able to sign.',
confirmLabel: 'Cancel contract',
});
if (ok) cancelMutation.mutate();
}}
onClick={() => setCancelDialogOpen(true)}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
@@ -342,6 +355,13 @@ function ActiveContractCard({
</Button>
</div>
</footer>
<CancelDocumentDialog
open={cancelDialogOpen}
onOpenChange={setCancelDialogOpen}
documentLabel="Contract"
isSubmitting={cancelMutation.isPending}
onConfirm={(params) => cancelMutation.mutate(params)}
/>
{confirmDialog}
</section>
);

View File

@@ -106,13 +106,13 @@ interface InterestDetailHeaderProps {
eoiDocStatus?: string | null;
reservationDocStatus?: string | null;
contractDocStatus?: string | null;
/** Activity-log entries in the last 7 days drives deal-pulse +5 signal. */
/** Activity-log entries in the last 7 days - drives deal-pulse +5 signal. */
recentActivityCount?: number | null;
/** Phase 2 risk-signal dates fed into DealPulseChip. */
dateDocumentDeclined?: string | Date | null;
dateReservationCancelled?: string | Date | null;
dateBerthSoldToOther?: string | Date | null;
/** Sales rep who owns this deal populated by the AssignedToChip. */
/** Sales rep who owns this deal - populated by the AssignedToChip. */
assignedTo?: string | null;
assignedToName?: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
@@ -160,9 +160,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
// F26: confirm to the user that the action ran pre-fix the
// F26: confirm to the user that the action ran - pre-fix the
// button gave no feedback and reps weren't sure if it took.
toast.success('Outcome cleared interest is open again.');
toast.success('Outcome cleared - interest is open again.');
},
});

View File

@@ -44,7 +44,7 @@ interface InterestData {
} | null;
berthId: string | null;
berthMooringNumber: string | null;
/** Linked yacht null until the rep ties one to the deal. Required to
/** Linked yacht - null until the rep ties one to the deal. Required to
* leave Enquiry; surfaced inline in the stage picker as a prereq. */
yachtId: string | null;
/** Yacht-fit dimensions (numeric strings from postgres). Drive the
@@ -97,7 +97,7 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
// F17: don't retry 404s they're intentional (wrong port, archived,
// F17: don't retry 404s - they're intentional (wrong port, archived,
// deleted). Let the error state render the EmptyState below.
retry: (failureCount, err) => {
const status = (err as { status?: number } | null | undefined)?.status;
@@ -124,7 +124,7 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
// Topbar breadcrumb: Clients Mary Smith Interest B17.
// Parent client links straight back to the client detail; the
// current crumb is the primary berth's mooring (or "Interest" if
// no berth linked yet same trick the page H1 uses).
// no berth linked yet - same trick the page H1 uses).
useBreadcrumbHint(
data
? {

View File

@@ -27,9 +27,9 @@ interface InterestData {
}
/**
* Documents tab legal instruments (EOI / contract / reservation) with
* Documents tab - legal instruments (EOI / contract / reservation) with
* full signing status, plus an Attachments section for any other file the
* rep wants on the deal. Replaces the standalone Files tab at the
* rep wants on the deal. Replaces the standalone Files tab - at the
* interest level virtually everything is either a legal doc or rare
* one-off, and a separate tab was dead weight 95% of the time.
*/
@@ -47,7 +47,7 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
// Files attach at the client level (the schema has no interest_id
// FK on `files`). For an interest, surface every file that belongs
// to its parent client covers the realistic case where a rep
// to its parent client - covers the realistic case where a rep
// uploaded a passport / scan / photo while working a deal.
// Until the interest record loads we pass a sentinel clientId so the
// server returns empty rather than the unscoped port-wide file list.

View File

@@ -27,6 +27,7 @@ import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { MarkExternallySignedDialog } from '@/components/interests/mark-externally-signed-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -105,6 +106,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [generateOpen, setGenerateOpen] = useState(false);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const [markSignedOpen, setMarkSignedOpen] = useState(false);
// Lifted preview state so the View button on every signed-PDF row opens
// the in-app preview dialog rather than navigating to a presigned URL
@@ -138,6 +140,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
) : (
<EmptyEoiState
onGenerate={() => setGenerateOpen(true)}
onUploadForSigning={() => setUploadForSigningOpen(true)}
onUploadSigned={() => setUploadSignedOpen(true)}
onMarkSigned={() => setMarkSignedOpen(true)}
/>
@@ -200,6 +203,19 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
interestId={interestId}
/>
{/* Phase 4 parity - same upload-PDF + place-fields wizard as
Contract/Reservation, scoped to documentType="eoi". The
template-driven generate flow lives on `EoiGenerateDialog`
above; this branch handles the custom-draft path. */}
{uploadForSigningOpen && (
<UploadForSigningDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
interestId={interestId}
documentType="eoi"
/>
)}
<MarkExternallySignedDialog
open={markSignedOpen}
onOpenChange={setMarkSignedOpen}
@@ -242,7 +258,7 @@ function ActiveEoiCard({
// Polling backstop in case a webhook event misses the open browser
// (transient socket drop, user in a different tab when the event
// fires, cloudflared tunnel hiccup). Primary update path is
// socket-driven via `useRealtimeInvalidation` below this just
// socket-driven via `useRealtimeInvalidation` below - this just
// bounds the worst-case staleness to ~5s.
refetchInterval: 5_000,
});
@@ -268,7 +284,7 @@ function ActiveEoiCard({
const allSigned = totalCount > 0 && signedCount === totalCount;
// Treat "all signers complete" as the finalised UX even when the
// DOCUMENT_COMPLETED webhook hasn't landed yet defends against the
// DOCUMENT_COMPLETED webhook hasn't landed yet - defends against the
// gap between the last per-recipient sign event and the document-level
// completion event. The badge below flips to "Finalising" so the rep
// sees the in-flight state rather than a stale PARTIALLY_SIGNED chip.
@@ -287,7 +303,7 @@ function ActiveEoiCard({
'document:rejected': [['documents', doc.id, 'signers'], ['documents']],
});
// §4.13: surface the rejection callout in a high-visibility banner
// §4.13: surface the rejection callout in a high-visibility banner -
// status pill alone doesn't communicate that the doc is dead and the
// rep must take action.
const isRejected = doc.status === 'rejected' || doc.status === 'declined';
@@ -448,7 +464,8 @@ function ActiveEoiCard({
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" aria-hidden />
Reminders are rate-limited (max once per 7 days per signer).
Manual reminders are rate-limited by Documenso (max once per 7 days per signer).
Automatic follow-ups run on the configured cadence and are not throttled by us.
</p>
<div className="flex items-center gap-1">
<Button
@@ -535,7 +552,7 @@ function SignedPdfPreview({ fileId }: { fileId: string }) {
queryKey: ['files', fileId, 'download-url'],
queryFn: () =>
apiFetch<{ data: { url: string; filename: string } }>(`/api/v1/files/${fileId}/download`),
// Presigned URL TTLs vary per backend refresh well before they
// Presigned URL TTLs vary per backend - refresh well before they
// expire so a long-open card doesn't suddenly 403. 4 minutes is
// comfortably below the 5-minute MinIO default.
staleTime: 4 * 60_000,
@@ -568,10 +585,12 @@ function SignedPdfPreview({ fileId }: { fileId: string }) {
function EmptyEoiState({
onGenerate,
onUploadForSigning,
onUploadSigned,
onMarkSigned,
}: {
onGenerate: () => void;
onUploadForSigning: () => void;
onUploadSigned: () => void;
onMarkSigned: () => void;
}) {
@@ -584,14 +603,18 @@ function EmptyEoiState({
No EOI in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Generate the EOI to send it for signing. The signing service handles the signing chain. You
can also upload a paper-signed copy if it was signed outside the system.
Generate the EOI from the template, upload a custom draft and place signing fields, or
upload a paper-signed copy if it was signed outside the system.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onGenerate} size="sm" className="gap-1.5">
<FileSignature className="size-4" aria-hidden />
Generate EOI
</Button>
<Button onClick={onUploadForSigning} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" aria-hidden />
Upload draft for signing
</Button>
<Button onClick={onUploadSigned} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" aria-hidden />
Upload paper-signed copy

View File

@@ -128,7 +128,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
// Auto-fill pipelineStage + leadCategory based on whether a berth was
// picked. Once the rep manually edits either field we stop touching it,
// so we don't fight the user. Edit mode skips the auto-fill entirely
// so we don't fight the user. Edit mode skips the auto-fill entirely -
// changing the berth on an in-flight interest shouldn't silently demote
// it back to "enquiry".
const userTouchedStage = useRef(false);
@@ -177,7 +177,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
// reps don't get stuck on an empty dropdown wondering what to do. We hit
// the same autocomplete endpoint the picker uses but with an empty query
// to get the full unfiltered list scoped to the owner filter.
// Tags-availability probe drives whether the whole Tags section
// Tags-availability probe - drives whether the whole Tags section
// (label + picker) renders. The picker itself returns null when empty,
// but the wrapping label/separator needed the same gate.
const { data: tagsList } = useQuery<{ data: Array<{ id: string }> }>({
@@ -292,7 +292,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
// Materialise any additional berths the rep picked in the multi-
// select. The first (primary) berth is already linked via the create
// payload's berthId; everything else gets a follow-up POST to the
// junction endpoint. We fire them in parallel failure on one is
// junction endpoint. We fire them in parallel - failure on one is
// surfaced as a toast but doesn't roll back the interest creation.
if (additionalBerthIds.length > 0) {
await Promise.allSettled(
@@ -312,7 +312,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
toast.success(result.created ? 'Interest created' : 'Interest updated');
onOpenChange(false);
// F20: navigate to the new interest's detail page so the rep can
// start the workflow immediately. Edits stay in place no point
// start the workflow immediately. Edits stay in place - no point
// re-loading the same row's detail page they just came from.
if (result.created && portSlug) {
router.push(`/${portSlug}/interests/${result.id}` as never);
@@ -335,7 +335,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
// gets a follow-up POST /interests/{id}/berths so they show up in the
// linked-berths list with isPrimary=false. The primary berth (the form's
// `berthId`) is materialised by the standard create path. Edit mode
// doesn't surface this managing extra berths post-create happens on
// doesn't surface this - managing extra berths post-create happens on
// the interest detail page's linked-berths section.
const [additionalBerthIds, setAdditionalBerthIds] = useState<string[]>([]);
@@ -553,7 +553,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</div>
{/* Hide the picker entirely when the selected client has no
yachts on file (and isn't linked to a company with yachts).
An empty dropdown is a dead-end UX the only useful action
An empty dropdown is a dead-end UX - the only useful action
in that state is "create a yacht for this client". */}
{selectedClientId && !hasAnyYachts ? (
<div className="rounded-md border border-dashed bg-muted/40 p-3 text-sm">
@@ -736,7 +736,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
)}
</div>
{/* Tags TagPicker itself returns null when the port has no tags
{/* Tags - TagPicker itself returns null when the port has no tags
configured AND the form has nothing selected. We hide the
wrapping label + separator in that same case so an empty
"Tags" header doesn't sit in the form. */}

View File

@@ -79,7 +79,7 @@ export function InterestList() {
}, [setChrome]);
// Force the list view at mobile widths even when the user previously
// toggled the kanban from desktop the board is desktop-only.
// toggled the kanban from desktop - the board is desktop-only.
useEffect(() => {
if (typeof window === 'undefined') return;
if (viewMode === 'board' && window.innerWidth < 640) setViewMode('table');
@@ -135,7 +135,7 @@ export function InterestList() {
},
});
// Single bulk endpoint replaces the prior parallel fan-out gives
// Single bulk endpoint replaces the prior parallel fan-out - gives
// the user a per-row failure summary and shares one server-side
// permission check.
const bulkMutation = useMutation({
@@ -155,7 +155,7 @@ export function InterestList() {
const s = res.data.summary;
if (s.failed > 0) {
toast.warning(
`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed check the activity log.`,
`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed - check the activity log.`,
);
}
},
@@ -167,7 +167,7 @@ export function InterestList() {
onArchive: (interest) => setArchiveInterest(interest),
});
// Persisted per-user column visibility same pattern as ClientList.
// Persisted per-user column visibility - same pattern as ClientList.
// The hidden array is the source of truth; built columns stay
// declared and we drive table visibility via columnVisibility.
const { hidden, setHidden } = useTablePreferences('interests', INTEREST_DEFAULT_HIDDEN);
@@ -181,7 +181,7 @@ export function InterestList() {
variant="gradient"
actions={
<div className="flex items-center gap-2">
{/* Kanban view is desktop-only mobile drops the toggle and
{/* Kanban view is desktop-only - mobile drops the toggle and
falls back to the list/cards view (the board's column
horizontal-scroll model is unusable at phone widths). */}
<div
@@ -223,7 +223,7 @@ export function InterestList() {
<div className="flex flex-wrap items-center gap-2">
{/* On the kanban view we strip filters that don't make sense
* there: `pipelineStage` (the columns ARE the stages) and
* `includeArchived` (the board is for active deals the
* `includeArchived` (the board is for active deals - the
* list view is the place to see history). The board endpoint
* rejects these via boardFiltersSchema if they're sent. */}
<FilterBar
@@ -238,7 +238,7 @@ export function InterestList() {
onChange={setFilter}
onClear={clearFilters}
/>
{/* Tag picker primary use case is filtering by event/yacht-show
{/* Tag picker - primary use case is filtering by event/yacht-show
* ("Palm Beach 2026") that the rep tagged interests with at the
* show. The validator already accepts `tagIds` on listInterests;
* this surfaces the input in the filter UI. */}

View File

@@ -59,7 +59,7 @@ export function InterestPicker({
if (!value) return placeholder;
const match = options.find((o) => o.id === value);
if (!match) return `Interest ${value.slice(0, 8)}`;
if (match.clientName) return `${match.clientName} ${match.pipelineStage ?? 'open'}`;
if (match.clientName) return `${match.clientName} - ${match.pipelineStage ?? 'open'}`;
return `Interest ${match.id.slice(0, 8)}`;
})();

View File

@@ -22,6 +22,10 @@ import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upl
import { MarkExternallySignedDialog } from '@/components/interests/mark-externally-signed-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import {
CancelDocumentDialog,
type CancelMode,
} from '@/components/documents/cancel-document-dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -55,6 +59,8 @@ interface DocumentSigner {
signingOrder: number;
status: string;
signedAt?: string | null;
invitedAt?: string | null;
signingUrl?: string | null;
}
const STATUS_LABELS = DOCUMENT_STATUS_LABELS;
@@ -75,13 +81,13 @@ const ACTIVE_STATUSES = DOCUMENT_STATUS_ACTIVE;
/**
* Dedicated Reservation workspace tab. Mirrors the EOI tab pattern but
* for reservation agreements. Contracts differ from EOIs in that there's no
* standard Documenso template each reservation is drafted custom per
* standard Documenso template - each reservation is drafted custom per
* deal. So the active flows are:
*
* 1. **Upload paper-signed copy** the signed reservation was handled
* 1. **Upload paper-signed copy** - the signed reservation was handled
* outside the system; rep uploads the PDF for the record.
*
* 2. **Upload draft for Documenso signing** rep uploads the PDF
* 2. **Upload draft for Documenso signing** - rep uploads the PDF
* draft, configures signers + signing order + signature field
* placement, then sends via Documenso. (Recipient configurator
* and field-placement UI are the bigger pieces; for v1 a default
@@ -209,7 +215,7 @@ function ActiveReservationCard({
onUploadSigned: () => void;
}) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const { dialog: confirmDialog } = useConfirmation();
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'],
@@ -222,11 +228,24 @@ function ActiveReservationCard({
const totalCount = signers.length;
const allSigned = totalCount > 0 && signedCount === totalCount;
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }),
onSuccess: () => {
mutationFn: (params: { cancelMode: CancelMode; reason: string }) =>
apiFetch(`/api/v1/documents/${doc.id}/cancel`, {
method: 'POST',
body: {
cancelMode: params.cancelMode,
...(params.reason ? { reason: params.reason } : {}),
},
}),
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success('Reservation cancelled.');
toast.success(
vars.cancelMode === 'keep_remote'
? 'Reservation cancelled. Envelope kept on Documenso for audit.'
: 'Reservation cancelled.',
);
setCancelDialogOpen(false);
},
onError: (err) => toastError(err),
});
@@ -305,7 +324,8 @@ function ActiveReservationCard({
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" aria-hidden />
Reminders are rate-limited (max once per 7 days per signer).
Manual reminders are rate-limited by Documenso (max once per 7 days per signer). Automatic
follow-ups run on the configured cadence and are not throttled by us.
</p>
<div className="flex items-center gap-1">
<Button
@@ -323,21 +343,21 @@ function ActiveReservationCard({
variant="ghost"
size="sm"
disabled={cancelMutation.isPending}
onClick={async () => {
const ok = await confirm({
title: 'Cancel contract',
description: 'Signers will no longer be able to sign.',
confirmLabel: 'Cancel contract',
});
if (ok) cancelMutation.mutate();
}}
onClick={() => setCancelDialogOpen(true)}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel contract
Cancel reservation
</Button>
</div>
</footer>
<CancelDocumentDialog
open={cancelDialogOpen}
onOpenChange={setCancelDialogOpen}
documentLabel="Reservation"
isSubmitting={cancelMutation.isPending}
onConfirm={(params) => cancelMutation.mutate(params)}
/>
{confirmDialog}
</section>
);

View File

@@ -23,7 +23,7 @@ import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/fiel
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RemindersInline } from '@/components/reminders/reminders-inline';
// Legacy `RecommendationList` removed 2026-05-15 replaced by the same
// Legacy `RecommendationList` removed 2026-05-15 - replaced by the same
// rule-based `BerthRecommenderPanel` (already imported above) used on the
// Overview tab so the scoring + UI stay consistent. The old component
// pulled stale "AI"-style rows that all scored 50% because the underlying
@@ -111,7 +111,7 @@ interface InterestTabsOptions {
desiredLengthM?: string | null;
desiredWidthM?: string | null;
desiredDraftM?: string | null;
/** Unit the rep originally entered the dims in drives the
/** Unit the rep originally entered the dims in - drives the
* recommender header's display so a metric-entered deal doesn't
* render as ft. The three columns share an entry unit in practice. */
desiredLengthUnit?: string | null;
@@ -125,25 +125,25 @@ interface InterestTabsOptions {
* auto-advance once payment totals catch up. */
depositExpectedAmount?: string | null;
depositExpectedCurrency?: string | null;
/** Doc-bearing stage sub-status badges drive the milestone past/current
/** Doc-bearing stage sub-status badges - drive the milestone past/current
* classification independently of the pipeline stage. NULL until the
* matching stage is reached. */
eoiDocStatus?: string | null;
reservationDocStatus?: string | null;
contractDocStatus?: string | null;
/** Final outcome 'won' surfaces the wrap-up checklist panel. */
/** Final outcome - 'won' surfaces the wrap-up checklist panel. */
outcome?: string | null;
/** Interest id needed for the queryClient.invalidateQueries calls
/** Interest id - needed for the queryClient.invalidateQueries calls
* that fire after an inline contact edit. The parent passes this
* through `interestId` already, but the inline-edit handlers below
* use the structured object form. */
id: string;
/** Linked client id required for the PATCH /api/v1/clients/[id]/
/** Linked client id - required for the PATCH /api/v1/clients/[id]/
* contacts/[contactId] flow that the inline Email + Phone editors
* use. Null on an unlinked interest (rare but possible). */
clientId: string | null;
/** Primary contact channels resolved from the linked client record by
* getInterestById both editable inline. The contact row's id is
* getInterestById - both editable inline. The contact row's id is
* exposed alongside so the inline editor can PATCH the right row
* without an extra fetch. */
clientPrimaryEmail?: string | null;
@@ -163,7 +163,7 @@ interface InterestTabsOptions {
reminderEnabled: boolean;
reminderDays: number | null;
reminderLastFired: string | null;
/** Count of berths linked via the interest_berths junction
/** Count of berths linked via the interest_berths junction -
* drives the "Berth Interest" milestone on the Overview tab. */
linkedBerthCount?: number;
notes: string | null;
@@ -391,7 +391,7 @@ function MilestoneAdvanceButton({
* Skip-ahead backfill control: shown next to past milestones whose
* date column is null. Opens the same date popover as
* MilestoneAdvanceButton but PATCHes the date column directly without
* triggering a stage transition the stage was already advanced
* triggering a stage transition - the stage was already advanced
* manually upstream.
*/
function MilestoneBackfillButton({
@@ -640,7 +640,7 @@ function OverviewTab({
const portSlug = params?.portSlug ?? '';
// QueryClient lifted to the top of the tab so the inline-edit email +
// Lift the EOI generate dialog into the Overview so the milestone card
// can launch it inline same dialog the dedicated EOI tab uses, so the
// can launch it inline - same dialog the dedicated EOI tab uses, so the
// editing/confirmation flow is identical regardless of entry point.
const [eoiGenerateOpen, setEoiGenerateOpen] = useState(false);
const mutation = useInterestPatch(interestId);
@@ -681,14 +681,14 @@ function OverviewTab({
};
// Determine each milestone's phase relative to the current pipeline
// stage. The overview hides future-phase milestones by default it
// stage. The overview hides future-phase milestones by default - it
// was visually noisy to see Deposit + Contract cards on a deal still
// at the EOI stage, and the empty cards invited mis-clicks.
//
// Past milestones still render (collapsed history) so reps can see
// what's been completed. Future milestones are gated behind a "Show
// upcoming milestones" toggle so the rep CAN reach them when a deal
// genuinely skips stages the click then routes through the same
// genuinely skips stages - the click then routes through the same
// override-confirm flow as the inline stage picker.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
@@ -707,7 +707,7 @@ function OverviewTab({
// pipeline-stage column happens to sit. The previous "phase === current
// only when stageIdx exactly matches" rule produced an empty Overview
// for the qualified + nurturing stages (no milestone marked current, EOI
// hidden under "show upcoming") exactly the gap the rep complained
// hidden under "show upcoming") - exactly the gap the rep complained
// about. New model: the FIRST not-yet-complete milestone in the fixed
// berth_interest → eoi → reservation → deposit → contract order is
// 'current'. Everything before is 'past'; everything after is 'future'.
@@ -727,7 +727,7 @@ function OverviewTab({
// the deal's current pipelineStage column. When the rep manually
// jumps the stage forward (Reservation+) but earlier sub-statuses
// are still un-signed, we need the current-stage milestone to stay
// marked `'current'` regardless of completion otherwise EOI gets
// marked `'current'` regardless of completion - otherwise EOI gets
// flagged as NEXT STEP and the actual current stage hides under
// "Upcoming milestones". Earlier-than-stage milestones go to
// `'past'` so the rep can render backfill controls against them.
@@ -749,7 +749,7 @@ function OverviewTab({
if (k === firstIncompleteKey) return 'current';
return 'future';
}
// A stage DOES own a different milestone bucket by position
// A stage DOES own a different milestone - bucket by position
// relative to it. Earlier slots go to `past` even if incomplete
// (the backfill controls live there); later slots go to `future`.
const idx = order.indexOf(k);
@@ -797,12 +797,12 @@ function OverviewTab({
phase: berthInterestPhase,
title: 'Berth Interest',
icon: Anchor,
// No status badge the count IS the status. Showing "0 berths"
// No status badge - the count IS the status. Showing "0 berths"
// would just duplicate the empty-state copy below.
status: hasLinkedBerth
? `${interest.linkedBerthCount} berth${(interest.linkedBerthCount ?? 0) === 1 ? '' : 's'}`
: null,
// No advanceStage step the milestone tracks a state (berths
// No advanceStage step - the milestone tracks a state (berths
// linked) rather than a stage transition. Hide the row chrome by
// passing an empty steps array; the footer renders the action.
steps: [],
@@ -846,8 +846,8 @@ function OverviewTab({
// When the EOI milestone is the active next step but nothing's been
// sent yet, surface the actual generation entry points instead of
// making the rep navigate to the EOI tab first. Mirrors the EOI
// tab's Generate flow exactly same dialog component, same
// confirmation step so behaviour stays consistent.
// tab's Generate flow exactly - same dialog component, same
// confirmation step - so behaviour stays consistent.
footer:
eoiPhase === 'current' && !interest.dateEoiSent ? (
<div className="flex flex-wrap items-center gap-2 pt-1">
@@ -1062,7 +1062,7 @@ function OverviewTab({
</AccordionTrigger>
<AccordionContent>
{/* Reuse the same MilestoneSection layout used for the
current milestone the steps list, sub-status badge,
current milestone - the steps list, sub-status badge,
and any inline doc actions all render the same way.
`isActive={false}` keeps the NEXT-STEP pill off. */}
<MilestoneSection
@@ -1329,7 +1329,7 @@ function OverviewTab({
stands today; reading it on Overview, "current stage"
answers the implicit "where in the deal is this?". A
historical "stage-at-note-time" lookup would need an
audit_logs read per teaser render over-engineered for
audit_logs read per teaser render - over-engineered for
a context hint. */}
<span
className={cn(
@@ -1424,7 +1424,7 @@ export function getInterestTabs({
clientId = null,
interest,
}: InterestTabsOptions): DetailTab[] {
// The EOI / Contract / Reservation tabs are stage-conditional
// The EOI / Contract / Reservation tabs are stage-conditional -
// each appears only at the stages where the rep is likely to act
// on it. Hides clutter from later-stage deals where earlier docs
// are ancient history. Each tab still queries for its own past
@@ -1437,7 +1437,7 @@ export function getInterestTabs({
const contractIdx = PIPELINE_STAGES.indexOf('contract');
// EOI: from qualified through contract (the deal's whole life past lead-only).
const showEoiTab = stageIdx >= qualifiedIdx;
// Reservation: once the EOI is signed onward the reservation agreement
// Reservation: once the EOI is signed onward - the reservation agreement
// is the v1 step between EOI and deposit. Stays visible through contract
// so the rep can re-open the signed reservation later.
const showReservationTab = stageIdx >= reservationIdx;

View File

@@ -115,7 +115,7 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
const isLast = idx === events.length - 1;
return (
<div key={event.id} className="relative flex gap-4 pb-6">
{/* Vertical line only between bubbles, never trailing past the last. */}
{/* Vertical line - only between bubbles, never trailing past the last. */}
{!isLast && (
<span
aria-hidden

View File

@@ -1,10 +1,10 @@
'use client';
/**
* Linked-berths list plan §5.5.
* Linked-berths list - plan §5.5.
*
* Shows every berth currently linked to the interest with per-row controls:
* - "Specifically pitching" toggle (`is_specific_interest`) drives the
* - "Specifically pitching" toggle (`is_specific_interest`) - drives the
* public-map "Under Offer" sub-status. Each state surfaces its consequence
* in plain text below the toggle.
* - "Mark in EOI bundle" toggle (`is_in_eoi_bundle`).
@@ -13,7 +13,7 @@
* - "Bypass EOI for this berth" with a reason textarea. Only rendered when
* the parent interest's `eoiStatus === 'signed'`. Writes
* `eoi_bypass_reason`, `eoi_bypassed_by`, `eoi_bypassed_at`.
* - "Remove" calls `removeInterestBerth`.
* - "Remove" - calls `removeInterestBerth`.
*/
import { useMemo, useState } from 'react';
@@ -51,7 +51,7 @@ import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
// ─── Types (mirror the API GET shape see interest-berths.service.ts) ─────
// ─── Types (mirror the API GET shape - see interest-berths.service.ts) ─────
export interface LinkedBerthRow {
id: string;
@@ -266,7 +266,7 @@ interface RowProps {
onUpdate: (berthId: string, patch: PatchPayload) => void;
onRemove: (berthId: string) => void;
isPending: boolean;
/** When true, this is the deal berth render with elevated styling. */
/** When true, this is the deal berth - render with elevated styling. */
highlight?: boolean;
}
@@ -520,7 +520,7 @@ interface AddBerthDialogProps {
open: boolean;
onOpenChange: (next: boolean) => void;
interestId: string;
/** IDs already linked filtered out of the picker so the rep can't double-add. */
/** IDs already linked - filtered out of the picker so the rep can't double-add. */
excludeIds: Set<string>;
}
@@ -651,11 +651,11 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
const linkedBerthIds = useMemo(() => new Set(rows.map((r) => r.berthId)), [rows]);
// Three-bucket split per the Deal-berth + Bundle model:
// • dealBerth: the single is_primary row the one templates/EOI
// • dealBerth: the single is_primary row - the one templates/EOI
// resolve through ("the berth for this deal").
// • bundleRows: in EOI bundle but not primary.
// • exploringRows: everything else (also-considering, internal-only links).
// The same row never appears in two buckets primary takes precedence,
// The same row never appears in two buckets - primary takes precedence,
// then bundle, then exploring.
const dealBerth = rows.find((r) => r.isPrimary) ?? null;
const bundleRows = rows.filter((r) => !r.isPrimary && r.isInEoiBundle);
@@ -759,7 +759,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
}
/** Section header + body wrapper for the three-bucket layout. Kept inline
* because it's only used here promoting it to /shared isn't worth the
* because it's only used here - promoting it to /shared isn't worth the
* indirection for a card-header + a help line. */
function BerthSection({
title,

View File

@@ -43,7 +43,7 @@ export function MultiEoiChip({ interestId }: { interestId: string }) {
return (
<span
title={`This interest has ${inflight.length} in-flight EOI documents review on the EOI tab.`}
title={`This interest has ${inflight.length} in-flight EOI documents - review on the EOI tab.`}
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
>
<FileSignature className="size-3" aria-hidden />

View File

@@ -65,7 +65,7 @@ function formatMoney(amount: string, currency: string): string {
if (!Number.isFinite(n)) return `${amount} ${currency}`;
try {
// `undefined` locale honours the user's browser locale. The
// previous `'en-EU'` literal is not a valid BCP-47 tag every
// previous `'en-EU'` literal is not a valid BCP-47 tag - every
// implementation falls back to the default anyway.
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(n);
} catch {

View File

@@ -26,7 +26,7 @@ interface BoardResponse {
}
interface PipelineBoardProps {
/** Filter values from the parent's FilterBar passed through to the
/** Filter values from the parent's FilterBar - passed through to the
* /api/v1/interests/board endpoint. Subset of listInterests filters
* (no pipelineStage, no includeArchived). Optional; board works
* fine without filters. */
@@ -40,7 +40,7 @@ export function PipelineBoard({ filters }: PipelineBoardProps = {}) {
// Build the board endpoint URL with the supported filter subset.
// pipelineStage + includeArchived are intentionally not threaded
// through see boardFiltersSchema on the backend. Stable JSON-string
// through - see boardFiltersSchema on the backend. Stable JSON-string
// form is reused as the queryKey so React Query caches per filter combo.
const queryString = useMemo(() => {
if (!filters) return '';
@@ -65,7 +65,7 @@ export function PipelineBoard({ filters }: PipelineBoardProps = {}) {
}, [filters]);
const boardQueryKey = ['interests-board', portSlug, queryString] as const;
// Dedicated board endpoint bypasses the paginated list's max(100)
// Dedicated board endpoint - bypasses the paginated list's max(100)
// cap, projects only the 5 fields PipelineCard renders, and hard-caps
// at 5000 server-side. If `truncated: true`, surface a banner so the
// rep knows the board isn't showing every active deal.

View File

@@ -92,7 +92,7 @@ export function QualificationChecklist({
const fullyQualified = data.data.fullyQualified;
const showPromoteHint = fullyQualified && currentStage === 'enquiry';
// Auto-collapse when fully confirmed rep can expand to inspect.
// Auto-collapse when fully confirmed - rep can expand to inspect.
// Force-expanded whenever there's still an outstanding item.
const expanded = manuallyExpanded || !fullyQualified;
// Avoid referencing `params` in the JSX so the unused destructure passes
@@ -159,7 +159,7 @@ export function QualificationChecklist({
<Checkbox
id={`qual-${c.key}`}
checked={c.confirmed}
// Auto-satisfied rows can't be unchecked from the UI the
// Auto-satisfied rows can't be unchecked from the UI - the
// underlying data signal would just re-tick the box on the next
// refetch. The rep clears the dimensions tick by removing the
// yacht dims or desired-berth dims from the interest.

View File

@@ -26,7 +26,7 @@ interface SkipAheadInterest {
* deposit_paid with no dateEoiSent looks like a 0-day-EOI in the report,
* which skews the cohort.
*
* The banner is informational only no enforcement. Reps still have the
* The banner is informational only - no enforcement. Reps still have the
* override path; we just nudge them to fill in the gaps.
*/
export function SkipAheadBanner({ interest }: { interest: SkipAheadInterest }) {

View File

@@ -9,7 +9,7 @@ import type { PipelineStage } from '@/lib/constants';
interface StageGuidanceCardProps {
/** Current pipeline stage. Drives the per-stage copy + actions. */
stage: PipelineStage;
/** Callbacks for each shortcut action. Optional if absent, the
/** Callbacks for each shortcut action. Optional - if absent, the
* corresponding button is hidden. */
onScrollToRecommender?: () => void;
onOpenEoiGenerate?: () => void;
@@ -30,7 +30,7 @@ interface StageCopy {
}
/**
* §7.2 Stage-aware "what to do next" prompt that sits on the Overview
* §7.2 - Stage-aware "what to do next" prompt that sits on the Overview
* tab in the spot the Payments section occupies post-reservation. Each
* pre-deposit stage gets a one-card prompt + a shortcut button that
* jumps the rep to the right surface.
@@ -65,7 +65,7 @@ function copyFor(stage: PipelineStage, ctx: { hasLinkedBerth?: boolean }): Stage
return {
title: 'Stay in touch',
description:
'Deal is in nurture schedule a follow-up reminder or log a contact when the prospect re-engages, then move them back to Qualified.',
'Deal is in nurture - schedule a follow-up reminder or log a contact when the prospect re-engages, then move them back to Qualified.',
next: 'qualified',
};
case 'eoi':

View File

@@ -13,7 +13,7 @@ import { apiFetch } from '@/lib/api/client';
interface Props {
interestId: string;
/** Hide the button when EOI has already been sent / signed at that
/** Hide the button when EOI has already been sent / signed - at that
* point the supplemental step is past its window. Caller passes the
* current eoiStatus so we can render contextually. */
eoiStatus?: string | null;
@@ -45,14 +45,14 @@ interface TokenHistoryRow {
* copy-to-clipboard button in case the rep needs to share it through
* another channel.
*
* Hidden once the EOI is `signed` the supplemental step only makes
* Hidden once the EOI is `signed` - the supplemental step only makes
* 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
// 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'],
@@ -90,7 +90,7 @@ 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
// 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).
@@ -167,7 +167,7 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
) : null}
</div>
{/* Issuance history every past supplemental link for this
{/* 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