// 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 (
);
};
// ─────────────────────────────────────────────────────────────
// 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 (
);
}
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 (
);
}
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 */}
{/* Content overlaid on top of the folder body */}
);
}
// ─────────────────────────────────────────────────────────────
// 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 }) => (
);
// 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 (
{/* 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 (
{/* 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. */}
);
}
// ─────────────────────────────────────────────────────────────
// 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. */}
);
}
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} />
)}