/** * Every status a PIG record can be in, drawn once. * * The audit found this file's contents scattered across five others: account * side and relationship state in `Account.tsx` and again in `Growth.tsx` with * different tones for the same value, contract status in `Contracts.tsx` and * again in `Account.tsx` disagreeing about whether "expired" is red, run and * task state in `activity-panel.tsx`, renewal urgency in `Contracts.tsx`. Two * implementations of the same badge do not merely duplicate; they contradict, * and a reader who learns that green means good on one page and nothing on the * next has learned that colour here is decoration. * * So the tones follow one rule, taken from the design direction, and the rule * is stricter than what any of the five did: * * positive a business figure that is good — capacity actually running, a * deal won, an expansion open * warning something needs a person NOW — a notice window already open * danger a loss, or a failure a person must resolve — an account gone, a * run that failed, an authorisation that has lapsed * info a neutral time or system fact — running, queued, out for signature * neutral done, fine, nothing to do * * The consequence, and it is the point: **process outcomes get no colour.** * "Executed", "Succeeded", "Closed lost", "Terminated" are all neutral. A * ledger where every success is green is a ledger nobody scans, and the one * row that needs a person is invisible in a column of colour. * * Colour is never the only signal. Every badge here carries a word, and the * states that need a person or record a failure carry a mark as well, so the * scan works without hue: a **circle** for "act on this", a **triangle** for * "this went wrong". Two different shapes, not two colours of the same one — * lucide's `AlertTriangle` is an alias of `TriangleAlert`, so the obvious pair * drew the identical glyph twice and the distinction existed only in the hue it * was supposed to be independent of. Nothing else gets an icon: a badge set * where every chip has a glyph is a badge set with no emphasis left to spend. */ import { CircleAlert, TriangleAlert } from 'lucide-react'; import { DEMAND_STAGE_LABELS, SUPPLY_STAGE_LABELS, type AccountSide, type ContractStatus, type CustomerRelationshipState, type DemandStage, type GrowthFacet, type SupplyStage, } from '@pig/core'; import { Badge, cn } from '@/components/ui'; import { shortDate } from '@/lib/api'; /** The tones `Badge` understands. `accent` is deliberately absent: the accent * is PIG's identity colour and never carries meaning. */ export type StatusTone = 'neutral' | 'positive' | 'warning' | 'danger' | 'info'; /** * The two marks, sized to the 12px badge text. * * Lucide ships icons at 24px and `Badge` does not size its children, so an * unsized glyph in a badge renders twice the height of the word beside it — * which is what `Contracts.tsx` and `Growth.tsx` were both doing. */ function ActMark() { return ; } function FailMark() { return ; } /** Underscored enum value to a readable word, for the values with no label map. */ function humanise(value: string): string { const words = value.replaceAll('_', ' ').trim(); return words ? `${words.charAt(0).toUpperCase()}${words.slice(1)}` : value; } // --------------------------------------------------------------- account side /** * Which side of the book an account sits on. * * All three are neutral. Side is identity, not status — nothing about being a * supplier is good or bad or needs anybody — and the words already tell them * apart. `Account.tsx` and `Accounts.tsx` were both painting supply blue and * both sides accent, which spent two of the five tones on a fact that changes * nothing a reader would do. */ export const SIDE_TONES: Record = { supply: 'neutral', demand: 'neutral', both: 'neutral', }; export const SIDE_LABELS: Record = { supply: 'Buy-side', demand: 'Sell-side', both: 'Both sides', }; export function SideBadge({ side, className }: { side: AccountSide; className?: string }) { return ( {SIDE_LABELS[side]} ); } // -------------------------------------------------------- customer lifecycle /** * Where a customer relationship stands. * * `deployed` is the only good one: capacity is actually running, which is the * figure the business is built on. `former_customer` is a loss and says so. * `prospect` and `contracted` are stages on the way, and a stage is not news. */ export const RELATIONSHIP_TONES: Record = { prospect: 'neutral', contracted: 'neutral', deployed: 'positive', former_customer: 'danger', }; export const RELATIONSHIP_LABELS: Record = { prospect: 'Prospect', contracted: 'Contracted', deployed: 'Deployed', former_customer: 'Former customer', }; export function RelationshipBadge({ state, className, }: { state: CustomerRelationshipState; className?: string; }) { return ( {RELATIONSHIP_LABELS[state]} ); } // ---------------------------------------------------------------- growth facet /** * Why an account is on the Growth page. * * The old version coloured all six, which made the page a mosaic. Here a * deadline and a risk are the only things that get a person's attention, and * the hygiene facets — a coverage gap, stale data — are grey. Stale data was * `warning` before, competing for the eye with `at_risk` on the same row. */ export const FACET_TONES: Record = { expansion_candidate: 'positive', renewal_due: 'warning', at_risk: 'danger', idle_supply_match: 'info', coverage_gap: 'neutral', data_stale: 'neutral', }; export const FACET_LABELS: Record = { expansion_candidate: 'Expansion candidate', renewal_due: 'Renewal due', at_risk: 'At risk', idle_supply_match: 'Idle supply match', coverage_gap: 'Coverage gap', data_stale: 'Data stale', }; export function FacetBadge({ facet, className }: { facet: GrowthFacet; className?: string }) { return ( {facet === 'renewal_due' ? : facet === 'at_risk' ? : null} {FACET_LABELS[facet]} ); } // -------------------------------------------------------------------- deals /** * Pipeline stage, both sides. * * Won and live are business-good; everything in flight is process and stays * grey, which is the change from the accent-coloured pipeline the pages drew * before. `churned` is red where `closed_lost` is not: losing a relationship * is a loss worth marking, losing one deal out of a pipeline of them is the * ordinary shape of the job. */ export const DEMAND_STAGE_TONES: Record = { qualification: 'neutral', legal: 'neutral', scoping: 'neutral', proposal: 'neutral', procurement: 'neutral', poc: 'neutral', deployment: 'neutral', expansion: 'neutral', closed_won: 'positive', closed_lost: 'neutral', }; export const SUPPLY_STAGE_TONES: Record = { sourced: 'neutral', qualifying: 'neutral', technical_diligence: 'neutral', financial_diligence: 'neutral', pricing: 'neutral', contracting: 'neutral', onboarding: 'neutral', live: 'positive', renewal: 'warning', churned: 'danger', rejected: 'neutral', }; export type DealStageBadgeProps = | { side: 'demand'; stage: DemandStage; className?: string } | { side: 'supply'; stage: SupplyStage; className?: string }; /** * Not named in the direction's component table, but the audit counted deal * stage among the domains drawn several ways, and `Account.tsx` holds two * private tone functions for it. It belongs with the rest of the vocabulary. */ export function DealStageBadge(props: DealStageBadgeProps) { const { tone, label } = props.side === 'demand' ? { tone: DEMAND_STAGE_TONES[props.stage], label: DEMAND_STAGE_LABELS[props.stage] } : { tone: SUPPLY_STAGE_TONES[props.stage], label: SUPPLY_STAGE_LABELS[props.stage] }; return ( {props.side === 'supply' && props.stage === 'renewal' ? : null} {label} ); } // ---------------------------------------------------------------- contracts /** * Paper state. * * `executed` is neutral, which is the tone change most likely to be questioned. * It is a process outcome — the paper is signed, nothing follows from it today * — and on the contracts list most rows are executed, so colouring it green * paints the whole table and leaves the expiring one indistinguishable. * `out_for_signature` is `info` rather than `warning` because it is waiting on * the counterparty, not on us. */ export const CONTRACT_STATUS_TONES: Record = { draft: 'neutral', in_review: 'neutral', in_negotiation: 'neutral', out_for_signature: 'info', executed: 'neutral', expired: 'danger', terminated: 'neutral', }; export const CONTRACT_STATUS_LABELS: Record = { draft: 'Draft', in_review: 'In review', in_negotiation: 'Negotiating', out_for_signature: 'For signature', executed: 'Executed', expired: 'Expired', terminated: 'Terminated', }; export function ContractStatusBadge({ status, className, }: { status: ContractStatus; className?: string; }) { return ( {status === 'expired' ? : null} {CONTRACT_STATUS_LABELS[status]} ); } // ------------------------------------------------------------------ renewals /** Mirrors `RenewalState` in `apps/api/src/services/contracts.ts`, which the * web app cannot import. Expiry minus notice days against today. */ export type RenewalState = 'not_applicable' | 'scheduled' | 'due' | 'expired'; export const RENEWAL_TONES: Record = { not_applicable: 'neutral', scheduled: 'neutral', due: 'warning', expired: 'danger', }; /** * Renewal urgency, which is the one status in the product that is worth money * on a deadline: a notice window that quietly opened last week is the most * expensive thing in this book to miss. * * Only the two states that need a person are drawn as badges. A contract with * no alarm, or one whose notice is months away, is fine print — this appears in * a column, and forty grey chips down a table are forty things to look past * before finding the two amber ones. That restraint is `Contracts.tsx`'s * original design and it is kept deliberately rather than regularised away. */ export function RenewalBadge({ state, noticeAt, className, }: { state: RenewalState; /** The computed notice date, shown when it is still ahead. */ noticeAt?: string | Date | null; className?: string; }) { if (state === 'due') { return ( Notice due ); } if (state === 'expired') { return ( Expired ); } return ( {state === 'not_applicable' ? 'No alarm' : noticeAt ? `Notice ${shortDate(noticeAt)}` : 'Notice scheduled'} ); } // ------------------------------------------------------------ agent activity /** * A run's status is free text on the wire — `agent_runs.status` is a `text` * column — so an unrecognised value is shown as it arrived, in neutral, rather * than forced into one of the four we know. A status this panel has never heard * of is information, not an error. * * `aborted` reads "Stopped by you" because that is what it means: the reader * pressed Stop, or navigated away. "Aborted" describes the process; the person * wants to know whether it was them. */ export const RUN_STATUS_TONES: Record = { running: 'info', awaiting_approval: 'warning', succeeded: 'neutral', aborted: 'neutral', failed: 'danger', }; export const RUN_STATUS_LABELS: Record = { running: 'Running', awaiting_approval: 'Needs you', succeeded: 'Succeeded', aborted: 'Stopped by you', failed: 'Failed', }; export function RunStatusBadge({ status, className }: { status: string; className?: string }) { const tone = RUN_STATUS_TONES[status] ?? 'neutral'; return ( {status === 'failed' ? : status === 'awaiting_approval' ? : null} {RUN_STATUS_LABELS[status] ?? humanise(status)} ); } /** Mirrors `PiggyTaskSummary['state']`: the three live states the queue derives * plus `AGENT_TASK_OUTCOMES`. */ export type TaskState = | 'running' | 'queued' | 'scheduled' | 'succeeded' | 'failed' | 'skipped' | 'cancelled'; export const TASK_STATE_TONES: Record = { running: 'info', queued: 'info', scheduled: 'info', succeeded: 'neutral', failed: 'danger', skipped: 'neutral', cancelled: 'neutral', }; export const TASK_STATE_LABELS: Record = { running: 'Running', queued: 'Queued', scheduled: 'Scheduled', succeeded: 'Succeeded', failed: 'Failed', skipped: 'Skipped', cancelled: 'Cancelled', }; export function TaskStateBadge({ state, className }: { state: TaskState; className?: string }) { return ( {state === 'failed' ? : null} {TASK_STATE_LABELS[state]} ); } // ------------------------------------------------------------------ approval /** The five states of a proposed write. Structurally identical to * `PiggyApprovalState` in `piggy/approval-card.tsx`, which owns the * transitions; declared here so this module does not depend on the card. */ export type ApprovalState = 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed'; /** * The decided table from the design direction. * * `applied` is neutral, not green — the confirmation the reader wants is a * single positive check beside the record link in the card's status line, not a * green block in a transcript where every approved write would then be green. * `pending` is the only warning, and it is warning because it is the only state * in the product where the agent has stopped and is waiting for a person. */ export const APPROVAL_STATE_TONES: Record = { pending: 'warning', submitting: 'neutral', applied: 'neutral', rejected: 'neutral', failed: 'danger', }; export function ApprovalStateBadge({ state, /** Which way the person answered, so `submitting` can say which. */ decision, className, }: { state: ApprovalState; decision?: 'apply' | 'reject' | null; className?: string; }) { const label = state === 'pending' ? 'Needs you' : state === 'submitting' ? decision === 'reject' ? 'Rejecting' : 'Applying' : state === 'applied' ? 'Applied' : state === 'rejected' ? 'Rejected' : 'Not applied'; return ( {state === 'pending' ? : state === 'failed' ? : null} {label} ); }