import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { RestApiClient } from 'twenty-client-sdk/rest'; import { defineFrontComponent } from 'twenty-sdk/define'; import { enqueueSnackbar } from 'twenty-sdk/front-component'; import { ICP_CLIENT_PATH, ICP_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, } from 'src/constants/icp-identifiers'; import type { UnmatchedValues } from 'src/icp/icp-analysis'; import type { IcpImpact } from 'src/icp/icp-impact'; import type { IcpProposal, IcpSelection, IcpValueDimensionProposal, } from 'src/icp/icp-proposal'; import { mergeBucketsIntoBands, toPlainBand, type HeadcountBucket, } from 'src/icp/icp-size-bands'; import type { IcpStatus } from 'src/icp/icp-status'; /** * "Ideal customer profile" — the surface that makes an inert ICP impossible to * miss, and a proposed one impossible to apply blind. * * ## Why this leads with a number, twice * * The backfill panel established the pattern and the reason holds harder here. * The defect is not "the ICP is empty" — an empty ICP is the correct shipped * default, and the engine handles it correctly by dropping the three rules from * both sides of the weighting. The defect is that **the consequence is * invisible**: three rules quietly report `not_applicable`, forty points of the * model do nothing, and the score that comes out still looks like a score. So * the first thing on this page is not a button and not a warning triangle, it is * the arithmetic: *this many points of your model are switched off, which is this * percentage of it, and it is the part that decides fit.* * * The second number is the impact preview, and it exists for the mirror-image * reason. Writing an ICP changes every future score, and a profile that reads * perfectly can still hold a thousand leads a team is working from — because the * rules that were exempt start failing on records that simply have no industry * recorded. Nobody can predict that from the profile. They can only be shown it, * measured, before they commit. So Apply stays disabled until an impact has been * measured against the exact profile on screen, and it re-disables the moment a * checkbox changes. * * ## Why the suggestion is a proposal and never an application * * Every chip below is a value somebody in this workspace typed into a Company * record, with the count next to it. Greenlight has no opinion about which of * them the business sells to and cannot acquire one from a CRM export — an ICP is * a commercial decision. What it can do is put the evidence in front of the * person who does have that opinion, and make it cheap for them to say "those * four, not those nine". * * ## Nothing here is trusted * * The panel posts an action and a profile; the logic function re-validates both * against closed lists, re-measures the impact server-side before writing, and * takes the requester's identity from the authenticated request. The confirmation * checkbox is a courtesy — the server requires `confirm: true` in the body and * would refuse without it — so the honesty is in the copy, not in the guard. */ const GO = '#1F8A4C'; const WARN = '#B54708'; const STOP = '#B42318'; const INK = '#333'; const MUTED = '#888'; const PANEL_STYLE: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: '16px', padding: '20px', height: '100%', overflowY: 'auto', boxSizing: 'border-box', fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontSize: '13px', color: INK, }; const CARD_STYLE: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: '10px', padding: '14px 16px', borderRadius: '10px', border: '1px solid #e0e0e0', }; interface IcpResponse { readonly outcome?: string; readonly message?: string; readonly status?: IcpStatus | null; readonly proposal?: IcpProposal | null; readonly impact?: IcpImpact | null; readonly selection?: IcpSelection | null; readonly warnings?: readonly string[]; readonly notes?: readonly string[]; readonly unmatched?: { readonly industry: UnmatchedValues; readonly region: UnmatchedValues; } | null; readonly rescore?: { started: boolean; message: string } | null; } const serverMessage = (error: unknown): string => { const body = (error as { body?: unknown } | null)?.body; if (typeof body === 'object' && body !== null) { const message = (body as Record)['message']; if (typeof message === 'string' && message !== '') { return message; } } return error instanceof Error && error.message !== '' ? error.message : 'Something went wrong.'; }; const formatMoment = (iso: string | null | undefined): string => { if (iso === null || iso === undefined) { return '—'; } const parsed = new Date(iso); return Number.isNaN(parsed.getTime()) ? iso : parsed.toLocaleString(); }; const percent = (share: number): string => `${Math.round(share * 100)}%`; /** Rule ids are how the trace names these; nobody outside the code says them. */ const DIMENSION_COPY: Record = { 'icp.industry': 'Industry', 'icp.region': 'Region', 'icp.company-size': 'Company size', }; const FIELD_COPY: Record = { industry: 'an industry', region: 'a country or region', employeeCount: 'an employee count', }; const CONFIDENCE_COPY: Record = { none: { label: 'no evidence', color: MUTED }, low: { label: 'thin evidence', color: WARN }, moderate: { label: 'moderate evidence', color: INK }, high: { label: 'strong evidence', color: GO }, }; const Chip = ({ checked, onToggle, title, subtitle, disabled, }: { checked: boolean; onToggle: () => void; title: string; subtitle: string; disabled: boolean; }) => ( ); const Stat = ({ label, value, color, }: { label: string; value: string; color?: string; }) => (
{label} {value}
); const button = ( primary: boolean, disabled: boolean, ): React.CSSProperties => ({ padding: '10px 16px', borderRadius: '8px', border: '1px solid #e0e0e0', background: disabled ? '#f5f5f5' : primary ? GO : '#fff', color: disabled ? '#aaa' : primary ? '#fff' : INK, fontWeight: 600, cursor: disabled ? 'not-allowed' : 'pointer', }); /** Does a proposed band already accept everything in this bucket? */ const bucketIsCovered = ( bucket: HeadcountBucket, bands: IcpSelection['sizeBands'], ): boolean => bands.some( (band) => bucket.minEmployees >= band.minEmployees && (band.maxEmployees === null || (bucket.maxEmployees !== null && bucket.maxEmployees <= band.maxEmployees)), ); const GreenlightIcp = () => { const [state, setState] = useState(null); const [busy, setBusy] = useState(null); const [industryPicks, setIndustryPicks] = useState([]); const [regionPicks, setRegionPicks] = useState([]); const [bucketPicks, setBucketPicks] = useState([]); const [extraIndustry, setExtraIndustry] = useState(''); const [extraRegion, setExtraRegion] = useState(''); const [customIndustries, setCustomIndustries] = useState([]); const [customRegions, setCustomRegions] = useState([]); /** The profile the impact on screen was measured against. */ const [measured, setMeasured] = useState(null); const [confirmApply, setConfirmApply] = useState(false); const [rescore, setRescore] = useState(true); const mounted = useRef(true); const post = useCallback( async (body: Record): Promise => { try { const response = await new RestApiClient().post( ICP_CLIENT_PATH, body, ); if (mounted.current) { setState(response ?? null); } return response ?? null; } catch (error) { if (mounted.current) { enqueueSnackbar({ message: serverMessage(error), variant: 'error' }); } return null; } }, [], ); useEffect(() => { mounted.current = true; void post({ action: 'status' }); return () => { mounted.current = false; }; }, [post]); const proposal = state?.proposal ?? null; const status = state?.status ?? null; const impact = state?.impact ?? null; const industryClusters = useMemo( () => proposal === null ? [] : [...proposal.industry.suggestions, ...proposal.industry.alternatives], [proposal], ); const regionClusters = useMemo( () => proposal === null ? [] : [...proposal.region.suggestions, ...proposal.region.alternatives], [proposal], ); const buckets = proposal?.size.buckets ?? []; /** Seed the checkboxes from a fresh proposal, once per proposal. */ useEffect(() => { if (proposal === null) { return; } setIndustryPicks(proposal.industry.suggestions.map((one) => one.label)); setRegionPicks(proposal.region.suggestions.map((one) => one.label)); setBucketPicks( proposal.size.buckets .filter((bucket) => bucketIsCovered(bucket, proposal.selection.sizeBands), ) .map((bucket) => bucket.label), ); setCustomIndustries([]); setCustomRegions([]); }, [proposal]); const selection: IcpSelection = useMemo( () => ({ industries: [ ...industryClusters .filter((cluster) => industryPicks.includes(cluster.label)) .flatMap((cluster) => cluster.values), ...customIndustries, ], regions: [ ...regionClusters .filter((cluster) => regionPicks.includes(cluster.label)) .flatMap((cluster) => cluster.values), ...customRegions, ], // The same helper the server used to build the proposal, on the same // buckets — so an untouched proposal serialises to exactly what was // already measured and Apply is reachable without a redundant round trip. sizeBands: mergeBucketsIntoBands(bucketPicks, buckets).map(toPlainBand), }), [ industryClusters, industryPicks, customIndustries, regionClusters, regionPicks, customRegions, buckets, bucketPicks, ], ); const selectionKey = JSON.stringify(selection); const impactIsCurrent = measured === selectionKey && impact !== null; const selectionIsEmpty = selection.industries.length === 0 && selection.regions.length === 0 && selection.sizeBands.length === 0; const act = async ( action: string, body: Record = {}, ): Promise => { setBusy(action); const response = await post({ action, ...body }); if (response?.message !== undefined && response.message !== '') { enqueueSnackbar({ message: response.message, variant: response.outcome === 'applied' ? 'success' : 'info', }); } // Both `propose` and `preview` come back with an impact and the profile it // was measured against. Recording that profile verbatim is what makes // "these numbers describe what is on screen" a fact rather than a hope: the // moment a checkbox moves, the derived profile stops matching and Apply // goes back to being unreachable. if ( response !== null && (action === 'preview' || action === 'propose') && response.selection !== null && response.selection !== undefined && response.impact !== null && response.impact !== undefined ) { setMeasured(JSON.stringify(response.selection)); } if (response?.outcome === 'applied' || response?.outcome === 'left_open') { setConfirmApply(false); setMeasured(null); } setBusy(null); }; const toggle = ( values: readonly string[], setter: (next: readonly string[]) => void, label: string, ) => { setConfirmApply(false); setter( values.includes(label) ? values.filter((one) => one !== label) : [...values, label], ); }; const disabled = busy !== null; return (
Ideal customer profile Which industries, regions and company sizes count as a fit. Greenlight ships this empty on purpose — it cannot know your market — and until you fill it in, the part of the score that judges fit does nothing.
{state === null ? ( Checking… ) : status === null ? (
{state.message !== undefined && state.message !== '' ? state.message : 'Greenlight could not read its configuration.'}
) : ( <>
{status.idlePoints === 0 ? 'Your profile is scoring' : `${status.idlePoints} of ${status.totalPoints} scoring points are switched off`} {status.headline}
{status.dimensions.map((dimension) => ( ))}
{status.review.decision === 'APPLIED' ? `Profile applied ${formatMoment(status.review.reviewedAt)}.` : status.review.decision === 'LEFT_OPEN' ? `Deliberately left open ${formatMoment(status.review.reviewedAt)}. Scores are built from contact quality and compliance alone, and that is a decision on record.` : 'Nobody has reviewed this profile yet.'}
{(state.notes ?? []).length > 0 && (
Where this comes from {(state.notes ?? []).map((note) => ( {note} ))}
)} toggle(industryPicks, setIndustryPicks, label)} /> toggle(regionPicks, setRegionPicks, label)} /> {(state.warnings ?? []).length > 0 && (state.warnings ?? []).map((warning) => ( {warning} ))} {proposal === null ? ( <> Greenlight can read your existing Companies and show you which industries, regions and sizes they actually cluster into. It is a suggestion with the evidence attached, not a setting — nothing is written until you say so. ) : ( <> toggle(industryPicks, setIndustryPicks, label) } custom={customIndustries} onRemoveCustom={(value) => { setConfirmApply(false); setCustomIndustries( customIndustries.filter((one) => one !== value), ); }} draft={extraIndustry} onDraft={setExtraIndustry} onAdd={() => { const value = extraIndustry.trim(); if (value.length > 0 && !customIndustries.includes(value)) { setConfirmApply(false); setCustomIndustries([...customIndustries, value]); } setExtraIndustry(''); }} disabled={disabled} /> toggle(regionPicks, setRegionPicks, label)} custom={customRegions} onRemoveCustom={(value) => { setConfirmApply(false); setCustomRegions(customRegions.filter((one) => one !== value)); }} draft={extraRegion} onDraft={setExtraRegion} onAdd={() => { const value = extraRegion.trim(); if (value.length > 0 && !customRegions.includes(value)) { setConfirmApply(false); setCustomRegions([...customRegions, value]); } setExtraRegion(''); }} disabled={disabled} />
Company size {CONFIDENCE_COPY[proposal.size.confidence]?.label ?? proposal.size.confidence}
{proposal.size.reason} {proposal.size.buckets.map((bucket) => ( toggle(bucketPicks, setBucketPicks, bucket.label) } title={bucket.label} subtitle={ bucket.count === 0 ? 'no companies of this size on record' : `${bucket.count.toLocaleString()} companies · ${percent(bucket.share)} of those with a headcount` } /> ))} {proposal.size.known > 0 && ( Anything outside every band you tick fails the size rule, not just scores lower. )}
{ setMeasured(selectionKey); void act('preview', { selection }); }} />
{state.rescore !== null && state.rescore !== undefined && ( {state.rescore.message} )}
)} {status.review.decision === 'NOT_REVIEWED' && (
Or say you meant to leave it open Plenty of businesses genuinely sell to every industry in every country. If that is you, record it — the profile stays permissive, scores keep being built from contact quality and compliance, and this page stops telling you something is missing when nothing is.
)} )}
); }; /* -------------------------------------------------------------------------- */ /* Sub-components */ /* -------------------------------------------------------------------------- */ /** * Values the workspace's own records carry that the profile rejects. * * This panel exists for one failure a customer would otherwise have to diagnose * from a score that will not move: enrichment writes an industry, the industry * rule keeps failing, and every surface reports it as a property of the lead. * It is not — matching is exact set membership and the value is simply absent * from the list. The lead cannot be fixed; the list can. * * Each row adds to the same pick set the proposal below uses, so accepting one * is the apply flow that already exists rather than a second way to write * configuration. Nothing here changes a score on its own. */ const UnmatchedPanel = ({ dimension, found, picked, onAdd, }: { dimension: 'industries' | 'regions'; found: UnmatchedValues | undefined; picked: readonly string[]; onAdd: (label: string) => void; }) => { if (found === undefined || found.clusters.length === 0) { return null; } const companies = found.companies.toLocaleString('en-US'); return (
{found.clusters.length.toLocaleString('en-US')}{' '} {found.clusters.length === 1 ? dimension.replace(/s$/, '') : dimension}{' '} on your records that this profile rejects {companies} {found.companies === 1 ? 'company carries' : 'companies carry'}{' '} a value that is not on your list. Matching is exact, so these score as a miss — and will keep doing so however well enrichment fills the field in. Add the ones you actually sell to; leave the rest.
{found.clusters.slice(0, 12).map((cluster) => { const isPicked = picked.includes(cluster.label); return ( ); })}
{found.clusters.length > 12 && ( The {(found.clusters.length - 12).toLocaleString('en-US')} rarest are not shown — the full list is in the panel below, under “Show every value”. )}
); }; const ValueDimension = ({ dimension, clusters, picks, onToggle, custom, onRemoveCustom, draft, onDraft, onAdd, disabled, }: { dimension: IcpValueDimensionProposal; clusters: IcpProposal['industry']['suggestions']; picks: readonly string[]; onToggle: (label: string) => void; custom: readonly string[]; onRemoveCustom: (value: string) => void; draft: string; onDraft: (value: string) => void; onAdd: () => void; disabled: boolean; }) => { const [showAll, setShowAll] = useState(false); const suggested = new Set(dimension.suggestions.map((one) => one.label)); const visible = showAll ? clusters : clusters.filter((cluster) => suggested.has(cluster.label)); const confidence = CONFIDENCE_COPY[dimension.confidence]; return (
{dimension.dimension === 'industry' ? 'Industries' : 'Regions'} {confidence?.label ?? dimension.confidence}
{dimension.reason} {visible.map((cluster) => ( onToggle(cluster.label)} title={cluster.label} subtitle={`${cluster.count.toLocaleString()} companies · ${percent( cluster.share, )}${ cluster.values.length > 1 ? ` · written ${cluster.values.length} ways: ${cluster.values.join(', ')}` : '' }${cluster.examples.length > 0 ? ` · e.g. ${cluster.examples.join(', ')}` : ''}`} /> ))} {clusters.length > visible.length && ( )} {custom.map((value) => (
{value}{' '} · added by hand, not seen in your data
))}
onDraft(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); onAdd(); } }} style={{ flex: 1, padding: '8px 10px', borderRadius: '8px', border: '1px solid #e0e0e0', fontSize: '13px', }} />
{/* The example has to come from the dimension being edited. This card is rendered twice — once for industries, once for regions — and a regions card that illustrates case-insensitive matching with “SaaS” is telling an admin about a value that cannot appear in the column they are looking at. */} Matching is exact, ignoring case and spacing. Ticking a row writes every spelling of it that exists in your data, so a lead recorded as{' '} {dimension.dimension === 'industry' ? '“SaaS” and one recorded as “saas”' : '“United Kingdom” and one recorded as “united kingdom”'}{' '} both match.
); }; const ImpactCard = ({ impact, current, empty, busy, disabled, onMeasure, }: { impact: IcpImpact | null; current: boolean; empty: boolean; busy: boolean; disabled: boolean; onMeasure: () => void; }) => { const unreadable = (impact?.readability ?? []).filter( (entry) => entry.active && entry.readable === 0 && entry.unreadable > 0, ); return (
0 ? `${WARN}55` : '#e0e0e0', }} > What applying this would change {empty ? ( Nothing is ticked, so there is no profile to measure. An empty profile is the state you are already in. ) : impact === null || !current ? ( {impact === null ? 'Greenlight scores a sample of your leads twice — once as they stand and once against this profile — so you can see who would start being held before anything is written.' : 'You changed the profile, so the numbers below no longer describe it. Measure again.'} ) : null} {impact !== null && current && !empty && ( <>
0 ? WARN : GO} value={`${impact.newlyGated.toLocaleString()} of ${impact.sampled.toLocaleString()} sampled`} />
{impact.estimatedNewlyGated !== null && impact.totalLeads !== null && impact.totalLeads > impact.sampled && ( 0 ? WARN : MUTED, lineHeight: 1.5, }} > Scaled to all {impact.totalLeads.toLocaleString()} leads, roughly{' '} {impact.estimatedNewlyGated.toLocaleString()} currently-passing leads {' '} would start being held for review. That is an estimate from a sample of {impact.sampled.toLocaleString()}, not a count. )} {unreadable.length > 0 && ( Greenlight could not read{' '} {unreadable .map((entry) => FIELD_COPY[entry.fieldKey] ?? entry.fieldKey) .join(' or ')}{' '} on a single one of the {impact.sampled.toLocaleString()} leads sampled. It looks for{' '} {unreadable[0]?.candidates.join(', ') ?? 'those fields'} on the lead record. Applied as it stands, that rule would fail every lead for want of data rather than filter anything — the profile is not what is wrong, the data behind it is missing. )} {impact.readability .filter((entry) => entry.active && entry.readable > 0) .map((entry) => ( {DIMENSION_COPY[entry.ruleId] ?? entry.ruleId}: readable on{' '} {entry.readable.toLocaleString()} of{' '} {impact.sampled.toLocaleString()} sampled leads {entry.resolvedPaths.length > 0 ? ` (from ${entry.resolvedPaths.join(', ')})` : ''} ; {entry.matched.toLocaleString()} would match,{' '} {entry.mismatched.toLocaleString()} would not. ))} {impact.examples.length > 0 && (
Leads that would start being held {impact.examples.map((example) => ( {example.display}: {example.scoreBefore ?? '—'} →{' '} {example.scoreAfter ?? '—'} · {example.reason} ))}
)} )}
); }; export default defineFrontComponent({ universalIdentifier: ICP_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, name: 'greenlight-icp', description: 'Shows how much of the Greenlight scoring model is idle for want of an ideal-customer profile, proposes one from the workspace’s own companies with the evidence attached, previews what applying it would change, and applies it.', component: GreenlightIcp, });