feat(pipeline): 9→7 stage refactor + v1.1 hardening wave

Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 03:39:21 +02:00
parent b10bf9bf8e
commit 6b28459c45
110 changed files with 5402 additions and 796 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 is hidden from the public map."
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

@@ -0,0 +1,124 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { UserCircle2 } from 'lucide-react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
interface UserOption {
id: string;
displayName: string | null;
}
/**
* Click-to-edit ownership chip for the interest detail header. Stores the
* assignee's user-id on `interests.assigned_to`. The "Unassigned" path writes
* null so the chip falls back to a muted ghost state.
*/
export function AssignedToChip({
interestId,
currentAssignedTo,
currentAssignedToName,
}: {
interestId: string;
currentAssignedTo: string | null;
currentAssignedToName: string | null;
}) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const { data } = useQuery<{ data: UserOption[] }>({
queryKey: ['user-options'],
queryFn: () => apiFetch('/api/v1/admin/users/options'),
staleTime: 5 * 60_000,
enabled: open,
});
const users = data?.data ?? [];
const mutation = useMutation({
mutationFn: (next: string | null) =>
apiFetch(`/api/v1/interests/${interestId}`, {
method: 'PATCH',
body: { assignedTo: next },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setOpen(false);
},
onError: (err) => {
toastError(err);
},
});
const label = currentAssignedToName
? currentAssignedToName
: currentAssignedTo
? `User ${currentAssignedTo.slice(0, 8)}`
: 'Unassigned';
return (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`Change deal owner (currently ${label})`}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
currentAssignedTo
? 'border-sky-200 bg-sky-50 text-sky-800 hover:bg-sky-100'
: 'border-border bg-muted/50 text-muted-foreground hover:bg-muted',
)}
>
<UserCircle2 className="size-3" aria-hidden />
{label}
</button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command>
<CommandInput placeholder="Search users…" />
<CommandList>
<CommandEmpty>No users found.</CommandEmpty>
<CommandGroup heading="Assign to">
{users.map((u) => (
<CommandItem
key={u.id}
value={u.displayName ?? u.id}
onSelect={() => mutation.mutate(u.id)}
disabled={mutation.isPending}
>
{u.displayName ?? u.id.slice(0, 8)}
</CommandItem>
))}
</CommandGroup>
{currentAssignedTo ? (
<CommandGroup heading="Or">
<CommandItem
value="__unassign__"
onSelect={() => mutation.mutate(null)}
className="text-muted-foreground"
disabled={mutation.isPending}
>
Unassign
</CommandItem>
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -345,6 +345,10 @@ 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
// 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[]>([]);
const hasDimensions = desiredLengthFt !== null;
@@ -367,7 +371,27 @@ export function BerthRecommenderPanel({
staleTime: 60_000,
});
const recommendations = data ?? [];
const allRecommendations = data ?? [];
// Build the set of dock-letter chips from whatever came back, then
// filter the visible recommendations by the active selection. Empty
// selection = show everything (default).
const areaChips = useMemo(() => {
const set = new Set<string>();
for (const r of allRecommendations) {
const m = r.mooringNumber.match(/^([A-Z]+)/);
if (m?.[1]) set.add(m[1]);
}
return Array.from(set).sort();
}, [allRecommendations]);
const recommendations =
selectedAreas.length === 0
? allRecommendations
: allRecommendations.filter((r) => {
const m = r.mooringNumber.match(/^([A-Z]+)/);
return m?.[1] ? selectedAreas.includes(m[1]) : false;
});
return (
<Card>
@@ -410,6 +434,43 @@ export function BerthRecommenderPanel({
{filtersOpen && hasDimensions ? (
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} />
) : null}
{hasDimensions && areaChips.length > 1 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-xs font-medium text-muted-foreground">Area:</span>
{areaChips.map((letter) => {
const active = selectedAreas.includes(letter);
return (
<button
key={letter}
type="button"
onClick={() =>
setSelectedAreas((prev) =>
prev.includes(letter) ? prev.filter((l) => l !== letter) : [...prev, letter],
)
}
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
active
? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-background text-foreground hover:bg-muted',
)}
aria-pressed={active}
>
{letter}
</button>
);
})}
{selectedAreas.length > 0 ? (
<button
type="button"
onClick={() => setSelectedAreas([])}
className="text-xs text-muted-foreground underline ml-1"
>
Clear
</button>
) : null}
</div>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{!hasDimensions ? (

View File

@@ -0,0 +1,78 @@
'use client';
import { Activity } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
import { cn } from '@/lib/utils';
const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = {
hot: 'border-emerald-200 bg-emerald-50 text-emerald-800',
warm: 'border-amber-200 bg-amber-50 text-amber-800',
cold: 'border-rose-200 bg-rose-50 text-rose-800',
};
const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
hot: 'Hot',
warm: 'Warm',
cold: 'Cold',
};
/**
* Header chip surfacing the rule-based deal-health score. The tooltip
* exposes every signal that contributed to the score so the calculation is
* transparent — stakeholders averse to AI black boxes can read exactly
* which dates / stages drove the verdict.
*/
export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
// Closed / archived deals don't get a pulse — UX would be confusing.
if (interest.archivedAt || interest.outcome) return null;
const health = computeDealHealth(interest);
const tint = PULSE_TINT[health.pulse];
const label = PULSE_LABEL[health.pulse];
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium cursor-help',
tint,
)}
aria-label={`Deal pulse: ${label}, score ${health.score}/100`}
>
<Activity className="size-3" aria-hidden />
{label} · {health.score}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p className="font-semibold mb-1.5">
Deal pulse {label} ({health.score}/100)
</p>
{health.signals.length === 0 ? (
<p className="text-xs">
Baseline score (50) nothing notable yet. Log contact or progress the stage to move
the dial.
</p>
) : (
<ul className="space-y-1 text-xs">
{health.signals.map((s) => (
<li key={s.id} className="flex gap-2">
<span className={s.delta > 0 ? 'text-emerald-300' : 'text-rose-300'}>
{s.delta > 0 ? `+${s.delta}` : s.delta}
</span>
<span>{s.detail}</span>
</li>
))}
</ul>
)}
<p className="mt-2 text-[10px] opacity-70">
Rule-based. Every signal traces to a date or stage you can see no AI.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -121,11 +121,11 @@ export function InlineStagePicker({
setOpen(false);
return;
}
// Rewind-to-open guard: if the rep is dropping the stage back to
// 'open' AND the interest still has linked berths, intercept to ask
// Rewind-to-enquiry guard: if the rep is dropping the stage back to
// 'enquiry' AND the interest still has linked berths, intercept to ask
// whether to unlink them. Skipped when there are no linked berths
// (the prompt would be noise) or when the rep already came from open.
if (next === 'open' && stage !== 'open' && linkedBerthCount > 0) {
// (the prompt would be noise) or when the rep is already at enquiry.
if (next === 'enquiry' && stage !== 'enquiry' && linkedBerthCount > 0) {
setOpenConfirmTarget(next);
setOpen(false);
return;

View File

@@ -0,0 +1,76 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
interface BerthRow {
id: string;
mooringNumber: string;
status: string;
isPrimary: boolean;
}
interface BerthsResponse {
data: BerthRow[];
}
/**
* 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
* 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
* lost deals are noise.
*/
export function InterestBerthStatusBanner({
interestId,
interestPipelineStage,
interestOutcome,
archivedAt,
}: {
interestId: string;
interestPipelineStage: string;
interestOutcome?: string | null;
archivedAt?: string | null;
}) {
const { data } = useQuery<BerthsResponse>({
queryKey: ['interest-berths', interestId],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),
});
if (archivedAt || interestOutcome) return null;
// 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;
const berths = data?.data ?? [];
const conflicts = berths.filter((b) => b.status === 'sold' || b.status === 'under_offer');
if (conflicts.length === 0) return null;
return (
<div
role="status"
className="flex items-start gap-2 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-900"
>
<AlertTriangle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
<div>
<p className="font-medium">
{conflicts.length === 1
? `Berth ${conflicts[0]!.mooringNumber} is ${
conflicts[0]!.status === 'sold' ? 'Sold' : 'Under Offer'
} to another deal.`
: `${conflicts.length} linked berths are no longer freely available.`}
</p>
<p className="mt-0.5 text-rose-800">
You can still progress this interest as a backup, but the rep on the other deal owns the
primary path. If their deal falls through, this one can step in.
</p>
</div>
</div>
);
}

View File

@@ -1,12 +1,13 @@
'use client';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Bell,
CalendarDays,
Mail,
MessageCircle,
Mic,
MicOff,
MoreVertical,
Phone,
Pencil,
@@ -15,6 +16,8 @@ import {
Users,
Video,
} from 'lucide-react';
import { useVoiceTranscription } from '@/hooks/use-voice-transcription';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { toast } from 'sonner';
@@ -58,12 +61,16 @@ interface InterestContactLogTabProps {
type Channel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
type Direction = 'outbound' | 'inbound';
type Template = 'call' | 'visit' | 'email';
interface ContactLogEntry {
id: string;
occurredAt: string;
channel: Channel;
direction: Direction;
summary: string;
voiceTranscript: string | null;
templateUsed: string | null;
followUpAt: string | null;
reminderId: string | null;
createdBy: string;
@@ -71,10 +78,40 @@ interface ContactLogEntry {
updatedAt: string;
}
const CHANNEL_META: Record<Channel, { label: string; icon: typeof Phone; tone: string }> = {
/** 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,
{ channel: Channel; direction: Direction; summary: string; label: string; icon: ChannelIcon }
> = {
call: {
channel: 'phone',
direction: 'outbound',
summary: 'Called the client. Discussed:\n\n• \n\nNext step: ',
label: 'Call',
icon: Phone,
},
visit: {
channel: 'in_person',
direction: 'outbound',
summary: 'Met with the client in person. Discussed:\n\n• \n\nNext step: ',
label: 'Visit',
icon: Users,
},
email: {
channel: 'email',
direction: 'outbound',
summary: 'Emailed the client.\n\nTopic: \n\nResponse expected: ',
label: 'Email',
icon: Mail,
},
};
type ChannelIcon = React.ComponentType<{ className?: string }>;
const CHANNEL_META: Record<Channel, { label: string; icon: ChannelIcon; tone: string }> = {
email: { label: 'Email', icon: Mail, tone: 'bg-sky-100 text-sky-700' },
phone: { label: 'Phone', icon: Phone, tone: 'bg-emerald-100 text-emerald-700' },
whatsapp: { label: 'WhatsApp', icon: MessageCircle, tone: 'bg-emerald-100 text-emerald-700' },
whatsapp: { label: 'WhatsApp', icon: WhatsAppIcon, tone: 'bg-emerald-100 text-emerald-700' },
in_person: { label: 'In person', icon: Users, tone: 'bg-amber-100 text-amber-800' },
video: { label: 'Video', icon: Video, tone: 'bg-violet-100 text-violet-700' },
other: { label: 'Other', icon: CalendarDays, tone: 'bg-slate-100 text-slate-700' },
@@ -306,6 +343,44 @@ function ComposeDialogBody({
const [followUpAt, setFollowUpAt] = useState<string>(
existing?.followUpAt ? localIsoString(existing.followUpAt) : '',
);
const [templateUsed, setTemplateUsed] = useState<Template | null>(
(existing?.templateUsed as Template | undefined) ?? null,
);
// Voice transcript is captured separately so an edit to summary doesn't
// overwrite the rep's original raw utterance. Preserved on the row.
const [voiceTranscript, setVoiceTranscript] = useState<string>(existing?.voiceTranscript ?? '');
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.
const previousFinalRef = useRef<string>('');
useEffect(() => {
const prev = previousFinalRef.current;
if (voice.transcript === prev) return;
const added = voice.transcript.slice(prev.length).trim();
if (added.length === 0) {
previousFinalRef.current = voice.transcript;
return;
}
setSummary((prevSummary) => {
const sep =
prevSummary && !prevSummary.endsWith(' ') && !prevSummary.endsWith('\n') ? ' ' : '';
return prevSummary + sep + added;
});
setVoiceTranscript((prev2) => (prev2 ? `${prev2} ${added}` : added));
previousFinalRef.current = voice.transcript;
}, [voice.transcript]);
function applyTemplate(t: Template) {
const seed = TEMPLATE_SEEDS[t];
setChannel(seed.channel);
setDirection(seed.direction);
// 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);
}
const mutation = useMutation({
mutationFn: async () => {
@@ -314,6 +389,8 @@ function ComposeDialogBody({
channel,
direction,
summary,
voiceTranscript: voiceTranscript.trim().length > 0 ? voiceTranscript : null,
templateUsed,
followUpAt: followUpAt ? new Date(followUpAt).toISOString() : null,
};
if (isEdit) {
@@ -350,6 +427,35 @@ function ComposeDialogBody({
</DialogHeader>
<div className="space-y-3 py-1">
{/* Quick-template buttons. Tap one to seed the channel + direction
+ a starter summary so the rep can focus on the substance.
Hidden when editing — templates are a fresh-entry affordance. */}
{!isEdit ? (
<div className="flex flex-wrap gap-1.5">
{(Object.keys(TEMPLATE_SEEDS) as Template[]).map((t) => {
const seed = TEMPLATE_SEEDS[t];
const Icon = seed.icon;
const active = templateUsed === t;
return (
<button
key={t}
type="button"
onClick={() => applyTemplate(t)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors',
active
? 'border-sky-300 bg-sky-50 text-sky-800'
: 'border-border bg-muted/40 text-foreground hover:bg-muted',
)}
>
<Icon className="size-3" aria-hidden />
{seed.label}
</button>
);
})}
</div>
) : null}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="cl-channel">Channel</Label>
@@ -391,7 +497,44 @@ function ComposeDialogBody({
</div>
<div className="space-y-1">
<Label htmlFor="cl-summary">Summary</Label>
<div className="flex items-center justify-between">
<Label htmlFor="cl-summary">Summary</Label>
{voice.supported ? (
<button
type="button"
aria-label={
voice.isListening ? 'Stop voice transcription' : 'Start voice transcription'
}
onClick={() => (voice.isListening ? voice.stop() : voice.start())}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
voice.isListening
? 'border-rose-300 bg-rose-50 text-rose-800 animate-pulse'
: 'border-border bg-muted/40 text-muted-foreground hover:bg-muted',
)}
>
{voice.isListening ? (
<>
<Mic className="size-3" aria-hidden />
Recording
</>
) : (
<>
<MicOff className="size-3" aria-hidden />
Voice
</>
)}
</button>
) : (
<span
title="Voice transcription isn't supported in this browser."
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground"
>
<MicOff className="size-3" aria-hidden />
Voice unavailable
</span>
)}
</div>
<Textarea
id="cl-summary"
placeholder="e.g. Confirmed yacht size, asked about tax structure, said they'll respond after their accountant reviews."
@@ -399,6 +542,12 @@ function ComposeDialogBody({
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
{voice.isListening && voice.interim ? (
<p className="text-[11px] italic text-muted-foreground">{voice.interim}</p>
) : null}
{voice.error ? (
<p className="text-[11px] text-rose-700">Voice error: {voice.error}</p>
) : null}
</div>
<div className="space-y-2 rounded-md border bg-muted/30 p-3">

View File

@@ -10,10 +10,10 @@ import {
XCircle,
RefreshCcw,
Mail,
MessageCircle,
Phone,
AlarmClock,
} from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
@@ -25,6 +25,9 @@ import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { AssignedToChip } from '@/components/interests/assigned-to-chip';
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
import { apiFetch } from '@/lib/api/client';
import { formatOutcome } from '@/lib/constants';
import { cn } from '@/lib/utils';
@@ -86,6 +89,21 @@ interface InterestDetailHeaderProps {
outcome?: string | null;
outcomeReason?: string | null;
dateLastContact?: string | null;
dateFirstContact?: string | null;
dateEoiSent?: string | null;
dateEoiSigned?: string | null;
dateReservationSigned?: string | null;
dateContractSent?: string | null;
dateContractSigned?: string | null;
dateDepositReceived?: string | null;
eoiDocStatus?: string | null;
reservationDocStatus?: string | null;
contractDocStatus?: string | null;
/** Activity-log entries in the last 7 days — drives deal-pulse +5 signal. */
recentActivityCount?: number | null;
/** Sales rep who owns this deal — populated by the AssignedToChip. */
assignedTo?: string | null;
assignedToName?: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
};
}
@@ -235,6 +253,33 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
{interest.activeReminderCount}
</span>
) : null}
<PermissionGate resource="interests" action="edit">
<AssignedToChip
interestId={interest.id}
currentAssignedTo={interest.assignedTo ?? null}
currentAssignedToName={interest.assignedToName ?? null}
/>
</PermissionGate>
<MultiEoiChip interestId={interest.id} />
<DealPulseChip
interest={{
pipelineStage: interest.pipelineStage,
outcome: interest.outcome,
archivedAt: interest.archivedAt,
dateFirstContact: interest.dateFirstContact,
dateLastContact: interest.dateLastContact,
dateEoiSent: interest.dateEoiSent,
dateEoiSigned: interest.dateEoiSigned,
dateReservationSigned: interest.dateReservationSigned,
dateContractSent: interest.dateContractSent,
dateContractSigned: interest.dateContractSigned,
dateDepositReceived: interest.dateDepositReceived,
eoiDocStatus: interest.eoiDocStatus,
reservationDocStatus: interest.reservationDocStatus,
contractDocStatus: interest.contractDocStatus,
recentActivityCount: interest.recentActivityCount,
}}
/>
</div>
{meta.length > 0 ? (
@@ -311,7 +356,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
rel="noopener noreferrer"
aria-label={`Message on WhatsApp`}
>
<MessageCircle />
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>

View File

@@ -102,7 +102,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
defaultValues: {
clientId: '',
yachtId: undefined,
pipelineStage: 'open',
pipelineStage: 'enquiry',
reminderEnabled: false,
tagIds: [],
},
@@ -189,7 +189,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
reset({
clientId: defaultClientId ?? '',
yachtId: undefined,
pipelineStage: 'open',
pipelineStage: 'enquiry',
reminderEnabled: false,
tagIds: [],
});
@@ -389,7 +389,9 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Yacht</Label>
<Label>
Yacht <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
{selectedClientId && (
<Button
type="button"

View File

@@ -82,7 +82,7 @@ export function InterestList() {
// Bulk-action dialog state
const [stageDialog, setStageDialog] = useState<{ ids: string[] } | null>(null);
const [stageChoice, setStageChoice] = useState<PipelineStage>('open');
const [stageChoice, setStageChoice] = useState<PipelineStage>('enquiry');
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
null,
);
@@ -99,6 +99,7 @@ export function InterestList() {
setPageSize,
filters,
setFilter,
setAllFilters,
clearFilters,
} = usePaginatedQuery<InterestRow>({
queryKey: ['interests'],
@@ -237,8 +238,7 @@ export function InterestList() {
<SavedViewsDropdown
entityType="interests"
onApplyView={(savedFilters) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
setAllFilters(savedFilters);
}}
/>
<ColumnPicker
@@ -284,7 +284,7 @@ export function InterestList() {
icon: ArrowRight,
onClick: (ids) => {
if (ids.length === 0) return;
setStageChoice('open');
setStageChoice('enquiry');
setStageDialog({ ids });
},
},

View File

@@ -5,7 +5,7 @@ import { useParams } from 'next/navigation';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from 'lucide-react';
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button';
@@ -18,6 +18,8 @@ import { RecommendationList } from '@/components/interests/recommendation-list';
import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel';
import { LinkedBerthsList } from '@/components/interests/linked-berths-list';
import { InterestTimeline } from '@/components/interests/interest-timeline';
import { WonStatusPanel } from '@/components/interests/won-status-panel';
import { SupplementalInfoRequestButton } from '@/components/interests/supplemental-info-request-button';
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
import {
LEAD_CATEGORIES,
@@ -28,6 +30,10 @@ import {
} from '@/lib/constants';
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
import { QualificationChecklist } from '@/components/interests/qualification-checklist';
import { PaymentsSection } from '@/components/interests/payments-section';
import { SkipAheadBanner } from '@/components/interests/skip-ahead-banner';
import { InterestBerthStatusBanner } from '@/components/interests/interest-berth-status-banner';
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
import { InterestReservationTab } from '@/components/interests/interest-reservation-tab';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -65,10 +71,23 @@ interface InterestTabsOptions {
contractStatus: string | null;
depositStatus: string | null;
reservationStatus: string | null;
/** Captured at reservation-agreement time. Drives the deposit-paid
* auto-advance once payment totals catch up. */
depositExpectedAmount?: string | null;
depositExpectedCurrency?: string | null;
/** 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. */
outcome?: string | null;
dateFirstContact: string | null;
dateLastContact: string | null;
dateEoiSent: string | null;
dateEoiSigned: string | null;
dateReservationSigned?: string | null;
dateContractSent: string | null;
dateContractSigned: string | null;
dateDepositReceived: string | null;
@@ -401,7 +420,7 @@ function FutureMilestones({
currentStage,
}: {
milestones: Array<{
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract';
title: string;
icon: React.ComponentType<{ className?: string }>;
status: string | null;
@@ -410,7 +429,7 @@ function FutureMilestones({
}>;
stageMutation: ReturnType<typeof useStageMutation>;
advance: (stage: string) => void | Promise<void>;
activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null;
activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null;
currentStage: string;
}) {
const [expanded, setExpanded] = useState(false);
@@ -511,17 +530,19 @@ function OverviewTab({
// 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 eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
const contractIdx = PIPELINE_STAGES.indexOf('contract');
// Sub-status carries the "is this milestone's doc actually signed?" bit
// for the doc-bearing stages (eoi / reservation / contract). A milestone
// is 'past' when stage is BEYOND its index OR when stage equals its index
// AND the doc sub-status is 'signed'.
const eoiSigned = interest.eoiDocStatus === 'signed';
const reservationSigned = interest.reservationDocStatus === 'signed';
const contractSigned = interest.contractDocStatus === 'signed';
const phaseFor = (milestoneEndStageIdx: number): Phase => {
if (stageIdx === -1) return 'future';
if (stageIdx >= milestoneEndStageIdx) return 'past';
// The "current" milestone is the one whose end-stage hasn't been
// reached and whose start-stage is at-or-before the current stage.
return 'current';
};
// Berth Interest milestone — first thing the rep needs to capture
// (especially for general_interest leads). Completes the moment ANY
// berth is linked to the interest via the junction. While unset, it
@@ -531,39 +552,59 @@ function OverviewTab({
const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0;
const berthInterestPhase: Phase = hasLinkedBerth
? 'past'
: stageIdx === -1 || stageIdx >= eoiSignedIdx
: stageIdx === -1 || stageIdx >= eoiIdx
? 'past'
: 'current';
const eoiPhase = phaseFor(eoiSignedIdx);
// Deposit is current once the EOI is signed but before deposit is in.
const eoiPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > eoiIdx || (stageIdx === eoiIdx && eoiSigned)
? 'past'
: stageIdx === eoiIdx
? 'current'
: 'future';
const reservationPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > reservationIdx || (stageIdx === reservationIdx && reservationSigned)
? 'past'
: stageIdx === reservationIdx
? 'current'
: 'future';
// Deposit becomes 'current' once the reservation is signed; auto-advance
// moves it to 'past' the moment the running deposit total catches up.
const depositPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx >= depositIdx
: stageIdx > depositIdx
? 'past'
: stageIdx >= eoiSignedIdx
? 'current'
: 'future';
: stageIdx === depositIdx
? 'past'
: stageIdx === reservationIdx && reservationSigned
? 'current'
: 'future';
const contractPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx >= contractSignedIdx
: stageIdx === contractIdx && contractSigned
? 'past'
: stageIdx >= depositIdx
: stageIdx === contractIdx
? 'current'
: 'future';
const activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null =
const activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null =
berthInterestPhase === 'current'
? 'berth_interest'
: eoiPhase === 'current'
? 'eoi'
: depositPhase === 'current'
? 'deposit'
: contractPhase === 'current'
? 'contract'
: null;
: reservationPhase === 'current'
? 'reservation'
: depositPhase === 'current'
? 'deposit'
: contractPhase === 'current'
? 'contract'
: null;
const toNum = (v: string | null | undefined): number | null => {
if (v === null || v === undefined) return null;
@@ -572,7 +613,7 @@ function OverviewTab({
};
const milestones: Array<{
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract';
phase: Phase;
title: string;
icon: React.ComponentType<{ className?: string }>;
@@ -612,18 +653,20 @@ function OverviewTab({
phase: eoiPhase,
title: 'EOI',
icon: Send,
status: interest.eoiStatus,
status: interest.eoiDocStatus ?? interest.eoiStatus,
steps: [
{
label: 'EOI sent',
date: interest.dateEoiSent,
advanceStage: 'eoi_sent',
advanceStage: 'eoi',
actionLabel: 'Mark EOI as sent',
},
{
label: 'EOI signed',
date: interest.dateEoiSigned,
advanceStage: 'eoi_signed',
// Stage stays at 'eoi'; the sub-status badge flips via a separate
// PATCH (see MilestoneAdvanceButton.onConfirm fallback below).
advanceStage: 'eoi',
actionLabel: 'Mark EOI as signed',
},
],
@@ -631,6 +674,24 @@ function OverviewTab({
? `Signed ${formatDate(interest.dateEoiSigned)}`
: 'Completed',
},
{
key: 'reservation',
phase: reservationPhase,
title: 'Reservation',
icon: FileSignature,
status: interest.reservationDocStatus ?? null,
steps: [
{
label: 'Reservation agreement signed',
date: interest.dateReservationSigned ?? null,
advanceStage: 'reservation',
actionLabel: 'Mark reservation as signed',
},
],
pastSummary: interest.dateReservationSigned
? `Signed ${formatDate(interest.dateReservationSigned)}`
: 'Completed',
},
{
key: 'deposit',
phase: depositPhase,
@@ -641,25 +702,22 @@ function OverviewTab({
{
label: 'Deposit received',
date: interest.dateDepositReceived,
advanceStage: 'deposit_10pct',
advanceStage: 'deposit_paid',
hideAutoButton: true,
},
],
footer:
depositPhase === 'current' && !interest.dateDepositReceived ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Button asChild size="sm" className="h-7 px-2.5 text-xs">
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
<Plus className="size-3.5" aria-hidden />
Create deposit invoice
</Link>
</Button>
<MilestoneAdvanceButton
label="Mark received manually"
variant="ghostLink"
disabled={stageMutation.isPending}
onConfirm={(date) => advance('deposit_10pct', date)}
onConfirm={(date) => advance('deposit_paid', date)}
/>
<span className="text-[11px] text-muted-foreground">
Or record a payment in the Payments section.
</span>
</div>
) : null,
pastSummary: interest.dateDepositReceived
@@ -671,18 +729,18 @@ function OverviewTab({
phase: contractPhase,
title: 'Contract',
icon: FileSignature,
status: interest.contractStatus,
status: interest.contractDocStatus ?? interest.contractStatus,
steps: [
{
label: 'Contract sent',
date: interest.dateContractSent,
advanceStage: 'contract_sent',
advanceStage: 'contract',
actionLabel: 'Mark contract as sent',
},
{
label: 'Contract signed',
date: interest.dateContractSigned,
advanceStage: 'contract_signed',
advanceStage: 'contract',
actionLabel: 'Mark contract as signed',
},
],
@@ -698,6 +756,35 @@ function OverviewTab({
return (
<div className="space-y-6">
{/* Skip-ahead nudge — informational only; fires when the deal jumped
past a milestone without stamping the matching date. */}
<SkipAheadBanner interest={interest} />
{/* Conflict callout — fires when a linked berth is sold or already
under offer to another active deal. Doesn't block the rep; just
surfaces the situation so they treat the deal as a backup. */}
<InterestBerthStatusBanner
interestId={interestId}
interestPipelineStage={interest.pipelineStage}
interestOutcome={interest.outcome}
archivedAt={null}
/>
{/* Qualification checklist — surfaces the port's per-port criteria so
the rep can mark each one confirmed before the deal advances out
of 'enquiry'. Hidden when the port has no enabled criteria. */}
<QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} />
{/* Payments — bank-issued invoices live elsewhere; this is the
internal audit record of money received against the deal. The
running deposit total here drives the auto-advance into the
deposit_paid stage server-side. */}
<PaymentsSection
interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
{/* Sales-process milestones — phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
history strip; the current milestone gets the full card; future
@@ -842,21 +929,30 @@ function OverviewTab({
)}
</div>
{/* Tags */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/interests/${interestId}/tags`}
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
</div>
<InlineTagEditor
heading="Tags"
wrapperClassName="md:col-span-2"
endpoint={`/api/v1/interests/${interestId}/tags`}
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
</div>
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
what's already linked before browsing more options. Each row exposes
per-berth role-flag toggles and the EOI bypass control (only visible
once the parent interest's primary EOI is signed). */}
{/* Won-status wrap-up checklist — only renders when this interest's
outcome is `won`. Surfaces upload slots for the manual paperwork
that didn't flow through the EOI->Contract chain automatically. */}
<WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} />
{/* Pre-EOI supplemental info request. Sends the client a one-time
public form pre-filled with what's on file so they can confirm /
correct details before the EOI is drafted. Hides itself once
the EOI is signed. */}
<SupplementalInfoRequestButton interestId={interestId} eoiStatus={interest.eoiStatus} />
<LinkedBerthsList interestId={interestId} />
{/* Berth recommender (plan §5.3) - always-mounted card driven by the
@@ -886,17 +982,19 @@ export function getInterestTabs({
// documents; if a deal regresses the past docs remain accessible
// via the generic Documents tab.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const detailsSentIdx = PIPELINE_STAGES.indexOf('details_sent');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
// EOI: from details_sent through contract_signed (the deal's whole life)
const showEoiTab = stageIdx >= detailsSentIdx && stageIdx <= contractSignedIdx;
// Contract: appears once the deposit's been paid (deal is committed)
// and stays visible until the contract is signed
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractSignedIdx;
// Reservation: appears once the contract's signed and stays visible
// through completion (reservation is the post-contract milestone)
const showReservationTab = stageIdx >= contractSignedIdx;
const qualifiedIdx = PIPELINE_STAGES.indexOf('qualified');
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
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
// 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;
// Contract: from deposit_paid onward (deal is committed and the contract
// becomes the next active document).
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractIdx;
const tabs: DetailTab[] = [
{

View File

@@ -114,8 +114,10 @@ function formatDimensions(
return parts.length > 0 ? parts.join(' · ') : null;
}
const SPECIFIC_CONSEQUENCE_ON = 'This berth will appear as under interest on the public map.';
const SPECIFIC_CONSEQUENCE_OFF = 'This berth is hidden from the public map.';
const SPECIFIC_CONSEQUENCE_ON =
'This berth will show as “Under Offer” on the public-facing marina map.';
const SPECIFIC_CONSEQUENCE_OFF =
'This berth stays marked “Available” on the public map — the link is internal only.';
// ─── Hooks ──────────────────────────────────────────────────────────────────
@@ -238,9 +240,19 @@ 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. */
highlight?: boolean;
}
function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPending }: RowProps) {
function LinkedBerthRowItem({
row,
portSlug,
eoiStatus,
onUpdate,
onRemove,
isPending,
highlight,
}: RowProps) {
const [bypassOpen, setBypassOpen] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
@@ -250,7 +262,7 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
<div
className={cn(
'rounded-lg border bg-card p-3 text-sm',
row.isPrimary ? 'border-brand-300 ring-1 ring-brand-200' : 'border-border',
highlight ? 'border-brand-300 ring-1 ring-brand-200 shadow-sm' : 'border-border',
)}
>
<div className="flex flex-wrap items-start justify-between gap-2">
@@ -480,6 +492,30 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
const eoiStatus = data?.meta.eoiStatus ?? null;
const isPending = updateMutation.isPending || removeMutation.isPending;
// Three-bucket split per the Deal-berth + Bundle model:
// • 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,
// then bundle, then exploring.
const dealBerth = rows.find((r) => r.isPrimary) ?? null;
const bundleRows = rows.filter((r) => !r.isPrimary && r.isInEoiBundle);
const exploringRows = rows.filter((r) => !r.isPrimary && !r.isInEoiBundle);
const renderRow = (row: LinkedBerthRow, options?: { highlight?: boolean }) => (
<LinkedBerthRowItem
key={row.id}
row={row}
portSlug={portSlug}
eoiStatus={eoiStatus}
onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })}
onRemove={(berthId) => removeMutation.mutate(berthId)}
isPending={isPending}
highlight={options?.highlight}
/>
);
return (
<Card>
<CardHeader>
@@ -488,7 +524,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
Linked berths{rows.length > 0 ? ` (${rows.length})` : ''}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-5">
{isLoading ? (
<div className="space-y-2">
{[0, 1].map((i) => (
@@ -500,19 +536,36 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
No berths linked yet. Use the recommender below to add one.
</p>
) : (
<div className="space-y-2">
{rows.map((row) => (
<LinkedBerthRowItem
key={row.id}
row={row}
portSlug={portSlug}
eoiStatus={eoiStatus}
onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })}
onRemove={(berthId) => removeMutation.mutate(berthId)}
isPending={isPending}
/>
))}
</div>
<>
<BerthSection
title="Deal berth"
hint="The one berth this interest is anchored to — drives templates, the EOI primary slot, and the public-map status. Promote any other berth to take its place."
emptyText="No deal berth selected. Pick one of the linked berths below as the primary."
count={dealBerth ? 1 : 0}
>
{dealBerth ? renderRow(dealBerth, { highlight: true }) : null}
</BerthSection>
{bundleRows.length > 0 || dealBerth ? (
<BerthSection
title="In EOI bundle"
hint="Additional berths covered by the same EOI signature. Won't drive templates, but the client's signature applies to all of them."
count={bundleRows.length}
>
{bundleRows.map((row) => renderRow(row))}
</BerthSection>
) : null}
{exploringRows.length > 0 ? (
<BerthSection
title="Also considering"
hint="Linked for sales context (alternates the client glanced at, fallback options, etc.). No EOI coverage; toggle “In EOI bundle” to promote one here."
count={exploringRows.length}
>
{exploringRows.map((row) => renderRow(row))}
</BerthSection>
) : null}
</>
)}
{updateMutation.isError ? (
<p className="text-sm text-destructive">
@@ -528,3 +581,43 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
</Card>
);
}
/** 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
* indirection for a card-header + a help line. */
function BerthSection({
title,
hint,
count,
emptyText,
children,
}: {
title: string;
hint: string;
count: number;
emptyText?: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-2">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-foreground">
{title}
{count > 0 ? (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">({count})</span>
) : null}
</h4>
</div>
<p className="text-[11px] text-muted-foreground">{hint}</p>
</div>
{count === 0 && emptyText ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{emptyText}
</p>
) : (
<div className="space-y-2">{children}</div>
)}
</section>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { FileSignature } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
interface DocumentRow {
id: string;
documentType: string;
status: string;
archivedAt: string | null;
}
interface DocumentsResponse {
data: DocumentRow[];
}
/**
* Subtle chip that surfaces when an interest has multiple in-flight EOI
* documents (status != voided, not archived). Per product direction we
* intentionally allow multi-EOI cases (sometimes a deal really does need
* a second EOI for a different berth combo), but the rep should see the
* conflict at a glance so they don't accidentally re-send.
*/
export function MultiEoiChip({ interestId }: { interestId: string }) {
const { data } = useQuery<DocumentsResponse>({
queryKey: ['documents', { interestId, documentType: 'eoi' }],
queryFn: () => apiFetch(`/api/v1/documents?interestId=${interestId}&documentType=eoi`),
staleTime: 60_000,
});
const inflight = (data?.data ?? []).filter(
(d) => !d.archivedAt && d.status !== 'voided' && d.status !== 'declined',
);
if (inflight.length < 2) return null;
return (
<span
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 />
{inflight.length} EOIs
</span>
);
}

View File

@@ -0,0 +1,377 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Receipt } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PermissionGate } from '@/components/shared/permission-gate';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface PaymentRow {
id: string;
paymentType: string;
amount: string;
currency: string;
receivedAt: string;
receiptFileId: string | null;
notes: string | null;
recordedBy: string;
createdAt: string;
}
interface PaymentsResponse {
data: {
payments: PaymentRow[];
depositTotal: { total: string; currency: string };
};
}
const TYPE_LABELS: Record<string, string> = {
deposit: 'Deposit',
balance: 'Balance',
refund: 'Refund',
other: 'Other',
};
const TYPE_TINT: Record<string, string> = {
deposit: 'bg-emerald-50 text-emerald-700 border-emerald-200',
balance: 'bg-sky-50 text-sky-700 border-sky-200',
refund: 'bg-rose-50 text-rose-700 border-rose-200',
other: 'bg-slate-100 text-slate-700 border-slate-200',
};
function formatMoney(amount: string, currency: string): string {
const n = Number(amount);
if (!Number.isFinite(n)) return `${amount} ${currency}`;
try {
return new Intl.NumberFormat('en-EU', { style: 'currency', currency }).format(n);
} catch {
return `${n.toFixed(2)} ${currency}`;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString();
}
export function PaymentsSection({
interestId,
depositExpectedAmount,
depositExpectedCurrency,
}: {
interestId: string;
depositExpectedAmount: string | null;
depositExpectedCurrency: string | null;
}) {
const queryClient = useQueryClient();
const [recordOpen, setRecordOpen] = useState(false);
const { data, isLoading } = useQuery<PaymentsResponse>({
queryKey: ['interest-payments', interestId],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/payments`),
});
const deleteMutation = useMutation({
mutationFn: async (paymentId: string) =>
apiFetch(`/api/v1/payments/${paymentId}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interest-payments', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
},
onError: (err) => toastError(err),
});
if (isLoading) {
return (
<section className="rounded-lg border p-4 text-sm text-muted-foreground">
Loading payments
</section>
);
}
const payments = data?.data.payments ?? [];
const total = data?.data.depositTotal;
const expectedAmount = depositExpectedAmount ? Number(depositExpectedAmount) : null;
const expectedCurrency = depositExpectedCurrency ?? 'EUR';
const runningTotal = total ? Number(total.total) : 0;
const remaining =
expectedAmount !== null && Number.isFinite(expectedAmount)
? Math.max(0, expectedAmount - runningTotal)
: null;
return (
<section className="rounded-lg border bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">Payments</h3>
<p className="text-xs text-muted-foreground">
Records that money was received or refunded. No invoices are issued the bank handles
that.
</p>
</div>
<PermissionGate resource="invoices" action="record_payment">
<Button size="sm" className="h-8 px-3 text-xs" onClick={() => setRecordOpen(true)}>
<Plus className="size-3.5" aria-hidden />
Record payment
</Button>
</PermissionGate>
</div>
{expectedAmount !== null ? (
<div className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
<span>
Expected deposit:{' '}
<strong>{formatMoney(String(expectedAmount), expectedCurrency)}</strong>
</span>
<span>
Received so far: <strong>{formatMoney(total?.total ?? '0', expectedCurrency)}</strong>
</span>
{remaining !== null ? (
<span className={remaining === 0 ? 'text-emerald-700' : 'text-amber-700'}>
{remaining === 0
? 'Fully received'
: `${formatMoney(String(remaining), expectedCurrency)} outstanding`}
</span>
) : null}
</div>
) : null}
{payments.length === 0 ? (
<p className="rounded border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
No payments recorded yet.
</p>
) : (
<ul className="divide-y divide-border rounded border">
{payments.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex items-center gap-2.5">
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${
TYPE_TINT[p.paymentType] ?? TYPE_TINT.other
}`}
>
{TYPE_LABELS[p.paymentType] ?? p.paymentType}
</span>
<div className="text-sm">
<span className="font-medium">{formatMoney(p.amount, p.currency)}</span>
<span className="ml-2 text-xs text-muted-foreground">
{formatDate(p.receivedAt)}
</span>
{p.notes ? (
<span className="ml-2 text-xs text-muted-foreground">· {p.notes}</span>
) : null}
</div>
{p.receiptFileId ? (
<Receipt className="size-3 text-emerald-600" aria-hidden />
) : null}
</div>
<PermissionGate resource="invoices" action="record_payment">
<button
type="button"
aria-label="Delete payment record"
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm('Delete this payment record? This cannot be undone.')) {
deleteMutation.mutate(p.id);
}
}}
>
<Trash2 className="size-3.5" aria-hidden />
</button>
</PermissionGate>
</li>
))}
</ul>
)}
<RecordPaymentSheet
open={recordOpen}
onOpenChange={setRecordOpen}
interestId={interestId}
defaultCurrency={expectedCurrency}
/>
</section>
);
}
function RecordPaymentSheet({
open,
onOpenChange,
interestId,
defaultCurrency,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
interestId: string;
defaultCurrency: string;
}) {
const queryClient = useQueryClient();
const [paymentType, setPaymentType] = useState<string>('deposit');
const [amount, setAmount] = useState('');
const [currency, setCurrency] = useState(defaultCurrency);
const [receivedAt, setReceivedAt] = useState(() => {
const today = new Date();
return today.toISOString().slice(0, 10);
});
const [notes, setNotes] = useState('');
const [acknowledgedNoReceipt, setAcknowledgedNoReceipt] = useState(false);
const mutation = useMutation({
mutationFn: async () =>
apiFetch(`/api/v1/interests/${interestId}/payments`, {
method: 'POST',
body: {
interestId,
paymentType,
amount,
currency,
receivedAt: new Date(receivedAt).toISOString(),
notes: notes || null,
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interest-payments', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
onOpenChange(false);
// Reset form for next use
setAmount('');
setNotes('');
setAcknowledgedNoReceipt(false);
},
onError: (err) => toastError(err),
});
const canSubmit =
amount.trim().length > 0 && receivedAt && acknowledgedNoReceipt && !mutation.isPending;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>Record payment</SheetTitle>
<SheetDescription>
Capture that money was received (or refunded). Reps don&apos;t issue invoices the bank
does that so this is just an audit record.
</SheetDescription>
</SheetHeader>
<form
className="mt-5 space-y-4"
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="payment-type">Type</Label>
<Select value={paymentType} onValueChange={setPaymentType}>
<SelectTrigger id="payment-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deposit">Deposit</SelectItem>
<SelectItem value="balance">Balance</SelectItem>
<SelectItem value="refund">Refund</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-[1fr_100px] gap-2">
<div className="space-y-1.5">
<Label htmlFor="payment-amount">Amount</Label>
<Input
id="payment-amount"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="payment-currency">Currency</Label>
<Input
id="payment-currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
maxLength={3}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="payment-date">Received on</Label>
<Input
id="payment-date"
type="date"
value={receivedAt}
onChange={(e) => setReceivedAt(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="payment-notes">Notes (optional)</Label>
<Input
id="payment-notes"
placeholder="Reference, payer name, etc."
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<label className="flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<input
type="checkbox"
checked={acknowledgedNoReceipt}
onChange={(e) => setAcknowledgedNoReceipt(e.target.checked)}
className="mt-0.5"
/>
<span>
I understand that recording a payment without an attached receipt may make later
verification harder, and that the bank-issued receipt is the canonical proof.
</span>
</label>
<SheetFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{mutation.isPending ? 'Saving…' : 'Record payment'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { CheckCircle2, ChevronRight } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
interface QualificationRow {
key: string;
label: string;
description: string | null;
enabled: boolean;
displayOrder: number;
confirmed: boolean;
confirmedAt: string | null;
confirmedBy: string | null;
notes: string | null;
}
interface QualificationResponse {
data: {
criteria: QualificationRow[];
fullyQualified: boolean;
};
}
/**
* Per-interest qualification checklist. Hidden when the port has no
* enabled criteria. When the rep has confirmed every enabled criterion AND
* the deal is still in 'enquiry', a soft hint surfaces a Promote button
* that advances the stage to 'qualified' through the standard transition
* endpoint (no override; this is the canonical adjacent move).
*/
export function QualificationChecklist({
interestId,
currentStage,
}: {
interestId: string;
currentStage: string;
}) {
const params = useParams<{ portSlug: string }>();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<QualificationResponse>({
queryKey: ['interest-qualifications', interestId],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/qualifications`),
});
const toggleMutation = useMutation({
mutationFn: async (vars: { criterionKey: string; confirmed: boolean }) =>
apiFetch(`/api/v1/interests/${interestId}/qualifications`, {
method: 'PUT',
body: { criterionKey: vars.criterionKey, confirmed: vars.confirmed },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interest-qualifications', interestId] });
},
onError: (err) => toastError(err),
});
const promoteMutation = useMutation({
mutationFn: async () =>
apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'POST',
body: { pipelineStage: 'qualified' },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
},
onError: (err) => toastError(err),
});
if (isLoading) return null;
if (!data) return null;
const criteria = data.data.criteria;
if (criteria.length === 0) return null;
const fullyQualified = data.data.fullyQualified;
const showPromoteHint = fullyQualified && currentStage === 'enquiry';
// Avoid referencing `params` in the JSX so the unused destructure passes
// strict noUnused checks; it stays available for future deep-link hooks.
void params;
return (
<section className="rounded-lg border bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">Qualification</h3>
{fullyQualified ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-700">
<CheckCircle2 className="size-3.5" aria-hidden />
All confirmed
</span>
) : (
<span className="text-xs text-muted-foreground">
{criteria.filter((c) => c.confirmed).length} of {criteria.length} confirmed
</span>
)}
</div>
<ul className="space-y-2">
{criteria.map((c) => (
<li key={c.key} className="flex items-start gap-2.5">
<Checkbox
id={`qual-${c.key}`}
checked={c.confirmed}
disabled={toggleMutation.isPending}
onCheckedChange={(v) =>
toggleMutation.mutate({ criterionKey: c.key, confirmed: v === true })
}
className="mt-0.5"
/>
<label
htmlFor={`qual-${c.key}`}
className={cn(
'flex-1 text-sm cursor-pointer',
c.confirmed ? 'text-foreground' : 'text-foreground/90',
)}
>
<span
className={cn('font-medium', c.confirmed && 'line-through text-muted-foreground')}
>
{c.label}
</span>
{c.description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>
) : null}
</label>
</li>
))}
</ul>
{showPromoteHint ? (
<div className="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2">
<p className="text-xs text-emerald-800">
All criteria confirmed this lead is ready to qualify.
</p>
<Button
type="button"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={promoteMutation.isPending}
onClick={() => promoteMutation.mutate()}
>
Promote to Qualified
<ChevronRight className="size-3.5" aria-hidden />
</Button>
</div>
) : null}
</section>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { AlertCircle } from 'lucide-react';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
interface SkipAheadInterest {
pipelineStage: string;
dateEoiSent?: string | null;
dateEoiSigned?: string | null;
dateReservationSigned?: string | null;
dateDepositReceived?: string | null;
dateContractSent?: string | null;
dateContractSigned?: string | null;
eoiDocStatus?: string | null;
reservationDocStatus?: string | null;
contractDocStatus?: string | null;
}
/**
* Soft banner that fires when a rep skip-advanced a deal past earlier
* milestones without backfilling the matching dates / doc-status badges.
*
* Why we care: the funnel/conversion analytics rely on these timestamps to
* compute how long deals sit in each stage. A deal that jumped straight to
* 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
* override path; we just nudge them to fill in the gaps.
*/
export function SkipAheadBanner({ interest }: { interest: SkipAheadInterest }) {
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
if (stageIdx < 0) return null;
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
const gaps: string[] = [];
// Past EOI but never stamped sent → likely a skip.
if (stageIdx > eoiIdx && !interest.dateEoiSent) gaps.push('EOI sent date');
if (stageIdx > eoiIdx && interest.eoiDocStatus !== 'signed' && !interest.dateEoiSigned) {
gaps.push('EOI signed date');
}
if (
stageIdx > reservationIdx &&
interest.reservationDocStatus !== 'signed' &&
!interest.dateReservationSigned
) {
gaps.push('Reservation signed date');
}
if (stageIdx > depositIdx && !interest.dateDepositReceived) {
gaps.push('Deposit received date');
}
if (gaps.length === 0) return null;
return (
<div
role="status"
className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900"
>
<AlertCircle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
<div>
<p className="font-medium">
{gaps.length === 1
? 'A past milestone is missing its date.'
: `${gaps.length} past milestones are missing their dates.`}
</p>
<p className="mt-0.5 text-amber-800">
Backfill {gaps.join(' · ')} below so reports show accurate cycle times.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { ClipboardCopy, Mail } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { apiFetch } from '@/lib/api/client';
interface Props {
interestId: string;
/** 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;
}
interface IssueResponse {
data: {
link: string;
expiresAt: string;
emailSent: boolean;
};
}
/**
* One-click "Request more info" action. Fires the supplemental-info-
* request endpoint, which emails the client a public form pre-filled
* with what's on file. On success we display the generated link + a
* 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
* sense before the signed EOI freezes the data into the contract path.
*/
export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props) {
const [link, setLink] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
apiFetch<IssueResponse>(`/api/v1/interests/${interestId}/supplemental-info-request`, {
method: 'POST',
}),
onSuccess: (res) => {
setLink(res.data.link);
if (res.data.emailSent) {
toast.success('Email sent — link also shown below for sharing manually.');
} else {
toast.message('Link generated — no client email on file, share manually.');
}
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Failed to generate the form link.'),
});
if (eoiStatus === 'signed') return null;
return (
<Card>
<CardContent className="space-y-3 p-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Need more info before drafting the EOI?</h3>
<p className="text-xs text-muted-foreground">
Email the client a one-time link to a public form pre-filled with what we have on file.
Submissions auto-update this client + interest record.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
<Mail className="mr-1.5 size-3.5" aria-hidden />
{mutation.isPending ? 'Generating…' : link ? 'Resend' : 'Request more info'}
</Button>
{link ? (
<>
<Input value={link} readOnly className="h-8 text-xs font-mono flex-1 min-w-[260px]" />
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard.writeText(link);
toast.success('Link copied');
}}
>
<ClipboardCopy className="mr-1.5 size-3.5" aria-hidden />
Copy
</Button>
</>
) : null}
</div>
</CardContent>
</Card>
);
}

View File

@@ -11,7 +11,9 @@ const SILENT_DAYS_THRESHOLD = 7;
const EOI_AWAITING_DAYS_THRESHOLD = 14;
const DEPOSIT_PENDING_DAYS_THRESHOLD = 21;
const ACTIVE_MID_FUNNEL_STAGES = new Set(['details_sent', 'in_communication']);
// Mid-funnel = post-enquiry, pre-EOI. Surfaces the silent-deal warning so
// reps notice deals stuck in qualifying/nurturing without recent contact.
const ACTIVE_MID_FUNNEL_STAGES = new Set(['qualified', 'nurturing']);
export interface InterestUrgencyInput {
pipelineStage: string;
@@ -74,8 +76,11 @@ export function computeUrgencyBadges(row: InterestUrgencyInput): UrgencyBadge[]
}
}
// EOI signed but deposit not received.
if (row.pipelineStage === 'eoi_signed' && !row.dateDepositReceived && row.dateEoiSent) {
// EOI signed (or further along) but deposit not received yet. The deposit
// is its own stage now; we trigger the warning while the deal is past EOI
// signing but hasn't reached deposit_paid + has no dateDepositReceived.
const eoiOrPast = row.pipelineStage === 'eoi' || row.pipelineStage === 'reservation';
if (eoiOrPast && !row.dateDepositReceived && row.dateEoiSent) {
const days = daysSince(row.dateEoiSent);
if (days !== null && days >= DEPOSIT_PENDING_DAYS_THRESHOLD) {
badges.push({

View File

@@ -0,0 +1,211 @@
'use client';
import { useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, FileUp, Trophy, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
/**
* Won-status manual-upload panel.
*
* Renders only when the interest's outcome is `won`. Detects whether
* a Contract / EOI / Other doc is already attached (via the existing
* files endpoint) and shows an "Upload {missing}" slot for each one
* that isn't there yet. Naturally hides itself once the rep has filed
* everything.
*
* Reps that reached Won through the natural EOI → Contract → Deposit
* chain will typically see all three slots already filled and the
* panel collapses to a green confirmation strip.
*/
interface WonStatusPanelProps {
interestId: string;
/** Outcome of the interest. Panel hides unless 'won'. */
outcome: string | null;
}
interface FileRow {
id: string;
category: string | null;
filename: string;
originalName: string | null;
}
interface FilesResponse {
data: FileRow[];
}
interface UploadSlot {
key: 'contract' | 'eoi' | 'other';
label: string;
description: string;
category: string;
}
const SLOTS: UploadSlot[] = [
{
key: 'contract',
label: 'Signed contract',
description: 'Final purchase / lease agreement signed by both sides.',
category: 'contract',
},
{
key: 'eoi',
label: 'Signed EOI',
description: "Required only if you didn't run the EOI through the in-app signing flow.",
category: 'eoi',
},
{
key: 'other',
label: 'Other supporting docs',
description: 'Insurance certificates, ID, anything else worth attaching to the closed deal.',
category: 'other',
},
];
export function WonStatusPanel({ interestId, outcome }: WonStatusPanelProps) {
const qc = useQueryClient();
// Fetch the files attached to this interest so we can hide slots that
// are already filled. The endpoint accepts `entityType` + `entityId`
// for polymorphic ownership; non-interest files are filtered out.
const { data } = useQuery<FilesResponse>({
queryKey: ['interest-files', interestId],
queryFn: () => apiFetch(`/api/v1/files?entityType=interest&entityId=${interestId}&limit=100`),
enabled: outcome === 'won',
staleTime: 30_000,
});
const existing = data?.data ?? [];
if (outcome !== 'won') return null;
const slots = SLOTS.map((s) => ({
...s,
files: existing.filter((f) => (f.category ?? '').toLowerCase() === s.category),
}));
const allFilled = slots[0]!.files.length > 0 && slots[1]!.files.length > 0;
return (
<Card className="border-emerald-200 bg-emerald-50/40">
<CardHeader className="gap-1">
<CardTitle className="flex items-center gap-2 text-base text-emerald-900">
<Trophy className="size-4" aria-hidden />
Won wrap-up checklist
</CardTitle>
<p className="text-xs text-emerald-800/80">
Upload anything that didn&apos;t flow through the system automatically. Reservations,
deposit invoicing, and client billing are handled outside the CRM this checklist is for
the paperwork that lives on the deal itself.
</p>
</CardHeader>
<CardContent className="space-y-2">
{slots.map((s) => (
<UploadSlotRow
key={s.key}
slot={s}
interestId={interestId}
onUploaded={() => qc.invalidateQueries({ queryKey: ['interest-files', interestId] })}
/>
))}
{allFilled ? (
<p className="pt-1 text-xs text-emerald-800/80 italic">
All required documents are attached. Anything else you upload here will appear in the
client&apos;s signed-docs folder.
</p>
) : null}
</CardContent>
</Card>
);
}
function UploadSlotRow({
slot,
interestId,
onUploaded,
}: {
slot: UploadSlot & { files: FileRow[] };
interestId: string;
onUploaded: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const filled = slot.files.length > 0;
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData();
fd.append('file', file);
fd.append('entityType', 'interest');
fd.append('entityId', interestId);
fd.append('category', slot.category);
const res = await fetch('/api/v1/files/upload', { method: 'POST', body: fd });
if (!res.ok) {
const payload = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
throw new Error(payload.error?.message ?? `Upload failed (${res.status})`);
}
},
onMutate: () => setUploading(true),
onSuccess: () => {
toast.success(`${slot.label} uploaded`);
onUploaded();
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Upload failed'),
onSettled: () => setUploading(false),
});
return (
<div
className={cn(
'rounded-md border p-3 text-sm',
filled ? 'border-emerald-300 bg-white' : 'border-input bg-card',
)}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 font-medium">
{filled ? <CheckCircle2 className="size-4 text-emerald-600" aria-hidden /> : null}
{slot.label}
{filled ? (
<span className="text-xs font-normal text-muted-foreground">
({slot.files.length} on file)
</span>
) : null}
</div>
<p className="text-xs text-muted-foreground">{slot.description}</p>
</div>
<Button
type="button"
size="sm"
variant={filled ? 'outline' : 'default'}
onClick={() => inputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" aria-hidden />
) : (
<FileUp className="mr-1.5 size-3.5" aria-hidden />
)}
{filled ? 'Add another' : 'Upload'}
</Button>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload.mutate(f);
e.target.value = '';
}}
/>
</div>
</div>
);
}