feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin
Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
51
src/app/(dashboard)/[portSlug]/admin/pulse/page.tsx
Normal file
51
src/app/(dashboard)/[portSlug]/admin/pulse/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Deal Pulse"
|
||||||
|
description="Tune the chip that scores every interest's health. Toggle the chip off entirely, disable individual signals you don't want surfaced, or rename the tier labels per your sales vocabulary."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||||
|
How the pulse chip works
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
See the full guide at{' '}
|
||||||
|
<Link href="/docs/deal-pulse" className="underline">
|
||||||
|
/docs/deal-pulse
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<RegistryDrivenForm
|
||||||
|
title="Pulse chip behaviour"
|
||||||
|
description="Master toggle, per-signal toggles, and per-port label overrides. Defaults: chip visible, all signals on, built-in tier names ('Hot' / 'Warm' / 'Cold')."
|
||||||
|
sections={['pulse']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { errorResponse } from '@/lib/errors';
|
|||||||
import { issueToken } from '@/lib/services/supplemental-forms.service';
|
import { issueToken } from '@/lib/services/supplemental-forms.service';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
|
import { getPortEmailConfig } from '@/lib/services/port-config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/interests/[id]/supplemental-info-request
|
* POST /api/v1/interests/[id]/supplemental-info-request
|
||||||
@@ -22,7 +23,14 @@ export const POST = withAuth(
|
|||||||
issuedBy: ctx.userId,
|
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) {
|
if (result.clientEmail) {
|
||||||
const html = `
|
const html = `
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ const GROUPS: AdminGroup[] = [
|
|||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
keywords: ['pipeline', 'auto-advance', 'stage rules', 'aggressive', 'conservative'],
|
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',
|
href: 'reminders',
|
||||||
label: 'Reminders',
|
label: 'Reminders',
|
||||||
|
|||||||
@@ -42,12 +42,19 @@ interface InvitationData {
|
|||||||
|
|
||||||
function InvitationBody({ data, accent }: { data: InvitationData; accent: string }) {
|
function InvitationBody({ data, accent }: { data: InvitationData; accent: string }) {
|
||||||
const greeting = `Dear ${data.recipientName},`;
|
const greeting = `Dear ${data.recipientName},`;
|
||||||
const isClient = (data.signerRole ?? 'client') === 'client';
|
const role = data.signerRole ?? 'client';
|
||||||
const leadCopy = isClient
|
// §1.3 audit: role-specific copy stays order-agnostic. The original
|
||||||
? `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.`
|
// copy assumed client→developer→approver order; ports can configure
|
||||||
: data.signerRole === 'approver'
|
// any sequence, so generic phrasing is safer than naming the prior
|
||||||
? `An ${data.documentLabel} is awaiting your approval. The earlier signers have completed their parts; please review and sign to finalise the document.`
|
// signer.
|
||||||
: `An ${data.documentLabel} is awaiting your signature. The client has already signed; you're the next signer in the chain.`;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -56,6 +56,28 @@ export interface DealHealthInput {
|
|||||||
* the scoring function pure / synchronous so the chip can render without a
|
* the scoring function pure / synchronous so the chip can render without a
|
||||||
* separate fetch on every interest list row. */
|
* separate fetch on every interest list row. */
|
||||||
recentActivityCount?: number | null;
|
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<Record<string, boolean>>;
|
||||||
|
labels?: {
|
||||||
|
hot?: string;
|
||||||
|
warm?: string;
|
||||||
|
cold?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DealHealthSignal {
|
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);
|
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 {
|
export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||||
let score = 50;
|
let score = 50;
|
||||||
const signals: DealHealthSignal[] = [];
|
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
|
// 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
|
// anyway, but compute a neutral score so callers using this in reports
|
||||||
// don't crash on undefined.
|
// 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].
|
// Clamp to [0, 100].
|
||||||
score = Math.max(0, Math.min(100, score));
|
score = Math.max(0, Math.min(100, score));
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export const SETTING_KEYS = {
|
|||||||
emailFromName: 'email_from_name',
|
emailFromName: 'email_from_name',
|
||||||
emailFromAddress: 'email_from_address',
|
emailFromAddress: 'email_from_address',
|
||||||
emailReplyTo: 'email_reply_to',
|
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/<token>` is used.
|
||||||
|
supplementalFormUrl: 'supplemental_form_url',
|
||||||
// email_signature_html / email_footer_html — removed; the email shell
|
// email_signature_html / email_footer_html — removed; the email shell
|
||||||
// reads branding_email_header_html / branding_email_footer_html from
|
// reads branding_email_header_html / branding_email_footer_html from
|
||||||
// /admin/branding, which is the source of truth.
|
// /admin/branding, which is the source of truth.
|
||||||
@@ -220,6 +224,12 @@ export interface PortEmailConfig {
|
|||||||
* account. Defaults to false for safety.
|
* account. Defaults to false for safety.
|
||||||
*/
|
*/
|
||||||
allowPersonalAccountSends: boolean;
|
allowPersonalAccountSends: boolean;
|
||||||
|
/**
|
||||||
|
* §1.4: optional per-port URL for the supplemental-info email link.
|
||||||
|
* When set, the email contains `${supplementalFormUrl}?token=<raw>`;
|
||||||
|
* when null, the built-in CRM route is used.
|
||||||
|
*/
|
||||||
|
supplementalFormUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPortEmailConfig(portId: string): Promise<PortEmailConfig> {
|
export async function getPortEmailConfig(portId: string): Promise<PortEmailConfig> {
|
||||||
@@ -232,6 +242,7 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
|
|||||||
smtpUser,
|
smtpUser,
|
||||||
smtpPass,
|
smtpPass,
|
||||||
allowPersonalAccountSends,
|
allowPersonalAccountSends,
|
||||||
|
supplementalFormUrl,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
readSetting<string>(SETTING_KEYS.emailFromName, portId),
|
readSetting<string>(SETTING_KEYS.emailFromName, portId),
|
||||||
readSetting<string>(SETTING_KEYS.emailFromAddress, portId),
|
readSetting<string>(SETTING_KEYS.emailFromAddress, portId),
|
||||||
@@ -241,6 +252,7 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
|
|||||||
readSetting<string>(SETTING_KEYS.smtpUserOverride, portId),
|
readSetting<string>(SETTING_KEYS.smtpUserOverride, portId),
|
||||||
readSetting<string>(SETTING_KEYS.smtpPassOverride, portId),
|
readSetting<string>(SETTING_KEYS.smtpPassOverride, portId),
|
||||||
readSetting<boolean>(SETTING_KEYS.emailAllowPersonalAccountSends, portId),
|
readSetting<boolean>(SETTING_KEYS.emailAllowPersonalAccountSends, portId),
|
||||||
|
readSetting<string>(SETTING_KEYS.supplementalFormUrl, portId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Parse env.SMTP_FROM into name + address if no port override
|
// Parse env.SMTP_FROM into name + address if no port override
|
||||||
@@ -265,6 +277,7 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
|
|||||||
smtpUser: smtpUser ?? env.SMTP_USER ?? null,
|
smtpUser: smtpUser ?? env.SMTP_USER ?? null,
|
||||||
smtpPass: smtpPass ?? env.SMTP_PASS ?? null,
|
smtpPass: smtpPass ?? env.SMTP_PASS ?? null,
|
||||||
allowPersonalAccountSends: allowPersonalAccountSends ?? false,
|
allowPersonalAccountSends: allowPersonalAccountSends ?? false,
|
||||||
|
supplementalFormUrl: supplementalFormUrl ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -353,6 +353,16 @@ export const REGISTRY: SettingEntry[] = [
|
|||||||
scope: 'port',
|
scope: 'port',
|
||||||
placeholder: 'sales@example.com',
|
placeholder: 'sales@example.com',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'supplemental_form_url',
|
||||||
|
section: 'email.from',
|
||||||
|
label: 'Supplemental info form URL (optional)',
|
||||||
|
description:
|
||||||
|
"When set, the supplemental-info email links to this URL with ?token=… appended (typically the marketing site's hosted form). Leave blank to use the built-in CRM form at /public/supplemental-info/<token>. 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 ───────────────────────────────────────────────
|
// ─── Email — SMTP overrides ───────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
@@ -498,6 +508,97 @@ export const REGISTRY: SettingEntry[] = [
|
|||||||
envFallback: 'PUBLIC_SITE_URL',
|
envFallback: 'PUBLIC_SITE_URL',
|
||||||
placeholder: 'https://example.com',
|
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. */
|
/** Quick lookup index keyed by setting key. */
|
||||||
|
|||||||
Reference in New Issue
Block a user