/**
* 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}
);
}