// Shared UI primitives — Pill, Score, Avatar, FlowStep, Cite, etc.
// Reads tokens / data from window.IPSEM_DATA.

const { PRIORITY_META, STATUS_META, RESEARCH_META, SIGNALS, CATEGORY_MODIFIERS, PROPERTIES, TODAY } = window.IPSEM_DATA;

const PropertyById = (id) => PROPERTIES.find((p) => p.id === id);

// ─── Pill / Badge ────────────────────────────────────────────────────────
const Pill = ({ kind, children, dot, mono, className = '', style, onClick, title }) => {
  const classes = `pill pill-${kind || 'neutral'} ${kind || ''} ${mono ? 'pill-mono' : ''} ${className}`;
  return (
    <span className={classes} style={style} onClick={onClick} title={title}>
      {dot ? <span className="dot" /> : null}
      {children}
    </span>
  );
};

const PriorityPill = ({ priority }) => {
  if (!priority) return null;
  const m = PRIORITY_META[priority];
  if (!m) return null;
  return <Pill kind={m.color}>{m.label}</Pill>;
};

const StatusPill = ({ status }) => {
  if (!status) return null;
  const m = STATUS_META[status];
  if (!m) return null;
  return <Pill kind={m.color} dot={status === 'maybe'}>{m.label}</Pill>;
};

const ResearchPill = ({ research }) => {
  if (!research) return null;
  const m = RESEARCH_META[research];
  if (!m) return null;
  return <Pill kind={m.color} dot={research === 'researching'}>{m.label}</Pill>;
};

const SignalChip = ({ signal }) => {
  const s = SIGNALS[signal];
  if (!s) return null;
  const bg = `var(--sig-${s.tone}-bg)`;
  const fg = `var(--sig-${s.tone}-fg)`;
  return (
    <span
      className="pill"
      style={{
        background: bg,
        color: fg,
        fontSize: 11,
        fontWeight: 500,
        border: '1px solid transparent',
      }}
    >
      {s.label}
    </span>
  );
};

// ─── Score bar ───────────────────────────────────────────────────────────
const ScoreBar = ({ value, max = 100, mute }) => (
  <span className={`scorebar ${mute ? 'low' : ''}`}>
    <span className="track">
      <span
        className="fill"
        style={{ transform: `scaleX(${Math.max(0, Math.min(1, value / max))})` }}
      />
    </span>
    <span>{value}</span>
  </span>
);

// ─── Scoring — relevance breakdown ───────────────────────────────────────
// Returns an array of {label, value, polarity, kind} factors that approximately sum to the brand's relevance score.
// The polarity flag lets the UI render reds for penalties.
function computeScoreBreakdown(brand) {
  const factors = [];

  // Signal contributions
  (brand.signals || []).forEach((sigKey) => {
    const s = SIGNALS[sigKey];
    if (!s) return;
    factors.push({ kind: 'signal', label: s.label, value: s.weight, desc: s.desc });
  });

  // Category fit / brand-safety modifier
  const catMod = CATEGORY_MODIFIERS[brand.category];
  if (catMod != null && catMod !== 0) {
    factors.push({
      kind: 'category',
      label: catMod > 0 ? `Category fit · ${brand.category}` : `Category risk · ${brand.category}`,
      value: catMod,
      desc: catMod > 0
        ? 'Historically converts well against IPSEM portfolio.'
        : 'Brand-safety or fit risk for IPSEM properties.',
    });
  }

  // AI portfolio fit boost
  if (brand.aiRecommendation && (brand.aiRecommendation.properties || []).length > 0) {
    const n = brand.aiRecommendation.properties.length;
    factors.push({
      kind: 'ai',
      label: `Property fit · ${n} match${n === 1 ? '' : 'es'}`,
      value: 8 + (n - 1) * 4,
      desc: `AI matched this brand to ${n} IPSEM propert${n === 1 ? 'y' : 'ies'}.`,
    });
  }

  // Recency
  if (brand.news?.date && TODAY) {
    const days = Math.max(0, Math.round((new Date(TODAY) - new Date(brand.news.date)) / 86400000));
    if (days <= 7) {
      factors.push({ kind: 'recency', label: 'Recency · within 7 days', value: 6, desc: 'Fresh news has higher conversion to outreach reply.' });
    } else if (days <= 21) {
      factors.push({ kind: 'recency', label: 'Recency · within 3 weeks', value: 2, desc: 'Still timely.' });
    } else {
      factors.push({ kind: 'recency', label: 'Recency · older than 3 weeks', value: -4, desc: 'News momentum has faded.' });
    }
  }

  // Duplicate / not-relevant additional penalty
  if (brand.status === 'duplicate') {
    factors.push({ kind: 'penalty', label: 'Duplicate of tracked item', value: -30, desc: 'Substantially same as another news item.' });
  }
  if (brand.status === 'not-relevant') {
    factors.push({ kind: 'penalty', label: 'Classified not relevant', value: -25, desc: 'Failed brand-fit threshold.' });
  }

  // Compute total; clamp 0-100; reconcile with brand.relevanceScore if set
  const subtotal = factors.reduce((a, f) => a + f.value, 0);
  const reported = brand.relevanceScore;
  const drift = reported != null ? reported - Math.max(0, Math.min(100, subtotal)) : 0;

  // Show small calibration so totals match the displayed score
  if (Math.abs(drift) > 1) {
    factors.push({
      kind: 'calibration',
      label: 'Portfolio calibration',
      value: drift,
      desc: 'Model calibration against historic win-rate.',
    });
  }

  return {
    factors,
    total: reported != null ? reported : Math.max(0, Math.min(100, subtotal)),
  };
}

function scoreBand(value) {
  if (value >= 80) return { band: 'high', label: 'High', desc: 'Strong signals + clean category fit. Almost always converts to active brief.', color: 'var(--positive)' };
  if (value >= 60) return { band: 'medium', label: 'Medium', desc: 'Real signals but partial fit, older news, or category caveat. Worth a fast review.', color: 'var(--pending)' };
  if (value >= 30) return { band: 'low', label: 'Low', desc: 'Few or weak signals. Default action is KIV or auto-reject.', color: 'var(--muted)' };
  return { band: 'reject', label: 'Reject', desc: 'Below brand-fit threshold. Auto-rejected by the system.', color: 'var(--ink-4)' };
}

// Score with a hover/click popover showing the full breakdown
const ScoreWithBreakdown = ({ brand, mute, alignRight }) => {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);

  React.useEffect(() => {
    if (!open) return;
    const onClick = (e) => {
      if (ref.current && !ref.current.contains(e.target)) setOpen(false);
    };
    document.addEventListener('mousedown', onClick);
    return () => document.removeEventListener('mousedown', onClick);
  }, [open]);

  const { factors, total } = computeScoreBreakdown(brand);
  const band = scoreBand(total);

  return (
    <span ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
      <span
        onClick={(e) => { e.stopPropagation(); setOpen((x) => !x); }}
        style={{ cursor: 'pointer', display: 'inline-block' }}
      >
        <ScoreBar value={total} mute={mute} />
      </span>
      {open ? (
        <div
          onClick={(e) => e.stopPropagation()}
          style={{
            position: 'absolute',
            top: 'calc(100% + 8px)',
            [alignRight ? 'right' : 'left']: 0,
            width: 320,
            background: 'var(--card)',
            border: '1px solid var(--hairline-strong)',
            borderRadius: 10,
            boxShadow: 'var(--shadow-lg)',
            zIndex: 50,
            padding: 14,
          }}
        >
          <div className="row gap-2" style={{ marginBottom: 10, alignItems: 'baseline' }}>
            <span className="mono" style={{ fontSize: 22, fontWeight: 500, color: band.color }}>{total}</span>
            <Pill kind="muted" style={{ background: 'transparent', borderColor: band.color, color: band.color }}>{band.label}</Pill>
          </div>
          <div className="text-2 text-xs" style={{ marginBottom: 12, lineHeight: 1.5 }}>{band.desc}</div>
          <div className="eyebrow" style={{ marginBottom: 8 }}>Breakdown</div>
          <div style={{ display: 'flex', flexDirection: 'column' }}>
            {factors.map((f, i) => (
              <div
                key={i}
                className="row gap-2"
                style={{
                  padding: '6px 0',
                  borderBottom: i === factors.length - 1 ? 'none' : '1px solid var(--hairline)',
                  fontSize: 12,
                }}
                title={f.desc}
              >
                <span className="grow truncate text-2">{f.label}</span>
                <span
                  className="mono"
                  style={{
                    minWidth: 36,
                    textAlign: 'right',
                    color: f.value > 0 ? 'var(--positive)' : f.value < 0 ? 'var(--negative)' : 'var(--ink-3)',
                    fontWeight: 500,
                  }}
                >
                  {f.value > 0 ? '+' : ''}{f.value}
                </span>
              </div>
            ))}
          </div>
        </div>
      ) : null}
    </span>
  );
};

// Ring score for property match
const ScoreRing = ({ value, size = 56, stroke = 4, color }) => {
  const r = (size - stroke) / 2;
  const C = 2 * Math.PI * r;
  const pct = Math.max(0, Math.min(1, value / 100));
  return (
    <span className="ring" style={{ width: size, height: size }}>
      <svg width={size} height={size}>
        <circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          stroke="var(--muted-soft)"
          strokeWidth={stroke}
        />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          stroke={color || 'var(--accent)'}
          strokeWidth={stroke}
          strokeDasharray={`${C * pct} ${C}`}
          strokeLinecap="round"
        />
      </svg>
      <span className="num">{value}</span>
    </span>
  );
};

// ─── Avatar ──────────────────────────────────────────────────────────────
const Avatar = ({ initials, size = 26, tone = 'accent' }) => {
  const bg = tone === 'accent' ? 'var(--accent-soft)' : 'var(--muted-soft)';
  const fg = tone === 'accent' ? 'var(--accent)' : 'var(--ink-2)';
  return (
    <span className="avatar" style={{ width: size, height: size, fontSize: size * 0.4, background: bg, color: fg }}>
      {initials || '—'}
    </span>
  );
};

