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