// 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 (
);
}
// ─────────────────────────────────────────────────────────────
// 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 (
<>
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 }) {
// 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 = [
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 (
);
}
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.