// Dashboard — écran d'accueil / lancement de dictée
// Brutalist editorial — strict B&W + un accent.

const VT = window.VT || {};
const INK = VT.ink || '#0a0a0a';
const PAPER = VT.paper || '#f6f5f1';
const SURF = VT.surface || '#ffffff';
const SUB = VT.sub || '#6b7280';
const HAIR = VT.hair || 'rgba(10,10,10,0.06)';
const HAIR_2 = VT.hair2 || 'rgba(10,10,10,0.10)';
const SHADOW_SM = VT.shadowSm || '0 1px 2px rgba(10,10,10,0.04), 0 0 0 1px rgba(10,10,10,0.04)';
const SHADOW_MD = VT.shadowMd || '0 1px 3px rgba(10,10,10,0.06), 0 4px 12px rgba(10,10,10,0.04)';
const OK = VT.success || '#059669';
const MED = VT.medBlue || '#3d6b8f';
const MED_TINT = VT.medBlueTint || 'rgba(61,107,143,0.10)';
// Rouge réservé à l'alerte clinique critique, cf. §1 de l'audit.
const DANGER = VT.danger || '#b91c1c';

// ─────────────────────────────────────────────────────────────
// Atoms
// ─────────────────────────────────────────────────────────────
function Caption({ children, color, style = {} }) {
  return (
    <span className="mono" style={{
      fontSize: 11, fontWeight: 600, letterSpacing: '0.6px',
      textTransform: 'uppercase', color: color || SUB,
      ...style
    }}>{children}</span>);

}

function HRule({ heavy = false, style = {} }) {
  return <div style={{ height: 1, background: heavy ? HAIR_2 : HAIR, ...style }} />;
}

// Tabs CRO / CRC / CRH — underline only
function UnderlineTabs({ tabs, active, onChange }) {
  return (
    <div style={{ display: 'flex', gap: 6, padding: '0 12px' }}>
      {tabs.map((t) => {
        const on = t.id === active;
        return (
          <button key={t.id}
          onClick={() => onChange(t.id)}
          className="press"
          style={{
            background: on ? INK : SURF,
            color: on ? '#fff' : INK,
            border: 'none',
            borderRadius: 999,
            boxShadow: on ? 'none' : SHADOW_SM,
            padding: '8px 14px',
            cursor: 'pointer', position: 'relative',
            fontFamily: 'inherit',
            fontSize: 12.5, fontWeight: 600, letterSpacing: '0.1px',
            display: 'inline-flex', alignItems: 'center', gap: 6
          }}>
            {t.label}
            <span className="mono" style={{
              fontSize: 10, fontWeight: 500,
              opacity: on ? 0.7 : 0.5
            }}>{t.count}</span>
          </button>);

      })}
    </div>);

}

// ─────────────────────────────────────────────────────────────
// Rappel Bell — popover with reminders + add form
// ─────────────────────────────────────────────────────────────
const RAPPEL_INIT = [
  { id: 1, text: 'Appeler M. Martin — résultat INR', date: '30/04 · 16:00', patient: 'M. Martin', priority: 'high', done: false },
  { id: 2, text: 'Commander prothèse PTFE 6mm Bloc A', date: '01/05 · 09:00', patient: null, priority: 'medium', done: false },
  { id: 3, text: 'Vérifier doppler Mme Durand post-op', date: '01/05 · 14:00', patient: 'Mme Durand', priority: 'high', done: false },
  { id: 4, text: 'Envoyer courrier Dr. Legrand', date: '02/05 · 10:00', patient: null, priority: 'low', done: true },
  { id: 5, text: 'RDV staff vasculaire M. Bernard', date: '03/05 · 11:00', patient: 'M. Bernard', priority: 'medium', done: false },
];

const PRIO_STYLE = {
  high:   { dot: '#b91c1c', bg: 'rgba(185,28,28,0.06)' },
  medium: { dot: '#d97706', bg: 'rgba(217,119,6,0.06)' },
  low:    { dot: '#9ca3af', bg: 'rgba(10,10,10,0.03)' },
};

// Libellés des `reason_code` du tableau de bord, repris mot pour mot de
// l'application native afin qu'un refus s'énonce de la même façon partout.
const DB_REASONS = {
  role_not_supported: 'Rôle non pris en charge',
  feature_unavailable: 'Fonction indisponible',
  source_unavailable: 'Source indisponible',
  provider_unavailable: 'Fournisseur indisponible',
  reference_unavailable: 'Référentiel indisponible',
  target_unavailable: 'Cible indisponible',
  patient_archived: 'Patient archivé',
  patient_deceased: 'Patient décédé',
  document_unavailable: 'Document indisponible',
  document_state_changed: 'État du document modifié',
  document_not_owned: 'Document non attribué',
  feature_not_in_j1: 'Fonction hors périmètre',
  reminder_open: 'Rappel déjà ouvert',
  reminder_completed: 'Rappel terminé',
  reminder_archived: 'Rappel archivé',
};

const dbReason = (capability) => DB_REASONS[capability?.reason_code] || 'Action indisponible';

// Un message d'erreur inline vaut mieux qu'un toast qui disparaît : le
// praticien doit pouvoir relire pourquoi sa commande a été refusée.
function DbBanner({ text, tone = 'error' }) {
  if (!text) return null;
  const color = tone === 'error' ? DANGER : SUB;
  return (
    <div role={tone === 'error' ? 'alert' : 'status'} style={{
      margin: '0 16px 10px', padding: '8px 10px', borderRadius: 10,
      background: tone === 'error' ? 'rgba(185,28,28,0.06)' : 'rgba(10,10,10,0.03)',
      fontSize: 11, fontWeight: 600, color, lineHeight: 1.35,
    }}>{text}</div>
  );
}

// `datetime-local` rend une heure locale sans fuseau ; l'API attend un instant.
function localInputToIso(value) {
  if (!value) return null;
  const parsed = new Date(value);
  return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}

