CumulativeFieldsSection.tsx4.8 KBView on GitHub
/**
 * CumulativeFieldsSection — pick which roll-up tiles a canvas shows.
 *
 * Lives inside the Fields popover next to column selection, because that is the same
 * question in a different direction: Fields chooses what each ROW shows, this chooses what
 * the WHOLE SET shows. Keeping them in one place means there is one answer to "how do I
 * change what this view displays".
 *
 * Writes `summaries` on the canvas viewConfig; the bar itself renders nothing when the list
 * is empty, so unchecking everything is the same as turning it off.
 */

import type {
  CanvasSummaryMetric,
  CanvasSummarySpec,
  ConversationViewConfig,
} from '@/modules/canvas/types/canvas-types';
import { Checkbox } from '@/components/ui/checkbox';
import { useCedarStore } from '@/modules/store';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useState } from 'react';

/**
 * The offerable tiles, in the order HubSpot lists them — the bar gets compared against
 * theirs, so matching the order costs nothing and removes a point of confusion.
 *
 * `showAveragePerDeal` mirrors HubSpot too: the three amount tiles carry the sub-line, the
 * summary tiles do not.
 */
const AVAILABLE: { id: string; label: string; metric: CanvasSummaryMetric; avg?: boolean }[] = [
  { id: 'total', label: 'Total deal amount', metric: { kind: 'totalDealAmount' }, avg: true },
  { id: 'weighted', label: 'Weighted deal amount', metric: { kind: 'weightedDealAmount' }, avg: true },
  { id: 'open', label: 'Open deal amount', metric: { kind: 'openDealAmount' }, avg: true },
  { id: 'closed', label: 'Closed deal amount', metric: { kind: 'closedDealAmount' } },
  { id: 'new', label: 'New deal amount', metric: { kind: 'newDealAmount' } },
  { id: 'age', label: 'Average deal age', metric: { kind: 'averageDealAge' } },
  { id: 'count', label: 'Deal count', metric: { kind: 'count' } },
];

interface CumulativeFieldsSectionProps {
  canvasId: string;
}

export function CumulativeFieldsSection({ canvasId }: CumulativeFieldsSectionProps) {
  const summaries = useCedarStore((state) => {
    const c = state.canvasesById[canvasId];
    return (c?.viewConfig as ConversationViewConfig | undefined)?.summaries;
  });
  const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
  const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);

  const enabled = new Set((summaries ?? []).map((s) => s.id));

  const toggle = (id: string) => {
    const current = (useCedarStore.getState().canvasesById[canvasId]?.viewConfig ??
      {}) as ConversationViewConfig;
    const existing = current.summaries ?? [];
    const isOn = existing.some((s) => s.id === id);

    // Rebuild from AVAILABLE rather than push/splice so the tiles always come out in the
    // canonical order no matter what sequence they were ticked in.
    const nextIds = new Set(existing.map((s) => s.id));
    if (isOn) nextIds.delete(id);
    else nextIds.add(id);

    const next: CanvasSummarySpec[] = AVAILABLE.filter((a) => nextIds.has(a.id)).map((a) => ({
      id: a.id,
      metric: a.metric,
      ...(a.avg ? { showAveragePerDeal: true } : {}),
    }));

    updateCanvasViewConfig(canvasId, { ...current, summaries: next });
    void saveCanvasViewConfig(canvasId);
  };

  // Collapsed by default: the tile set is a set-once decision, while the column list beside
  // it gets touched constantly. Leaving seven checkboxes permanently expanded pushed the
  // presets — the thing people actually reach for — below the fold.
  const [open, setOpen] = useState(false);
  const activeCount = enabled.size;

  return (
    <div className="border-b border-border/40">
      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left hover:bg-muted/50"
        aria-expanded={open}
      >
        {open ? (
          <ChevronDown className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
        ) : (
          <ChevronRight className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
        )}
        <span className="flex-1 select-none text-sm">Cumulative</span>
        {activeCount > 0 && (
          <span className="bg-muted-foreground/20 rounded-full px-1.5 py-0.5 text-xs font-medium leading-none">
            {activeCount}
          </span>
        )}
      </button>

      {open &&
        AVAILABLE.map((item) => (
          <div
            key=[redacted]
            className="flex cursor-pointer items-center gap-2.5 py-1.5 pl-8 pr-3 hover:bg-muted/50"
            onClick={() => toggle(item.id)}
          >
            <Checkbox checked={enabled.has(item.id)} tabIndex={-1} className="pointer-events-none" />
            <span className="flex-1 select-none text-sm">{item.label}</span>
          </div>
        ))}
    </div>
  );
}