// Coque Web MVP. Les composants et leurs styles proviennent directement de la V5 figée.
function VascoConnectionOverlay({ label }) {
return (
{label}
);
}
// Les écrans secondaires de la V5 attendent `BottomNav`, défini dans la
// coque historique qui n'est volontairement pas montée par ce MVP connecté.
// On réutilise donc la TabBar visuellement identique du dashboard afin de
// conserver la navigation sur Patients, Agenda, Assistant et Compte.
function BottomNav(props) {
return ;
}
// Onglet retenu d'un rechargement à l'autre, en local seulement. Les écrans qui
// dépendent d'un état non rejouable — un dossier patient ouvert, un assistant de
// création en cours — n'en font pas partie : les rouvrir sans leur contexte
// donnerait un écran vide.
const DEV_ROUTE_KEY = 'vasco_dev_route';
const DEV_RESTORABLE_ROUTES = ['dashboard', 'patients', 'agenda', 'aichat', 'account'];
const devLocal = ['localhost', '127.0.0.1', '[::1]'].includes(window.location.hostname);
function initialRoute() {
if (!devLocal) return 'dashboard';
let stored = null;
try { stored = window.sessionStorage.getItem(DEV_ROUTE_KEY); } catch { return 'dashboard'; }
return DEV_RESTORABLE_ROUTES.includes(stored) ? stored : 'dashboard';
}
function rememberRoute(route) {
if (!devLocal || !DEV_RESTORABLE_ROUTES.includes(route)) return;
try { window.sessionStorage.setItem(DEV_ROUTE_KEY, route); } catch { /* stockage refusé */ }
}
// `loadMvpData` ne charge que le tableau de bord. Afficher l'écran retenu des
// patients, de l'agenda ou du compte sans charger ses données montrait les
// fixtures de la maquette — trente patients inventés dans une session qui en
// compte huit. On charge donc l'écran restauré avant de le montrer, que l'on
// arrive par une reprise de session ou par une connexion fraîche : les deux
// chemins partent du même onglet retenu et méritent la même vérité.
// Rend `false` quand le chargement a échoué, pour retomber sur le tableau de
// bord : mieux vaut un écran de moins qu'un écran qui ment.
async function loadRouteData(route) {
const loaders = {
patients: window.VascoApi.loadPatients,
agenda: window.VascoApi.loadAgenda,
account: window.VascoApi.loadAccount,
};
const loader = loaders[route];
if (!loader) return true;
try {
await loader();
return true;
} catch {
return false;
}
}
// Le QR d'un dossier encode un lien vers cette application, l'IPP porté par le
// fragment — la partie de l'URL qu'aucun navigateur n'envoie à un serveur. On
// le lit une seule fois, au chargement du script, et on efface le fragment dans
// la foulée : un rechargement ne doit pas rejouer le geste, et l'IPP n'a pas à
// rester dans la barre d'adresse ni dans l'historique.
function takeRecordIppFromHash() {
let raw = null;
try {
const match = /^#dossier=(.+)$/.exec(window.location.hash || '');
if (!match) return null;
raw = match[1];
window.history.replaceState(null, '', window.location.pathname + window.location.search);
} catch { return null; }
try {
return decodeURIComponent(raw).trim() || null;
} catch {
// Fragment malformé : on garde ce qui a été lu plutôt que d'abandonner en
// silence. Un IPP qui ne correspond à rien se dit à l'écran.
return raw.trim() || null;
}
}
const SCANNED_RECORD_IPP = takeRecordIppFromHash();
function AppMobileMvp() {
const accent = '#3d6b8f';
const currentName = 'Bleu médical';
const [authed, setAuthed] = React.useState(false);
const [onboarded, setOnboarded] = React.useState(false);
const [busy, setBusy] = React.useState(null);
const [loginKey, setLoginKey] = React.useState(0);
const [errorView, setErrorView] = React.useState(() => window.vascoErrorFromUrl());
const [accountStats, setAccountStats] = React.useState(false);
const [route, setRoute] = React.useState(initialRoute);
// Écran vers lequel refermer l'assistant de création de patient, quand il a été
// ouvert au milieu d'un geste commencé ailleurs. `null` : retour au répertoire.
const [wizardReturn, setWizardReturn] = React.useState(null);
// Enregistrement de compte rendu interrompu par une création de patient :
// `pending` retient la carte, `resume` la rend à l'Assistant avec le dossier créé.
const [pendingReportSave, setPendingReportSave] = React.useState(null);
const [resumeReportSave, setResumeReportSave] = React.useState(null);
const [patientId, setPatientId] = React.useState(null);
// Onglet d'ouverture du dossier : « Nouvelle note » depuis la pile des
// dossiers doit tomber sur la saisie, pas sur le résumé.
const [patientTab, setPatientTab] = React.useState('resume');
// Patient désigné pour une création de rendez-vous engagée depuis son dossier.
const [newRdvFor, setNewRdvFor] = React.useState(null);
const [prevRoute, setPrevRoute] = React.useState(null);
const [slideAnim, setSlideAnim] = React.useState(false);
React.useEffect(() => { rememberRoute(route); }, [route]);
// Reprise de la session locale au chargement. Une modification d'écran ne doit
// coûter ni une nouvelle organisation de démonstration, ni la conversation en
// cours : le serveur garde les deux, seul le jeton avait besoin de survivre.
React.useEffect(() => {
if (!window.VascoApi?.hasResumableSession?.()) return undefined;
let cancelled = false;
setBusy('Reprise de la session locale…');
(async () => {
try {
const resumed = await window.VascoApi.resumeSession();
if (!resumed || cancelled) return;
await window.VascoApi.loadMvpData();
if (cancelled) return;
// L'écran retenu a besoin de ses propres données avant d'être montré.
const loaded = await loadRouteData(route);
if (cancelled) return;
if (!loaded) setRoute('dashboard');
setOnboarded(Boolean(window.VascoApi.currentUser()?.onboarding?.completed));
setAuthed(true);
} catch {
// La session locale n'est plus valable : l'écran de connexion reprend
// la main, ce qui est la réponse honnête plutôt qu'une page à moitié
// chargée.
window.VascoApi.clearLocalSession();
} finally {
if (!cancelled) setBusy(null);
}
})();
return () => { cancelled = true; };
}, []);
const connect = async () => {
const provider = (() => {
try { return window.localStorage.getItem('vasco_auth'); } catch { return null; }
})();
setBusy('Connexion sécurisée au serveur…');
try {
if (provider === 'psc') {
const capabilities = await window.VascoApi.getAuthCapabilities();
const psc = capabilities?.providers?.find((item) => item.id === 'pro_sante_connect');
if (!psc?.enabled) throw new window.VascoApi.VascoApiError(
'Pro Santé Connect n’est pas encore disponible sur cet environnement', 503, 'provider_unavailable', null,
);
throw new window.VascoApi.VascoApiError(
'Le parcours Pro Santé Connect sera activé avec le fournisseur', 503, 'provider_unavailable', null,
);
}
const result = await window.VascoApi.createDemoSession();
await window.VascoApi.loadMvpData();
// Une connexion fraîche reprend elle aussi l'onglet retenu — `route` porte
// déjà cette valeur, lue de sessionStorage au premier rendu. Sans ce
// chargement, se connecter sur l'onglet Patients ouvrait le répertoire de
// la maquette.
const loaded = await loadRouteData(route);
if (!loaded) setRoute('dashboard');
setOnboarded(Boolean(result.user.onboarding.completed));
setAuthed(true);
} catch (error) {
window.vascoAuthClear();
window.VascoApi.clearLocalSession();
setLoginKey((value) => value + 1);
showToast(error?.message || 'Connexion impossible', 'error');
} finally {
setBusy(null);
}
};
// Une session démo suivante crée une organisation neuve. Les caches de la
// session précédente doivent donc disparaître, sinon le prochain login
// afficherait les patients et l'agenda d'un autre espace.
const handleLoggedOut = () => {
window.VASCO_RUNTIME = {};
window.vascoAuthClear();
window.VascoApi.clearLocalSession();
setAuthed(false);
setOnboarded(false);
setAccountStats(false);
setPatientId(null);
setRoute('dashboard');
// L'écran de connexion qui revient est la preuve de la déconnexion.
setLoginKey((value) => value + 1);
};
const finishOnboarding = async () => {
setBusy('Enregistrement de vos préférences…');
try {
await window.VascoApi.completeOnboarding();
setOnboarded(true);
} catch (error) {
showToast(error?.message || 'Enregistrement impossible', 'error');
} finally {
setBusy(null);
}
};
const slideToChat = () => {
if (route === 'aichat') return;
setPrevRoute(route);
setSlideAnim(true);
setRoute('aichat');
setTimeout(() => { setSlideAnim(false); setPrevRoute(null); }, 1200);
};
// Le dossier est chargé avant d'être montré : arriver sur l'écran puis découvrir
// qu'il n'y a rien à lire donnerait un dossier vide sous un nom. Rend `false`
// quand le chargement a échoué — l'appelant qui enchaîne un geste, comme
// « Ouvrir » sur une fiche de l'Assistant, saurait sinon dire qu'il a ouvert
// un dossier que personne n'a vu.
const openPatient = async (id, tab = 'resume') => {
setBusy('Chargement du dossier patient…');
try {
await window.VascoApi.loadPatient(id);
setPatientId(id);
setPatientTab(tab);
setRoute('patient');
return true;
} catch (error) {
showToast(error?.message || 'Dossier patient indisponible', 'error');
return false;
} finally {
setBusy(null);
}
};
// Dossier désigné par un QR : le code ne donne que l'IPP, il faut donc le
// répertoire pour le traduire en dossier. `loadPatients` sert le cache quand
// il est déjà chargé, l'appel ne coûte rien de plus au geste.
const openRecordByIpp = async (ipp) => {
setBusy('Ouverture du dossier scanné…');
let target = null;
try {
const patients = await window.VascoApi.loadPatients();
target = (patients || []).find((patient) => patient.ipp === ipp) || null;
} catch (error) {
showToast(error?.message || 'Répertoire indisponible', 'error');
setBusy(null);
return;
}
setBusy(null);
if (!target) {
// Aucun dossier ne porte cet IPP ici. On ouvre quand même l'écran du
// dossier : PatientDetail y rend « Dossier introuvable », qui reste
// affiché avec son retour au répertoire. Un toast seul aurait disparu
// avant d'être lu, en laissant le tableau de bord comme réponse — et un
// tableau de bord ne se lit pas comme un refus. Le toast le double pour
// nommer l'IPP en cause, que l'écran ne connaît pas.
showToast(`Aucun dossier ne porte l’IPP ${ipp}`, 'error');
setPatientId(`ipp-introuvable:${ipp}`);
setPatientTab('resume');
setRoute('patient');
return;
}
await openPatient(target.id);
};
// Le geste attend l'authentification, quel qu'en soit le chemin : reprise de
// session ou connexion fraîche passent toutes les deux par `authed`. Le lien
// dit où aller, l'authentification garde la porte. Une seule fois : se
// déconnecter puis se reconnecter ne rouvre pas le dossier scanné.
// Un objet plutôt que l'IPP nu : rescanner le même dossier est un nouveau
// geste, et doit rouvrir le dossier même si la valeur n'a pas changé.
const [scannedRecord, setScannedRecord] = React.useState(
SCANNED_RECORD_IPP ? { ipp: SCANNED_RECORD_IPP } : null,
);
const scannedRecordDone = React.useRef(false);
// Un scan peut aussi arriver alors que l'app est déjà ouverte : un simple
// changement de fragment ne recharge pas la page, donc la lecture du
// chargement ne suffit pas. On écoute le fragment en continu — même geste,
// même porte.
React.useEffect(() => {
const onHashChange = () => {
const ipp = takeRecordIppFromHash();
if (!ipp) return;
scannedRecordDone.current = false;
setScannedRecord({ ipp });
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
React.useEffect(() => {
if (!authed || !scannedRecord || scannedRecordDone.current) return;
scannedRecordDone.current = true;
openRecordByIpp(scannedRecord.ipp);
}, [authed, scannedRecord]);
// Prendre un rendez-vous depuis un dossier : c'est l'agenda qui crée, avec le
// patient déjà désigné. L'agenda doit être chargé avant d'y arriver, sinon sa
// semaine est vide le temps de la requête.
const openAgendaFor = async (id) => {
setBusy('Chargement de l’agenda…');
try {
await window.VascoApi.loadAgenda();
setNewRdvFor(id);
setRoute('agenda');
} catch (error) {
showToast(error?.message || 'Agenda indisponible', 'error');
} finally {
setBusy(null);
}
};
const handleResumeHandled = React.useCallback(() => setResumeReportSave(null), []);
const handleNewRdvHandled = React.useCallback(() => setNewRdvFor(null), []);
const handleTab = async (id) => {
if (id === 'MOI') setAccountStats(false);
const target = { DICT: 'dashboard', PAT: 'patients', AGE: 'agenda', IA: 'aichat', MOI: 'account' }[id];
if (!target) return;
// Le tableau de bord passe par la même porte que les trois autres onglets.
// Il était le seul à s'afficher sans rien demander : après un archivage ou
// un renommage fait au répertoire, il gardait ses chiffres du jour, son
// prochain rendez-vous et le nom d'avant. `loadDashboard` ne rappelle le
// serveur que si une mutation l'a marqué périmé.
const loaders = {
DICT: window.VascoApi.loadDashboard, PAT: window.VascoApi.loadPatients,
AGE: window.VascoApi.loadAgenda, MOI: window.VascoApi.loadAccount,
};
const labels = {
DICT: 'Actualisation du tableau de bord…', PAT: 'Chargement des patients…',
AGE: 'Chargement de l’agenda…', MOI: 'Chargement du compte…',
};
const loader = loaders[id];
if (!loader) { setRoute(target); return; }
setBusy(labels[id]);
try {
await loader();
setRoute(target);
} catch (error) {
showToast(error?.message || 'Chargement impossible', 'error');
} finally {
setBusy(null);
}
};
const baseScreen = (() => {
if (route === 'patient') {
return setRoute('patients')} onOpenChat={slideToChat} />;
}
if (route === 'patients') {
return setRoute('wizard')}
onNewAppointment={openAgendaFor}
onNewNote={(id) => openPatient(id, 'notes')}
// Retour au tableau de bord par la même porte que l'onglet : c'est le
// chemin le plus court entre une suppression de dossier et l'écran qui
// le comptait encore.
onBack={() => handleTab('DICT')} onTabChange={handleTab} />;
}
if (route === 'agenda') return ;
if (route === 'aichat') return {
setPendingReportSave(widget || null);
setWizardReturn('aichat');
setRoute('wizard');
}} />;
if (route === 'account') {
return setAccountStats(false)}
runtime={window.VascoApi.account} onLoggedOut={handleLoggedOut} />;
}
return { setAccountStats(true); setRoute('account'); }} />;
})();
return (
{route === 'wizard' && {
setRoute(wizardReturn || 'patients');
setWizardReturn(null);
// Création abandonnée : il n'y a pas de dossier où écrire, l'enregistrement
// en attente est oublié plutôt que rejoué au hasard.
setPendingReportSave(null);
}}
onComplete={async (patient) => {
// Le répertoire est en cache : après une création il doit être
// relu, sinon le nouveau dossier n'apparaît pas dans la liste.
await window.VascoApi.loadPatients().catch(() => {});
if (pendingReportSave?.id && patient?.id) {
setResumeReportSave({ widgetId: pendingReportSave.id, patientId: patient.id });
}
setPendingReportSave(null);
setRoute(wizardReturn || 'patients');
setWizardReturn(null);
}} />}
>}
{errorView && (