metric-widgets.tsx11.8 KBView on GitHub 'use client';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Activity, DollarSign } from 'lucide-react';
import type { HomeOverview } from '@/modules/home/hooks/use-home-overview';
import { resolveDealsAopId } from '@/modules/aop/utils/deals-aop';
import { useHomePipelineFilters } from '@/modules/home/hooks/use-home-pipeline-filters';
import { PipelineFiltersDialog } from './PipelineFiltersDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { useCedarStore } from '@/modules/store';
import { useTRPC } from '@/providers/query-provider';
import { WidgetFrame } from './WidgetFrame';
import { cn } from '@/lib/utils';
import { formatCount, formatCurrency, formatDuration, formatStageLabel } from './format';
/**
* The rail's two metric tiles. Both are PURE PROJECTIONS of the single
* `statistics.getOverview` the rail fetches — see use-home-overview. Neither fetches the
* overview itself, which is why they cost one request between them.
*
* Both read the same fixed rolling 30-day window. `/statistics` is where rescoping lives.
*/
export interface MetricWidgetProps {
overview: HomeOverview | undefined;
isLoading: boolean;
}
/** Shared table geometry, so the two tiles' number columns line up with each other. */
const NUM_COL = 'w-14 shrink-0 text-right tabular-nums';
/**
* The statistics tile's value column. A duration is ONE value — "21h 52m" wrapped to a second
* line inside the fixed `NUM_COL` width read as two separate figures, so this column sizes to
* its content and never breaks. Rows still line up: the label takes the remaining width, so
* every value shares the same right edge.
*/
const VALUE_COL = 'shrink-0 whitespace-nowrap pl-2 text-right tabular-nums';
// ─── Pipeline ────────────────────────────────────────────────────────────────
/**
* Deals and ACV by stage.
*
* ── It is only a "pipeline" once it is SCOPED ────────────────────────────────
*
* `computePipelineStats` groups over every `crm_conversations` row the user owns, filtered
* by AOP only when an `aopId` is passed. Unscoped on a real account that means cold inbound,
* vendors, recruiting, spam and "unknown" are all counted as deals — measured on one account
* it read 788 "deals" against an actual deal pipeline of 124.
*
* So the widget carries its OWN AOP selection (`homePipelineAopId`, a one-element list so it
* can reuse the rail's settings hook). There is no type column on
* `agent_operating_procedures` — an AOP is a user-named category — so nothing can infer "the
* deal one" for them; the scope is the user's to state.
*
* The default is the fuzzy-matched DEALS AOP, the same match `/statistics` uses (see
* modules/aop/utils/deals-aop.ts) — so the rail and the stats page never quietly count
* different things. The scope's NAME is the table's first column header, which is where a
* table says what it is about, rather than a dropdown restating itself in the widget header.
*
* The rest of the filters live behind the header's hover pencil, in a modal: which pipeline,
* whose, and which stages are three related decisions, and the stage multi-select needs room.
*
* Demo mode blurs both number columns — the rail is the first thing on screen in a
* screen-share, and deal count is as much a fact about the business as ACV is. The stage
* LABELS stay legible, so the table still reads as a pipeline with its figures redacted
* rather than as a broken widget.
*/
export function PipelineWidget({ overview, isLoading }: MetricWidgetProps) {
const trpc = useTRPC();
const isDemoMode = useCedarStore((state) => state.isDemoMode);
const [editOpen, setEditOpen] = useState(false);
const { filters, setFilters } = useHomePipelineFilters();
const { data: aopsData } = useQuery(trpc.aop.listAopsForUser.queryOptions({}));
const aops = useMemo(() => aopsData?.aops ?? [], [aopsData]);
// `undefined` means "never chosen" and falls back to the deals AOP; `''` is the user
// explicitly asking for all conversations. See use-home-pipeline-filters.
const effectiveAopId = filters.aopId ?? resolveDealsAopId(aops);
const scopeName =
effectiveAopId === '' || effectiveAopId === undefined
? 'All conversations'
: (aops.find((a) => a.id === effectiveAopId)?.name ?? 'Pipeline');
const allStages = overview?.pipelineStats ?? [];
const selected = filters.statuses ?? [];
// An empty selection means EVERY stage — the alternative is a widget that shows nothing
// until you have clicked something, which reads as broken.
const stages =
selected.length === 0 ? allStages : allStages.filter((s) => selected.includes(s.status));
const totalAcv = stages.reduce((sum, s) => sum + (s.totalDealValue ?? 0), 0);
const totalCount = stages.reduce((sum, s) => sum + (s.count ?? 0), 0);
// Biggest first, capped: the rail is 21rem and a pipeline with twenty statuses would push
// every other widget off the screen. The TOTAL row still sums all of them, so the cap
// hides rows without ever misstating the number.
const shown = [...stages].sort((a, b) => b.totalDealValue - a.totalDealValue).slice(0, 6);
return (
<>
<WidgetFrame
title="Pipeline"
icon={DollarSign}
onEdit={() => setEditOpen(true)}
editLabel="Pipeline filters"
>
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
) : (
// A real table: header rule, a divider under every row, and a heavier rule above
// the total. Three columns you read across and compare down need a grid.
<div className="flex flex-col text-sm">
<div className="flex items-center gap-2 border-b border-border pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">
<span className="min-w-0 flex-1 truncate" title={scopeName}>
{scopeName}
</span>
<span className="w-10 shrink-0 text-right">Deals</span>
<span className={NUM_COL}>ACV</span>
</div>
{shown.map((stage) => (
<div
key=[redacted]
className="flex items-center gap-2 border-b border-border/50 py-1.5"
>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{formatStageLabel(stage.status)}
</span>
<span
className={cn(
'w-10 shrink-0 text-right font-medium tabular-nums text-foreground transition-all',
isDemoMode && 'select-none blur-[2px]',
)}
title={isDemoMode ? 'Hidden in demo mode' : undefined}
>
{formatCount(stage.count) ?? '0'}
</span>
<span
className={cn(
NUM_COL,
'font-medium text-foreground transition-all',
isDemoMode && 'select-none blur-[2px]',
)}
title={isDemoMode ? 'Hidden in demo mode' : undefined}
>
{formatCurrency(stage.totalDealValue) ?? '—'}
</span>
</div>
))}
{shown.length === 0 && (
<p className="py-2 text-xs text-muted-foreground">
Nothing in this pipeline in the last 30 days.
</p>
)}
{/* The total is a ROW of the same table, so it lines up under the columns it
sums instead of being a separate sentence to re-orient to. */}
<div className="flex items-center gap-2 border-t-2 border-border pt-1.5">
<span className="min-w-0 flex-1 truncate font-medium text-foreground">Total</span>
<span
className={cn(
'w-10 shrink-0 text-right font-semibold tabular-nums text-foreground transition-all',
isDemoMode && 'select-none blur-[2px]',
)}
title={isDemoMode ? 'Hidden in demo mode' : undefined}
>
{formatCount(totalCount) ?? '0'}
</span>
<span
className={cn(
NUM_COL,
'font-semibold text-foreground transition-all',
isDemoMode && 'select-none blur-[2px]',
)}
title={isDemoMode ? 'Hidden in demo mode' : undefined}
>
{formatCurrency(totalAcv) ?? '—'}
</span>
</div>
</div>
)}
</WidgetFrame>
{editOpen && (
<PipelineFiltersDialog
open
onOpenChange={setEditOpen}
filters={filters}
onChange={setFilters}
availableStatuses={allStages.map((s) => s.status)}
/>
)}
</>
);
}
// ─── Statistics ──────────────────────────────────────────────────────────────
/**
* Response time, follow-up time, emails sent and total actions — ONE widget, four rows.
*
* They were four separate tiles, each a big number in its own bordered card, which took four
* cards' worth of the rail to say four numbers. As rows of one table they are also comparable,
* which four stacked cards never were.
*/
export function StatisticsWidget({ overview, isLoading }: MetricWidgetProps) {
const isDemoMode = useCedarStore((state) => state.isDemoMode);
const sentEmails = overview?.activityTimeline?.reduce(
(sum, day) => sum + (day.emailOutbound ?? 0),
0,
);
// In demo mode (blur-for-screen-share) the figures are swapped for fixed presentable values
// rather than blurred — same call as the home stat cards — so a shared screen reads cleanly.
const rows: { label: string; value: string | null; demoValue: string }[] = [
{
label: 'Response time',
value: formatDuration(overview?.responseTime?.medianSeconds),
demoValue: '1h 48m',
},
{
label: 'Follow-up time',
value: formatDuration(overview?.followUpTime?.medianSeconds),
demoValue: '38m',
},
{ label: 'Emails sent', value: formatCount(sentEmails), demoValue: '412' },
{ label: 'Total actions', value: formatCount(overview?.totalEvents), demoValue: '1,284' },
];
return (
<WidgetFrame
title="Statistics"
icon={Activity}
action={<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">30d</span>}
>
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
) : (
<div className="flex flex-col text-sm">
{rows.map((row, index) => (
<div
key=[redacted]
className={`flex items-center gap-2 py-1.5 ${
index < rows.length - 1 ? 'border-b border-border/50' : ''
}`}
>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{row.label}</span>
{/* An em dash, not "0". A window with no replies and a window with instant
replies are different facts, and printing zero for the first is a lie. */}
<span className={`${VALUE_COL} font-medium text-foreground`}>
{isDemoMode ? row.demoValue : (row.value ?? '—')}
</span>
</div>
))}
</div>
)}
</WidgetFrame>
);
}