blocks.tsx43.5 KBView on GitHub
'use client';

import { Fragment, useMemo, useState } from 'react';
import {
  Bar,
  BarChart,
  CartesianGrid,
  Cell,
  Legend,
  Line,
  LineChart,
  Pie,
  PieChart,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from 'recharts';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, FileText, Info, MessageSquare, Sparkles } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import {
  inferScopeTypeFromPath as scopeTypeFromPath,
  inferScopeIdFromPath as scopeIdFromPath,
} from '@/modules/files/store/documentsSlice';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ConversationActivityOverview } from '@/modules/conversations/components/timeline/ConversationActivityOverview';
import type { ConversationEvent } from '@/modules/crm/types';
import {
  formatValue,
  interpolate,
  hasTemplate,
  teamAggregate,
  type NumberFormat,
} from '../lib/data';
import { useScope, useSources, ScopeProvider, templateScope, type RepeaterScope } from './context';
import type {
  ChartBlock,
  ComparisonBlock,
  ContainerNode,
  DashboardRow,
  DocLinkBlock,
  CallComparisonBlock,
  CallMoment,
  CalloutBlock,
  EntityCardBlock,
  FunnelBlock,
  FunnelStep,
  HeatmapBlock,
  LayoutNode,
  PlaybookBlock,
  PlaybookStage,
  ProfileBlock,
  RepeaterBlock,
  StatBlock,
  TableBlock,
  TabsBlock,
  TextBlock,
  TimelineBlock,
} from '../types/dashboard';

const HIGHLIGHT = '#10b981'; // emerald-500
const NEUTRAL = '#94a3b8'; // slate-400
const PIE_COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#a855f7', '#ef4444', '#14b8a6'];

const GAP: Record<string, string> = { none: 'gap-0', sm: 'gap-2', md: 'gap-4', lg: 'gap-6' };

// ── Recursive dispatcher ──────────────────────────────────────────────────────

export function LayoutNode({ node }: { node: LayoutNode }) {
  switch (node.type) {
    case 'row':
    case 'column':
    case 'grid':
      return <Container node={node} />;
    case 'tabs':
      return <TabsView block={node} />;
    case 'text':
      return <TextBlockView block={node} />;
    case 'stat':
      return <StatBlockView block={node} />;
    case 'chart':
      return <ChartBlockView block={node} />;
    case 'timeline':
      return <TimelineView block={node} />;
    case 'table':
      return <TableBlockView block={node} />;
    case 'docLink':
      return <DocLinkView block={node} />;
    case 'entityCard':
      return <EntityCardView block={node} />;
    case 'profile':
      return <ProfileView block={node} />;
    case 'funnel':
      return <FunnelView block={node} />;
    case 'comparison':
      return <ComparisonView block={node} />;
    case 'heatmap':
      return <HeatmapView block={node} />;
    case 'callComparison':
      return <CallComparisonView block={node} />;
    case 'playbook':
      return <PlaybookView block={node} />;
    case 'callout':
      return <CalloutView block={node} />;
    case 'repeater':
      return <RepeaterView block={node} />;
    default:
      return null;
  }
}

// ── Data resolution helpers ───────────────────────────────────────────────────

/**
 * Rows for a block:
 *  - `source`   → a named top-level source,
 *  - `rowField` → an array held on the current repeater/detail row,
 *  - otherwise  → the current repeater row as a single-row set.
 */
function useBlockRows(opts: { source?: string; rowField?: string }): DashboardRow[] {
  const sources = useSources();
  const scope = useScope();
  if (opts.source) return sources[opts.source]?.rows ?? [];
  if (opts.rowField && scope) return (scope.row[opts.rowField] as DashboardRow[]) ?? [];
  if (scope) return [scope.row];
  return [];
}

function useText(value: string): string {
  const scope = useScope();
  return hasTemplate(value) ? interpolate(value, templateScope(scope)) : value;
}

// ── Containers ────────────────────────────────────────────────────────────────

function Container({ node }: { node: ContainerNode }) {
  const gap = GAP[node.gap ?? 'md'];
  const align = node.align ? `items-${node.align}` : '';
  if (node.type === 'grid') {
    return (
      <div
        className={cn('grid', gap, node.className)}
        style={{ gridTemplateColumns: `repeat(${node.columns ?? node.children.length}, minmax(0, 1fr))` }}
      >
        {node.children.map((child, i) => (
          <LayoutNode key={i} node={child} />
        ))}
      </div>
    );
  }
  return (
    <div
      className={cn('flex', node.type === 'row' ? 'flex-row' : 'flex-col', gap, align, node.className)}
    >
      {node.children.map((child, i) => (
        <LayoutNode key={i} node={child} />
      ))}
    </div>
  );
}

// ── Tabs ──────────────────────────────────────────────────────────────────────

