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

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