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