// Patients — métaphore "dossiers colorés + photo polaroid".
// Carousel principal + vue grille + appel vers patient-detail existant.

const PF_VT = window.VT || {};
const PF_INK = PF_VT.ink || '#0a0a0a';
const PF_SUB = PF_VT.sub || '#6b7280';
const PF_BG = PF_VT.paper || '#f6f5f1';
const PF_SURF = PF_VT.surface || '#ffffff';
const PF_BRAND = '#0284c7';
const PF_ALERT = '#b91c1c';
const PF_HAIR = PF_VT.hair || 'rgba(10,10,10,0.06)';
const PF_HAIR2 = PF_VT.hair2 || 'rgba(10,10,10,0.10)';
const PF_SHADOW_SM = PF_VT.shadowSm || '0 1px 2px rgba(10,10,10,0.04), 0 0 0 1px rgba(10,10,10,0.04)';
const PF_SHADOW_MD = PF_VT.shadowMd || '0 1px 3px rgba(10,10,10,0.06), 0 4px 12px rgba(10,10,10,0.04)';
const PF_OK = PF_VT.success || '#059669';
const PF_MED = PF_VT.medBlue || '#3d6b8f';
const PF_MED_TINT = PF_VT.medBlueTint || 'rgba(61,107,143,0.10)';
const PF_OK_TINT = PF_VT.successTint || 'rgba(5,150,105,0.10)';
const PF_OK_TINT_SOFT = PF_VT.successTintSoft || 'rgba(5,150,105,0.06)';

// ── Voile dégradé du bas d'écran ──
// La barre de recherche flotte au-dessus de la liste ; un voile la précède pour
// que les lignes s'y estompent au lieu d'être tranchées net (cf. son rendu au
// bas de `PatientFolders`). Le voile est posé sur le cadre, la zone
// défilante s'arrête au-dessus de la barre d'onglets : il mord donc de
// `PF_VEIL_REACH` pixels dans le bas du défilement. C'est exactement la marge
// de fin qu'il faut laisser sous la dernière rangée pour que, une fois le
// panneau déroulé, le voile ne recouvre plus que du vide — sans quoi il grise
// la dernière rangée, quel que soit son état, et lui donne l'apparence d'un
// contrôle refusé.
const PF_VEIL_BOTTOM = 78;  // depuis le bas du cadre
const PF_VEIL_H = 132;
const PF_NAV_H = 80;        // barre d'onglets, dans le flux (6 + 60 + 14)
const PF_VEIL_REACH = PF_VEIL_BOTTOM + PF_VEIL_H - PF_NAV_H;

// Pastel folder palette — assigned by patient index. Manila / cream / soft pastels with explicit darker "spine" tone for the back panel.
const FOLDER_COLORS = [
{ id: 'manila',   body: '#fdf3c4', tab: '#fbe8a3', spine: '#f5d97a', deep: '#e8c454' },
{ id: 'mint',     body: '#dcfce7', tab: '#c5f3d3', spine: '#a7e8be', deep: '#86d8a3' },
{ id: 'powder',   body: '#fce7f3', tab: '#f9d2e6', spine: '#f0b4d2', deep: '#e294be' },
{ id: 'sky',      body: '#dbeafe', tab: '#bfd9fc', spine: '#94bff0', deep: '#7aabe6' },
{ id: 'lavender', body: '#e9d5ff', tab: '#d8b4fe', spine: '#c4a0f0', deep: '#a982e0' }];


const folderFor = (i) => FOLDER_COLORS[i % FOLDER_COLORS.length];

// ─────────────────────────────────────────────────────────────
// Tri des patients
// ─────────────────────────────────────────────────────────────
const byName = (a, b) =>
  `${a.last} ${a.first}`.localeCompare(`${b.last} ${b.first}`, 'fr');

// Gravité décroissante : ce qu'un chirurgien veut voir remonter.
const CLINICAL_RANK = { rea: 0, hosp: 1, palliatif: 2, ambu: 3, decede: 4 };

// nextRdv.when vaut 'AUJ · 14:30' ou 'JJ/MM · HH:MM'. On en fait une clé
// triable ; les patients sans rendez-vous partent en fin de liste.
const rdvKey = (p) => {
  const w = p.nextRdv && p.nextRdv.when;
  if (!w) return Number.POSITIVE_INFINITY;
  const time = (w.match(/(\d{1,2}):(\d{2})/) || [0, 0, 0]);
  const minutes = Number(time[1]) * 60 + Number(time[2]);
  if (/^AUJ/i.test(w)) return minutes;            // aujourd'hui : avant tout le reste
  const d = w.match(/(\d{2})\/(\d{2})/);
  if (!d) return Number.POSITIVE_INFINITY;
  return (Number(d[2]) * 100 + Number(d[1])) * 1440 + minutes;
};

const SORT_OPTIONS = [
  { id: 'name',     label: 'Nom',                 sub: 'De A à Z',
    cmp: byName },
  { id: 'clinical', label: 'Priorité clinique',   sub: 'Réa, hospitalisés, puis les autres',
    cmp: (a, b) => (CLINICAL_RANK[a.status] ?? 9) - (CLINICAL_RANK[b.status] ?? 9) || byName(a, b) },
  { id: 'rdv',      label: 'Prochain rendez-vous', sub: 'Le plus proche en premier',
    cmp: (a, b) => rdvKey(a) - rdvKey(b) || byName(a, b) },
  { id: 'age',      label: 'Âge',                 sub: 'Du plus âgé au plus jeune',
    cmp: (a, b) => (b.age || 0) - (a.age || 0) || byName(a, b) },
  { id: 'sex',      label: 'Sexe',                sub: 'Femmes puis hommes',
    cmp: (a, b) => String(a.sex).localeCompare(String(b.sex)) || byName(a, b) },
];

// Document type → icon + tinted bg
const DOC_TYPES = {
  CRC: { color: '#0284c7', bg: '#dbeafe', label: 'CR Consultation',
    icon: <><path d="M19 14a3 3 0 0 1-3 3h-3l-3 3v-3H8a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3z" /><path d="M9 11h6M9 8h4" /></> },
  CRO: { color: '#dc2626', bg: '#fee2e2', label: 'CR Opératoire',
    icon: <><path d="M14.5 4.5l5 5L9 20l-5 .5.5-5z" /><path d="M12 7l5 5" /></> },
  ORD: { color: '#16a34a', bg: '#dcfce7', label: 'Ordonnance',
    icon: <><circle cx="9" cy="9" r="5" /><path d="M5.5 5.5l7 7" /><path d="M14 14l5 5M19 14l-5 5" /></> },
  COUR: { color: '#d97706', bg: '#fef3c7', label: 'Courrier',
    icon: <><path d="M3 7l9 6 9-6M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7M3 7l2-2h14l2 2" /></> },
  EXAM: { color: '#7c3aed', bg: '#ede9fe', label: 'Biologie',
    icon: <><path d="M9 3h6M10 3v6.5L5.5 18a3 3 0 0 0 2.6 4.5h7.8A3 3 0 0 0 18.5 18L14 9.5V3" /></> },
  IMG: { color: '#0d9488', bg: '#ccfbf1', label: 'Imagerie',
    icon: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></> },
  CRH: { color: '#0284c7', bg: '#dbeafe', label: 'CR Hospitalisation',
    icon: <><rect x="4" y="5" width="16" height="14" rx="2" /><path d="M12 9v6M9 12h6" /></> }
};
const DocIcon = ({ type }) => {
  const t = DOC_TYPES[type] || DOC_TYPES.COUR;
  return (
    <div style={{
      width: 36, height: 36, flexShrink: 0,
      borderRadius: 12, background: t.bg,
      display: 'flex', alignItems: 'center', justifyContent: 'center'
    }}>
      <svg width="18" height="18" viewBox="0 0 24 24" fill="none"
      stroke={t.color} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">{t.icon}</svg>
    </div>);

};

// ─────────────────────────────────────────────────────────────
// Polaroid — square photo + thick white frame, slightly tilted.
// Renders a softly-shaded "portrait" silhouette on the colored bg
// so it reads like a real photo without an actual asset.
// ─────────────────────────────────────────────────────────────

// Per-patient skin/hair palettes so each polaroid feels like a different person
const PORTRAIT_PALETTE = [
  { skin: '#e3b894', hair: '#3d2817', bg: '#9b7355' }, // medium-warm
  { skin: '#f0d4b8', hair: '#8b5a2b', bg: '#c19a6b' }, // light-warm
  { skin: '#c08868', hair: '#1a1110', bg: '#6b4226' }, // deeper
  { skin: '#f5dcc4', hair: '#d4a574', bg: '#a87f5e' }, // blonde
  { skin: '#d8a87e', hair: '#2d1810', bg: '#8b5e3c' }, // medium
];
const portraitFor = (idx) => PORTRAIT_PALETTE[idx % PORTRAIT_PALETTE.length];