// ─── Brand logo ──────────────────────────────────────────────────────────
// Resolves a real brand logo from the company's web domain and falls back to
// an initials avatar if nothing loads. No data stored — the domain is derived
// from the brand name (or an explicit brand.domain/website if present), so it
// works for every scanned brand with zero backend/schema changes.
function brandDomainGuess(brand) {
  const explicit = (brand && (brand.domain || brand.website)) || '';
  if (explicit) {
    try {
      const u = explicit.startsWith('http') ? explicit : `https://${explicit}`;
      return new URL(u).hostname.replace(/^www\./, '');
    } catch (_) { /* fall through to name guess */ }
  }
  const name = (brand && brand.brand) || '';
  const slug = name.toLowerCase()
    .replace(/&/g, 'and')
    .replace(/['’.]/g, '')
    .replace(/\b(inc|llc|ltd|plc|gmbh|sa|ag|co|corp|corporation|company|group|holdings|the|uk|usa|us|eu|global|international|intl|worldwide)\b/g, '')
    .replace(/[^a-z0-9]+/g, '')
    .trim();
  return slug ? `${slug}.com` : '';
}

// Favicon services return a generic globe (HTTP 200) instead of failing when a
// site has no icon, so onError never fires. We detect the miss by size: Google's
// service returns the real favicon at the requested size (>=32px) when it exists,
// but a 16px default globe when it doesn't. Anything not clearly a real logo
// falls back to the brand's initials.
const BrandLogo = ({ brand, size = 28, radius = 7 }) => {
  const domain = React.useMemo(
    () => brandDomainGuess(brand),
    [brand && brand.brand, brand && brand.domain, brand && brand.website]
  );
  const src = domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=128` : '';
  const [status, setStatus] = React.useState('loading'); // loading | ok | fail

  React.useEffect(() => {
    if (!src) { setStatus('fail'); return; }
    setStatus('loading');
    let alive = true;
    const img = new Image();
    img.onload = () => { if (alive) setStatus(img.naturalWidth >= 32 ? 'ok' : 'fail'); };
    img.onerror = () => { if (alive) setStatus('fail'); };
    img.src = src;
    return () => { alive = false; };
  }, [src]);

  // Show initials while loading and whenever no real logo is available.
  if (status !== 'ok') {
    return <Avatar initials={initialsFor(brand && brand.brand)} size={size} tone="muted" />;
  }
  return (
    <span
      style={{
        width: size, height: size, borderRadius: radius,
        background: '#fff', border: '1px solid var(--hairline)',
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        overflow: 'hidden', flexShrink: 0,
      }}
      title={brand && brand.brand}
    >
      <img src={src} alt="" width={size - 6} height={size - 6} style={{ objectFit: 'contain', display: 'block' }} />
    </span>
  );
};

// ─── Cite (citation chip) ────────────────────────────────────────────────
const Cite = ({ n, onClick, title }) => (
  <sup className="cite" title={title} onClick={onClick}>{n}</sup>
);

// ─── Flow step (workflow visual) ─────────────────────────────────────────
const FlowStep = ({ idx, label, mode, current, stopped, kiv }) => (
  <div className={`flowstep ${mode} ${current ? 'is-current' : ''} ${stopped ? 'is-stopped' : ''}`}>
    <span className="badge">{idx + 1}</span>
    <span className="meta">
      <span className="lbl">{label}</span>
      <span className="typ">{mode === 'auto' ? 'Automated' : 'You'}</span>
    </span>
  </div>
);

// ─── Section header ──────────────────────────────────────────────────────
const SectionHeader = ({ eyebrow, title, sub, right }) => (
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 22, gap: 24 }}>
    <div>
      {eyebrow ? <div className="eyebrow" style={{ marginBottom: 8 }}>{eyebrow}</div> : null}
      <div className="h1">{title}</div>
      {sub ? <div className="text-2 text-sm" style={{ marginTop: 8, maxWidth: 640 }}>{sub}</div> : null}
    </div>
    {right ? <div className="row gap-2">{right}</div> : null}
  </div>
);

// ─── Card chrome ────────────────────────────────────────────────────────
const Card = ({ children, className = '', pad = true, lg, style, onClick }) => (
  <div
    className={`card ${pad ? (lg ? 'card-pad-lg' : 'card-pad') : ''} ${className}`}
    style={style}
    onClick={onClick}
  >
    {children}
  </div>
);

// ─── Definition list (label / value) ─────────────────────────────────────
// Grid lives in .deflist (index.html) so the value track can be minmax(0,…) and
// stack on phones — an inline 160px/1fr grid let long unbroken text widen the card.
const Definition = ({ label, value, cites, children }) => (
  <div className="deflist">
    <div className="eyebrow" style={{ paddingTop: 2 }}>{label}</div>
    <div className="text-sm defval" style={{ lineHeight: 1.55 }}>
      {value || children}
      {cites && cites.length ? (
        <span>
          {cites.map((c) => <Cite key={c} n={c} />)}
        </span>
      ) : null}
    </div>
  </div>
);

// ─── Toast (root-level imperative) ───────────────────────────────────────
// msg is a string, or {text, action:{label,onClick}} when the toast needs a
// button. Both shapes are supported because ~50 call sites pass a plain string,
// and rendering an object as a React child throws.
const Toast = ({ msg, onClear }) => {
  const text = msg && typeof msg === 'object' ? msg.text : msg;
  const action = msg && typeof msg === 'object' ? msg.action : null;
  React.useEffect(() => {
    if (!text) return;
    // A toast with a button waits for the person to read and decide, so it gets
    // far longer than one that is only telling them something happened.
    // Longer messages stay visible longer (roughly reading speed), 4-10s.
    const duration = action
      ? 30000
      : Math.min(10000, Math.max(4000, String(text).length * 55));
    const t = setTimeout(onClear, duration);
    return () => clearTimeout(t);
  }, [text, action, onClear]);
  if (!text) return null;
  return (
    <div className="toast">
      <span>{text}</span>
      {action ? (
        <button
          className="btn btn-sm"
          style={{ marginLeft: 10, height: 26 }}
          onClick={() => { onClear(); action.onClick && action.onClick(); }}
        >
          {action.label}
        </button>
      ) : null}
    </div>
  );
};

// ─── Copy button with success feedback ────────────────────────────────────
// Shows a checkmark + "Copied" for 1.5s after a successful copy.
const legacyCopy = (text) => {
  const ta = document.createElement('textarea');
  ta.value = text;
  ta.style.position = 'fixed';
  ta.style.opacity = '0';
  document.body.appendChild(ta);
  ta.select();
  let ok = false;
  try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
  document.body.removeChild(ta);
  return ok;
};

const CopyBtn = ({ text, label, onToast, className = 'btn btn-sm', title }) => {
  const [copied, setCopied] = React.useState(false);
  const timer = React.useRef(null);
  React.useEffect(() => () => clearTimeout(timer.current), []);
  const flash = () => {
    setCopied(true);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1500);
  };
  const doCopy = () => {
    const write = navigator.clipboard
      ? navigator.clipboard.writeText(text)
      : Promise.reject(new Error('Clipboard unavailable'));
    write.then(flash).catch(() => {
      // Clipboard API can be blocked (permissions, embedded browsers) —
      // fall back to the legacy textarea+execCommand path.
      if (legacyCopy(text)) flash();
      else onToast && onToast('Could not copy — select and copy manually');
    });
  };
  return (
    <button className={className} onClick={doCopy} title={title} style={copied ? { color: 'var(--positive)', borderColor: 'var(--positive)' } : undefined}>
      {copied ? <window.I.check size={12} stroke="var(--positive)" /> : <window.I.copy size={12} />}
      {copied ? 'Copied' : label}
    </button>
  );
};

// ─── Confirm modal (replaces window.confirm — themed, dark-mode aware) ────
const ConfirmModal = ({ open, title, message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', danger, onConfirm, onCancel }) => {
  React.useEffect(() => {
    if (!open) return;
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    const onKey = (e) => { if (e.key === 'Escape') onCancel(); };
    window.addEventListener('keydown', onKey);
    return () => {
      document.body.style.overflow = prev;
      window.removeEventListener('keydown', onKey);
    };
  }, [open, onCancel]);
  if (!open) return null;
  return (
    <div
      onClick={onCancel}
      style={{
        position: 'fixed', inset: 0,
        background: 'rgba(31, 27, 22, 0.45)',
        backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        zIndex: 300, padding: 24,
      }}
    >
      <div className="card card-pad-lg" onClick={(e) => e.stopPropagation()} style={{ width: 440, maxWidth: '100%', boxShadow: 'var(--shadow-lg)' }}>
        <div className="row gap-2" style={{ alignItems: 'center', marginBottom: 12 }}>
          <window.I.warn size={18} stroke={danger ? 'var(--negative)' : 'var(--accent)'} />
          <h3 className="h3" style={{ margin: 0 }}>{title}</h3>
        </div>
        <div className="text-2 text-sm" style={{ marginBottom: 20, lineHeight: 1.6 }}>{message}</div>
        <div className="row gap-2" style={{ justifyContent: 'flex-end' }}>
          <button className="btn" onClick={onCancel} autoFocus>{cancelLabel}</button>
          <button
            className="btn btn-primary"
            onClick={onConfirm}
            style={danger ? { background: 'var(--negative)', borderColor: 'var(--negative)' } : undefined}
          >
            {confirmLabel}
          </button>
        </div>
      </div>
    </div>
  );
};

// Promise-based confirm: const [confirm, confirmEl] = useConfirm();
// if (await confirm({ title, message, confirmLabel, cancelLabel, danger })) { ... }
// Render {confirmEl} once in the component.
const useConfirm = () => {
  const [state, setState] = React.useState(null);
  const confirm = React.useCallback((opts) => new Promise((resolve) => {
    setState({ ...opts, resolve });
  }), []);
  const confirmEl = state ? (
    <ConfirmModal
      open
      title={state.title}
      message={state.message}
      confirmLabel={state.confirmLabel}
      cancelLabel={state.cancelLabel}
      danger={state.danger}
      onConfirm={() => { state.resolve(true); setState(null); }}
      onCancel={() => { state.resolve(false); setState(null); }}
    />
  ) : null;
  return [confirm, confirmEl];
};

// Warn-but-allow guard for outreach stage changes: marking a brand contacted
// (or further) with no draft is usually a mistake — but allowed, because the
// user may have contacted them outside the app. Returns true to proceed.
async function guardOutreachChange(brand, nextStage, confirm) {
  const advanced = nextStage && nextStage !== 'not_contacted' && nextStage !== 'closed';
  if (!advanced || brand.draft) return true;
  return confirm({
    title: 'No draft for this brand yet',
    message: `${brand.brand} has no outreach draft. Mark it as "${outreachMeta(nextStage).label}" anyway? That is fine if you contacted them outside the app.`,
    confirmLabel: 'Mark anyway',
  });
}

// Helpers
const initialsFor = (name) => {
  if (!name) return '';
  const parts = name.trim().split(/\s+/);
  return ((parts[0]?.[0] || '') + (parts[1]?.[0] || '')).toUpperCase();
};

// Dates come in two flavours and must be handled differently:
//   "2026-07-24"            — a calendar date. Show it verbatim. Parsing it with
//                             new Date() yields UTC midnight, which then renders
//                             as the PREVIOUS day for anyone west of UTC.
//   "2026-07-24T13:44:10Z"  — an instant. Render it in the reader's zone.
const formatDate = (iso) => {
  if (!iso) return '';
  const s = String(iso);
  if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
    const [y, m, d] = s.split('-').map(Number);
    return new Intl.DateTimeFormat('en-GB', {
      timeZone: 'UTC', day: 'numeric', month: 'short', year: 'numeric',
    }).format(new Date(Date.UTC(y, m - 1, d)));
  }
  const d = new Date(s);
  if (isNaN(d.getTime())) return '';
  return formatInstant(s, viewerTimeZone(), { hour: undefined, minute: undefined });
};

const propLabel = (id) => {
  const p = PropertyById(id);
  return p ? p.short : id;
};
const propName = (id) => {
  const p = PropertyById(id);
  return p ? p.name : id;
};

// ─── Outreach pipeline stages (shared across screens) ─────────────────────
const OUTREACH_STAGES = [
  { value: 'not_contacted',  label: 'Not contacted',        color: 'var(--ink-3)' },
  { value: 'contacted',      label: 'Contacted',            color: 'var(--pending)' },
  { value: 'replied',        label: 'Replied',              color: 'var(--accent)' },
  { value: 'call_scheduled', label: 'Call scheduled',       color: 'var(--positive)' },
  { value: 'closed',         label: 'No response / Closed', color: 'var(--ink-2)' },
];
const outreachMeta = (v) => OUTREACH_STAGES.find((s) => s.value === v) || OUTREACH_STAGES[0];

// ─── Canonical pipeline stage model (single source of truth) ──────────────
// Used by BOTH the Pipeline board and the Dashboard breakdown so their counts
// always agree. Each brand maps to exactly ONE active stage, or is held/closed.
const PB_STAGES = [
  { key: 'triage',    n: 1, label: 'Triage',        desc: 'Needs your decision',    color: 'var(--pending)',       route: 'intake' },
  { key: 'research',  n: 2, label: 'Research',       desc: 'AI building profile',    color: 'var(--sig-blue-fg)',   route: 'research' },
  { key: 'match',     n: 3, label: 'Property match', desc: 'Pick the best fit',      color: 'var(--sig-purple-fg)', route: 'match' },
  { key: 'approval',  n: 4, label: 'Rightsholder approval', desc: 'Waiting on the property', color: 'var(--sig-amber-fg)', route: 'properties' },
  { key: 'contacts',  n: 5, label: 'Contacts',       desc: 'Finding people',         color: 'var(--accent)',        route: 'contacts' },
  { key: 'draft',     n: 6, label: 'Draft',          desc: 'Write the outreach',     color: 'var(--sig-amber-fg)',  route: 'draft' },
  { key: 'outreach',  n: 7, label: 'Ready to send',  desc: 'Draft ready, not sent',  color: 'var(--attention)',     route: 'draft' },
  { key: 'contacted', n: 8, label: 'Contacted',      desc: 'Sent, tracking replies', color: 'var(--positive)',      route: 'draft' },
];
const PB_INDEX = PB_STAGES.reduce((m, s, i) => { m[s.key] = i; return m; }, {});

function pbConfirmed(b) {
  return (b.confirmedProperties || []).filter((p) => p !== 'none');
}
function pbIsHeldOrClosed(b) {
  const hasNone = (b.confirmedProperties || []).includes('none');
  return b.status === 'not-relevant' || b.status === 'duplicate' ||
    b.priority === 'no-action' || b.priority === 'kiv' ||
    b.researchStatus === 'rejected' || b.researchStatus === 'kiv' || hasNone ||
    b.doNotContact === true || b.outreachStatus === 'closed';
}
// ─── Rightsholder approval index ──────────────────────────────────────────
// {brandId: {approved, pending, discuss, rejected, askedAt}}, loaded once by
// app.jsx from /api/brands/approvals. A module global for the same reason
// window.IPSEM_DATA.PROPERTIES is one: pbStage() is called from four screens
// and from deep inside chips, and threading this through every one of them as a
// prop would touch far more code than it is worth.
// How long a rightsholder gets before the app starts calling it late. Two
// working weeks — long enough that a normal reply is not nagged, short enough
// that a forgotten round surfaces inside the month.
const APPROVAL_CHASE_DAYS = 14;

let APPROVAL_INDEX = {};
function setApprovalIndex(map) { APPROVAL_INDEX = map || {}; }
function approvalFor(brandId) { return APPROVAL_INDEX[brandId] || null; }

// Is this brand sat waiting on a rightsholder?
//
// Deliberately keyed on having been ASKED, not on lacking an approval. Treating
// "never raised" as awaiting would drop the entire existing book — hundreds of
// brands matched long before this existed — into the approval stage overnight,
// and the funnel would be a wall of one colour. It mirrors the rule the server
// enforces: only an explicit answer changes what you may do.
//
// A brand every rightsholder refused also lands here. It has nowhere to go and
// needs a person to rematch or overturn it, so it belongs in a stage someone
// looks at rather than quietly parked further down the funnel.
function awaitingApproval(b) {
  const specific = pbConfirmed(b).filter((p) => p !== 'generic');
  if (!specific.length) return false;              // generic pitch — nobody to ask
  const a = approvalFor(b.id);
  if (!a) return false;
  if (specific.some((p) => (a.approved || []).includes(p))) return false;  // through the gate
  return specific.some((p) =>
    (a.pending || []).includes(p) || (a.discuss || []).includes(p) || (a.rejected || []).includes(p));
}

// Map a brand to its current ACTIVE stage key, or null if held/closed.
function pbStage(b) {
  if (pbIsHeldOrClosed(b)) return null;
  // Checked before approval on purpose: a brand already drafted or contacted —
  // everything sent before this feature existed — must not be pulled backwards
  // into the approval stage.
  if (b.draft) {
    const sent = b.outreachStatus && b.outreachStatus !== 'not_contacted';
    return sent ? 'contacted' : 'outreach';
  }
  if (pbConfirmed(b).length) {
    if (awaitingApproval(b)) return 'approval';
    return (b.contacts || []).length ? 'draft' : 'contacts';
  }
  if (b.researchStatus === 'complete') return 'match';
  if (b.researchStatus === 'researching' || b.researchStatus === 'needs-review') return 'research';
  return 'triage';
}

// ─── Detailed outreach stage ladder (mirrors the Weekly Brand Review workbook) ─
const DETAIL_STAGES = [
  { value: '1-potential-alignment',   label: '1 · Potential alignment' },
  { value: '2-approved-for-approach', label: '2 · Approved for approach' },
  { value: '3-rejected-by-rh',        label: '3 · Rejected by rightsholder' },
  { value: '3-rejected-by-ipsem',     label: '3 · Rejected by IPSEM' },
  { value: '4-initial-outreach',      label: '4 · Initial outreach' },
  { value: '4-initial-outreach-wa',   label: '4 · Initial outreach (WhatsApp)' },
  { value: '5-active-interest',       label: '5 · Active interest' },
  { value: '6-proposal',              label: '6 · Proposal' },
  { value: '7-negotiation',           label: '7 · Negotiation' },
  { value: '8-terms-agreed',          label: '8 · Terms agreed' },
  { value: '9-contracted',            label: '9 · Contracted' },
  { value: 'on-hold',                 label: 'On hold' },
  { value: 'declined-lost',           label: 'Declined / Lost' },
];
const detailStageLabel = (v) => {
  const s = DETAIL_STAGES.find((x) => x.value === v);
  return s ? s.label : v;
};

// Rightsholder approval to approach a brand (workbook "Client Approval Status").
const APPROVAL_META = {
  'approved':     { label: 'Approved by rightsholder', kind: 'positive' },
  'not-approved': { label: 'Not yet approved',         kind: 'pending' },
};

// ─── Date helpers ─────────────────────────────────────────────────────────
// The team works across three zones. Two different kinds of value are involved
// and they must not be conflated:
//
//   INSTANTS  — a moment in time (note written, notification raised). Stored as
//               UTC ISO, rendered in the reader's own zone with the zone shown.
//   BUSINESS  — a working day (review date, follow-up, sent date). These belong
//   DATES       to IPSEM's operating day, which is GMT+8 because that is when
//               the daily scan runs. They are calendar dates, never instants,
//               and must never be shifted across a timezone.
const IPSEM_TZ = 'Asia/Singapore';   // GMT+8, no DST — IPSEM's operating day

// Notifications are read against London. Whoever is reading, "when was this
// said?" is answered in one agreed zone rather than in each reader's own, so two
// people discussing the same note are never looking at two different clocks.
// An IANA zone, not a fixed offset: London is GMT+0 in winter, GMT+1 under BST.
const NOTIFICATION_TZ = 'Europe/London';

// IANA zones, not fixed offsets: London is GMT+0 in winter and GMT+1 under BST,
// Paris GMT+1 / GMT+2. Hardcoding the winter offset would be wrong half the year.
const TEAM_ZONES = [
  { name: 'Aaron',  tz: 'Asia/Singapore' },
  { name: 'Stuart', tz: 'Europe/London' },
  { name: 'Rene',   tz: 'Europe/Paris' },
];

// The reader's zone: their profile override if set, else whatever the browser
// reports. app.jsx pushes the profile value in once the session loads.
let _viewerTz = null;
function setViewerTimeZone(tz) { _viewerTz = tz || null; }
function browserTimeZone() {
  try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
  catch (e) { return 'UTC'; }
}
function viewerTimeZone() { return _viewerTz || browserTimeZone(); }

// "GMT+8" for a zone at a given instant (DST-aware).
function tzOffsetLabel(tz, date) {
  try {
    const parts = new Intl.DateTimeFormat('en-GB', { timeZone: tz, timeZoneName: 'shortOffset' })
      .formatToParts(date || new Date());
    const hit = parts.find((p) => p.type === 'timeZoneName');
    return hit ? hit.value : '';
  } catch (e) {
    return '';
  }
}

// Render an instant in a specific zone. Never used for calendar dates.
function formatInstant(iso, tz, opts) {
  if (!iso) return '';
  const d = new Date(iso);
  if (isNaN(d.getTime())) return '';
  try {
    return new Intl.DateTimeFormat('en-GB', {
      timeZone: tz || viewerTimeZone(),
      day: 'numeric', month: 'short', year: 'numeric',
      hour: '2-digit', minute: '2-digit', hour12: false,
      ...(opts || {}),
    }).format(d);
  } catch (e) {
    return d.toISOString().slice(0, 16).replace('T', ' ');
  }
}

// Timezone-free wording — "2 hours ago" means the same thing in every zone,
// which is the whole point when three people discuss the same note.
function relativeTime(iso) {
  if (!iso) return '';
  const then = new Date(iso).getTime();
  if (isNaN(then)) return '';
  const secs = Math.round((Date.now() - then) / 1000);
  if (secs < 0) {
    const ahead = Math.abs(secs);
    if (ahead < 90) return 'just now';
    if (ahead < 3600) return `in ${Math.round(ahead / 60)} min`;
    if (ahead < 86400) return `in ${Math.round(ahead / 3600)} h`;
    return `in ${Math.round(ahead / 86400)} d`;
  }
  if (secs < 60) return 'just now';
  if (secs < 3600) { const m = Math.round(secs / 60); return `${m} min ago`; }
  if (secs < 86400) { const h = Math.round(secs / 3600); return `${h} hour${h === 1 ? '' : 's'} ago`; }
  const days = Math.round(secs / 86400);
  if (days === 1) return 'yesterday';
  if (days < 7) return `${days} days ago`;
  if (days < 30) { const w = Math.round(days / 7); return `${w} week${w === 1 ? '' : 's'} ago`; }
  return formatInstant(iso, viewerTimeZone(), { hour: undefined, minute: undefined });
}

// Every timestamp in the app. Relative by default (unambiguous across zones);
// hovering reveals the exact instant in the reader's zone AND in all three
// team zones, so "which 10:00?" never has to be asked.
// Pass `tz` to pin a timestamp to one agreed zone regardless of the reader —
// notifications do this so everyone quotes the same clock (see NOTIFICATION_TZ).
function TimeStamp({ iso, mode = 'relative', className, style, tz: pinnedTz }) {
  if (!iso) return null;
  const tz = pinnedTz || viewerTimeZone();
  const when = new Date(iso);
  const own = viewerTimeZone();
  const head = pinnedTz
    ? `${formatInstant(iso, pinnedTz)} ${tzOffsetLabel(pinnedTz, when)} — London`
    : `${formatInstant(iso, tz)} ${tzOffsetLabel(tz, when)} — your time`;
  // When pinned, still show the reader their own clock, so a London time never
  // has to be converted in someone's head.
  const stamp = (z) => `${formatInstant(iso, z)} ${tzOffsetLabel(z, when)}`;
  const mineToo = (pinnedTz && stamp(own) !== stamp(pinnedTz))
    ? [`${stamp(own)} — your time`] : [];
  // Drop any team line that reads identically to one already shown. Zone NAMES
  // differ where the clock does not — a reader in Asia/Kuala_Lumpur and Aaron in
  // Asia/Singapore are both GMT+8 — so filtering on the tz string listed the
  // same time twice.
  const shown = new Set([stamp(tz), ...(pinnedTz ? [stamp(own)] : [])]);
  const others = [];
  for (const z of TEAM_ZONES) {
    const s = stamp(z.tz);
    if (shown.has(s)) continue;
    shown.add(s);
    others.push(`${z.name}: ${s}`);
  }
  const title = [head, ...mineToo, ...others].join('\n');

  const absolute = `${formatInstant(iso, tz)} ${tzOffsetLabel(tz, when)}`;
  const text = mode === 'absolute' ? absolute
    : mode === 'both' ? `${relativeTime(iso)} · ${absolute}`
    : relativeTime(iso);

  return (
    <span className={className} style={{ cursor: 'help', ...(style || {}) }} title={title}>
      {text}
    </span>
  );
}

// Today on IPSEM's operating day (GMT+8), whatever zone the reader is in.
// Derived through Intl rather than a fixed +8 offset so it stays correct if
// the operating zone is ever changed to one that observes DST.
function ipsemToday() {
  try {
    // en-CA formats as YYYY-MM-DD.
    return new Intl.DateTimeFormat('en-CA', { timeZone: IPSEM_TZ }).format(new Date());
  } catch (e) {
    return new Date(Date.now() + 8 * 3600 * 1000).toISOString().slice(0, 10);
  }
}
// True when the reader's calendar day differs from IPSEM's operating day —
// used to warn that "today" on screen is not today where they are sitting.
function viewerOffsetFromIpsemDay() {
  try {
    const mine = new Intl.DateTimeFormat('en-CA', { timeZone: viewerTimeZone() }).format(new Date());
    return mine === ipsemToday() ? 0 : (mine < ipsemToday() ? -1 : 1);
  } catch (e) {
    return 0;
  }
}
function addBusinessDays(fromISO, n) {
  const d = new Date(((fromISO || ipsemToday())) + 'T00:00:00Z');
  let added = 0;
  while (added < n) {
    d.setUTCDate(d.getUTCDate() + 1);
    const wd = d.getUTCDay();
    if (wd !== 0 && wd !== 6) added++;
  }
  return d.toISOString().slice(0, 10);
}
// Days until an ISO date (negative = overdue). null if no date.
function daysUntil(iso) {
  if (!iso) return null;
  const a = new Date(ipsemToday() + 'T00:00:00Z').getTime();
  const b = new Date(String(iso).slice(0, 10) + 'T00:00:00Z').getTime();
  return Math.round((b - a) / 86400000);
}

// ─── Rightsholder refusals ────────────────────────────────────────────────
// A property that has refused a brand must not appear in an email pitched on
// its behalf. The server enforces this; these let the UI say so first, rather
// than letting someone write a draft and then be told no.

// { refused: [propertyId…], remaining: [propertyId…], blocked: bool }
// `blocked` means there is nothing left to pitch — every specific property said
// no. Generic outreach is never blocked: it is not sent on any one
// rightsholder's behalf, so none of them can veto it.
function refusalState(brand, approvals) {
  const confirmed = (brand?.confirmedProperties || []).filter((p) => p !== 'none');
  const specific = confirmed.filter((p) => p !== 'generic');
  // Falls back to the shared index, so callers that already have it app-wide
  // need not pass it down — the two are the same data.
  const entry = (approvals || {})[brand?.id] || approvalFor(brand?.id) || {};
  const refusedAll = entry.rejected || [];
  const refused = specific.filter((p) => refusedAll.includes(p));
  const remaining = specific.filter((p) => !refusedAll.includes(p));
  return {
    refused,
    remaining,
    blocked: refused.length > 0 && remaining.length === 0 && !confirmed.includes('generic'),
  };
}

function RefusalNotice({ brand, approvals, onNavigate }) {
  const state = refusalState(brand, approvals);
  if (!state.refused.length) return null;
  const names = state.refused.map(propName).join(', ');
  const danger = state.blocked;
  // A generic match survives a refusal but has no property id to name, so it is
  // spelled out — otherwise the sentence ends "will pitch only".
  const survivors = [
    ...state.remaining.map(propName),
    ...((brand?.confirmedProperties || []).includes('generic') ? ['the IPSEM portfolio'] : []),
  ].join(', ');
  return (
    <div
      className="row gap-2"
      style={{
        alignItems: 'flex-start',
        padding: '10px 12px',
        borderRadius: 8,
        border: `1px solid ${danger ? 'var(--negative)' : 'var(--hairline-strong)'}`,
        background: 'var(--card-alt)',
        marginBottom: 12,
      }}
    >
      <window.I.ban size={14} stroke={danger ? 'var(--negative)' : 'var(--attention)'} />
      <div className="grow">
        <div className="text-sm" style={{ fontWeight: 600, color: danger ? 'var(--negative)' : 'var(--ink)' }}>
          {danger
            ? `${names} refused this brand — outreach is blocked`
            : `${names} refused this brand`}
        </div>
        <div className="text-xs text-2" style={{ marginTop: 3, lineHeight: 1.5 }}>
          {danger
            ? 'There is no property left to pitch on. Change the approval on the Properties page, or match the brand to another property.'
            : `The draft will pitch ${survivors} only.`}
        </div>
      </div>
      {onNavigate ? (
        <button className="btn btn-sm" onClick={() => onNavigate('properties')}>
          Properties
          <window.I.arrowRight size={12} />
        </button>
      ) : null}
    </div>
  );
}

// ─── CSV export (client-side download) ────────────────────────────────────
function downloadCsv(filename, rows) {
  if (!rows || !rows.length) return;
  const headers = Object.keys(rows[0]);
  const esc = (v) => {
    if (v == null) return '';
    const s = Array.isArray(v) ? v.join('; ') : (typeof v === 'object' ? JSON.stringify(v) : String(v));
    return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
  };
  const csv = [headers.join(','), ...rows.map((r) => headers.map((h) => esc(r[h])).join(','))].join('\n');
  const blob = new Blob(['﻿' + csv], { type: 'text/csv;charset=utf-8;' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = filename; a.click();
  URL.revokeObjectURL(url);
}

// ─── Assignee + location helpers (shared across screens) ──────────────────
const ASSIGNEE_NAMES = ['Aaron', 'Rene', 'Stuart'];

// Normalise legacy seed values (AS/RV) to the full-name set.
const displayAssignee = (raw) => {
  if (!raw) return '';
  if (raw === 'AS') return 'Aaron';
  if (raw === 'RV') return 'Rene';
  return raw;
};

// Pull the country from an HQ string. "London, UK" → "UK"; "Singapore" → "Singapore".
const extractCountry = (hq) => {
  if (!hq) return '';
  const parts = String(hq).split(',').map((s) => s.trim()).filter(Boolean);
  return parts[parts.length - 1] || '';
};

// ─── Filter state that survives navigation ────────────────────────────────
// Screens unmount when you leave them, so plain useState wiped every filter
// (the "I have to re-choose Tier 1 each time" complaint). Same API as
// useState, backed by sessionStorage so it also survives a page refresh but
// resets when the tab closes.
function usePersistentState(key, initial) {
  const storageKey = `ipsem-ui:${key}`;
  const [value, setValue] = React.useState(() => {
    try {
      const raw = sessionStorage.getItem(storageKey);
      return raw === null ? initial : JSON.parse(raw);
    } catch (e) {
      return initial;
    }
  });
  React.useEffect(() => {
    try { sessionStorage.setItem(storageKey, JSON.stringify(value)); } catch (e) { /* private mode */ }
  }, [storageKey, value]);
  return [value, setValue];
}

// ─── Property engagement type ─────────────────────────────────────────────
// How long we can work a property for. 'one-off' covers short-window assets
// (a single tour, a single event) that should not be pitched as season deals.
const ENGAGEMENT_TYPES = [
  { value: 'ongoing',    label: 'Ongoing',    short: 'Ongoing',    kind: 'muted',    desc: 'Season-long or multi-year partnership' },
  { value: 'short-term', label: 'Short term', short: 'Short term', kind: 'pending',  desc: 'A limited window — a series, a season, a campaign' },
  { value: 'one-off',    label: 'One-off',    short: 'One-off',    kind: 'accent',   desc: 'A single event or tour — cannot be sold as an ongoing deal' },
];
const engagementMeta = (v) => ENGAGEMENT_TYPES.find((t) => t.value === v) || ENGAGEMENT_TYPES[0];

// ─── Identity ─────────────────────────────────────────────────────────────
// Mentions and notifications are keyed by the canonical team name (Aaron /
// Rene / Stuart), but a signed-in user is "Aaron Sailis" or, with no profile
// name set, just an email. Resolve any of those to the canonical name so a
// notification addressed to @Aaron actually reaches Aaron Sailis.
// Returns '' when the user cannot be matched to anyone on the team.
function resolveTeamName(user) {
  if (!user) return '';
  const tryMatch = (candidate) => {
    const c = String(candidate || '').trim().toLowerCase();
    if (!c) return '';
    return ASSIGNEE_NAMES.find((n) => n.toLowerCase() === c) || '';
  };
  // Exact display name, then each word of it ("Aaron Sailis" → "Aaron").
  const name = String((user.name || '')).trim();
  let hit = tryMatch(name);
  if (hit) return hit;
  for (const word of name.split(/\s+/)) {
    hit = tryMatch(word);
    if (hit) return hit;
  }
  // Fall back to the email local part ("stuart@ipsemsquared.com" → "Stuart").
  const local = String(user.email || '').split('@')[0];
  const parts = local.split(/[^A-Za-z]+/).filter(Boolean);
  for (const part of parts) {
    hit = tryMatch(part);
    if (hit) return hit;
  }
  // Last resort: a local part that STARTS WITH a team name. Real addresses here
  // look like aaronks@… — with exact matching only, a user who had not set a
  // full name resolved to nobody, so the bell returned null and they received
  // no notifications at all, mentions included. Longest name first so a prefix
  // of another name cannot win. Must stay last, after every exact check.
  for (const part of parts) {
    const p = part.toLowerCase();
    const byLength = ASSIGNEE_NAMES.slice().sort((a, b) => b.length - a.length);
    const pre = byLength.find((n) => p.startsWith(n.toLowerCase()));
    if (pre) return pre;
  }
  return '';
}

// How to sign a note. Prefers the profile name, falls back to the team name
// so a user with no full name set is not credited as a raw email address.
// A note's author is whatever string was stored when it was written — sometimes
// a display name ("Stuart"), sometimes the raw email, depending on whether the
// writer had set a profile name at the time. Left as-is, the same person shows
// up twice in the Notes author filter and as two different people in the list.
// Emails resolve to the team name; anything else is already a name and is kept
// verbatim, so "Aaron Sailis" does not get shortened.
function authorLabel(author) {
  const raw = String(author || '').trim();
  if (!raw) return 'Unknown';
  if (!raw.includes('@')) return raw;
  return resolveTeamName({ email: raw }) || raw;
}

function displayNameFor(user) {
  if (!user) return 'Unknown';
  return (user.name || '').trim() || resolveTeamName(user) || user.email || 'Unknown';
}

// ─── @mentions ────────────────────────────────────────────────────────────
// Mentions resolve against the shared team list. Returns canonical names.
function parseMentions(text) {
  const found = [];
  const re = /@([A-Za-z][A-Za-z'-]*)/g;
  let m;
  while ((m = re.exec(String(text || ''))) !== null) {
    const hit = ASSIGNEE_NAMES.find((n) => n.toLowerCase() === m[1].toLowerCase());
    if (hit && !found.includes(hit)) found.push(hit);
  }
  return found;
}

// Renders note text with @mentions highlighted.
function MentionText({ text }) {
  const parts = String(text || '').split(/(@[A-Za-z][A-Za-z'-]*)/g);
  return (
    <span style={{ whiteSpace: 'pre-line' }}>
      {parts.map((p, i) => {
        const isMention = p.startsWith('@') &&
          ASSIGNEE_NAMES.some((n) => n.toLowerCase() === p.slice(1).toLowerCase());
        return isMention ? (
          <strong key={i} style={{ color: 'var(--accent)', background: 'var(--accent-soft-bg)', borderRadius: 4, padding: '1px 4px' }}>{p}</strong>
        ) : <React.Fragment key={i}>{p}</React.Fragment>;
      })}
    </span>
  );
}

// ─── Notes panel (shared) ─────────────────────────────────────────────────
// Lives in ui.jsx so it can be mounted on ANY screen where a decision is made
// — Research, Property Match, and anywhere else it is needed later.
// Type @ to mention a teammate; they get a real notification in the bell.
// One panel, one note list, identical on every stage. The notes live on the
// brand row (brands.notes), so a note written in Research is the same note read
// in Match, Contacts and Draft — there is no per-stage thread. The heading is
// deliberately fixed for the same reason: a different title on each page made
// it look like each stage had its own notes.
function NotesPanel({ brand, currentUser, onUpdateBrand, onToast, compact }) {
  const title = 'Notes';
  const [text, setText] = React.useState('');
  const [mentionQuery, setMentionQuery] = React.useState(null);
  const [isAction, setIsAction] = React.useState(false);
  const [actionOwner, setActionOwner] = React.useState('');
  const [editingAt, setEditingAt] = React.useState(null);   // createdAt of the note being edited
  const [editText, setEditText] = React.useState('');
  const taRef = React.useRef(null);
  const notes = Array.isArray(brand.notes) ? brand.notes : [];
  const author = displayNameFor(currentUser);
  const myTeamName = resolveTeamName(currentUser);

  const handleChange = (e) => {
    const v = e.target.value;
    setText(v);
    const caret = e.target.selectionStart || v.length;
    const m = v.slice(0, caret).match(/@([A-Za-z'-]*)$/);
    setMentionQuery(m ? m[1] : null);
  };

  const suggestions = mentionQuery === null
    ? []
    : ASSIGNEE_NAMES.filter((n) => n.toLowerCase().startsWith(mentionQuery.toLowerCase()));

  const insertMention = (name) => {
    const ta = taRef.current;
    const caret = ta ? ta.selectionStart : text.length;
    const before = text.slice(0, caret).replace(/@([A-Za-z'-]*)$/, `@${name} `);
    setText(before + text.slice(caret));
    setMentionQuery(null);
    requestAnimationFrame(() => {
      if (ta) { ta.focus(); ta.setSelectionRange(before.length, before.length); }
    });
  };

  const addNote = () => {
    const body = text.trim();
    if (!body) return;
    const mentions = parseMentions(body);
    // An action defaults to the first person mentioned, else the writer.
    const owner = isAction ? (actionOwner || mentions[0] || myTeamName || author) : '';
    const newNote = {
      // source marks this as hand-written, so the Notes & actions page can tell
      // it apart from a bulk Excel import and clear the two independently.
      text: body, author, createdAt: new Date().toISOString(), mentions, source: 'app',
      ...(isAction ? { isAction: true, actionOwner: owner, actionDone: false } : {}),
    };
    onUpdateBrand(brand.id, { notes: [newNote, ...notes] });
    setText('');
    setMentionQuery(null);
    setIsAction(false);
    setActionOwner('');

    // EVERY comment notifies the rest of the team, not only @mentions. The
    // weekly meeting runs on comments, and a note nobody is told about is a note
    // nobody reads — that was the whole point of the panel.
    //
    // Two kinds, because being named is stronger than being copied in:
    //   mention — someone typed @you
    //   comment — someone wrote on a brand
    // You are never notified about your own note. Compare on the canonical team
    // name, since `author` may be a full name or an email.
    const meLower = String(myTeamName || author).toLowerCase();
    const mentioned = mentions.filter((n) => n.toLowerCase() !== meLower);
    const mentionedLower = new Set(mentioned.map((n) => n.toLowerCase()));
    const others = ASSIGNEE_NAMES.filter((n) => {
      const l = n.toLowerCase();
      return l !== meLower && !mentionedLower.has(l);
    });

    const notify = (recipients, kind) => {
      if (!recipients.length) return;
      window.IPSEM_API.createNotifications({
        recipients, actor: author, brandId: brand.id, brandName: brand.brand, body, kind,
      }).catch((e) => console.warn(`${kind} notification failed:`, e));
    };
    notify(mentioned, 'mention');
    notify(others, 'comment');

    const told = [...mentioned, ...others];
    onToast && onToast(
      told.length
        ? `${isAction ? 'Next action' : 'Note'} added — ${told.join(' and ')} notified`
        : (isAction ? 'Next action added' : 'Note added')
    );
  };

  const deleteNote = (createdAt) => {
    onUpdateBrand(brand.id, { notes: notes.filter((n) => n.createdAt !== createdAt) });
  };

  // Whether this note is yours. Not a plain string comparison: the author was
  // stored as whatever displayNameFor returned at the time, so the same person
  // appears as "Stuart", "stuart@ipsemsquared.com" and "Aaron Sailis". Comparing
  // exactly would hide the edit control on a person's own older notes — 13 of
  // Stuart's are stored under his email address. resolveTeamName maps all three
  // forms onto one team name; the myTeamName guard stops two non-members (or
  // "Excel import") resolving to '' and matching each other.
  const isMine = (n) => {
    const a = (n && n.author) || '';
    if (!a) return false;
    if (a === author) return true;
    return !!myTeamName && resolveTeamName({ name: a, email: a }) === myTeamName;
  };

  // Editing keeps createdAt and author untouched, because the server matches a
  // note on that pair — change either and the update lands on nothing. It also
  // keeps attribution honest in a panel two people write in at once, which is
  // why only the author sees the control and why an edit is stamped rather than
  // applied silently.
  const saveEdit = (n, next) => {
    const body = (next || '').trim();
    setEditingAt(null);
    if (!body || body === n.text) return;
    onUpdateBrand(brand.id, {
      notes: notes.map((x) => (x.createdAt === n.createdAt && x.author === n.author
        ? { ...x, text: body, editedAt: new Date().toISOString() }
        : x)),
    });
    onToast && onToast('Note updated');
  };

  const toggleCleared = (n) => {
    onUpdateBrand(brand.id, { notes: toggleNoteCleared(notes, n.createdAt, author) });
    if (onToast) {
      const word = n.isAction ? 'action' : 'note';
      onToast(noteIsCleared(n) ? `Re-opened this ${word}` : `Marked this ${word} ${n.isAction ? 'done' : 'reviewed'}`);
    }
  };


  return (
    <Card lg={!compact}>
      <div className="row" style={{ marginBottom: 12, justifyContent: 'space-between', alignItems: 'baseline', flexWrap: 'wrap', gap: 6 }}>
        <div className="row gap-2" style={{ alignItems: 'baseline' }}>
          <div className="eyebrow">{title}</div>
          <span className="text-xs text-3">
            {brand.brand} · shared across every stage
          </span>
        </div>
        <span className="text-xs text-3">{notes.length} note{notes.length === 1 ? '' : 's'}</span>
      </div>

      <div className="col gap-2" style={{ marginBottom: notes.length ? 16 : 4, position: 'relative' }}>
        <textarea
          ref={taRef}
          value={text}
          onChange={handleChange}
          onKeyDown={(e) => {
            if (e.key === 'Escape') setMentionQuery(null);
            if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) addNote();
          }}
          placeholder="Add a note… type @ to tag someone (⌘/Ctrl + Enter to save)"
          rows={compact ? 2 : 2}
          style={{
            width: '100%', padding: '10px 12px',
            border: '1px solid var(--hairline-strong)', borderRadius: 6,
            font: 'inherit', fontSize: 13.5, lineHeight: 1.5,
            background: 'var(--card)', color: 'var(--ink)', resize: 'vertical',
          }}
        />
        {suggestions.length ? (
          <div style={{
            position: 'absolute', top: '100%', left: 0, zIndex: 30, marginTop: -6,
            background: 'var(--card)', border: '1px solid var(--hairline-strong)',
            borderRadius: 8, boxShadow: 'var(--shadow-lg)', padding: 4, minWidth: 180,
          }}>
            {suggestions.map((n) => (
              <button
                key={n}
                className="row gap-2"
                onClick={() => insertMention(n)}
                style={{ width: '100%', background: 'transparent', border: 0, cursor: 'pointer', padding: '7px 8px', borderRadius: 6, alignItems: 'center', textAlign: 'left' }}
              >
                <Avatar initials={initialsFor(n)} size={20} />
                <span className="text-sm">{n}</span>
              </button>
            ))}
          </div>
        ) : null}
        <div className="row" style={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
          <div className="row gap-3" style={{ alignItems: 'center', flexWrap: 'wrap' }}>
            <span className="text-xs text-3 row gap-1">
              <window.I.at size={11} stroke="var(--ink-3)" />
              Saved as <strong>{author}</strong>
            </span>
            <label className="row gap-1 text-xs" style={{ cursor: 'pointer', alignItems: 'center' }} title="Show this note as an open action on the Notes & actions page">
              <input type="checkbox" checked={isAction} onChange={(e) => setIsAction(e.target.checked)} />
              Next action
            </label>
            {isAction ? (
              <select
                value={actionOwner}
                onChange={(e) => setActionOwner(e.target.value)}
                className="field"
                style={{ height: 26, padding: '0 6px', fontSize: 11.5 }}
              >
                <option value="">Owner: auto</option>
                {ASSIGNEE_NAMES.map((n) => <option key={n} value={n}>{n}</option>)}
              </select>
            ) : null}
          </div>
          <button className="btn btn-sm btn-primary" onClick={addNote} disabled={!text.trim()}>
            <window.I.plus size={13} />
            {isAction ? 'Add action' : 'Add note'}
          </button>
        </div>
      </div>

      {notes.length === 0 ? (
        <div className="text-3 text-xs" style={{ paddingTop: 4 }}>No notes yet.</div>
      ) : (
        <div className="col gap-2">
          {notes.map((n) => (
            <div
              key={n.createdAt}
              className="col gap-1"
              style={{ padding: '10px 12px', background: 'var(--card-alt)', borderRadius: 8 }}
            >
              <div className="row gap-2" style={{ alignItems: 'center', flexWrap: 'wrap' }}>
                <Avatar initials={initialsFor(authorLabel(n.author))} size={20} />
                <span className="text-xs" style={{ fontWeight: 600 }}>{authorLabel(n.author)}</span>
                <TimeStamp iso={n.createdAt} className="text-xs text-3 mono" />
                {noteSource(n) === 'import' ? (
                  <Pill kind="neutral" style={{ fontSize: 10 }} title="Loaded from the Weekly Brand Review workbook, not written here">
                    Imported
                  </Pill>
                ) : null}
                {n.isAction ? (
                  <Pill kind={noteIsCleared(n) ? 'positive' : 'attention'}>
                    {noteIsCleared(n) ? 'Action done' : 'Next action'}{n.actionOwner ? ` · ${n.actionOwner}` : ''}
                  </Pill>
                ) : noteIsCleared(n) ? (
                  <Pill kind="positive">Reviewed{n.clearedBy ? ` · ${n.clearedBy}` : ''}</Pill>
                ) : null}
                {n.editedAt ? (
                  <span className="text-xs text-4" title={`Edited ${new Date(n.editedAt).toLocaleString()}`}>
                    edited
                  </span>
                ) : null}
                <span className="right row gap-1">
                  {isMine(n) && noteSource(n) !== 'import' ? (
                    <button
                      className="btn btn-icon btn-ghost"
                      onClick={() => { setEditingAt(n.createdAt); setEditText(n.text || ''); }}
                      title="Edit this note"
                      style={{ color: 'var(--ink-4)' }}
                    >
                      <window.I.edit size={12} />
                    </button>
                  ) : null}
                  <button
                    className="btn btn-sm btn-ghost"
                    onClick={() => toggleCleared(n)}
                    title={noteIsCleared(n)
                      ? 'Re-open this — it goes back on the Notes & Actions list'
                      : (n.isAction ? 'Mark this action done' : 'Mark this note reviewed and clear it')}
                    style={{ fontSize: 11 }}
                  >
                    <window.I.check size={12} />
                    {noteIsCleared(n) ? 'Re-open' : (n.isAction ? 'Done' : 'Reviewed')}
                  </button>
                  <button
                    className="btn btn-icon btn-ghost"
                    onClick={() => deleteNote(n.createdAt)}
                    title="Delete note"
                    style={{ color: 'var(--ink-4)' }}
                  >
                    <window.I.x size={12} />
                  </button>
                </span>
              </div>
              {editingAt === n.createdAt ? (
                <div className="col gap-2" style={{ marginTop: 6 }}>
                  <textarea
                    className="field"
                    value={editText}
                    autoFocus
                    onChange={(e) => setEditText(e.target.value)}
                    onKeyDown={(e) => {
                      if (e.key === 'Escape') setEditingAt(null);
                      // Enter saves, Shift+Enter breaks the line — the same
                      // shortcut the composer above uses.
                      if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveEdit(n, editText); }
                    }}
                    style={{ height: 'auto', minHeight: 68, padding: '8px 10px', resize: 'vertical', lineHeight: 1.5 }}
                  />
                  <div className="row gap-2">
                    <button className="btn btn-primary btn-sm" onClick={() => saveEdit(n, editText)}>Save</button>
                    <button className="btn btn-sm btn-ghost" onClick={() => setEditingAt(null)}>Cancel</button>
                  </div>
                </div>
              ) : (
                <div className="text-sm text-2" style={{ lineHeight: 1.55, opacity: noteIsCleared(n) ? 0.55 : 1 }}>
                  <MentionText text={n.text} />
                </div>
              )}
            </div>
          ))}
        </div>
      )}
    </Card>
  );
}

// ─── Stop / reject a brand from ANY stage ─────────────────────────────────
// Previously reject only existed in News Intake. This control is mounted in the
// topbar (so it is reachable on every brand screen) and on the Research and
// Property Match pages.
function StopBrandModal({ brand, mode, onCancel, onSubmit }) {
  const [reason, setReason] = React.useState('');
  const isKiv = mode === 'kiv';
  const quick = isKiv
    ? ['Right brand, wrong timing', 'Waiting on rightsholder approval', 'Revisit next quarter']
    : ['Not a category fit', 'No suitable property', 'Competitor conflict', 'Budget or timing wrong', 'Already contacted elsewhere'];

  return (
    <div
      onClick={onCancel}
      style={{
        position: 'fixed', inset: 0, background: 'rgba(31, 27, 22, 0.45)',
        backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
        display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 300, padding: 24,
      }}
    >
      <div className="card card-pad-lg" onClick={(e) => e.stopPropagation()} style={{ width: 480, maxWidth: '100%', boxShadow: 'var(--shadow-lg)' }}>
        <div className="row gap-2" style={{ alignItems: 'center', marginBottom: 10 }}>
          {isKiv ? <window.I.eye size={18} stroke="var(--pending)" /> : <window.I.ban size={18} stroke="var(--negative)" />}
          <h3 className="h3" style={{ margin: 0 }}>
            {isKiv ? `Keep ${brand.brand} in view?` : `Reject ${brand.brand}?`}
          </h3>
        </div>
        <div className="text-2 text-sm" style={{ marginBottom: 16, lineHeight: 1.6 }}>
          {isKiv
            ? 'The brand is held out of the active flow but stays on the books. You can activate it again at any time from the Research page.'
            : 'The flow stops here — no contact research and no draft. You can bring the brand back later with "Research anyway" on the Research page.'}
        </div>

        <div className="col gap-2" style={{ marginBottom: 16 }}>
          <div className="eyebrow text-xs">Reason (saved on the brand)</div>
          <div className="row gap-1" style={{ flexWrap: 'wrap' }}>
            {quick.map((q) => (
              <button
                key={q}
                type="button"
                className="pill"
                onClick={() => setReason(q)}
                style={{
                  cursor: 'pointer', fontSize: 11, padding: '4px 9px',
                  background: reason === q ? 'var(--accent)' : 'transparent',
                  color: reason === q ? 'white' : 'var(--ink-2)',
                  border: '1px solid ' + (reason === q ? 'var(--accent)' : 'var(--hairline-strong)'),
                }}
              >
                {q}
              </button>
            ))}
          </div>
          <textarea
            value={reason}
            onChange={(e) => setReason(e.target.value)}
            rows={2}
            placeholder="Or write your own — this is what the team will read later."
            style={{
              width: '100%', padding: '10px 12px', border: '1px solid var(--hairline-strong)',
              borderRadius: 6, font: 'inherit', fontSize: 13.5, lineHeight: 1.5,
              background: 'var(--card)', color: 'var(--ink)', resize: 'vertical',
            }}
          />
        </div>

        <div className="row gap-2" style={{ justifyContent: 'flex-end' }}>
          <button className="btn" onClick={onCancel}>Cancel</button>
          <button
            className="btn btn-primary"
            onClick={() => onSubmit(reason.trim())}
            style={isKiv ? undefined : { background: 'var(--negative)', borderColor: 'var(--negative)' }}
          >
            {isKiv ? 'Keep in view' : 'Reject brand'}
          </button>
        </div>
      </div>
    </div>
  );
}

// Reject / KIV / reactivate, usable from any screen.
// `variant='compact'` renders icon-sized buttons for the topbar.
function BrandStopControl({ brand, onUpdateBrand, onMoveToResearch, onToast, variant }) {
  const [mode, setMode] = React.useState(null);   // null | 'reject' | 'kiv'
  if (!brand) return null;

  const isRejected = brand.status === 'not-relevant' || brand.status === 'duplicate' ||
    brand.researchStatus === 'rejected';
  const isKiv = brand.priority === 'kiv' || brand.researchStatus === 'kiv';
  const small = variant === 'compact';

  const submit = (reason) => {
    if (mode === 'kiv') {
      onUpdateBrand(brand.id, {
        status: 'maybe', priority: 'kiv', researchStatus: 'kiv',
        needsManualReview: false, userOverride: true,
        manualReviewNote: reason || 'Kept in view — no outreach for now.',
      });
      onToast && onToast(`${brand.brand} → kept in view`);
    } else {
      onUpdateBrand(brand.id, {
        status: 'not-relevant', priority: 'no-action', researchStatus: 'rejected',
        needsManualReview: false, userOverride: true,
        rejectReason: reason || 'Rejected by the team.',
      });
      onToast && onToast(`${brand.brand} → rejected`);
    }
    setMode(null);
  };

  if (isRejected || isKiv) {
    if (!onMoveToResearch) return null;
    return (
      <button
        className={`btn ${small ? 'btn-sm' : ''}`}
        onClick={() => { onMoveToResearch(brand.id); onToast && onToast(`${brand.brand} → back in the active flow`); }}
        title="Bring this brand back into the active flow"
        style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }}
      >
        <window.I.refresh size={small ? 12 : 13} />
        Reactivate
      </button>
    );
  }

  return (
    <>
      <div className="row gap-2">
        <button
          className={`btn ${small ? 'btn-sm' : ''}`}
          onClick={() => setMode('kiv')}
          title="Hold this brand — keep it on the books but out of the active flow"
        >
          <window.I.eye size={small ? 12 : 13} />
          KIV
        </button>
        <button
          className={`btn ${small ? 'btn-sm' : ''}`}
          onClick={() => setMode('reject')}
          title="Reject this brand — stops the flow at whatever stage it is in"
          style={{ color: 'var(--negative)', borderColor: 'var(--hairline-strong)' }}
        >
          <window.I.ban size={small ? 12 : 13} />
          Reject
        </button>
      </div>
      {mode ? (
        <StopBrandModal brand={brand} mode={mode} onCancel={() => setMode(null)} onSubmit={submit} />
      ) : null}
    </>
  );
}

// ─── Clearing notes and actions ───────────────────────────────────────────
// Any note can be cleared, not just the ones flagged as an action: an action
// gets "done", a plain comment gets "reviewed". Both write clearedAt/clearedBy.
// actionDone is the pre-existing flag and still counts as cleared, so nothing
// already ticked off reappears.
function noteIsCleared(n) {
  return !!(n && (n.clearedAt || n.actionDone));
}

// Returns the full notes array with one note's cleared state flipped.
function toggleNoteCleared(notes, createdAt, by) {
  return (Array.isArray(notes) ? notes : []).map((n) => {
    if (n.createdAt !== createdAt) return n;
    if (noteIsCleared(n)) {
      return { ...n, clearedAt: null, clearedBy: null, ...(n.isAction ? { actionDone: false } : {}) };
    }
    return {
      ...n,
      clearedAt: new Date().toISOString(),
      clearedBy: by || null,
      ...(n.isAction ? { actionDone: true, actionDoneAt: new Date().toISOString() } : {}),
    };
  });
}

// Flatten every brand's notes into one list, newest first. Used by the
// Notes & actions page so comments can be read across the whole book.
function collectNotes(brands) {
  const out = [];
  (brands || []).forEach((b) => {
    (Array.isArray(b.notes) ? b.notes : []).forEach((n) => {
      if (!n || !n.text) return;
      out.push({
        key: `${b.id}:${n.createdAt}`,
        brandId: b.id,
        brandName: b.brand,
        brandCategory: b.category || '',
        priority: b.priority,
        assignee: displayAssignee(b.assignee),
        text: n.text,
        author: n.author || 'Unknown',
        createdAt: n.createdAt || '',
        mentions: n.mentions || parseMentions(n.text),
        isAction: !!n.isAction,
        actionOwner: n.actionOwner || '',
        source: noteSource(n),
        cleared: noteIsCleared(n),
        clearedAt: n.clearedAt || n.actionDoneAt || '',
        clearedBy: n.clearedBy || '',
      });
    });
  });
  return out.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
}

// ─── Brand control bar ────────────────────────────────────────────────────
// The News Intake row controls — tier, status, relationship, outreach stage,
// assignee and the reject / duplicate / KIV actions — as one strip that mounts
// on every stage. Previously a brand could only be re-tiered or re-assigned
// from Intake, which meant leaving whatever stage you were working in.
const CONTACT_STATUSES = [
  { value: 'cold',        label: 'Cold prospect',       color: 'var(--ink-3)' },
  { value: 'warm',        label: 'Warm prospect',       color: 'var(--pending)' },
  { value: 'established', label: 'Established partner', color: 'var(--positive)' },
];

const PRIORITY_OPTIONS = [
  { value: 'tier-1',    label: 'Tier 1' },
  { value: 'tier-2',    label: 'Tier 2' },
  { value: 'tier-3',    label: 'Tier 3' },
  { value: 'kiv',       label: 'KIV — monitor only' },
  { value: 'no-action', label: 'No action' },
];

const STATUS_OPTIONS = [
  { value: 'relevant',     label: 'Relevant' },
  { value: 'maybe',        label: 'Maybe relevant' },
  { value: 'not-relevant', label: 'Not relevant' },
  { value: 'duplicate',    label: 'Duplicate' },
];

function BrandControlBar({ brand, onUpdateBrand, onMoveToResearch, onToast, currentUser }) {
  const [confirm, confirmEl] = useConfirm();
  const [stopMode, setStopMode] = React.useState(null);   // null | 'reject' | 'kiv'
  if (!brand || !onUpdateBrand) return null;

  const isRejected = brand.status === 'not-relevant' || brand.status === 'duplicate' ||
    brand.researchStatus === 'rejected';
  const isKiv = brand.priority === 'kiv' || brand.researchStatus === 'kiv';
  const stopped = isRejected || isKiv;

  const set = (patch, message) => {
    onUpdateBrand(brand.id, patch);
    if (message && onToast) onToast(message);
  };

  // Changing tier or status by hand is an override — record it so the next
  // scan does not quietly re-tier the brand back.
  const setPriority = (value) =>
    set({ priority: value, userOverride: true }, `${brand.brand} → ${(PRIORITY_OPTIONS.find(p => p.value === value) || {}).label}`);

  const setStatus = (value) =>
    set({ status: value, userOverride: true }, `${brand.brand} → ${(STATUS_OPTIONS.find(s => s.value === value) || {}).label}`);

  const setOutreach = async (value) => {
    if (await guardOutreachChange(brand, value, confirm)) {
      set({ outreachStatus: value }, `${brand.brand} → ${outreachMeta(value).label}`);
    }
  };

  const markDuplicate = async () => {
    const ok = await confirm({
      title: `Mark ${brand.brand} as a duplicate?`,
      message: 'The brand stops here and drops out of the active flow. You can bring it back with Reactivate.',
      confirmLabel: 'Mark duplicate',
      danger: true,
    });
    if (!ok) return;
    set({
      status: 'duplicate', priority: 'no-action', researchStatus: 'rejected',
      needsManualReview: false, userOverride: true,
      rejectReason: 'Marked as a duplicate.',
    }, `${brand.brand} → duplicate`);
  };

  const submitStop = (reason) => {
    if (stopMode === 'kiv') {
      set({
        status: 'maybe', priority: 'kiv', researchStatus: 'kiv',
        needsManualReview: false, userOverride: true,
        manualReviewNote: reason || 'Kept in view — no outreach for now.',
      }, `${brand.brand} → kept in view`);
    } else {
      set({
        status: 'not-relevant', priority: 'no-action', researchStatus: 'rejected',
        needsManualReview: false, userOverride: true,
        rejectReason: reason || 'Rejected by the team.',
      }, `${brand.brand} → rejected`);
    }
    setStopMode(null);
  };

  const selStyle = { height: 28, padding: '0 6px', fontSize: 12, minWidth: 120 };
  const Field = ({ label, children, title }) => (
    <div className="col gap-1" title={title}>
      <span className="eyebrow text-xs">{label}</span>
      {children}
    </div>
  );

  return (
    <Card>
      <div className="row gap-3" style={{ flexWrap: 'wrap', alignItems: 'flex-end', rowGap: 10 }}>
        <Field label="Tier" title="Priority tier for this brand">
          <select value={brand.priority || 'tier-3'} onChange={(e) => setPriority(e.target.value)} className="field" style={selStyle}>
            {PRIORITY_OPTIONS.map((p) => <option key={p.value} value={p.value}>{p.label}</option>)}
          </select>
        </Field>

        <Field label="Status" title="Is this brand relevant to IPSEM?">
          <select value={brand.status || 'maybe'} onChange={(e) => setStatus(e.target.value)} className="field" style={selStyle}>
            {STATUS_OPTIONS.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
          </select>
        </Field>

        <Field label="Relationship" title="How well do we know this brand?">
          <select
            value={brand.contactStatus || 'cold'}
            onChange={(e) => set({ contactStatus: e.target.value })}
            className="field"
            style={{ ...selStyle, color: (CONTACT_STATUSES.find(c => c.value === (brand.contactStatus || 'cold')) || {}).color }}
          >
            {CONTACT_STATUSES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
          </select>
        </Field>

        <Field label="Outreach" title="Where this brand sits in the outreach pipeline">
          <select
            value={brand.outreachStatus || 'not_contacted'}
            onChange={(e) => setOutreach(e.target.value)}
            className="field"
            style={{ ...selStyle, color: outreachMeta(brand.outreachStatus).color }}
          >
            {OUTREACH_STAGES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
          </select>
        </Field>

        <Field label="Assignee" title="Who owns this brand">
          <select
            value={displayAssignee(brand.assignee) || ''}
            onChange={(e) => set({ assignee: e.target.value || null },
              e.target.value ? `${brand.brand} → assigned to ${e.target.value}` : `${brand.brand} → unassigned`)}
            className="field"
            style={selStyle}
          >
            <option value="">Unassigned</option>
            {ASSIGNEE_NAMES.map((a) => <option key={a} value={a}>{a}</option>)}
          </select>
        </Field>

        {/* Review sign-off. Sits on the control bar so the weekly meeting can
            tick a brand off from whichever stage it happens to be open at. */}
        <Field label="Review" title="Has a person confirmed they looked at this brand?">
          <div className="row gap-2" style={{ alignItems: 'center', minHeight: 30 }}>
            {onUpdateBrand ? (
              <MarkReviewedControl
                brand={brand}
                currentUser={currentUser}
                onUpdateBrand={onUpdateBrand}
                onToast={onToast}
              />
            ) : null}
            {isReviewed(brand) && brand.reviewedBy ? (
              <span className="text-xs text-3 truncate" style={{ maxWidth: 170 }} title={reviewHint(brand)}>
                {/* The backfill marker is a sentence, not a name — showing it
                    raw read as though somebody called "Backfilled" signed off. */}
                {reviewIsInferred(brand) ? 'auto · from an existing decision' : brand.reviewedBy}
              </span>
            ) : null}
          </div>
        </Field>

        <div className="col gap-1 right" style={{ alignItems: 'flex-start' }}>
          <span className="eyebrow text-xs">Actions</span>
          <div className="row gap-2" style={{ flexWrap: 'wrap' }}>
            {stopped ? (
              <>
                <Pill kind="muted">{isKiv ? 'Monitoring' : 'Stopped'}</Pill>
                {onMoveToResearch ? (
                  <button
                    className="btn btn-sm"
                    onClick={() => { onMoveToResearch(brand.id); onToast && onToast(`${brand.brand} → back in the active flow`); }}
                    style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }}
                    title="Bring this brand back into the active flow"
                  >
                    <window.I.refresh size={12} />
                    Reactivate
                  </button>
                ) : null}
              </>
            ) : (
              <>
                <button className="btn btn-sm" onClick={() => setStopMode('kiv')} title="Hold this brand — on the books, out of the active flow">
                  <window.I.eye size={12} />
                  KIV
                </button>
                <button className="btn btn-sm" onClick={markDuplicate} title="Mark as a duplicate of another brand">
                  <window.I.copy size={12} />
                  Duplicate
                </button>
                <button
                  className="btn btn-sm"
                  onClick={() => setStopMode('reject')}
                  title="Reject this brand — stops the flow at whatever stage it is in"
                  style={{ color: 'var(--negative)', borderColor: 'var(--hairline-strong)' }}
                >
                  <window.I.ban size={12} />
                  Reject
                </button>
              </>
            )}
          </div>
        </div>
      </div>

      {stopMode ? (
        <StopBrandModal brand={brand} mode={stopMode} onCancel={() => setStopMode(null)} onSubmit={submitStop} />
      ) : null}
      {confirmEl}
    </Card>
  );
}

// ─── Stale build detection ────────────────────────────────────────────────
// The app is a long-lived tab: people leave it open for days, and there is no
// build step, so a deploy does not disturb a running page — it keeps executing
// the JavaScript it loaded whenever it was opened. That is how someone ends up
// looking at last week's behaviour and reporting a bug that was fixed.
//
// So: once the tab has gone 10 minutes without interaction, compare the build the
// server is serving against the one this page loaded, and reload only if they
// differ. A version check rather than a blind timer, because reloading a page
// that is already current is pure interruption — it would throw away scroll
// position and open panels to achieve nothing.
//
// Never reloads over unsaved typing. A half-written note is worth more than
// being current, so if anything is in a textarea the reload waits and offers
// itself as a toast instead.
const IDLE_BEFORE_CHECK_MS = 10 * 60 * 1000;  // 10 min without interaction
// How often the idle test runs while the tab is VISIBLE. A hidden tab checks
// nothing at all: the backend is on Render's free tier, which sleeps when idle
// and bills by the hour, so a forgotten tab polling every minute would hold the
// instance awake around the clock and eat the monthly allowance. Nothing is
// gained by it either — a tab nobody is looking at does not need to be current.
const BUILD_CHECK_EVERY_MS = 5 * 60 * 1000;

async function fetchBuildCommit() {
  try {
    // no-store: /api responses are not covered by the no-cache middleware, so
    // without this the check could be answered from cache and never see a deploy.
    const res = await fetch('/api/admin/version', { cache: 'no-store' });
    if (!res.ok) return null;
    const json = await res.json();
    return json && json.commit ? String(json.commit) : null;
  } catch (e) {
    return null;   // offline or backend restarting — try again next tick
  }
}

// True when the user would lose something by reloading right now.
function hasUnsavedTyping() {
  const nodes = document.querySelectorAll('textarea');
  for (const el of nodes) if (el.value && el.value.trim()) return true;
  return false;
}

// The whole decision, as a pure function so each branch can be tested without
// waiting out the idle timer:
//   'none'   nothing to do — same build, or a check we cannot trust
//   'offer'  a new build, but there is unsaved typing: ask, do not discard
//   'reload' a new build and nothing to lose
function buildReloadDecision(bootCommit, servedCommit, hasTyping) {
  // A failed or unknown check proves nothing. Never reload on missing data.
  if (!bootCommit || !servedCommit) return 'none';
  if (bootCommit === servedCommit) return 'none';
  return hasTyping ? 'offer' : 'reload';
}

function useFreshBuild(onOfferReload) {
  const bootRef = React.useRef(null);
  const lastActiveRef = React.useRef(Date.now());
  const reloadingRef = React.useRef(false);

  React.useEffect(() => {
    let cancelled = false;
    fetchBuildCommit().then((c) => { if (!cancelled) bootRef.current = c; });

    const touch = () => { lastActiveRef.current = Date.now(); };
    const events = ['pointerdown', 'keydown', 'wheel', 'touchstart'];
    events.forEach((e) => window.addEventListener(e, touch, { passive: true }));

    const check = async () => {
      if (reloadingRef.current) return;
      // Never reach for the network for a tab nobody is looking at.
      if (document.visibilityState !== 'visible') return;
      // Always require real inactivity, including on the way back from another
      // tab. Someone switching windows every few seconds should not fire a
      // request each time; a tab left for ten minutes is the case worth checking.
      if (Date.now() - lastActiveRef.current < IDLE_BEFORE_CHECK_MS) return;
      const served = await fetchBuildCommit();
      const decision = buildReloadDecision(bootRef.current, served, hasUnsavedTyping());
      if (decision === 'none') return;
      if (decision === 'offer') {
        onOfferReload && onOfferReload();      // ask instead of discarding work
        return;
      }
      reloadingRef.current = true;
      window.location.reload();
    };

    // Coming back to a tab left open a while is the moment staleness matters and
    // the moment a reload costs least — you have not started anything yet.
    const onVisible = () => { if (document.visibilityState === 'visible') check(); };
    document.addEventListener('visibilitychange', onVisible);
    const timer = setInterval(check, BUILD_CHECK_EVERY_MS);

    return () => {
      cancelled = true;
      events.forEach((e) => window.removeEventListener(e, touch));
      document.removeEventListener('visibilitychange', onVisible);
      clearInterval(timer);
    };
  }, [onOfferReload]);
}

// ─── Notifications ────────────────────────────────────────────────────────
// Two kinds land in the bell: a mention (someone typed @you) and a comment
// (someone wrote on a brand). Rows written before comments were notified carry
// no kind at all, so an unknown kind reads as a mention — that is what it was.
function notificationVerb(n) {
  return (n && n.kind) === 'comment' ? 'commented on a brand' : 'mentioned you';
}

// ─── Notification bell (mentions + comments) ──────────────────────────────
function NotificationBell({ currentUser, onNavigate, onUnreadChange }) {
  // Notifications are addressed to the canonical team name, not the profile
  // name — "Aaron Sailis" must still receive anything sent to @Aaron.
  const me = resolveTeamName(currentUser);
  const [items, setItems] = React.useState([]);
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);

  // Marks in flight. The 45s poll must not land on top of one and put an item
  // the user just read back to unread — the same race that made decisions bounce
  // back on News Intake, in miniature.
  const writingRef = React.useRef(0);

  const load = React.useCallback(() => {
    if (!me || writingRef.current > 0) return;
    window.IPSEM_API.getNotifications(me)
      .then((rows) => setItems(Array.isArray(rows) ? rows : []))
      .catch(() => {});
  }, [me]);

  React.useEffect(() => {
    load();
    const t = setInterval(load, 45000);
    return () => clearInterval(t);
  }, [load]);

  // Optimistic, then tell the server. No reload afterwards: the local patch and
  // the write say the same thing, and the extra fetch only widened the race.
  const markRead = (payload, patch) => {
    setItems(patch);
    writingRef.current += 1;
    window.IPSEM_API.markNotificationsRead(payload)
      .catch(() => {})
      .then(() => { writingRef.current = Math.max(0, writingRef.current - 1); });
  };

  React.useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', h);
    return () => document.removeEventListener('mousedown', h);
  }, []);

  const unread = items.filter((n) => !n.readAt);

  // Feed the sidebar badge so the count lives in one place.
  React.useEffect(() => {
    if (onUnreadChange) onUnreadChange(unread.length);
  }, [unread.length, onUnreadChange]);

  if (!me) return null;   // no display name set → nothing to address them by

  const openItem = (n) => {
    setOpen(false);
    if (!n.readAt) {
      markRead({ ids: [n.id] },
        (cur) => cur.map((x) => x.id === n.id ? { ...x, readAt: new Date().toISOString() } : x));
    }
    if (n.brandId && onNavigate) onNavigate('research', n.brandId);
  };

  const markAll = () => {
    markRead({ recipient: me },
      (cur) => cur.map((x) => ({ ...x, readAt: x.readAt || new Date().toISOString() })));
  };


  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button
        className="btn btn-icon btn-ghost"
        onClick={() => setOpen((x) => !x)}
        title={unread.length
          ? `${unread.length} unread update${unread.length === 1 ? '' : 's'}`
          : 'Mentions and comments'}
        style={{ position: 'relative' }}
      >
        <window.I.bell size={16} stroke={unread.length ? 'var(--accent)' : 'var(--ink-3)'} />
        {unread.length ? (
          <span style={{
            position: 'absolute', top: 2, right: 2, minWidth: 15, height: 15, padding: '0 3px',
            borderRadius: 999, background: 'var(--accent)', color: 'white',
            fontSize: 9.5, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center',
          }}>{unread.length > 9 ? '9+' : unread.length}</span>
        ) : null}
      </button>
      {open ? (
        <div style={{
          position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 60,
          width: 320, maxHeight: 380, overflowY: 'auto',
          background: 'var(--card)', border: '1px solid var(--hairline-strong)',
          borderRadius: 10, boxShadow: 'var(--shadow-lg)', padding: 6,
        }}>
          <div className="row" style={{ padding: '6px 8px', justifyContent: 'space-between', alignItems: 'center' }}>
            <span className="eyebrow">Activity</span>
            {unread.length ? (
              <button className="btn btn-sm btn-ghost" onClick={markAll} style={{ fontSize: 11 }}>Mark all read</button>
            ) : null}
          </div>
          {items.length === 0 ? (
            <div className="text-3 text-xs" style={{ padding: '10px 8px' }}>
              Nothing yet. Any comment a teammate writes on a brand lands here,
              and being tagged with <strong>@{me}</strong> is flagged as a mention.
            </div>
          ) : items.map((n) => (
            <div
              key={n.id}
              onClick={() => openItem(n)}
              className="col gap-1"
              style={{
                padding: '9px 8px', borderRadius: 7, cursor: 'pointer',
                background: n.readAt ? 'transparent' : 'var(--accent-soft-bg)',
              }}
            >
              <div className="row gap-2" style={{ alignItems: 'center' }}>
                <Avatar initials={initialsFor(authorLabel(n.actor))} size={18} />
                <span className="text-xs" style={{ fontWeight: 600 }}>{n.actor ? authorLabel(n.actor) : 'Someone'}</span>
                <span className="text-xs text-3">{notificationVerb(n)}</span>
                <TimeStamp iso={n.createdAt} tz={NOTIFICATION_TZ} className="right text-xs text-3 mono" />
              </div>
              {n.brandName ? <div className="text-xs" style={{ fontWeight: 600 }}>{n.brandName}</div> : null}
              <div className="text-xs text-2" style={{ lineHeight: 1.45 }}>
                {String(n.body || '').slice(0, 140)}{String(n.body || '').length > 140 ? '…' : ''}
              </div>
            </div>
          ))}
          <div style={{ borderTop: '1px solid var(--hairline)', marginTop: 4, paddingTop: 4 }}>
            <button
              className="btn btn-sm btn-ghost row gap-1"
              onClick={() => { setOpen(false); onNavigate && onNavigate('notifications'); }}
              style={{ width: '100%', justifyContent: 'center', fontSize: 12 }}
            >
              Open all notifications
              <window.I.arrowRight size={12} />
            </button>
          </div>
        </div>
      ) : null}
    </div>
  );
}

// ─── Review state ─────────────────────────────────────────────────────────
// "Reviewed" means a person has confirmed they looked at this brand and is
// happy with where it sits. It is recorded explicitly (brands.reviewed_at /
// reviewed_by, see MIGRATION_review_state.sql) rather than inferred, because
// the old inference — status 'maybe' AND NOT user_override — was wrong: it
// counted already-KIV'd brands as untouched, and user_override was set on 47 of
// 593 rows, so its absence meant nothing.
function isReviewed(b) {
  return !!(b && b.reviewedAt);
}

// Most reviewed_at values in the book were not typed by anyone: the one-time
// backfill (MIGRATION_review_state.sql) inferred them from a decision already on
// record — usually a saved property match. Worth saying out loud, because
// "Reviewed by Backfilled from an existing decision" reads as a person's
// sign-off when it is a migration's inference.
const REVIEW_BACKFILL_MARKER = 'Backfilled from an existing decision';
function reviewIsInferred(b) {
  return !!(b && b.reviewedBy === REVIEW_BACKFILL_MARKER);
}
function reviewHint(b) {
  if (!b || !b.reviewedAt) return '';
  const when = b.reviewedAt ? ` — ${formatDate(b.reviewedAt)}` : '';
  return reviewIsInferred(b)
    ? `A property decision was already on record when review tracking was added${when}. Nobody has signed this off by hand.`
    : `Reviewed${b.reviewedBy ? ` by ${b.reviewedBy}` : ''}${when}`;
}

// ── The two review gates ──────────────────────────────────────────────────
// The weekly meeting walks two DIFFERENT questions, and a brand belongs to
// exactly one of them at a time:
//
//   1. News Intake     — "is this brand relevant to us at all?"
//   2. Brand Research  — "which property do we pitch this brand?"
//
// A single global "reviewed" flag cannot express that: a brand ruled relevant at
// intake still owes a property decision at research. So each gate is derived
// from what that stage is actually missing, and reviewed_at is only the escape
// hatch for "we looked at this and are deliberately leaving it alone".
//
// Held/closed brands are in neither: rejecting or parking a brand IS an outcome.

// Gate 1 — nothing has been researched yet, so the only open question is
// whether this brand is worth pursuing at all. A brand mid-research is NOT
// here: the app is working, nobody is waiting on a person.
function needsRelevanceCall(b) {
  if (!b || isReviewed(b) || pbIsHeldOrClosed(b)) return false;
  // A confirmed property IS the relevance answer. Without this, a brand that
  // arrived with its property already set (Excel import, or matched before the
  // app ever researched it) sat on the review list asking "is this relevant?"
  // when the only work left was contacts and a draft.
  if (pbConfirmed(b).length) return false;
  const rs = b.researchStatus || 'not-started';
  return rs === 'not-started' || rs === 'needs-review';
}

// Gate 2 — research finished, no property picked yet. Confirming a property (or
// "No match") in Property Match clears it, so there is no second tick to
// remember. Brands the AI was unsure about live here too, flagged low
// confidence: research ran, so the open question is which property, and the
// profile just needs reading more carefully first.
function needsPropertyDecision(b) {
  if (!b || isReviewed(b) || pbIsHeldOrClosed(b)) return false;
  if (b.researchStatus !== 'complete') return false;
  return (b.confirmedProperties || []).length === 0;
}

// On somebody's list, either gate. Used for the badges and the queue.
function needsReview(b) {
  return needsRelevanceCall(b) || needsPropertyDecision(b);
}

// ── The stage lists: one active population, one "decided" test ─────────────
// Brand Research and Property Match are two halves of one decision and show the
// same three tabs, so they must count the same brands. They did not: Research
// used pbIsHeldOrClosed() while Match hand-rolled a narrower test, which let 3
// closed brands into Match and kept 17 unresearched ones out (274 vs 288).
function pbActive(b) {
  return !!b && !pbIsHeldOrClosed(b);
}

// Decided = a property decision is on record, or a person signed the brand off.
// Stated explicitly rather than derived as "everything that is not on the review
// list", which quietly counted the 17 brands nobody had researched yet as
// "decided" when they are simply not at this gate yet.
function isDecided(b) {
  if (!b) return false;
  return (b.confirmedProperties || []).length > 0 || isReviewed(b);
}

// Active, but at neither gate and not decided — research is still running, or
// the brand is waiting on a relevance call over in News Intake. Surfaced in the
// list footers so All never exceeds "To review + Decided" without explanation.
function awaitingEarlierStage(b) {
  return pbActive(b) && !needsPropertyDecision(b) && !isDecided(b);
}

// Which gate, for grouping and for routing the user to the right screen.
function reviewGate(b) {
  if (needsRelevanceCall(b)) return 'relevance';
  if (needsPropertyDecision(b)) return 'property';
  return null;
}

// Why it is on the list — shown so the list is never a mystery pile. Written as
// the action to take, not as a description of the state: "needs a relevance
// call" told nobody what to actually do about it.
function reviewReason(b) {
  const gate = reviewGate(b);
  if (gate === 'relevance') {
    return b.researchStatus === 'needs-review'
      ? 'Research could not finish. Open News Intake and either run it again, or reject the brand.'
      : 'Nobody has looked into this brand yet. Open News Intake and pick one: Research it, Keep in view, or Reject.';
  }
  if (gate === 'property') {
    return b.needsManualReview
      ? 'The AI was not confident. Read the profile, then open Property Match and choose which property to pitch.'
      : 'Research is done. Open Property Match and choose which property to pitch — or No match to stop it.';
  }
  return '';
}

// The label and destination for whichever gate a brand sits at. Labels name the
// decision the person has to make, in words used elsewhere in the app.
const REVIEW_GATES = {
  relevance: { label: 'Decide if relevant', route: 'intake',   kind: 'pending' },
  property:  { label: 'Choose a property',  route: 'match',    kind: 'attention' },
};

// ─── Contacts gate ────────────────────────────────────────────────────────
// A confirmed property is useless without somebody to email, and the app's own
// contact discovery is unreliable — so a guessed address counts as unfinished,
// not done. Two distinct states, because they need different work: find a person
// versus verify an address.
function contactState(b) {
  if (!b) return 'ready';
  const contacts = b.contacts || [];
  if (!contacts.length) return 'missing';
  const verified = contacts.some((c) => c && c.email && !c.email_guessed && !c.emailGuessed);
  return verified ? 'ready' : 'unverified';
}
const CONTACT_STATES = {
  missing:    { label: 'Missing contact',  kind: 'attention', hint: 'Nobody on record — research the decision-maker on LinkedIn' },
  unverified: { label: 'Unverified email', kind: 'pending',   hint: 'Contact exists but the address is guessed — confirm it before sending' },
  ready:      { label: 'Contact ready',    kind: 'positive',  hint: 'At least one contact with a verified email' },
};
// Only asked of brands that have got as far as a confirmed property.
function needsContactWork(b) {
  if (!b || pbIsHeldOrClosed(b)) return false;
  if (!pbConfirmed(b).length) return false;
  return contactState(b) !== 'ready';
}

// ─── Draft gates ──────────────────────────────────────────────────────────
function needsDraft(b) {
  return !!b && !pbIsHeldOrClosed(b) && pbConfirmed(b).length > 0 && !b.draft;
}
function draftReadyNotSent(b) {
  if (!b || pbIsHeldOrClosed(b) || !b.draft) return false;
  return !b.outreachStatus || b.outreachStatus === 'not_contacted';
}
function draftSent(b) {
  return !!b && !!b.outreachStatus && b.outreachStatus !== 'not_contacted';
}
// The instant an email went out. Falls back to the legacy office-day date for
// sends recorded before outreach_sent_at existed.
function sentInstant(b) {
  return (b && (b.outreachSentAt || b.outreachSentDate)) || null;
}

// ─── One status chip, one vocabulary ──────────────────────────────────────
// Rows used to carry BOTH a review pill and a blocker chip, which for a
// researched brand read "Needs a property" next to "Needs a match" — two names
// for the same thing. brandStatus() returns the single most important thing
// about a brand so every list says it once, the same way.
function brandStatus(b) {
  if (!b) return null;
  if (pbIsHeldOrClosed(b)) {
    const bl = brandBlocker(b);
    return bl ? { ...bl, you: false } : null;
  }
  // Reviewed is checked LAST, not first. Signing a brand off answers the two
  // review gates — is it relevant, which property — and nothing after them. When
  // it led, a brand with no contact and no verified email still showed
  // "Reviewed" while sitting in the Contacts To-do list: the chip said done, the
  // tab said not done, and both were describing different questions.
  const gate = reviewGate(b);
  if (gate) {
    const meta = REVIEW_GATES[gate];
    return { label: meta.label, kind: meta.kind, route: meta.route, you: true, hint: reviewReason(b) };
  }
  // Past both review gates — report what the stage itself is waiting on, and
  // prefer the contact detail over the generic stage label when it applies.
  const reviewNote = isReviewed(b) ? ` · ${reviewHint(b)}` : '';
  if (needsContactWork(b)) {
    const st = CONTACT_STATES[contactState(b)];
    return { label: st.label, kind: st.kind, route: 'contacts', you: true, hint: st.hint + reviewNote };
  }
  const bl = brandBlocker(b);
  if (bl) return { ...bl, hint: bl.label + reviewNote };
  if (isReviewed(b)) {
    return {
      // Nothing outstanding, so the sign-off IS the state. Same word as the
      // button that sets it — "Set aside" next to a "Mark reviewed" button read
      // as two different states. Inferred sign-offs say so rather than claiming
      // a person looked at it.
      label: reviewIsInferred(b) ? 'Decision on record' : 'Reviewed',
      kind: 'positive', route: 'research', you: false,
      hint: reviewHint(b),
    };
  }
  return null;
}

// ─── Stage filter bar ─────────────────────────────────────────────────────
// The same "To review (n) / … / All" control on every stage list, so the counts
// are in the same place and read the same way wherever you are. Options are
// [{ id, label, count, title }]; a zero count still renders, because "0 to
// review" is the answer the weekly meeting is looking for.
function StageFilterBar({ options, value, onChange, note }) {
  return (
    <div className="row gap-2" style={{ padding: '10px 14px', borderBottom: '1px solid var(--hairline)', flexWrap: 'wrap' }}>
      <div className="toggle-group" style={{ fontSize: 11 }}>
        {(options || []).map((o) => (
          <button
            key={o.id}
            className={value === o.id ? 'is-active' : ''}
            onClick={() => onChange(o.id)}
            title={o.title}
          >
            {o.label}{o.count != null ? ` (${o.count})` : ''}
          </button>
        ))}
      </div>
      {note ? <span className="text-xs text-3">{note}</span> : null}
    </div>
  );
}

function BrandStatusChip({ brand, onNavigate, style }) {
  const s = brandStatus(brand);
  if (!s) return null;
  const clickable = !!onNavigate;
  return (
    <Pill
      kind={s.kind}
      style={{ fontSize: 10.5, cursor: clickable ? 'pointer' : 'default', ...(style || {}) }}
      title={s.hint || s.label}
      onClick={clickable ? (e) => { if (e && e.stopPropagation) e.stopPropagation(); onNavigate(s.route, brand.id); } : undefined}
    >
      {s.label}
    </Pill>
  );
}

// The patch that marks a brand reviewed, or puts it back on the list.
function reviewPatch(reviewed, by) {
  return reviewed
    ? { reviewedAt: new Date().toISOString(), reviewedBy: by || null }
    : { reviewedAt: null, reviewedBy: null };
}

// Sign-off. The weekly meeting walks a list and ticks brands off, so this is
// the single most-used control in the app and it says what it does: "Mark
// reviewed", then "Reviewed" once it is done.
//
// It was previously labelled "Leave as-is" / "Set aside". That wording was the
// reason nobody ever used it — the team was looking for a Reviewed button and
// there wasn't one, so every reviewed_at in the database came from the backfill
// rather than from a person. Do not rename it back without a better reason.
function MarkReviewedControl({ brand, currentUser, onUpdateBrand, onToast, size = 'sm' }) {
  const reviewed = isReviewed(brand);
  const by = displayNameFor(currentUser);
  const toggle = (e) => {
    if (e && e.stopPropagation) e.stopPropagation();
    onUpdateBrand(brand.id, reviewPatch(!reviewed, by));
    onToast && onToast(reviewed
      ? `${brand.brand} back on the review list`
      : `${brand.brand} marked reviewed by ${by} — it will not reappear`);
  };
  return (
    <button
      className={`btn btn-${size} ${reviewed ? 'btn-reviewed' : 'btn-primary'}`}
      onClick={toggle}
      title={reviewed
        ? `${reviewHint(brand)}. Click to put it back on the review list.`
        : 'Confirm you have looked at this brand — it leaves the review list and will not reappear'}
    >
      <window.I.check size={12} />
      {reviewed ? 'Reviewed' : 'Mark reviewed'}
    </button>
  );
}

// ReviewPill used to live here. It was superseded by BrandStatusChip (one chip,
// one vocabulary) and nothing rendered it, but it kept its own wording — so it
// was a standing invitation to reintroduce two names for one state. Removed.

// ─── What is this brand waiting on? ───────────────────────────────────────
// A brand row used to show tier and status but never WHY it was sitting still.
// brandBlocker() names the one thing standing between it and the next stage,
// derived from the same pbStage() model the board and dashboard use, so the
// chip can never disagree with them.
//
// `you: true` means the blocker needs a human — those are what the work queue
// walks. `you: false` is the app working (research running) or nothing owed.
function brandBlocker(b) {
  if (!b) return null;
  if (pbIsHeldOrClosed(b)) {
    if (b.status === 'duplicate') return { label: 'Duplicate', kind: 'muted', route: 'intake', you: false };
    if (b.doNotContact) return { label: 'Do not contact', kind: 'muted', route: 'research', you: false };
    if (b.priority === 'kiv' || b.researchStatus === 'kiv') return { label: 'KIV — watching', kind: 'pending', route: 'research', you: false };
    if (b.outreachStatus === 'closed') return { label: 'Closed', kind: 'muted', route: 'draft', you: false };
    if ((b.confirmedProperties || []).includes('none')) return { label: 'No match — stopped', kind: 'muted', route: 'match', you: false };
    return { label: 'Rejected', kind: 'muted', route: 'intake', you: false };
  }
  const TIERS = ['tier-1', 'tier-2', 'tier-3'];
  switch (pbStage(b)) {
    case 'triage':
      return TIERS.includes(b.priority)
        ? { label: 'Needs triage', kind: 'pending', route: 'intake', you: true }
        : { label: 'Needs a tier', kind: 'pending', route: 'intake', you: true };
    case 'research':
      return b.researchStatus === 'needs-review'
        ? { label: 'Check the research', kind: 'attention', route: 'research', you: true }
        : { label: 'Researching…', kind: 'neutral', route: 'research', you: false };
    // Same words as the review gate — three names for one job ("Needs a match",
    // "Needs a property", "Choose a property") read as three different jobs.
    case 'match':    return { label: 'Choose a property', kind: 'attention', route: 'match',  you: true };
    // Two different jobs wearing one stage. Waiting on a rightsholder is not on
    // you — chasing is, but only once they are late; a refusal is entirely on
    // you, and calling it "awaiting" would hide it in the queue forever.
    case 'approval': {
      const a = approvalFor(b.id) || {};
      const specific = pbConfirmed(b).filter((p) => p !== 'generic');
      if (specific.length && specific.every((p) => (a.rejected || []).includes(p))) {
        return { label: 'Refused — needs a rematch', kind: 'attention', route: 'properties', you: true };
      }
      if ((a.discuss || []).length) {
        return { label: 'Rightsholder wants to discuss', kind: 'attention', route: 'properties', you: true };
      }
      const days = a.askedAt ? Math.floor((Date.now() - new Date(a.askedAt).getTime()) / 86400000) : null;
      return days !== null && days >= APPROVAL_CHASE_DAYS
        ? { label: `Approval ${days}d outstanding`, kind: 'attention', route: 'properties', you: true }
        : { label: 'Awaiting rightsholder', kind: 'pending', route: 'properties', you: false };
    }
    case 'contacts': return { label: 'Needs a contact', kind: 'attention', route: 'contacts', you: true };
    case 'draft':    return { label: 'Needs a draft',   kind: 'pending',   route: 'draft',    you: true };
    case 'outreach': return { label: 'Ready to send',   kind: 'positive',  route: 'draft',    you: true };
    case 'contacted': {
      const d = daysUntil(b.nextFollowUp);
      if (d !== null && d <= 0) {
        return {
          label: d < 0 ? `Follow-up ${Math.abs(d)}d overdue` : 'Follow-up due today',
          kind: 'attention', route: 'draft', you: true,
        };
      }
      return { label: outreachMeta(b.outreachStatus).label, kind: 'neutral', route: 'draft', you: false };
    }
    default: return null;
  }
}

// BlockerChip used to live here — it rendered brandBlocker() as its own pill.
// BrandStatusChip replaced it (one chip, one vocabulary) and nothing rendered it
// any more, so like ReviewPill it was only a way to get two chips saying the
// same thing back onto a row. brandBlocker() itself is still used, by
// brandStatus() and buildQueue().

// ─── The work queue ───────────────────────────────────────────────────────
// Every screen is a list plus filters, so choosing where to start was itself a
// decision. buildQueue() answers "what next" once: every brand whose blocker
// needs a person, earliest stage first, then tier, then newest review date.
const QUEUE_ORDER = ['triage', 'research', 'match', 'contacts', 'draft', 'outreach', 'contacted'];

function buildQueue(brands) {
  const tierRank = { 'tier-1': 0, 'tier-2': 1, 'tier-3': 2 };
  return (brands || [])
    .map((b) => ({ brand: b, stage: pbStage(b), blocker: brandBlocker(b), review: needsReview(b) }))
    .filter((x) => x.stage && x.blocker && x.blocker.you)
    .sort((a, z) => {
      // Anything never signed off leads, whatever stage it sits at — that is
      // the weekly review list, and it is the thing people forget.
      if (a.review !== z.review) return a.review ? -1 : 1;
      const sa = QUEUE_ORDER.indexOf(a.stage), sz = QUEUE_ORDER.indexOf(z.stage);
      if (sa !== sz) return sa - sz;
      const ta = tierRank[a.brand.priority] ?? 9, tz = tierRank[z.brand.priority] ?? 9;
      if (ta !== tz) return ta - tz;
      return String(z.brand.reviewDate || '').localeCompare(String(a.brand.reviewDate || ''));
    });
}

// buildReviewList used to build a second, differently-sorted list of the same
// brands for a "Brands to review" card on the Dashboard. It was a strict subset
// of buildQueue() — every brand needing review also has a blocker that needs a
// person — and buildQueue already sorts those to the top, so the Dashboard was
// showing one set of brands as three lists. The queue rows carry the review
// reason and the mark-reviewed control now, and this is gone.

// Topbar button: one click lands on the next thing that needs a person.
function QueueButton({ brands, onNavigate, className = 'btn btn-primary' }) {
  const queue = React.useMemo(() => buildQueue(brands), [brands]);
  const next = queue[0];
  if (!next) {
    return (
      <Pill kind="positive" className="row gap-1" title="Nothing in the flow is waiting on a person">
        <window.I.check size={11} />
        Queue clear
      </Pill>
    );
  }
  return (
    <button
      className={className}
      onClick={() => onNavigate(next.blocker.route, next.brand.id)}
      title={`${queue.length} brand${queue.length === 1 ? '' : 's'} waiting on you. Next: ${next.brand.brand} — ${next.blocker.label.toLowerCase()}`}
    >
      <window.I.bolt size={14} />
      Work the queue
      <span className="mono" style={{
        background: 'rgba(255,255,255,0.22)', borderRadius: 999,
        padding: '0 6px', fontSize: 11, fontWeight: 600,
      }}>{queue.length}</span>
    </button>
  );
}

// ─── Shared brand filters ─────────────────────────────────────────────────
// Tier / category / assignee / country used to be declared separately on every
// stage, each with its own storage key — so "my Tier 1 brands" had to be set
// four times. One hook, one set of keys: set it anywhere, it holds everywhere.
function useBrandFilters(pool) {
  const [tier,      setTier]      = usePersistentState('filters.tier', 'all');
  const [category,  setCategory]  = usePersistentState('filters.category', 'all');
  const [assignee,  setAssignee]  = usePersistentState('filters.assignee', 'all');
  const [countries, setCountries] = usePersistentState('filters.countries', []);
  const [open,      setOpen]      = usePersistentState('filters.open', false);

  const categories = React.useMemo(
    () => [...new Set((pool || []).map((b) => b.category).filter(Boolean))].sort(), [pool]);
  const countryOptions = React.useMemo(
    () => [...new Set((pool || []).map((b) => extractCountry(b.hq)).filter(Boolean))].sort(), [pool]);

  const active = tier !== 'all' || category !== 'all' || assignee !== 'all' || countries.length > 0;

  const matches = React.useCallback((b) => {
    if (tier !== 'all' && b.priority !== tier) return false;
    if (category !== 'all' && b.category !== category) return false;
    if (assignee !== 'all') {
      const who = displayAssignee(b.assignee);
      if (assignee === '__unassigned' ? !!who : who !== assignee) return false;
    }
    if (countries.length > 0 && !countries.includes(extractCountry(b.hq))) return false;
    return true;
  }, [tier, category, assignee, countries]);

  const clear = () => { setTier('all'); setCategory('all'); setAssignee('all'); setCountries([]); };
  const toggleCountry = (c) =>
    setCountries((cur) => (cur.includes(c) ? cur.filter((x) => x !== c) : [...cur, c]));

  return {
    tier, setTier, category, setCategory, assignee, setAssignee,
    countries, setCountries, toggleCountry, open, setOpen,
    categories, countryOptions, active, matches, clear,
  };
}

// The filter body, identical on every stage because it is now one component.
function BrandFilterPanel({ f }) {
  return (
    <div className="col gap-2" style={{ padding: '12px 14px', borderBottom: '1px solid var(--hairline)', background: 'var(--card-alt)' }}>
      <div className="text-xs text-3" style={{ lineHeight: 1.45 }}>
        These filters are shared by Research, Match, Contacts and Draft — set them once.
      </div>
      <div className="col gap-1">
        <span className="eyebrow text-xs">Tier</span>
        <select value={f.tier} onChange={(e) => f.setTier(e.target.value)} className="field" style={{ height: 28, padding: '0 8px', fontSize: 12 }}>
          <option value="all">All tiers</option>
          <option value="tier-1">Tier 1</option>
          <option value="tier-2">Tier 2</option>
          <option value="tier-3">Tier 3</option>
          <option value="kiv">KIV</option>
        </select>
      </div>
      <div className="col gap-1">
        <span className="eyebrow text-xs">Category</span>
        <select value={f.category} onChange={(e) => f.setCategory(e.target.value)} className="field" style={{ height: 28, padding: '0 8px', fontSize: 12 }}>
          <option value="all">All categories</option>
          {f.categories.map((c) => <option key={c} value={c}>{c}</option>)}
        </select>
      </div>
      <div className="col gap-1">
        <span className="eyebrow text-xs">Assignee</span>
        <select value={f.assignee} onChange={(e) => f.setAssignee(e.target.value)} className="field" style={{ height: 28, padding: '0 8px', fontSize: 12 }}>
          <option value="all">All assignees</option>
          {ASSIGNEE_NAMES.map((a) => <option key={a} value={a}>{a}</option>)}
          <option value="__unassigned">Unassigned</option>
        </select>
      </div>
      <div className="col gap-1">
        <span className="eyebrow text-xs">
          Country {f.countries.length > 0 ? `· ${f.countries.length} selected` : ''}
        </span>
        {f.countryOptions.length === 0 ? (
          <span className="text-xs text-3">No countries to filter.</span>
        ) : (
          <div className="row gap-1" style={{ flexWrap: 'wrap' }}>
            {f.countryOptions.map((c) => {
              const on = f.countries.includes(c);
              return (
                <button
                  key={c}
                  type="button"
                  onClick={() => f.toggleCountry(c)}
                  className="pill"
                  style={{
                    cursor: 'pointer',
                    background: on ? 'var(--accent)' : 'transparent',
                    color: on ? 'white' : 'var(--ink-2)',
                    border: '1px solid ' + (on ? 'var(--accent)' : 'var(--hairline-strong)'),
                    fontSize: 11, padding: '3px 8px',
                  }}
                >
                  {c}
                </button>
              );
            })}
          </div>
        )}
      </div>
      {f.active ? (
        <button className="btn btn-sm btn-ghost" onClick={f.clear} style={{ alignSelf: 'flex-start', marginTop: 4 }}>
          Clear filters
        </button>
      ) : null}
    </div>
  );
}

// ─── Bulk selection ───────────────────────────────────────────────────────
// Ticking rows and acting on the lot existed only on News Intake. This is the
// same behaviour as a hook plus a bar, so any list can offer it.
function useBulkSelect(rows) {
  const [sel, setSel] = React.useState(() => new Set());
  const ids = (rows || []).map((r) => r.id);
  const idKey = ids.join(',');
  // Rows leave the list as they are actioned — drop their ids so the count
  // never claims more than is actually on screen.
  React.useEffect(() => {
    setSel((cur) => {
      const keep = new Set([...cur].filter((id) => ids.includes(id)));
      return keep.size === cur.size ? cur : keep;
    });
  }, [idKey]);
  const toggle = (id) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  const clear = () => setSel(new Set());
  const allSelected = ids.length > 0 && ids.every((id) => sel.has(id));
  const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids));
  return { ids: [...sel], size: sel.size, has: (id) => sel.has(id), toggle, clear, allSelected, toggleAll };
}

function BrandBulkBar({ bulk, actions, onAssign, noun = 'brand' }) {
  if (!bulk.size) return null;
  return (
    <div className="row gap-2" style={{
      marginBottom: 12, padding: '10px 14px',
      background: 'var(--accent-soft-bg)', border: '1px solid var(--accent)',
      borderRadius: 10, alignItems: 'center', flexWrap: 'wrap',
    }}>
      <span className="text-sm" style={{ fontWeight: 600 }}>
        {bulk.size} {noun}{bulk.size === 1 ? '' : 's'} selected
      </span>
      <span className="grow" />
      {onAssign ? (
        <select
          defaultValue=""
          onChange={(e) => { onAssign(e.target.value); e.target.value = ''; }}
          className="field"
          style={{ height: 30, padding: '0 8px', fontSize: 12.5 }}
          title="Assign selected to"
        >
          <option value="">Assign to…</option>
          {ASSIGNEE_NAMES.map((a) => <option key={a} value={a}>{a}</option>)}
        </select>
      ) : null}
      {(actions || []).map((a) => {
        const Ico = a.icon && window.I[a.icon];
        return (
          <button
            key={a.label}
            className={a.className || 'btn btn-sm'}
            onClick={a.onClick}
            style={a.danger ? { color: 'var(--negative)' } : undefined}
            title={a.title}
          >
            {Ico ? <Ico size={13} /> : null}
            {a.label}
          </button>
        );
      })}
      <button className="btn btn-sm btn-ghost" onClick={bulk.clear}>Clear</button>
    </div>
  );
}

// ─── Manual scan (shared) ─────────────────────────────────────────────────
// New brands only arrive when someone runs a scan, so the control belongs
// everywhere, not buried on News Intake. Hook + button + the quota warning.
// Blurs the page so the quota choice is unmissable.
function ScanWarningModal({ warning, onContinue, onCancel }) {
  const isInProgress = warning.kind === 'in-progress';
  const brandsAdded  = warning.brandsAdded ?? 0;
  const hoursAgo     = warning.hoursAgo;
  const sinceLabel =
    hoursAgo == null ? null :
    hoursAgo < 1 ? 'less than an hour ago' :
    hoursAgo < 2 ? 'about an hour ago' :
    `about ${Math.round(hoursAgo)} hours ago`;

  // Lock body scroll while the modal is open + close on Escape.
  React.useEffect(() => {
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    const onKey = (e) => { if (e.key === 'Escape') onCancel(); };
    window.addEventListener('keydown', onKey);
    return () => {
      document.body.style.overflow = prev;
      window.removeEventListener('keydown', onKey);
    };
  }, [onCancel]);

  return (
    <div
      onClick={onCancel}
      style={{
        position: 'fixed', inset: 0,
        background: 'rgba(31, 27, 22, 0.45)',
        backdropFilter: 'blur(8px)',
        WebkitBackdropFilter: 'blur(8px)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        zIndex: 300, padding: 24,
      }}
    >
      <div
        className="card card-pad-lg"
        onClick={(e) => e.stopPropagation()}
        style={{ width: 480, maxWidth: '100%', boxShadow: 'var(--shadow-lg)' }}
      >
        <div className="row gap-2" style={{ alignItems: 'center', marginBottom: 14 }}>
          <window.I.warn size={20} stroke="var(--accent)" />
          <h3 className="h3" style={{ margin: 0 }}>
            {isInProgress ? 'Scan already in progress' : 'Scan already ran today'}
          </h3>
        </div>

        <div className="text-2 text-sm" style={{ marginBottom: 22, lineHeight: 1.6 }}>
          {isInProgress ? (
            <p style={{ margin: 0 }}>
              A scan is currently running. Wait for it to finish before starting another.
            </p>
          ) : (
            <>
              <p style={{ margin: '0 0 10px' }}>
                The last scan ran {sinceLabel || 'recently'} and added{' '}
                <strong>{brandsAdded} brand{brandsAdded === 1 ? '' : 's'}</strong>.
              </p>
              <p style={{ margin: 0 }}>
                Each scan uses ~4 Tavily credits and several Gemini calls. The free tier is limited to 1,000 Tavily searches/month — running multiple scans per day will burn through your quota quickly.
              </p>
            </>
          )}
        </div>

        <div className="row gap-2" style={{ justifyContent: 'flex-end' }}>
          <button className="btn" onClick={onCancel} autoFocus>
            {isInProgress ? 'OK' : 'Cancel'}
          </button>
          {!isInProgress ? (
            <button className="btn btn-primary" onClick={onContinue}>
              <window.I.refresh size={13} />
              Scan anyway
            </button>
          ) : null}
        </div>
      </div>
    </div>
  );
}

function useScan(onToast, onScanStart) {
  const [scanning, setScanning] = React.useState(false);
  // null | { kind: 'already-today', brandsAdded, hoursAgo } | { kind: 'in-progress' }
  const [warning, setWarning] = React.useState(null);

  const doScan = async () => {
    // The scan runs server-side: the API returns immediately and the global
    // banner + poll take over, completion toast included.
    setWarning(null);
    setScanning(true);
    try {
      await window.IPSEM_API.runScan();
      onScanStart && onScanStart();
      onToast && onToast('Scan started — new brands will appear automatically');
    } catch (err) {
      console.error('Scan failed:', err);
      const m = (err && err.message) ? String(err.message) : '';
      onToast && onToast(
        /already running/i.test(m) ? 'A scan is already running — new brands will appear when it finishes'
        : /quota|rate.?limit|exhausted|429/i.test(m) ? 'Scan failed — service quota reached, try later'
        : m ? `Scan failed: ${m}` : 'Scan failed');
    } finally {
      setScanning(false);
    }
  };

  // Quota guard: warn if a scan is running or one finished within 24h.
  const start = async () => {
    try {
      const runs = await window.IPSEM_API.getScanRuns();
      const last = Array.isArray(runs) ? runs[0] : null;
      if (last) {
        if (last.status === 'running') { setWarning({ kind: 'in-progress' }); return; }
        if (last.status === 'complete' && last.runDate) {
          const hoursAgo = (Date.now() - new Date(last.runDate).getTime()) / 3600000;
          if (hoursAgo < 24) {
            setWarning({ kind: 'already-today', brandsAdded: last.brandsClassified ?? 0, hoursAgo });
            return;
          }
        }
      }
    } catch (err) {
      console.warn('Could not check prior scan runs:', err);
    }
    doScan();
  };

  const warningEl = warning
    ? <ScanWarningModal warning={warning} onContinue={doScan} onCancel={() => setWarning(null)} />
    : null;

  return { scanning, start, warningEl };
}

function ScanButton({ onToast, onScanStart, scanRunning, className, label, title, iconOnly }) {
  const { scanning, start, warningEl } = useScan(onToast, onScanStart);
  const busy = scanning || scanRunning;
  const cls = className || (iconOnly ? 'btn btn-icon' : 'btn btn-primary');
  return (
    <>
      <button
        className={cls}
        onClick={start}
        disabled={busy}
        aria-label="Re-run scan"
        title={title || 'Re-run scan — the only way new brands enter the app. Warns first if you already scanned today.'}
      >
        <window.I.refresh size={14} className={busy ? 'spin' : ''} />
        {iconOnly ? null : (busy ? 'Scanning…' : (label || 'Re-run scan'))}
      </button>
      {warningEl}
    </>
  );
}

// ─── Where a note came from ───────────────────────────────────────────────
// Notes written in the app carry source:'app'. Anything bulk-loaded from the
// Weekly Brand Review workbook is 'import'. Older imports predate the field,
// so the author name is the fallback test.
function noteSource(n) {
  if (!n) return 'app';
  if (n.source === 'import' || n.source === 'app') return n.source;
  return /import/i.test(String(n.author || '')) ? 'import' : 'app';
}
const NOTE_SOURCES = [
  { value: 'all',    label: 'Any source' },
  { value: 'app',    label: 'Written in the app' },
  { value: 'import', label: 'Imported from Excel' },
];

// ─── Is this the same company? ────────────────────────────────────────────
// Mirror of backend services/dedupe.py brand_key(). Keep the two in step: the
// Add-brand drawer warns with this copy, and the backend refuses the save with
// its own. Collapses curly apostrophes, doubled spaces, accents, corporate
// suffixes and a bracketed parent company, so "Jacob’s Creek  (Vinarchy)",
// "Jacob's Creek (Vinarchy)" and "Jacobs Creek" all key the same.
const BRAND_STOPWORDS = new Set([
  'inc', 'llc', 'ltd', 'limited', 'plc', 'gmbh', 'sa', 'ag', 'co', 'corp',
  'corporation', 'company', 'group', 'holdings', 'holding', 'the', 'uk',
  'usa', 'us', 'eu', 'global', 'international', 'intl', 'worldwide',
  'brands', 'brand',
]);
function brandKey(name) {
  const s = String(name || '')
    .normalize('NFKD')
    .replace(/[\u0300-\u036f]/g, '')            // drop diacritics
    .replace(/[\u2018\u2019\u201a\u201b\u2032\u02bc\u00b4`]/g, "'")
    .replace(/[\u2010-\u2015\u2212]/g, '-')
    .toLowerCase();
  const join = (text) => {
    const words = text.replace(/[^a-z0-9\s]+/g, ' ').split(/\s+/).filter(Boolean);
    const kept = words.filter((w) => !BRAND_STOPWORDS.has(w));
    return (kept.length ? kept : words).join('');
  };
  // Brackets usually hold the parent company, the part that varies by source.
  // Only drop them when something identifying survives.
  return join(s.replace(/[([][^)\]]*[)\]]/g, ' ')) || join(s);
}