function TabsView({ block }: { block: TabsBlock }) {
  const [active, setActive] = useState(0);
  const tabs = block.tabs;
  return (
    <div className="flex flex-col gap-3">
      <div className="flex flex-wrap items-center gap-1 border-b border-border">
        {tabs.map((tab, i) => (
          <button
            key={i}
            type="button"
            onClick={() => setActive(i)}
            className={cn(
              '-mb-px border-b-2 px-3 py-1.5 text-[13px] font-medium transition-colors',
              i === active
                ? 'border-emerald-500 text-foreground'
                : 'border-transparent text-muted-foreground hover:text-foreground',
            )}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs[active] && <LayoutNode node={tabs[active].node} />}
    </div>
  );
}

// ── Text ──────────────────────────────────────────────────────────────────────

function TextBlockView({ block }: { block: TextBlock }) {
  const text = useText(block.markdown);
  // Lightweight rendering (avoids a cycle with the markdown editor): paragraphs,
  // headings (#), and **bold**. Dashboard text blocks are short by design.
  const lines = text.split('\n');
  return (
    <div className={cn('text-sm leading-relaxed', block.className)}>
      {lines.map((line, i) => {
        const heading = /^(#{1,3})\s+(.*)$/.exec(line);
        if (heading) {
          const level = heading[1].length;
          return (
            <p
              key={i}
              className={cn(
                'font-semibold',
                level === 1 ? 'text-lg mt-2' : level === 2 ? 'text-base mt-2' : 'text-sm mt-1',
              )}
            >
              {renderBold(heading[2])}
            </p>
          );
        }
        if (!line.trim()) return <div key={i} className="h-2" />;
        return (
          <p key={i} className="my-0.5">
            {renderBold(line)}
          </p>
        );
      })}
    </div>
  );
}

function renderBold(text: string) {
  return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) =>
    part.startsWith('**') && part.endsWith('**') ? (
      <strong key={i} className="font-semibold">
        {part.slice(2, -2)}
      </strong>
    ) : (
      <span key=[redacted]
    ),
  );
}

// ── Stat (plain + radial) ──────────────────────────────────────────────────────

function StatBlockView({ block }: { block: StatBlock }) {
  const rows = useBlockRows({ source: block.source });
  const raw = rows[0]?.[block.field];
  const numeric = typeof raw === 'number' ? raw : Number(raw);
  const display = formatValue(raw, block.format);

  if (block.style === 'radial') {
    const max = block.max ?? (block.format === 'percent' ? 100 : 100);
    const pct = Number.isFinite(numeric) ? Math.max(0, Math.min(1, numeric / max)) : 0;
    return (
      <div className="flex flex-col items-center justify-center gap-2 p-2">
        <Radial pct={pct} highlight={block.highlight} label={display} />
        {block.label && (
          <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
            {block.label}
          </span>
        )}
      </div>
    );
  }

  return (
    <div className="flex flex-col gap-0.5">
      <span className="text-2xl font-semibold tabular-nums">{display}</span>
      {block.label && <span className="text-xs text-muted-foreground">{block.label}</span>}
    </div>
  );
}

function Radial({ pct, label, highlight }: { pct: number; label: string; highlight?: boolean }) {
  const size = 96;
  const stroke = 9;
  const r = (size - stroke) / 2;
  const c = 2 * Math.PI * r;
  const color = highlight === false ? NEUTRAL : HIGHLIGHT;
  return (
    <div className="relative" style={{ width: size, height: size }}>
      <svg width={size} height={size} className="-rotate-90">
        <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth={stroke} className="text-muted/40" />
        <motion.circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          stroke={color}
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={c}
          initial={{ strokeDashoffset: c }}
          animate={{ strokeDashoffset: c * (1 - pct) }}
          transition={{ duration: 0.8, ease: 'easeOut' }}
        />
      </svg>
      <div className="absolute inset-0 flex items-center justify-center">
        <span className="text-lg font-semibold tabular-nums">{label}</span>
      </div>
    </div>
  );
}

// ── Chart ───────────────────────────────────────────────────────────────────

/** Truncate long category-axis labels so they stay on a single readable line. */
function truncateLabel(value: unknown): string {
  const s = String(value ?? '');
  return s.length > 22 ? `${s.slice(0, 21)}…` : s;
}

function ChartBlockView({ block }: { block: ChartBlock }) {
  const rows = useBlockRows({ source: block.source, rowField: block.rowField });
  const series = block.series ?? (block.yAxis ? [{ key=[redacted], label: block.yAxis }] : []);

  if (block.chart === 'table')
    return <TableBlockView block={{ type: 'table', source: block.source, rowField: block.rowField }} />;

  // Horizontal bar charts get one row per category, so the height must grow with
  // the data — a fixed height crams (and clips) the bars when there are many
  // categories. Vertical/line/pie charts keep a constant height.
  const isHorizontalBar = block.chart === 'bar' && block.orientation === 'horizontal';
  const height = isHorizontalBar ? Math.max(200, rows.length * 44 + 56) : 200;

  return (
    <div className="flex flex-col gap-1">
      {block.title && <span className="text-[13px] font-semibold">{block.title}</span>}
      <ResponsiveContainer width="100%" height={height}>
        {block.chart === 'pie' ? (
          <PieChart>
            <Pie
              data={rows as Record<string, unknown>[]}
              dataKey=[redacted] ?? 'value'}
              nameKey=[redacted] ?? 'name'}
              outerRadius={80}
              label
            >
              {rows.map((_, i) => (
                <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
              ))}
            </Pie>
            <Tooltip />
          </PieChart>
        ) : block.chart === 'line' ? (
          <LineChart data={rows as Record<string, unknown>[]}>
            <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
            <XAxis dataKey=[redacted] tick={{ fontSize: 11 }} />
            <YAxis tick={{ fontSize: 11 }} />
            <Tooltip />
            {series.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
            {series.map((s, i) => (
              <Line key=[redacted] name={s.label ?? s.key} dataKey=[redacted] stroke={PIE_COLORS[i % PIE_COLORS.length]} strokeWidth={2} dot={false} />
            ))}
          </LineChart>
        ) : (
          <BarChart
            data={rows as Record<string, unknown>[]}
            layout={isHorizontalBar ? 'vertical' : 'horizontal'}
            margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
          >
            <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
            {isHorizontalBar ? (
              <>
                <XAxis type="number" tick={{ fontSize: 11 }} />
                <YAxis
                  type="category"
                  dataKey=[redacted]
                  tick={{ fontSize: 11 }}
                  width={160}
                  interval={0}
                  tickFormatter={truncateLabel}
                />
              </>
            ) : (
              <>
                <XAxis dataKey=[redacted] tick={{ fontSize: 11 }} />
                <YAxis tick={{ fontSize: 11 }} />
              </>
            )}
            <Tooltip />
            <Legend wrapperStyle={{ fontSize: 11 }} />
            {series.map((s, i) => (
              <Bar
                key=[redacted]
                name={s.label ?? s.key}
                dataKey=[redacted]
                fill={PIE_COLORS[i % PIE_COLORS.length]}
                radius={isHorizontalBar ? [0, 3, 3, 0] : [3, 3, 0, 0]}
              />
            ))}
          </BarChart>
        )}
      </ResponsiveContainer>
    </div>
  );
}

