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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user