// The brand a new name would collide with, or null. Prefers a live record over
// one archived away by a merge, so the warning points where the work happens.
function findBrandByName(brands, name) {
  const key = brandKey(name);
  if (!key) return null;
  const hits = (brands || []).filter((b) => brandKey(b.brand) === key);
  return hits.find((b) => b.status !== 'duplicate' && !b.duplicateOf) || hits[0] || null;
}

Object.assign(window, {
  Pill, PriorityPill, StatusPill, ResearchPill, SignalChip,
  ScoreBar, ScoreWithBreakdown, ScoreRing, Avatar, BrandLogo, brandDomainGuess, Cite, FlowStep, SectionHeader, Card, Definition, Toast,
  initialsFor, formatDate, propLabel, propName, PropertyById,
  computeScoreBreakdown, scoreBand,
  ASSIGNEE_NAMES, displayAssignee, extractCountry,
  OUTREACH_STAGES, outreachMeta,
  DETAIL_STAGES, detailStageLabel, APPROVAL_META,
  ConfirmModal, useConfirm, guardOutreachChange, CopyBtn,
  usePersistentState,
  ENGAGEMENT_TYPES, engagementMeta,
  parseMentions, MentionText, NotesPanel, collectNotes,
  brandKey, findBrandByName,
  resolveTeamName, displayNameFor, noteIsCleared, toggleNoteCleared,
  IPSEM_TZ, TEAM_ZONES, setViewerTimeZone, viewerTimeZone, browserTimeZone,
  tzOffsetLabel, formatInstant, relativeTime, TimeStamp, viewerOffsetFromIpsemDay,
  StopBrandModal, BrandStopControl, NotificationBell, notificationVerb,
  useFreshBuild, fetchBuildCommit, hasUnsavedTyping, buildReloadDecision,
  CONTACT_STATUSES, PRIORITY_OPTIONS, STATUS_OPTIONS, BrandControlBar,
  PB_STAGES, pbStage, pbIsHeldOrClosed, pbConfirmed,
  brandBlocker, buildQueue, QueueButton,
  isReviewed, needsReview, needsRelevanceCall, needsPropertyDecision,
  pbActive, isDecided, awaitingEarlierStage, NOTIFICATION_TZ,
  reviewGate, REVIEW_GATES, reviewReason, reviewPatch, MarkReviewedControl,
  contactState, CONTACT_STATES, needsContactWork,
  needsDraft, draftReadyNotSent, draftSent, sentInstant,
  brandStatus, BrandStatusChip, StageFilterBar,
  useBrandFilters, BrandFilterPanel, useBulkSelect, BrandBulkBar,
  useScan, ScanButton, ScanWarningModal,
  noteSource, NOTE_SOURCES, authorLabel,
  daysUntil, ipsemToday, addBusinessDays, downloadCsv,
  refusalState, RefusalNotice,
  setApprovalIndex, approvalFor, awaitingApproval, APPROVAL_CHASE_DAYS,
});