function Portrait({ patient, rounded = false }) {
  const p = portraitFor(patient._idx);
  return (
    <svg viewBox="0 0 100 100" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
      <defs>
        <linearGradient id={`pbg-${patient.id}`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor={p.bg} stopOpacity="0.75" />
          <stop offset="100%" stopColor={p.bg} stopOpacity="0.95" />
        </linearGradient>
      </defs>
      <rect width="100" height="100" fill={`url(#pbg-${patient.id})`} rx={rounded ? 50 : 0} />
      {/* shoulders / shirt */}
      <path d="M6 100c0-22 18-36 44-36s44 14 44 36" fill="rgba(20,15,12,0.55)" />
      {/* neck */}
      <rect x="42" y="56" width="16" height="14" fill={p.skin} opacity="0.85" />
      {/* head */}
      <ellipse cx="50" cy="42" rx="17" ry="20" fill={p.skin} />
      {/* hair top */}
      <path d="M30 42c0-14 9-24 20-24s20 10 20 24c0-2-2-6-4-7-2 6-7 9-16 9s-14-3-16-9c-2 1-4 5-4 7z" fill={p.hair} />
      {/* subtle face shadow */}
      <ellipse cx="50" cy="48" rx="14" ry="9" fill="rgba(0,0,0,0.07)" />
    </svg>
  );
}

function Polaroid({ patient, size = 84, tilt = 5 }) {
  const initials = `${patient.first[0]}${patient.last[0]}`.toUpperCase();
  return (
    <div style={{
      width: size + 14, background: '#fff',
      padding: '7px 7px 18px',
      borderRadius: 2,
      boxShadow: '0 10px 22px rgba(10,10,10,0.18), 0 2px 4px rgba(10,10,10,0.08)',
      transform: `rotate(${tilt}deg)`
    }}>
      <div style={{
        width: size, height: size,
        position: 'relative', overflow: 'hidden',
      }}>
        <Portrait patient={patient} />
        <div style={{
          position: 'absolute', bottom: 4, right: 5,
          fontFamily: "'Inter Tight', sans-serif",
          fontSize: 9, fontWeight: 700, letterSpacing: '0.6px',
          color: 'rgba(255,255,255,0.85)',
          textShadow: '0 1px 2px rgba(0,0,0,0.4)',
        }}>{initials}</div>
      </div>
    </div>
  );
}

// Small circular avatar (top-left of folder)
function Avatar({ patient, size = 32 }) {
  return (
    <div style={{
      width: size, height: size, borderRadius: '50%',
      position: 'relative', overflow: 'hidden',
      boxShadow: '0 1px 3px rgba(10,10,10,0.12), 0 0 0 2px rgba(255,255,255,0.7)',
      flexShrink: 0,
    }}>
      <Portrait patient={patient} rounded />
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Manila folder — SVG shape: back panel peeking + front body w/ notched tab
// The "body" path traces: top-left up to tab, across the tab, back down,
// across to the right edge, around the rounded body, and back. This gives
// us a single cohesive folder silhouette like a physical manila file.
// ─────────────────────────────────────────────────────────────
function FolderShape({ color, width, height, tabWidth = 130, tabHeight = 26, bodyRadius = 18, tabRadius = 10, showBack = true, children, alert, tabLabel, labelColor, onClick }) {
  // Coordinates (in SVG space)
  const W = width, H = height;
  const TW = tabWidth, TH = tabHeight;
  const BR = bodyRadius, TR = tabRadius;
  const backLip = showBack ? 6 : 0; // how much of the back panel shows below

  // Front body path with tab on top-left
  // Start at left edge, just below the tab
  const path = [
    `M 0 ${TH}`,                                  // start at left, level with tab base
    `L 0 ${H - BR}`,                              // down left side
    `Q 0 ${H} ${BR} ${H}`,                        // bottom-left corner
    `L ${W - BR} ${H}`,                           // bottom edge
    `Q ${W} ${H} ${W} ${H - BR}`,                 // bottom-right corner
    `L ${W} ${BR}`,                               // up right side to body radius
    `Q ${W} 0 ${W - BR} 0`,                       // top-right corner of body
    `L ${TW + TR} 0`,                             // top edge across to tab notch
    `Q ${TW} 0 ${TW} ${TR}`,                      // outer curve into the notch
    // The tab notch goes UP — from y=TH (body top is actually at y=TH) to y=0... wait:
    // We want the tab to be ABOVE the body. So body top is at y=TH, tab top is at y=0.
    // Re-do path: start at (0, TH), tab spans (TR..TW) at y=0, body top at y=TH.
  ].join(' ');

  // Cleaner: build path with tab raised above body
  const p = [
    `M ${TR} 0`,                                  // top-left of tab (after rounding)
    `Q 0 0 0 ${TR}`,                              // round top-left corner of tab
    `L 0 ${H - BR}`,                              // down left side all the way
    `Q 0 ${H} ${BR} ${H}`,                        // bottom-left corner
    `L ${W - BR} ${H}`,                           // bottom edge
    `Q ${W} ${H} ${W} ${H - BR}`,                 // bottom-right corner
    `L ${W} ${TH + BR}`,                          // up right side to where body top curves
    `Q ${W} ${TH} ${W - BR} ${TH}`,               // top-right of body
    `L ${TW + TR} ${TH}`,                         // top of body across to tab
    `Q ${TW} ${TH} ${TW + TR/2} ${TH - TR/2}`,    // small inner curve into tab edge
    `L ${TW - TR + TR} ${TR}`,                    // up the right side of the tab (slight slant)
    `Q ${TW - TR} 0 ${TW - TR - TR} 0`,           // round the top-right of tab inward... too complex
    `Z`
  ].join(' ');

  // Actually, simplest reliable shape: a rounded rectangle for the BODY,
  // overlaid with a rounded "tab" rectangle on top-left whose bottom-right
  // is squared so it merges into the body. That's exactly what the reference looks like.
  return (
    <svg width={W} height={H + backLip} viewBox={`0 0 ${W} ${H + backLip}`} style={{ display: 'block', overflow: 'visible' }}>
      <defs>
        {/* Body gradient — slightly darker at bottom for 3D */}
        <linearGradient id={`fg-${color.id}`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor={color.body} stopOpacity="1" />
          <stop offset="100%" stopColor={color.spine} stopOpacity="1" />
        </linearGradient>
        {/* Tab gradient — slightly different shade for separation */}
        <linearGradient id={`fgt-${color.id}`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor={color.tab} stopOpacity="1" />
          <stop offset="100%" stopColor={color.body} stopOpacity="1" />
        </linearGradient>
        {/* Drop shadow */}
        <filter id={`fs-${color.id}`} x="-10%" y="-10%" width="120%" height="130%">
          <feDropShadow dx="0" dy="6" stdDeviation="8" floodColor="#0f172a" floodOpacity="0.18" />
        </filter>
      </defs>

      {/* BACK PANEL — same shape, slightly larger, darker tone, offset down */}
      {showBack && (
        <g transform={`translate(0, ${backLip})`}>
          <rect x="0" y={TH} width={W} height={H - TH} rx={BR} ry={BR} fill={color.deep} opacity="0.75" />
        </g>
      )}

      {/* FRONT FOLDER — tab + body as single visual */}
      <g filter={`url(#fs-${color.id})`}>
        {/* Body */}
        <rect x="0" y={TH} width={W} height={H - TH} rx={BR} ry={BR} fill={`url(#fg-${color.id})`} />
        {/* Tab — sits on top, slight inset from left, squared bottom-right so it merges */}
        <path
          d={`M ${TR} 0
              Q 0 0 0 ${TR}
              L 0 ${TH}
              L ${TW} ${TH}
              L ${TW} ${TR + 2}
              Q ${TW} ${TR - 2} ${TW - 8} ${TR - 4}
              L ${TR + 4} 0
              Z`}
          fill={`url(#fgt-${color.id})`}
        />
        {/* Subtle fold-line where tab meets body */}
        <line x1="0" y1={TH} x2={TW} y2={TH} stroke="rgba(10,10,10,0.08)" strokeWidth="1" />
        {/* Inner highlight along top of body for paper feel */}
        <line x1={BR} y1={TH + 0.5} x2={W - BR} y2={TH + 0.5} stroke="rgba(255,255,255,0.6)" strokeWidth="1" />
      </g>

      {/* Tab label */}
      {tabLabel && (
        <text
          x="14" y={TH / 2 + 4}
          fontFamily="'JetBrains Mono', ui-monospace, monospace"
          fontSize="10" fontWeight="600"
          letterSpacing="0.8"
          fill={labelColor || 'rgba(10,10,10,0.55)'}
          style={{ textTransform: 'uppercase' }}
        >{tabLabel}</text>
      )}
      {alert && (
        <circle cx={TW - 14} cy={TH / 2} r="3.5" fill={PF_ALERT} />
      )}
    </svg>
  );
}

function Folder({ patient, depth = 0, scale = 1, opacity = 1, withPolaroid = true,
  showLabel = true, showActions = false, alert = null,
  onClick, style = {} }) {
  const c = folderFor(patient._idx);
  const tabLabel = `${patient.first[0]}. ${patient.last.toUpperCase().slice(0, 8)}.`;
  const W = 320;   // folder width
  const H = 220;   // folder height — taller to fill the space
  const TH = 26;   // tab height

  return (
    <div onClick={onClick} style={{
      position: 'relative',
      transform: `translateY(${depth}px) scale(${scale})`,
      transformOrigin: 'top center',
      opacity,
      transition: 'transform .35s cubic-bezier(.2,.7,.2,1), opacity .25s ease',
      cursor: onClick ? 'pointer' : 'default',
      width: '100%',
      ...style
    }}>
      {/* Folder shape as background */}
      <div style={{ position: 'relative', width: '100%' }}>
        <svg viewBox={`0 0 ${W} ${H + 6}`} preserveAspectRatio="xMinYMin meet" style={{
          width: '100%', height: 'auto', display: 'block',
          filter: 'drop-shadow(0 8px 16px rgba(10,10,10,0.10)) drop-shadow(0 2px 4px rgba(10,10,10,0.05))',
          overflow: 'visible'
        }}>
          <defs>
            <linearGradient id={`body-${patient.id}`} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor={c.body} />
              <stop offset="100%" stopColor={c.spine} />
            </linearGradient>
            <linearGradient id={`tab-${patient.id}`} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor={c.tab} />
              <stop offset="100%" stopColor={c.body} />
            </linearGradient>
          </defs>
          {/* Back panel — peeks at the bottom */}
          <rect x="2" y={TH + 4} width={W - 4} height={H - TH} rx="18" ry="18" fill={c.deep} opacity="0.5" />
          {/* Front body */}
          <rect x="0" y={TH} width={W} height={H - TH} rx="18" ry="18" fill={`url(#body-${patient.id})`} />

          {/* White page — just a thin sliver peeking out at the top of the body, very subtle */}
          <rect x="10" y={TH + 3} width={W - 20} height="14" rx="4" ry="4"
            fill="rgba(255,255,255,0.4)" />

          {/* Tab — rounded top-left + top-right, bottom merges into the body */}
          <path
            d={`M 10 0
                Q 0 0 0 10
                L 0 ${TH + 12}
                L 148 ${TH + 12}
                L 148 10
                Q 148 0 138 0
                Z`}
            fill={`url(#tab-${patient.id})`}
          />
          {/* Soft fold line where tab meets body */}
          <line x1="0" y1={TH} x2="148" y2={TH} stroke="rgba(10,10,10,0.05)" strokeWidth="0.5" />
          {/* Top body highlight */}
          <line x1="18" y1={TH + 0.8} x2={W - 18} y2={TH + 0.8} stroke="rgba(255,255,255,0.5)" strokeWidth="1" />
          {/* Tab label */}
          <text
            x="14" y={TH / 2 + 4}
            fontFamily="'JetBrains Mono', ui-monospace, monospace"
            fontSize="10.5" fontWeight="600" letterSpacing="0.8"
            fill="rgba(10,10,10,0.55)"
          >{tabLabel}</text>
          {alert && <circle cx="136" cy={TH / 2} r="3.5" fill={PF_ALERT} />}
        </svg>

        {/* Content overlaid on top of the folder body */}
        <div style={{
          position: 'absolute',
          left: 0, right: 0, top: `${(TH / (H + 6)) * 100}%`, bottom: 0,
          padding: '12px 18px 14px',
        }}>
          {/* Avatar top-left */}
          {withPolaroid && (
            <Avatar patient={patient} size={30} />
          )}
          {/* Polaroid bottom-right, hanging slightly off the bottom edge */}
          {withPolaroid && (
            <div style={{ position: 'absolute', right: 18, bottom: -10, transform: 'rotate(5deg)' }}>
              <Polaroid patient={patient} size={80} tilt={0} />
            </div>
          )}
          {/* Name — single line, anchored to bottom-left */}
          {showLabel && (
            <div style={{
              position: 'absolute', left: 20, bottom: 16,
              right: 105,
            }}>
              <div style={{
                fontFamily: "'Inter Tight', sans-serif",
                fontSize: 20, fontWeight: 700, color: PF_INK,
                letterSpacing: '-0.4px', lineHeight: 1.1,
                whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
              }}>
                {patient.first} {patient.last.charAt(0) + patient.last.slice(1).toLowerCase()}
              </div>
            </div>
          )}
        </div>
      </div>
    </div>);

}

// ─────────────────────────────────────────────────────────────
// Stack — active folder on top + 2-3 background folders peeking
// ─────────────────────────────────────────────────────────────
function FolderStack({ patients, activeIdx, onSwipe, onTapActive }) {
  const total = patients.length;
  const back = [];
  for (let off = 3; off >= 1; off--) {
    const i = (activeIdx - off + total) % total;
    back.push({ p: patients[i], layer: off });
  }
  const active = patients[activeIdx];

  // Animation state: 'idle' | 'exit-up' | 'exit-down' | 'enter'
  const [anim, setAnim] = React.useState('idle');
  const [displayIdx, setDisplayIdx] = React.useState(activeIdx);
  const swipeLocked = React.useRef(false);

  // When activeIdx changes externally (e.g. search), sync immediately
  React.useEffect(() => {
    if (!swipeLocked.current) {
      setDisplayIdx(activeIdx);
      setAnim('idle');
    }
  }, [activeIdx]);

  // Vertical-only swipe with higher threshold
  const startY = React.useRef(null);
  const onPointerDown = (e) => {
    if (anim !== 'idle') return;
    const ev = e.touches ? e.touches[0] : e;
    startY.current = ev.clientY;
  };
  const onPointerUp = (e) => {
    if (startY.current == null || anim !== 'idle') return;
    const ev = e.changedTouches ? e.changedTouches[0] : e;
    const dy = ev.clientY - startY.current;
    startY.current = null;

    // Need at least 90px vertical drag to trigger
    if (Math.abs(dy) < 90) return;

    const dir = dy < 0 ? 1 : -1; // swipe up = next, swipe down = prev
    swipeLocked.current = true;

    // Phase 1: active folder exits (flies up or down)
    setAnim(dir > 0 ? 'exit-up' : 'exit-down');

    // Phase 2: after exit animation, swap the data and enter the new folder
    setTimeout(() => {
      setDisplayIdx((activeIdx + dir + total) % total);
      setAnim('enter');
      onSwipe(dir);

      // Phase 3: settle into idle
      setTimeout(() => {
        setAnim('idle');
        swipeLocked.current = false;
      }, 400);
    }, 300);
  };

  const ACTIVE_TOP = 48;
  const TAB_SPACING = 16;

  // Compute active folder transform based on animation phase
  let activeSt = {};
  if (anim === 'exit-up') {
    activeSt = { transform: 'translateY(-120px) scale(0.92)', opacity: 0, transition: 'all .3s cubic-bezier(.4,.0,.2,1)' };
  } else if (anim === 'exit-down') {
    activeSt = { transform: 'translateY(80px) scale(0.95)', opacity: 0, transition: 'all .3s cubic-bezier(.4,.0,.2,1)' };
  } else if (anim === 'enter') {
    activeSt = { transform: 'translateY(0) scale(1)', opacity: 1, transition: 'all .4s cubic-bezier(.15,.85,.35,1.05)' };
  } else {
    activeSt = { transform: 'translateY(0) scale(1)', opacity: 1, transition: 'all .4s cubic-bezier(.15,.85,.35,1.05)' };
  }

  // Use displayIdx for rendering during animation
  const shownPatient = patients[displayIdx] || active;
  const shownBack = [];
  for (let off = 3; off >= 1; off--) {
    const i = (displayIdx - off + total) % total;
    shownBack.push({ p: patients[i], layer: off });
  }

  return (
    <div
    onTouchStart={onPointerDown} onTouchEnd={onPointerUp}
    onMouseDown={onPointerDown} onMouseUp={onPointerUp}
    style={{ position: 'relative', padding: `${ACTIVE_TOP}px 20px 16px`, minHeight: 230, userSelect: 'none', touchAction: 'pan-x' }}>
      {/* Background folders — only TAB visible */}
      {shownBack.map((b, i) => {
        const layer = b.layer;
        const c = folderFor(b.p._idx);
        const tabTop = ACTIVE_TOP - layer * TAB_SPACING;
        const tabLabel = `${b.p.first[0]}. ${b.p.last.toUpperCase().slice(0, 10)}.`;
        const layerOpacity = layer === 3 ? 0.55 : layer === 2 ? 0.7 : 0.85;
        return (
          <div key={`b${b.p.id}`} style={{
            position: 'absolute',
            left: 20, right: 20,
            top: tabTop,
            pointerEvents: 'none',
            zIndex: i,
            opacity: layerOpacity,
            transition: 'all .45s cubic-bezier(.15,.85,.35,1)',
          }}>
            <svg viewBox="0 0 320 56" preserveAspectRatio="xMinYMin meet" style={{
              width: '100%', height: 'auto', display: 'block',
            }}>
              <defs>
                <linearGradient id={`stab-${b.p.id}`} x1="0" y1="0" x2="0" y2="1">
                  <stop offset="0%" stopColor={c.tab} />
                  <stop offset="100%" stopColor={c.body} />
                </linearGradient>
                <linearGradient id={`sbody-${b.p.id}`} x1="0" y1="0" x2="0" y2="1">
                  <stop offset="0%" stopColor={c.body} />
                  <stop offset="100%" stopColor={c.spine} />
                </linearGradient>
              </defs>
              <rect x="0" y="28" width="320" height="172" rx="20" ry="20" fill={`url(#sbody-${b.p.id})`} />
              <path
                d={`M 10 0
                    Q 0 0 0 10
                    L 0 42
                    L 152 42
                    L 152 10
                    Q 152 0 142 0
                    Z`}
                fill={`url(#stab-${b.p.id})`}
              />
              <text
                x="16" y="18"
                fontFamily="'JetBrains Mono', ui-monospace, monospace"
                fontSize="11" fontWeight="600" letterSpacing="0.8"
                fill="rgba(10,10,10,0.5)"
              >{tabLabel}</text>
            </svg>
          </div>
        );
      })}
      {/* Active folder with animation */}
      <div style={{ position: 'relative', zIndex: 10, ...activeSt }}>
        <Folder patient={shownPatient} onClick={onTapActive} />
      </div>
    </div>);

}

// ─────────────────────────────────────────────────────────────
// FullStack — all folders stacked, scroll/swipe to browse
// Active folder = front, behind it all remaining folders peek
// with their tabs. Scroll up/down or swipe to change.
// ─────────────────────────────────────────────────────────────
function FullStack({ patients, onTapActive, onNewPatient, onOpenPatient }) {
  const total = patients.length;
  const [idx, setIdx] = React.useState(0);
  const [anim, setAnim] = React.useState('idle'); // idle | exit-up | exit-down | enter
  const locked = React.useRef(false);
  const importRef = React.useRef(null);
  const [importDone, setImportDone] = React.useState(false);

  // Clamp idx when patients list changes (search filter)
  React.useEffect(() => {
    if (idx >= total && total > 0) setIdx(0);
  }, [total, idx]);

  const go = React.useCallback((dir) => {
    if (locked.current || total <= 1) return;
    const next = (idx + dir + total) % total;
    locked.current = true;
    setAnim(dir > 0 ? 'exit-up' : 'exit-down');
    setTimeout(() => {
      setIdx(next);
      setAnim('enter');
      setTimeout(() => { setAnim('idle'); locked.current = false; }, 350);
    }, 250);
  }, [idx, total]);

  // Vertical swipe (touch)
  const startY = React.useRef(null);
  const onTouchStart = (e) => { startY.current = e.touches[0].clientY; };
  const onTouchEnd = (e) => {
    if (startY.current == null) return;
    const dy = e.changedTouches[0].clientY - startY.current;
    startY.current = null;
    if (Math.abs(dy) > 60) go(dy < 0 ? 1 : -1);
  };

  // Mouse drag
  const mouseY = React.useRef(null);
  const onMouseDown = (e) => { mouseY.current = e.clientY; };
  const onMouseUp = (e) => {
    if (mouseY.current == null) return;
    const dy = e.clientY - mouseY.current;
    mouseY.current = null;
    if (Math.abs(dy) > 60) go(dy < 0 ? 1 : -1);
  };

  // Mouse wheel
  const wheelTimer = React.useRef(null);
  const containerRef = React.useRef(null);
  React.useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const handler = (e) => {
      e.preventDefault();
      if (wheelTimer.current) return;
      if (Math.abs(e.deltaY) > 15) {
        go(e.deltaY > 0 ? 1 : -1);
        wheelTimer.current = setTimeout(() => { wheelTimer.current = null; }, 500);
      }
    };
    el.addEventListener('wheel', handler, { passive: false });
    return () => el.removeEventListener('wheel', handler);
  }, [go]);

  if (total === 0) {
    return (
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: PF_SUB, fontSize: 14 }}>
        Aucun patient trouvé
      </div>
    );
  }

  const active = patients[idx];

  // Build background tabs — show up to 5 behind the active
  const MAX_BEHIND = Math.min(5, total - 1);
  const behindItems = [];
  for (let off = MAX_BEHIND; off >= 1; off--) {
    const i = (idx + off) % total;
    behindItems.push({ p: patients[i], layer: off });
  }

  const TAB_SPACING = 14;
  const STACK_TOP = 10;

  // Active folder animation styles
  let activeSt = {};
  if (anim === 'exit-up') {
    activeSt = { transform: 'translateY(-100px) scale(0.93)', opacity: 0, transition: 'all .25s cubic-bezier(.4,0,.7,1)' };
  } else if (anim === 'exit-down') {
    activeSt = { transform: 'translateY(60px) scale(0.96)', opacity: 0, transition: 'all .25s cubic-bezier(.4,0,.7,1)' };
  } else if (anim === 'enter') {
    activeSt = { transform: 'translateY(0) scale(1)', opacity: 1, transition: 'all .35s cubic-bezier(.15,.85,.35,1.04)' };
  } else {
    activeSt = { transform: 'translateY(0) scale(1)', opacity: 1, transition: 'all .35s cubic-bezier(.15,.85,.35,1.04)' };
  }

  // Counter label
  const counterLabel = `${idx + 1} / ${total}`;

  return (
    <div ref={containerRef}
      onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}
      onMouseDown={onMouseDown} onMouseUp={onMouseUp}
      style={{
        flex: 1, position: 'relative',
        padding: `${STACK_TOP + MAX_BEHIND * TAB_SPACING}px 20px 16px`,
        userSelect: 'none', overflow: 'hidden',
      }}>

      {/* Counter */}
      <div style={{
        position: 'absolute', top: STACK_TOP + MAX_BEHIND * TAB_SPACING - 20, right: 24,
        fontSize: 11, fontWeight: 600, color: PF_SUB, letterSpacing: '0.3px',
        zIndex: 15,
      }}>{counterLabel}</div>

      {/* Background tabs — each one peeks above the active */}
      {behindItems.map((b, i) => {
        const c = folderFor(b.p._idx);
        const tabTop = STACK_TOP + (MAX_BEHIND - b.layer) * TAB_SPACING;
        const tabLabel = `${b.p.first[0]}. ${b.p.last.toUpperCase().slice(0, 10)}.`;
        const layerOpacity = 0.4 + (MAX_BEHIND - b.layer) * (0.5 / MAX_BEHIND);
        return (
          <div key={`bg-${b.p.id}`} style={{
            position: 'absolute',
            left: 20, right: 20,
            top: tabTop,
            pointerEvents: 'none',
            zIndex: i,
            opacity: layerOpacity,
            transition: 'all .4s cubic-bezier(.15,.85,.35,1)',
          }}>
            <svg viewBox="0 0 320 56" preserveAspectRatio="xMinYMin meet" style={{
              width: '100%', height: 'auto', display: 'block',
            }}>
              <defs>
                <linearGradient id={`fstab-${b.p.id}`} x1="0" y1="0" x2="0" y2="1">
                  <stop offset="0%" stopColor={c.tab} />
                  <stop offset="100%" stopColor={c.body} />
                </linearGradient>
                <linearGradient id={`fsbody-${b.p.id}`} x1="0" y1="0" x2="0" y2="1">
                  <stop offset="0%" stopColor={c.body} />
                  <stop offset="100%" stopColor={c.spine} />
                </linearGradient>
              </defs>
              <rect x="0" y="28" width="320" height="172" rx="20" ry="20" fill={`url(#fsbody-${b.p.id})`} />
              <path
                d={`M 10 0 Q 0 0 0 10 L 0 42 L 152 42 L 152 10 Q 152 0 142 0 Z`}
                fill={`url(#fstab-${b.p.id})`}
              />
              <text x="16" y="18"
                fontFamily="'JetBrains Mono', ui-monospace, monospace"
                fontSize="11" fontWeight="600" letterSpacing="0.8"
                fill="rgba(10,10,10,0.45)"
              >{tabLabel}</text>
            </svg>
          </div>
        );
      })}

      {/* Active folder */}
      <div style={{ position: 'relative', zIndex: 10, ...activeSt }}>
        <Folder patient={active} onClick={() => onTapActive(active.id)} />
      </div>

      {/* Action widgets below the folder */}
      <div style={{
        position: 'relative', zIndex: 10,
        display: 'flex', gap: 10,
        marginTop: 16, padding: '0 2px',
        ...activeSt,
      }}>
        {/* Add patient widget */}
        <button onClick={() => onNewPatient && onNewPatient()} className="press" style={{
          flex: 1, display: 'flex', alignItems: 'center', gap: 10,
          background: PF_SURF, border: `1px solid ${PF_HAIR}`,
          borderRadius: 16, padding: '12px 14px',
          cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
        }}>
          <div style={{
            width: 36, height: 36, borderRadius: 12,
            background: 'rgba(10,10,10,0.05)',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            flexShrink: 0,
          }}>
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none"
              stroke="#0a0a0a" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
              <circle cx="9" cy="7" r="4" />
              <line x1="19" y1="8" x2="19" y2="14" />
              <line x1="22" y1="11" x2="16" y2="11" />
            </svg>
          </div>
          <div>
            <div style={{ fontSize: 13, fontWeight: 600, color: PF_INK, lineHeight: 1.2 }}>Nouveau</div>
            <div style={{ fontSize: 11, fontWeight: 500, color: PF_SUB, marginTop: 1 }}>Créer un dossier</div>
          </div>
        </button>

        {/* Import patient widget */}
        <label className="press" style={{
          flex: 1, display: 'flex', alignItems: 'center', gap: 10,
          background: importDone ? PF_OK_TINT_SOFT : PF_SURF,
          border: `1px solid ${importDone ? PF_OK_TINT : PF_HAIR}`,
          borderRadius: 16, padding: '12px 14px',
          cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
          transition: 'all .2s ease',
        }}>
          <div style={{
            width: 36, height: 36, borderRadius: 12,
            background: importDone ? PF_OK_TINT : 'rgba(10,10,10,0.05)',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            flexShrink: 0,
          }}>
            {importDone ? (
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={PF_OK} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M20 6L9 17l-5-5"/>
              </svg>
            ) : (
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none"
                stroke="#0a0a0a" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
                <polyline points="7 10 12 15 17 10" />
                <line x1="12" y1="15" x2="12" y2="3" />
              </svg>
            )}
          </div>
          <div>
            <div style={{ fontSize: 13, fontWeight: 600, color: importDone ? PF_OK : PF_INK, lineHeight: 1.2 }}>
              {importDone ? 'Importé' : 'Importer'}
            </div>
            <div style={{ fontSize: 11, fontWeight: 500, color: PF_SUB, marginTop: 1 }}>
              {importDone ? 'Fichier traité avec succès' : 'Depuis un fichier'}
            </div>
          </div>
          <input type="file" accept=".csv,.xlsx,.json" style={{ display: 'none' }}
            onChange={() => { setImportDone(true); setTimeout(() => setImportDone(false), 3000); }} />
        </label>
      </div>

      {/* Activity summary of the active patient */}
      <div style={{
        position: 'relative', zIndex: 10,
        marginTop: 12, padding: '0 2px',
        ...activeSt,
      }}>
        <div style={{
          background: PF_SURF, border: `1px solid ${PF_HAIR}`,
          borderRadius: 16, padding: '12px 14px',
          display: 'flex', flexDirection: 'column', gap: 10,
        }}>
          <div style={{ fontSize: 12, fontWeight: 600, color: PF_SUB, letterSpacing: '0.3px', textTransform: 'uppercase' }}>
            Activité
          </div>
          {/* Next RDV */}
          {active.nextRdv && (
            <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
              <div style={{
                width: 30, height: 30, borderRadius: 8,
                background: 'rgba(10,10,10,0.05)',
                display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
              }}>
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
                  stroke="#0a0a0a" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                  <rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
                </svg>
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 12.5, fontWeight: 600, color: PF_INK, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                  {active.nextRdv.what}
                </div>
                <div style={{ fontSize: 11, fontWeight: 500, color: PF_SUB, marginTop: 1 }}>
                  {active.nextRdv.when}
                </div>
              </div>
            </div>
          )}
          {/* Last document */}
          {active.documents && active.documents[0] && (
            <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
              <div style={{
                width: 30, height: 30, borderRadius: 8,
                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="#0a0a0a" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                  <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" />
                </svg>
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 12.5, fontWeight: 600, color: PF_INK, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                  {active.documents[0].label.replace(/^(CR[OCH]|EXAM|IMG|ORD)\s—\s/, '')}
                </div>
                <div style={{ fontSize: 11, fontWeight: 500, color: PF_SUB, marginTop: 1 }}>
                  {active.documents[0].d}
                </div>
              </div>
            </div>
          )}
          {/* Alert / flags */}
          {active.flags.length > 0 && (
            <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
              <div style={{
                width: 30, height: 30, borderRadius: 8,
                background: 'rgba(185,28,28,0.06)',
                display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
              }}>
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
                  stroke={PF_ALERT} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M12 4l10 17H2z" /><path d="M12 11v4" /><circle cx="12" cy="18" r="0.5" fill={PF_ALERT} />
                </svg>
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 12.5, fontWeight: 600, color: PF_INK, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                  {active.flags.join(', ')}
                </div>
                <div style={{ fontSize: 11, fontWeight: 500, color: PF_SUB, marginTop: 1 }}>
                  Alertes actives
                </div>
              </div>
            </div>
          )}
          {/* CRs count */}
          <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
            <div style={{
              width: 30, height: 30, borderRadius: 8,
              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="#0a0a0a" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                <path d="M3 3v18h18" /><path d="M7 14l4-4 4 4 5-6" />
              </svg>
            </div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontSize: 12.5, fontWeight: 600, color: PF_INK }}>
                {active.crsCount || 0} comptes rendus
              </div>
              <div style={{ fontSize: 11, fontWeight: 500, color: PF_SUB, marginTop: 1 }}>
                {active.stats ? `Depuis ${active.stats.firstVisit}` : 'Total'}
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Wallet-style card stack — replaces FullStack in the "stack" view
// Cards fan vertically like Apple Wallet: peeks show the top strip,
// tap a peek to promote it, tap the front card to open a quick-actions sheet.
// ─────────────────────────────────────────────────────────────

const WALLET_STATUS_META = {
  hosp:      { label: 'HOSPITALISÉ',  bg: 'rgba(10,10,10,0.78)', fg: '#fff' },
  rea:       { label: 'RÉANIMATION',  bg: '#b91c1c',              fg: '#fff' },
  ambu:      { label: 'AMBULATOIRE',  bg: PF_OK_TINT,             fg: PF_OK },
  palliatif: { label: 'PALLIATIF',    bg: 'rgba(10,10,10,0.10)',  fg: 'rgba(10,10,10,0.6)' },
  decede:    { label: 'DÉCÉDÉ',       bg: 'rgba(10,10,10,0.10)',  fg: 'rgba(10,10,10,0.6)' },
};

function WalletStatusChip({ status }) {
  const s = WALLET_STATUS_META[status];
  if (!s) return null;
  return (
    <span className="mono" style={{
      display: 'inline-flex', alignItems: 'center',
      padding: '3px 9px', background: s.bg, color: s.fg,
      borderRadius: 999,
      fontSize: 9.5, fontWeight: 700, letterSpacing: '0.5px',
    }}>{s.label}</span>
  );
}

function PatientCard({ patient, color, tall = false, active = false }) {
  return (
    <div style={{
      width: '100%', height: tall ? 220 : 200,
      borderRadius: 22,
      // Sélection en bleu clinique : dans cette app le rouge ne signifie que le
      // danger. Les cartes au repos gardent leur crème « dossier papier ».
      background: active
        ? 'linear-gradient(145deg, #F8FBFD 0%, #E9F1F7 100%)'
        : 'linear-gradient(145deg, #FFFDFC 0%, #F7F2EF 100%)',
      border: active
        ? `1.5px solid ${PF_MED}`
        : '1px solid #E6DDDA',
      boxShadow: active
        ? '0 4px 14px rgba(61, 107, 143, 0.18)'
        : '0 2px 8px rgba(0, 0, 0, 0.06)',
      padding: '16px 18px',
      position: 'relative',
      overflow: 'hidden',
      display: 'flex', flexDirection: 'column',
      justifyContent: 'space-between',
    }}>
      {/* Top row — avatar + civ/age + name */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
        <div style={{
          width: 46, height: 46, borderRadius: '50%',
          overflow: 'hidden', position: 'relative',
          boxShadow: '0 2px 6px rgba(10,10,10,0.15), 0 0 0 2px rgba(255,255,255,0.65)',
          flexShrink: 0,
        }}>
          <Portrait patient={patient} rounded />
        </div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div className="mono" style={{
            fontSize: 9.5, fontWeight: 600,
            color: 'rgba(10,10,10,0.55)',
            letterSpacing: '0.5px', textTransform: 'uppercase',
          }}>
            {patient.civ || ''} · {patient.age} ans
          </div>
          <div style={{
            fontSize: 19, fontWeight: 700, color: '#0a0a0a',
            letterSpacing: '-0.4px', marginTop: 2, lineHeight: 1.1,
            whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
          }}>
            {patient.last} <span style={{ fontWeight: 500 }}>{patient.first}</span>
          </div>
        </div>
      </div>

      {/* Middle — summary */}
      <div style={{
        fontSize: 12.5, fontWeight: 500,
        color: 'rgba(10,10,10,0.72)', lineHeight: 1.35,
        display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
        overflow: 'hidden',
      }}>
        {patient.summary || '—'}
      </div>

      {/* Bottom row — status chip + bed */}
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
        <WalletStatusChip status={patient.status} />
        {patient.bed && patient.bed !== '—' && (
          <div className="mono" style={{
            fontSize: 10.5, fontWeight: 600,
            color: 'rgba(10,10,10,0.6)', letterSpacing: '0.4px',
          }}>{patient.bed}</div>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Focus panel — patient info + inline action list (shown under focused card)
// ─────────────────────────────────────────────────────────────
function FocusDetails({ patient, onOpen, onNewAppointment, onNewNote }) {
  // Déclaré avant le retour anticipé : un crochet appelé sous condition change
  // de rang d'un rendu à l'autre, et React s'arrête.
  const [doneAction, markDone] = useActionDone();

  if (!patient) return null;
  const Icon = ({ d }) => (
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none"
      stroke={PF_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">{d}</svg>
  );
  // Le serveur refuse un rendez-vous pour un patient décédé : le dire ici évite
  // trois écrans de saisie pour finir sur un refus.
  const deceased = patient.status === 'decede';

  // La copie ne se voit nulle part ailleurs : le bouton porte sa confirmation.
  // Le presse-papier peut ne jamais répondre — permission en attente, fenêtre
  // sans le focus : sans délai de garde, le bouton resterait muet.
  const copyIPP = async () => {
    try {
      await copyToClipboard(patient.ipp);
      markDone('ipp');
    } catch (e) {
      showToast('Copie impossible — presse-papier refusé', 'error');
    }
  };

  const actions = [
    { id: 'view',  label: 'Voir le dossier',
      icon: <Icon d={<><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>} />,
      onClick: onOpen },
    // Le rendez-vous et la note ouvrent les écrans qui les créent vraiment, le
    // dossier déjà désigné : c'est le même geste que depuis l'agenda ou le
    // dossier, en partant de la carte qu'on a sous les yeux.
    { id: 'rdv',   label: 'Nouveau rendez-vous', disabled: deceased, reason: deceased ? 'Patient décédé' : null,
      icon: <Icon d={<><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></>} />,
      onClick: () => {
        if (deceased) { showToast('Rendez-vous impossible — patient décédé', 'error'); return; }
        onNewAppointment(patient);
      } },
    // Le serveur refuse aussi l'appel d'un patient décédé : la carte ne peut pas
    // ouvrir un appel que le dossier interdit deux écrans plus loin.
    { id: 'call',  label: patient.phone ? `Appeler ${patient.phone}` : 'Appeler', disabled: deceased,
      reason: deceased ? 'Patient décédé' : null,
      icon: <Icon d={<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"/>} />,
      onClick: () => {
        if (deceased) { showToast('Appel impossible — patient décédé', 'error'); return; }
        requestCall(`${patient.last} ${patient.first}`, patient.phone);
      } },
    { id: 'note',  label: 'Nouvelle note',
      icon: <Icon d={<><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4z"/></>} />,
      onClick: () => onNewNote(patient) },
    { id: 'ipp',   label: "Copier l'IPP",
      icon: <Icon d={<><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></>} />,
      onClick: copyIPP },
    // « Partager » a été retiré : le serveur refuse le partage (share=false),
    // faute de canal de messagerie de santé. Une ligne de menu qui ne mène
    // qu'à un refus n'a pas sa place ici.
  ];

  const kv = [
    patient.summary && ['Résumé', patient.summary],
    patient.blood && ['Groupe', patient.blood],
    patient.allergies?.length > 0 && ['Allergies', patient.allergies.join(', ')],
    patient.nextRdv && ['Prochain RDV', `${patient.nextRdv.when} · ${patient.nextRdv.what}`],
    patient.bed && patient.bed !== '—' && ['Lit', patient.bed],
  ].filter(Boolean);

  return (
    <div style={{ padding: '16px 4px 24px', animation: 'focusFadeIn .35s ease .1s both' }}>
      {/* Title */}
      <div style={{ padding: '0 12px 12px' }}>
        <div style={{ fontSize: 22, fontWeight: 700, color: PF_INK, letterSpacing: '-0.5px', lineHeight: 1.1 }}>
          {patient.last} <span style={{ fontWeight: 500 }}>{patient.first}</span>
        </div>
        <div className="mono" style={{
          fontSize: 10.5, fontWeight: 600, color: PF_SUB,
          marginTop: 6, letterSpacing: '0.4px', textTransform: 'uppercase',
        }}>
          {patient.civ || ''} · {patient.age} ans{patient.ipp ? ` · IPP ${patient.ipp}` : ''}
        </div>
      </div>

      {/* Info block */}
      {kv.length > 0 && (
        <div style={{
          background: PF_SURF, borderRadius: 16,
          border: `1px solid ${PF_HAIR}`,
          padding: '14px 16px', margin: '0 8px 12px',
          boxShadow: VT.shadowSm,
        }}>
          {kv.map(([k, v], i) => (
            <div key={k} style={{
              display: 'flex', alignItems: 'flex-start', gap: 12,
              padding: '8px 0',
              borderTop: i === 0 ? 'none' : `1px solid ${PF_HAIR}`,
            }}>
              <div className="mono" style={{
                fontSize: 10, fontWeight: 600, color: PF_SUB,
                letterSpacing: '0.4px', textTransform: 'uppercase',
                width: 96, flexShrink: 0, paddingTop: 2,
              }}>{k}</div>
              <div style={{ fontSize: 13.5, fontWeight: 500, color: PF_INK, lineHeight: 1.4, flex: 1, minWidth: 0 }}>
                {v}
              </div>
            </div>
          ))}
        </div>
      )}

      {/* Actions */}
      <div style={{
        background: PF_SURF, borderRadius: 16,
        border: `1px solid ${PF_HAIR}`,
        margin: '0 8px',
        boxShadow: VT.shadowSm,
        overflow: 'hidden',
      }}>
        {/* Une ligne éteinte dit pourquoi à l'écran, pas au clic : la raison ne
            vivait que dans le toast qui suivait l'appui, donc jamais pour qui
            regarde le panneau sans y toucher. Elle se lit maintenant sous le
            libellé, et l'`aria-label` la porte pour la synthèse vocale. Seuls
            l'icône et le libellé s'éteignent : la raison, elle, doit rester
            lisible — c'est la seule chose qui reste à faire de cette ligne. */}
        {actions.map((a, i) => (
          <button key={a.id} onClick={a.onClick} className="press" aria-disabled={!!a.disabled}
            aria-label={a.disabled && a.reason ? `${a.label} — ${a.reason}` : undefined}
            title={a.disabled && a.reason ? `${a.label} — ${a.reason}` : undefined}
            style={{
              display: 'flex', alignItems: 'center', gap: 14,
              width: '100%', padding: '13px 16px',
              background: 'none', border: 'none', cursor: 'pointer',
              fontFamily: 'inherit', fontSize: 14.5, fontWeight: 500,
              color: PF_INK, textAlign: 'left',
              borderTop: i === 0 ? 'none' : `1px solid ${PF_HAIR}`,
            }}>
            <span style={{ display: 'flex', alignItems: 'center', width: 22, opacity: a.disabled ? 0.45 : 1 }}>{a.icon}</span>
            <span style={{ flex: 1, minWidth: 0 }}>
              <span style={{
                display: 'block',
                color: doneAction === a.id ? PF_OK : undefined,
                opacity: a.disabled ? 0.45 : 1,
              }}>
                {doneAction === a.id ? `IPP ${patient.ipp} copié` : a.label}
              </span>
              {a.disabled && a.reason && (
                <span style={{
                  display: 'block', marginTop: 2,
                  fontSize: 12, fontWeight: 600, color: PF_SUB, lineHeight: 1.35,
                }}>{a.reason}</span>
              )}
            </span>
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
              stroke={PF_SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"
              style={{ opacity: a.disabled ? 0.45 : 1 }}>
              <path d="M9 6l6 6-6 6"/>
            </svg>
          </button>
        ))}
      </div>
      <style>{`@keyframes focusFadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }`}</style>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// WalletCards — fanned stack (PassStack-style) with focus mode
// Ported from https://github.com/… PassStack SwiftUI reference:
// deck view: cards offset y=i*STEP; focus view: selected zooms to top,
// prior cards collapse behind (scaled+blurred), later cards slide off.
// ─────────────────────────────────────────────────────────────
function WalletCards({ patients, onNewPatient, onOpenPatient, onNewAppointment, onNewNote }) {
  const total = patients.length;
  const [selectedId, setSelectedId] = React.useState(null);
  const scrollRef = React.useRef(null);

  // Reset selection when list changes and selected patient is gone
  React.useEffect(() => {
    if (selectedId && !patients.find(p => p.id === selectedId)) {
      setSelectedId(null);
    }
  }, [patients, selectedId]);

  // Scroll to top when entering/leaving focus
  React.useEffect(() => {
    if (scrollRef.current) scrollRef.current.scrollTop = 0;
  }, [selectedId]);

  if (total === 0) {
    return (
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: PF_SUB, fontSize: 14 }}>
        Aucun patient trouvé
      </div>
    );
  }

  const STEP = 70;              // Vertical offset between stacked cards
  const CARD_H = 206;           // Card height (matches Swift PassView 206pt)
  const SIDE_PAD = 22;
  const OFF_SCREEN = 900;       // Where "after" cards fly to
  const selectedIdx = selectedId ? patients.findIndex(p => p.id === selectedId) : -1;
  const isFocused = selectedIdx >= 0;
  const selected = isFocused ? patients[selectedIdx] : null;

  // Deck geometry
  const deckHeight = isFocused ? CARD_H : (total - 1) * STEP + CARD_H;

  return (
    <div ref={scrollRef} style={{
      flex: 1,
      overflowY: 'auto', overflowX: 'hidden',
      position: 'relative',
      WebkitOverflowScrolling: 'touch',
    }}>
      {/* Top-right chrome — Done (focus) or + Nouveau (deck) */}
      <div style={{
        position: 'sticky', top: 0, height: 0,
        zIndex: 500,
      }}>
        <div style={{
          position: 'absolute', top: 8, right: 14,
          display: 'flex', alignItems: 'center', gap: 8,
        }}>
          {isFocused ? (
            <button onClick={() => setSelectedId(null)} className="press" style={{
              height: 30, padding: '0 14px', borderRadius: 999,
              background: PF_INK, color: '#fff', border: 'none',
              fontFamily: 'inherit', fontSize: 12, fontWeight: 700,
              cursor: 'pointer', letterSpacing: '0.2px',
              boxShadow: '0 2px 6px rgba(10,10,10,0.15)',
            }}>OK</button>
          ) : (
            <>
              <div style={{ fontSize: 11, fontWeight: 600, color: PF_SUB, letterSpacing: '0.3px' }}>
                {total}
              </div>
              <button onClick={() => onNewPatient && onNewPatient()} className="press" title="Nouveau patient" style={{
                width: 32, height: 32, borderRadius: '50%',
                background: PF_SURF, border: `1px solid ${PF_HAIR2}`,
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                cursor: 'pointer', padding: 0,
                boxShadow: '0 1px 3px rgba(10,10,10,0.08)',
              }}>
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
                  stroke={PF_INK} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M12 5v14M5 12h14"/>
                </svg>
              </button>
            </>
          )}
        </div>
      </div>

      {/* Deck container */}
      <div style={{
        position: 'relative',
        height: deckHeight,
        margin: `44px ${SIDE_PAD}px 0`,
        transition: 'height .55s cubic-bezier(.34,1.15,.64,1)',
      }}>
        {patients.map((p, i) => {
          const isSel = p.id === selectedId;
          const isBefore = isFocused && selectedIdx > i;
          const isAfter = isFocused && !isSel && !isBefore;

          // translateY: default fanned; focus → selected/before at 0, after off-screen
          const ty =
            !isFocused ? i * STEP :
            (isSel || isBefore) ? 0 :
            OFF_SCREEN;

          // Slight scale reduction for "before" cards to hint depth in focus
          const reversedIdx = total - 1 - i;
          const displayScale = (reversedIdx / total) * 0.15;
          const sc = isBefore ? (1 - displayScale) : 1;

          // Blur & opacity: only selected fully visible in focus; before-cards subtly dim
          const op = isAfter ? 0 : 1;
          const bl = isBefore ? 6 : 0;

          return (
            <div key={p.id}
              onClick={() => setSelectedId(prev => prev === p.id ? null : p.id)}
              style={{
                position: 'absolute', left: 0, right: 0, top: 0,
                height: CARD_H,
                transform: `translateY(${ty}px) scale(${sc})`,
                transformOrigin: 'center top',
                opacity: op,
                filter: bl > 0 ? `blur(${bl}px)` : 'none',
                zIndex: i + 1,
                cursor: 'pointer',
                transition: 'transform .55s cubic-bezier(.34,1.15,.64,1), opacity .35s ease, filter .35s ease',
                willChange: 'transform, opacity, filter',
              }}>
              <PatientCard patient={p} color={folderFor(p._idx)} active={isSel} />
            </div>
          );
        })}
      </div>

      {/* Focus panel — patient info + actions */}
      {isFocused && (
        <FocusDetails
          patient={selected}
          onOpen={() => { onOpenPatient(selected.id); setSelectedId(null); }}
          onNewAppointment={() => { onNewAppointment(selected.id); setSelectedId(null); }}
          onNewNote={() => { onNewNote(selected.id); setSelectedId(null); }}
        />
      )}

      {/* Marge de fin — laisse passer le voile du bas d'écran sur du vide.
          Elle valait 40 px en mode focus : le voile, qui mord de 130 px dans le
          défilement, recouvrait alors la dernière rangée du panneau — « Copier
          l'IPP » ne se lisait jamais en clair, et le gris lui donnait l'air
          d'un contrôle refusé, alors que les refus veulent dire quelque chose
          depuis qu'ils portent leur raison. On garde le voile tel quel, indice
          de défilement et raccord sous la barre de recherche : c'est la liste
          qui lui cède la place. */}
      <div data-vasco="list-tail" style={{ height: PF_VEIL_REACH }} />
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Action pills — Partager / Paramètres
// ─────────────────────────────────────────────────────────────
function ActionPill({ label, icon, onClick }) {
  return (
    <button onClick={onClick} className="press" style={{
      flex: 1,
      display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
      height: 44,
      background: PF_SURF, border: `1px solid ${PF_HAIR2}`,
      borderRadius: 22,
      cursor: 'pointer', fontFamily: 'inherit',
      fontSize: 14, fontWeight: 500, color: PF_INK
    }}>
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
      stroke={PF_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">{icon}</svg>
      {label}
    </button>);

}

// ─────────────────────────────────────────────────────────────
// Alert banner
// ─────────────────────────────────────────────────────────────
function AlertBanner({ text, onClick }) {
  return (
    <button onClick={onClick} className="press" style={{
      width: '100%', textAlign: 'left',
      display: 'flex', alignItems: 'center', gap: 12,
      background: PF_SURF, border: `1px solid ${PF_HAIR}`,
      borderRadius: 16,
      boxShadow: VT.shadowSm,
      padding: '10px 14px', cursor: 'pointer', fontFamily: 'inherit'
    }}>
      <div style={{
        width: 28, height: 28, borderRadius: 8,
        background: 'rgba(185,28,28,0.06)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        flexShrink: 0
      }}>
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
        stroke={PF_ALERT} strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
          <path d="M12 4l10 17H2z" /><path d="M12 11v4" /><circle cx="12" cy="18" r="0.5" fill={PF_ALERT} />
        </svg>
      </div>
      <div style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, color: PF_INK, lineHeight: 1.3 }}>
        {text}
      </div>
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
      stroke={PF_SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
        <path d="M9 6l6 6-6 6" />
      </svg>
    </button>);

}

// ─────────────────────────────────────────────────────────────
// Document row
// ─────────────────────────────────────────────────────────────
function DocRow({ doc }) {
  const t = DOC_TYPES[doc.type] || DOC_TYPES.COUR;
  return (
    <div style={{
      display: 'flex', alignItems: 'center', gap: 12,
      padding: '10px 4px'
    }}>
      <DocIcon type={doc.type} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{
          fontSize: 14.5, fontWeight: 600, color: PF_INK, letterSpacing: '-0.1px',
          whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'
        }}>{doc.label.replace(/^(CR[OCH]|EXAM|IMG|ORD)\s—\s/, '')}</div>
        <div style={{ fontSize: 12, fontWeight: 500, color: PF_SUB, marginTop: 2 }}>
          {doc.d} · {t.label}
        </div>
      </div>
      <button className="press" style={{
        background: 'none', border: 'none', cursor: 'pointer',
        padding: 4, color: PF_SUB
      }}>
        <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
          <circle cx="5" cy="12" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="19" cy="12" r="1.5" />
        </svg>
      </button>
    </div>);

}

// ─────────────────────────────────────────────────────────────
// Grid view — 2 columns of mini folders
// ─────────────────────────────────────────────────────────────
function FolderGrid({ patients, onBack, onPick }) {
  const [query, setQuery] = React.useState('');
  const visible = patients.filter((p) =>
  !query || `${p.last} ${p.first}`.toLowerCase().includes(query.toLowerCase()));
  return (
    <div style={{
      width: '100%', height: '100%', background: PF_BG, color: PF_INK,
      display: 'flex', flexDirection: 'column',
      fontFamily: "'Inter Tight', -apple-system, system-ui, sans-serif"
    }}>
      <div style={{ paddingTop: 62 }}>
        <div style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          padding: '4px 16px 8px'
        }}>
          <button onClick={onBack} className="press" style={{
            width: 36, height: 36, borderRadius: '50%',
            background: PF_SURF, border: `1px solid ${PF_HAIR}`,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            cursor: 'pointer', padding: 0
          }}>
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none"
            stroke={PF_INK} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <path d="M15 6l-6 6 6 6" />
            </svg>
          </button>
          <div style={{ fontSize: 16, fontWeight: 600, color: PF_INK, letterSpacing: '-0.2px' }}>
            Dossiers patients
          </div>
          <div style={{ width: 36 }} />
        </div>
        <div style={{ padding: '6px 16px 12px' }}>
          <div style={{
            display: 'flex', alignItems: 'center', gap: 8,
            background: PF_SURF, border: `1px solid ${PF_HAIR}`,
            borderRadius: 16, padding: '10px 14px'
          }}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
            stroke={PF_SUB} strokeWidth="1.8" 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="Rechercher un dossier…"
            style={{
              flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none',
              fontFamily: 'inherit', fontSize: 14, color: PF_INK
            }} />
          </div>
        </div>
      </div>
      <div className="vasco-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 16px 24px' }}>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 14 }}>
          {visible.map((p) =>
          <button key={p.id} onClick={() => onPick(p.id)} className="press" style={{
            background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
            padding: 0, textAlign: 'left'
          }}>
              <div style={{ position: 'relative' }}>
                <Folder patient={p} withPolaroid={true} showLabel={false} alert={p.flags.includes('AVK')} />
              </div>
              <div style={{
              marginTop: 8, padding: '0 4px',
              display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6
            }}>
                <div style={{
                fontSize: 13.5, fontWeight: 600, color: PF_INK,
                whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'
              }}>
                  {p.last.charAt(0) + p.last.slice(1).toLowerCase()} {p.first[0]}.
                </div>
                <span style={{
                background: 'rgba(10,10,10,0.04)', color: PF_SUB,
                borderRadius: 999, padding: '2px 8px',
                fontSize: 11, fontWeight: 600
              }}>{(p.documents || []).length}</span>
              </div>
            </button>
          )}
        </div>
      </div>
    </div>);

}

// ─────────────────────────────────────────────────────────────
// Main carousel screen
// ─────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────
// List view — compact rows
// ─────────────────────────────────────────────────────────────
function PatientListView({ patients, onPick, loading = false, searching = false }) {
  if (loading) {
    return (
      <div className="vasco-scroll" style={{ flex: 1, overflowY: 'auto', padding: '4px 16px 150px' }}>
        <SkeletonPatientRows count={6} />
      </div>
    );
  }
  return (
    <div className="vasco-scroll" style={{
      flex: 1, overflowY: 'auto', padding: '4px 16px 150px',
      // Recherche en cours : on garde les résultats en place et on les estompe,
      // au lieu de les remplacer par un squelette qui clignoterait à chaque touche.
      opacity: searching ? 0.45 : 1, transition: 'opacity .18s ease',
    }}>
      {patients.map((p) => {
        const c = folderFor(p._idx);
        const hasAlert = p.flags.includes('AVK') || p.flags.includes('DNR') || p.status === 'rea';
        return (
          <button key={p.id} onClick={() => onPick(p.id)} className="press" style={{
            width: '100%', display: 'flex', alignItems: 'center', gap: 12,
            background: PF_SURF, border: `1px solid ${PF_HAIR}`,
            borderRadius: 16, padding: '10px 14px', marginBottom: 8,
            cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
          }}>
            {/* Color dot */}
            <div style={{
              width: 36, height: 36, borderRadius: 12,
              background: c.body, border: `1px solid ${c.spine}`,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              flexShrink: 0, position: 'relative',
            }}>
              <span style={{ fontSize: 11, fontWeight: 700, color: 'rgba(10,10,10,0.5)' }}>
                {p.first[0]}{p.last[0]}
              </span>
              {hasAlert && <div style={{ position: 'absolute', top: -2, right: -2, width: 7, height: 7, borderRadius: '50%', background: PF_ALERT, border: '1.5px solid #fff' }} />}
            </div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontSize: 14, fontWeight: 600, color: PF_INK, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                {p.first} {p.last.charAt(0) + p.last.slice(1).toLowerCase()}
              </div>
              <div style={{ fontSize: 11.5, fontWeight: 500, color: PF_SUB, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                {p.age} ans · {p.summary ? p.summary.slice(0, 35) + (p.summary.length > 35 ? '…' : '') : ''}
              </div>
            </div>
            <span style={{
              background: 'rgba(10,10,10,0.04)', color: PF_SUB,
              borderRadius: 999, padding: '2px 7px',
              fontSize: 10, fontWeight: 600, flexShrink: 0,
            }}>{p.crsCount || 0}</span>
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
              stroke={PF_SUB} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <path d="M9 6l6 6-6 6" />
            </svg>
          </button>
        );
      })}
      {patients.length === 0 && (
        <div style={{ textAlign: 'center', padding: '40px 0', color: PF_SUB, fontSize: 14 }}>Aucun patient trouvé</div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Icon/Grid view — avatar circles in a grid
// ─────────────────────────────────────────────────────────────
function PatientIconView({ patients, onPick, loading = false, searching = false }) {
  if (loading) {
    return (
      <div className="vasco-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 16px 150px' }}>
        <SkeletonIconGrid count={9} />
      </div>
    );
  }
  return (
    <div className="vasco-scroll" style={{
      flex: 1, overflowY: 'auto', padding: '8px 16px 150px',
      opacity: searching ? 0.45 : 1, transition: 'opacity .18s ease',
    }}>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
        {patients.map((p) => {
          const c = folderFor(p._idx);
          const hasAlert = p.flags.includes('AVK') || p.flags.includes('DNR') || p.status === 'rea';
          return (
            <button key={p.id} onClick={() => onPick(p.id)} className="press" style={{
              background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
              padding: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
            }}>
              <div style={{ position: 'relative' }}>
                <div style={{
                  width: 64, height: 64, borderRadius: '50%',
                  background: c.body, border: `2px solid ${c.spine}`,
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                  overflow: 'hidden', position: 'relative',
                }}>
                  <Portrait patient={p} rounded />
                </div>
                {hasAlert && <div style={{ position: 'absolute', top: 0, right: 0, width: 10, height: 10, borderRadius: '50%', background: PF_ALERT, border: `2px solid ${PF_BG}` }} />}
              </div>
              <div style={{ textAlign: 'center' }}>
                <div style={{ fontSize: 12, fontWeight: 600, color: PF_INK, lineHeight: 1.2 }}>
                  {p.first[0]}. {p.last.charAt(0) + p.last.slice(1).toLowerCase()}
                </div>
                <div style={{ fontSize: 10, fontWeight: 500, color: PF_SUB, marginTop: 1 }}>
                  {p.crsCount || 0} CRs
                </div>
              </div>
            </button>
          );
        })}
      </div>
      {patients.length === 0 && (
        <div style={{ textAlign: 'center', padding: '40px 0', color: PF_SUB, fontSize: 14 }}>Aucun patient trouvé</div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// View mode toggle — 3 icons
// ─────────────────────────────────────────────────────────────
// ── Sélecteur de tri ──
function SortPicker({ value, onChange }) {
  const [open, setOpen] = React.useState(false);
  const current = SORT_OPTIONS.find((o) => o.id === value) || SORT_OPTIONS[0];

  // Un tri autre que le défaut est signalé par une pastille, faute de libellé.
  const active = value !== SORT_OPTIONS[0].id;

  // Le bouton n'est pas collé au bord droit : sur écran étroit, un menu aligné
  // à droite sort de l'écran par la gauche. On mesure et on recale.
  const menuRef = React.useRef(null);
  const [shift, setShift] = React.useState(0);

  React.useLayoutEffect(() => {
    if (!open) { setShift(0); return; }
    const el = menuRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const margin = 8;
    if (r.left < margin) setShift(margin - r.left);
    else if (r.right > window.innerWidth - margin) setShift(window.innerWidth - margin - r.right);
  }, [open]);

  return (
    <div style={{ position: 'relative', flexShrink: 0 }}>
      <button onClick={() => setOpen((o) => !o)} className="press"
        aria-label="Trier les patients" title={`Trier : ${current.label}`}
        style={{
          width: 34, height: 32, borderRadius: 12,
          background: 'rgba(10,10,10,0.04)', border: 'none',
          cursor: 'pointer', padding: 0, position: 'relative',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
        }}>
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
          stroke={active ? PF_MED : PF_SUB}
          strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
          <path d="M7 4v16M7 20l-3-3M7 4l3 3M17 20V4M17 4l3 3M17 20l-3-3" />
        </svg>
        {active && (
          <span style={{
            position: 'absolute', top: 5, right: 5,
            width: 5, height: 5, borderRadius: '50%', background: PF_MED,
          }} />
        )}
      </button>

      {open && (
        <>
          <div onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 30 }} />
          {/* Aligné à droite : le bouton est dans le coin haut droit. */}
          <div ref={menuRef} style={{
            position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 31,
            transform: shift ? `translateX(${shift}px)` : 'none',
            background: PF_SURF, borderRadius: 12,
            border: `1px solid ${PF_HAIR2}`,
            boxShadow: PF_VT.shadowLg || '0 12px 32px rgba(10,10,10,0.12)',
            padding: 4, minWidth: 232, overflow: 'hidden',
          }}>
            {SORT_OPTIONS.map((o) => {
              const on = o.id === value;
              return (
                <button key={o.id} className="press"
                  onClick={() => { onChange(o.id); setOpen(false); }}
                  style={{
                    width: '100%', display: 'flex', alignItems: 'center', gap: 9,
                    background: on ? PF_MED_TINT : 'none', border: 'none',
                    borderRadius: 8, padding: '9px 10px', cursor: 'pointer',
                    fontFamily: 'inherit', textAlign: 'left',
                  }}>
                  <span style={{
                    width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
                    background: on ? PF_MED : 'rgba(10,10,10,0.18)',
                  }} />
                  <span style={{ flex: 1, minWidth: 0 }}>
                    <span style={{
                      display: 'block', fontSize: 13, fontWeight: 600,
                      color: on ? PF_MED : PF_INK,
                    }}>{o.label}</span>
                    <span style={{
                      display: 'block', fontSize: 11, fontWeight: 500, color: PF_SUB,
                      marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                    }}>{o.sub}</span>
                  </span>
                </button>
              );
            })}
          </div>
        </>
      )}
    </div>
  );
}

function ViewToggle({ mode, onChange }) {
  const modes = [
    { id: 'stack', icon: <><rect x="4" y="4" width="16" height="4" rx="1" /><rect x="6" y="10" width="12" height="4" rx="1" /><rect x="8" y="16" width="8" height="4" rx="1" /></> },
    { id: 'list', icon: <><line x1="4" y1="6" x2="20" y2="6" /><line x1="4" y1="12" x2="20" y2="12" /><line x1="4" y1="18" x2="20" y2="18" /></> },
    { id: 'icons', icon: <><circle cx="7" cy="7" r="3" /><circle cx="17" cy="7" r="3" /><circle cx="7" cy="17" r="3" /><circle cx="17" cy="17" r="3" /></> },
  ];
  return (
    <div style={{
      display: 'flex', gap: 2,
      background: 'rgba(10,10,10,0.04)', borderRadius: 12, padding: 2,
    }}>
      {modes.map((m) => (
        <button key={m.id} onClick={() => onChange(m.id)} className="press" style={{
          width: 30, height: 28, borderRadius: 8,
          background: mode === m.id ? PF_SURF : 'transparent',
          border: mode === m.id ? `1px solid ${PF_HAIR}` : '1px solid transparent',
          boxShadow: mode === m.id ? '0 1px 3px rgba(10,10,10,0.08)' : 'none',
          cursor: 'pointer', padding: 0,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          transition: 'all .15s ease',
        }}>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
            stroke={mode === m.id ? PF_INK : PF_SUB} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
            {m.icon}
          </svg>
        </button>
      ))}
    </div>
  );
}

function PatientFolders({ accent, onOpenPatient, onNewPatient, onBack, onTabChange,
  onNewAppointment, onNewNote }) {
  const patients = React.useMemo(
    () => PATIENTS_FULL.map((p, i) => ({ ...p, _idx: i })),
    []);
  const [searchQuery, setSearchQuery] = React.useState('');
  const [viewMode, setViewMode] = React.useState('stack'); // stack | list | icons
  const [sortId, setSortId] = React.useState('name');

  // Recherche puis tri. On copie avant de trier : sort() modifie le tableau
  // sur place, et `patients` vient du dataset global partagé.
  const filtered = React.useMemo(() => {
    const q = searchQuery.trim().toLowerCase();
    const base = !q ? patients : patients.filter((p) =>
      `${p.first} ${p.last}`.toLowerCase().includes(q) ||
      `${p.last} ${p.first}`.toLowerCase().includes(q)
    );
    const opt = SORT_OPTIONS.find((o) => o.id === sortId) || SORT_OPTIONS[0];
    return [...base].sort(opt.cmp);
  }, [patients, searchQuery, sortId]);

  // Latence simulée. Deux régimes distincts, parce que le ressenti n'est pas le
  // même : changer de vue ou de tri recharge tout (squelette), taper dans la
  // recherche affine (résultats estompés + spinner dans la barre).
  const loading = useMockLoad(`${viewMode}|${sortId}`);
  const searching = useTypingLoad(searchQuery);
  const busy = loading || searching;

  return (
    <div style={{
      width: '100%', height: '100%', background: PF_BG, color: PF_INK,
      display: 'flex', flexDirection: 'column',
      fontFamily: "'Inter Tight', -apple-system, system-ui, sans-serif"
    }}>
      {/* Header */}
      <div style={{
        paddingTop: 62, paddingBottom: 4,
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        padding: '62px 16px 8px'
      }}>
        <div style={{ fontSize: 38, fontWeight: 700, color: PF_INK, letterSpacing: '-1.2px', lineHeight: 1 }}>
          Patients <span style={{ fontWeight: 500, color: PF_SUB, fontSize: 16 }}>({busy ? '…' : filtered.length})</span>
        </div>
        {/* Tri + vue, regroupés : les cartes gardent leur position d'origine. */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
          <SortPicker value={sortId} onChange={setSortId} />
          <ViewToggle mode={viewMode} onChange={setViewMode} />
        </div>
      </div>

      {/* Content based on view mode */}
      {viewMode === 'stack' && (
        loading ? (
          // Les cartes Wallet sont trop composées (portrait, pile, rotation)
          // pour un squelette honnête : un bloc centré est plus lisible.
          <LoadingBlock label="Chargement des dossiers…" color={accent} />
        ) : (
          <WalletCards
            patients={filtered}
            onNewPatient={onNewPatient}
            onOpenPatient={(id) => onOpenPatient(id)}
            onNewAppointment={(id) => onNewAppointment(id)}
            onNewNote={(id) => onNewNote(id)}
          />
        )
      )}
      {viewMode === 'list' && (
        <PatientListView patients={filtered} onPick={(id) => onOpenPatient(id)}
          loading={loading} searching={searching} />
      )}
      {viewMode === 'icons' && (
        <PatientIconView patients={filtered} onPick={(id) => onOpenPatient(id)}
          loading={loading} searching={searching} />
      )}

      {/* Voile dégradé sous la barre de recherche : elle flotte au-dessus de la
          liste et lui recouvrait 51px. Les lignes s'estompent maintenant dans le
          fond au lieu d'être tranchées net à mi-hauteur. */}
      <div data-vasco="bottom-veil" style={{
        position: 'absolute', bottom: PF_VEIL_BOTTOM, left: 0, right: 0, height: PF_VEIL_H,
        background: `linear-gradient(to bottom, rgba(246,245,241,0) 0%, rgba(246,245,241,0.75) 42%, ${PF_BG} 68%, ${PF_BG} 100%)`,
        pointerEvents: 'none', zIndex: 19,
      }} />

      {/* Search bar fixed at bottom */}
      <div style={{
        position: 'absolute', bottom: 90, left: 16, right: 16,
        zIndex: 20,
      }}>
        <div style={{
          display: 'flex', alignItems: 'center', gap: 8,
          background: 'rgba(255,255,255,0.92)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)',
          border: `1px solid ${PF_HAIR2}`,
          borderRadius: 22, padding: '10px 16px',
          boxShadow: PF_SHADOW_MD,
        }}>
          {/* La loupe cède la place au spinner pendant la frappe — même
              encombrement, donc pas de saut dans la barre. */}
          {searching ? (
            <VascoSpinner size={16} color={accent} />
          ) : (
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
              stroke={PF_SUB} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" />
            </svg>
          )}
          <input
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            placeholder="Rechercher un patient…"
            style={{
              flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none',
              fontFamily: 'inherit', fontSize: 14, fontWeight: 500, color: PF_INK,
            }}
          />
          {searchQuery && (
            <button onClick={() => setSearchQuery('')} style={{
              background: 'none', border: 'none', cursor: 'pointer', padding: 2,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
            }}>
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
                stroke={PF_SUB} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M18 6L6 18M6 6l12 12" />
              </svg>
            </button>
          )}
        </div>
      </div>

      {/* Bottom nav — same as dashboard */}
      {typeof BottomNav !== 'undefined' && (
        <BottomNav active="PAT" onChange={(id) => { if (onTabChange) onTabChange(id); }} accent={accent} />
      )}
    </div>);

}

window.PatientFolders = PatientFolders;