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 }) =>
;
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;