// ── Timeline (mock ConversationActivityOverview) ──────────────────────────────

type TimelineKind = 'inbound_email' | 'outbound_email' | 'meeting' | 'call';

/**
 * Adapt a mock row (`{ kind, at, title }`) into the minimal shape
 * `ConversationActivityOverview` consumes. `getEventType`/`getEventTitle` branch
 * on the presence of sub-objects (`emailEvent`/`meetingEvent`/`callEvent`) and
 * `direction`, so we only populate those — the rest of `ConversationEvent` is
 * never read on this path, hence the cast.
 */
function adaptTimelineEvent(row: DashboardRow, index: number): ConversationEvent {
  const kind = String(row.kind ?? 'outbound_email') as TimelineKind;
  const title = String(row.title ?? '');
  const base = { id: String(row.id ?? index), occurredAt: new Date(String(row.at ?? '')) };
  switch (kind) {
    case 'inbound_email':
      return { ...base, direction: 'inbound', emailEvent: { subject: title } } as ConversationEvent;
    case 'meeting':
      return { ...base, meetingEvent: { title } } as ConversationEvent;
    case 'call':
      return { ...base, callEvent: {} } as ConversationEvent;
    case 'outbound_email':
    default:
      return { ...base, direction: 'outbound', emailEvent: { subject: title } } as ConversationEvent;
  }
}

function TimelineView({ block }: { block: TimelineBlock }) {
  const rows = useBlockRows({ source: block.source, rowField: block.rowField });
  const title = useText(block.title ?? '');
  const events = useMemo(() => rows.map(adaptTimelineEvent), [rows]);
  const timeRange = block.rangeStart
    ? { start: new Date(block.rangeStart), end: new Date(block.rangeEnd ?? Date.now()) }
    : undefined;
  return (
    <div className="flex flex-col gap-2">
      {title && <span className="text-[13px] font-semibold">{title}</span>}
      <ConversationActivityOverview events={events} timeRange={timeRange} />
    </div>
  );
}

// ── Table ───────────────────────────────────────────────────────────────────

