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:
@@ -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>
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.');
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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)}`;
|
||||
})();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user