// Patient detail — 5 onglets + menu « Plus ».

const PD_VT = window.VT || {};
const PD_MED = PD_VT.medBlue || '#3d6b8f';
const PD_MED_TINT = PD_VT.medBlueTint || 'rgba(61,107,143,0.10)';
const PD_WARN = PD_VT.warning || '#d97706';

// 5 onglets max, ordonnés par fréquence d'usage. Les antécédents ont rejoint
// le Résumé, les statistiques sont passées dans le menu « Plus » : à 7 onglets
// la barre débordait et il fallait la faire défiler pour voir les derniers.
const TABS = [
  { id: 'resume',  label: 'Résumé' },
  { id: 'docs',    label: 'Documents' },
  { id: 'notes',   label: 'Notes' },
  { id: 'agenda',  label: 'Agenda' },
  { id: 'postop',  label: 'Post-op' },
];

// ─────────────────────────────────────────────────────────────
// Mini sparkline (CRs/month) for the stats card
// ─────────────────────────────────────────────────────────────
function MiniBars({ data, accent }) {
  const max = Math.max(...data, 1);
  return (
    <div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 36 }}>
      {data.map((v, i) => (
        <div key={i} style={{
          width: 6, borderRadius: 3,
          height: `${(v / max) * 100}%`,
          minHeight: 2,
          background: i === data.length - 1 ? accent : P_INK,
        }} />
      ))}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Tab : Résumé
// ─────────────────────────────────────────────────────────────
function TabResume({ p, accent, onOpenChat, onTab, runtime = null, onQrCode }) {
  const capabilities = p.capabilities || {};

  // Les capacités d'un dossier sont de simples booléens : le serveur ne joint
  // pas de code de motif comme le fait l'agenda. On ne nomme donc la cause que
  // là où on la connaît de source sûre — un patient décédé, un contact absent
  // sont les causes que le serveur retient — et l'on retombe sur une formule
  // générale ailleurs, plutôt que d'inventer un motif.
  const deceased = p.status === 'decede';
  const GENERIC_REFUSAL = 'Indisponible pour ce dossier';
  const callRefusal = deceased ? 'Patient décédé'
    : !p.phone ? 'Aucun numéro renseigné' : GENERIC_REFUSAL;
  const emailRefusal = deceased ? 'Patient décédé'
    : !p.email ? 'Aucune adresse renseignée' : GENERIC_REFUSAL;

  // Ouvrir une discussion crée une vraie conversation rattachée au dossier ;
  // la maquette basculait simplement d'écran.
  const openConversation = () => {
    if (capabilities.open_conversation === false) {
      showToast('Discussion indisponible pour ce dossier', 'error');
      return;
    }
    if (!runtime) { if (onOpenChat) onOpenChat(); return; }
    runtime.openConversation(p.id)
      .then((conversation) => { if (onOpenChat) onOpenChat(conversation); })
      .catch((failure) => showToast(failure?.message || 'Discussion impossible', 'error'));
  };

  return (
    <div>
      {/* Pinned alerts */}
      {p.pinned && p.pinned.length > 0 && (
        <div style={{ padding: '20px 24px 8px' }}>
          <div style={{
            display: 'flex', alignItems: 'center', justifyContent: 'space-between',
            marginBottom: 10,
          }}>
            <PCaption>Alertes épinglées</PCaption>
            <PCaption style={{ color: accent }}>· · ·</PCaption>
          </div>
          <div style={{ borderTop: `1px solid ${P_HAIR2}` }}>
            {p.pinned.map((t, i) => (
              <div key={i} style={{
                display: 'flex', gap: 10, padding: '12px 0',
                borderBottom: i === p.pinned.length - 1 ? 'none' : `1px solid ${P_HAIR}`,
              }}>
                <div style={{
                  width: 4, alignSelf: 'stretch', flexShrink: 0,
                  background: accent, marginTop: 4,
                }} />
                <div className="serif" style={{
                  fontSize: 15, lineHeight: 1.45, color: P_INK, fontWeight: 500,
                }}>{t}</div>
              </div>
            ))}
          </div>
        </div>
      )}

      {/* Next RDV */}
      {p.nextRdv && (
        <div style={{ padding: '8px 24px 16px' }}>
          <PCaption>Prochain RDV</PCaption>
          <div style={{
            marginTop: 10, padding: '14px 16px',
            border: `1px solid ${P_HAIR2}`, borderRadius: 16,
            display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
          }}>
            <div>
              <div className="mono" style={{ fontSize: 11, fontWeight: 600, color: accent, letterSpacing: '0.5px' }}>
                {p.nextRdv.when}
              </div>
              <div style={{
                fontSize: 16, fontWeight: 600, color: P_INK, marginTop: 4,
                letterSpacing: '-0.2px',
              }}>{p.nextRdv.what}</div>
            </div>
            <button onClick={() => onTab('agenda')} className="press"
              style={{
                background: 'none', border: 'none', padding: 0, cursor: 'pointer',
                color: P_INK,
              }}>
              <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={P_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                <path d="M9 6l6 6-6 6" />
              </svg>
            </button>
          </div>
        </div>
      )}

      {/* Latest documents */}
      <div style={{ padding: '8px 24px 16px' }}>
        <div style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          marginBottom: 10,
        }}>
          <PCaption>Derniers comptes rendus</PCaption>
          <button onClick={() => onTab('docs')} className="press"
            style={{
              background: 'none', border: 'none', padding: 0, cursor: 'pointer',
              fontFamily: 'inherit', fontSize: 11, fontWeight: 600,
              color: P_SUB, letterSpacing: '0.3px',
            }}>TOUT VOIR ({p.documents?.length || 0})</button>
        </div>
        <div style={{ borderTop: `1px solid ${P_HAIR2}` }}>
          {(p.documents || []).slice(0, 3).map((d, i, arr) => (
            <div key={i} style={{
              display: 'flex', alignItems: 'flex-start', gap: 10,
              padding: '12px 0',
              borderBottom: i === arr.length - 1 ? `1px solid ${P_HAIR}` : `1px solid ${P_HAIR}`,
            }}>
              <span className="mono" style={{
                fontSize: 9.5, fontWeight: 600, color: P_INK,
                letterSpacing: '0.5px', padding: '2px 8px', borderRadius: 6,
                border: `1px solid ${P_HAIR2}`, borderRadius: 16, marginTop: 2,
                background: d.tag === 'live' ? PD_WARN : 'transparent',
                color: d.tag === 'live' ? '#fff' : P_INK,
                borderColor: d.tag === 'live' ? PD_WARN : P_HAIR2,
              }}>{d.type}</span>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{
                  fontSize: 14, fontWeight: 500, color: P_INK,
                  letterSpacing: '-0.1px',
                }}>{d.label}</div>
                <div className="mono" style={{
                  fontSize: 10.5, fontWeight: 500, color: P_SUB,
                  letterSpacing: '0.3px', marginTop: 2,
                }}>{d.d}{d.tag === 'live' && <> · EN COURS</>}</div>
              </div>
            </div>
          ))}
        </div>
      </div>

      {/* Quick clinical glance */}
      <div style={{ padding: '8px 24px 16px' }}>
        <PCaption>Clinique</PCaption>
        <div style={{
          marginTop: 10,
          border: `1px solid ${P_HAIR2}`, borderRadius: 16,
        }}>
          {[
            ['Groupe',     p.blood || '—'],
            ['Allergies',  (p.allergies && p.allergies.length) ? p.allergies.join(', ') : 'Aucune connue'],
            ['Traitements', (p.treatments && p.treatments.length) ? `${p.treatments.length} en cours` : '—'],
            ['Médecin tt.', p.mt || '—'],
          ].map(([k, v], i, arr) => (
            <div key={k} style={{
              display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12,
              padding: '11px 14px',
              borderBottom: i === arr.length - 1 ? 'none' : `1px solid ${P_HAIR}`,
            }}>
              <PCaption>{k}</PCaption>
              <div style={{
                fontSize: 13.5, fontWeight: 500, color: P_INK,
                textAlign: 'right', maxWidth: '60%',
                whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
              }}>{v}</div>
            </div>
          ))}
        </div>
      </div>

      {/* Antécédents — rapatriés depuis leur ancien onglet. Le bloc « Clinique »
          ci-dessus résume déjà groupe / allergies / nombre de traitements ;
          on ne remet donc ici que le détail qui n'y figure pas. */}
      <AtcdSections p={p} accent={accent} />

      {/* Quick actions */}
      <div style={{ padding: '8px 24px 32px' }}>
        <PCaption>Actions</PCaption>
        {/* Appeler et écrire sont tracés côté serveur avant que le lien ne
            s'ouvre. Le QR code est dessiné par le serveur, à partir du seul
            IPP. Imprimer compose la feuille depuis ce dossier et la sort sur
            l'imprimante du poste. */}
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginTop: 10 }}>
          <QAction label="Appeler" disabled={capabilities.call === false} reason={callRefusal}
            onClick={() => {
              if (capabilities.call === false) { showToast('Appel indisponible pour ce dossier', 'error'); return; }
              if (!runtime) { requestCall(`${p.last} ${p.first}`, p.phone); return; }
              runtime.authorizeAction(p.id, 'call')
                .then(() => requestCall(`${p.last} ${p.first}`, p.phone))
                .catch((failure) => showToast(failure?.message || 'Appel impossible', 'error'));
            }} icon={
            <path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.7A2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7 12.8 12.8 0 0 0 .7 2.8 2 2 0 0 1-.5 2.1L8 9.9a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.5 12.8 12.8 0 0 0 2.8.7A2 2 0 0 1 22 16.9z"/>
          } />
          <QAction label="Email" disabled={capabilities.email === false} reason={emailRefusal}
            onClick={() => {
              if (capabilities.email === false) { showToast('Courriel indisponible pour ce dossier', 'error'); return; }
              if (!runtime) { showToast(p.email ? `Courriel à ${p.email}` : 'Aucune adresse renseignée', p.email ? 'info' : 'error'); return; }
              runtime.authorizeAction(p.id, 'email')
                .then(() => { window.location.href = `mailto:${p.email}`; })
                .catch((failure) => showToast(failure?.message || 'Courriel impossible', 'error'));
            }} icon={
            <><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 7l-10 7L2 7"/></>
          } />
          <QAction label="QR code" disabled={capabilities.qr !== true} reason={GENERIC_REFUSAL}
            onClick={() => {
              if (capabilities.qr !== true) { showToast('QR code indisponible pour ce dossier', 'error'); return; }
              if (onQrCode) onQrCode();
            }} icon={
            <><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><path d="M14 14h3v3h-3zM20 14h1M14 20h7M20 17h1"/></>
          } />
          <QAction label="Imprimer" disabled={capabilities.print === false} reason={GENERIC_REFUSAL}
            onClick={() => {
              if (capabilities.print === false) { showToast('Impression indisponible pour ce dossier', 'error'); return; }
              printPatientRecord(p, runtime)
                .catch((failure) => showToast(failure?.message || 'Impression impossible', 'error'));
            }} icon={
            <><path d="M6 9V3h12v6"/><rect x="6" y="13" width="12" height="8"/><path d="M6 17H3v-5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5h-3"/></>
          } />
        </div>

        {/* Open chat with this patient */}
        <button onClick={openConversation} disabled={capabilities.open_conversation === false} className="press"
          aria-label={capabilities.open_conversation === false
            ? `Ouvrir une discussion VASCO — ${GENERIC_REFUSAL}` : undefined}
          title={capabilities.open_conversation === false
            ? `Ouvrir une discussion VASCO — ${GENERIC_REFUSAL}` : undefined}
          style={{
            marginTop: 12, width: '100%', height: 50, borderRadius: 16,
            background: P_INK, color: '#fff', border: 'none', cursor: 'pointer',
            fontFamily: 'inherit', fontSize: 14, fontWeight: 700, letterSpacing: '-0.1px',
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
            boxShadow: '0 2px 8px rgba(10,10,10,0.12)',
          }}>
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" 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" />
          </svg>
          Ouvrir une discussion VASCO
        </button>
        {/* Le bouton est un aplat sombre : y écrire la raison la rendrait
            illisible. Elle se pose donc juste dessous, comme sous les
            commandes de l'agenda. */}
        {capabilities.open_conversation === false && (
          <div style={{
            marginTop: 8, fontSize: 12, fontWeight: 600, color: P_SUB, lineHeight: 1.4,
          }}>Ouvrir une discussion VASCO — {GENERIC_REFUSAL}</div>
        )}
      </div>
    </div>
  );
}

