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

@@ -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,
};

View File

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

View 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 };
}