function RappelBell({ accent, runtime = null, capability = null, activeCount = null }) {
  const [open, setOpen] = React.useState(false);
  const [rappels, setRappels] = React.useState(RAPPEL_INIT);
  const [loaded, setLoaded] = React.useState(false);
  const [showAdd, setShowAdd] = React.useState(false);
  const [newText, setNewText] = React.useState('');
  const [newDate, setNewDate] = React.useState('');
  const [newPatient, setNewPatient] = React.useState('');
  const [newPrio, setNewPrio] = React.useState('medium');
  const [patients, setPatients] = React.useState([]);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const ref = React.useRef(null);

  const allowed = !capability || capability.enabled;
  const active = rappels.filter(r => !r.done);
  const done = rappels.filter(r => r.done);
  // La pastille annonce ce que le panneau va montrer. L'aperçu du tableau de
  // bord s'arrête à cinq rappels : en compter les lignes faisait dire « 5 » à
  // la pastille pour six rappels ouverts, et le chiffre ne se corrigeait qu'à
  // l'ouverture. Tant que la liste complète n'est pas chargée, c'est le
  // décompte du serveur qui s'affiche ; ensuite la liste à l'écran fait foi,
  // elle seule suit les commandes passées depuis le panneau.
  const badge = loaded || activeCount === null ? active.length : activeCount;

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

  // L'aperçu du tableau de bord est tronqué : à l'ouverture on recharge la
  // liste complète, ouverts et terminés, avec les versions à jour.
  const reload = React.useCallback(async () => {
    if (!runtime) return;
    setBusy(true);
    try {
      setRappels(await runtime.reminders.load());
      setLoaded(true);
      setError(null);
    } catch (failure) {
      setError(failure?.message || 'Chargement des rappels impossible');
    } finally {
      setBusy(false);
    }
  }, [runtime]);

  React.useEffect(() => { if (open) void reload(); }, [open, reload]);

  React.useEffect(() => {
    if (!open || !showAdd || !runtime?.patients) return;
    let cancelled = false;
    runtime.patients()
      .then((list) => { if (!cancelled) setPatients(list); })
      .catch(() => { if (!cancelled) setPatients([]); });
    return () => { cancelled = true; };
  }, [open, showAdd, runtime]);

  // Une commande refusée ne doit rien changer à l'écran : on ne remplace la
  // ligne qu'avec ce que le serveur renvoie réellement.
  const runCommand = async (reminder, action) => {
    if (!runtime) return;
    setBusy(true);
    setError(null);
    try {
      const updated = await runtime.reminders[action](reminder);
      setRappels((list) => (action === 'archive'
        ? list.filter((item) => item.id !== reminder.id)
        : list.map((item) => (item.id === updated.id ? updated : item))));
      // La ligne du rappel change à l'écran — reportée, archivée, terminée.
      await runtime.refresh().catch(() => {});
    } catch (failure) {
      setError(failure?.message || 'Commande refusée');
      if (failure?.status === 409) await reload();
    } finally {
      setBusy(false);
    }
  };

  const toggle = (reminder) => {
    const target = reminder.done ? 'reopen' : 'complete';
    const capabilities = reminder.capabilities?.[target];
    if (capabilities && !capabilities.enabled) { setError(dbReason(capabilities)); return; }
    void runCommand(reminder, target);
  };

  const remove = (reminder) => {
    const capabilities = reminder.capabilities?.archive;
    if (capabilities && !capabilities.enabled) { setError(dbReason(capabilities)); return; }
    void runCommand(reminder, 'archive');
  };

  const addRappel = async () => {
    if (!newText.trim() || !runtime) return;
    setBusy(true);
    setError(null);
    try {
      const created = await runtime.reminders.create({
        text: newText.trim(),
        dueAt: localInputToIso(newDate),
        patientId: newPatient || null,
        priority: newPrio,
      });
      setRappels((list) => [created, ...list]);
      setNewText(''); setNewDate(''); setNewPatient(''); setNewPrio('medium');
      // Le rappel créé apparaît en tête de liste, formulaire refermé.
      setShowAdd(false);
      await runtime.refresh().catch(() => {});
    } catch (failure) {
      setError(failure?.message || 'Création du rappel impossible');
    } finally {
      setBusy(false);
    }
  };

  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button onClick={() => { if (allowed) setOpen(!open); else showToast(dbReason(capability), 'error'); }}
        disabled={!allowed} aria-disabled={!allowed}
        title={allowed ? 'Rappels' : dbReason(capability)}
        className="press" style={{
          position: 'relative', width: 44, height: 44, borderRadius: 16,
          background: SURF, border: `1px solid ${HAIR}`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          cursor: allowed ? 'pointer' : 'default', padding: 0, opacity: allowed ? 1 : 0.45,
        }}>
        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
          <circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
        </svg>
        {badge > 0 && (
          <div style={{
            position: 'absolute', top: -2, right: -2,
            minWidth: 16, height: 16, borderRadius: 8,
            background: accent, color: '#fff',
            fontSize: 9, fontWeight: 700,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            padding: '0 4px', border: `2px solid ${PAPER}`,
          }}>{badge}</div>
        )}
      </button>

      {open && (
        <div style={{
          position: 'absolute', top: 50, right: 0, width: 300, maxHeight: 440,
          background: SURF, borderRadius: 22, overflow: 'hidden',
          boxShadow: '0 8px 32px rgba(10,10,10,0.14), 0 1px 4px rgba(10,10,10,0.06)',
          border: `1px solid ${HAIR}`, zIndex: 100,
          animation: 'notifIn .2s ease',
        }}>
          {/* Header */}
          <div style={{
            display: 'flex', alignItems: 'center', justifyContent: 'space-between',
            padding: '14px 16px 10px',
          }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              <span style={{ fontSize: 15, fontWeight: 700, color: INK }}>Rappels</span>
              {badge > 0 && (
                <span className="mono" style={{
                  fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 999,
                  background: `${accent}14`, color: accent,
                }}>{badge}</span>
              )}
            </div>
            <button onClick={() => setShowAdd(!showAdd)} className="press" style={{
              width: 28, height: 28, borderRadius: 8,
              background: showAdd ? INK : 'rgba(10,10,10,0.04)',
              border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
              cursor: 'pointer',
            }}>
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
                stroke={showAdd ? '#fff' : INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                <path d="M12 5v14M5 12h14"/>
              </svg>
            </button>
          </div>

          {/* Add form */}
          {showAdd && (
            <div style={{ padding: '0 16px 12px', borderBottom: `1px solid ${HAIR}` }}>
              <input value={newText} onChange={e => setNewText(e.target.value)}
                placeholder="Ex : appeler M. Martin pour son INR"
                style={{ width: '100%', border: `1px solid ${HAIR_2}`, borderRadius: 12, padding: '8px 12px', fontFamily: 'inherit', fontSize: 13, fontWeight: 500, color: INK, outline: 'none', marginBottom: 8, background: PAPER }} />
              {/* L'échéance est un instant côté serveur et le patient une clé :
                  les champs libres de la maquette ne pouvaient pas être
                  transmis, ils sont remplacés par une date et une liste. */}
              <div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
                <input type="datetime-local" value={newDate} onChange={e => setNewDate(e.target.value)}
                  aria-label="Échéance du rappel"
                  style={{ flex: 1, minWidth: 0, border: `1px solid ${HAIR_2}`, borderRadius: 12, padding: '8px 10px', fontFamily: "'JetBrains Mono', monospace", fontSize: 11, fontWeight: 500, color: INK, outline: 'none', background: PAPER }} />
                <select value={newPatient} onChange={e => setNewPatient(e.target.value)}
                  aria-label="Patient concerné"
                  style={{ flex: 1, minWidth: 0, border: `1px solid ${HAIR_2}`, borderRadius: 12, padding: '8px 10px', fontFamily: 'inherit', fontSize: 12, fontWeight: 500, color: INK, outline: 'none', background: PAPER }}>
                  <option value="">Sans patient</option>
                  {patients.map((patient) => (
                    <option key={patient.id} value={patient.id} disabled={patient.status === 'decede'}>
                      {patient.last} {patient.first}{patient.status === 'decede' ? ' · décédé' : ''}
                    </option>
                  ))}
                </select>
              </div>
              {/* Priority */}
              <div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
                {[
                  { id: 'high', label: 'Haute' },
                  { id: 'medium', label: 'Moyenne' },
                  { id: 'low', label: 'Basse' },
                ].map(p => (
                  <button key={p.id} onClick={() => setNewPrio(p.id)} className="press" style={{
                    flex: 1, padding: '5px 0', borderRadius: 8, border: 'none',
                    background: newPrio === p.id ? PRIO_STYLE[p.id].bg : 'transparent',
                    fontFamily: "'JetBrains Mono', monospace", fontSize: 9, fontWeight: 600,
                    color: newPrio === p.id ? PRIO_STYLE[p.id].dot : SUB,
                    cursor: 'pointer', letterSpacing: '0.3px',
                    outline: newPrio === p.id ? `1.5px solid ${PRIO_STYLE[p.id].dot}` : 'none',
                  }}>{p.label}</button>
                ))}
              </div>
              <button onClick={() => void addRappel()} disabled={!newText.trim() || busy || !runtime} className="press" style={{
                width: '100%', height: 36, borderRadius: 12,
                background: newText.trim() && !busy ? INK : 'rgba(10,10,10,0.06)',
                color: newText.trim() && !busy ? '#fff' : SUB,
                border: 'none', fontFamily: 'inherit', fontSize: 12, fontWeight: 700,
                cursor: newText.trim() && !busy ? 'pointer' : 'default',
              }}>{busy ? 'Envoi…' : 'Ajouter'}</button>
            </div>
          )}

          <DbBanner text={error} />

          {/* List */}
          <div className="vasco-scroll" style={{ maxHeight: showAdd ? 200 : 340, overflowY: 'auto' }}>
            {rappels.length === 0 ? (
              <div style={{ padding: '32px 16px', textAlign: 'center' }}>
                <div style={{ fontSize: 13, fontWeight: 500, color: SUB }}>
                  {busy ? 'Chargement des rappels…' : 'Aucun rappel'}
                </div>
              </div>
            ) : (
              <React.Fragment>
                {active.map(r => {
                  const ps = PRIO_STYLE[r.priority] || PRIO_STYLE.low;
                  return (
                    <div key={r.id} style={{
                      display: 'flex', alignItems: 'flex-start', gap: 10,
                      padding: '10px 16px', borderTop: `1px solid ${HAIR}`,
                    }}>
                      <button onClick={() => toggle(r)} disabled={busy}
                        aria-label={`Terminer ${r.text}`}
                        title={r.capabilities?.complete?.enabled === false ? dbReason(r.capabilities.complete) : 'Terminer'}
                        className="press" style={{
                          width: 20, height: 20, borderRadius: 6, flexShrink: 0, marginTop: 1,
                          border: `1.5px solid ${ps.dot}`, background: 'transparent',
                          cursor: busy ? 'default' : 'pointer', padding: 0,
                          opacity: r.capabilities?.complete?.enabled === false ? 0.4 : 1,
                        }} />
                      <div style={{ flex: 1, minWidth: 0 }}>
                        <div style={{ fontSize: 13, fontWeight: 600, color: INK, lineHeight: 1.3 }}>{r.text}</div>
                        <div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
                          <span className="mono" style={{ fontSize: 10, fontWeight: 500, color: SUB, letterSpacing: '0.2px' }}>{r.date}</span>
                          {/* Archiver un dossier n'efface pas le rappel qui le
                              nomme : la pastille garde le nom et dit que le
                              dossier est rangé, sans quoi « Alice Durand » se
                              lit comme un dossier qu'on peut encore ouvrir. */}
                          {r.patient && (
                            <span style={{ fontSize: 10, fontWeight: 600, color: INK, padding: '1px 6px', borderRadius: 4, background: 'rgba(10,10,10,0.04)' }}>
                              {r.patient}
                              {r.patientArchived && (
                                <span style={{ fontWeight: 500, color: SUB }}> — patient archivé</span>
                              )}
                            </span>
                          )}
                        </div>
                      </div>
                      <button onClick={() => remove(r)} disabled={busy}
                        aria-label={`Archiver ${r.text}`} title="Archiver"
                        className="press" style={{
                          background: 'none', border: 'none', cursor: busy ? 'default' : 'pointer', padding: 2, opacity: 0.3,
                        }}>
                        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                          <path d="M18 6L6 18M6 6l12 12"/>
                        </svg>
                      </button>
                    </div>
                  );
                })}
                {done.length > 0 && (
                  <div style={{ padding: '8px 16px 4px' }}>
                    <span className="mono" style={{ fontSize: 9, fontWeight: 600, color: SUB, letterSpacing: '0.4px', textTransform: 'uppercase' }}>Terminés</span>
                  </div>
                )}
                {done.map(r => (
                  <div key={r.id} style={{
                    display: 'flex', alignItems: 'flex-start', gap: 10,
                    padding: '8px 16px', opacity: 0.5,
                  }}>
                    <button onClick={() => toggle(r)} disabled={busy}
                      aria-label={`Rouvrir ${r.text}`} title="Rouvrir"
                      className="press" style={{
                        width: 20, height: 20, borderRadius: 6, flexShrink: 0, marginTop: 1,
                        border: 'none', background: 'rgba(10,10,10,0.08)',
                        cursor: busy ? 'default' : 'pointer', padding: 0,
                        display: 'flex', alignItems: 'center', justifyContent: 'center',
                      }}>
                      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={INK} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                        <path d="M20 6L9 17l-5-5"/>
                      </svg>
                    </button>
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div style={{ fontSize: 13, fontWeight: 500, color: SUB, textDecoration: 'line-through', lineHeight: 1.3 }}>{r.text}</div>
                    </div>
                    <button onClick={() => remove(r)} disabled={busy}
                      aria-label={`Archiver ${r.text}`} title="Archiver"
                      className="press" style={{
                        background: 'none', border: 'none', cursor: busy ? 'default' : 'pointer', padding: 2, opacity: 0.3,
                      }}>
                      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                        <path d="M18 6L6 18M6 6l12 12"/>
                      </svg>
                    </button>
                  </div>
                ))}
              </React.Fragment>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Notification Bell — popover dropdown
// ─────────────────────────────────────────────────────────────
const NOTIF_DATA = [
  { id: 1, type: 'alert', title: 'INR critique', text: 'M. Martin Jean — INR à 3.8, contrôle urgent', time: 'Il y a 12 min', priority: 'high', patientId: 'p-2210' },
  { id: 2, type: 'rdv', title: 'RDV dans 30 min', text: 'Mme Petit Aline — Suivi FAV, Salle 2', time: 'Il y a 18 min', priority: 'medium', patientId: 'p-5023' },
  { id: 3, type: 'doc', title: 'CR à finaliser', text: 'CRO Pontage fémoro-poplité — M. Martin', time: 'Il y a 1h', priority: 'medium', patientId: 'p-2210' },
  { id: 4, type: 'sign', title: 'Signature requise', text: 'Ordonnance anticoagulants Mme Durand', time: 'Il y a 2h', priority: 'low', patientId: 'p-1987' },
  { id: 5, type: 'patient', title: 'Nouveau patient', text: 'M. Garnier Robert enregistré par le secrétariat', time: 'Il y a 3h', priority: 'low', patientId: 'p-5511' },
  { id: 6, type: 'postop', title: 'Alerte post-op', text: 'Mme Lefèvre J+5 — température 38.2°C', time: 'Il y a 4h', priority: 'high', patientId: 'p-4128' },
];

const NOTIF_ICONS = {
  alert:   <><path d="M12 4l10 17H2z"/><path d="M12 11v4"/></>,
  rdv:     <><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></>,
  doc:     <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
  sign:    <><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></>,
  patient: <><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a8 8 0 0 1 16 0v1"/></>,
  postop:  <><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></>,
};

const NOTIF_COLORS = {
  high:   { bg: 'rgba(185,28,28,0.06)', dot: '#b91c1c' },
  medium: { bg: 'rgba(217,119,6,0.06)', dot: '#d97706' },
  low:    { bg: 'rgba(10,10,10,0.03)', dot: '#9ca3af' },
};

function NotifBell({ accent, onNavigate, onOpenPatient, onOpenReport, runtime = null, capability = null, dismissCapability = null, activeCount = null }) {
  const [open, setOpen] = React.useState(false);
  const [notifs, setNotifs] = React.useState(NOTIF_DATA);
  const [loaded, setLoaded] = React.useState(false);
  const [snapshot, setSnapshot] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const ref = React.useRef(null);
  // Même règle que la pastille des rappels : l'aperçu du tableau de bord est
  // tronqué à cinq notifications, son compte n'est pas celui du serveur. La
  // pastille porte le décompte servi par `notifications_preview.active_count`
  // jusqu'à ce que la liste complète soit chargée, elle prend le relais ensuite.
  const count = loaded || activeCount === null ? notifs.length : activeCount;

  const allowed = !capability || capability.enabled;
  const canDismiss = !dismissCapability || dismissCapability.enabled;

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

  const reload = React.useCallback(async () => {
    if (!runtime) return;
    setBusy(true);
    try {
      const page = await runtime.notifications.load();
      setNotifs(page.items);
      setLoaded(true);
      setSnapshot(page);
      setError(null);
    } catch (failure) {
      setError(failure?.message || 'Chargement des notifications impossible');
    } finally {
      setBusy(false);
    }
  }, [runtime]);

  React.useEffect(() => { if (open) void reload(); }, [open, reload]);

  const dismiss = async (notification) => {
    if (!runtime) return;
    if (!canDismiss) { setError(dbReason(dismissCapability)); return; }
    setBusy(true);
    setError(null);
    try {
      await runtime.notifications.dismiss(notification);
      setNotifs((list) => list.filter((item) => item.id !== notification.id));
      await runtime.refresh().catch(() => {});
    } catch (failure) {
      setError(failure?.message || 'Retrait impossible');
      if (failure?.status === 409) await reload();
    } finally {
      setBusy(false);
    }
  };

  // « Tout effacer » est scellé par le jeton d'instantané : une notification
  // arrivée après l'affichage n'est pas emportée par le clic.
  const dismissAll = async () => {
    if (!runtime || !snapshot) return;
    if (!canDismiss) { setError(dbReason(dismissCapability)); return; }
    setBusy(true);
    setError(null);
    try {
      await runtime.notifications.dismissAll(snapshot);
      // La liste se vide sous les yeux : le compte des lignes retirées n'apprend
      // rien de plus que la liste elle-même.
      await reload();
      await runtime.refresh().catch(() => {});
    } catch (failure) {
      setError(failure?.message || 'Retrait impossible');
      await reload();
    } finally {
      setBusy(false);
    }
  };

  // La navigation d'une notification est autorisée par le serveur, cible par
  // cible : un dossier archivé ou un document repris ne s'ouvre pas.
  const openTarget = async (notification) => {
    const target = notification.target;
    if (!target || target.kind === 'none') { setError('Cette notification n’a pas de cible'); return; }
    if (notification.targetCapability && !notification.targetCapability.enabled) {
      setError(dbReason(notification.targetCapability));
      return;
    }
    if (runtime && !notification.read) {
      await runtime.notifications.read(notification).catch(() => {});
      setNotifs((list) => list.map((item) => (item.id === notification.id ? { ...item, read: true } : item)));
    }
    setOpen(false);
    if (target.kind === 'patient' && onOpenPatient) onOpenPatient(target.patient_id);
    else if (target.kind === 'appointment' && onNavigate) onNavigate('AGE');
    else if (target.kind === 'document_review' && onOpenReport) onOpenReport(target.document_id);
  };

  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button onClick={() => { if (allowed) setOpen(!open); else showToast(dbReason(capability), 'error'); }}
        disabled={!allowed} aria-disabled={!allowed}
        title={allowed ? 'Notifications' : dbReason(capability)}
        className="press" style={{
          position: 'relative', width: 44, height: 44, borderRadius: 16,
          background: SURF, border: `1px solid ${HAIR}`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          cursor: allowed ? 'pointer' : 'default', padding: 0, opacity: allowed ? 1 : 0.45,
        }}>
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke={INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
          <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>
        </svg>
        {count > 0 && (
          <div style={{
            position: 'absolute', top: -2, right: -2,
            minWidth: 16, height: 16, borderRadius: 8,
            background: accent, color: '#fff',
            fontSize: 9, fontWeight: 700,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            padding: '0 4px', border: `2px solid ${PAPER}`,
          }}>{count}</div>
        )}
      </button>

      {/* Dropdown */}
      {open && (
        <div style={{
          position: 'absolute', top: 42, right: 0, width: 300, maxHeight: 400,
          background: SURF, borderRadius: 22, overflow: 'hidden',
          boxShadow: '0 8px 32px rgba(10,10,10,0.14), 0 1px 4px rgba(10,10,10,0.06)',
          border: `1px solid ${HAIR}`,
          zIndex: 100,
          animation: 'notifIn .2s ease',
        }}>
          <style>{`@keyframes notifIn { from { opacity:0; transform: translateY(-8px) scale(.97); } to { opacity:1; transform: translateY(0) scale(1); } }`}</style>

          {/* Header */}
          <div style={{
            display: 'flex', alignItems: 'center', justifyContent: 'space-between',
            padding: '14px 16px 10px',
          }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              <span style={{ fontSize: 15, fontWeight: 700, color: INK }}>Notifications</span>
              {count > 0 && (
                <span className="mono" style={{
                  fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 999,
                  background: `${accent}14`, color: accent,
                }}>{count}</span>
              )}
            </div>
            {count > 0 && (
              <button onClick={() => void dismissAll()} disabled={busy || !canDismiss || !snapshot}
                title={canDismiss ? 'Retirer toutes les notifications affichées' : dbReason(dismissCapability)}
                className="press" style={{
                  background: 'none', border: 'none',
                  cursor: busy || !canDismiss || !snapshot ? 'default' : 'pointer',
                  fontFamily: "'JetBrains Mono', monospace", fontSize: 9, fontWeight: 600,
                  color: SUB, letterSpacing: '0.3px', opacity: canDismiss ? 1 : 0.5,
                }}>TOUT EFFACER</button>
            )}
          </div>

          <DbBanner text={error} />

          {/* List */}
          <div className="vasco-scroll" style={{ maxHeight: 320, overflowY: 'auto' }}>
            {notifs.length === 0 ? (
              <div style={{ padding: '32px 16px', textAlign: 'center' }}>
                <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke={SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" style={{ margin: '0 auto 8px', display: 'block', opacity: 0.4 }}>
                  <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>
                </svg>
                <div style={{ fontSize: 13, fontWeight: 500, color: SUB }}>Aucune notification</div>
              </div>
            ) : notifs.map((n) => {
              const nc = NOTIF_COLORS[n.priority] || NOTIF_COLORS.low;
              return (
                <div key={n.id} role="button" tabIndex={0}
                  onClick={() => void openTarget(n)}
                  onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); void openTarget(n); } }}
                  title={n.targetCapability && !n.targetCapability.enabled ? dbReason(n.targetCapability) : undefined}
                  style={{
                    display: 'flex', alignItems: 'flex-start', gap: 10,
                    padding: '12px 16px',
                    borderTop: `1px solid ${HAIR}`,
                    cursor: 'pointer',
                    background: n.read ? 'transparent' : 'rgba(61,107,143,0.04)',
                    opacity: n.targetCapability && !n.targetCapability.enabled ? 0.6 : 1,
                  }} className="press">
                  <div style={{
                    width: 32, height: 32, borderRadius: 12, flexShrink: 0,
                    background: nc.bg,
                    display: 'flex', alignItems: 'center', justifyContent: 'center',
                    marginTop: 1,
                  }}>
                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={nc.dot} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                      {NOTIF_ICONS[n.type] || NOTIF_ICONS.alert}
                    </svg>
                  </div>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                      <span style={{ fontSize: 13, fontWeight: 600, color: INK }}>{n.title}</span>
                      {n.priority === 'high' && <div style={{ width: 5, height: 5, borderRadius: '50%', background: '#b91c1c', flexShrink: 0 }} />}
                    </div>
                    <div style={{ fontSize: 12, fontWeight: 500, color: SUB, marginTop: 2, lineHeight: 1.35 }}>{n.text}</div>
                    <div className="mono" style={{ fontSize: 10, fontWeight: 500, color: SUB, marginTop: 4, letterSpacing: '0.2px', opacity: 0.7 }}>{n.time}</div>
                    {/* Le corps d'une notification nomme un patient en clair.
                        Quand le serveur refuse d'ouvrir la cible, la raison ne
                        vivait que dans le `title` — invisible au doigt : une
                        alerte au nom d'un dossier archivé se lisait comme un
                        dossier ouvert. Elle se lit maintenant sous la ligne,
                        comme sous les commandes de l'agenda. */}
                    {n.targetCapability && !n.targetCapability.enabled && (
                      <div style={{ fontSize: 12, fontWeight: 600, color: SUB, marginTop: 4, lineHeight: 1.4 }}>
                        {dbReason(n.targetCapability)}
                      </div>
                    )}
                  </div>
                  <button onClick={(e) => { e.stopPropagation(); void dismiss(n); }} disabled={busy}
                    aria-label={`Retirer ${n.title}`}
                    title={canDismiss ? 'Retirer' : dbReason(dismissCapability)}
                    className="press" style={{
                      background: 'none', border: 'none', cursor: busy ? 'default' : 'pointer', padding: 4,
                      opacity: 0.3, marginTop: 2,
                    }}>
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                      <path d="M18 6L6 18M6 6l12 12"/>
                    </svg>
                  </button>
                </div>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Hero — outline mic 180px
// ─────────────────────────────────────────────────────────────
function MicHero({ accent, recording, onTap, onQuick }) {
  const [pressed, setPressed] = React.useState(false);
  const [elapsed, setElapsed] = React.useState(0);

  // Maintien = raccourci vers la dictée rapide. Ce n'est qu'un raccourci : le
  // bouton explicite sous le micro reste le chemin principal, un geste invisible
  // ne doit pas être la seule porte d'entrée.
  const holdTimer = React.useRef(null);
  const didHold = React.useRef(false);

  const startHold = () => {
    setPressed(true);
    didHold.current = false;
    holdTimer.current = setTimeout(() => {
      didHold.current = true;
      onQuick && onQuick();
    }, 600);
  };
  const endHold = () => {
    setPressed(false);
    if (holdTimer.current) clearTimeout(holdTimer.current);
  };
  React.useEffect(() => () => { if (holdTimer.current) clearTimeout(holdTimer.current); }, []);

  // Run a 1Hz timer while recording — reset to 0 when recording stops.
  React.useEffect(() => {
    if (!recording) {setElapsed(0);return;}
    const start = Date.now();
    setElapsed(0);
    const id = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 250);
    return () => clearInterval(id);
  }, [recording]);

  const mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
  const ss = String(elapsed % 60).padStart(2, '0');

  const ringScale = pressed || recording ? 1.04 : 1;

  return (
    <div style={{
      display: 'flex', flexDirection: 'column', alignItems: 'center',
      padding: '20px 0 24px'
    }}>
      <button
        type="button"
        onClick={() => {
          // Le maintien a déjà lancé la dictée rapide : on n'enchaîne pas.
          if (didHold.current) { didHold.current = false; return; }
          onTap && onTap();
        }}
        onMouseDown={startHold}
        onMouseUp={endHold}
        onMouseLeave={endHold}
        onTouchStart={startHold}
        onTouchEnd={endHold}
        style={{
          position: 'relative',
          width: 180, height: 180, borderRadius: '50%',
          border: `2px solid ${recording ? accent : INK}`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          transform: `scale(${ringScale})`,
          transition: 'transform .18s cubic-bezier(.2,.7,.3,1), border-color .2s ease',
          cursor: 'pointer',
          background: recording ? `${accent}10` : 'transparent',
          padding: 0,
          fontFamily: 'inherit'
        }}>
        {/* Inner ring — accent at 50% opacity, full when recording */}
        <div style={{
          position: 'absolute', inset: 10,
          borderRadius: '50%',
          border: `1px solid ${MED}`,
          opacity: recording ? 1 : 0.5,
          transition: 'opacity .2s ease'
        }} />
        {recording && <div className="mic-pulse" style={{ '--accent': accent }} />}

        {/* Glyph: mic when idle, stop square when recording */}
        {recording ?
        <div style={{
          width: 36, height: 36, background: accent,
          display: 'flex', alignItems: 'center', justifyContent: 'center'
        }}>
            {/* solid square = stop, no inner glyph needed */}
          </div> :

        <svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke={INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
            <rect x="9" y="3" width="6" height="12" rx="3" />
            <path d="M5 11a7 7 0 0 0 14 0" />
            <path d="M12 18v3" />
          </svg>
        }
      </button>

      <div style={{
        marginTop: 18,
        display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
        minHeight: 44
      }}>
        {recording ?
        <>
            <span className="mono" style={{
            fontSize: 22, fontWeight: 600, color: accent,
            letterSpacing: '0.5px', fontVariantNumeric: 'tabular-nums'
          }}>{mm}:{ss}</span>
            <span className="mono" style={{
            fontSize: 11, fontWeight: 500, color: SUB, letterSpacing: '0.4px',
            textTransform: 'uppercase'
          }}>TAP POUR ARRÊTER & OUVRIR LE CHAT</span>
          </> :

        <>
            <div style={{ fontSize: 17, fontWeight: 700, letterSpacing: '-0.2px', color: INK }}>
              Dicter <span style={{ color: SUB, fontWeight: 600 }}>→</span>
            </div>
            <span style={{
            fontSize: 12, fontWeight: 500, color: SUB, letterSpacing: '-0.1px'
          }}>Tapez pour choisir un patient</span>

            {/* Chemin explicite vers la dictée sans patient — le maintien du
                micro fait la même chose, mais ne peut pas être le seul accès. */}
            <button onClick={() => onQuick && onQuick()} className="press" style={{
            marginTop: 10, height: 34, padding: '0 14px', borderRadius: 999,
            background: MED_TINT, border: 'none', cursor: 'pointer',
            fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600, color: MED,
            display: 'flex', alignItems: 'center', gap: 6,
          }}>
              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={MED}
              strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                <path d="M13 2L3 14h7l-1 8 10-12h-7z" />
              </svg>
              Dictée rapide sans patient
            </button>
          </>
        }
      </div>
    </div>);

}

// ─────────────────────────────────────────────────────────────
// Resume banner — top-border 1px ink, pause label in accent
// ─────────────────────────────────────────────────────────────
function ResumeBanner({ accent, item, onResume }) {
  return (
    <div style={{ padding: '12px 12px 14px', background: PAPER }}>
      <button onClick={onResume} className="press" style={{
        display: 'block', width: '100%', textAlign: 'left',
        background: SURF, border: 'none',
        borderRadius: 16,
        boxShadow: SHADOW_MD,
        padding: '14px 16px',
        cursor: 'pointer', fontFamily: 'inherit',
        position: 'relative'
      }}>
        <span style={{
          position: 'absolute', left: 0, top: 12, bottom: 12, width: 3,
          background: accent, borderRadius: '0 3px 3px 0'
        }} />
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
          <Caption color={accent} style={{ fontSize: 10 }}>EN PAUSE · REPRENDRE</Caption>
          <span className="mono" style={{ fontSize: 10.5, fontWeight: 500, color: SUB }}>
            {item.duration}
          </span>
        </div>
        <div style={{
          marginTop: 6, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12
        }}>
          <div style={{ minWidth: 0 }}>
            <div style={{ fontSize: 14.5, fontWeight: 600, color: INK, letterSpacing: '-0.2px',
              whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
              {item.procedure}
            </div>
            <div style={{ fontSize: 12.5, fontWeight: 500, color: SUB, marginTop: 2 }}>
              {item.patient} <span className="mono" style={{ color: SUB, opacity: .65 }}> · {item.started}</span>
            </div>
          </div>
          <span style={{
            width: 28, height: 28, borderRadius: 999, background: accent,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            color: '#fff', fontSize: 14, fontWeight: 600, flexShrink: 0
          }}>→</span>
        </div>
      </button>
    </div>);

}

// ─────────────────────────────────────────────────────────────
// Smart Stack — horizontal swipable widget carousel
// ─────────────────────────────────────────────────────────────
// ── Feuille de validation des comptes rendus ──
// Les chips CRO/CRC/CRH du dashboard changeaient d'état sans rien filtrer.
// Elles ouvrent maintenant cette feuille sur le type choisi ; la même rangée
// est reprise ici pour changer de type sans ressortir.
// `filter` vaut 'all' (ouverture depuis la carte) ou un type de CR.
function CRValidationSheet({ pending, filter, onChangeFilter, onValidate, onClose, runtime = null, capability = null, focusReportId = null }) {
  const [serverList, setServerList] = React.useState(null);
  const [serverCounts, setServerCounts] = React.useState(null);
  const [loading, setLoading] = React.useState(Boolean(runtime));
  const [detail, setDetail] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);

  const rows = serverList || pending;
  const counts = serverCounts || {
    all: pending.length,
    CRO: pending.filter((r) => r.type === 'CRO').length,
    CRC: pending.filter((r) => r.type === 'CRC').length,
    CRH: pending.filter((r) => r.type === 'CRH').length,
  };
  const shown = serverList ? rows : (filter === 'all' ? rows : rows.filter((r) => r.type === filter));

  // La file est refiltrée par le serveur, pas dans le navigateur : le compteur
  // affiché doit être celui de l'API, sinon un CR repris ailleurs resterait
  // compté ici.
  const reload = React.useCallback(async () => {
    if (!runtime) return;
    setLoading(true);
    try {
      const page = await runtime.reports.load(filter);
      setServerList(page.items);
      setServerCounts({ all: page.counts.all, CRO: page.counts.cro, CRC: page.counts.crc, CRH: page.counts.crh });
      setError(null);
    } catch (failure) {
      setError(failure?.message || 'Chargement des comptes rendus impossible');
    } finally {
      setLoading(false);
    }
  }, [runtime, filter]);

  React.useEffect(() => { void reload(); }, [reload]);

  // Ouvrir un compte rendu, c'est le lire : le serveur exige l'empreinte du
  // contenu réellement affiché avant d'accepter la validation.
  const openDetail = React.useCallback(async (reportId) => {
    if (!runtime) return;
    setBusy(true);
    setError(null);
    try {
      setDetail(await runtime.reports.get(reportId));
    } catch (failure) {
      setError(failure?.message || 'Compte rendu indisponible');
    } finally {
      setBusy(false);
    }
  }, [runtime]);

  React.useEffect(() => {
    if (focusReportId) void openDetail(focusReportId);
  }, [focusReportId, openDetail]);

  const validateDetail = async () => {
    if (!runtime || !detail) return;
    if (!detail.content_sha256) { setError('Le contenu du compte rendu est indisponible'); return; }
    setBusy(true);
    setError(null);
    try {
      await runtime.reports.validate(detail, detail.content_sha256);
      // Le compte rendu validé quitte la file d'attente au rechargement.
      setDetail(null);
      await reload();
      await runtime.refresh().catch(() => {});
      if (onValidate) onValidate(detail);
    } catch (failure) {
      setError(failure?.message || 'Validation refusée');
      if (failure?.status === 409) await openDetail(detail.id);
    } finally {
      setBusy(false);
    }
  };

  const chips = [
    { id: 'all', label: 'Tous' },
    { id: 'CRO', label: 'CRO' },
    { id: 'CRC', label: 'CRC' },
    { id: 'CRH', label: 'CRH' },
  ];

  return (
    <div style={{ position: 'absolute', inset: 0, zIndex: 60 }}>
      <div onClick={onClose} style={{
        position: 'absolute', inset: 0, background: 'rgba(10,10,10,0.3)',
      }} />
      <div style={{
        position: 'absolute', bottom: 0, left: 0, right: 0,
        background: SURF, borderRadius: '22px 22px 0 0',
        boxShadow: VT.shadowSheet || '0 -8px 32px rgba(10,10,10,0.12)',
        // Hauteur plancher calée sur « Tous » : la feuille ne saute plus d'un
        // filtre à l'autre, et le contenu reste au-dessus de la bande du toast.
        minHeight: '46%', maxHeight: '78%',
        display: 'flex', flexDirection: 'column',
        animation: 'crSheetUp .28s cubic-bezier(.2,.7,.2,1)',
        paddingBottom: 34,
      }}>
        <div style={{
          width: 36, height: 4, borderRadius: 2, background: HAIR_2,
          margin: '8px auto 4px', flexShrink: 0,
        }} />
        <div style={{
          padding: '8px 20px 12px', flexShrink: 0,
          display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12,
        }}>
          <div>
            <div style={{ fontSize: 17, fontWeight: 700, color: INK, letterSpacing: '-0.3px' }}>
              Comptes rendus à valider
            </div>
            <div style={{ fontSize: 12.5, fontWeight: 500, color: SUB, marginTop: 2 }}>
              {loading ? 'Chargement…' : `${shown.length} en attente${filter === 'all' ? '' : ` · ${filter}`}`}
            </div>
          </div>
          <button onClick={onClose} className="press" aria-label="Fermer" style={{
            background: 'none', border: 'none', cursor: 'pointer', padding: 0, flexShrink: 0,
          }}>
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={SUB}
              strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
              <path d="M18 6L6 18M6 6l12 12" />
            </svg>
          </button>
        </div>

        <div style={{
          display: 'flex', gap: 6, padding: '0 20px 12px', flexShrink: 0,
        }}>
          {chips.map((c) => {
            const on = c.id === filter;
            return (
              <button key={c.id} onClick={() => onChangeFilter && onChangeFilter(c.id)}
                className="press" style={{
                  background: on ? INK : PAPER,
                  color: on ? '#fff' : INK,
                  border: 'none', borderRadius: 999, padding: '7px 13px',
                  cursor: 'pointer', fontFamily: 'inherit',
                  fontSize: 12.5, fontWeight: 600, letterSpacing: '0.1px',
                  display: 'inline-flex', alignItems: 'center', gap: 6,
                }}>
                {c.label}
                <span className="mono" style={{
                  fontSize: 10, fontWeight: 500, opacity: on ? 0.7 : 0.5,
                }}>{counts[c.id]}</span>
              </button>);
          })}
        </div>

        <DbBanner text={error} />

        <div className="vasco-scroll" style={{
          flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 20px 8px',
        }}>
          {/* Relecture : le compte rendu est affiché en entier avant que le
              bouton de validation n'apparaisse. */}
          {detail ? (
            <div>
              <button onClick={() => setDetail(null)} className="press" style={{
                background: 'none', border: 'none', cursor: 'pointer', padding: 0,
                fontFamily: 'inherit', fontSize: 12, fontWeight: 600, color: SUB, marginBottom: 10,
              }}>← Retour à la file</button>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
                <span className="mono" style={{
                  fontSize: 10.5, fontWeight: 700, color: MED, background: MED_TINT,
                  padding: '4px 8px', borderRadius: 999,
                }}>{detail.document_type}</span>
                <span style={{ fontSize: 13.5, fontWeight: 700, color: INK }}>{detail.patient.display_name}</span>
              </div>
              <div style={{ fontSize: 13, fontWeight: 600, color: INK }}>{detail.title}</div>
              <div className="mono" style={{ fontSize: 10.5, fontWeight: 500, color: SUB, marginTop: 2 }}>
                {detail.author.display_name} · version {detail.version}
              </div>
              <div style={{
                marginTop: 10, padding: '12px 14px', borderRadius: 14,
                border: `1px solid ${HAIR}`, background: PAPER,
                fontSize: 13, lineHeight: 1.5, color: INK, whiteSpace: 'pre-wrap',
              }}>
                {detail.content || 'Contenu indisponible : la validation est impossible.'}
              </div>
              {detail.capabilities?.validate?.enabled && (!capability || capability.enabled) ? (
                <button onClick={() => void validateDetail()} disabled={busy || !detail.content_sha256}
                  className="press" style={{
                    marginTop: 12, width: '100%', height: 42, borderRadius: 14,
                    background: busy ? 'rgba(10,10,10,0.06)' : INK, border: 'none',
                    color: busy ? SUB : '#fff', fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
                    cursor: busy ? 'default' : 'pointer',
                  }}>{busy ? 'Validation…' : 'Valider ce compte rendu'}</button>
              ) : (
                <div style={{ marginTop: 12, fontSize: 12, fontWeight: 600, color: DANGER }}>
                  {dbReason(capability && !capability.enabled ? capability : detail.capabilities?.validate)}
                </div>
              )}
            </div>
          ) : (<React.Fragment>
          {loading && <SkeletonCRRows count={Math.min(3, Math.max(1, shown.length)) || 1} />}
          {!loading && shown.length === 0 && (
            <div style={{ textAlign: 'center', padding: '28px 0', fontSize: 13, color: SUB }}>
              {counts.all === 0
                ? 'Tout est validé.'
                : `Aucun ${filter} en attente.`}
            </div>
          )}
          {!loading && shown.map((r) => (
            <div key={r.id} style={{
              display: 'flex', alignItems: 'center', gap: 12,
              padding: '12px 14px', marginBottom: 8,
              border: `1px solid ${HAIR}`, borderRadius: 16,
            }}>
              <span className="mono" style={{
                fontSize: 10.5, fontWeight: 700, color: MED, background: MED_TINT,
                padding: '4px 8px', borderRadius: 999, flexShrink: 0,
              }}>{r.type}</span>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{
                  fontSize: 13.5, fontWeight: 600, color: INK,
                  whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                }}>{r.procedure}</div>
                <div className="mono" style={{
                  fontSize: 11, fontWeight: 500, color: SUB, marginTop: 2,
                  whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                }}>{r.patient} · {r.started}</div>
              </div>
              {/* La maquette validait en un clic, sans que le contenu ait été
                  affiché. L'API l'interdit : elle réclame l'empreinte du texte
                  relu. Le bouton ouvre donc la relecture. */}
              <button onClick={() => void openDetail(r.id)}
                disabled={busy || !runtime || r.capabilities?.read?.enabled === false}
                title={r.capabilities?.read?.enabled === false ? dbReason(r.capabilities.read) : 'Relire avant validation'}
                className="press" style={{
                  flexShrink: 0, height: VT.hSm, padding: '0 12px', borderRadius: 999,
                  background: INK, border: 'none', color: '#fff',
                  fontFamily: 'inherit', fontSize: 12, fontWeight: 700,
                  cursor: busy || !runtime ? 'default' : 'pointer',
                  opacity: busy || !runtime ? 0.5 : 1,
                }}>Relire</button>
            </div>
          ))}
          </React.Fragment>)}
        </div>

        <style>{`@keyframes crSheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }`}</style>
      </div>
    </div>
  );
}

// La feuille de validation est remontée au Dashboard : les chips l'ouvrent
// aussi, on ne peut donc plus la garder dans l'état du carrousel.
// Sparkline des CR dictés — barres, pas de courbe : sur 40px de haut une
// courbe à 6 points est illisible, des barres se lisent d'un coup d'œil.
function StatSpark({ data, accent }) {
  const max = Math.max(...data, 1);
  return (
    <div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 34 }}>
      {data.map((v, i) => (
        <div key={i} style={{
          width: 6, borderRadius: 3,
          height: `${Math.max(14, (v / max) * 100)}%`,
          background: i === data.length - 1 ? accent : 'rgba(10,10,10,0.14)',
        }} />
      ))}
    </div>
  );
}

function SmartStack({ accent, paused, onResume, onOpenAgenda, pending = [], onOpenCR, onOpenStats }) {
  const [activeIdx, setActiveIdx] = React.useState(0);
  const [showCCAMSheet, setShowCCAMSheet] = React.useState(false);
  const startX = React.useRef(null);
  const [sliding, setSliding] = React.useState(null); // 'left' | 'right' | null

  const dashboardPayload = window.VASCO_RUNTIME && window.VASCO_RUNTIME.dashboard;
  const timeZone = dashboardPayload?.time_zone || 'Europe/Paris';
  const ccamCapability = dashboardPayload?.capabilities?.search_ccam || null;

  // Le prochain rendez-vous était écrit en dur dans la maquette (« 14:30,
  // M. Martin, Salle 2 ») : la carte annonçait donc un rendez-vous qui
  // n'existait pas. Elle lit maintenant `next_appointment`, et disparaît quand
  // le serveur n'en renvoie aucun.
  const nextAppointment = dashboardPayload?.next_appointment || null;
  const nextRdv = nextAppointment ? {
    time: new Intl.DateTimeFormat('fr-FR', {
      hour: '2-digit', minute: '2-digit', hour12: false, timeZone,
    }).format(new Date(nextAppointment.starts_at)),
    patient: nextAppointment.patient.display_name,
    // Archiver n'annule pas le rendez-vous : il reste le prochain, et la carte
    // le dit plutôt que d'annoncer un patient dont le dossier est rangé.
    archived: nextAppointment.patient.archived === true,
    lieu: nextAppointment.location || nextAppointment.reason,
    startsAt: new Date(nextAppointment.starts_at),
  } : null;

  // La charge vient du serveur quand il la calcule ; sinon elle reste dérivée
  // de l'agenda affiché, jamais d'un second jeu de chiffres inventé.
  const dayLoad = dashboardPayload?.day_load || null;
  const charge = dayLoad ? {
    consult: dayLoad.consultations, interv: dayLoad.procedures, urgence: dayLoad.emergencies,
  } : AGENDA_TODAY.reduce((acc, e) => {
    if (e.kind === 'CONSULTATION') acc.consult++;
    else if (e.kind === 'BLOC') acc.interv++;
    else if (e.kind === 'URGENCE') acc.urgence++;
    return acc;
  }, { consult: 0, interv: 0, urgence: 0 });

  const diffMin = nextRdv ? Math.round((nextRdv.startsAt.getTime() - Date.now()) / 60000) : 0;
  const countdown = !nextRdv ? '—'
    : diffMin > 0 ? `dans ${Math.floor(diffMin / 60)}h${String(diffMin % 60).padStart(2, '0')}`
    : 'maintenant';

  // Build cards array
  const cards = [];
  // « À valider » en tête : c'est l'action la plus attendue en ouvrant l'app.
  if (pending.length > 0) cards.push('cr');
  if (paused) cards.push('pause');
  if (nextRdv) cards.push('rdv');
  cards.push('charge');
  cards.push('stats');
  cards.push('ccam');
  const total = cards.length;

  // Les bornes sont appliquées au moment où l'index change, pas au moment du geste :
  // la garde lisait `activeIdx` figé dans la fermeture du gestionnaire, si bien qu'un
  // geste de trop sortait de la pile et affichait une carte vide.
  const goNext = () => {
    setSliding('left');
    setTimeout(() => {
      setActiveIdx((i) => Math.min(i + 1, total - 1));
      setSliding(null);
    }, 250);
  };
  const goPrev = () => {
    setSliding('right');
    setTimeout(() => {
      setActiveIdx((i) => Math.max(i - 1, 0));
      setSliding(null);
    }, 250);
  };

  const onTouchStart = (e) => { startX.current = e.touches[0].clientX; };
  const onTouchEnd = (e) => {
    if (startX.current == null) return;
    const dx = e.changedTouches[0].clientX - startX.current;
    startX.current = null;
    if (dx < -50) goNext();
    else if (dx > 50) goPrev();
  };
  // Mouse drag
  const onMouseDown = (e) => { startX.current = e.clientX; };
  const onMouseUp = (e) => {
    if (startX.current == null) return;
    const dx = e.clientX - startX.current;
    startX.current = null;
    if (dx < -50) goNext();
    else if (dx > 50) goPrev();
  };

  // Mouse wheel — scroll horizontally through cards
  const wheelTimer = React.useRef(null);
  const stackRef = React.useRef(null);
  React.useEffect(() => {
    const el = stackRef.current;
    if (!el) return;
    const handler = (e) => {
      e.preventDefault();
      if (wheelTimer.current) return;
      if (Math.abs(e.deltaY) > 10 || Math.abs(e.deltaX) > 10) {
        const dir = (e.deltaY > 0 || e.deltaX > 0) ? 1 : -1;
        if (dir > 0) goNext(); else goPrev();
        wheelTimer.current = setTimeout(() => { wheelTimer.current = null; }, 400);
      }
    };
    el.addEventListener('wheel', handler, { passive: false });
    return () => el.removeEventListener('wheel', handler);
  }, [goNext, goPrev]);

  // Animation style
  let contentStyle = { transition: 'all .25s cubic-bezier(.25,.1,.25,1)' };
  if (sliding === 'left') contentStyle = { ...contentStyle, transform: 'translateX(-30px)', opacity: 0 };
  else if (sliding === 'right') contentStyle = { ...contentStyle, transform: 'translateX(30px)', opacity: 0 };
  else contentStyle = { ...contentStyle, transform: 'translateX(0)', opacity: 1 };

  // La pile se raccourcit toute seule : la carte « à valider » disparaît quand la file
  // se vide, celle du prochain rendez-vous quand l'heure est passée. Sans ce recadrage,
  // l'index resté au-delà de la dernière carte affichait une carte vide — un rectangle
  // blanc sans un mot, à la place du tableau de bord.
  React.useEffect(() => {
    setActiveIdx((index) => (index > total - 1 ? Math.max(0, total - 1) : index));
  }, [total]);

  const currentCard = cards[Math.max(0, Math.min(activeIdx, total - 1))];

  return (
    <div style={{ padding: '8px 12px 10px', background: PAPER }}>
      <div ref={stackRef}
        onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}
        onMouseDown={onMouseDown} onMouseUp={onMouseUp}
        style={{
          background: SURF, borderRadius: 16,
          boxShadow: VT.shadowCard,
          padding: '14px 16px',
          position: 'relative', minHeight: 80,
          overflow: 'hidden', cursor: 'grab', userSelect: 'none',
        }}>

        {/* Colored left bar */}
        <span style={{
          position: 'absolute', left: 0, top: 14, bottom: 14, width: 3,
          background: accent,
          borderRadius: '0 3px 3px 0',
          transition: 'background .25s ease',
        }} />

        {/* Content — animates on swipe */}
        <div style={contentStyle}>
          {currentCard === 'cr' && (
            <div onClick={() => onOpenCR && onOpenCR('all')} style={{ cursor: 'pointer' }}>
              <Caption color={SUB} style={{ fontSize: 10 }}>COMPTES RENDUS À VALIDER</Caption>
              <div style={{
                marginTop: 6, display: 'flex', alignItems: 'center',
                justifyContent: 'space-between', gap: 12,
              }}>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
                  <span className="digit" style={{
                    fontSize: 22, fontWeight: 700, color: INK,
                    letterSpacing: '-0.6px', lineHeight: 1,
                  }}>{pending.length}</span>
                  <span style={{
                    fontSize: 12.5, fontWeight: 500, color: SUB,
                    whiteSpace: 'nowrap',
                  }}>en attente</span>
                </div>
                <span style={{
                  flexShrink: 0, height: VT.hSm, padding: '0 12px', borderRadius: 999,
                  background: MED, color: '#fff',
                  display: 'flex', alignItems: 'center',
                  fontSize: 12, fontWeight: 700,
                }}>Valider maintenant</span>
              </div>
            </div>
          )}

          {currentCard === 'pause' && paused && (
            <div onClick={onResume} style={{ cursor: 'pointer' }}>
              <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
                <Caption color={accent} style={{ fontSize: 10 }}>EN PAUSE · REPRENDRE</Caption>
                <span className="mono" style={{ fontSize: 10.5, fontWeight: 500, color: SUB }}>{paused.duration}</span>
              </div>
              <div style={{ marginTop: 6, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
                <div style={{ minWidth: 0 }}>
                  <div style={{ fontSize: 14.5, fontWeight: 600, color: INK, letterSpacing: '-0.2px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                    {paused.procedure}
                  </div>
                  <div style={{ fontSize: 12.5, fontWeight: 500, color: SUB, marginTop: 2 }}>
                    {paused.patient} <span className="mono" style={{ color: SUB, opacity: .65 }}> · {paused.started}</span>
                  </div>
                </div>
                <span style={{
                  width: 28, height: 28, borderRadius: 999, background: accent,
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                  color: '#fff', fontSize: 14, fontWeight: 600, flexShrink: 0,
                }}>→</span>
              </div>
            </div>
          )}

          {currentCard === 'rdv' && nextRdv && (
            <div onClick={onOpenAgenda} style={{ cursor: 'pointer' }}>
              <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
                <Caption color={accent} style={{ fontSize: 10 }}>PROCHAIN RDV</Caption>
                <span className="mono" style={{ fontSize: 10.5, fontWeight: 600, color: accent }}>{countdown}</span>
              </div>
              <div style={{ marginTop: 6, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
                <div style={{ minWidth: 0 }}>
                  <span className="mono" style={{ fontSize: 20, fontWeight: 800, color: INK, letterSpacing: '-0.5px' }}>{nextRdv.time}</span>
                  <div style={{ fontSize: 13, fontWeight: 500, color: SUB, marginTop: 2 }}>
                    {nextRdv.patient} · {nextRdv.lieu}
                  </div>
                  {nextRdv.archived && (
                    <div style={{ fontSize: 12, fontWeight: 600, color: SUB, lineHeight: 1.4, marginTop: 2 }}>
                      Patient archivé
                    </div>
                  )}
                </div>
                <div style={{
                  width: 28, height: 28, borderRadius: 999, background: 'rgba(10,10,10,0.04)',
                  display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
                }}>
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                    <rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>
                  </svg>
                </div>
              </div>
            </div>
          )}

          {currentCard === 'stats' && (
            <div onClick={onOpenStats} style={{ cursor: 'pointer' }}>
              <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
                <Caption color={accent} style={{ fontSize: 10 }}>MON ACTIVITÉ</Caption>
                <span className="mono" style={{ fontSize: 10.5, fontWeight: 500, color: SUB }}>
                  {ACTIVITE.periode}
                </span>
              </div>
              <div style={{
                marginTop: 8, display: 'flex', alignItems: 'flex-end',
                justifyContent: 'space-between', gap: 14,
              }}>
                <div style={{ display: 'flex', alignItems: 'flex-end', gap: 20, minWidth: 0 }}>
                  {['crs', 'rdvs'].map((k) => {
                    const kpi = ACTIVITE.kpis.find((x) => x.key === k);
                    return (
                      <div key={k} style={{ minWidth: 0 }}>
                        <div style={{ display: 'flex', alignItems: 'baseline', gap: 5 }}>
                          <span className="digit" style={{
                            fontSize: 22, fontWeight: 700, color: INK,
                            letterSpacing: '-0.6px', lineHeight: 1,
                          }}>{kpi.value}</span>
                          <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: OK }}>
                            {kpi.delta}
                          </span>
                        </div>
                        <div style={{ fontSize: 11.5, fontWeight: 500, color: SUB, marginTop: 3, whiteSpace: 'nowrap' }}>
                          {kpi.label.toLowerCase()}
                        </div>
                      </div>
                    );
                  })}
                </div>
                <StatSpark data={ACTIVITE.crsParMois} accent={accent} />
              </div>
            </div>
          )}

          {currentCard === 'charge' && (
            <div onClick={onOpenAgenda} style={{ cursor: 'pointer' }}>
              <Caption color={accent} style={{ fontSize: 10 }}>CHARGE DU JOUR</Caption>
              <div style={{ marginTop: 8, display: 'flex', alignItems: 'flex-end', gap: 22 }}>
                {[
                  // Le serveur compte ensemble les consultations et les suivis : dire
                  // « consultations » seul annonçait six consultations là où l'agenda
                  // du même jour en montre trois, plus trois suivis.
                  { n: charge.consult, label: 'consult. et suivis' },
                  { n: charge.interv, label: charge.interv > 1 ? 'interventions' : 'intervention' },
                  { n: charge.urgence, label: charge.urgence > 1 ? 'urgences' : 'urgence', danger: true },
                ].map((s) => (
                  <div key={s.label}>
                    <span className="digit" style={{
                      fontSize: 22, fontWeight: 700, letterSpacing: '-0.6px', lineHeight: 1,
                      color: s.danger && s.n > 0 ? DANGER : INK,
                    }}>{s.n}</span>
                    <div style={{ fontSize: 11.5, fontWeight: 500, color: SUB, marginTop: 3 }}>
                      {s.label}
                    </div>
                  </div>
                ))}
              </div>
            </div>
          )}

          {currentCard === 'ccam' && (
            <div>
              <Caption color={accent} style={{ fontSize: 10 }}>RECHERCHE CCAM</Caption>
              {/* Le référentiel CCAM n'est pas raccordé : la maquette cherchait
                  dans une table de six actes écrite dans le fichier, ce qui
                  ressemblait à une vraie recherche. La carte affiche désormais
                  le motif renvoyé par le serveur au lieu de faux résultats. */}
              {ccamCapability && !ccamCapability.enabled ? (
                <div style={{
                  marginTop: 8, background: PAPER, borderRadius: 12, padding: '10px 12px',
                  fontSize: 12.5, fontWeight: 600, color: SUB,
                }}>{dbReason(ccamCapability)}</div>
              ) : (
                <div onClick={() => setShowCCAMSheet(true)} style={{
                  marginTop: 8, display: 'flex', alignItems: 'center', gap: 10,
                  background: PAPER, borderRadius: 12, padding: '10px 12px',
                  cursor: 'pointer',
                }}>
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                    <circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>
                  </svg>
                  <span style={{ fontSize: 13, fontWeight: 500, color: SUB }}>Code, acte, catégorie…</span>
                </div>
              )}
            </div>
          )}
        </div>
      </div>

      {/* Dots */}
      <div style={{ display: 'flex', justifyContent: 'center', gap: 5, marginTop: 8 }}>
        {cards.map((_, i) => (
          <div key={i} style={{
            width: activeIdx === i ? 16 : 5, height: 5, borderRadius: 3,
            background: activeIdx === i ? INK : 'rgba(10,10,10,0.12)',
            transition: 'all .25s ease',
          }} />
        ))}
      </div>

      {showCCAMSheet && <CCAMBottomSheet onClose={() => setShowCCAMSheet(false)} />}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Tab bar — 64px, 18×2 accent indicator under active icon,
// labels uppercase 9px tracking 0.6
// ─────────────────────────────────────────────────────────────
const TAB_ICONS = {
  DICT: (s) => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={s} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
    <rect x="9" y="3" width="6" height="12" rx="3" /><path d="M5 11a7 7 0 0 0 14 0" /><path d="M12 18v3" />
  </svg>,
  PAT: (s) => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={s} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
    <circle cx="12" cy="8" r="3.4" /><path d="M5 20c1.5-3.6 4.2-5.2 7-5.2s5.5 1.6 7 5.2" />
  </svg>,
  AGE: (s) => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={s} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
    <rect x="3.5" y="5" width="17" height="15" rx="2" /><path d="M3.5 10h17" /><path d="M8 3v4M16 3v4" />
  </svg>,
  IA: (s) => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={s} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
    <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 10h.01M12 10h.01M16 10h.01"/>
  </svg>,
  MOI: (s) => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={s} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
    <circle cx="12" cy="8" r="3.4" /><path d="M5 20c1.5-3.6 4.2-5.2 7-5.2s5.5 1.6 7 5.2" />
  </svg>
};

function TabBar({ active, onChange, accent }) {
  const items = [
  { id: 'DICT', label: 'Dictée' },
  { id: 'PAT',  label: 'Patients' },
  { id: 'AGE',  label: 'Agenda' },
  { id: 'IA',   label: 'Assistant' },
  { id: 'MOI',  label: 'Compte' }];

  return (
    <div style={{
      background: PAPER,
      paddingBottom: 14, // home indicator clearance
      paddingTop: 6,
      boxShadow: '0 -1px 0 rgba(10,10,10,0.04)'
    }}>
      <div style={{
        height: 60, display: 'grid', gridTemplateColumns: 'repeat(5,1fr)'
      }}>
        {items.map((it) => {
          const on = it.id === active;
          const stroke = on ? INK : SUB;
          return (
            <button key={it.id} onClick={() => onChange(it.id)} className="press"
            style={{
              background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
              display: 'flex', flexDirection: 'column', alignItems: 'center',
              justifyContent: 'center', gap: 4, position: 'relative'
            }}>
              <span style={{
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                width: 44, height: 28, borderRadius: 16,
                background: on ? `${accent}14` : 'transparent',
                transition: 'background .15s ease'
              }}>
                {TAB_ICONS[it.id](on ? accent : stroke)}
              </span>
              <span style={{
                fontSize: 10.5, fontWeight: 600, letterSpacing: '-0.1px',
                color: on ? accent : stroke
              }}>{it.label}</span>
            </button>);

        })}
      </div>
    </div>);

}

// ─────────────────────────────────────────────────────────────
// CCAM Bottom Sheet
// ─────────────────────────────────────────────────────────────
// L'API date en ISO. On reformate sans `new Date()` : une date nue interprétée comme un
// instant UTC recule d'un jour à l'affichage pour un fuseau à l'est de Greenwich.
function frenchDate(iso) {
  const parts = String(iso || '').split('-');
  return parts.length === 3 ? `${parts[2]}/${parts[1]}/${parts[0]}` : String(iso || '');
}

function CCAMBottomSheet({ onClose }) {
  const [query, setQuery] = React.useState('');
  const [copied, setCopied] = React.useState(null);
  // La maquette filtrait quinze actes écrits dans ce fichier, dont les libellés et les
  // tarifs étaient faux sous des codes réels. La recherche interroge maintenant le
  // référentiel officiel importé en base, et il n'y a pas de repli local : tant qu'aucune
  // requête n'a abouti, la feuille n'affiche rien plutôt qu'un extrait inventé.
  const [results, setResults] = React.useState([]);
  const [reference, setReference] = React.useState(null);
  const [state, setState] = React.useState('idle');

  React.useEffect(() => {
    const term = query.trim();
    if (!term) { setResults([]); setState('idle'); return undefined; }
    let cancelled = false;
    setState('loading');
    // Le référentiel compte 8292 actes : on attend que la frappe se pose avant de
    // demander, sinon chaque lettre déclenche une recherche qui sera jetée.
    const handle = setTimeout(() => {
      window.VascoApi.ccam.search(term).then((payload) => {
        if (cancelled) return;
        setResults(payload.items);
        setReference(payload.referenceVersion ? payload : null);
        setState(payload.items.length ? 'ready' : 'empty');
      }).catch((error) => {
        if (cancelled) return;
        setResults([]);
        // L'API rend `detail` en chaîne brute pour ce refus, donc `code` est nul :
        // c'est le statut qui porte l'information, pas le corps.
        setState(error?.status === 503 ? 'unavailable' : 'error');
      });
    }, 250);
    return () => { cancelled = true; clearTimeout(handle); };
  }, [query]);

  // « Copié ✓ » ne s'affichait qu'à la faveur d'un `setTimeout` : rien n'était
  // jamais écrit au presse-papiers. La confirmation attend maintenant que
  // l'écriture ait abouti, comme la copie d'IPP du répertoire patients, et un
  // refus du navigateur se dit au lieu de se déguiser en réussite.
  const handleCopy = async (code) => {
    try {
      await copyToClipboard(code);
      setCopied(code);
      setTimeout(() => setCopied(null), 1500);
    } catch {
      showToast('Copie impossible — presse-papier refusé', 'error');
    }
  };

  return (
    <div style={{ position: 'absolute', inset: 0, zIndex: 50 }}>
      <div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(10,10,10,0.3)' }} />
      <div style={{
        position: 'absolute', bottom: 0, left: 0, right: 0,
        background: PAPER, borderRadius: '24px 24px 0 0',
        maxHeight: '85%', display: 'flex', flexDirection: 'column',
        animation: 'ccamSheetUp .3s cubic-bezier(.2,.7,.2,1)',
        boxShadow: VT.shadowSheet,
      }}>
        <style>{`@keyframes ccamSheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }`}</style>

        {/* Drag handle */}
        <div style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 6px' }}>
          <div style={{ width: 36, height: 4, borderRadius: 2, background: 'rgba(10,10,10,0.12)' }} />
        </div>

        {/* Header */}
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 20px 12px' }}>
          <div style={{ fontSize: 18, fontWeight: 800, color: INK, letterSpacing: '-0.3px' }}>Codes CCAM</div>
          <button onClick={onClose} className="press" style={{
            width: 30, height: 30, borderRadius: 12, background: 'rgba(10,10,10,0.04)',
            border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
          }}>
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
              <path d="M18 6L6 18M6 6l12 12"/>
            </svg>
          </button>
        </div>

        {/* Search */}
        <div style={{ padding: '0 20px 12px' }}>
          <div style={{
            display: 'flex', alignItems: 'center', gap: 10,
            background: SURF, borderRadius: 16, padding: '10px 14px',
            border: `1px solid ${HAIR_2}`,
          }}>
            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>
            </svg>
            <input value={query} onChange={e => setQuery(e.target.value)}
              placeholder="Acte, code, catégorie…"
              style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontFamily: 'inherit', fontSize: 14, fontWeight: 500, color: INK }} />
            {query && (
              <button onClick={() => setQuery('')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2 }}>
                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M18 6L6 18M6 6l12 12"/>
                </svg>
              </button>
            )}
          </div>
          {/* Quick suggestions */}
          {!query.trim() && (
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 10 }}>
              {['Pontage', 'Carotide', 'Fistule artérioveineuse', 'Endoprothèse', 'Saphène'].map(s => (
                <button key={s} onClick={() => setQuery(s)} className="press" style={{
                  padding: '5px 11px', borderRadius: 999,
                  background: SURF, border: `1px solid ${HAIR_2}`,
                  fontFamily: 'inherit', fontSize: 11, fontWeight: 600, color: INK, cursor: 'pointer',
                }}>{s}</button>
              ))}
            </div>
          )}
        </div>

        {/* Results */}
        <div className="vasco-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 20px 24px' }}>
          {results.length === 0 ? (
            <div style={{ textAlign: 'center', padding: '24px 0', color: SUB, fontSize: 13 }}>
              {state === 'idle' && 'Décrivez l’acte ou saisissez son code.'}
              {state === 'loading' && 'Recherche…'}
              {state === 'empty' && 'Aucun acte du référentiel ne correspond.'}
              {/* Ne jamais dire « aucun code trouvé » quand il n'y a pas de base :
                  ce serait affirmer que l'acte n'existe pas. */}
              {state === 'unavailable' && 'Référentiel CCAM non chargé sur ce serveur.'}
              {state === 'error' && 'La recherche a échoué. Réessayez.'}
            </div>
          ) : results.map((c, i, arr) => (
            <div key={c.code} className="press" onClick={() => void handleCopy(c.code)} style={{
              display: 'flex', alignItems: 'center', gap: 12,
              padding: '12px 0', cursor: 'pointer',
              borderBottom: i < arr.length - 1 ? `1px solid ${HAIR}` : 'none',
            }}>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                  <span className="mono" style={{
                    fontSize: 11, fontWeight: 700, color: INK,
                    letterSpacing: '0.3px', padding: '2px 8px', borderRadius: 8,
                    background: 'rgba(10,10,10,0.04)',
                  }}>{c.code}</span>
                  <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: SUB }}>{c.family}</span>
                </div>
                <div style={{
                  fontSize: 13, fontWeight: 500, color: INK, marginTop: 4,
                  overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                }}>{c.label}</div>
              </div>
              <div style={{ flexShrink: 0, textAlign: 'right' }}>
                {copied === c.code ? (
                  <span className="mono" style={{ fontSize: 11, fontWeight: 600, color: OK }}>Copié ✓</span>
                ) : (
                  <span className="mono" style={{ fontSize: 13, fontWeight: c.tariff ? 700 : 600, color: c.tariff ? INK : SUB }}>
                    {/* Pas de tarif opposable dans l'extrait : on l'écrit. Un 0 € en
                        ferait un acte gratuit, un tiret muet laisserait croire à un oubli. */}
                    {c.tariff || 'tarif non publié'}
                  </span>
                )}
              </div>
            </div>
          ))}
        </div>

        {/* Un code recopié sans la version sous laquelle il a été lu n'est plus
            vérifiable : la provenance reste affichée sous les résultats. */}
        {reference && (
          <div style={{
            padding: '10px 20px 16px', borderTop: `1px solid ${HAIR}`,
            fontSize: 11, fontWeight: 500, color: SUB,
          }}>
            Référentiel {reference.referenceVersion} — {reference.effectiveFrom
              ? `en vigueur depuis le ${frenchDate(reference.effectiveFrom)}`
              : `extrait du ${frenchDate(reference.referenceDate)}`}.
            Tarifs à vérifier avant facturation.
          </div>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Dashboard composition
// ─────────────────────────────────────────────────────────────
function Dashboard({ accent, accentName, onOpenChat, onChangeTab, onOpenPatient, onSlideToChat, onOpenStats, runtime = null, dictationRuntime = null }) {
  const runtimeDashboard = window.VASCO_RUNTIME && window.VASCO_RUNTIME.dashboard;
  const practitionerName = runtimeDashboard?.practitioner?.display_name || 'Dr.X';
  const patientsRemaining = runtimeDashboard?.summary?.patients_remaining ?? 12;
  const capabilities = runtimeDashboard?.capabilities || null;
  // Les aperçus du tableau de bord sont tronqués à cinq lignes, mais ils
  // portent le décompte complet : c'est lui que les pastilles affichent.
  const reminderCount = runtimeDashboard?.reminders_preview?.active_count ?? null;
  const notificationCount = runtimeDashboard?.notifications_preview?.active_count ?? null;
  const [tab, setTab] = React.useState('CRO');
  const [recording, setRecording] = React.useState(false);
  const [dictating, setDictating] = React.useState(false);
  const [reports, setReports] = React.useState(TODO_REPORTS);
  const [showCCAM, setShowCCAM] = React.useState(false);
  // null = feuille fermée ; sinon 'all' ou le type de CR affiché.
  const [crSheet, setCrSheet] = React.useState(null);
  // Une notification « compte rendu à valider » ouvre directement la relecture
  // du document visé, sans faire chercher la ligne dans la file.
  const [focusReportId, setFocusReportId] = React.useState(null);
  const bottomTab = 'DICT';
  const setBottomTab = (id) => {onChangeTab && onChangeTab(id);};

  const counts = {
    CRO: reports.filter(r => r.type === 'CRO').length,
    CRC: reports.filter(r => r.type === 'CRC').length,
    CRH: reports.filter(r => r.type === 'CRH').length,
  };
  const totalPending = counts.CRO + counts.CRC + counts.CRH;

  const tabs = [
  { id: 'CRO', label: 'CRO', count: counts.CRO },
  { id: 'CRC', label: 'CRC', count: counts.CRC },
  { id: 'CRH', label: 'CRH', count: counts.CRH }];


  const paused = reports.find((r) => r.state === 'paused');

  // Un tap sur une pastille ouvre la file de validation sur ce type.
  const openType = (type) => { setTab(type); setCrSheet(type); };

  // La feuille valide contre le serveur et se recharge elle-même ; il ne reste
  // ici qu'à retirer la ligne des compteurs de l'accueil.
  const validate = (document) => {
    setReports((prev) => prev.filter((x) => x.id !== document.id));
  };

  return (
    <div style={{
      width: '100%', height: '100%',
      background: PAPER, color: INK,
      display: 'flex', flexDirection: 'column',
      position: 'relative',
      fontFamily: "'Inter Tight', -apple-system, system-ui, sans-serif"
    }}>
      {/* Scrollable content above the tab bar */}
      {/* Top section */}
      <div style={{ paddingTop: 62 }}>
        <div style={{
          padding: '14px 20px 0',
          display: 'flex', alignItems: 'center', justifyContent: 'space-between'
        }}>
          <span className="mono" style={{
            fontSize: 11, fontWeight: 600, letterSpacing: '0.6px',
            textTransform: 'uppercase', color: INK
          }}>{DATE_TODAY.num} · {DATE_TODAY.dow}</span>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <RappelBell accent={accent} runtime={runtime} capability={capabilities?.manage_reminders}
              activeCount={reminderCount} />
            <NotifBell accent={accent}
              runtime={runtime}
              capability={capabilities?.open_notifications}
              dismissCapability={capabilities?.dismiss_notifications}
              activeCount={notificationCount}
              onNavigate={(route) => onChangeTab && onChangeTab(route)}
              onOpenPatient={(id) => onOpenPatient && onOpenPatient(id)}
              onOpenReport={(documentId) => { setFocusReportId(documentId); setCrSheet('all'); }} />
          </div>
        </div>

        <div style={{ padding: '14px 20px 4px' }}>
          <h1 style={{ margin: 0,
            fontSize: 38, fontWeight: 700, lineHeight: 1.0,
            letterSpacing: '-1.2px', color: INK
          }}>Bonjour,</h1>
          <h1 style={{
            margin: '2px 0 0',
            fontSize: 38, fontWeight: 700, lineHeight: 1.0,
            letterSpacing: '-1.2px', color: accent,
            fontStyle: 'italic'
          }}>{practitionerName}</h1>
        </div>

        <div style={{ padding: '10px 20px 14px', display: 'flex', alignItems: 'baseline', gap: 6 }}>
          {/* Le compteur ne porte que la journée en cours : il se vide le soir,
              et sans « aujourd'hui » un 0 se lit comme « plus aucun patient ». */}
          <span style={{ fontSize: 14, fontWeight: 600, color: INK }}>
            {patientsRemaining} patient{patientsRemaining > 1 ? 's' : ''} restant{patientsRemaining > 1 ? 's' : ''} aujourd’hui
          </span>
          <span style={{ fontSize: 14, fontWeight: 500, color: SUB }}>·</span>
          <span style={{ fontSize: 14, fontWeight: 600, color: INK }}>
            <span className="digit">{totalPending}</span> CR à finaliser
          </span>
        </div>

        <div style={{ marginTop: 14 }}>
          <UnderlineTabs tabs={tabs} active={tab} onChange={openType} />
        </div>
      </div>

      {/* Mic hero — centered between tabs and resume banner */}
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
        <MicHero accent={accent} recording={recording}
        onTap={() => {
          const capability = capabilities?.start_dictation;
          if (capability && !capability.enabled) { showToast(dbReason(capability), 'error'); return; }
          setDictating('full');
        }}
        onQuick={() => {
          const capability = capabilities?.start_quick_dictation;
          if (capability && !capability.enabled) { showToast(dbReason(capability), 'error'); return; }
          setDictating('quick');
        }} />
      </div>

      {/* Smart Stack — horizontal swipable widgets */}
      <SmartStack accent={accent} paused={paused}
        onResume={() => onSlideToChat ? onSlideToChat() : (onChangeTab && onChangeTab('IA'))}
        onOpenAgenda={() => onChangeTab && onChangeTab('AGE')}
        pending={reports}
        onOpenCR={(f) => setCrSheet(f)}
        onOpenStats={onOpenStats}
      />

      {crSheet && (
        <CRValidationSheet
          pending={reports}
          filter={crSheet}
          runtime={runtime}
          capability={capabilities?.validate_reports}
          focusReportId={focusReportId}
          onChangeFilter={(f) => { setFocusReportId(null); setCrSheet(f); if (f !== 'all') setTab(f); }}
          onValidate={validate}
          onClose={() => { setFocusReportId(null); setCrSheet(null); }} />
      )}

      {/* Tab bar */}
      <TabBar active={bottomTab} onChange={setBottomTab} accent={accent} />

      {/* Flux de dictée — le CR part dans le chat, on bascule sur l'assistant */}
      {dictating && (
        <DictationFlow
          mode={dictating}
          runtime={dictationRuntime}
          onClose={() => setDictating(false)}
          onSent={(conversation) => {
            void runtime?.refresh().catch(() => {});
            if (onChangeTab) onChangeTab('IA', conversation);
          }} />
      )}

    </div>);

}

window.Dashboard = Dashboard;
window.VascoDashboardFixtures = { RAPPEL_INIT, NOTIF_DATA };
