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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 03:39:21 +02:00
parent b10bf9bf8e
commit 6b28459c45
110 changed files with 5402 additions and 796 deletions

View File

@@ -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">