// Shared patient primitives used by the connected patient screens. // The directory itself is served by PatientFolders and the API. // Prefixe PS_VT : 'dashboard.jsx' de la reference figee declare deja 'VT' au // sommet de la portee globale que tous les scripts de la page partagent. const PS_VT = window.VT; // Ce fichier déclarait ses propres P_INK / P_PAPER / P_SUB / P_HAIR / P_HAIR2. // Elles atterrissaient dans la portée globale que tous les scripts de la page // partagent 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 PS_VT. // ───────────────────────────────────────────────────────────── // Reusable atoms // ───────────────────────────────────────────────────────────── function PCaption({ children, color, style = {} }) { return ( {children}); } function PatientNextAppointmentCard({ appointment, accent = PS_VT.medBlue, onOpen = null, style = {} }) { const { InteractiveRow } = window.VascoUI; if (!appointment) return null; const interactive = typeof onOpen === 'function'; const cardStyle = { width: '100%', marginTop: 10, padding: '14px 16px', border: `1px solid ${PS_VT.hair2}`, borderRadius: PS_VT.rLg, background: PS_VT.surface, boxShadow: PS_VT.shadowSm, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, color: PS_VT.ink, fontFamily: 'inherit', textAlign: 'left', cursor: interactive ? 'pointer' : 'default', }; const content = ( <> {appointment.when} {appointment.what} {interactive && } ); return (
Prochain RDV {interactive ? {content} :
{content}
}
); } 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 = PS_VT.ink,border = 'transparent'; if (status === 'rea') {bg = `${accent}14`;fg = accent;} else if (status === 'hosp') {bg = PS_VT.ink;fg = '#fff';} else if (status === 'ambu') {bg = PS_VT.successTintSoft;fg = PS_VT.success;} else if (status === 'palliatif') {bg = '#f3f4f6';fg = PS_VT.sub;} else if (status === 'decede') {bg = '#f3f4f6';fg = PS_VT.sub;} return ( {meta.label}); } // ───────────────────────────────────────────────────────────── // Search bar (in-page, not the system search) function usePatientQrCode(patientId, runtime) { // 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 }); 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]); return state; } function PatientQrCard({ patient, runtime = null }) { const state = usePatientQrCode(patient ? patient.id : null, runtime); if (!patient) return null; return (
{state.status === 'loading' && CHARGEMENT… } {state.status === 'error' && {state.error} } {state.status === 'ready' && {`QR}
QR CODE PATIENT
{patient.ipp}
Ce QR code permet de partager le dossier patient avec les personnes autorisées.
); } function PatientQrSheet({ patient, onClose, runtime = null }) { const state = usePatientQrCode(patient ? patient.id : null, runtime); if (!patient) return null; const { BottomSheet, Button } = window.VascoUI; return Fermer}>
{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); } // Un traitement imprimé porte ses dates : « du 10 sept. 2026 au 24 sept. 2026 », // « depuis le … » quand il court toujours. Sans fiche servie, la feuille imprime // exactement ce qu'elle imprimait. function patientPrintTreatments(p) { const entries = Array.isArray(p.treatmentEntries) ? p.treatmentEntries : []; if (!entries.length) return patientPrintList(p.treatments); return entries.map((entry) => { const label = patientPrintValue(entry?.label); if (!label) return ''; const range = window.VascoPatientEdit?.treatmentRangeLabel?.(entry) || ''; return range ? `${label} · ${range}` : label; }).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], // La feuille imprimée dit la même place que l'écran : l'étage, la chambre et // le lit, par la seule fonction qui compose ce libellé. Sans séjour, la // ligne disparaît comme toutes les autres valeurs absentes. ['Séjour', window.VascoPatientContactDetails.stayLabel(p)]]) }, { 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: patientPrintTreatments(p) }, { 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}
    `; } 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} `; } // 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, detailMode = false }) { const { BottomSheet, Button, InteractiveRow } = window.VascoUI; // 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 = [ !detailMode && onOpen && { id: 'view', label: 'Voir le dossier', icon: } />, onClick: () => {onOpen(patient);onClose();} }, !detailMode && 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); } }, !detailMode && 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); } }, !detailMode && 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); } }, !detailMode && { 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. !detailMode && { 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); } }, !detailMode && { id: 'print', label: 'Imprimer', disabled: capabilities.print === false, reason: capabilities.print === false ? GENERIC_REFUSAL : null, icon: } />, onClick: print }, !detailMode && { 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 ( <> {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) => {it.icon} {doneAction === it.id ? `IPP ${patient.ipp} copié` : it.label} {it.disabled && it.reason && {it.reason} } )}
    {/* 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 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; // Le répertoire réutilise le même chargement dans la carte dépliée. window.PatientQrCard = PatientQrCard;