// Patients — list view + new patient wizard. // Brutalist editorial. Same INK / PAPER / mono / serif tokens. const VT = window.VT; // Ce fichier déclarait ses propres P_INK / P_PAPER / P_SUB / P_HAIR / P_HAIR2. // Babel compile les `const` de haut niveau en `var` : elles atterrissaient sur // window et écrasaient celles de tokens.jsx, chargé juste avant. La fiche // patient et le wizard rendaient donc sur l'ancien fond #fafaf7 au lieu du // crème verrouillé. Tout passe maintenant par VT. // ───────────────────────────────────────────────────────────── // Reusable atoms // ───────────────────────────────────────────────────────────── function PCaption({ children, color, style = {} }) { return ( {children}); } function Initials({ first, last }) { const txt = `${(first || '').slice(0, 1)}${(last || '').slice(0, 1)}`.toUpperCase(); return (
{txt}
); } function StatusChip({ status, accent }) { const meta = STATUS_META[status]; if (!meta) return null; // Premium: soft tinted pill, rounded let bg = '#f3f4f6',fg = VT.ink,border = 'transparent'; if (status === 'rea') {bg = `${accent}14`;fg = accent;} else if (status === 'hosp') {bg = VT.ink;fg = '#fff';} else if (status === 'ambu') {bg = VT.successTintSoft;fg = VT.success;} else if (status === 'palliatif') {bg = '#f3f4f6';fg = VT.sub;} else if (status === 'decede') {bg = '#f3f4f6';fg = VT.sub;} return ( {meta.label}); } // ───────────────────────────────────────────────────────────── // Search bar (in-page, not the system search) // ───────────────────────────────────────────────────────────── function PatientsSearch({ value, onChange }) { return (
onChange(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: VT.ink }} /> {value && }
); } // ───────────────────────────────────────────────────────────── // Patient row // ───────────────────────────────────────────────────────────── function PatientRow({ p, accent, isLast, onOpen, onActions }) { return (
{p.last} {p.first}
{p.age}{p.sex} · {p.ddn} {p.nextRdv && · {p.nextRdv.when} }
); } // ───────────────────────────────────────────────────────────── // Filter chips (status segments at top) // ───────────────────────────────────────────────────────────── function FilterChips({ active, counts, onChange }) { const items = [ { id: 'all', label: 'Tous', count: counts.all }, { id: 'hosp', label: 'Hospi.', count: counts.hosp }, { id: 'ambu', label: 'Ambu.', count: counts.ambu }, { id: 'rea', label: 'Réa', count: counts.rea }, { id: 'pall', label: 'Pall.', count: counts.pall }]; return (
{items.map((it) => { const on = it.id === active; return ( ); })}
); } // ───────────────────────────────────────────────────────────── // QR code sheet // ───────────────────────────────────────────────────────────── // Le code n'encode que l'IPP, exactement ce que « Copier l'IPP » met dans le // presse-papier : un carré affiché à l'écran se photographie de loin, on n'y // met donc aucune donnée de santé. L'image vient du serveur sur une route // authentifiée — l'écran n'en dessine aucune de son côté, et dit son attente // comme son échec plutôt que de laisser un cadre vide. function PatientQrSheet({ patient, onClose, runtime = null }) { // Trois états seulement, et jamais deux à la fois : ce qui s'affiche dans le // cadre est soit l'attente, soit le code, soit la raison de son absence. const [state, setState] = React.useState({ status: 'loading', url: null, error: null }); const patientId = patient ? patient.id : null; React.useEffect(() => { if (!patientId) return undefined; if (!runtime || !runtime.qrCode) { setState({ status: 'error', url: null, error: 'QR code indisponible : cet écran n’est pas relié au serveur' }); return undefined; } let cancelled = false; let objectUrl = null; setState({ status: 'loading', url: null, error: null }); runtime.qrCode(patientId). then((code) => { objectUrl = code.url; // La feuille refermée pendant la requête : l'URL d'objet est libérée // tout de suite, sinon elle resterait accrochée au document. if (cancelled) { URL.revokeObjectURL(objectUrl); objectUrl = null; return; } setState({ status: 'ready', url: code.url, error: null }); }). catch((failure) => { if (cancelled) return; setState({ status: 'error', url: null, error: failure?.message || 'QR code indisponible' }); }); return () => { cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [runtime, patientId]); if (!patient) return null; return ( <>
{/* grabber */}
QR CODE
{patient.last} {patient.first}
{state.status === 'loading' && CHARGEMENT… } {state.status === 'error' &&
{state.error}
} {state.status === 'ready' && {`QR }
{patient.ipp}
Scanné, le code ouvre l’application sur ce dossier ; la connexion reste exigée. Il ne porte que l’IPP, aucune donnée de santé.
); } // ───────────────────────────────────────────────────────────── // Impression du dossier // ───────────────────────────────────────────────────────────── // L'application vit dans un cadre de téléphone posé au milieu de la page : // `window.print()` sur le document principal imprimerait ce cadre, son ombre et // le fond de la page autour. La feuille est donc composée dans un document à // part, porté par un iframe caché, et c'est lui qui imprime. // // Rien n'y est inventé : chaque ligne vient du dossier servi par l'API. Un // champ vide n'est pas rempli d'un tiret, il ne s'imprime pas — sur du papier // qui suivra le patient, une case remplie pour faire joli se lit comme un fait. const PATIENT_PRINT_STYLES = ` @page { size: A4; margin: 18mm 16mm; } body { margin: 0; font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 10.5pt; line-height: 1.45; color: #111; } .head { border-bottom: 1.5pt solid #111; padding-bottom: 8pt; margin-bottom: 16pt; } .kicker { font-size: 8pt; letter-spacing: 1.2pt; text-transform: uppercase; color: #555; } .name { font-size: 20pt; font-weight: 700; margin-top: 4pt; } .name .given { font-weight: 400; } .head-meta { display: flex; justify-content: space-between; gap: 12pt; font-size: 9pt; color: #555; margin-top: 6pt; } section { margin-bottom: 14pt; page-break-inside: avoid; } h2 { font-size: 8.5pt; letter-spacing: 1pt; text-transform: uppercase; color: #555; font-weight: 700; margin: 0 0 5pt; border-bottom: 0.5pt solid #bbb; padding-bottom: 3pt; } dl { margin: 0; display: grid; grid-template-columns: 42mm 1fr; column-gap: 6pt; row-gap: 3pt; } dt { color: #555; } dd { margin: 0; } ul { margin: 0; padding-left: 14pt; } p { margin: 0; } footer { margin-top: 18pt; border-top: 0.5pt solid #bbb; padding-top: 5pt; font-size: 8pt; color: #666; } `; function patientPrintEscape(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // Le tiret cadratin est la marque d'un champ absent dans les écrans ; sur la // feuille il n'a rien à dire, la ligne disparaît avec lui. function patientPrintValue(value) { const text = typeof value === 'string' ? value.trim() : value; return text || text === 0 ? (text === '—' ? null : String(text)) : null; } function patientPrintList(values) { return (Array.isArray(values) ? values : []).map(patientPrintValue).filter(Boolean); } function patientPrintSections(p) { const rows = (pairs) => pairs .map(([label, value]) => [label, patientPrintValue(value)]) .filter(([, value]) => value !== null); const status = ((window.STATUS_META || {})[p.status] || {}).label; const nextRdv = p.nextRdv ? [patientPrintValue(p.nextRdv.when), patientPrintValue(p.nextRdv.what)].filter(Boolean).join(' · ') : null; return [ { title: 'Identité', rows: rows([ ['Né(e) le', p.ddn], ['Âge', p.age || p.age === 0 ? `${p.age} ans` : null], ['Sexe', p.sex], ['Statut clinique', status], ['Lit', p.bed]]) }, { title: 'Coordonnées', rows: rows([ ['Téléphone', p.phone], ['Courriel', p.email], ['Adresse', p.address], ['N° de sécurité sociale', p.secu], ['Médecin traitant', p.mt]]) }, { title: 'Résumé clinique', text: patientPrintValue(p.summary) }, { title: 'Alertes épinglées', items: patientPrintList(p.pinned) }, { title: 'Clinique', rows: rows([['Groupe sanguin', p.blood]]) }, { title: 'Allergies', items: patientPrintList(p.allergies) }, { title: 'Traitements en cours', items: patientPrintList(p.treatments) }, { title: 'Antécédents personnels', items: patientPrintList(p.atcdPerso) }, { title: 'Antécédents familiaux', items: patientPrintList(p.atcdFam) }, { title: 'Prochain rendez-vous', text: nextRdv || null }, { title: 'Notes', text: patientPrintValue(p.generalNotes) }]. filter((section) => section.text || (section.rows || section.items || []).length > 0); } function patientPrintHtml(p, printedAt) { const esc = patientPrintEscape; const identity = `${esc(p.last || '')} ${esc(p.first || '')}`.trim(); const body = patientPrintSections(p).map((section) => { const title = `

${esc(section.title)}

`; if (section.rows) { const lines = section.rows .map(([label, value]) => `
${esc(label)}
${esc(value)}
`).join(''); return `
${title}
${lines}
`; } if (section.items) { const lines = section.items.map((item) => `
  • ${esc(item)}
  • `).join(''); return `
    ${title}
      ${lines}
    `; } return `
    ${title}

    ${esc(section.text)}

    `; }).join(''); return ` Dossier ${identity} — IPP ${esc(p.ipp || '')}
    Dossier patient
    ${esc(p.last || '')} ${esc(p.first || '')}
    IPP ${esc(p.ipp || '—')} Imprimé le ${esc(printedAt)}
    ${body}
    Document imprimé depuis VASCO à partir du dossier ${esc(p.ipp || '')}. Les champs non renseignés n'y figurent pas.
    `; } // Le navigateur peut imprimer en arrière-plan : retirer l'iframe dès le retour // de `print()` annulerait le travail en cours. On la retire donc sur // `afterprint`, avec une garde en dernier recours pour ne rien laisser traîner // dans la page si l'événement ne vient jamais. const PATIENT_PRINT_CLEANUP_MS = 60000; function patientPrintFrame(html) { return new Promise((resolve, reject) => { const frame = document.createElement('iframe'); frame.setAttribute('aria-hidden', 'true'); frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:210mm;height:297mm;border:0;'; document.body.appendChild(frame); const view = frame.contentWindow; const doc = frame.contentDocument || (view && view.document); if (!view || !doc) { frame.remove(); reject(new Error('Impression impossible — document refusé par le navigateur')); return; } doc.open(); doc.write(html); doc.close(); let removed = false; const remove = () => { if (!removed) { removed = true; frame.remove(); } }; view.addEventListener('afterprint', remove, { once: true }); window.setTimeout(remove, PATIENT_PRINT_CLEANUP_MS); // `print()` appelé dans la foulée de `write()` sort des pages blanches sur // WebKit : le document n'a pas encore été mis en page. Un tour de boucle // suffit à le poser. window.setTimeout(() => { try { view.focus(); view.print(); resolve(); } catch (error) { remove(); reject(new Error('Impression impossible — refusée par le navigateur')); } }, 0); }); } // Le dossier ouvert dans l'écran est déjà complet. Depuis le répertoire, la // ligne du menu ne porte qu'un résumé : on relit alors le dossier avant // d'imprimer, plutôt que d'imprimer une feuille amputée de ses coordonnées et // de ses antécédents. function printPatientRecord(patient, runtime = null) { const api = window.VascoApi && window.VascoApi.patient; const load = (runtime && runtime.load) || (api && api.load) || null; const loaded = Number.isInteger(patient.version) || !load ? Promise.resolve(patient) : load(patient.id); return loaded.then((record) => { const full = record || patient; if (full.capabilities && full.capabilities.print === false) { throw new Error('Impression indisponible pour ce dossier'); } const printedAt = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long', timeStyle: 'short' }).format(new Date()); return patientPrintFrame(patientPrintHtml(full, printedAt)); }); } window.printPatientRecord = printPatientRecord; // La mécanique de la feuille — document à part, nettoyage sur `afterprint`, tour de // boucle avant `print()` — vaut pour tout ce qui s'imprime depuis l'application. La // fiche ordonnance de l'Assistant la reprend telle quelle : deux copies de ces // précautions finiraient par diverger, et c'est la seconde qui sortirait blanche. window.vascoPrintFrame = patientPrintFrame; window.vascoPrintEscape = patientPrintEscape; // ───────────────────────────────────────────────────────────── // Bottom action sheet // ───────────────────────────────────────────────────────────── // Cette feuille est le menu « ··· » du dossier patient. Elle ne porte que des // gestes qui aboutissent : chaque entrée suit la capacité rendue par le // serveur, et la seule commande dont le serveur refuse encore le geste (le // partage) reste absente plutôt que promise « bientôt ». « Supprimer » archive // le dossier (`archive`) : il quitte les listes, la recherche et le détail // sans rien effacer en base. function ActionSheet({ patient, accent, onClose, onOpen, runtime = null, onNewAppointment, onNewNote, onEdit, onDelete }) { // Déclarés 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(); const [qrOpen, setQrOpen] = React.useState(false); const [confirmDelete, setConfirmDelete] = React.useState(false); const [archiving, setArchiving] = React.useState(false); // La feuille reste montée avec patient=null quand elle est fermée : on remet // la confirmation à zéro à chaque ouverture, sinon elle réapparaîtrait sur le // patient suivant. React.useEffect(() => { setConfirmDelete(false); setArchiving(false); }, [patient]); if (!patient) return null; const Icon = ({ d }) => {d}; const capabilities = patient.capabilities || {}; // Les capacités d'un dossier sont de simples booléens : contrairement à // l'agenda, le serveur ne joint pas de code de motif. On ne dit donc de la // cause que ce dont on est sûr — le dossier d'un patient décédé refuse le // rendez-vous et l'appel, un dossier sans numéro refuse l'appel — et l'on // retombe sur une formule générale plutôt que d'inventer un motif. const deceased = patient.status === 'decede'; const GENERIC_REFUSAL = 'Indisponible pour ce dossier'; const callRefusal = deceased ? 'Patient décédé' : !patient.phone ? 'Aucun numéro renseigné' : GENERIC_REFUSAL; // Une copie n'a aucun effet visible ailleurs : c'est donc la ligne du menu qui // porte sa confirmation, le temps d'être lue. La feuille ne se referme plus // aussitôt, sinon la confirmation partirait avec elle. const copyIPP = async () => { try { await copyToClipboard(patient.ipp); markDone('copy'); } catch (e) { showToast('Copie impossible — presse-papier refusé', 'error'); onClose(); } }; // L'impression est un geste local : le serveur n'a rien à en connaître, il // dit seulement si ce dossier peut être imprimé. La feuille est composée au // clic, depuis le dossier relu si la ligne du répertoire n'en portait qu'un // résumé, et un échec se dit — jamais de papier silencieusement vide. const print = () => { if (capabilities.print === false) { showToast('Impression indisponible pour ce dossier', 'error'); onClose(); return; } onClose(); printPatientRecord(patient, runtime) .catch((failure) => showToast(failure?.message || 'Impression impossible', 'error')); }; // Un appel est tracé côté serveur avant que le lien ne s'ouvre, comme sur le // bouton du dossier : la même action ne peut pas être suivie ici et pas là. const call = () => { if (capabilities.call === false) { showToast('Appel indisponible pour ce dossier', 'error'); onClose(); return; } onClose(); if (!runtime) { requestCall(`${patient.last} ${patient.first}`, patient.phone); return; } runtime.authorizeAction(patient.id, 'call') .then(() => requestCall(`${patient.last} ${patient.first}`, patient.phone)) .catch((failure) => showToast(failure?.message || 'Appel impossible', 'error')); }; // La suppression est un archivage côté serveur : le dossier quitte la vue, // rien ne s'efface. Le refus, lui, est dit tel que le serveur l'a formulé — // un conflit de version veut dire que le dossier a changé depuis son // chargement, et qu'il faut le relire avant de le retirer. const archive = () => { if (archiving) return; if (!runtime?.archive) { showToast('Suppression indisponible', 'error'); onClose(); return; } setArchiving(true); runtime.archive(patient.id, patient.version). then(() => { showToast(`${patient.last} ${patient.first} supprimé`, 'success'); onDelete && onDelete(patient); onClose(); }). catch((failure) => { showToast(failure?.message || 'Suppression impossible', 'error'); setArchiving(false); }); }; const items = [ onOpen && { id: 'view', label: 'Voir le dossier', icon: } />, onClick: () => {onOpen(patient);onClose();} }, onEdit && { id: 'edit', label: 'Modifier', disabled: capabilities.update === false, reason: capabilities.update === false ? GENERIC_REFUSAL : null, icon: } />, onClick: () => { if (capabilities.update === false) { showToast('Modification indisponible pour ce dossier', 'error'); onClose(); return; } onClose(); onEdit(patient); } }, onNewAppointment && { id: 'rdv', label: 'Nouveau rendez-vous', disabled: capabilities.create_appointment === false, reason: capabilities.create_appointment === false ? (deceased ? 'Patient décédé' : GENERIC_REFUSAL) : null, icon: } />, onClick: () => { if (capabilities.create_appointment === false) { showToast('Rendez-vous indisponible pour ce dossier', 'error'); onClose(); return; } onClose(); onNewAppointment(patient); } }, onNewNote && { id: 'note', label: 'Nouvelle note', disabled: capabilities.create_note === false, reason: capabilities.create_note === false ? GENERIC_REFUSAL : null, icon: } />, onClick: () => { if (capabilities.create_note === false) { showToast('Note indisponible pour ce dossier', 'error'); onClose(); return; } onClose(); onNewNote(patient); } }, { id: 'copy', label: 'Copier l\'IPP', icon: } />, onClick: copyIPP }, // Le QR ne s'ouvre que si le serveur le sert : sans capacité, la ligne reste // là mais éteinte, et dit pourquoi au lieu de faire monter une feuille vide. { id: 'qr', label: 'QR code', disabled: capabilities.qr !== true, reason: capabilities.qr !== true ? GENERIC_REFUSAL : null, icon: } />, onClick: () => { if (capabilities.qr !== true) { showToast('QR code indisponible pour ce dossier', 'error'); return; } setQrOpen(true); } }, { id: 'print', label: 'Imprimer', disabled: capabilities.print === false, reason: capabilities.print === false ? GENERIC_REFUSAL : null, icon: } />, onClick: print }, { id: 'call', label: patient.phone ? `Appeler ${patient.phone}` : 'Appeler', disabled: capabilities.call === false, reason: capabilities.call === false ? callRefusal : null, icon: } />, onClick: call }, // La ligne n'apparaît que si le serveur autorise l'archivage pour ce dossier // et que l'écran sait le lui demander : sans quoi elle ouvrirait une // confirmation irréversible sur un geste qui n'aboutirait pas. capabilities.archive === true && runtime?.archive && { id: 'delete', label: 'Supprimer', icon: , danger: true, onClick: () => setConfirmDelete(true) }]. filter(Boolean); return ( <>
    {/* grabber */}
    PATIENT
    {patient.last} {patient.first}
    {confirmDelete ?
    Supprimer ce patient ?
    {patient.last} {patient.first} sera retiré de votre liste. Cette action est irréversible.
    {/* Le libellé passe à « Suppression… » le temps de l'aller-retour : le serveur peut prendre plusieurs secondes, et un bouton qui ne bouge pas laisse croire que le geste n'est pas parti. */}
    : /* Une ligne éteinte porte sa raison sous son libellé. Elle ne vivait que dans le toast déclenché par l'appui : invisible pour qui lit la feuille, et hors de portée d'un lecteur d'écran avant le geste. L'`aria-label` la joint maintenant au libellé. Seuls l'icône et le libellé s'éteignent — une raison à 45 % d'opacité ne se lit pas. */ items.map((it, i) => )}
    {/* Le QR se pose par-dessus le menu et le rend en le refermant : on est venu chercher un code, pas quitter la fiche. */} {qrOpen && setQrOpen(false)} /> } ); } // ───────────────────────────────────────────────────────────── // Patient list screen // ───────────────────────────────────────────────────────────── function PatientList({ accent, onOpenPatient, onNewPatient, onBack, runtime = null }) { const [query, setQuery] = React.useState(''); const [filter, setFilter] = React.useState('all'); const [sheet, setSheet] = React.useState(null); // Le serveur a archivé ces dossiers : ils ont quitté ses listes. On les retire // ici aussi, sans attendre un rechargement — muter le tableau global ne // redessine rien. const [deletedIds, setDeletedIds] = React.useState([]); const alive = React.useMemo( () => PATIENTS_FULL.filter((p) => !deletedIds.includes(p.id)), [deletedIds]); const counts = React.useMemo(() => ({ all: alive.length, hosp: alive.filter((p) => p.status === 'hosp' || p.status === 'rea').length, ambu: alive.filter((p) => p.status === 'ambu').length, rea: alive.filter((p) => p.status === 'rea').length, pall: alive.filter((p) => p.status === 'palliatif' || p.status === 'decede').length }), [alive]); const visible = React.useMemo(() => { const q = query.trim().toLowerCase(); return alive.filter((p) => { if (filter === 'hosp' && !(p.status === 'hosp')) return false; if (filter === 'ambu' && p.status !== 'ambu') return false; if (filter === 'rea' && p.status !== 'rea') return false; if (filter === 'pall' && !(p.status === 'palliatif' || p.status === 'decede')) return false; if (!q) return true; const blob = `${p.last} ${p.first} ${p.ipp}`.toLowerCase(); return blob.includes(q); }); // `alive` fait partie des dépendances : sans lui, un dossier supprimé // restait affiché jusqu'au prochain changement de recherche ou de filtre. }, [query, filter, alive]); return (

    Patients

    {visible.length} {visible.length === 1 ? 'patient' : 'patients'} {filter !== 'all' && <> · filtre actif}
    {visible.length === 0 ?
    AUCUN RÉSULTAT
    Aucun patient ne correspond à « {query} ».
    : visible.map((p, i) => onOpenPatient(p.id)} onActions={setSheet} /> )}
    setSheet(null)} onOpen={(p) => onOpenPatient(p.id)} onDelete={(p) => setDeletedIds((prev) => [...prev, p.id])} />
    ); } window.PatientList = PatientList; window.PStatusChip = StatusChip; window.PInitials = Initials; window.PCaption = PCaption; // La fiche patient ouvre la même feuille depuis son bouton « QR code ». window.PatientQrSheet = PatientQrSheet; // Les P_* ne sont plus réexportés ici : tokens.jsx en est la seule source.