function TableBlockView({ block }: { block: TableBlock }) {
  const rows = useBlockRows({ source: block.source, rowField: block.rowField });

  const columns =
    block.columns ??
    (rows[0]
      ? Object.keys(rows[0]).map((key) => ({
          key,
          label: key,
          format: undefined as NumberFormat | undefined,
        }))
      : []);
  const totalWeight = columns.reduce((sum, c) => sum + ((c as { weight?: number }).weight ?? 1), 0);

  return (
    <div className="flex flex-col gap-1.5">
      {block.title && <span className="text-[13px] font-semibold">{block.title}</span>}
      <table className="w-full table-fixed border-collapse text-sm">
        <colgroup>
          {columns.map((col) => (
            <col key=[redacted] style={{ width: `${(((col as { weight?: number }).weight ?? 1) / totalWeight) * 100}%` }} />
          ))}
        </colgroup>
        <thead>
          <tr className="border-b border-border">
            {columns.map((col) => (
              <th
                key=[redacted]
                className={cn(
                  'px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground',
                  alignClass((col as { align?: string }).align),
                )}
              >
                {col.label}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((row, i) => (
            <tr key={i} className="border-b border-border/60 align-top">
              {columns.map((col) => (
                <DataCell key=[redacted] col={col} row={row} />
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function alignClass(align?: string) {
  return align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
}

type TableColumnDef = {
  key=[redacted];
  label: string;
  format?: NumberFormat;
  weight?: number;
  type?: 'text' | 'number' | 'docLink' | 'conversationLink' | 'rep';
  linkLabel?: string;
  align?: 'left' | 'right' | 'center';
};

function DataCell({ col, row }: { col: TableColumnDef; row: DashboardRow }) {
  const base = cn('px-3 py-2 text-[12px] leading-snug', alignClass(col.align));

  if (col.type === 'docLink') {
    const path = String(row[col.key] ?? '');
    return (
      <td className={base}>{path ? <DocLink path={path} label={col.linkLabel ?? 'View →'} /> : null}</td>
    );
  }

  if (col.type === 'conversationLink') {
    const conversationId = String(row[col.key] ?? '');
    return (
      <td className={base}>
        {conversationId ? (
          <ConversationLink conversationId={conversationId} label={col.linkLabel ?? 'Open thread →'} />
        ) : null}
      </td>
    );
  }

  if (col.type === 'rep') {
    const name = String(row[col.key] ?? '');
    return (
      <td className={base}>
        <span className="inline-flex items-center gap-1.5">
          <span className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500/15 text-[9px] font-semibold text-emerald-700 ring-1 ring-emerald-500/40 dark:text-emerald-300">
            {name.slice(0, 2).toUpperCase()}
          </span>
          <span className="font-medium">{name}</span>
        </span>
      </td>
    );
  }

  const isNumeric = col.type === 'number' || col.format;
  return <td className={cn(base, isNumeric && 'tabular-nums')}>{formatValue(row[col.key], col.format)}</td>;
}

// ── Document links ────────────────────────────────────────────────────────────

/** Resolve a Cedar Doc path → an onClick that navigates to it, plus its label. */
function useDocNav(path: string): { open: () => void; label: string; ready: boolean } {
  const trpc = useTRPC();
  const selectDocumentId = useCedarStore((s) => s.selectDocumentId);
  const setActiveFolderContext = useCedarStore((s) => s.setActiveFolderContext);
  const { data: meta } = useQuery(
    trpc.documents.getDoc.queryOptions(
      { documentType: 'document', path, omitContent: true },
      { enabled: !!path, staleTime: 5 * 60_000 },
    ),
  );
  const open = () => {
    if (!meta?.id) return;
    selectDocumentId(meta.id);
    setActiveFolderContext({
      documentId: meta.id,
      scopeType: scopeTypeFromPath(meta.path),
      scopeId: scopeIdFromPath(meta.path),
      path: meta.path,
      label: meta.title ?? path,
    });
  };
  return { open, label: meta?.title ?? '', ready: !!meta?.id };
}

function DocLink({ path, label }: { path: string; label?: string }) {
  const nav = useDocNav(path);
  // Regular document-link chip — mirrors FileLinkChip's styling.
  return (
    <button
      type="button"
      onClick={nav.open}
      disabled={!nav.ready}
      title={nav.label || label}
      className="inline-flex max-w-56 items-center gap-1 rounded-sm border bg-muted/50 px-1.5 py-0.5 align-middle text-xs font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50"
    >
      <FileText className="size-3 shrink-0 text-muted-foreground" />
      <span className="truncate">{label ?? nav.label ?? 'file'}</span>
    </button>
  );
}

function DocLinkView({ block }: { block: DocLinkBlock }) {
  const scope = useScope();
  const path = block.path ?? (block.pathField && scope ? String(scope.row[block.pathField] ?? '') : '');
  if (!path) return null;
  return <DocLink path={path} label={block.label} />;
}

/** Chip that opens a CRM conversation by id (mirrors DocLink's styling). */
function ConversationLink({ conversationId, label }: { conversationId: string; label: string }) {
  const openConversationContext = useCedarStore((s) => s.openConversationContext);
  return (
    <button
      type="button"
      onClick={(e) => {
        e.stopPropagation();
        openConversationContext(conversationId);
      }}
      className="inline-flex max-w-56 items-center gap-1 rounded-sm border bg-muted/50 px-1.5 py-0.5 align-middle text-xs font-medium text-foreground transition-colors hover:bg-muted"
    >
      <MessageSquare className="size-3 shrink-0 text-muted-foreground" />
      <span className="truncate">{label}</span>
    </button>
  );
}

// ── Entity card ───────────────────────────────────────────────────────────────

function EntityCardView({ block }: { block: EntityCardBlock }) {
  const scope = useScope();
  const path = block.linkPath ?? (block.linkField && scope ? String(scope.row[block.linkField] ?? '') : '');
  const nav = useDocNav(path);
  const clickable = !!path;
  return (
    <div
      className={cn(
        'flex flex-col gap-2 rounded-sm border border-border bg-raised p-3',
        clickable && 'cursor-pointer transition-colors hover:border-foreground/30 hover:bg-muted/40',
        block.className,
      )}
      onClick={clickable ? nav.open : undefined}
      role={clickable ? 'button' : undefined}
    >
      {block.children.map((child, i) => (
        <LayoutNode key={i} node={child} />
      ))}
    </div>
  );
}

// ── Callout ───────────────────────────────────────────────────────────────────

const CALLOUT_VARIANT = {
  recommendation: {
    container: 'border-emerald-500/40 bg-emerald-500/5',
    accent: 'bg-emerald-500',
    icon: 'text-emerald-600 dark:text-emerald-400',
    eyebrow: 'text-emerald-700 dark:text-emerald-400',
    Icon: Sparkles,
  },
  risk: {
    container: 'border-amber-500/40 bg-amber-500/5',
    accent: 'bg-amber-500',
    icon: 'text-amber-600 dark:text-amber-400',
    eyebrow: 'text-amber-700 dark:text-amber-400',
    Icon: AlertTriangle,
  },
  info: {
    container: 'border-border bg-muted/40',
    accent: 'bg-muted-foreground/40',
    icon: 'text-muted-foreground',
    eyebrow: 'text-muted-foreground',
    Icon: Info,
  },
} as const;

function CalloutView({ block }: { block: CalloutBlock }) {
  const v = CALLOUT_VARIANT[block.variant ?? 'recommendation'];
  const label = useText(block.label ?? '');
  const title = useText(block.title ?? '');
  const body = useText(block.body);
  return (
    <div className={cn('relative overflow-hidden rounded-lg border p-4 pl-5', v.container)}>
      <div className={cn('absolute inset-y-0 left-0 w-1', v.accent)} />
      <div className="flex items-start gap-3">
        <v.Icon className={cn('mt-0.5 h-4 w-4 shrink-0', v.icon)} />
        <div className="min-w-0 flex-1">
          {label && (
            <p className={cn('text-[11px] font-semibold uppercase tracking-wider', v.eyebrow)}>
              {label}
            </p>
          )}
          {title && <p className="mt-0.5 text-sm font-semibold leading-snug">{renderBold(title)}</p>}
          <div className="mt-1 text-sm leading-relaxed text-muted-foreground">
            {body.split('\n').map((line, i) =>
              line.trim() ? (
                <p key={i} className="my-0.5">
                  {renderBold(line)}
                </p>
              ) : (
                <div key={i} className="h-1.5" />
              ),
            )}
          </div>
          {block.docPath && (
            <div className="mt-2.5">
              <DocLink path={block.docPath} label={block.docLabel} />
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// ── Profile ───────────────────────────────────────────────────────────────────

function ProfileView({ block }: { block: ProfileBlock }) {
  const name = useText(block.name);
  const avatar = useText(block.avatar ?? '');
  const initials = useText(block.initials ?? '') || name.slice(0, 2).toUpperCase();
  const label = useText(block.label ?? '');
  const highlight = block.highlight;
  return (
    <div className="flex items-center gap-2">
      <Avatar className="h-7 w-7">
        {avatar && <AvatarImage src={avatar} className="object-cover" />}
        <AvatarFallback
          className={cn(
            'text-[10px] font-semibold',
            highlight
              ? 'bg-emerald-500/15 text-emerald-700 ring-1 ring-emerald-500/40 dark:text-emerald-300'
              : 'bg-muted text-muted-foreground',
          )}
        >
          {initials}
        </AvatarFallback>
      </Avatar>
      <div className="flex min-w-0 flex-col leading-tight">
        <span className="truncate text-[13px] font-semibold">{name}</span>
        {label && (
          <span
            className={cn(
              'text-[9px] font-medium uppercase tracking-wide',
              highlight ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground',
            )}
          >
            {label}
          </span>
        )}
      </div>
    </div>
  );
}

// ── Funnel ───────────────────────────────────────────────────────────────────

function FunnelView({ block }: { block: FunnelBlock }) {
  const sources = useSources();
  const scope = useScope();
  const steps: FunnelStep[] = block.source
    ? ((sources[block.source]?.rows as FunnelStep[]) ?? [])
    : (block.steps ??
      (scope && block.field ? ((scope.row[block.field] as FunnelStep[]) ?? []) : []));
  const max = block.max ?? steps[0]?.count ?? 1;
  const lg = block.size === 'lg';
  return (
    <div className={cn('flex flex-col', lg ? 'gap-2.5' : 'gap-1.5')}>
      {steps.map((step, i) => {
        const widthPct = max > 0 ? (step.count / max) * 100 : 0;
        return (
          <div key={i} className={cn('flex items-center', lg ? 'gap-3' : 'gap-1.5')}>
            <span
              className={cn(
                'shrink-0 truncate font-medium uppercase tracking-wide text-muted-foreground',
                lg ? 'w-28 text-[11px]' : 'w-16 text-[9px]',
              )}
            >
              {step.label}
            </span>
            <div className={cn('relative flex-1 overflow-hidden rounded-sm bg-muted/50', lg ? 'h-9' : 'h-4')}>
              <motion.div
                initial={{ width: 0 }}
                animate={{ width: `${widthPct}%` }}
                transition={{ duration: 0.7, delay: 0.1 + i * 0.1, ease: 'easeOut' }}
                className="h-full rounded-sm bg-emerald-500/80"
              />
            </div>
            <span
              className={cn(
                'shrink-0 text-right font-semibold tabular-nums',
                lg ? 'w-10 text-sm' : 'w-7 text-[11px]',
              )}
            >
              {step.count}
            </span>
          </div>
        );
      })}
    </div>
  );
}

// ── Comparison (row vs team average) ──────────────────────────────────────────

function ComparisonView({ block }: { block: ComparisonBlock }) {
  const sources = useSources();
  const scope = useScope();
  // Subject + team rows come from named single-row sources when given, else the
  // enclosing repeater's row/team aggregate.
  const subject = block.source ? (sources[block.source]?.rows[0] ?? {}) : scope?.row;
  const team = block.teamSource ? (sources[block.teamSource]?.rows[0] ?? {}) : scope?.team;
  if (!subject || !team) return null;
  const rowLabel = block.rowLabel ?? 'This rep';
  const teamLabel = block.teamLabel ?? 'Team avg';
  return (
    <div className="flex flex-col gap-3">
      <div className="flex items-center gap-4 text-[11px] text-muted-foreground">
        <span className="flex items-center gap-1.5">
          <span className="h-2.5 w-2.5 rounded-sm" style={{ background: HIGHLIGHT }} /> {rowLabel}
        </span>
        <span className="flex items-center gap-1.5">
          <span className="h-2.5 w-2.5 rounded-sm" style={{ background: NEUTRAL }} /> {teamLabel}
        </span>
      </div>
      {block.fields.map((f) => {
        const rowVal = Number(subject[f.key]) || 0;
        const teamVal = Number(team[f.key]) || 0;
        const max = Math.max(rowVal, teamVal, 1);
        return (
          <div key=[redacted] className="flex flex-col gap-1">
            <span className="text-[11px] font-medium text-muted-foreground">{f.label}</span>
            <CompareBar value={rowVal} max={max} color={HIGHLIGHT} display={formatValue(rowVal, block.format)} />
            <CompareBar value={teamVal} max={max} color={NEUTRAL} display={formatValue(teamVal, block.format)} />
          </div>
        );
      })}
    </div>
  );
}

function CompareBar({ value, max, color, display }: { value: number; max: number; color: string; display: string }) {
  return (
    <div className="flex items-center gap-2">
      <div className="relative h-5 flex-1 overflow-hidden rounded-sm bg-muted/50">
        <motion.div
          initial={{ width: 0 }}
          animate={{ width: `${(value / max) * 100}%` }}
          transition={{ duration: 0.7, ease: 'easeOut' }}
          className="h-full rounded-sm"
          style={{ background: color }}
        />
      </div>
      <span className="w-12 shrink-0 text-right text-[11px] font-semibold tabular-nums">{display}</span>
    </div>
  );
}

// ── Heatmap (coverage matrix: rows × columns, cells shaded by level) ───────────

// Emerald intensity ramp for legend levels ≥ 1. Index 0 ("empty") renders muted
// rather than emerald — in a coverage grid an empty cell is the absence of reach.
const HEAT_RAMP = ['rgba(16,185,129,0.30)', 'rgba(16,185,129,0.62)', 'rgba(16,185,129,0.92)'];

function HeatmapView({ block }: { block: HeatmapBlock }) {
  const rows = useBlockRows({ source: block.source, rowField: block.rowField });
  const legend = block.legend ?? [];
  const cols = block.columns;

  // Numeric-mode denominator: the largest cell value across the grid.
  const numericMax = useMemo(() => {
    if (legend.length) return 1;
    const all = rows.flatMap((r) => cols.map((c) => Number(r[c.key]) || 0));
    return block.max ?? Math.max(1, ...all);
  }, [legend.length, rows, cols, block.max]);

  // Resolve a legend index → its accent color (explicit, or the ramp by position).
  const colorForLegendIndex = (idx: number, explicit?: string): string | undefined =>
    explicit ?? (idx <= 0 ? undefined : HEAT_RAMP[Math.min(idx - 1, HEAT_RAMP.length - 1)]);

  // Resolve a raw cell value → { color, label } for shading + tooltip, against
  // the column's own legend when it has one, else the block legend.
  const cellFor = (
    value: unknown,
    cellLegend: HeatmapBlock['legend'] = legend,
  ): { color?: string; label: string } => {
    if (cellLegend && cellLegend.length) {
      const idx = cellLegend.findIndex((l) => String(l.value) === String(value));
      if (idx < 0) return { color: undefined, label: '—' };
      return { color: colorForLegendIndex(idx, cellLegend[idx].color), label: cellLegend[idx].label };
    }
    const n = Number(value);
    const t = Number.isFinite(n) ? Math.max(0, Math.min(1, n / numericMax)) : 0;
    return {
      color: t > 0 ? `rgba(16,185,129,${(0.2 + t * 0.72).toFixed(3)})` : undefined,
      label: formatValue(value, 'number'),
    };
  };

  // Legend strip = the block legend followed by any per-column legends (e.g. a
  // Won/Lost outcome scale), deduped by label so shared levels show once.
  const legendEntries = useMemo(() => {
    const seen = new Set<string>();
    const out: { label: string; color?: string }[] = [];
    for (const lg of [legend, ...cols.map((c) => c.legend ?? [])]) {
      lg.forEach((l, idx) => {
        if (seen.has(l.label)) return;
        seen.add(l.label);
        out.push({ label: l.label, color: colorForLegendIndex(idx, l.color) });
      });
    }
    return out;
  }, [legend, cols]);

  const template = `minmax(110px, 1.5fr) repeat(${cols.length}, minmax(0, 1fr))`;

  return (
    <div className="flex flex-col gap-2">
      {block.title && <span className="text-[13px] font-semibold">{block.title}</span>}
      <div className="grid items-stretch gap-1" style={{ gridTemplateColumns: template }}>
        {/* Header row: empty corner + column labels */}
        <div />
        {cols.map((c) => (
          <div
            key=[redacted]
            className="px-0.5 pb-1 text-center text-[10px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground"
          >
            {c.label}
          </div>
        ))}
        {/* Data rows */}
        {rows.map((row, i) => {
          const rowLabel = String(row[block.rowKey] ?? '');
          return (
            <Fragment key=[redacted]
              <div className="flex items-center truncate pr-2 text-[12px] font-medium" title={rowLabel}>
                {rowLabel}
              </div>
              {cols.map((c) => {
                const { color, label } = cellFor(row[c.key], c.legend);
                return (
                  <div
                    key=[redacted]
                    title={`${rowLabel} · ${c.label}: ${label}`}
                    className={cn('h-7 rounded-sm', !color && 'bg-muted/30')}
                    style={color ? { backgroundColor: color } : undefined}
                  />
                );
              })}
            </Fragment>
          );
        })}
      </div>
      {legendEntries.length > 0 && (
        <div className="flex flex-wrap items-center gap-x-3 gap-y-1 pt-0.5 text-[10px] text-muted-foreground">
          {legendEntries.map((e, i) => (
            <span key={i} className="flex items-center gap-1.5">
              <span
                className={cn('h-2.5 w-2.5 rounded-sm', !e.color && 'bg-muted/40')}
                style={e.color ? { backgroundColor: e.color } : undefined}
              />
              {e.label}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

// ── Call comparison (two speaker-aligned transcripts) ─────────────────────────

const CALL_TAG: Record<
  'good' | 'miss' | 'neutral',
  { bar: string; bg: string; mark: string; markColor: string }
> = {
  good: { bar: 'border-emerald-500/70', bg: 'bg-emerald-500/5', mark: '✓', markColor: 'text-emerald-600 dark:text-emerald-400' },
  miss: { bar: 'border-red-500/70', bg: 'bg-red-500/5', mark: '✗', markColor: 'text-red-600 dark:text-red-400' },
  neutral: { bar: 'border-border', bg: '', mark: '', markColor: 'text-muted-foreground' },
};

function CallChip({
  name,
  label,
  initials,
  highlight,
}: {
  name: string;
  label?: string;
  initials?: string;
  highlight?: boolean;
}) {
  const fallback = (initials || name.slice(0, 2)).toUpperCase();
  return (
    <div className="flex items-center gap-2">
      <Avatar className="h-7 w-7">
        <AvatarFallback
          className={cn(
            'text-[10px] font-semibold',
            highlight
              ? 'bg-emerald-500/15 text-emerald-700 ring-1 ring-emerald-500/40 dark:text-emerald-300'
              : 'bg-muted text-muted-foreground',
          )}
        >
          {fallback}
        </AvatarFallback>
      </Avatar>
      <div className="flex min-w-0 flex-col leading-tight">
        <span className="truncate text-[13px] font-semibold">{name}</span>
        {label && (
          <span
            className={cn(
              'text-[9px] font-medium uppercase tracking-wide',
              highlight ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground',
            )}
          >
            {label}
          </span>
        )}
      </div>
    </div>
  );
}

function CallTurnCell({ turn }: { turn: CallMoment['left'] }) {
  const style = CALL_TAG[turn.tag ?? 'neutral'];
  return (
    <div className={cn('flex flex-col gap-1 rounded-sm border-l-2 px-3 py-2', style.bar, style.bg)}>
      {turn.speaker && (
        <span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
          {turn.speaker}
        </span>
      )}
      <span className="text-[12px] leading-snug">{turn.text}</span>
      {turn.note && (
        <span className={cn('flex gap-1 text-[11px] italic leading-snug', style.markColor)}>
          {style.mark && <span className="not-italic">{style.mark}</span>}
          <span>{turn.note}</span>
        </span>
      )}
    </div>
  );
}

function CallComparisonView({ block }: { block: CallComparisonBlock }) {
  const sources = useSources();
  const scope = useScope();
  const moments: CallMoment[] =
    block.moments ??
    (block.source
      ? ((sources[block.source]?.rows as unknown as CallMoment[]) ?? [])
      : block.rowField && scope
        ? ((scope.row[block.rowField] as CallMoment[]) ?? [])
        : []);
  return (
    <div className="flex flex-col gap-2">
      {block.title && <span className="text-[13px] font-semibold">{block.title}</span>}
      <div className="grid grid-cols-2 gap-3 border-b border-border pb-2">
        <CallChip {...block.left} highlight />
        <CallChip {...block.right} />
      </div>
      <div className="flex flex-col gap-2">
        {moments.map((m, i) => (
          <div key={i} className="flex flex-col gap-1">
            {(m.topic || m.at) && (
              <div className="flex items-center gap-2 pt-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
                {m.at && <span className="tabular-nums">{m.at}</span>}
                {m.topic && <span>{m.topic}</span>}
              </div>
            )}
            <div className="grid grid-cols-2 items-start gap-3">
              <CallTurnCell turn={m.left} />
              <CallTurnCell turn={m.right} />
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

// ── Playbook ─────────────────────────────────────────────────────────────────

function PlaybookView({ block }: { block: PlaybookBlock }) {
  const sources = useSources();
  const scope = useScope();
  // Stages come from a named source (its rows ARE the stages) when given, else
  // the repeater row's array field.
  const stages: PlaybookStage[] = block.source
    ? ((sources[block.source]?.rows as PlaybookStage[]) ?? [])
    : scope && block.field
      ? ((scope.row[block.field] as PlaybookStage[]) ?? [])
      : [];
  return (
    <div className="flex flex-col gap-2.5">
      {block.title && (
        <div className="flex items-center gap-2">
          <Sparkles className="h-4 w-4 text-action" />
          <span className="text-[13px] font-semibold">{block.title}</span>
        </div>
      )}
      {stages.map((stage, i) => (
        <motion.div
          key=[redacted]
          initial={{ opacity: 0, y: 6 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.35, delay: 0.1 + i * 0.08 }}
          className="rounded-lg border border-border bg-raised p-3"
        >
          <div className="mb-2 flex items-center gap-2">
            <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700 dark:text-emerald-300">
              {stage.num}
            </span>
            <span className="text-[13px] font-semibold">{stage.title}</span>
            {typeof stage.winPct === 'number' && (
              <span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:text-emerald-300">
                {stage.winPct}% win
              </span>
            )}
            {stage.goal && (
              <span className="ml-auto truncate text-[11px] text-muted-foreground">Goal: {stage.goal}</span>
            )}
          </div>
          {stage.objection && (
            <div className="flex gap-2 text-[12px] leading-snug">
              <span className="mt-px shrink-0 text-amber-600 dark:text-amber-400">⚠</span>
              <span className="italic text-muted-foreground">&ldquo;{stage.objection}&rdquo;</span>
            </div>
          )}
          {stage.reply && (
            <div className="mt-1 flex gap-2 text-[12px] leading-snug">
              <span className="mt-px shrink-0 text-emerald-600 dark:text-emerald-400">→</span>
              <span>{stage.reply}</span>
            </div>
          )}
          {stage.tactic && (
            <div className="mt-2 flex gap-2 border-t border-border/40 pt-2 text-[11px] leading-snug text-muted-foreground">
              <span className="shrink-0">✓</span>
              <span>{stage.tactic}</span>
            </div>
          )}
          {stage.deliverable && (
            <div className="mt-1.5 flex items-center gap-2">
              <span className="rounded bg-foreground/5 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-muted-foreground">
                Deliverable
              </span>
              <span className="font-mono text-[10.5px] text-foreground/80">{stage.deliverable}</span>
            </div>
          )}
        </motion.div>
      ))}
    </div>
  );
}

// ── Repeater (grid/list of templated cards) ───────────────────────────────────

function RepeaterView({ block }: { block: RepeaterBlock }) {
  const sources = useSources();
  const rows = sources[block.source]?.rows ?? [];
  const as = block.as ?? 'row';
  const team = useMemo(() => teamAggregate(rows), [rows]);

  const isGrid = (block.layout ?? 'grid') === 'grid';
  const gap = GAP[block.gap ?? 'md'];

  return (
    <div
      className={cn(isGrid ? 'grid' : 'flex flex-col', gap)}
      style={isGrid ? { gridTemplateColumns: `repeat(${block.columns ?? rows.length}, minmax(0, 1fr))` } : undefined}
    >
      {rows.map((row, i) => {
        const scope: RepeaterScope = { row, team, as };
        return (
          <ScopeProvider key={i} value={scope}>
            <LayoutNode node={block.template} />
          </ScopeProvider>
        );
      })}
    </div>
  );
}