diff --git a/src/app/(dashboard)/[portSlug]/admin/pulse/page.tsx b/src/app/(dashboard)/[portSlug]/admin/pulse/page.tsx new file mode 100644 index 00000000..4faba48a --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/pulse/page.tsx @@ -0,0 +1,51 @@ +import Link from 'next/link'; +import { Activity } from 'lucide-react'; + +import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form'; +import { PageHeader } from '@/components/shared/page-header'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export default function PulseAdminPage() { + return ( +
+ + + + + + + + +

+ Every interest row carries a small coloured chip in the detail header. It scores the + deal from 0–100 using rule-based signals (no AI). Click the chip on any interest to see + the per-signal breakdown — every +N or -N traces back to a dated event on the deal. +

+

+ Positive signals (recent EOI sent, deposit received, contract signed) push the score up. + Risk signals (declined documents, cancelled reservations, berth resold elsewhere) push + it down. Stale-contact and stage-stuck signals weigh both directions automatically. +

+

+ See the full guide at{' '} + + /docs/deal-pulse + + . +

+
+
+ + +
+ ); +} diff --git a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts index 96705311..3825fa00 100644 --- a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts +++ b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts @@ -5,6 +5,7 @@ import { errorResponse } from '@/lib/errors'; import { issueToken } from '@/lib/services/supplemental-forms.service'; import { sendEmail } from '@/lib/email'; import { env } from '@/lib/env'; +import { getPortEmailConfig } from '@/lib/services/port-config'; /** * POST /api/v1/interests/[id]/supplemental-info-request @@ -22,7 +23,14 @@ export const POST = withAuth( issuedBy: ctx.userId, }); - const link = `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`; + // §1.4: prefer the per-port supplemental_form_url (typically the + // marketing site's hosted form) when configured; otherwise fall + // back to the built-in CRM route. Both modes use the same token + // — the marketing site forwards the token to the same backend. + const emailCfg = await getPortEmailConfig(ctx.portId); + const link = emailCfg.supplementalFormUrl + ? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}` + : `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`; if (result.clientEmail) { const html = ` diff --git a/src/components/admin/admin-sections-browser.tsx b/src/components/admin/admin-sections-browser.tsx index 1710ec92..ab85e737 100644 --- a/src/components/admin/admin-sections-browser.tsx +++ b/src/components/admin/admin-sections-browser.tsx @@ -126,6 +126,14 @@ const GROUPS: AdminGroup[] = [ icon: GitBranch, keywords: ['pipeline', 'auto-advance', 'stage rules', 'aggressive', 'conservative'], }, + { + href: 'pulse', + label: 'Deal Pulse', + description: + 'Configure the chip on every interest header — master toggle, per-signal toggles, and tier-label overrides.', + icon: Activity, + keywords: ['pulse', 'deal-health', 'health chip', 'hot warm cold'], + }, { href: 'reminders', label: 'Reminders', diff --git a/src/lib/email/templates/document-signing.tsx b/src/lib/email/templates/document-signing.tsx index 114e397c..3a599e1e 100644 --- a/src/lib/email/templates/document-signing.tsx +++ b/src/lib/email/templates/document-signing.tsx @@ -42,12 +42,19 @@ interface InvitationData { function InvitationBody({ data, accent }: { data: InvitationData; accent: string }) { const greeting = `Dear ${data.recipientName},`; - const isClient = (data.signerRole ?? 'client') === 'client'; - const leadCopy = isClient - ? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign — it should only take a couple of minutes.` - : data.signerRole === 'approver' - ? `An ${data.documentLabel} is awaiting your approval. The earlier signers have completed their parts; please review and sign to finalise the document.` - : `An ${data.documentLabel} is awaiting your signature. The client has already signed; you're the next signer in the chain.`; + const role = data.signerRole ?? 'client'; + // §1.3 audit: role-specific copy stays order-agnostic. The original + // copy assumed client→developer→approver order; ports can configure + // any sequence, so generic phrasing is safer than naming the prior + // signer. + const leadCopy = + role === 'client' + ? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign — it should only take a couple of minutes.` + : role === 'approver' + ? `An ${data.documentLabel} is awaiting your approval. Please review and sign to finalise the document.` + : role === 'developer' + ? `An ${data.documentLabel} is awaiting your signature as developer. Please review and sign when ready.` + : `An ${data.documentLabel} is awaiting your signature. Please review and sign when ready.`; return ( <> diff --git a/src/lib/services/deal-health.ts b/src/lib/services/deal-health.ts index f586298d..18525f29 100644 --- a/src/lib/services/deal-health.ts +++ b/src/lib/services/deal-health.ts @@ -56,6 +56,28 @@ export interface DealHealthInput { * the scoring function pure / synchronous so the chip can render without a * separate fetch on every interest list row. */ recentActivityCount?: number | null; + /** Phase 2 — risk signals captured in deal-pulse-trigger-audit.md. + * Any of these populated → strong negative signal pushed onto the + * chip so reps can triage cooling deals at a glance. All optional; + * callers populate from existing schema (document_events for + * declines, reservations.cancelled_at, interest_berths conflict). */ + dateDocumentDeclined?: string | Date | null; + dateReservationCancelled?: string | Date | null; + dateBerthSoldToOther?: string | Date | null; + /** Optional per-port config that lets admins disable individual + * signals or rename their tier labels. When omitted, defaults + * apply — current callers stay byte-identical without changes. */ + config?: DealHealthConfig | null; +} + +export interface DealHealthConfig { + enabled?: boolean; + signals?: Partial>; + labels?: { + hot?: string; + warm?: string; + cold?: string; + }; } export interface DealHealthSignal { @@ -80,10 +102,20 @@ function daysSince(iso: string | Date | null | undefined): number | null { return Math.floor((Date.now() - t) / 86_400_000); } +function signalEnabled(input: DealHealthInput, signalId: string): boolean { + const flag = input.config?.signals?.[signalId]; + return flag !== false; +} + export function computeDealHealth(input: DealHealthInput): DealHealth { let score = 50; const signals: DealHealthSignal[] = []; + // Master toggle — admin can hide the chip entirely per-port. + // Returning the neutral shape keeps callers happy; the chip uses + // a separate "visible" prop derived from config.enabled before + // calling compute. We still return real data so reports can read it. + // Closed / archived deals don't get a pulse score — UI hides the chip // anyway, but compute a neutral score so callers using this in reports // don't crash on undefined. @@ -204,6 +236,83 @@ export function computeDealHealth(input: DealHealthInput): DealHealth { }); } + // Phase 2 — positive momentum signals. + // EOI sent recently: forward motion that the original score didn't + // surface (the awaiting penalty only fires after 14d). Brightens the + // chip for fresh-EOI deals so reps see progress. + const eoiSentDaysPos = daysSince(input.dateEoiSent); + if (eoiSentDaysPos !== null && eoiSentDaysPos <= 14 && signalEnabled(input, 'eoi_sent_recent')) { + score += 5; + signals.push({ + id: 'eoi_sent_recent', + delta: +5, + detail: `EOI sent ${eoiSentDaysPos}d ago — awaiting signature.`, + }); + } + + // Deposit received: near-commit signal. Was previously invisible on + // the chip even though it's one of the strongest forward signals. + const depositDays = daysSince(input.dateDepositReceived); + if (depositDays !== null && signalEnabled(input, 'deposit_received')) { + score += 15; + signals.push({ + id: 'deposit_received', + delta: +15, + detail: `Deposit received ${depositDays}d ago.`, + }); + } + + // Contract signed: closed-loop reinforcement. The interest's outcome + // flips to 'won' shortly after, but until that happens the contract + // signature is a strong positive signal. + const contractSignedDays = daysSince(input.dateContractSigned); + if (contractSignedDays !== null && signalEnabled(input, 'contract_signed')) { + score += 10; + signals.push({ + id: 'contract_signed', + delta: +10, + detail: `Contract signed ${contractSignedDays}d ago.`, + }); + } + + // Phase 2 — risk signals. These are the strongest cooling indicators + // and previously didn't move the chip at all, leaving reps to discover + // them by clicking into the detail page. + + // Document declined (EOI/contract/reservation rejected by the client). + const declinedDays = daysSince(input.dateDocumentDeclined); + if (declinedDays !== null && signalEnabled(input, 'document_declined')) { + score -= 25; + signals.push({ + id: 'document_declined', + delta: -25, + detail: `Client declined a document ${declinedDays}d ago — intervene.`, + }); + } + + // Reservation cancelled: booked-then-cancelled is a high-value warning. + const reservationCancelledDays = daysSince(input.dateReservationCancelled); + if (reservationCancelledDays !== null && signalEnabled(input, 'reservation_cancelled')) { + score -= 20; + signals.push({ + id: 'reservation_cancelled', + delta: -20, + detail: `Reservation cancelled ${reservationCancelledDays}d ago.`, + }); + } + + // Berth resold to a different deal — this interest is effectively dead + // (the asset they wanted is gone). Sharp drop so the chip turns cold. + const berthSoldDays = daysSince(input.dateBerthSoldToOther); + if (berthSoldDays !== null && signalEnabled(input, 'berth_sold_to_other')) { + score -= 30; + signals.push({ + id: 'berth_sold_to_other', + delta: -30, + detail: `Primary berth was sold to a different deal ${berthSoldDays}d ago.`, + }); + } + // Clamp to [0, 100]. score = Math.max(0, Math.min(100, score)); diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts index 734135f8..f964125b 100644 --- a/src/lib/services/port-config.ts +++ b/src/lib/services/port-config.ts @@ -17,6 +17,10 @@ export const SETTING_KEYS = { emailFromName: 'email_from_name', emailFromAddress: 'email_from_address', emailReplyTo: 'email_reply_to', + // §1.4: optional per-port URL that the supplemental-info email links + // to (typically the marketing site's hosted form). When blank, the + // built-in CRM route `/public/supplemental-info/` is used. + supplementalFormUrl: 'supplemental_form_url', // email_signature_html / email_footer_html — removed; the email shell // reads branding_email_header_html / branding_email_footer_html from // /admin/branding, which is the source of truth. @@ -220,6 +224,12 @@ export interface PortEmailConfig { * account. Defaults to false for safety. */ allowPersonalAccountSends: boolean; + /** + * §1.4: optional per-port URL for the supplemental-info email link. + * When set, the email contains `${supplementalFormUrl}?token=`; + * when null, the built-in CRM route is used. + */ + supplementalFormUrl: string | null; } export async function getPortEmailConfig(portId: string): Promise { @@ -232,6 +242,7 @@ export async function getPortEmailConfig(portId: string): Promise(SETTING_KEYS.emailFromName, portId), readSetting(SETTING_KEYS.emailFromAddress, portId), @@ -241,6 +252,7 @@ export async function getPortEmailConfig(portId: string): Promise(SETTING_KEYS.smtpUserOverride, portId), readSetting(SETTING_KEYS.smtpPassOverride, portId), readSetting(SETTING_KEYS.emailAllowPersonalAccountSends, portId), + readSetting(SETTING_KEYS.supplementalFormUrl, portId), ]); // Parse env.SMTP_FROM into name + address if no port override @@ -265,6 +277,7 @@ export async function getPortEmailConfig(portId: string): Promise. Useful when you want the client to land on a branded marketing-site page instead of the CRM domain.", + type: 'string', + scope: 'port', + placeholder: 'https://portnimara.com/supplemental', + }, // ─── Email — SMTP overrides ─────────────────────────────────────────────── { @@ -498,6 +508,97 @@ export const REGISTRY: SettingEntry[] = [ envFallback: 'PUBLIC_SITE_URL', placeholder: 'https://example.com', }, + + // ─── Deal Pulse (Phase 2) ───────────────────────────────────────────────── + // Per-port admin controls for the deal-pulse chip on interest lists + + // detail headers. Master toggle hides the chip entirely; per-signal + // toggles let admins quiet specific signal types; label overrides + // rename tier labels for ports that prefer their own vocabulary. + { + key: 'pulse_enabled', + section: 'pulse', + label: 'Show deal pulse chips', + description: + 'Master toggle. When off, the pulse chip is hidden on every interest list row + detail header for this port. Useful when a port prefers to triage pipelines without the AI-tinted chip.', + type: 'boolean', + scope: 'port', + }, + { + key: 'pulse_signal_eoi_sent_recent_enabled', + section: 'pulse', + label: 'Signal: recent EOI sent (positive)', + description: 'Default on. Brightens chip when EOI was sent in last 14 days.', + type: 'boolean', + scope: 'port', + }, + { + key: 'pulse_signal_deposit_received_enabled', + section: 'pulse', + label: 'Signal: deposit received (positive)', + description: 'Default on. Strong forward signal once a deposit invoice flips to paid.', + type: 'boolean', + scope: 'port', + }, + { + key: 'pulse_signal_contract_signed_enabled', + section: 'pulse', + label: 'Signal: contract signed (positive)', + description: 'Default on. Reinforces closed-loop progress until outcome flips to won.', + type: 'boolean', + scope: 'port', + }, + { + key: 'pulse_signal_document_declined_enabled', + section: 'pulse', + label: 'Signal: document declined (risk)', + description: + 'Default on. Strongest cooling signal — client refused to sign an EOI / contract / reservation. Requires the risk-data wiring shipped alongside Phase 2 to populate.', + type: 'boolean', + scope: 'port', + }, + { + key: 'pulse_signal_reservation_cancelled_enabled', + section: 'pulse', + label: 'Signal: reservation cancelled (risk)', + description: 'Default on. Booked-then-cancelled signals require rep attention.', + type: 'boolean', + scope: 'port', + }, + { + key: 'pulse_signal_berth_sold_to_other_enabled', + section: 'pulse', + label: 'Signal: berth resold (risk)', + description: 'Default on. Primary berth got linked to a different completed interest.', + type: 'boolean', + scope: 'port', + }, + { + key: 'pulse_label_hot', + section: 'pulse', + label: 'Custom label: Hot tier', + description: 'Leave blank to use the built-in "Hot" label.', + type: 'string', + scope: 'port', + placeholder: 'Hot', + }, + { + key: 'pulse_label_warm', + section: 'pulse', + label: 'Custom label: Warm tier', + description: 'Leave blank to use the built-in "Warm" label.', + type: 'string', + scope: 'port', + placeholder: 'Warm', + }, + { + key: 'pulse_label_cold', + section: 'pulse', + label: 'Custom label: Cold tier', + description: 'Leave blank to use the built-in "Cold" label.', + type: 'string', + scope: 'port', + placeholder: 'Cold', + }, ]; /** Quick lookup index keyed by setting key. */