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:
@@ -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>
|
||||
|
||||
124
src/components/interests/assigned-to-chip.tsx
Normal file
124
src/components/interests/assigned-to-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
78
src/components/interests/deal-pulse-chip.tsx
Normal file
78
src/components/interests/deal-pulse-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
76
src/components/interests/interest-berth-status-banner.tsx
Normal file
76
src/components/interests/interest-berth-status-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/components/interests/multi-eoi-chip.tsx
Normal file
47
src/components/interests/multi-eoi-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
377
src/components/interests/payments-section.tsx
Normal file
377
src/components/interests/payments-section.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
157
src/components/interests/qualification-checklist.tsx
Normal file
157
src/components/interests/qualification-checklist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/interests/skip-ahead-banner.tsx
Normal file
76
src/components/interests/skip-ahead-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/components/interests/supplemental-info-request-button.tsx
Normal file
102
src/components/interests/supplemental-info-request-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
211
src/components/interests/won-status-panel.tsx
Normal file
211
src/components/interests/won-status-panel.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user