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:
@@ -109,6 +109,20 @@ export function usePaginatedQuery<T>({
|
||||
syncUrl(1, pageSize, sort, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically replace the entire filter set. Used by the saved-views
|
||||
* apply path — calling `clearFilters()` + N x `setFilter()` in a row
|
||||
* lost all but the last setFilter because each one reads the stale
|
||||
* `filters` closure and overwrites with `{...filters, key: val}`.
|
||||
* setAllFilters writes the whole object in one setState so the view
|
||||
* lands intact.
|
||||
*/
|
||||
function setAllFilters(next: FilterValues) {
|
||||
setFiltersState(next);
|
||||
setPageState(1);
|
||||
syncUrl(1, pageSize, sort, next);
|
||||
}
|
||||
|
||||
// Build query string for API
|
||||
const apiParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -174,6 +188,7 @@ export function usePaginatedQuery<T>({
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilter,
|
||||
setAllFilters,
|
||||
clearFilters,
|
||||
optimisticRemove,
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface ClientResult {
|
||||
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
|
||||
archivedAt: string | null;
|
||||
relatedVia?: RelatedVia | null;
|
||||
matchedOn?: string | null;
|
||||
}
|
||||
export interface ResidentialClientResult {
|
||||
id: string;
|
||||
|
||||
153
src/hooks/use-voice-transcription.ts
Normal file
153
src/hooks/use-voice-transcription.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Web Speech API wrapper. Browser-only — gracefully reports `supported: false`
|
||||
* when SpeechRecognition isn't available (Firefox, Safari < 14.1, server-side
|
||||
* render).
|
||||
*
|
||||
* Surfaces both the interim (still-being-spoken) and final (committed)
|
||||
* transcripts so the UI can show "real-time" feedback while typing.
|
||||
*
|
||||
* Usage:
|
||||
* const { supported, isListening, transcript, interim, start, stop, reset } =
|
||||
* useVoiceTranscription();
|
||||
*
|
||||
* The summary form appends `transcript` (final committed text) to the textarea
|
||||
* and separately persists it to `voiceTranscript` on the server. The rep can
|
||||
* still edit the summary freely — the raw transcript stays untouched.
|
||||
*/
|
||||
interface SpeechRecognitionEventLike {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<{
|
||||
isFinal: boolean;
|
||||
0: { transcript: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionLike {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
|
||||
onerror: ((event: { error: string }) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
||||
|
||||
function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const w = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognitionCtor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
export interface VoiceTranscriptionApi {
|
||||
/** True when the browser exposes Web Speech (or a vendor-prefixed equivalent). */
|
||||
supported: boolean;
|
||||
/** Currently capturing audio. */
|
||||
isListening: boolean;
|
||||
/** Final, committed transcript accumulated across the current session. */
|
||||
transcript: string;
|
||||
/** Latest interim (still being recognized) transcript fragment. */
|
||||
interim: string;
|
||||
/** Set when the underlying API returns an error (permission denied, no mic, etc.). */
|
||||
error: string | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useVoiceTranscription(opts?: { lang?: string }): VoiceTranscriptionApi {
|
||||
// SSR-safe: getSpeechRecognitionCtor() returns null on the server. We want
|
||||
// the support flag to be stable across renders rather than flipping inside
|
||||
// an effect (set-state-in-effect lint), so derive it from the constructor
|
||||
// lookup at render time — useState's lazy initializer runs once per mount.
|
||||
const [supported] = useState(() => getSpeechRecognitionCtor() !== null);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [interim, setInterim] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const Ctor = getSpeechRecognitionCtor();
|
||||
if (!Ctor) return;
|
||||
|
||||
const recognition = new Ctor();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = opts?.lang ?? 'en-US';
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let interimText = '';
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const res = event.results[i];
|
||||
if (!res) continue;
|
||||
const chunk = res[0].transcript;
|
||||
if (res.isFinal) {
|
||||
setTranscript((prev) => (prev ? `${prev} ${chunk}`.replace(/ {2,}/g, ' ') : chunk));
|
||||
} else {
|
||||
interimText += chunk;
|
||||
}
|
||||
}
|
||||
setInterim(interimText);
|
||||
};
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
setError(event.error);
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
setInterim('');
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
|
||||
return () => {
|
||||
try {
|
||||
recognition.abort();
|
||||
} catch {
|
||||
// ignore — already-stopped recognizers throw on abort()
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
}, [opts?.lang]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!recognitionRef.current || isListening) return;
|
||||
setError(null);
|
||||
try {
|
||||
recognitionRef.current.start();
|
||||
setIsListening(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start voice capture');
|
||||
}
|
||||
}, [isListening]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!recognitionRef.current || !isListening) return;
|
||||
try {
|
||||
recognitionRef.current.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isListening]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setTranscript('');
|
||||
setInterim('');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { supported, isListening, transcript, interim, error, start, stop, reset };
|
||||
}
|
||||
Reference in New Issue
Block a user