// 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: <> }, CRO: { color: '#dc2626', bg: '#fee2e2', label: 'CR Opératoire', icon: <> }, ORD: { color: '#16a34a', bg: '#dcfce7', label: 'Ordonnance', icon: <> }, COUR: { color: '#d97706', bg: '#fef3c7', label: 'Courrier', icon: <> }, EXAM: { color: '#7c3aed', bg: '#ede9fe', label: 'Biologie', icon: <> }, IMG: { color: '#0d9488', bg: '#ccfbf1', label: 'Imagerie', icon: <> }, CRH: { color: '#0284c7', bg: '#dbeafe', label: 'CR Hospitalisation', icon: <> } }; const DocIcon = ({ type }) => { const t = DOC_TYPES[type] || DOC_TYPES.COUR; return (
{t.icon}
); }; // ───────────────────────────────────────────────────────────── // 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 ( {/* shoulders / shirt */} {/* neck */} {/* head */} {/* hair top */} {/* subtle face shadow */} ); } function Polaroid({ patient, size = 84, tilt = 5 }) { const initials = `${patient.first[0]}${patient.last[0]}`.toUpperCase(); return (
{initials}
); } // Small circular avatar (top-left of folder) function Avatar({ patient, size = 32 }) { return (
); } // ───────────────────────────────────────────────────────────── // 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 ( {/* Body gradient — slightly darker at bottom for 3D */} {/* Tab gradient — slightly different shade for separation */} {/* Drop shadow */} {/* BACK PANEL — same shape, slightly larger, darker tone, offset down */} {showBack && ( )} {/* FRONT FOLDER — tab + body as single visual */} {/* Body */} {/* Tab — sits on top, slight inset from left, squared bottom-right so it merges */} {/* Subtle fold-line where tab meets body */} {/* Inner highlight along top of body for paper feel */} {/* Tab label */} {tabLabel && ( {tabLabel} )} {alert && ( )} ); } 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 (
{/* Folder shape as background */}
{/* Back panel — peeks at the bottom */} {/* Front body */} {/* White page — just a thin sliver peeking out at the top of the body, very subtle */} {/* Tab — rounded top-left + top-right, bottom merges into the body */} {/* Soft fold line where tab meets body */} {/* Top body highlight */} {/* Tab label */} {tabLabel} {alert && } {/* Content overlaid on top of the folder body */}
{/* Avatar top-left */} {withPolaroid && ( )} {/* Polaroid bottom-right, hanging slightly off the bottom edge */} {withPolaroid && (
)} {/* Name — single line, anchored to bottom-left */} {showLabel && (
{patient.first} {patient.last.charAt(0) + patient.last.slice(1).toLowerCase()}
)}
); } // ───────────────────────────────────────────────────────────── // 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 (
{/* 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 (
{tabLabel}
); })} {/* Active folder with animation */}
); } // ───────────────────────────────────────────────────────────── // 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 (
Aucun patient trouvé
); } 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 (
{/* Counter */}
{counterLabel}
{/* 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 (
{tabLabel}
); })} {/* Active folder */}
onTapActive(active.id)} />
{/* Action widgets below the folder */}
{/* Add patient widget */} {/* Import patient widget */}
{/* Activity summary of the active patient */}
Activité
{/* Next RDV */} {active.nextRdv && (
{active.nextRdv.what}
{active.nextRdv.when}
)} {/* Last document */} {active.documents && active.documents[0] && (
{active.documents[0].label.replace(/^(CR[OCH]|EXAM|IMG|ORD)\s—\s/, '')}
{active.documents[0].d}
)} {/* Alert / flags */} {active.flags.length > 0 && (
{active.flags.join(', ')}
Alertes actives
)} {/* CRs count */}
{active.crsCount || 0} comptes rendus
{active.stats ? `Depuis ${active.stats.firstVisit}` : 'Total'}
); } // ───────────────────────────────────────────────────────────── // 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 ( {s.label} ); } function PatientCard({ patient, color, tall = false, active = false }) { return (
{/* Top row — avatar + civ/age + name */}
{patient.civ || ''} · {patient.age} ans
{patient.last} {patient.first}
{/* Middle — summary */}
{patient.summary || '—'}
{/* Bottom row — status chip + bed */}
{patient.bed && patient.bed !== '—' && (
{patient.bed}
)}
); } // ───────────────────────────────────────────────────────────── // 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 }) => ( {d} ); // 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: } />, 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: } />, 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: } />, 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: } />, onClick: () => onNewNote(patient) }, { id: 'ipp', label: "Copier l'IPP", icon: } />, 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 (
{/* Title */}
{patient.last} {patient.first}
{patient.civ || ''} · {patient.age} ans{patient.ipp ? ` · IPP ${patient.ipp}` : ''}
{/* Info block */} {kv.length > 0 && (
{kv.map(([k, v], i) => (
{k}
{v}
))}
)} {/* Actions */}
{/* 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) => ( ))}
); } // ───────────────────────────────────────────────────────────── // 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 (
Aucun patient trouvé
); } 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 (
{/* Top-right chrome — Done (focus) or + Nouveau (deck) */}
{isFocused ? ( ) : ( <>
{total}
)}
{/* Deck container */}
{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 (
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', }}>
); })}
{/* Focus panel — patient info + actions */} {isFocused && ( { 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. */}
); } // ───────────────────────────────────────────────────────────── // Action pills — Partager / Paramètres // ───────────────────────────────────────────────────────────── function ActionPill({ label, icon, onClick }) { return ( ); } // ───────────────────────────────────────────────────────────── // Alert banner // ───────────────────────────────────────────────────────────── function AlertBanner({ text, onClick }) { return ( ); } // ───────────────────────────────────────────────────────────── // Document row // ───────────────────────────────────────────────────────────── function DocRow({ doc }) { const t = DOC_TYPES[doc.type] || DOC_TYPES.COUR; return (
{doc.label.replace(/^(CR[OCH]|EXAM|IMG|ORD)\s—\s/, '')}
{doc.d} · {t.label}
); } // ───────────────────────────────────────────────────────────── // 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 (
Dossiers patients
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 }} />
{visible.map((p) => )}
); } // ───────────────────────────────────────────────────────────── // Main carousel screen // ───────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────── // List view — compact rows // ───────────────────────────────────────────────────────────── function PatientListView({ patients, onPick, loading = false, searching = false }) { if (loading) { return (
); } return (
{patients.map((p) => { const c = folderFor(p._idx); const hasAlert = p.flags.includes('AVK') || p.flags.includes('DNR') || p.status === 'rea'; return ( ); })} {patients.length === 0 && (
Aucun patient trouvé
)}
); } // ───────────────────────────────────────────────────────────── // Icon/Grid view — avatar circles in a grid // ───────────────────────────────────────────────────────────── function PatientIconView({ patients, onPick, loading = false, searching = false }) { if (loading) { return (
); } return (
{patients.map((p) => { const c = folderFor(p._idx); const hasAlert = p.flags.includes('AVK') || p.flags.includes('DNR') || p.status === 'rea'; return ( ); })}
{patients.length === 0 && (
Aucun patient trouvé
)}
); } // ───────────────────────────────────────────────────────────── // 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 (
{open && ( <>
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 30 }} /> {/* Aligné à droite : le bouton est dans le coin haut droit. */}
{SORT_OPTIONS.map((o) => { const on = o.id === value; return ( ); })}
)}
); } function ViewToggle({ mode, onChange }) { const modes = [ { id: 'stack', icon: <> }, { id: 'list', icon: <> }, { id: 'icons', icon: <> }, ]; return (
{modes.map((m) => ( ))}
); } 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 (
{/* Header */}
Patients ({busy ? '…' : filtered.length})
{/* Tri + vue, regroupés : les cartes gardent leur position d'origine. */}
{/* 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. ) : ( onOpenPatient(id)} onNewAppointment={(id) => onNewAppointment(id)} onNewNote={(id) => onNewNote(id)} /> ) )} {viewMode === 'list' && ( onOpenPatient(id)} loading={loading} searching={searching} /> )} {viewMode === 'icons' && ( 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. */}
{/* Search bar fixed at bottom */}
{/* La loupe cède la place au spinner pendant la frappe — même encombrement, donc pas de saut dans la barre. */} {searching ? ( ) : ( )} 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 && ( )}
{/* Bottom nav — same as dashboard */} {typeof BottomNav !== 'undefined' && ( { if (onTabChange) onTabChange(id); }} accent={accent} /> )}
); } window.PatientFolders = PatientFolders;