// 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 (
{!authed && } {authed && !onboarded && } {authed && <>
{slideAnim && prevRoute && (
{}} onChangeTab={() => {}} />
)}
{baseScreen}
{route === 'patient' && }
{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 && (
{ if (errorView === '404') { setErrorView(null); setRoute('dashboard'); return; } showToast('Nouvelle tentative…', 'info'); setTimeout(() => setErrorView(null), 900); }} onSecondary={errorView === '404' ? undefined : () => { setErrorView(null); setRoute('dashboard'); }} />
)} {busy && }
); } ReactDOM.createRoot(document.getElementById('root')).render();