// Une commande éteinte dit pourquoi sur la carte elle-même : la raison ne
// vivait que dans le toast déclenché par l'appui, donc jamais pour qui regarde
// la grille sans y toucher. Le libellé et l'icône s'éteignent, la raison reste
// à pleine opacité — c'est la seule chose qui reste à lire.
function QAction({ label, icon, onClick, disabled = false, reason = null }) {
  const refused = disabled && reason;
  return (
    <button onClick={onClick} className="press" aria-disabled={disabled}
      aria-label={refused ? `${label} — ${reason}` : undefined}
      title={refused ? `${label} — ${reason}` : undefined}
      style={{
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6,
        padding: '14px 10px', minHeight: 64,
        border: `1px solid ${P_HAIR2}`, borderRadius: 22, background: '#fff',
        boxShadow: VT.shadowMd,
        cursor: 'pointer', fontFamily: 'inherit',
        fontSize: 11.5, fontWeight: 600, color: P_INK, letterSpacing: '0.1px',
        textAlign: 'center',
      }}>
      <div style={{
        width: 36, height: 36, borderRadius: 12,
        background: 'rgba(10,10,10,0.04)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        opacity: disabled ? 0.45 : 1,
      }}>
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={P_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">{icon}</svg>
      </div>
      <span style={{ opacity: disabled ? 0.45 : 1 }}>{label}</span>
      {refused && (
        <span style={{
          fontSize: 10.5, fontWeight: 600, color: P_SUB, lineHeight: 1.3,
        }}>{reason}</span>
      )}
    </button>
  );
}

// ─────────────────────────────────────────────────────────────
// Tab : Documents
// ─────────────────────────────────────────────────────────────
// Horodatage des documents — mocké, mais dérivé du libellé plutôt que tiré au
// sort : la ligne doit rester identique d'un rendu à l'autre, sinon l'auteur
// change à chaque changement de filtre.
// L'auteur d'un compte rendu était fabriqué en hachant son titre : la ligne
// annonçait « Signé par Dr. Legrand » pour un document que personne n'avait
// signé. Le serveur renvoie l'auteur et l'état réels.
const PD_DOC_STATUS = {
  draft: 'Brouillon',
  in_progress: 'En cours',
  awaiting_validation: 'En attente de validation',
  validated: 'Validé',
  archived: 'Archivé',
};

function docSignature(d) {
  const state = PD_DOC_STATUS[d.status] || d.status || '';
  if (!d.author || d.author === '—') return state;
  return d.status === 'validated' ? `Validé par ${d.author}` : `${state} · ${d.author}`;
}

function TabDocuments({ p, accent, runtime = null }) {
  const [filter, setFilter] = React.useState('all');
  const [docs, setDocs] = React.useState(p.documents || []);
  const [counts, setCounts] = React.useState(null);
  const [opened, setOpened] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const types = ['all', 'CRO', 'CRC', 'CRH', 'IMG', 'EXAM'];
  const labels = { all: 'Tous', CRO: 'Op.', CRC: 'Cons.', CRH: 'Hospi.', IMG: 'Imagerie', EXAM: 'Examens' };

  // Le filtre est appliqué par le serveur : le résumé du patient ne porte que
  // les documents récents, pas l'ensemble du dossier.
  React.useEffect(() => {
    if (!runtime) return;
    let cancelled = false;
    setBusy(true);
    runtime.documents(p.id, filter)
      .then((page) => {
        if (cancelled) return;
        setDocs(page.items);
        setCounts(page.counts);
        setError(null);
      })
      .catch((failure) => { if (!cancelled) setError(failure?.message || 'Documents indisponibles'); })
      .finally(() => { if (!cancelled) setBusy(false); });
    return () => { cancelled = true; };
  }, [runtime, p.id, filter]);

  const openDocument = async (document) => {
    if (!runtime) return;
    setBusy(true);
    setError(null);
    try {
      setOpened(await runtime.document(p.id, document.id));
    } catch (failure) {
      setError(failure?.message || 'Document indisponible');
    } finally {
      setBusy(false);
    }
  };

  if (opened) {
    return (
      <div style={{ padding: '16px 24px 24px' }}>
        <button onClick={() => setOpened(null)} className="press" style={{
          background: 'none', border: 'none', cursor: 'pointer', padding: 0,
          fontFamily: 'inherit', fontSize: 12, fontWeight: 600, color: P_SUB, marginBottom: 12,
        }}>← Retour aux documents</button>
        <div style={{ fontSize: 15, fontWeight: 700, color: P_INK }}>{opened.title}</div>
        <div className="mono" style={{ fontSize: 10.5, fontWeight: 500, color: P_SUB, marginTop: 4 }}>
          {opened.document_type} · version {opened.version} · {opened.author?.display_name || '—'}
        </div>
        <div className="serif" style={{
          marginTop: 12, padding: '14px 16px', borderRadius: 16,
          border: `1px solid ${P_HAIR2}`, background: '#fff',
          fontSize: 14.5, lineHeight: 1.55, color: P_INK, whiteSpace: 'pre-wrap',
        }}>{opened.content}</div>
      </div>
    );
  }

  return (
    <div>
      <div style={{
        display: 'flex', gap: 6, padding: '16px 24px 12px',
        overflowX: 'auto',
      }} className="vasco-scroll">
        {types.map(t => {
          const on = filter === t;
          return (
            <button key={t} onClick={() => setFilter(t)} className="press"
              style={{
                background: on ? P_INK : 'transparent',
                color: on ? '#fff' : P_INK,
                border: `1px solid ${on ? P_INK : P_HAIR2}`, borderRadius: 999,
                padding: '6px 11px', cursor: 'pointer',
                fontFamily: 'inherit', fontSize: 11.5, fontWeight: 600, letterSpacing: '0.2px',
                flexShrink: 0,
              }}>{labels[t]}{counts && counts[t.toLowerCase()] !== undefined ? ` ${counts[t.toLowerCase()]}` : ''}</button>
          );
        })}
      </div>
      <div style={{ padding: '0 24px 24px' }}>
        {error && (
          <div role="alert" style={{
            padding: '9px 12px', borderRadius: 12, marginBottom: 12,
            background: 'rgba(185,28,28,0.06)', fontSize: 12, fontWeight: 600, color: '#b91c1c',
          }}>{error}</div>
        )}
        {docs.length === 0 ? (
          <div className="serif" style={{
            fontSize: 14, color: P_SUB, padding: '24px 0', textAlign: 'center',
          }}>{busy ? 'Chargement…' : 'Aucun document de ce type.'}</div>
        ) : (
          <div style={{ borderTop: `1px solid ${P_HAIR2}` }}>
            {docs.map((d) => (
              <div key={d.id} className="press" role="button" tabIndex={0}
                onClick={() => void openDocument(d)}
                onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void openDocument(d); } }}
                style={{
                  display: 'flex', alignItems: 'center', gap: 10,
                  padding: '14px 0',
                  borderBottom: `1px solid ${P_HAIR}`,
                  cursor: 'pointer',
                  opacity: d.available === false ? 0.5 : 1,
                }}>
                <span className="mono" style={{
                  fontSize: 9.5, fontWeight: 600,
                  letterSpacing: '0.5px', padding: '2px 8px', borderRadius: 6,
                  border: d.status === 'awaiting_validation' ? `1px solid ${PD_WARN}` : `1px solid ${P_HAIR2}`,
                  background: d.status === 'awaiting_validation' ? PD_WARN : 'transparent',
                  color: d.status === 'awaiting_validation' ? '#fff' : P_INK,
                  flexShrink: 0,
                }}>{d.type}</span>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{
                    fontSize: 14, fontWeight: 500, color: P_INK,
                    letterSpacing: '-0.1px',
                  }}>{d.label}</div>
                  <div className="mono" style={{
                    fontSize: 10.5, fontWeight: 500, color: P_SUB,
                    letterSpacing: '0.3px', marginTop: 2,
                  }}>{d.d}{d.status === 'awaiting_validation' && <> · À VALIDER</>}</div>
                  <div style={{
                    fontSize: 11, fontWeight: 500, color: P_SUB, marginTop: 3,
                    whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                  }}>{docSignature(d)}</div>
                </div>
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={P_SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M9 6l6 6-6 6"/>
                </svg>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Tab : Antécédents
// ─────────────────────────────────────────────────────────────
function AtcdSections({ p, accent }) {
  const sections = [
    { title: 'Antécédents personnels',  items: p.atcdPerso || [], empty: 'Aucun antécédent personnel renseigné.' },
    { title: 'Antécédents familiaux',   items: p.atcdFam   || [], empty: 'Aucun antécédent familial renseigné.' },
    { title: 'Traitements en cours',    items: p.treatments|| [], empty: 'Aucun traitement en cours.' },
  ];
  return (
    <div style={{ padding: '0 24px 8px' }}>
      {sections.map((s, idx) => (
        <div key={s.title} style={{ marginBottom: 24 }}>
          <PCaption style={{ color: s.accent ? accent : P_SUB }}>{s.title}</PCaption>
          {s.items.length === 0 ? (
            <div className="serif" style={{
              fontSize: 14, color: P_SUB, marginTop: 8, fontStyle: 'italic',
            }}>{s.empty}</div>
          ) : (
            <div style={{ marginTop: 10, borderTop: `1px solid ${P_HAIR2}` }}>
              {s.items.map((it, i, arr) => (
                <div key={i} style={{
                  display: 'flex', gap: 10, padding: '10px 0',
                  borderBottom: `1px solid ${P_HAIR}`,
                }}>
                  <span className="mono" style={{
                    fontSize: 10, fontWeight: 600, color: P_SUB,
                    letterSpacing: '0.4px', minWidth: 18,
                  }}>{String(i + 1).padStart(2, '0')}</span>
                  <span className="serif" style={{
                    fontSize: 15, lineHeight: 1.45, color: P_INK, flex: 1,
                  }}>{it}</span>
                </div>
              ))}
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Tab : Agenda
// ─────────────────────────────────────────────────────────────
function TabAgenda({ p, accent, runtime = null }) {
  const [rdvs, setRdvs] = React.useState(p.rdvs || []);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const [form, setForm] = React.useState(null);
  const canCreate = (p.capabilities || {}).create_appointment !== false;
  // Le serveur refuse le rendez-vous d'un patient décédé ; hors de ce cas, ses
  // capacités de dossier sont des booléens sans code de motif, et l'on s'en
  // tient à une formule générale plutôt que d'inventer une cause.
  const createRefusal = p.status === 'decede' ? 'Patient décédé' : 'Indisponible pour ce dossier';

  const reload = React.useCallback(async () => {
    if (!runtime) return;
    setBusy(true);
    try {
      setRdvs(await runtime.appointments(p.id));
      setError(null);
    } catch (failure) {
      setError(failure?.message || 'Rendez-vous indisponibles');
    } finally {
      setBusy(false);
    }
  }, [runtime, p.id]);

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

  // Depuis le dossier, un chevauchement est un avertissement et non un refus :
  // le rendez-vous est créé, et l'écran dit avec quoi il se chevauche.
  const submit = async () => {
    if (!runtime || !form?.reason.trim() || !form.start || !form.end) return;
    setBusy(true);
    setError(null);
    try {
      const result = await runtime.createAppointment(p.id, {
        startsAtLocal: form.start,
        endsAtLocal: form.end,
        reason: form.reason.trim(),
        type: form.type,
        location: form.location.trim() || null,
      });
      // La création n'est pas annoncée : le rendez-vous apparaît dans l'agenda
      // du dossier au rechargement. Le chevauchement, lui, n'est pas une
      // validation mais un avertissement — il reste dit.
      const overlaps = result.warnings.find((warning) => warning.code === 'appointment_overlap');
      if (overlaps) {
        showToast(
          `Rendez-vous créé en parallèle de ${overlaps.appointment_ids.length} autre(s)`,
          'info',
        );
      }
      setForm(null);
      await reload();
    } catch (failure) {
      setError(failure?.message || 'Création impossible');
    } finally {
      setBusy(false);
    }
  };

  return (
    <div style={{ padding: '20px 24px 24px' }}>
      <PCaption>Rendez-vous</PCaption>
      {error && (
        <div role="alert" style={{
          padding: '9px 12px', borderRadius: 12, marginTop: 10,
          background: 'rgba(185,28,28,0.06)', fontSize: 12, fontWeight: 600, color: '#b91c1c',
        }}>{error}</div>
      )}
      {rdvs.length === 0 ? (
        <div className="serif" style={{
          fontSize: 14, color: P_SUB, marginTop: 12, fontStyle: 'italic',
        }}>{busy ? 'Chargement…' : 'Aucun RDV planifié.'}</div>
      ) : (
        <div style={{ marginTop: 10, borderTop: `1px solid ${P_HAIR2}` }}>
          {rdvs.map((r) => (
            <div key={r.id} style={{
              display: 'flex', gap: 12, padding: '14px 0',
              borderBottom: `1px solid ${P_HAIR}`,
              opacity: r.status === 'cancelled' ? 0.5 : 1,
            }}>
              <div style={{
                minWidth: 56, display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
              }}>
                <div className="mono" style={{
                  fontSize: 10, fontWeight: 600, color: P_INK, letterSpacing: '0.4px',
                }}>{(r.when || '').split(' ·')[0]}</div>
                <div className="mono" style={{
                  fontSize: 18, fontWeight: 700, color: P_INK, letterSpacing: '-0.5px',
                }}>{r.start}</div>
              </div>
              <div style={{ flex: 1 }}>
                <div style={{
                  fontSize: 14.5, fontWeight: 600, color: P_INK, letterSpacing: '-0.1px',
                }}>{r.what}</div>
                <div className="mono" style={{
                  fontSize: 10, fontWeight: 600, color: P_SUB,
                  letterSpacing: '0.5px', marginTop: 4, textTransform: 'uppercase',
                }}>{r.status === 'cancelled' ? 'Annulé' : r.status === 'completed' ? 'Terminé' : 'Programmé'} · {r.lieu}</div>
              </div>
            </div>
          ))}
        </div>
      )}

      {form ? (
        <div style={{
          marginTop: 16, padding: 14, borderRadius: 16,
          border: `1px solid ${P_HAIR2}`, background: '#fff',
        }}>
          <PCaption>Nouveau rendez-vous</PCaption>
          <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
            <input type="datetime-local" value={form.start} aria-label="Début"
              onChange={(e) => setForm({ ...form, start: e.target.value })}
              style={{ flex: 1, minWidth: 0, height: 38, borderRadius: 12, border: `1px solid ${P_HAIR2}`, padding: '0 10px', fontFamily: "'JetBrains Mono', monospace", fontSize: 11, color: P_INK, outline: 'none' }} />
            <input type="datetime-local" value={form.end} aria-label="Fin"
              onChange={(e) => setForm({ ...form, end: e.target.value })}
              style={{ flex: 1, minWidth: 0, height: 38, borderRadius: 12, border: `1px solid ${P_HAIR2}`, padding: '0 10px', fontFamily: "'JetBrains Mono', monospace", fontSize: 11, color: P_INK, outline: 'none' }} />
          </div>
          <input value={form.reason} maxLength={160} placeholder="Motif" aria-label="Motif"
            onChange={(e) => setForm({ ...form, reason: e.target.value })}
            style={{ width: '100%', height: 38, borderRadius: 12, border: `1px solid ${P_HAIR2}`, padding: '0 12px', fontFamily: 'inherit', fontSize: 13, color: P_INK, outline: 'none', marginTop: 8 }} />
          <input value={form.location} maxLength={160} placeholder="Lieu (optionnel)" aria-label="Lieu"
            onChange={(e) => setForm({ ...form, location: e.target.value })}
            style={{ width: '100%', height: 38, borderRadius: 12, border: `1px solid ${P_HAIR2}`, padding: '0 12px', fontFamily: 'inherit', fontSize: 13, color: P_INK, outline: 'none', marginTop: 8 }} />
          <select value={form.type} aria-label="Type"
            onChange={(e) => setForm({ ...form, type: e.target.value })}
            style={{ width: '100%', height: 38, borderRadius: 12, border: `1px solid ${P_HAIR2}`, padding: '0 12px', fontFamily: 'inherit', fontSize: 13, color: P_INK, outline: 'none', marginTop: 8 }}>
            <option value="consultation">Consultation</option>
            <option value="intervention">Intervention</option>
            <option value="urgence">Urgence</option>
            <option value="suivi">Suivi</option>
          </select>
          <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
            <button onClick={() => setForm(null)} className="press" style={{
              height: 38, padding: '0 14px', borderRadius: 12, background: 'transparent',
              border: `1px solid ${P_HAIR2}`, fontFamily: 'inherit', fontSize: 12.5, fontWeight: 700, color: P_INK, cursor: 'pointer',
            }}>Annuler</button>
            <button onClick={() => void submit()} disabled={busy || !form.reason.trim()} className="press" style={{
              flex: 1, height: 38, borderRadius: 12, background: P_INK, border: 'none',
              fontFamily: 'inherit', fontSize: 12.5, fontWeight: 700, color: '#fff',
              cursor: busy ? 'default' : 'pointer', opacity: busy || !form.reason.trim() ? 0.5 : 1,
            }}>{busy ? 'Envoi…' : 'Planifier'}</button>
          </div>
        </div>
      ) : (
        <>
        <button className="press" disabled={!runtime || !canCreate}
          title={canCreate ? 'Planifier un rendez-vous' : `Planifier un RDV — ${createRefusal}`}
          aria-label={canCreate ? undefined : `Planifier un RDV — ${createRefusal}`}
          onClick={() => {
            // Le créneau proposé démarre à l'heure ronde suivante et dure une
            // demi-heure. Tronquer les minutes des deux bornes donnait un début
            // et une fin identiques, que le serveur refuse.
            const pad = (n) => String(n).padStart(2, '0');
            const start = new Date(Date.now() + 3600000);
            start.setMinutes(0, 0, 0);
            const end = new Date(start.getTime() + 1800000);
            const stamp = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
              + `T${pad(d.getHours())}:${pad(d.getMinutes())}`;
            setForm({ start: stamp(start), end: stamp(end), reason: '', location: '', type: 'consultation' });
          }}
          style={{
            marginTop: 16, width: '100%', height: 44, borderRadius: 16,
            background: '#fff', color: P_INK,
            border: `1px solid ${P_HAIR2}`, cursor: runtime && canCreate ? 'pointer' : 'default',
            fontFamily: 'inherit', fontSize: 13, fontWeight: 700, letterSpacing: '-0.1px',
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
            boxShadow: VT.shadowMd, opacity: canCreate ? 1 : 0.45,
          }}>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={P_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
            <path d="M12 5v14M5 12h14" />
          </svg>
          Planifier un RDV
        </button>
        {/* La raison ne vivait que dans le `title` : une infobulle ne se
            survole pas au doigt, et le bouton est désactivé, donc son `onClick`
            ne peut pas la dire non plus. Elle se lit maintenant sous lui. */}
        {!canCreate && (
          <div style={{
            marginTop: 8, fontSize: 12, fontWeight: 600, color: P_SUB, lineHeight: 1.4,
          }}>Planifier un RDV — {createRefusal}</div>
        )}
        </>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Tab : Suivi Post-opératoire
// ─────────────────────────────────────────────────────────────
function TabPostOp({ p, accent, runtime = null }) {
  // La chronologie était entièrement inventée : « Température 38.1°C »,
  // « hémocultures prélevées », « TA 13/8, FC 72 ». Ces lignes se lisaient
  // comme des observations cliniques alors qu'aucune n'avait été relevée.
  // L'épisode vient maintenant du serveur, et il n'y en a pas toujours un.
  const [episode, setEpisode] = React.useState(undefined);
  const [error, setError] = React.useState(null);
  const canSms = (p.capabilities || {}).send_postop_sms !== false;
  const canCall = (p.capabilities || {}).call !== false;
  // Le SMS de suivi n'est raccordé à aucun canal ; l'appel se refuse pour le
  // dossier d'un patient décédé ou sans numéro, les deux causes que le serveur
  // retient. Les deux boutons éteints le disent sous eux.
  const smsRefusal = 'Aucun canal de messagerie raccordé';
  const postOpCallRefusal = p.status === 'decede' ? 'Patient décédé'
    : !p.phone ? 'Aucun numéro renseigné' : 'Indisponible pour ce dossier';

  React.useEffect(() => {
    if (!runtime) { setEpisode(null); return undefined; }
    let cancelled = false;
    runtime.postOperative(p.id)
      .then((response) => { if (!cancelled) { setEpisode(response.episode || null); setError(null); } })
      .catch((failure) => { if (!cancelled) { setEpisode(null); setError(failure?.message || 'Suivi indisponible'); } });
    return () => { cancelled = true; };
  }, [runtime, p.id]);

  const timeZone = window.VASCO_RUNTIME?.dashboard?.time_zone || 'Europe/Paris';
  const stamp = (value) => new Intl.DateTimeFormat('fr-FR', {
    day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone,
  }).format(new Date(value)).replace(',', ' ·');

  const typeIcons = {
    intervention: <><path d="M14.5 4.5l5 5L9 20l-5 .5.5-5z"/></>,
    action: <><path d="M20 6L9 17l-5-5"/></>,
    exam: <><circle cx="12" cy="12" r="9"/><path d="M12 8v4l2 2"/></>,
    vitals: <><circle cx="12" cy="12" r="9"/><path d="M12 8v4l2 2"/></>,
    alert: <><path d="M12 4l10 17H2z"/><path d="M12 11v4"/></>,
    appointment: <><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></>,
  };

  const statusStyles = {
    done:    { bg: VT.successTintSoft, dot: VT.success, line: VT.success },
    alert:   { bg: 'rgba(185,28,28,0.08)', dot: '#b91c1c', line: '#b91c1c' },
    planned: { bg: 'rgba(10,10,10,0.03)', dot: '#9ca3af', line: '#d1d5db' },
  };

  if (episode === undefined) {
    return (
      <div style={{ padding: '20px 24px 24px' }}>
        <div className="serif" style={{ fontSize: 14, color: P_SUB, fontStyle: 'italic' }}>
          Chargement du suivi post-opératoire…
        </div>
      </div>
    );
  }

  if (!episode) {
    return (
      <div style={{ padding: '20px 24px 24px' }}>
        {error && (
          <div role="alert" style={{
            padding: '9px 12px', borderRadius: 12, marginBottom: 12,
            background: 'rgba(185,28,28,0.06)', fontSize: 12, fontWeight: 600, color: '#b91c1c',
          }}>{error}</div>
        )}
        <div className="serif" style={{ fontSize: 14, color: P_SUB, fontStyle: 'italic' }}>
          Aucun épisode post-opératoire ouvert pour ce dossier.
        </div>
      </div>
    );
  }

  // L'état global est calculé par le serveur, selon une règle versionnée :
  // l'écran le rapporte, il ne le recalcule pas.
  const hasAlert = episode.status === 'alert';
  const events = episode.events || [];

  return (
    <div style={{ padding: '20px 24px 24px' }}>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 12,
        padding: '12px 16px', borderRadius: 16, marginBottom: 20,
        background: hasAlert ? 'rgba(185,28,28,0.06)' : VT.successTintSoft,
        border: `1px solid ${hasAlert ? 'rgba(185,28,28,0.12)' : VT.successTint}`,
      }}>
        <div style={{
          width: 36, height: 36, borderRadius: 12,
          background: hasAlert ? 'rgba(185,28,28,0.1)' : VT.successTint,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
        }}>
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={hasAlert ? '#b91c1c' : VT.success} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
            {hasAlert ? <><path d="M12 4l10 17H2z"/><path d="M12 11v4"/></> : <><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></>}
          </svg>
        </div>
        <div style={{ minWidth: 0 }}>
          <div style={{ fontSize: 14, fontWeight: 700, color: hasAlert ? '#b91c1c' : VT.success }}>
            {hasAlert ? 'À surveiller' : 'Évolution favorable'}
          </div>
          <div style={{ fontSize: 12, fontWeight: 500, color: P_SUB, marginTop: 2 }}>
            J+{episode.day_number} · {episode.intervention_label}
          </div>
          <div className="mono" style={{ fontSize: 9.5, fontWeight: 500, color: P_SUB, marginTop: 3, letterSpacing: '0.3px' }}>
            {episode.status_rule_version} · {episode.status_reason === 'unresolved_alert' ? 'alerte non levée' : 'aucune alerte non levée'}
          </div>
        </div>
      </div>

      {/* Le SMS de suivi n'est pas raccordé : le bouton dit pourquoi au lieu
          de promettre un envoi. */}
      <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
        <button onClick={() => showToast('Envoi de SMS non disponible sur cet environnement', 'error')}
          disabled={!canSms} className="press"
          aria-label={canSms ? undefined : `SMS indisponible — ${smsRefusal}`}
          title={canSms ? undefined : `SMS indisponible — ${smsRefusal}`}
          style={{
          flex: 1, height: VT.hMd, borderRadius: VT.rMd,
          background: canSms ? P_INK : 'rgba(10,10,10,0.06)', color: canSms ? '#fff' : P_SUB,
          border: 'none', fontFamily: 'inherit', fontSize: 12, fontWeight: 700,
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
          cursor: canSms ? 'pointer' : 'default',
        }}>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={canSms ? '#fff' : P_SUB} 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"/>
          </svg>
          {canSms ? 'Envoyer SMS' : 'SMS indisponible'}
        </button>
        <button onClick={() => {
            if (!canCall) { showToast('Appel indisponible pour ce dossier', 'error'); return; }
            if (!runtime) { requestCall(`${p.last} ${p.first}`, p.phone); return; }
            runtime.authorizeAction(p.id, 'call')
              .then(() => requestCall(`${p.last} ${p.first}`, p.phone))
              .catch((failure) => showToast(failure?.message || 'Appel impossible', 'error'));
          }}
          disabled={!canCall} className="press"
          aria-label={canCall ? undefined : `Appeler — ${postOpCallRefusal}`}
          title={canCall ? undefined : `Appeler — ${postOpCallRefusal}`}
          style={{
          flex: 1, height: VT.hMd, borderRadius: VT.rMd,
          background: '#fff', color: P_INK, border: `1px solid ${P_HAIR2}`,
          fontFamily: 'inherit', fontSize: 12, fontWeight: 700,
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
          cursor: canCall ? 'pointer' : 'default', opacity: canCall ? 1 : 0.45,
        }}>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={P_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
            <path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.1 2 2 0 0 1 4.1 2h3"/>
          </svg>
          Appeler
        </button>
      </div>
      {/* « SMS indisponible » disait l'état, pas la cause ; « Appeler » éteint
          ne disait rien du tout. Les deux raisons se lisent sous les boutons,
          chacune la sienne, comme sous les commandes de l'agenda. */}
      {(!canSms || !canCall) && (
        <div style={{ marginBottom: 20, display: 'flex', flexDirection: 'column', gap: 4 }}>
          {!canSms && (
            <div style={{ fontSize: 12, fontWeight: 600, color: P_SUB, lineHeight: 1.4 }}>
              SMS indisponible — {smsRefusal}
            </div>
          )}
          {!canCall && (
            <div style={{ fontSize: 12, fontWeight: 600, color: P_SUB, lineHeight: 1.4 }}>
              Appeler — {postOpCallRefusal}
            </div>
          )}
        </div>
      )}
      {canSms && canCall && <div style={{ marginBottom: 12 }} />}

      <PCaption>Chronologie post-opératoire</PCaption>
      <div style={{ marginTop: 12 }}>
        {events.length === 0 && (
          <div className="serif" style={{ fontSize: 14, color: P_SUB, fontStyle: 'italic' }}>
            Aucun événement enregistré.
          </div>
        )}
        {events.map((ev, i) => {
          const ss = statusStyles[ev.status] || statusStyles.planned;
          const isLast = i === events.length - 1;
          const isPast = ev.status !== 'planned';
          return (
            <div key={ev.id} style={{ display: 'flex', gap: 14 }}>
              <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: 20 }}>
                <div style={{
                  width: 10, height: 10, borderRadius: '50%',
                  background: ss.dot, flexShrink: 0, marginTop: 4,
                  border: ev.status === 'alert' ? '2px solid rgba(185,28,28,0.2)' : 'none',
                }} />
                {!isLast && (
                  <div style={{
                    width: 2, flex: 1, minHeight: 24,
                    background: isPast ? ss.line : '#e5e7eb',
                    borderRadius: 1,
                  }} />
                )}
              </div>

              <div style={{ flex: 1, paddingBottom: isLast ? 0 : 16, minWidth: 0 }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                  <div style={{
                    width: 28, height: 28, borderRadius: 8, background: ss.bg,
                    display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
                  }}>
                    <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={ss.dot} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                      {typeIcons[ev.event_type] || typeIcons.action}
                    </svg>
                  </div>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{
                      fontSize: 13, fontWeight: 600, color: isPast ? P_INK : P_SUB,
                      whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                    }}>{ev.label}</div>
                  </div>
                  <span className="mono" style={{
                    fontSize: 9, fontWeight: 600, color: P_SUB,
                    letterSpacing: '0.3px', flexShrink: 0,
                  }}>{stamp(ev.event_at)}</span>
                </div>
                <div style={{
                  fontSize: 12, fontWeight: 500, color: P_SUB, marginTop: 4, marginLeft: 36,
                  lineHeight: 1.4, opacity: isPast ? 1 : 0.6,
                }}>{ev.detail}</div>
                {/* Une mesure venue d'un appareil n'a pas la même valeur qu'une
                    observation signée : la source est nommée. */}
                <div className="mono" style={{
                  fontSize: 9, fontWeight: 500, color: P_SUB, marginTop: 3, marginLeft: 36,
                  letterSpacing: '0.3px', opacity: 0.8,
                }}>{ev.author?.display_name || ev.source_label || 'Source non renseignée'}</div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Tab : Notes
// ─────────────────────────────────────────────────────────────
function TabNotes({ p, accent, runtime = null, focusToken = 0 }) {
  const [draft, setDraft] = React.useState('');
  const draftRef = React.useRef(null);
  const [notes, setNotes] = React.useState(p.notes || []);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const pinned = notes.filter(n => n.pinned);
  const free   = notes.filter(n => !n.pinned);

  const capabilities = p.capabilities || {};
  const canCreate = capabilities.create_note !== false;
  const canPin = capabilities.pin_note !== false;
  const canDelete = capabilities.delete_note !== false;
  // La capacité `create_note` est un booléen sans code de motif : on dit le
  // refus tel qu'on le connaît, sans lui prêter une cause qu'on ignore.
  const noteRefusal = 'Indisponible pour ce dossier';

  const reload = React.useCallback(async () => {
    if (!runtime) return;
    setBusy(true);
    try {
      setNotes(await runtime.notes(p.id));
      setError(null);
    } catch (failure) {
      setError(failure?.message || 'Chargement des notes impossible');
    } finally {
      setBusy(false);
    }
  }, [runtime, p.id]);

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

  // Arrivée par « Nouvelle note » : le champ prend le curseur et se met sous les
  // yeux. Sans cela l'onglet s'ouvre sur sa liste et le geste reste à finir.
  React.useEffect(() => {
    if (!focusToken || !draftRef.current) return;
    draftRef.current.focus({ preventScroll: true });
    draftRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' });
  }, [focusToken]);

  const add = async () => {
    if (!draft.trim() || !runtime) return;
    setBusy(true);
    setError(null);
    try {
      const created = await runtime.createNote(p.id, draft.trim());
      setNotes((list) => [created, ...list]);
      setDraft('');
    } catch (failure) {
      setError(failure?.message || 'Création de la note impossible');
    } finally {
      setBusy(false);
    }
  };

  const togglePin = async (note) => {
    if (!runtime) return;
    setBusy(true);
    setError(null);
    try {
      const updated = await runtime.pinNote(p.id, note, !note.pinned);
      setNotes((list) => list.map((item) => (item.id === updated.id ? updated : item)));
    } catch (failure) {
      setError(failure?.message || 'Épinglage impossible');
      if (failure?.status === 409) await reload();
    } finally {
      setBusy(false);
    }
  };

  // La suppression est réversible côté serveur pendant un délai que l'API
  // annonce. La maquette réinsérait la note en mémoire, donc « Annuler »
  // faisait réapparaître une note que le serveur n'avait jamais supprimée.
  const del = async (note) => {
    if (!runtime) return;
    setBusy(true);
    setError(null);
    try {
      const deleted = await runtime.deleteNote(p.id, note);
      setNotes((list) => list.filter((item) => item.id !== note.id));
      const undoable = Boolean(deleted.undoExpiresAt)
        && new Date(deleted.undoExpiresAt).getTime() > Date.now();
      showToast('Note supprimée', 'info', undoable ? {
        duration: 5000,
        action: {
          label: 'Annuler',
          onClick: () => {
            runtime.restoreNote(p.id, deleted)
              .then((restored) => setNotes((list) => [restored, ...list]))
              .catch((failure) => setError(failure?.message || 'Restauration impossible'));
          },
        },
      } : { duration: 5000 });
    } catch (failure) {
      setError(failure?.message || 'Suppression impossible');
      if (failure?.status === 409) await reload();
    } finally {
      setBusy(false);
    }
  };

  return (
    <div style={{ padding: '20px 24px 24px' }}>
      {/* Add note */}
      <div style={{
        border: `1px solid ${P_HAIR2}`, borderRadius: 16, padding: 12, marginBottom: 24,
      }}>
        <PCaption>Nouvelle note</PCaption>
        <textarea ref={draftRef} value={draft} onChange={e => setDraft(e.target.value)}
          placeholder="Une observation, un détail à retenir…"
          rows={3}
          style={{
            width: '100%', marginTop: 8,
            border: 'none', outline: 'none', resize: 'none',
            background: 'transparent',
            fontFamily: 'inherit', fontSize: 14, lineHeight: 1.5, color: P_INK,
          }}/>
        {/* Le bouton s'éteint aussi quand le serveur refuse la note à ce
            dossier, et la raison ne vivait que dans le `title` : une infobulle
            ne se survole pas au doigt, et un bouton désactivé ne déclenche pas
            son `onClick`. Elle se lit maintenant dans le bloc, à gauche du
            bouton, et l'`aria-label` la joint au libellé. */}
        <div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 8 }}>
          {!canCreate && (
            <span style={{
              marginRight: 'auto', minWidth: 0,
              fontSize: 12, fontWeight: 600, color: P_SUB, lineHeight: 1.4,
            }}>Nouvelle note — {noteRefusal}</span>
          )}
          <button onClick={() => void add()} disabled={!draft.trim() || busy || !runtime || !canCreate} className="press"
            title={canCreate ? 'Ajouter la note' : `Ajouter — ${noteRefusal}`}
            aria-label={canCreate ? undefined : `Ajouter — ${noteRefusal}`}
            style={{
              background: draft.trim() && !busy && canCreate ? P_INK : '#e5e5e5',
              color: draft.trim() && !busy && canCreate ? '#fff' : P_SUB,
              border: 'none', padding: '8px 16px', borderRadius: 12,
              cursor: draft.trim() && !busy && canCreate ? 'pointer' : 'default',
              fontFamily: 'inherit', fontSize: 12, fontWeight: 700, letterSpacing: '0.2px',
            }}>{busy ? 'ENVOI…' : 'AJOUTER'}</button>
        </div>
      </div>

      {error && (
        <div role="alert" style={{
          padding: '9px 12px', borderRadius: 12, marginBottom: 16,
          background: 'rgba(185,28,28,0.06)', fontSize: 12, fontWeight: 600, color: '#b91c1c',
        }}>{error}</div>
      )}

      {pinned.length > 0 && (
        <div style={{ marginBottom: 24 }}>
          <PCaption style={{ color: accent }}>Épinglées</PCaption>
          <div style={{ marginTop: 10 }}>
            {pinned.map((n) => (
              <div key={n.id} style={{
                display: 'flex', gap: 10, padding: '10px 12px',
                background: '#fff', border: `1px solid ${P_HAIR2}`, borderRadius: 16, marginBottom: 8, boxShadow: VT.shadowMd,
              }}>
                <div style={{ width: 3, alignSelf: 'stretch', background: accent, flexShrink: 0 }} />
                <div style={{ flex: 1 }}>
                  <div className="serif" style={{
                    fontSize: 14.5, lineHeight: 1.5, color: P_INK,
                  }}>{n.text}</div>
                  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
                    <span className="mono" style={{
                      fontSize: 10, fontWeight: 500, color: P_SUB, letterSpacing: '0.3px',
                    }}>{n.when || n.d} · {n.author}</span>
                    {canPin && (
                      <button onClick={() => void togglePin(n)} disabled={busy} className="press" style={{
                        background: 'none', border: 'none', cursor: busy ? 'default' : 'pointer',
                        padding: 0, fontFamily: 'inherit', fontSize: 10, fontWeight: 600,
                        color: P_SUB, letterSpacing: '0.3px',
                      }}>DÉTACHER</button>
                    )}
                  </div>
                </div>
              </div>
            ))}
          </div>
        </div>
      )}

      <PCaption>Notes libres</PCaption>
      <div style={{ marginTop: 10, borderTop: `1px solid ${P_HAIR2}` }}>
        {free.length === 0 ? (
          <div className="serif" style={{
            fontSize: 14, color: P_SUB, padding: '14px 0', fontStyle: 'italic',
          }}>Aucune note libre.</div>
        ) : free.map((n) => (
            <div key={n.id} style={{
              padding: '12px 0', borderBottom: `1px solid ${P_HAIR}`,
            }}>
              <div className="serif" style={{
                fontSize: 14.5, lineHeight: 1.5, color: P_INK,
              }}>{n.text}</div>
              <div style={{
                display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                marginTop: 6, gap: 10,
              }}>
                <span className="mono" style={{
                  fontSize: 10, fontWeight: 500, color: P_SUB, letterSpacing: '0.3px',
                }}>{n.when || n.d} · {n.author}</span>
                <div style={{ display: 'flex', gap: 12, flexShrink: 0 }}>
                  {canPin && (
                    <button onClick={() => void togglePin(n)} disabled={busy} className="press"
                      style={{
                        background: 'none', border: 'none', cursor: busy ? 'default' : 'pointer',
                        padding: 0, fontFamily: 'inherit', fontSize: 10, fontWeight: 600,
                        color: P_SUB, letterSpacing: '0.3px',
                      }}>ÉPINGLER</button>
                  )}
                  {canDelete && (
                    <button onClick={() => void del(n)} disabled={busy} className="press"
                      style={{
                        background: 'none', border: 'none', cursor: busy ? 'default' : 'pointer',
                        padding: 0, fontFamily: 'inherit', fontSize: 10, fontWeight: 600,
                        color: P_SUB, letterSpacing: '0.3px',
                      }}>SUPPRIMER</button>
                  )}
                </div>
              </div>
            </div>
          ))}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Tab : Stats
// ─────────────────────────────────────────────────────────────
function TabStats({ p, accent, runtime = null }) {
  // Les barres étaient écrites en dur ([2,1,3,2,4,5] sur « Déc → Mai ») et ne
  // bougeaient pour aucun patient. La série vient maintenant de l'API.
  const [stats, setStats] = React.useState(null);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    if (!runtime) return undefined;
    let cancelled = false;
    runtime.statistics(p.id)
      .then((view) => { if (!cancelled) { setStats(view); setError(null); } })
      .catch((failure) => { if (!cancelled) setError(failure?.message || 'Statistiques indisponibles'); });
    return () => { cancelled = true; };
  }, [runtime, p.id]);

  const monthNames = ['Jan', 'Fév', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'];
  const series = stats?.series || [];
  const months = series.map((point) => monthNames[Number(point.month.split('-')[1]) - 1]);
  const data = series.map((point) => point.report_count);
  const data2 = series.map((point) => point.appointment_count);

  const dateLabel = (value) => (value
    ? new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: stats?.timezone || 'Europe/Paris' }).format(new Date(value))
    : '—');

  return (
    <div style={{ padding: '20px 24px 24px' }}>
      {error && (
        <div role="alert" style={{
          padding: '9px 12px', borderRadius: 12, marginBottom: 12,
          background: 'rgba(185,28,28,0.06)', fontSize: 12, fontWeight: 600, color: '#b91c1c',
        }}>{error}</div>
      )}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
        <StatCard label="Comptes rendus" value={stats ? stats.report_count : '—'} sub="sur la période" />
        <StatCard label="RDV total"      value={stats ? stats.appointment_count : '—'} sub="sur la période" />
        <StatCard label="Première visite" value={dateLabel(stats?.first_visit_at)} sub="rendez-vous le plus ancien" wide />
        <StatCard label="Dernière visite" value={dateLabel(stats?.last_visit_at)} sub="rendez-vous le plus récent" wide />
      </div>

      <div style={{ marginTop: 28 }}>
        <PCaption>Activité — 6 mois</PCaption>
        {/* Le serveur dit lui-même si la période contient assez de points pour
            être lue ; on ne trace pas une courbe sur du vide. */}
        {stats && !stats.has_sufficient_data && (
          <div className="serif" style={{ fontSize: 13, color: P_SUB, marginTop: 8, fontStyle: 'italic' }}>
            Données insuffisantes sur la période pour une lecture utile.
          </div>
        )}
        <div style={{
          marginTop: 12, padding: 14, border: `1px solid ${P_HAIR2}`, borderRadius: 16,
        }}>
          <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', height: 100, gap: 6 }}>
            {months.map((m, i) => {
              const max = Math.max(1, ...data, ...data2);
              return (
                <div key={`${m}-${i}`} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
                  <div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 2, width: '100%', justifyContent: 'center' }}>
                    <div style={{ width: 9, borderRadius: 4, height: `${(data[i] / max) * 100}%`, background: P_INK }} />
                    <div style={{ width: 9, borderRadius: 4, height: `${(data2[i] / max) * 100}%`, background: accent }} />
                  </div>
                  <div className="mono" style={{
                    fontSize: 9.5, fontWeight: 600, color: P_SUB,
                    letterSpacing: '0.3px', marginTop: 8,
                    textTransform: 'uppercase',
                  }}>{m}</div>
                </div>
              );
            })}
          </div>
          <div style={{ display: 'flex', gap: 14, marginTop: 14, paddingTop: 12, borderTop: `1px solid ${P_HAIR}` }}>
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
              <span style={{ width: 10, height: 10, background: P_INK, borderRadius: 3 }} />
              <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: P_INK, letterSpacing: '0.3px' }}>CR</span>
            </span>
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
              <span style={{ width: 10, height: 10, background: accent, borderRadius: 3 }} />
              <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: P_INK, letterSpacing: '0.3px' }}>RDV</span>
            </span>
          </div>
        </div>
      </div>
    </div>
  );
}

function StatCard({ label, value, sub, wide }) {
  return (
    <div style={{
      gridColumn: wide ? 'span 1' : 'auto',
      border: `1px solid ${P_HAIR2}`, borderRadius: 16, padding: 14,
      background: '#fff', boxShadow: VT.shadowMd,
    }}>
      <PCaption>{label}</PCaption>
      <div style={{
        fontSize: 24, fontWeight: 800, color: P_INK,
        letterSpacing: '-0.8px', lineHeight: 1, marginTop: 6,
      }}>{value}</div>
      <div className="mono" style={{
        fontSize: 9.5, fontWeight: 500, color: P_SUB, letterSpacing: '0.3px',
        marginTop: 6, textTransform: 'uppercase',
      }}>{sub}</div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Header (sticky-ish — non sticky here, scrolls with content)
// ─────────────────────────────────────────────────────────────
// « IPP » ne parle qu'aux gens qui travaillent déjà avec. Sur desktop le
// `title` suffit au survol ; sur mobile il n'y a pas de survol, donc le
// numéro devient tapable et ouvre la même explication.
function IppHint({ ipp }) {
  const [open, setOpen] = React.useState(false);

  React.useEffect(() => {
    if (!open) return;
    const t = setTimeout(() => setOpen(false), 4000);
    return () => clearTimeout(t);
  }, [open]);

  return (
    <div style={{ position: 'relative', display: 'inline-flex' }}>
      <button
        onClick={() => setOpen((v) => !v)}
        title="IPP — Identifiant Permanent du Patient"
        className="press"
        style={{
          background: 'none', border: 'none', padding: '2px 0', cursor: 'pointer',
          fontFamily: 'inherit',
        }}>
        <PCaption>DOSSIER PATIENT · {ipp}</PCaption>
      </button>

      {open && (
        <>
          <div onClick={() => setOpen(false)} style={{
            position: 'fixed', inset: 0, zIndex: 39,
          }} />
          <div style={{
            position: 'absolute', top: 'calc(100% + 8px)', left: '50%',
            transform: 'translateX(-50%)', zIndex: 40,
            width: 232, padding: '10px 13px',
            background: P_INK, borderRadius: PD_VT.rMd || 12,
            boxShadow: PD_VT.shadowLg,
            animation: 'ippIn .16s ease',
          }}>
            <div style={{ fontSize: 12, fontWeight: 700, color: '#fff' }}>
              IPP — Identifiant Permanent du Patient
            </div>
            <div style={{ fontSize: 11.5, fontWeight: 500, color: 'rgba(255,255,255,0.72)', marginTop: 4, lineHeight: 1.4 }}>
              Le numéro unique du patient dans l'établissement. Il ne change jamais, même après un nouveau séjour.
            </div>
            {/* Petite flèche vers le libellé */}
            <div style={{
              position: 'absolute', top: -4, left: '50%', marginLeft: -4,
              width: 8, height: 8, background: P_INK, transform: 'rotate(45deg)',
            }} />
          </div>
          <style>{`@keyframes ippIn { from { opacity: 0; transform: translateX(-50%) translateY(-4px); } }`}</style>
        </>
      )}
    </div>
  );
}

function PatientHeader({ p, accent, onBack, onActions }) {
  return (
    <>
      <div style={{
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        padding: '8px 16px 4px',
      }}>
        <button onClick={onBack} className="press"
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            padding: 6, marginLeft: -6,
          }} aria-label="Retour">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={P_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
            <path d="M15 6l-6 6 6 6" />
          </svg>
        </button>
        <IppHint ipp={p.ipp} />
        <button onClick={onActions} className="press"
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            padding: 6, marginRight: -6, color: P_INK,
          }} aria-label="Actions">
          <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
            <circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" />
          </svg>
        </button>
      </div>

      {/* Identity */}
      <div style={{ padding: '12px 24px 8px' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
          <PStatusChip status={p.status} accent={accent} />
          {p.bed && p.bed !== '—' && (
            <span className="mono" style={{
              fontSize: 9.5, fontWeight: 600, color: P_INK,
              letterSpacing: '0.4px', padding: '3px 7px',
              border: `1px solid ${P_HAIR2}`, borderRadius: 16,
              whiteSpace: 'nowrap',
            }}>LIT {p.bed}</span>
          )}
        </div>
        <div style={{
          fontSize: 32, fontWeight: 800, lineHeight: 1.0,
          letterSpacing: '-1px', color: P_INK,
        }}>
          {p.last}
        </div>
        <div style={{
          fontSize: 24, fontWeight: 500, color: P_INK,
          letterSpacing: '-0.6px', marginTop: 2,
        }}>
          {p.first}
        </div>
        <div className="mono" style={{
          fontSize: 11, fontWeight: 500, color: P_SUB, letterSpacing: '0.3px',
          marginTop: 8,
        }}>
          {p.age} ans · {p.sex} · né{p.sex === 'F' ? 'e' : ''} le {p.ddn}
        </div>
        {p.summary && (
          <div className="serif" style={{
            fontSize: 15, lineHeight: 1.45, color: P_INK,
            marginTop: 10, fontWeight: 500,
            paddingLeft: 12, borderLeft: `3px solid ${accent}`, borderRadius: 2,
          }}>{p.summary}</div>
        )}
        {p.flags && p.flags.length > 0 && (
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10 }}>
            {p.flags.map(f => (
              <span key={f} className="mono" style={{
                fontSize: 9.5, fontWeight: 600, color: P_INK,
                letterSpacing: '0.5px', padding: '3px 10px', borderRadius: 999,
                background: 'rgba(10,10,10,0.04)',
                border: `1px solid ${P_HAIR}`,
              }}>{f.toUpperCase()}</span>
            ))}
          </div>
        )}
      </div>
    </>
  );
}

// ─────────────────────────────────────────────────────────────
// Tabs strip (sticky)
// ─────────────────────────────────────────────────────────────
function TabsStrip({ active, onChange }) {
  const [more, setMore] = React.useState(false);
  const inMore = active === 'stats';

  // Le dégradé ne doit apparaître que s'il reste réellement du contenu à droite.
  const stripRef = React.useRef(null);
  const [canScroll, setCanScroll] = React.useState(false);
  const measure = () => {
    const el = stripRef.current;
    if (!el) return;
    setCanScroll(el.scrollWidth - el.clientWidth - el.scrollLeft > 4);
  };
  React.useLayoutEffect(measure, [active]);
  React.useEffect(() => {
    window.addEventListener('resize', measure);
    return () => window.removeEventListener('resize', measure);
  }, []);
  const onStripScroll = measure;

  return (
    <div style={{
      position: 'sticky', top: 0, zIndex: 5,
      background: P_PAPER,
      marginTop: 16,
      padding: '8px 16px',
      display: 'flex', alignItems: 'center', gap: 6,
    }}>
      {/* Les onglets s'étirent quand il y a la place et gardent leur largeur
          naturelle sinon : sur écran étroit la barre défile plutôt que de
          tronquer les libellés. Le dégradé à droite signale ce défilement. */}
      <div style={{ flex: 1, minWidth: 0, position: 'relative' }}>
        <div ref={stripRef} onScroll={onStripScroll} className="vasco-scroll" style={{
          display: 'flex', gap: 6, overflowX: 'auto',
          background: 'rgba(10,10,10,0.04)', borderRadius: 16, padding: 3,
        }}>
        {TABS.map(t => {
          const on = t.id === active;
          return (
            <button key={t.id} onClick={() => onChange(t.id)} className="press"
              style={{
                background: on ? '#fff' : 'transparent',
                border: 'none', cursor: 'pointer',
                padding: '8px 6px', borderRadius: 12,
                fontFamily: 'inherit', fontSize: 12,
                fontWeight: on ? 700 : 500,
                color: on ? P_INK : P_SUB,
                letterSpacing: '0.1px',
                whiteSpace: 'nowrap',
                flex: '1 1 auto', minWidth: 'max-content',
                boxShadow: on ? '0 1px 3px rgba(10,10,10,0.08)' : 'none',
                transition: 'all .15s ease',
              }}>
              {t.label}
            </button>
          );
        })}
        </div>

        {/* Dégradé de bord — n'apparaît que s'il reste à faire défiler */}
        {canScroll && (
          <div style={{
            position: 'absolute', top: 3, bottom: 3, right: 0, width: 28,
            borderRadius: '0 16px 16px 0', pointerEvents: 'none',
            background: `linear-gradient(to right, rgba(246,245,241,0), ${P_PAPER})`,
          }} />
        )}
      </div>

      {/* Menu « Plus » — ce qui ne mérite pas un onglet permanent */}
      <div style={{ position: 'relative', flexShrink: 0 }}>
        <button onClick={() => setMore((o) => !o)} className="press" aria-label="Plus"
          style={{
            width: 34, height: 34, borderRadius: 12,
            background: inMore ? '#fff' : 'rgba(10,10,10,0.04)',
            border: 'none', cursor: 'pointer', padding: 0,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            boxShadow: inMore ? '0 1px 3px rgba(10,10,10,0.08)' : 'none',
          }}>
          <svg width="16" height="16" viewBox="0 0 24 24" fill={inMore ? PD_MED : P_SUB} stroke="none">
            <circle cx="5" cy="12" r="1.7" /><circle cx="12" cy="12" r="1.7" /><circle cx="19" cy="12" r="1.7" />
          </svg>
        </button>

        {more && (
          <>
            <div onClick={() => setMore(false)} style={{ position: 'fixed', inset: 0, zIndex: 20 }} />
            <div style={{
              position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 21,
              background: '#fff', borderRadius: 12,
              border: `1px solid ${P_HAIR2}`,
              boxShadow: '0 12px 32px rgba(10,10,10,0.12)',
              padding: 4, minWidth: 178,
            }}>
              <button className="press"
                onClick={() => { onChange('stats'); setMore(false); }}
                style={{
                  width: '100%', display: 'flex', alignItems: 'center', gap: 9,
                  background: inMore ? PD_MED_TINT : 'none', border: 'none',
                  borderRadius: 8, padding: '9px 10px', cursor: 'pointer',
                  fontFamily: 'inherit', textAlign: 'left',
                  fontSize: 13, fontWeight: 600, color: inMore ? PD_MED : P_INK,
                }}>
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none"
                  stroke={inMore ? PD_MED : P_SUB} strokeWidth="1.7"
                  strokeLinecap="round" strokeLinejoin="round">
                  <path d="M3 3v18h18" /><path d="M7 14l4-4 4 4 5-6" />
                </svg>
                Statistiques
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Patient detail screen
// ─────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────
// Feuille « Modifier » — la correction du dossier, adossée au PATCH serveur.
// ─────────────────────────────────────────────────────────────

const EDIT_STATUS_LABELS = [
  ['ambulatory', 'Ambulatoire'],
  ['hospitalized', 'Hospitalisé'],
  ['icu', 'Réa'],
  ['palliative', 'Soins palliatifs'],
  ['deceased', 'Décédé']];

const EDIT_STATUS_FROM_SHORT = {
  ambu: 'ambulatory', hosp: 'hospitalized', rea: 'icu',
  palliatif: 'palliative', decede: 'deceased' };

// Le mappage local maquille certaines valeurs pour l'affichage — nom en
// capitales, « — » de repli sur le lit et le résumé. La feuille n'envoie donc
// que les champs réellement changés par le praticien, jamais le formulaire
// entier, sinon ces artifices d'affichage seraient réécrits dans le dossier.
function EditPatientSheet({ p, accent, runtime, onClose, onSaved }) {
  const initial = React.useMemo(() => ({
    given_name: p.first || '',
    family_name: p.last || '',
    clinical_status: EDIT_STATUS_FROM_SHORT[p.status] || 'ambulatory',
    phone: p.phone || '',
    email: p.email || '',
    address: p.address || '',
    primary_physician: p.mt || '',
    bed_label: p.bed && p.bed !== '—' ? p.bed : '',
    clinical_summary: p.summary && p.summary !== '—' ? p.summary : '',
  }), [p]);
  // La saisie ne se réinitialise pas quand `p` se rafraîchit : après un
  // conflit de version, on recharge le dossier sans perdre le travail en cours.
  const [form, setForm] = React.useState(initial);
  const [saving, setSaving] = React.useState(false);
  const [error, setError] = React.useState(null);
  const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));

  const changes = {};
  for (const key of Object.keys(initial)) {
    if (form[key] === initial[key]) continue;
    const value = typeof form[key] === 'string' ? form[key].trim() : form[key];
    if (key === 'given_name' || key === 'family_name' || key === 'clinical_summary') {
      // Le serveur refuse le vide sur ces champs : un effacement n'est pas
      // une correction envoyable.
      if (value) changes[key] = value;
    } else if (key === 'clinical_status') {
      changes[key] = value;
    } else {
      changes[key] = value || null;
    }
  }
  const dirty = Object.keys(changes).length > 0;

  const save = async () => {
    if (!dirty || saving) return;
    setSaving(true);
    setError(null);
    try {
      await runtime.update(p, changes);
      showToast('Dossier mis à jour', 'success');
      onSaved();
      onClose();
    } catch (failure) {
      if (failure?.code === 'version_conflict') {
        // Recharger ramène la version à jour ; le prochain essai part d'elle.
        try { await runtime.load(p.id); onSaved(); } catch (ignored) { /* le message suffit */ }
        setError('Le dossier a été modifié entre-temps. Vos saisies sont conservées — vérifiez-les puis réessayez.');
      } else {
        setError(failure?.message || 'La modification a échoué');
      }
      setSaving(false);
    }
  };

  const statusLabel = (code) => (EDIT_STATUS_LABELS.find(([c]) => c === code) || [])[1] || code;

  return (
    <>
      <div onClick={saving ? undefined : onClose} style={{
        position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.35)', zIndex: 110,
        animation: 'fadeIn .2s ease'
      }} />
      <div style={{
        position: 'absolute', left: 8, right: 8, bottom: 12, zIndex: 111,
        maxHeight: '84%', display: 'flex', flexDirection: 'column',
        background: VT.surface,
        borderRadius: VT.rXl,
        boxShadow: VT.shadowLg,
        paddingTop: 6, paddingBottom: 8,
        animation: 'slideUp .25s cubic-bezier(.2,.7,.2,1)',
        overflow: 'hidden'
      }}>
        <div style={{
          width: 36, height: 4, borderRadius: 2,
          background: VT.hair2, margin: '6px auto 4px'
        }} />
        <div style={{ padding: '10px 20px 10px' }}>
          <div className="mono" style={{
            fontSize: 10, fontWeight: 600, color: VT.sub, letterSpacing: '0.5px', textTransform: 'uppercase'
          }}>MODIFIER</div>
          <div style={{
            fontSize: 17, fontWeight: 700, color: VT.ink, letterSpacing: '-0.3px', marginTop: 2
          }}>
            {p.last} <span style={{ fontWeight: 500 }}>{p.first}</span>
          </div>
        </div>
        <div style={{ height: 1, background: VT.hair, margin: '0 16px' }} />
        <div className="vasco-scroll" style={{ flex: 1, overflowY: 'auto', padding: '14px 20px 4px' }}>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
            <Field label="Prénom" required><Inp value={form.given_name} onChange={set('given_name')} placeholder="Prénom" /></Field>
            <Field label="Nom" required><Inp value={form.family_name} onChange={set('family_name')} placeholder="Nom" /></Field>
            <Field label="Statut clinique" full>
              <Sel value={statusLabel(form.clinical_status)}
                onChange={(label) => set('clinical_status')((EDIT_STATUS_LABELS.find(([, l]) => l === label) || [form.clinical_status])[0])}
                options={EDIT_STATUS_LABELS.map(([, label]) => label)}
                placeholder="Statut clinique" />
            </Field>
            <Field label="Téléphone"><Inp value={form.phone} onChange={set('phone')} placeholder="06 …" type="tel" /></Field>
            <Field label="Email"><Inp value={form.email} onChange={set('email')} placeholder="adresse@exemple.fr" type="email" /></Field>
            <Field label="Adresse" full><Inp value={form.address} onChange={set('address')} placeholder="Adresse postale" /></Field>
            <Field label="Médecin traitant"><Inp value={form.primary_physician} onChange={set('primary_physician')} placeholder="Dr …" /></Field>
            <Field label="Lit"><Inp value={form.bed_label} onChange={set('bed_label')} placeholder="Chambre / lit" /></Field>
            <Field label="Résumé clinique" full><TA value={form.clinical_summary} onChange={set('clinical_summary')} placeholder="Résumé clinique" rows={3} /></Field>
          </div>
          {error && (
            <div role="alert" style={{
              marginTop: 12, fontSize: 12.5, fontWeight: 600, color: '#b91c1c', lineHeight: 1.4
            }}>{error}</div>
          )}
        </div>
        <div style={{ display: 'flex', gap: 8, padding: '12px 20px 8px' }}>
          <button onClick={onClose} disabled={saving} className="press" style={{
            flex: 1, height: VT.hLg, borderRadius: VT.rLg,
            background: 'none', border: `1px solid ${VT.hair2}`,
            fontFamily: 'inherit', fontSize: 14, fontWeight: 700, color: VT.ink,
            cursor: 'pointer', opacity: saving ? 0.5 : 1
          }}>
            Annuler
          </button>
          <button onClick={save} disabled={!dirty || saving} className="press" style={{
            flex: 1, height: VT.hLg, borderRadius: VT.rLg,
            background: accent, border: 'none',
            fontFamily: 'inherit', fontSize: 14, fontWeight: 700, color: '#fff',
            cursor: 'pointer', opacity: !dirty || saving ? 0.5 : 1
          }}>
            {saving ? 'Enregistrement…' : 'Enregistrer'}
          </button>
        </div>
      </div>
    </>
  );
}

function PatientDetail({ patientId, accent, onBack, onOpenChat, runtime = null,
  initialTab = 'resume', onNewAppointment }) {
  // Le jeton de fraîcheur force la relecture de PATIENTS_FULL : la correction
  // y remplace l'entrée, mais ce remplacement ne re-rend rien tout seul.
  const [recordToken, setRecordToken] = React.useState(0);
  const p = React.useMemo(
    () => PATIENTS_FULL.find(x => x.id === patientId),
    [patientId, recordToken]
  );
  // Ouvrir le dossier sur un onglet précis : « Nouvelle note » depuis la pile
  // des dossiers doit tomber sur le champ de saisie, pas sur le résumé.
  const [tab, setTab] = React.useState(initialTab);
  // Un jeton plutôt qu'un booléen : deux demandes successives de saisie doivent
  // toutes les deux ramener le curseur dans le champ.
  const [noteFocus, setNoteFocus] = React.useState(initialTab === 'notes' ? 1 : 0);
  React.useEffect(() => {
    setTab(initialTab);
    if (initialTab === 'notes') setNoteFocus((n) => n + 1);
  }, [patientId, initialTab]);
  const [sheetOpen, setSheetOpen] = React.useState(false);
  const [editOpen, setEditOpen] = React.useState(false);
  // La feuille du QR est montée au niveau du dossier, comme la feuille
  // d'actions : posée dans le contenu qui défile, elle défilerait avec lui.
  const [qrOpen, setQrOpen] = React.useState(false);

  // Le dossier affiché peut changer sans que l'écran soit démonté : un QR scanné
  // pendant qu'on lit un dossier en ouvre un autre à la même place. Une feuille
  // restée ouverte se retrouverait alors posée sur le nouveau nom avec les
  // commandes de l'ancien — supprimer, modifier, un QR qui n'est plus le sien.
  // Elles se referment donc avec le dossier qui les avait ouvertes.
  React.useEffect(() => {
    setSheetOpen(false);
    setEditOpen(false);
    setQrOpen(false);
  }, [patientId]);

  // Le dossier demandé n'est pas chargé : on le dit. Se replier sur le premier
  // dossier de la liste affichait le nom, l'IPP et les antécédents de quelqu'un
  // d'autre sous l'identité attendue — un écran de ce genre ne peut pas être
  // relu comme une erreur, il se lit comme un dossier. Rendu placé après tous
  // les hooks, qui doivent s'exécuter à chaque rendu.
  if (!p) {
    return (
      <div style={{
        width: '100%', height: '100%',
        background: P_PAPER, color: P_INK,
        display: 'flex', flexDirection: 'column',
        alignItems: 'center', justifyContent: 'center', gap: 10, padding: '0 32px',
        fontFamily: "'Inter Tight', -apple-system, system-ui, sans-serif",
        textAlign: 'center',
      }}>
        <div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.2px' }}>
          Dossier introuvable
        </div>
        <div style={{ fontSize: 13.5, fontWeight: 500, color: P_SUB, lineHeight: 1.5 }}>
          Ce dossier n’est pas chargé dans cette session.
        </div>
        <button onClick={onBack} className="press" style={{
          marginTop: 18, height: 46, padding: '0 22px', borderRadius: 16,
          background: P_INK, color: '#fff', border: 'none', cursor: 'pointer',
          fontFamily: 'inherit', fontSize: 14, fontWeight: 700, letterSpacing: '-0.1px',
        }}>
          Retour aux patients
        </button>
      </div>
    );
  }

  return (
    <div style={{
      width: '100%', height: '100%',
      background: P_PAPER, color: P_INK,
      display: 'flex', flexDirection: 'column',
      fontFamily: "'Inter Tight', -apple-system, system-ui, sans-serif",
      position: 'relative', overflow: 'hidden',
    }}>
      {/* La réserve du haut est une cale qui défile, pas une marge intérieure : une
          marge intérieure ne colle pas la barre d'onglets au bord visible mais 62 px
          plus bas, où elle vient se poser au milieu du contenu — et comme elle porte
          la couleur du fond, elle y ouvre un trou. */}
      <div className="vasco-scroll" style={{ flex: 1, overflowY: 'auto' }}>
        <div aria-hidden="true" style={{ height: 62 }} />
        <PatientHeader p={p} accent={accent}
          onBack={onBack} onActions={() => setSheetOpen(true)} />

        <TabsStrip active={tab} onChange={setTab} />

        <div>
          {tab === 'resume'  && <TabResume    p={p} accent={accent} onOpenChat={onOpenChat} onTab={setTab} runtime={runtime}
            onQrCode={() => setQrOpen(true)} />}
          {tab === 'docs'    && <TabDocuments p={p} accent={accent} runtime={runtime} />}
          {tab === 'agenda'  && <TabAgenda    p={p} accent={accent} runtime={runtime} />}
          {tab === 'postop'  && <TabPostOp    p={p} accent={accent} runtime={runtime} />}
          {tab === 'notes'   && <TabNotes     p={p} accent={accent} runtime={runtime}
            focusToken={noteFocus} />}
          {tab === 'stats'   && <TabStats     p={p} accent={accent} runtime={runtime} />}
        </div>

        <div style={{ height: 90 }} />
      </div>

      {/* Feuille d'actions partagée avec le répertoire. « Voir le dossier » n'y
          est pas passé : on y est déjà. Après une suppression, le dossier a
          quitté la vue du serveur : rester ici afficherait une fiche qu'il ne
          rendrait plus, donc on repart d'où l'on venait. */}
      {sheetOpen && (
        <ActionSheet patient={p} accent={accent} runtime={runtime}
          onClose={() => setSheetOpen(false)}
          onDelete={onBack}
          onNewNote={() => { setTab('notes'); setNoteFocus((n) => n + 1); }}
          onNewAppointment={onNewAppointment ? () => onNewAppointment(p.id) : undefined}
          onEdit={runtime ? () => setEditOpen(true) : undefined} />
      )}

      {editOpen && runtime && (
        <EditPatientSheet p={p} accent={accent} runtime={runtime}
          onClose={() => setEditOpen(false)}
          onSaved={() => setRecordToken((n) => n + 1)} />
      )}

      {qrOpen && (
        <PatientQrSheet patient={p} runtime={runtime} onClose={() => setQrOpen(false)} />
      )}
    </div>
  );
}

window.PatientDetail = PatientDetail;
