11. Étude de cas : le client Ionic de l'application RdvMedecins
11.1. Le problème
L'application [RdvMedecins] permet de prendre des rendez-vous dans un cabinet médical. Le cours [NestJS] l'a écrite sous la forme d'une application HTML, dont le serveur fabriquait les pages ; les cours [Vue.js], [Angular] et [React] en ont écrit des clients web, qui fabriquent les MÊMES pages dans le navigateur, à partir des données JSON du serveur présenté au chapitre « Le serveur RdvMedecins, une boîte noire » ; le cours [Flutter] en a écrit un client mobile. Nous en écrivons ici le client [Ionic] : une application mobile ([Android]), construite avec [Angular] et [Capacitor], qui est AUSSI, sans rien changer, une application web.
Le client doit offrir tout ce qu'offraient les autres :
- un écran de connexion, protégé par un captcha, avec la possibilité de créer un compte de patient ;
- l'agenda d'un médecin pour un jour donné, avec réservation (fenêtre de choix du patient) et annulation ;
- pour l'administrateur, la gestion des médecins et des clients (liste, ajout, modification, suppression) ;
- pour le patient, son espace (son rendez-vous à venir, qu'il peut annuler) et la gestion de son compte ;
- deux langues, le français et l'anglais ;
- les mêmes adresses que les clients web ([/agenda?idMedecin=1&jour=...]). Sur le téléphone, elles sont invisibles, mais organisent l'application ; sur le web, on peut les recharger, les mettre en favori, et parcourir l'application avec les boutons Précédent / Suivant du navigateur.
Une application mobile ajoute ses propres exigences : un écran étroit (pas de tableau à cinq colonnes), un clavier qui cache la moitié de l'écran, un bouton Retour, un gestionnaire de mots de passe, et des requêtes HTTP qui ne partent pas d'une page web servie par le serveur (où ranger le cookie du jeton ?).
C'est l'occasion de réunir tout ce que les exemples ont présenté séparément : composants et signaux, formulaires, routeur et gardes, état partagé, traductions, dialogue avec le serveur.
11.2. Installation et exécution
Le serveur JSON doit être installé et lancé (cf. chapitre « Le serveur RdvMedecins, une boîte noire »). On n'a PAS besoin du mode test du captcha : on peut laisser [CAPTCHA_TEST] vide dans le fichier [.env] du serveur.
Dans une console ouverte sur le dossier [rdvmedecins_ionic] :
- ligne 1 : télécharge les paquets du projet (dossier [node_modules]), décrits par [package.json] ;
- ligne 2 : lance [ng serve], le serveur de développement d'[Angular], à l'adresse http://localhost:8100. Il transmet les requêtes /api au serveur JSON (fichier [proxy.conf.json], comme pour les exemples 22 à 25). On ouvre cette adresse dans [Chrome], de préférence avec les outils de développement (F12) en mode « appareil mobile » (Ctrl+Maj+M), à la taille d'un téléphone.
Comme pour les exemples, toute modification d'un fichier source est compilée et rechargée aussitôt dans le navigateur.
Pour l'essayer sur [Android] (le SDK d'[Android] et [Android Studio] installés, cf. chapitre « Mise en place de l'environnement de travail ») :
| npx cap add android
npx ng build
npx cap sync android
npx cap run android
|
- ligne 1 : une seule fois : crée le projet [Android Studio] (dossier [android/]). Avec le JDK d'un [Android Studio] récent ([Java] 25), on passe ensuite le projet à [Gradle] 9.1 (fichier [android/gradle/wrapper/gradle-wrapper.properties], cf. exemple 01) ;
- ligne 2 : compile l'application web dans [dist/rdvmedecins_ionic/browser/] ;
- ligne 3 : la copie dans le projet [Android] ;
- ligne 4 : la lance sur l'émulateur, ou sur un téléphone branché en USB (la commande demande lequel). L'émulateur joint le serveur à l'adresse [10.0.2.2:8080] (cf. exemple 22) ; pour un vrai téléphone, on donne l'adresse de l'ordinateur sur le réseau Wi-Fi à la compilation : [npx ng build --define "SERVEUR='http://192.168.1.20:8080'"] (cf. [config.ts]). Le chapitre « Déploiement » détaille ces étapes.
Les comptes de démonstration sont rappelés sur l'écran de connexion (mot de passe = login).
11.3. Utilisation de l'application
Les copies d'écran de cette section sont celles d'un téléphone de 1082 × 2342 pixels (412 × 892 points). Elles ont été fabriquées par le programme [captures/captures.mjs] (cf. exemple 01), qui pilote un navigateur [Chromium] à cette taille et joue, pour chaque groupe d'écrans, un scénario du dossier [captures/scenarios/].
11.3.1. L'écran de connexion
L'écran de connexion ; un mauvais mot de passe : le message s'affiche en bas de l'écran, et une nouvelle image est proposée (chacune ne sert qu'une fois) :
11.3.2. L'agenda, vu par l'administrateur et les médecins
Connecté comme [admin], on arrive sur l'agenda. Le bouton ☰ ouvre le menu : les écrans permis à l'administrateur, et, en haut, qui est connecté :
On choisit un médecin et un jour (le jour, dans un calendrier), puis « Voir l'agenda ». L'adresse devient [/agenda?idMedecin=1&jour=2026-10-13]. « Réserver » ouvre la fenêtre de réservation ; l'adresse devient [/agenda?idMedecin=1&jour=2026-10-13&reserver=2] :
Le rendez-vous est enregistré ; « Annuler » demande confirmation :
Un médecin arrive directement sur SON agenda du jour :

11.3.3. L'espace du patient
Un patient ([martin]) arrive sur « Mon espace ». Dans un agenda, il voit « Réservé » en face des créneaux des autres patients, et réserve pour lui-même, sans choisir de patient :
Son rendez-vous apparaît sous son nom ; il n'a plus de bouton « Réserver » (un seul rendez-vous à venir). Dans l'agenda d'un autre médecin, où son rendez-vous n'apparaît pas, un message lui dirait pourquoi il ne peut pas réserver. Son espace montre le rendez-vous :
« Gérer mon compte » : toute modification exige le mot de passe actuel ; les erreurs sont celles du serveur :

Le menu d'un patient n'a pas d'entrée « Médecins ». Sur le web, s'il tape lui-même l'adresse /medecins, le garde du routeur lui affiche une page d'erreur 403 (cf. [app.routes.ts]).
11.3.4. La gestion des médecins et des clients
La liste des médecins ; un ajout mal rempli :
Un ajout réussi ; un homonyme ; la suppression d'un médecin qui a des rendez-vous est refusée :
11.3.5. La création d'un compte
Sur l'écran de connexion, on recopie le captcha puis on touche « Créer un compte client ». Le formulaire, refusé par le serveur ; une fois le compte créé, retour à l'écran de connexion, login pré-rempli :
11.3.6. En anglais, sur un grand écran
Le menu FR / EN de la barre du haut retraduit l'écran sur place, sans rien demander au serveur, y compris les dates (« Tuesday, October 13, 2026 »). Sur un écran large (le web, une tablette), le menu reste affiché à gauche :


11.3.7. Session expirée
Si le jeton expire (ou si le cookie disparaît), la requête suivante reçoit 401 ; l'application renvoie à l'écran de connexion, avec un message :

11.4. L'architecture du client
| rdvmedecins_ionic/
├── package.json les paquets, les scripts
├── angular.json la configuration du projet Angular
├── tsconfig.json la configuration de TypeScript
├── proxy.conf.json le serveur de développement (relais /api)
├── capacitor.config.ts la configuration de Capacitor (Android)
├── android/ le projet Android Studio (après npx cap add android)
├── captures/ la fabrication des copies d'écran (Playwright)
├── public/
│ └── i18n/ les dictionnaires fr.json, en.json
└── src/
├── index.html la page qui accueille l'application
├── main.ts le démarrage
├── styles.scss les styles communs
└── app/
├── app.ts le composant racine
├── bouton-retour.ts le bouton Retour d'Android
├── app.routes.ts les écrans, leurs adresses, leurs droits
├── config.ts l'adresse du serveur
├── formatage.ts la mise en forme des heures et des jours
├── api/ la couche d'accès à l'API
├── i18n/ les langues
├── etat/ session, messages, cache (services à signaux)
├── composants/ le gabarit et les composants réutilisables
└── pages/ les écrans
|
C'est l'architecture du client [Flutter], fichier pour fichier, avec les noms de fichiers d'[Angular] (en minuscules, mots séparés par des tirets) :
- une couche données ([api/]) : la seule à connaître le client HTTP d'[Angular], les URL et le JSON ;
- une couche état ([etat/]) : des services à signaux, fournis à toute l'application par l'injection de dépendances, que les écrans lisent ;
- une couche interface ([pages/], [composants/]) : des composants, qui affichent l'état et transmettent les actions de l'utilisateur.
Le tableau suivant met en regard le client [Flutter] et ce client :
Notion | Client Flutter | Client Ionic |
les écrans | des widgets | des composants Angular et ceux d'Ionic |
ce qui entoure les écrans | [gabarit.dart], le widget d'une [ShellRoute] | [gabarit.ts], autour d'[ion-router-outlet] |
le menu | [NavigationDrawer] / [NavigationRail] | [ion-menu] dans [ion-split-pane] |
les adresses | [go_router] | le routeur d'Angular |
les droits d'accès | la fonction [redirect] du routeur | un garde [CanActivateFn] |
le cookie du jeton | un client HTTP qui gère les cookies | le navigateur (web), [CapacitorHttp] (Android) |
la session expirée (401) | une fonction confiée à [ClientApi] | un intercepteur [HttpInterceptorFn] |
l'état partagé | des [ChangeNotifier] ([provider]) | des services à signaux ([inject()]) |
les données asynchrones | [FutureBuilder] | [resource()] |
les données déjà lues | [Cache] (quelques lignes) | [Cache] (quelques lignes) |
la langue mémorisée | [shared_preferences] | [@capacitor/preferences] |
formulaire refusé | erreurs sous les champs | idem |
Tous les fichiers du client sont présentés ci-dessous. Ils sont abondamment commentés : leurs commentaires font partie du cours.
11.5. La configuration du projet
Le projet a été créé comme celui des exemples (cf. exemple 01) : [ng new], puis l'ajout des paquets d'[Ionic] et de [Capacitor]. Les fichiers [angular.json] et [tsconfig.json] sont ceux que fabrique [ng new], à deux détails près, les mêmes que pour les exemples : dans [angular.json], le port du serveur de développement (8100) et le fichier du relais ([proxyConfig]) ; dans [tsconfig.json], les vérifications strictes ([strict], [strictTemplates]).
11.5.1. package.json
| {
"name": "rdvmedecins-ionic",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"android": "ng build && cap sync android && cap run android",
"captures": "node captures/captures.mjs"
},
"private": true,
"packageManager": "npm@10.9.4",
"dependencies": {
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.2",
"@capacitor/core": "^8.5.2",
"@capacitor/preferences": "^8.0.1",
"@ionic/angular": "^9.0.6",
"ionicons": "^8.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.2.1",
"@angular/cli": "^22.2.1",
"@angular/compiler-cli": "^22.2.0",
"@capacitor/cli": "^8.5.2",
"playwright": "^1.56.1",
"prettier": "^3.8.1",
"typescript": "~6.0.2"
}
}
|
- lignes 4-11 : les scripts : [npm start] lance le serveur de développement, [npm run build] compile l'application, [npm run android] la compile, la copie dans le projet [Android] et la lance sur le téléphone, [npm run captures] fabrique les copies d'écran ;
- lignes 14-29 : les paquets de l'application : [Angular] (sans [@angular/animations] ni [zone.js] : l'application est « zoneless », cf. exemple 01), [Capacitor] (son noyau, la plate-forme [Android], le greffon [App] qui donne accès au bouton Retour, et le greffon [Preferences] qui mémorise la langue), [Ionic] et ses icônes ; [rxjs] est la bibliothèque des [Observable] du client HTTP ;
- lignes 30-38 : les outils de développement : le compilateur d'[Angular], la commande [ng], la commande [cap] de [Capacitor], [Playwright] pour les copies d'écran, [Prettier] pour la mise en forme du code, [TypeScript].
Il n'y a AUCUN paquet de plus que pour les exemples : pas de bibliothèque de traduction, de gestion d'état ou de formulaires. Tout ce dont l'application a besoin est dans [Angular] et [Ionic], et dans quelques fichiers écrits ici.
11.5.2. proxy.conf.json
| {
"/api": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": false,
"logLevel": "warn"
}
}
|
Celui de l'exemple 22 : en développement web, [ng serve] transmet les requêtes /api au serveur JSON. Pour le navigateur, l'application et l'API ont la MÊME origine (http://localhost:8100) : pas de contrôle CORS, et le cookie du jeton circule.
11.5.3. capacitor.config.ts
| // =========================================================================
// capacitor.config.ts - la configuration de Capacitor
// -------------------------------------------------------------------------
// Capacitor transforme l'application web (le dossier produit par ng build)
// en application Android : il l'emballe dans un projet Android Studio
// (dossier android/), où elle est affichée par une "WebView", le moteur du
// navigateur Chrome du téléphone. Commandes (cf. chapitre Déploiement) :
// npx cap add android (une seule fois : crée le dossier android/)
// ng build (fabrique l'application web)
// npx cap sync android (la copie dans le projet Android)
// npx cap run android (la lance sur un émulateur ou un téléphone)
// =========================================================================
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
// l'identifiant unique de l'application sur le téléphone (et sur le Play Store)
appId: 'fr.stahe.rdvmedecins',
// le nom affiché sous l'icône
appName: 'RdvMedecins',
// le dossier de l'application web compilée (ng build)
webDir: 'dist/rdvmedecins_ionic/browser',
server: {
// autorise les requêtes HTTP non chiffrées (http://10.0.2.2:8080) vers
// le serveur JSON de développement (à retirer en production : HTTPS)
cleartext: true,
},
plugins: {
// CapacitorHttp : les requêtes HTTP de l'application ne sont plus faites
// par la WebView mais par le code natif d'Android. Conséquences (cf.
// api/client-http.ts) : pas de contrôle CORS (le serveur JSON n'a rien à
// autoriser), et les cookies (celui du jeton d'authentification) sont
// rangés par Android et renvoyés automatiquement
CapacitorHttp: {
enabled: true,
},
},
};
export default config;
|
La configuration de [Capacitor] (cf. exemple 01) :
- ligne 18 : l'identifiant de l'application sur le téléphone (et sur le [Play Store]) : celui de l'application [Flutter]. Les deux ne peuvent donc pas être installées en même temps sur un même téléphone ; pour les comparer, il faudrait en changer un ;
- ligne 22 : le dossier de l'application web compilée, que [npx cap sync] copie dans le projet [Android] ;
- ligne 26 : [Android] refuse par défaut les requêtes HTTP non chiffrées ; on les autorise, pour joindre le serveur de développement ([http://10.0.2.2:8080]). En production, le serveur doit être en HTTPS ;
- lignes 34-36 : le greffon [CapacitorHttp] (exemple 22) : sur le téléphone, les requêtes HTTP ne sont plus faites par la [WebView] mais par le code natif d'[Android]. Le serveur n'a donc aucune règle CORS à respecter, et les cookies sont rangés et renvoyés par [Android] lui-même. C'est ce qui permet au client [Ionic] de se passer du client HTTP « qui gère les cookies » du client [Flutter].
11.6. Le démarrage de l'application
11.6.1. src/app/config.ts
| // =========================================================================
// src/app/config.ts - OÙ est le serveur JSON ?
// -------------------------------------------------------------------------
// La question ne se pose pas pour une application web servie par le
// serveur lui-même : les requêtes partent vers la MÊME origine que la page
// (une adresse relative, /api/...). Mais une application MOBILE n'a pas
// d'origine utilisable (sur Android, la page est servie par Capacitor
// depuis https://localhost) : il faut lui donner l'adresse complète du serveur.
// - l'émulateur Android voit l'ordinateur qui le fait tourner à
// l'adresse 10.0.2.2 (localhost, pour lui, c'est l'émulateur lui-même) ;
// - un vrai téléphone, relié au même réseau Wi-Fi que l'ordinateur, doit
// utiliser l'adresse de l'ordinateur sur ce réseau (192.168.1.20...) :
// on la donne à la construction de l'application :
// ng build --define "SERVEUR='http://192.168.1.20:8080'"
// - en développement web (ng serve), le serveur de développement
// d'Angular transmet les requêtes /api au serveur JSON (fichier
// proxy.conf.json) : une adresse relative suffit.
// =========================================================================
import { Capacitor } from '@capacitor/core';
// la valeur donnée par --define "SERVEUR='...'" à la construction
// (ng build, ng serve). L'option remplace, dans le code compilé, le mot
// SERVEUR par la valeur donnée. Sans elle, SERVEUR n'existe pas : typeof
// permet de le vérifier sans provoquer d'erreur
declare const SERVEUR: string | undefined;
const serveurImpose = typeof SERVEUR === 'string' ? SERVEUR : '';
export const config = {
// Capacitor.isNativePlatform() : vrai dans l'application Android (ou iOS),
// faux dans un navigateur ; Capacitor.getPlatform() : 'android', 'ios' ou 'web'
urlServeur:
serveurImpose !== ''
? serveurImpose
: !Capacitor.isNativePlatform()
? '' // le web : même origine, adresses relatives
: Capacitor.getPlatform() === 'android'
? 'http://10.0.2.2:8080'
: 'http://localhost:8080',
};
|
L'adresse du serveur, selon la plate-forme (cf. exemple 22) :
- ligne 26 : [declare const SERVEUR] annonce au compilateur [TypeScript] une constante qu'il ne voit nulle part : c'est la commande de compilation qui la fournit ([ng build --define "SERVEUR='...'"]). L'option [--define] REMPLACE, dans le code compilé, le mot [SERVEUR] par la valeur donnée : l'adresse est inscrite dans l'application construite, comme avec le [--dart-define] de [Flutter] ;
- ligne 27 : sans l'option, [SERVEUR] n'existe pas, et le lire provoquerait une erreur à l'exécution ; [typeof] est le seul opérateur qui accepte un nom inconnu (il donne alors « undefined ») ;
- lignes 32-39 : sinon, une adresse relative sur le web (le serveur de développement, puis le serveur JSON lui-même en production, servent la page ET l'API), [10.0.2.2] sur [Android] (l'ordinateur, vu de l'émulateur), [localhost] ailleurs. [Capacitor.isNativePlatform()] dit si l'application tourne dans l'enveloppe native de [Capacitor] (et non dans un navigateur), [Capacitor.getPlatform()] sur quel système.
11.6.2. src/main.ts
| // =========================================================================
// src/main.ts - le point d'entrée de l'application
// -------------------------------------------------------------------------
// Une application Angular démarre par bootstrapApplication : Angular crée
// le composant RACINE (App) et l'insère dans la balise <app-root> de la
// page index.html. Le second paramètre donne les "fournisseurs" (providers)
// de toute l'application : Ionic, le routeur, le client HTTP et son
// intercepteur, le titre des écrans, et le chargement de la langue.
// Les services (session, messages, cache, traductions, API) n'ont pas à
// être déclarés ici : providedIn: 'root' les fournit à toute l'application.
// =========================================================================
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { inject, provideAppInitializer } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router';
import { provideIonicAngular } from '@ionic/angular';
import { intercepteurApi } from './app/api/client-http';
import { App } from './app/app';
import { routes } from './app/app.routes';
import { TitrePage } from './app/composants/titre-page';
import { Traductions } from './app/i18n/traductions';
bootstrapApplication(App, {
providers: [
// Ionic : mode 'md' (Material Design) = l'aspect d'Android, même dans
// le navigateur d'un ordinateur
provideIonicAngular({ mode: 'md' }),
// le routeur : la liste des routes (app.routes.ts) ;
// withComponentInputBinding : les paramètres d'une route (:id, ?jour=...,
// data) sont donnés aux entrées (input) du composant de l'écran
provideRouter(routes, withComponentInputBinding()),
// le client HTTP d'Angular, dont chaque requête passe par notre intercepteur
provideHttpClient(withInterceptors([intercepteurApi])),
// nos titres d'écrans sont des clés de traduction (cf. titre-page.ts)
{ provide: TitleStrategy, useClass: TitrePage },
// avant le premier écran : la langue (mémorisée, ou celle de l'appareil)
// et son dictionnaire. Angular attend la fin de la Promise renvoyée
provideAppInitializer(() => inject(Traductions).demarrer()),
],
}).catch((err) => console.error(err));
|
Le démarrage. Il n'y a ici que des fournisseurs : les objets partagés (la session, les messages, le cache, les traductions, la couche d'accès à l'API) sont des services [providedIn: 'root'], créés par [Angular] à leur première injection (cf. exemple 14).
- ligne 28 : [Ionic], en mode [md] (l'aspect d'[Android]) ;
- ligne 32 : le routeur ; [withComponentInputBinding()] donne les paramètres de l'adresse ([?jour=...], [:id]) et les données des routes ([data]) aux entrées des composants des écrans (cf. exemple 17). Les écrans n'ont ainsi jamais à lire l'adresse eux-mêmes ;
- ligne 34 : le client HTTP, et son intercepteur (cf. [api/client-http.ts]) ;
- ligne 36 : la « stratégie de titre » du routeur est remplacée par la nôtre, qui traduit les titres (cf. [composants/titre-page.ts]) ;
- ligne 39 : [provideAppInitializer] : une fonction exécutée AVANT l'affichage du premier écran ; si elle renvoie une [Promise], [Angular] en attend la fin. On y charge le dictionnaire de la langue de départ : le premier écran s'affiche directement dans la bonne langue (c'est le [await traductions.demarrer()] placé avant [runApp] dans le client [Flutter]).
11.6.3. src/app/app.ts
| // =========================================================================
// src/app/app.ts - le composant RACINE de l'application
// -------------------------------------------------------------------------
// Il affiche le gabarit (qui contient l'emplacement des écrans), et relie
// entre eux les services de l'application par deux mécanismes :
// 1. la session change (connexion, déconnexion, session expirée) : les
// données lues pour l'utilisateur précédent ne valent plus rien (on
// vide le cache), et les gardes du routeur sont réévalués pour
// l'écran courant : ce sont eux qui quittent l'écran de connexion
// après la connexion, ou qui y renvoient après une déconnexion ;
// 2. les messages "flash" : à la fin de CHAQUE navigation, les messages
// de l'écran précédent disparaissent, ceux déposés pour l'écran
// suivant apparaissent (cf. etat/messages.ts).
// La session EXPIRÉE (réponse 401) est traitée par l'intercepteur
// (api/client-http.ts) : il vide la session, et le mécanisme 1 fait le reste.
// Il branche aussi le bouton Retour d'Android (cf. bouton-retour.ts).
// =========================================================================
import { Component, computed, effect, inject, untracked } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { IonApp, NavController } from '@ionic/angular';
import { filter } from 'rxjs';
import { gererBoutonRetour } from './bouton-retour';
import { Gabarit } from './composants/gabarit';
import { Cache } from './etat/cache';
import { Messages } from './etat/messages';
import { Session } from './etat/session';
@Component({
selector: 'app-root',
imports: [IonApp, Gabarit],
template: `
<ion-app>
<app-gabarit />
</ion-app>
`,
})
export class App {
private readonly session = inject(Session);
private readonly cache = inject(Cache);
private readonly messages = inject(Messages);
private readonly router = inject(Router);
private readonly navigation = inject(NavController);
// le rôle de l'utilisateur connecté (null : personne). Un nouveau nom
// (modification du compte) ne change pas ce signal : seuls la connexion
// et la déconnexion le changent
private readonly role = computed(() => this.session.utilisateur()?.role ?? null);
private adresse = '';
constructor() {
// le bouton Retour d'Android
gererBoutonRetour();
// 1a. la session change : le cache est vidé
effect(() => {
this.session.utilisateur();
untracked(() => this.cache.vider());
});
// 1b. connexion, déconnexion : les gardes sont réévalués pour l'adresse
// courante. navigateRoot : l'écran affiché remplacera toute la pile des
// écrans d'Ionic (ceux de l'utilisateur précédent disparaissent) ;
// onSameUrlNavigation: 'reload' : même vers l'adresse courante, la
// navigation a lieu (et les gardes s'exécutent)
effect(() => {
this.role();
untracked(() => {
// pas avant la première navigation : elle s'en charge
if (!this.router.navigated) return;
void this.navigation.navigateRoot(this.router.url, { onSameUrlNavigation: 'reload', replaceUrl: true });
});
});
// 2. à chaque navigation terminée (l'adresse a changé) : nouvelle page
this.router.events
.pipe(
filter((e) => e instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe((e) => {
if (e.urlAfterRedirects !== this.adresse) {
this.adresse = e.urlAfterRedirects;
this.messages.nouvellePage();
}
});
}
}
|
Le composant racine : il affiche le gabarit, et relie entre eux les services de l'application, comme le faisait [main.dart] :
- lignes 34-36 : [ion-app], le conteneur de toute application [Ionic], et le gabarit (cf. [composants/gabarit.ts]), qui contient l'emplacement des écrans ;
- ligne 48 : le rôle de l'utilisateur connecté, calculé à partir de la session. Un signal calculé ([computed]) ne prévient ceux qui le lisent que si sa VALEUR change : modifier son nom (« Gérer mon compte ») change l'utilisateur, mais pas son rôle ;
- ligne 53 : le bouton Retour d'[Android], branché par la fonction de [bouton-retour.ts] (ci-dessous) ;
- lignes 55-58 : 1a. à chaque changement d'utilisateur, le cache est vidé : les données lues pour l'utilisateur précédent ne valent rien pour le suivant (un patient ne doit pas voir l'agenda lu pour l'administrateur). Un effet ([effect]) est réexécuté quand change un signal qu'il a lu : ici, [this.session.utilisateur()]. [untracked] : ce que fait la suite n'est pas une dépendance de l'effet ;
- lignes 64-71 : 1b. à chaque connexion ou déconnexion, on « renavigue » vers l'adresse courante, ce qui réexécute les gardes du routeur (cf. [app.routes.ts]) : c'est le garde qui quitte l'écran de connexion après une connexion réussie, ou qui y renvoie après une déconnexion ou une session expirée. C'est le [refreshListenable] de [go_router], fait à la main. [navigateRoot] (du [NavController] d'[Ionic]) : l'écran affiché remplace TOUTE la pile des écrans d'[Ionic] ; ceux de l'utilisateur précédent sont détruits. [onSameUrlNavigation: 'reload'] : sans cette option, le routeur ignore une navigation vers l'adresse courante ;
- ligne 68 : au démarrage, l'effet s'exécute avant la première navigation, qui exécutera de toute façon les gardes : on ne fait rien ;
- lignes 73-83 : 2. les messages flash. Le routeur publie ses événements dans un [Observable] ([router.events]) ; on ne garde que les fins de navigation ([filter]), et, si l'adresse a changé, on passe à une « nouvelle page » : les messages de l'écran précédent disparaissent, ceux déposés pour celui-ci apparaissent (cf. [messages.ts]). Le bouton Retour, qui ferme la fenêtre de réservation, change l'adresse : il efface lui aussi les messages. [takeUntilDestroyed()] : l'abonnement prend fin avec le composant (ici, avec l'application).
11.6.4. src/app/bouton-retour.ts
| // =========================================================================
// src/app/bouton-retour.ts - le bouton Retour d'Android
// -------------------------------------------------------------------------
// Sur le téléphone, Capacitor reçoit l'appui sur le bouton Retour. S'il n'a
// aucun écouteur pour cet événement, il se contente de reculer dans
// l'historique de la WebView, puis ferme l'application : Ionic n'en sait
// rien (une fenêtre ouverte ne se ferme pas, le menu en tiroir non plus).
// Le greffon @capacitor/app permet de s'y abonner : dès qu'il y a un
// écouteur, Capacitor transmet l'appui à Ionic, qui appelle le traitement
// de plus haute priorité :
// - 100 : une fenêtre ouverte (alerte, ion-modal...) se ferme ;
// - 99 : le menu en tiroir ouvert se ferme ;
// - 1 : (le nôtre) on revient à l'écran précédent ; s'il n'y en a pas,
// on quitte l'application.
// Dans le navigateur, il n'y a pas de bouton Retour : on ne fait rien.
// =========================================================================
import { inject } from '@angular/core';
import { NavController, Platform } from '@ionic/angular';
import { App as AppCapacitor } from '@capacitor/app';
import { Capacitor } from '@capacitor/core';
// à appeler dans le constructeur du composant racine (contexte d'injection)
export function gererBoutonRetour(): void {
if (!Capacitor.isNativePlatform()) return;
const navigation = inject(NavController);
// un écouteur (vide) : Capacitor transmet désormais l'appui à Ionic
void AppCapacitor.addListener('backButton', () => {});
inject(Platform).backButton.subscribeWithPriority(1, async () => {
// pop() : retour à l'écran précédent de la pile d'Ionic ; false : la
// pile ne contient que l'écran affiché, il n'y a plus rien derrière
if (!(await navigation.pop())) {
await AppCapacitor.exitApp();
}
});
}
|
Le même fichier que dans les exemples (cf. exemple 01, « Le bouton Retour d'Android »). Ici, il donne au bouton Retour du téléphone, dans l'ordre : fermer une fenêtre ouverte (la fenêtre de réservation, une demande de confirmation, la liste des médecins, le calendrier), fermer le menu en tiroir, revenir à l'écran précédent, et enfin quitter l'application depuis le premier écran.
11.6.5. src/styles.scss
| // =========================================================================
// src/styles.scss - les styles de toute l'application
// -------------------------------------------------------------------------
// Les feuilles de style d'Ionic : sans elles, ses composants n'ont pas
// leur aspect. core.css est indispensable ; les autres sont des utilitaires
// (marges, alignements, classes ion-padding, ion-text-center...).
// =========================================================================
@use '@ionic/angular/css/core.css';
@use '@ionic/angular/css/normalize.css';
@use '@ionic/angular/css/structure.css';
@use '@ionic/angular/css/typography.css';
@use '@ionic/angular/css/padding.css';
@use '@ionic/angular/css/float-elements.css';
@use '@ionic/angular/css/text-alignment.css';
@use '@ionic/angular/css/text-transformation.css';
@use '@ionic/angular/css/flex-utils.css';
@use '@ionic/angular/css/display.css';
// la couleur principale de l'application (boutons, barre du haut) : un indigo,
// comme dans les exemples du cours. Ionic dérive de --ion-color-primary les
// variantes plus claire (tint) et plus foncée (shade)
:root {
--ion-color-primary: #3f51b5;
--ion-color-primary-rgb: 63, 81, 181;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #37479f;
--ion-color-primary-tint: #5262bc;
}
// -------------------------------------------------------------------------
// les styles communs aux écrans de RdvMedecins
// -------------------------------------------------------------------------
// le titre d'un écran
h1 {
font-size: 26px;
font-weight: 400;
margin: 8px 0 12px;
}
// le titre d'une section ("Compte utilisateur")
h3 {
font-size: 15px;
font-weight: 500;
margin: 16px 0 12px;
}
// un texte d'aide, en gris
.aide,
.intro {
font-size: 14px;
color: var(--ion-color-medium-shade);
}
// un peu d'espace sous chaque liste déroulante
ion-select {
margin-bottom: 12px;
}
// les boutons d'un formulaire, côte à côte (les uns sous les autres s'ils
// ne tiennent pas)
.boutons {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
// les comptes de démonstration de l'écran de connexion
.demo {
margin-top: 16px;
font-size: 13px;
color: var(--ion-color-medium-shade);
code {
font-family: 'Roboto Mono', monospace;
font-size: 12px;
}
}
// la boîte de confirmation (AlertController, cf. confirmation.ts) : un peu
// plus large, et son bouton "Oui" en rouge
ion-alert.confirmation {
--min-width: 310px;
--max-width: 340px;
}
ion-alert.confirmation button.bouton-danger {
background: #d32f2f;
color: white;
border-radius: 20px;
padding: 0 16px;
}
// une fenêtre (ion-modal) à la taille de son contenu, au centre de l'écran,
// plutôt que sur tout l'écran (le défaut sur un téléphone)
ion-modal.fenetre {
--width: calc(100% - 48px);
--max-width: 420px;
--height: fit-content;
--border-radius: 16px;
}
|
Les styles communs à tous les écrans :
- lignes 9-18 : les feuilles de style d'[Ionic] (cf. exemple 01) ;
- lignes 23-30 : la couleur principale, un indigo, et ses variantes ;
- lignes 37-60 : les titres, les textes d'aide, l'espace sous les listes déroulantes ;
- lignes 64-69 : les boutons d'un formulaire, côte à côte, ou les uns sous les autres s'ils ne tiennent pas (le [Wrap] de [Flutter], en CSS : [flex-wrap: wrap]) ;
- lignes 72-81 : les comptes de démonstration de l'écran de connexion, en petits caractères ; les logins dans une police à chasse fixe ;
- lignes 85-95 : la boîte de confirmation, un peu plus large que celle d'[Ionic] (« Non, revenir » et « Oui, confirmer » tiennent sur une ligne), et son bouton rouge. Les boîtes créées par l'[AlertController] sont ajoutées à la fin de la page, hors de tout composant : seule une feuille de style globale peut les atteindre ;
- lignes 99-104 : une fenêtre ([ion-modal]) de la classe [fenetre] prend la taille de son contenu, au centre de l'écran. Sur un téléphone, une fenêtre d'[Ionic] occupe par défaut tout l'écran ; les fenêtres de réservation et du calendrier ressemblent ainsi aux boîtes de dialogue de [Material]. [fit-content] : la hauteur du contenu.
11.7. La couche d'accès à l'API
11.7.1. src/app/api/client-api.ts
| // =========================================================================
// src/app/api/client-api.ts - TOUTES les requêtes vers l'API passent par ici
// -------------------------------------------------------------------------
// Repris de la couche API des exemples (exemple 24) : une seule méthode,
// requete(), qui construit l'adresse complète (config.urlServeur + /api +
// url), envoie le corps en JSON, et renvoie une Promise de la réponse.
//
// Le client HTTP d'Angular (HttpClient) fait passer chaque requête par
// l'INTERCEPTEUR de l'application (client-http.ts), qui convertit toute
// erreur en ErreurApi et traite, en UN seul endroit, la réponse 401
// (session expirée).
// Certaines requêtes traitent elles-mêmes leur 401 (la connexion, GET
// /api/auth/moi...) : elles sont "publiques" ; on le signale à
// l'intercepteur par le CONTEXTE de la requête (HttpContext).
// =========================================================================
import { HttpClient, HttpContext, HttpContextToken } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { config } from '../config';
// une information attachée à une requête, que lit l'intercepteur. Sa
// valeur par défaut (false) : une requête protégée
export const PUBLIQUE = new HttpContextToken<boolean>(() => false);
export type Methode = 'GET' | 'POST' | 'PUT' | 'DELETE';
@Injectable({ providedIn: 'root' })
export class ClientApi {
private readonly http = inject(HttpClient);
// publique : true pour les routes qui traitent elles-mêmes le 401.
// En cas d'échec, la Promise est rejetée avec une ErreurApi
requete<T>(methode: Methode, url: string, corps?: unknown, publique = false): Promise<T> {
// http.request renvoie un Observable ; firstValueFrom le transforme en
// Promise (la requête part à ce moment-là). L'objet corps est envoyé
// en JSON (entête content-type: application/json), la réponse JSON est
// décodée (null si son corps est vide)
return firstValueFrom(
this.http.request<T>(methode, `${config.urlServeur}/api${url}`, {
body: corps,
context: new HttpContext().set(PUBLIQUE, publique),
}),
);
}
}
|
Toutes les requêtes passent par la méthode [requete] (cf. exemple 24) :
- ligne 24 : un « jeton de contexte » : une information attachée à une requête, que l'intercepteur pourra lire. Sa valeur par défaut est donnée par la fonction : [false], une requête protégée ;
- ligne 34 : une méthode générique : [requete<Personne[]>(...)] renvoie une [Promise<Personne[]>]. [publique = false] : un paramètre facultatif, avec sa valeur par défaut ;
- lignes 39-44 : [http.request] renvoie un [Observable] (exemple 22), qui n'envoie la requête qu'au moment où l'on s'y abonne ; [firstValueFrom] s'y abonne, et renvoie une [Promise] de sa première valeur (la réponse). Le corps est un objet [JavaScript] : le client HTTP l'envoie en JSON, avec l'en-tête [Content-Type] qui convient ; la réponse JSON est décodée (en UTF-8 : les accents arrivent intacts). Le contexte de la requête porte l'information « publique ».
Le client [Flutter] traitait ici les erreurs et le 401 ; le client [Ionic] les confie à l'intercepteur.
11.7.2. src/app/api/client-http.ts
| // =========================================================================
// src/app/api/client-http.ts - l'INTERCEPTEUR des requêtes HTTP
// -------------------------------------------------------------------------
// Un intercepteur est une fonction par laquelle passe CHAQUE requête du
// client HTTP d'Angular (il est déclaré dans main.ts :
// provideHttpClient(withInterceptors([intercepteurApi]))). Il reçoit la
// requête et le "suivant" (next) qui l'envoie ; il peut modifier l'une et
// transformer la réponse. Le nôtre traite les ERREURS :
// - toute erreur devient une ErreurApi (cf. erreur-api.ts) ;
// - la réponse 401 d'une requête protégée (session expirée) est traitée
// ICI, une fois pour toutes : on oublie l'utilisateur, on vide le
// cache, on prépare le message pour l'écran de connexion. Les gardes
// du routeur, réévalués quand la session change (cf. app.ts), renvoient
// alors à l'écran de connexion.
//
// Et le COOKIE du jeton JWT ? Le serveur le dépose dans un cookie
// (httpOnly). Le code de l'application n'a RIEN à faire :
// - sur le web, c'est le navigateur qui range le cookie et le renvoie ;
// - sur Android, le greffon CapacitorHttp (activé dans
// capacitor.config.ts) fait passer les requêtes par le code natif
// d'Android, qui range les cookies et les renvoie lui aussi (et les
// garde quand on arrête puis relance l'application).
// =========================================================================
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { Cache } from '../etat/cache';
import { Messages } from '../etat/messages';
import { Session } from '../etat/session';
import { PUBLIQUE } from './client-api';
import { ErreurApi } from './erreur-api';
export const intercepteurApi: HttpInterceptorFn = (requete, suivant) => {
// inject() : seulement ici, au début de la fonction (plus tard, dans
// catchError, on n'est plus dans un "contexte d'injection")
const session = inject(Session);
const cache = inject(Cache);
const messages = inject(Messages);
const publique = requete.context.get(PUBLIQUE);
// suivant(requete) : la requête part ; pipe(catchError(...)) : ce qu'on
// fait si elle échoue (la réponse réussie passe sans changement)
return suivant(requete).pipe(
catchError((e: unknown) => {
// une erreur qui ne vient pas du client HTTP : une anomalie, qu'on laisse passer
if (!(e instanceof HttpErrorResponse)) return throwError(() => e);
const erreur = ErreurApi.depuisReponse(e);
if (erreur.statusCode === 401 && !publique) {
session.oublier();
cache.vider();
messages.flash({ succes: false, cle: 'ERRORS.SESSION_EXPIRED' });
// l'écran qui attendait la réponse affichera, lui aussi, CE message
return throwError(() => new ErreurApi(401, 'ERRORS.SESSION_EXPIRED'));
}
return throwError(() => erreur);
}),
);
};
|
L'intercepteur des requêtes : il traite, en UN seul endroit, toutes les erreurs, et en particulier la réponse 401.
- ligne 34 : un intercepteur est une simple fonction ([HttpInterceptorFn]), déclarée dans [main.ts] : elle reçoit la requête et la fonction [suivant] qui la transmet (au serveur, ou à l'intercepteur suivant s'il y en a plusieurs), et renvoie l'[Observable] de la réponse ;
- lignes 37-39 : les services dont l'intercepteur a besoin. [inject()] n'est permis qu'au début de la fonction, dans le « contexte d'injection » où [Angular] l'appelle : plus tard, dans [catchError], ce contexte n'existe plus ;
- ligne 40 : la requête est-elle publique ? (cf. [client-api.ts]) ;
- lignes 44-58 : [suivant(requete)] envoie la requête ; [pipe(catchError(...))] transforme l'[Observable] de la réponse : une réponse réussie passe sans changement, une erreur passe par la fonction donnée, qui renvoie un nouvel [Observable] (ici, toujours une erreur : [throwError]) ;
- ligne 47 : une erreur qui ne vient pas d'une réponse HTTP est une anomalie du client : elle passe telle quelle ;
- ligne 48 : toute réponse d'erreur devient une [ErreurApi] (cf. [erreur-api.ts]) ;
- lignes 49-55 : un 401 sur une requête protégée signifie que la session a expiré : on oublie l'utilisateur, on vide le cache, et l'on prépare le message de l'écran suivant. Le changement d'écran, lui, est l'affaire des gardes du routeur, réévalués quand la session change (cf. [app.ts]). On renvoie quand même une erreur (l'écran qui attendait la réponse doit savoir qu'elle n'est pas venue), avec la clé [ERRORS.SESSION_EXPIRED] : si cet écran affiche l'erreur, c'est le même message.
Et le cookie du jeton ? Le client n'a RIEN à écrire : sur le web, le navigateur le range et le renvoie ; sur [Android], [CapacitorHttp] fait passer les requêtes par le code natif, qui le range et le renvoie lui aussi, et le garde quand on arrête puis relance l'application (cf. exemple 23). C'est la principale simplification par rapport au client [Flutter], qui devait lire l'en-tête [Set-Cookie] et écrire l'en-tête [Cookie] lui-même.
11.7.3. src/app/api/erreur-api.ts
| // =========================================================================
// src/app/api/erreur-api.ts - UNE classe pour toutes les erreurs de l'API
// -------------------------------------------------------------------------
// Toutes les réponses d'erreur du serveur RdvMedecins ont la même forme :
// { "statusCode": 409, "cle": "ERRORS.LOGIN_TAKEN", "params": {...}, "champs": {...} }
// Mais une requête peut aussi échouer AUTREMENT : pas de réponse du tout
// (réseau coupé, serveur arrêté, mauvaise adresse), ou une réponse qui ne
// vient pas de notre serveur (une page d'erreur d'un proxy). On convertit
// tout cela en UNE classe, ErreurApi, qui porte la clé de traduction
// envoyée par le serveur (et, s'il y en a, ses paramètres et l'erreur de
// chaque champ).
// =========================================================================
import { HttpErrorResponse } from '@angular/common/http';
// un objet { nom: texte }
export type Textes = Record<string, string>;
// "extends Error" : une erreur JavaScript à part entière (throw, catch, et
// la propriété error() d'une resource d'Angular la garde telle quelle)
export class ErreurApi extends Error {
constructor(
readonly statusCode: number, // 0 : le serveur n'a pas répondu
readonly cle: string, // ex : 'ERRORS.LOGIN_TAKEN'
readonly params: Textes = {},
readonly champs: Textes = {},
) {
super(`${statusCode} ${cle}`);
this.name = 'ErreurApi';
}
// une réponse en erreur du client HTTP d'Angular -> une ErreurApi
static depuisReponse(reponse: HttpErrorResponse): ErreurApi {
// status 0 : aucune réponse (réseau coupé, serveur arrêté, mauvaise adresse)
if (reponse.status === 0) {
return new ErreurApi(0, 'ERRORS.SERVER_UNREACHABLE');
}
// error : le corps de la réponse, déjà décodé s'il s'agit de JSON
const corps: unknown = reponse.error;
if (estUnObjet(corps) && typeof corps['cle'] === 'string') {
return new ErreurApi(reponse.status, corps['cle'], textes(corps['params']), textes(corps['champs']));
}
// pas de clé : le proxy ou le réseau ont répondu à sa place
return new ErreurApi(
reponse.status,
reponse.status >= 500 ? 'ERRORS.SERVER_UNREACHABLE' : 'ERRORS.SERVER_ERROR',
);
}
}
function estUnObjet(valeur: unknown): valeur is Record<string, unknown> {
return typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
}
// un objet JSON { "nom": "ERRORS..." } -> Textes (vide sinon)
function textes(json: unknown): Textes {
if (!estUnObjet(json)) return {};
return Object.fromEntries(Object.entries(json).map(([cle, valeur]) => [cle, String(valeur)]));
}
|
Celle de l'exemple 24 : toute requête qui échoue se termine par une [ErreurApi], qui porte la clé de traduction du message, ses paramètres, et l'erreur de chaque champ.
- ligne 21 : [extends Error] : une erreur [JavaScript] à part entière. C'est important pour les ressources d'[Angular] ([resource()], plus loin) : leur signal [error()] garde une [Error] telle quelle, mais « enveloppe » toute autre valeur rejetée ;
- lignes 22-27 : les paramètres du constructeur précédés de [readonly] deviennent des propriétés de l'objet : c'est une écriture abrégée de [TypeScript] ;
- lignes 35-37 : [status] vaut 0 quand aucune réponse n'est arrivée (réseau coupé, serveur arrêté, mauvaise adresse) ;
- lignes 39-42 : [reponse.error] : le corps de la réponse d'erreur, déjà décodé par le client HTTP s'il s'agit de JSON. Son type est [unknown] : on vérifie sa forme ([estUnObjet], une clé [cle] de type texte) avant de s'en servir ;
- lignes 44-47 : pas de clé : le proxy ou le réseau ont répondu à la place du serveur ;
- ligne 51 : [valeur is Record<string, unknown>] : une fonction de « garde de type » : quand elle renvoie [true], [TypeScript] sait, dans la suite du code, que la valeur est un objet ;
- lignes 56-59 : un objet JSON ([params], [champs]) en objet de textes.
11.7.4. src/app/api/types.ts
| // =========================================================================
// src/app/api/types.ts - les objets JSON échangés avec le serveur
// -------------------------------------------------------------------------
// La traduction, en types TypeScript, de l'API décrite au chapitre « Le
// serveur, une boîte noire ». Ce ne sont que des DESCRIPTIONS : elles
// disparaissent à la compilation, et ne vérifient rien pendant
// l'exécution. Elles permettent au compilateur de vérifier que le code
// n'utilise que des champs qui existent, avec le bon type.
// =========================================================================
export type Role = 'ADMIN' | 'DOCTOR' | 'USER';
// POST /api/auth/login, GET /api/auth/moi, PUT /api/mon-compte
export interface Utilisateur {
login: string;
nom: string; // nom affiché : "Marie PELISSIER"
role: Role;
}
// GET /api/captcha
export interface Captcha {
id: string;
svg: string; // l'image, au format SVG (du texte)
}
// un titre, un prénom, un nom
export interface Identite {
titre: string;
prenom: string;
nom: string;
}
// "Mme Alice DURAND"
export function libelle(i: Identite): string {
return `${i.titre} ${i.prenom} ${i.nom}`;
}
// un médecin ou un client : GET /api/medecins, GET /api/clients/:id...
export interface Personne extends Identite {
id: number;
version: number; // verrou optimiste : à renvoyer avec une modification
login?: string; // seulement pour l'ADMIN
}
// un créneau horaire
export interface Creneau {
id: number;
hdebut: number;
mdebut: number;
hfin: number;
mfin: number;
}
// une ligne de l'agenda, avec les DROITS de l'utilisateur connecté
export interface CreneauAgenda extends Creneau {
libre: boolean;
patient: Identite | null; // null : libre, ou occupé par un autre patient
moi: boolean; // le rendez-vous du patient connecté
reservable: boolean;
idRv: number | null; // renseigné si l'utilisateur peut annuler
}
// GET /api/agenda?idMedecin=...&jour=...
export interface Agenda {
medecin: Personne;
jour: string; // "yyyy-MM-dd"
creneaux: CreneauAgenda[];
info: string | null; // clé de traduction (patient qui a déjà un rendez-vous)
}
// un rendez-vous de GET /api/mon-espace
export interface RendezVous {
id: number;
jour: string;
creneau: Creneau;
medecin: Personne;
}
// GET /api/mon-espace
export interface MonEspace {
patient: Identite;
rendezVous: RendezVous[];
}
// GET /api/mon-compte
export interface MonCompte extends Identite {
login: string;
version: number;
}
|
Les objets JSON de l'API, en types [TypeScript] (cf. exemple 24). Ce ne sont que des DESCRIPTIONS : elles disparaissent à la compilation, et ne vérifient rien pendant l'exécution (le client [Flutter], lui, vérifiait la forme de chaque réponse par le filtrage de [Dart] 3). Elles permettent au compilateur, et à l'éditeur, de vérifier que le code n'utilise que des champs qui existent, avec le bon type.
- ligne 11 : un type « union de textes » : un rôle ne peut valoir QUE l'une de ces trois chaînes, et le compilateur le vérifie ([u.role === 'ADMNI'] serait une erreur de compilation) ;
- lignes 34-36 : une fonction, puisqu'une interface ne peut pas porter de code : « Mme Alice DURAND » ;
- lignes 39-43 : un médecin ou un client : une [Identite], plus un identifiant, une version (le verrou optimiste : elle repartira avec une modification) et un login, que seul l'ADMIN reçoit (d'où le point d'interrogation : un champ facultatif) ;
- lignes 55-61 : une ligne de l'agenda, avec les DROITS de l'utilisateur connecté, calculés par le serveur : [patient] n'est donné qu'à qui a le droit de le voir, [reservable] dit s'il peut réserver, [idRv] n'est donné qu'à qui peut annuler ;
- lignes 86-89 : le compte du patient connecté : son identité, son login et sa version.
11.7.5. src/app/api/rdvmedecins-api.ts
| // =========================================================================
// src/app/api/rdvmedecins-api.ts - une méthode par route de l'API
// -------------------------------------------------------------------------
// La "couche d'accès aux données" du client : les écrans appellent ces
// méthodes, typées, sans rien savoir des URL, du client HTTP ni du JSON.
// Si l'API change, seul ce fichier change.
// Chaque méthode renvoie une Promise ; en cas d'échec, elle est rejetée
// avec une ErreurApi (cf. client-http.ts).
// Les données envoyées (formulaires) sont de simples objets Donnees :
// leurs clés sont les noms des champs attendus par le serveur - et ceux
// des erreurs qu'il renvoie (ErreurApi.champs).
// =========================================================================
import { inject, Injectable } from '@angular/core';
import { ClientApi } from './client-api';
import { Agenda, Captcha, MonCompte, MonEspace, Personne, Utilisateur } from './types';
// /api/medecins ou /api/clients : les deux se gèrent de la même façon
export type TypePersonnes = 'medecins' | 'clients';
export type Donnees = Record<string, unknown>;
@Injectable({ providedIn: 'root' })
export class RdvMedecinsApi {
private readonly client = inject(ClientApi);
private get<T>(url: string, publique = false): Promise<T> {
return this.client.requete<T>('GET', url, undefined, publique);
}
private post<T>(url: string, corps: unknown, publique = false): Promise<T> {
return this.client.requete<T>('POST', url, corps, publique);
}
// ------------------------------------------------ connexion (routes publiques)
captcha(): Promise<Captcha> {
return this.get('/captcha', true);
}
moi(): Promise<Utilisateur> {
return this.get('/auth/moi', true);
}
connecter(login: string, password: string, captchaId: string, captcha: string): Promise<Utilisateur> {
return this.post('/auth/login', { login, password, captchaId, captcha }, true);
}
async deconnecter(): Promise<void> {
await this.post('/auth/logout', {}, true);
}
// ------------------------------------------------ création de compte (publique)
async accesInscription(captchaId: string, captcha: string): Promise<void> {
await this.post('/inscription/acces', { captchaId, captcha }, true);
}
async etatAccesInscription(): Promise<boolean> {
const reponse = await this.get<{ valide?: boolean }>('/inscription/acces', true);
return reponse?.valide === true;
}
async inscrire(donnees: Donnees): Promise<string> {
const reponse = await this.post<{ login: string }>('/inscription', donnees, true);
return reponse.login;
}
// ------------------------------------------------ agenda et rendez-vous
// idMedecin undefined : pour un médecin connecté, SON agenda
agenda(idMedecin: number | undefined, jour: string): Promise<Agenda> {
// URLSearchParams : "jour=2026-10-05&idMedecin=1", caractères spéciaux encodés
const parametres = new URLSearchParams({ jour });
if (idMedecin !== undefined) parametres.set('idMedecin', String(idMedecin));
return this.get(`/agenda?${parametres}`);
}
async reserver(jour: string, idCreneau: number, idClient: number | undefined): Promise<void> {
// idClient: undefined disparaît du JSON envoyé
await this.post('/rendez-vous', { jour, idCreneau, idClient });
}
async annulerRv(idRv: number): Promise<void> {
await this.client.requete('DELETE', `/rendez-vous/${idRv}`);
}
// ------------------------------------------------ médecins et clients
lister(type: TypePersonnes): Promise<Personne[]> {
return this.get(`/${type}`);
}
lire(type: TypePersonnes, id: number): Promise<Personne> {
return this.get(`/${type}/${id}`);
}
ajouter(type: TypePersonnes, donnees: Donnees): Promise<Personne> {
return this.post(`/${type}`, donnees);
}
modifier(type: TypePersonnes, id: number, donnees: Donnees): Promise<Personne> {
return this.client.requete('PUT', `/${type}/${id}`, donnees);
}
async supprimer(type: TypePersonnes, id: number): Promise<void> {
await this.client.requete('DELETE', `/${type}/${id}`);
}
// ------------------------------------------------ l'espace du patient
monEspace(): Promise<MonEspace> {
return this.get('/mon-espace');
}
monCompte(): Promise<MonCompte> {
return this.get('/mon-compte');
}
modifierMonCompte(donnees: Donnees): Promise<Utilisateur> {
return this.client.requete('PUT', '/mon-compte', donnees);
}
async supprimerMonCompte(motDePasseActuel: string): Promise<void> {
await this.client.requete('DELETE', '/mon-compte', { motDePasseActuel });
}
}
|
Une méthode par route de l'API : c'est la classe de l'exemple 24, complétée.
- ligne 19 : [/api/medecins] et [/api/clients] se gèrent de la même façon : le type, une union de deux textes, est aussi le nom de la route ;
- ligne 21 : les données d'un formulaire : un objet dont les clés sont des textes, et les valeurs n'importe quoi ;
- lignes 23-25 : un service fourni à toute l'application, qui reçoit le [ClientApi] par injection ;
- lignes 35-50 : les routes de la connexion, publiques : un 401 y a son sens propre (« personne n'est connecté », « mauvais mot de passe ») et ne signifie pas que la session a expiré ;
- lignes 48-50 : une méthode [async] qui ne renvoie rien ([Promise<void>]) : la réponse du serveur ne nous intéresse pas, seul compte le succès ;
- lignes 57-60 : le laissez-passer de la création de compte est-il valide ? [reponse?.valide] : l'opérateur [?.] donne [undefined] si la réponse est vide, au lieu de provoquer une erreur ;
- lignes 69-74 : les paramètres de l'adresse, construits par [URLSearchParams] (qui les encode). Un médecin connecté demande l'agenda sans [idMedecin] : le serveur lui renvoie le sien ;
- ligne 78 : de même, [idClient] n'est envoyé que s'il est connu (un patient réserve pour lui-même) : [JSON.stringify] omet les propriétés qui valent [undefined] ;
- lignes 86-104 : les cinq opérations sur les médecins ou les clients : [/api/${type}] ;
- lignes 107-121 : l'espace du patient. La suppression du compte envoie un corps avec une requête [DELETE] (le mot de passe actuel) : c'est permis par HTTP, et par [http.request].
11.8. Les langues
11.8.1. src/app/i18n/traductions.ts
| // =========================================================================
// src/app/i18n/traductions.ts - les dictionnaires, la langue courante
// -------------------------------------------------------------------------
// Repris de l'exemple 21. Les dictionnaires (public/i18n/fr.json, en.json)
// sont ceux des clients Vue.js, Angular, React et Flutter : mêmes clés,
// mêmes conventions ({{param}}, pluriels _one / _other). Le serveur ne
// renvoie que des CLÉS ("ERRORS.LOGIN_TAKEN") : c'est ici qu'elles
// deviennent du texte, dans la langue choisie par l'utilisateur.
// - t('CLE', { param: valeur }) : le texte de la langue courante ;
// - changerLangue('en') : charge l'autre dictionnaire, et
// mémorise le choix sur l'appareil.
// La langue et le dictionnaire sont des SIGNAUX : t() les lit, donc tout
// gabarit qui affiche t('...') se met à jour quand la langue change.
// =========================================================================
import { Injectable, signal } from '@angular/core';
import { Preferences } from '@capacitor/preferences';
export const langues = ['fr', 'en'] as const;
export type Langue = (typeof langues)[number]; // 'fr' | 'en'
// un dictionnaire : des textes, rangés dans des sections imbriquées
interface Dictionnaire {
[cle: string]: string | Dictionnaire;
}
const CLE_LANGUE = 'rdvmedecins.langue';
@Injectable({ providedIn: 'root' })
export class Traductions {
private readonly _langue = signal<Langue>('fr');
private readonly dictionnaire = signal<Dictionnaire>({});
readonly langue = this._langue.asReadonly();
// la langue de départ : celle mémorisée sur l'appareil, sinon celle du
// téléphone (si elle est connue), sinon le français. Appelée une fois,
// avant l'affichage du premier écran (provideAppInitializer, main.ts)
async demarrer(): Promise<void> {
// Preferences : un stockage clé / valeur sur l'appareil (le
// localStorage du navigateur sur le web)
const { value: memorisee } = await Preferences.get({ key: CLE_LANGUE });
const appareil = navigator.language.slice(0, 2); // "fr-FR" -> "fr"
await this.changerLangue(memorisee ?? appareil);
}
async changerLangue(demandee: string): Promise<void> {
const langue: Langue = langues.find((l) => l === demandee) ?? 'fr';
// fetch : les fichiers du dossier public/ sont livrés avec l'application
// (sur Android aussi : une adresse RELATIVE ne passe pas par CapacitorHttp)
const reponse = await fetch(`i18n/${langue}.json`);
this.dictionnaire.set((await reponse.json()) as Dictionnaire);
this._langue.set(langue);
await Preferences.set({ key: CLE_LANGUE, value: langue });
}
// "ESPACE.HELLO" -> dictionnaire['ESPACE']['HELLO'] ; undefined si absente
private chercher(cle: string): string | undefined {
let noeud: string | Dictionnaire | undefined = this.dictionnaire();
for (const partie of cle.split('.')) {
noeud = typeof noeud === 'object' ? noeud[partie] : undefined;
}
return typeof noeud === 'string' ? noeud : undefined;
}
// une fonction fléchée : on peut la passer telle quelle aux composants
// (readonly t = inject(Traductions).t), elle garde son "this"
readonly t = (cle: string, params: Record<string, unknown> = {}): string => {
let texte: string | undefined;
const count = params['count'];
if (typeof count === 'number') {
// Intl.PluralRules : la forme du pluriel, selon les règles de la
// langue ('zero', 'one', 'other'...) ; 0 est 'one' en français
const forme = new Intl.PluralRules(this._langue()).select(count);
texte = this.chercher(`${cle}_${count === 0 ? 'zero' : forme}`) ?? this.chercher(`${cle}_${forme}`);
}
texte ??= this.chercher(cle);
// une clé inconnue s'affiche telle quelle : on voit tout de suite ce qui manque
if (texte === undefined) return cle;
// {{nom}} -> la valeur du paramètre nom
return texte.replace(/\{\{(\w+)\}\}/g, (tout, nom: string) => (nom in params ? String(params[nom]) : tout));
};
}
|
La classe [Traductions] de l'exemple 21, devenue un service fourni à toute l'application, et simplifiée : les dictionnaires ont toujours le même nom ([public/i18n/fr.json]).
- ligne 19 : [as const] : le tableau est figé, et son type est exactement [readonly ['fr', 'en']] ; la ligne 20 en déduit le type [Langue], ['fr' | 'en'] : ajouter une langue au tableau l'ajoute au type ;
- lignes 23-25 : un dictionnaire : des textes, rangés dans des sections imbriquées (un type récursif) ;
- lignes 31-33 : la langue et le dictionnaire sont des SIGNAUX. Tout ce qui lit [t(...)] (un gabarit, un signal calculé, un effet) lit aussi ces signaux, et se met donc à jour quand la langue change : c'est le [notifyListeners()] du [ChangeNotifier] de [Flutter], sans rien écrire. [asReadonly()] : le reste de l'application peut lire la langue, mais seul ce service peut la changer ;
- lignes 38-44 : la langue de départ : celle choisie lors d'une utilisation précédente, sinon celle du téléphone si l'application la connaît, sinon le français. [Preferences] (le greffon de [Capacitor], cf. exemple 20) range des paires clé / valeur sur l'appareil ; dans un navigateur, il utilise le [localStorage]. [navigator.language] : la langue du système (« fr-FR ») ;
- lignes 46-54 : changer de langue : le dictionnaire est lu par [fetch] dans les fichiers livrés avec l'application (le dossier [public/], copié tel quel par [ng build]). Sur [Android], une adresse RELATIVE ([i18n/fr.json]) n'est pas détournée par [CapacitorHttp] : elle est servie par l'application elle-même. La langue inconnue devient le français ;
- lignes 57-63 : « ESPACE.HELLO » -> [dictionnaire['ESPACE']['HELLO']], en descendant section par section ;
- lignes 67-81 : la traduction d'une clé, avec les pluriels ([_zero], [_one], [_other]) et les paramètres ([{{nom}}]), selon les conventions d'[i18next], celles des dictionnaires des autres clients. [Intl.PluralRules] connaît les règles du pluriel de chaque langue (en français, 0 et 1 sont au singulier). [t] est une fonction fléchée, et non une méthode : elle garde son [this] quand on la recopie dans un composant ([readonly t = inject(Traductions).t]), ce que font tous les écrans.
11.8.2. Les dictionnaires
Les dictionnaires sont ceux du client [Flutter], sans changement (eux-mêmes repris du client [React], complétés de quelques clés propres à l'application mobile : le menu « Mon compte », « Choisir le jour »...). Le début du dictionnaire français, [public/i18n/fr.json] :
| {
"APP": {
"TITLE": "RdvMedecins",
"LOGOUT": "Se déconnecter",
"MENU_AGENDA": "Agenda",
"MENU_DOCTORS": "Médecins",
"MENU_PATIENTS": "Clients",
"MENU_MY_SPACE": "Mon espace",
"LOADING": "Chargement...",
"LANGUAGE": "Langue",
"MENU_ACCOUNT": "Mon compte",
"CONNECTED_AS": "Connecté : {{nom}}",
"RETRY": "Réessayer"
},
"LOGIN": {
"TITLE": "Connexion",
"LOGIN_LABEL": "Login",
"PASSWORD_LABEL": "Mot de passe",
"SUBMIT": "Se connecter",
"ERROR": "Login ou mot de passe incorrect.",
"DEMO_ACCOUNTS": "Comptes de démonstration (mot de passe = login) :",
"DEMO_ADMIN": "rôle ADMIN : rendez-vous + gestion des médecins et des clients",
"DEMO_DOCTOR": "rôle DOCTOR : prise et annulation de rendez-vous",
"DEMO_USER": "rôle USER (patients) : réservation pour eux-mêmes, gestion de leur compte",
"CREATE_ACCOUNT": "Créer un compte client",
"CREATE_ACCOUNT_HELP": "Pour créer un compte, recopiez d'abord les caractères de l'image."
},
"DOCTOR_DAY_PICKER": {
"DOCTOR_LABEL": "Médecin",
"CHOOSE_DOCTOR": "-- choisir un médecin --",
"DAY_LABEL": "Jour",
"VIEW_AGENDA": "Voir l'agenda",
|
Un fichier par langue, les mêmes clés dans chacun ; un groupe de clés par écran ; les clés [ERRORS.*] sont celles que renvoie le serveur.
11.9. L'état partagé
Trois services à signaux, fournis à toute l'application ([providedIn: 'root']), comme celui de l'exemple 20.
11.9.1. src/app/etat/session.ts
| // =========================================================================
// src/app/etat/session.ts - QUI est connecté ?
// -------------------------------------------------------------------------
// L'utilisateur connecté (ou null), dans un SERVICE à signaux fourni à
// toute l'application (providedIn: 'root', cf. exemple 20) : lisible par
// les écrans ET par les gardes du routeur, réévalués quand il change
// (cf. app.ts).
//
// Le jeton JWT lui-même n'est PAS ici : il est dans un cookie (géré par le
// navigateur sur le web, par le code natif d'Android sur le téléphone). La
// session ne garde que la DESCRIPTION de l'utilisateur ({ login, nom, role }).
//
// Au démarrage de l'application, la session est vide : verifier() demande
// au serveur, une seule fois, qui est connecté (GET /api/auth/moi).
// =========================================================================
import { inject, Injectable, signal } from '@angular/core';
import { RdvMedecinsApi } from '../api/rdvmedecins-api';
import { Utilisateur } from '../api/types';
@Injectable({ providedIn: 'root' })
export class Session {
private readonly api = inject(RdvMedecinsApi);
private readonly _utilisateur = signal<Utilisateur | null>(null);
// la version "lecture seule" du signal, pour le reste de l'application
readonly utilisateur = this._utilisateur.asReadonly();
// vrai après une déconnexion VOULUE (bouton, suppression du compte) ; faux
// après une session expirée. Les gardes en tiennent compte (cf. app.routes.ts)
private _departVolontaire = false;
// la question "qui est connecté ?" a-t-elle déjà été posée au serveur ?
private verification?: Promise<void>;
get departVolontaire(): boolean {
return this._departVolontaire;
}
// ne questionne le serveur que la première fois (la Promise est gardée)
verifier(): Promise<void> {
this.verification ??= this.api.moi().then(
(u) => this.changer(u),
() => this.changer(null), // 401 : personne ; ou serveur injoignable
);
return this.verification;
}
async connecter(login: string, password: string, captchaId: string, captcha: string): Promise<void> {
this.changer(await this.api.connecter(login, password, captchaId, captcha));
}
// l'utilisateur a changé de nom ou de login (PUT /api/mon-compte)
mettreAJour(u: Utilisateur): void {
this.changer(u);
}
async deconnecter(): Promise<void> {
try {
await this.api.deconnecter();
} finally {
this.oublier(true);
}
}
// sans rien demander au serveur : session expirée, compte supprimé
oublier(volontaire = false): void {
this.changer(null, volontaire);
}
// le signal change : tout ce qui le lit (gabarit, gardes...) est prévenu
private changer(u: Utilisateur | null, volontaire = false): void {
this._departVolontaire = volontaire;
this._utilisateur.set(u);
}
}
// ce qu'on déduit de l'utilisateur (null : personne n'est connecté)
export const estAdmin = (u: Utilisateur | null): boolean => u?.role === 'ADMIN';
export const estPatient = (u: Utilisateur | null): boolean => u?.role === 'USER';
// ADMIN et DOCTOR : prennent et annulent les rendez-vous de n'importe quel patient
export const estGestionnaire = (u: Utilisateur | null): boolean => u?.role === 'ADMIN' || u?.role === 'DOCTOR';
// la page d'accueil de chaque rôle, juste après la connexion
export const pageAccueil = (u: Utilisateur | null): string => (estPatient(u) ? '/mon-espace' : '/agenda');
|
Qui est connecté ? La session garde la DESCRIPTION de l'utilisateur (login, nom, rôle), et non le jeton, qui reste dans son cookie.
- lignes 24-26 : l'utilisateur, dans un signal privé et modifiable ([_utilisateur]), et sa version en lecture seule, pour le reste de l'application. Le gabarit (qui affiche son nom), [app.ts] (qui vide le cache et réévalue les gardes) et les écrans le lisent ;
- lignes 38-44 : la question « qui est connecté ? », posée au serveur ([GET /api/auth/moi]) UNE seule fois, au démarrage : [??=] n'affecte la [Promise] que si [verification] est [undefined]. Les appels suivants renvoient la même [Promise], déjà terminée : le garde du routeur peut appeler [verifier()] avant chaque navigation sans rien coûter. Au démarrage, si le cookie contient un jeton encore valide, l'utilisateur est toujours connecté. [then(succes, echec)] : un 401 (personne n'est connecté) ou un serveur injoignable donnent tous deux une session vide ;
- lignes 46-48 : la connexion : si le serveur refuse, [api.connecter] rejette sa [Promise] avec une [ErreurApi], l'[await] la relance, et [changer] n'est pas appelée ;
- lignes 55-61 : la déconnexion : on oublie l'utilisateur même si le serveur ne répond pas ([finally]) ;
- lignes 69-72 : tout changement passe par ici. [_departVolontaire] distingue une déconnexion voulue d'une session expirée (cf. [app.routes.ts]) ; on le range AVANT de changer le signal, que lisent les gardes ;
- lignes 76-81 : ce qu'on déduit de l'utilisateur, en fonctions (le client [Flutter] utilisait une extension de [Dart]). [u?.role] : [undefined] si personne n'est connecté ([null]), sans test préalable.
11.9.2. src/app/etat/messages.ts
| // =========================================================================
// src/app/etat/messages.ts - les messages affichés à l'utilisateur
// -------------------------------------------------------------------------
// Même présentation que les autres clients de RdvMedecins :
// - un SUCCÈS s'affiche en HAUT de l'écran (bandeau vert) ;
// - une ERREUR en BAS (bandeau rose), toujours visible.
// Un message est une CLÉ de traduction (+ ses paramètres) : il se retraduit
// donc tout seul si l'utilisateur change de langue.
//
// Les messages "flash" : une action qui se termine par un changement
// d'écran (réservation, ajout d'un médecin...) dépose son message avec
// flash() ; il s'affichera sur l'écran SUIVANT, puis disparaîtra à la
// navigation d'après. C'est app.ts qui appelle nouvellePage() à la fin de
// chaque navigation.
// =========================================================================
import { effect, inject, Injectable, Resource, signal, untracked } from '@angular/core';
import { ErreurApi, Textes } from '../api/erreur-api';
export interface Message {
cle: string;
params: Textes;
}
@Injectable({ providedIn: 'root' })
export class Messages {
readonly succes = signal<Message | null>(null);
readonly erreur = signal<Message | null>(null);
// les messages à afficher sur l'écran suivant
private succesEnAttente: Message | null = null;
private erreurEnAttente: Message | null = null;
afficherSucces(cle: string, params: Textes = {}): void {
this.succes.set({ cle, params });
this.erreur.set(null);
}
afficherErreur(cle: string, params: Textes = {}): void {
this.erreur.set({ cle, params });
this.succes.set(null);
}
// une ErreurApi -> le message de l'écran ; toute autre erreur est une
// anomalie du client, qu'on laisse remonter
afficherErreurApi(e: unknown): void {
if (!(e instanceof ErreurApi)) throw e;
this.afficherErreur(e.cle, e.params);
}
effacer(): void {
this.succes.set(null);
this.erreur.set(null);
}
// pour l'écran SUIVANT
flash({ succes = true, cle, params = {} }: { succes?: boolean; cle: string; params?: Textes }): void {
if (succes) {
this.succesEnAttente = { cle, params };
} else {
this.erreurEnAttente = { cle, params };
}
}
// un nouvel écran : les messages du précédent disparaissent, les messages
// flash apparaissent
nouvellePage(): void {
this.succes.set(this.succesEnAttente);
this.erreur.set(this.erreurEnAttente);
this.succesEnAttente = null;
this.erreurEnAttente = null;
}
}
// à appeler dans le constructeur d'un écran : l'échec de la lecture de ces
// ressources (resource d'Angular) affiche son message en bas de l'écran
export function signalerErreurs(...ressources: Resource<unknown>[]): void {
const messages = inject(Messages);
for (const ressource of ressources) {
// un effet : une fonction réexécutée chaque fois que change un signal
// qu'elle a lu (ici, l'erreur de la ressource)
effect(() => {
const erreur = ressource.error();
// untracked : ce que fait la suite ne doit pas devenir une dépendance de l'effet
if (erreur) untracked(() => messages.afficherErreurApi(erreur));
});
}
}
|
Les messages de l'application : un succès en haut de l'écran, une erreur en bas (cf. [gabarit.html]).
- lignes 20-23 : un message est une CLÉ de traduction et ses paramètres. Il n'est traduit qu'à l'affichage : changer de langue retraduit les messages affichés ;
- lignes 27-28 : le succès et l'erreur affichés : deux signaux, que lit le gabarit ;
- lignes 45-48 : une [ErreurApi] devient le message d'erreur de l'écran. Toute autre exception est une anomalie du client : on la relance, pour qu'elle apparaisse dans la console pendant le développement, plutôt que de la cacher ;
- lignes 56-62 : un message pour l'écran SUIVANT : une action qui se termine par un changement d'écran (une réservation, un ajout...) dépose son message ici. Le paramètre est un objet « déstructuré » : [flash({ succes: false, cle: '...' })], avec des valeurs par défaut ; ce sont les paramètres nommés de [Dart] ;
- lignes 66-71 : appelée à chaque changement d'adresse (cf. [app.ts]) : les messages de l'écran précédent disparaissent, les messages flash apparaissent ;
- lignes 76-87 : une fonction à appeler dans le constructeur d'un écran : l'échec de la lecture de ses ressources ([resource()], cf. [agenda.ts]) affiche son message en bas de l'écran. Pour chaque ressource, un effet lit son signal [error()] : il est réexécuté à chaque nouvelle erreur. Un effet ne peut être créé que dans un contexte d'injection, comme [inject()] : d'où l'obligation d'appeler la fonction dans un constructeur (ou à l'initialisation d'un champ).
11.9.3. src/app/etat/cache.ts
| // =========================================================================
// src/app/etat/cache.ts - les données déjà lues sur le serveur
// -------------------------------------------------------------------------
// La liste des médecins ne change presque jamais : inutile de la relire à
// chaque visite de l'agenda. Le cache garde la Promise de chaque lecture,
// sous une CLÉ ('medecins', 'agenda/1/2026-10-05'...) :
//
// cache.lire('medecins', () => api.lister('medecins'))
//
// renvoie la Promise déjà gardée, ou lance la lecture et la garde.
// Après une modification, on INVALIDE les clés concernées (elles seront
// relues à la prochaine demande) : cache.invalider('agenda').
// Une lecture en échec n'est pas gardée : la suivante réessaiera.
// Chaque invalidation incrémente le signal version : un écran encore
// vivant (même caché sous un autre dans la pile d'Ionic) peut ainsi
// relire ses données (cf. relireApresInvalidation, plus bas).
// C'est, en quelques lignes, ce que fait la bibliothèque TanStack Query du
// client React.
// =========================================================================
import { effect, inject, Injectable, signal, untracked, WritableResource } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Cache {
private readonly promesses = new Map<string, Promise<unknown>>();
private readonly _version = signal(0);
readonly version = this._version.asReadonly();
lire<T>(cle: string, charger: () => Promise<T>): Promise<T> {
let promesse = this.promesses.get(cle) as Promise<T> | undefined;
if (promesse === undefined) {
promesse = charger();
this.promesses.set(cle, promesse);
// en cas d'échec, on oublie cette Promise (la prochaine demande réessaiera)
promesse.catch(() => this.promesses.delete(cle));
}
return promesse;
}
// oublie toutes les clés qui commencent par prefixe, et prévient les écrans
invalider(prefixe: string): void {
for (const cle of [...this.promesses.keys()]) {
if (cle.startsWith(prefixe)) this.promesses.delete(cle);
}
this._version.update((v) => v + 1);
}
// changement d'utilisateur : on oublie tout, SANS prévenir (les écrans de
// l'utilisateur précédent vont disparaître, ils ne doivent rien relire)
vider(): void {
this.promesses.clear();
}
}
// à appeler dans le constructeur d'un écran : ses ressources sont relues
// (reload) après chaque invalidation du cache. reload() garde la valeur
// actuelle pendant la nouvelle lecture : l'écran ne "clignote" pas
export function relireApresInvalidation(...ressources: WritableResource<unknown>[]): void {
const cache = inject(Cache);
const depart = cache.version();
effect(() => {
// l'effet s'exécute une première fois à sa création : on attend une
// VRAIE invalidation
if (cache.version() === depart) return;
untracked(() => ressources.forEach((r) => r.reload()));
});
}
|
Les données déjà lues sur le serveur : l'équivalent, en quelques lignes, de [TanStack Query] dans le client [React], et du [Cache] du client [Flutter].
- ligne 25 : le cache garde des [Promise], et non des valeurs : deux écrans qui demandent la même donnée pendant qu'elle est en route reçoivent la MÊME [Promise], et une seule requête part ;
- lignes 29-38 : une méthode générique : [lire<Personne[]>(...)] renvoie une [Promise<Personne[]>] (le type [T] est déduit de la fonction [charger]). Une lecture en échec est oubliée ([catch]) : la prochaine demande réessaiera ;
- lignes 41-46 : après une modification, on oublie les clés concernées, et l'on incrémente le signal [version] : les écrans qui le lisent sont prévenus ;
- lignes 50-52 : au changement d'utilisateur, on oublie tout, sans prévenir personne : les écrans de l'utilisateur précédent vont disparaître, ils n'ont rien à relire ;
- lignes 58-67 : une fonction à appeler dans le constructeur d'un écran : ses ressources sont relues après chaque invalidation du cache. L'effet lit [cache.version()] : il est réexécuté à chaque invalidation, et relit les ressources ([reload()]). Les écrans d'[Ionic] restent vivants, cachés, sous l'écran affiché (la pile du bouton Retour) : après une modification, ils sont déjà à jour quand on y revient. [reload()] garde la valeur actuelle pendant la nouvelle lecture : l'écran ne « clignote » pas.
| // =========================================================================
// src/app/formatage.ts - l'affichage des heures et des jours
// -------------------------------------------------------------------------
// Le serveur envoie des données BRUTES (hdebut: 8, mdebut: 0 ; jour:
// "2026-10-05") ; la mise en forme, qui dépend de la langue, est l'affaire
// du client (cf. exemple 09). On utilise ici l'API Intl de JavaScript,
// qui connaît les règles de toutes les langues.
// =========================================================================
import { Creneau } from './api/types';
const deux = (n: number): string => n.toString().padStart(2, '0');
// "8:05" plutôt que "8:5"
export const heure = (h: number, m: number): string => `${h}:${deux(m)}`;
// "8:00 - 8:20"
export const horaire = (c: Creneau): string => `${heure(c.hdebut, c.mdebut)} - ${heure(c.hfin, c.mfin)}`;
// une date -> "yyyy-MM-dd" (le format de l'API), dans le fuseau de l'appareil
export const versJour = (d: Date): string => `${d.getFullYear()}-${deux(d.getMonth() + 1)}-${deux(d.getDate())}`;
export const aujourdhui = (): string => versJour(new Date());
// "2026-10-05" -> le 5 octobre 2026 à 0 h, heure locale (new Date("2026-10-05")
// donnerait 0 h en temps universel : la veille au soir, sur un autre continent)
export function versDate(jour: string): Date {
const [a, m, j] = jour.split('-').map(Number);
return new Date(a, m - 1, j);
}
// vrai si jour est une date qui existe, au format yyyy-MM-dd
export function estUneDateValide(jour: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(jour)) return false;
return versJour(versDate(jour)) === jour; // 2026-02-30 -> 2026-03-02 : refusé
}
// "lundi 5 octobre 2026" / "Monday, October 5, 2026"
export function jourLong(jour: string, langue: string): string {
return new Intl.DateTimeFormat(langue, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }).format(
versDate(jour),
);
}
|
La mise en forme des données brutes du serveur (cf. exemple 09) :
- ligne 21 : une date au format de l'API (« 2026-10-05 »), dans le fuseau horaire de l'appareil. [getMonth()] compte les mois à partir de 0 ;
- lignes 27-30 : l'inverse : « 2026-10-05 » -> le 5 octobre à 0 h, heure locale. [new Date('2026-10-05')] donnerait minuit en temps universel, c'est-à-dire, à l'ouest de Greenwich, la veille au soir ;
- lignes 33-36 : une date de l'adresse est-elle valide ? [new Date(2026, 1, 30)] donne le 2 mars : on vérifie qu'on retrouve la même chaîne ;
- lignes 39-43 : « lundi 5 octobre 2026 » ou « Monday, October 5, 2026 », selon la langue : [Intl.DateTimeFormat], l'API de mise en forme de [JavaScript], connaît toutes les langues. Le client [Flutter] avait besoin pour cela du paquet [intl].
11.11. Le routeur
11.11.1. src/app/app.routes.ts
| // =========================================================================
// src/app/app.routes.ts - les écrans de l'application et leurs droits d'accès
// -------------------------------------------------------------------------
// Les MÊMES adresses que les autres clients (/login, /agenda, /medecins,
// /mon-espace...). Sur le web, elles s'affichent dans la barre d'adresse ;
// sur le téléphone, elles restent invisibles, mais organisent tout autant
// l'application (et la pile des écrans du bouton Retour).
//
// Les GARDES : la fonction garde (un CanActivateFn) est appelée avant
// CHAQUE navigation vers chaque écran. Elle est asynchrone : la première
// fois (démarrage de l'application, F5 sur le web), elle attend la réponse
// du serveur à la question "qui est connecté ?" (session.verifier()). Puis
// elle vérifie les droits de l'écran demandé (droitsDe), et redirige si
// besoin. runGuardsAndResolvers: 'always' : les gardes sont aussi
// réévalués quand on "renavigue" vers l'adresse courante, ce que fait
// app.ts quand la session change (connexion, déconnexion, expiration).
// RAPPEL : ces contrôles ne sont qu'un CONFORT (ne pas afficher un écran
// inutile). La protection des données est assurée par le serveur, qui
// revérifie le jeton et le rôle à CHAQUE requête.
// =========================================================================
import { inject } from '@angular/core';
import { CanActivateFn, CanMatchFn, Router, Routes } from '@angular/router';
import { RdvMedecinsApi, TypePersonnes } from './api/rdvmedecins-api';
import { Role } from './api/types';
import { Messages } from './etat/messages';
import { pageAccueil, Session } from './etat/session';
import { cles } from './pages/personnes-config';
// les droits d'accès d'un écran : public (sans connexion), ou réservé à
// certains rôles (roles absent : tous les rôles connectés)
interface Droits {
public: boolean;
roles?: Role[];
}
export function droitsDe(chemin: string): Droits {
if (chemin === '/login' || chemin === '/inscription') return { public: true };
if (chemin.startsWith('/medecins') || chemin.startsWith('/clients')) return { public: false, roles: ['ADMIN'] };
if (chemin === '/mon-espace' || chemin === '/mon-compte') return { public: false, roles: ['USER'] };
if (chemin === '/agenda' || chemin === '/interdit' || chemin === '/') return { public: false };
return { public: true }; // adresse inconnue : la page 404, pour tous
}
// seulement un chemin INTERNE ("/medecins") : jamais "//autre-site.com"
export function cheminInterne(r: string | undefined): string | undefined {
return r !== undefined && r.startsWith('/') && !r.startsWith('//') ? r : undefined;
}
// "3" -> 3 ; "", "abc", "-2", absent (undefined) -> undefined
export function entierPositif(valeur: string | undefined): number | undefined {
if (valeur === undefined || !/^\d+$/.test(valeur)) return undefined;
const n = Number(valeur);
return n > 0 ? n : undefined;
}
// le garde de TOUS les écrans
export const garde: CanActivateFn = async (_route, etat) => {
// inject() d'abord : après un await, on n'est plus dans un contexte d'injection
const session = inject(Session);
const router = inject(Router);
await session.verifier();
const utilisateur = session.utilisateur();
// etat.url : l'adresse demandée, paramètres compris ("/agenda?idMedecin=1...")
const adresse = router.parseUrl(etat.url);
const chemin = etat.url.split(/[?#]/)[0];
const droits = droitsDe(chemin);
if (droits.public) {
// connecté : l'écran de connexion n'a plus de sens. C'est ICI qu'on le
// quitte, juste après la connexion : la session a changé, les gardes
// sont réévalués. On va à l'écran demandé avant la connexion
// (?redirect=...), sinon à l'accueil du rôle
if (chemin === '/login' && utilisateur !== null) {
return router.parseUrl(cheminInterne(adresse.queryParams['redirect']) ?? pageAccueil(utilisateur));
}
return true;
}
if (utilisateur === null) {
// déconnexion voulue : l'écran de connexion, tout simplement
if (session.departVolontaire) return router.parseUrl('/login');
// session expirée, adresse ouverte sans être connecté : on retient
// l'écran demandé, pour y revenir après la connexion
return router.createUrlTree(['/login'], { queryParams: { redirect: etat.url } });
}
if (chemin === '/') return router.parseUrl(pageAccueil(utilisateur));
if (droits.roles !== undefined && !droits.roles.includes(utilisateur.role)) return router.parseUrl('/interdit');
// true : la navigation continue ; un UrlTree : elle est redirigée
return true;
};
// un garde PROPRE à /inscription : sans laissez-passer (captcha résolu sur
// l'écran de connexion), retour à l'écran de connexion
const gardeInscription: CanActivateFn = async () => {
const api = inject(RdvMedecinsApi);
const messages = inject(Messages);
const router = inject(Router);
const valide = await api.etatAccesInscription().catch(() => false);
if (valide) return true;
messages.flash({ succes: false, cle: 'ERRORS.CAPTCHA_BEFORE_ACCOUNT' });
return router.parseUrl('/login');
};
// /medecins/abc/modifier : cette route ne "correspond" pas (canMatch) ;
// le routeur essaie les suivantes, jusqu'à la page 404 (**)
const idValide: CanMatchFn = (_route, segments) => entierPositif(segments[1]?.path) !== undefined;
// les trois écrans de gestion des médecins OU des clients (ADMIN) ;
// data : des valeurs fixes, données aux entrées du composant (type)
function routesPersonnes(type: TypePersonnes): Routes {
const c = cles(type);
const liste = () => import('./pages/personnes').then((m) => m.PagePersonnes);
const formulaire = () => import('./pages/personne-formulaire').then((m) => m.PagePersonneFormulaire);
return [
{ path: type, title: c.titre, data: { type }, loadComponent: liste },
{ path: `${type}/ajouter`, title: c.ajouter, data: { type }, loadComponent: formulaire },
{
path: `${type}/:id/modifier`,
title: c.modifier,
data: { type },
canMatch: [idValide],
loadComponent: formulaire,
},
];
}
// les écrans, sans leurs gardes ; title : une CLÉ de traduction (cf.
// composants/titre-page.ts) ; loadComponent : le code de l'écran n'est
// chargé qu'à sa première visite (un fichier JavaScript à part)
const ecrans: Routes = [
// "/" : toujours redirigé par le garde (accueil du rôle, ou connexion)
{ path: '', pathMatch: 'full', children: [] },
{
path: 'login',
title: 'LOGIN.TITLE',
loadComponent: () => import('./pages/connexion').then((m) => m.PageConnexion),
},
{
path: 'agenda',
title: 'AGENDA.PAGE_TITLE',
loadComponent: () => import('./pages/agenda').then((m) => m.PageAgenda),
},
...routesPersonnes('medecins'),
...routesPersonnes('clients'),
{
path: 'inscription',
title: 'INSCRIPTION.TITLE',
canActivate: [gardeInscription],
loadComponent: () => import('./pages/inscription').then((m) => m.PageInscription),
},
{
path: 'mon-espace',
title: 'ESPACE.TITLE',
loadComponent: () => import('./pages/espace-patient').then((m) => m.PageEspacePatient),
},
{
path: 'mon-compte',
title: 'MON_COMPTE.TITLE',
loadComponent: () => import('./pages/mon-compte').then((m) => m.PageMonCompte),
},
{
path: 'interdit',
title: 'ERROR_PAGE.TITLE',
data: { code: 403 },
loadComponent: () => import('./pages/page-erreur').then((m) => m.PageErreur),
},
// une adresse qu'aucune route ne décrit : la page 404
{
path: '**',
title: 'ERROR_PAGE.TITLE',
data: { code: 404 },
loadComponent: () => import('./pages/page-erreur').then((m) => m.PageErreur),
},
];
// chaque écran reçoit le garde commun (avant ses gardes propres)
export const routes: Routes = ecrans.map((r) => ({
...r,
canActivate: [garde, ...(r.canActivate ?? [])],
runGuardsAndResolvers: 'always',
}));
|
Les écrans de l'application, leurs adresses et leurs droits d'accès (cf. exemples 17 et 18).
- lignes 32-35 : les droits d'un écran : public, ou réservé à certains rôles ([roles] absent : tous les rôles) ;
- lignes 37-43 : les droits de chaque adresse, en UN seul endroit ;
- lignes 46-48 : l'adresse [?redirect=] n'est suivie que si c'est un chemin INTERNE : sur le web, une adresse [/login?redirect=//site-pirate.com] envoyée par courriel ne doit pas emmener l'utilisateur ailleurs après sa connexion ;
- lignes 51-55 : les paramètres d'une adresse sont des textes, écrits par n'importe qui (sur le web) : « abc » ou « -2 » deviennent [undefined] ;
- lignes 58-89 : LE garde, exécuté avant chaque navigation vers n'importe quel écran (cf. lignes 176-180) :
- ligne 62 : la première fois, on attend la réponse du serveur à la question « qui est connecté ? » ; ensuite, cet appel ne coûte rien. Un garde peut être asynchrone ([async]) : le routeur attend sa réponse ;
- lignes 65-67 : [etat.url] : l'adresse demandée, paramètres compris ; [router.parseUrl] la découpe (son chemin, ses paramètres [queryParams]) ;
- lignes 68-77 : un écran public est permis à tous ; mais un utilisateur connecté n'a rien à faire sur l'écran de connexion : il va à l'écran demandé avant sa connexion, ou à l'accueil de son rôle. C'est ainsi que l'application quitte l'écran de connexion après une connexion réussie : l'écran de connexion n'a pas à naviguer lui-même. Un garde qui renvoie un [UrlTree] (une adresse analysée) REDIRIGE la navigation ;
- lignes 78-84 : personne n'est connecté : direction l'écran de connexion. Après une session expirée, ou si l'on a ouvert directement une adresse protégée (sur le web), on retient l'écran demandé ([?redirect=]), pour y revenir après la connexion ; après une déconnexion voulue, non. [createUrlTree] construit l'adresse, en encodant le paramètre ;
- lignes 85-88 : [/] mène à l'accueil du rôle ; un rôle insuffisant, à la page 403 ; sinon, [true] : la navigation continue ;
- lignes 93-101 : un garde PROPRE à une route : la création de compte exige un laissez-passer, que le serveur délivre quand on a recopié le captcha ([POST /api/inscription/acces], cf. écran de connexion). Sans lui, retour à l'écran de connexion, avec un message ;
- ligne 105 : un garde [CanMatchFn] ne dit pas si l'on a le DROIT d'aller sur un écran, mais si la route CORRESPOND à l'adresse : [/medecins/abc/modifier] ne correspond pas à la route [medecins/:id/modifier] ; le routeur essaie les routes suivantes, jusqu'à la page 404 ;
- lignes 109-124 : une fonction, qui fabrique les trois routes des médecins OU des clients : [/medecins], [/medecins/ajouter], [/medecins/:id/modifier]. [:id] : un paramètre de chemin, donné à l'entrée [id] du composant ; [data: { type }] : une valeur fixe, donnée à son entrée [type]. [title] : le titre de l'écran (une clé de traduction, cf. [titre-page.ts]) ;
- lignes 111-112 : [loadComponent] : le code d'un écran n'est chargé qu'à sa première visite. [import()] charge un module à la demande ; [ng build] range donc chaque écran dans un fichier [JavaScript] à part, et l'application démarre plus vite ;
- ligne 131 : [/] : une route sans écran ([children: []]), toujours redirigée par le garde ;
- lignes 132-136 : [/login?login=jbernard] (après la création d'un compte) pré-remplit le champ login : le paramètre [login] est donné à l'entrée de même nom de l'écran de connexion ;
- lignes 137-141 : de même, les trois paramètres de l'agenda ([idMedecin], [jour], [reserver]) sont donnés aux entrées de l'écran : tout l'état de l'écran est dans l'adresse (cf. [agenda.ts]) ;
- lignes 160-172 : la page 403 et, pour toute adresse qu'aucune route ne décrit (le chemin « joker », deux étoiles), la page 404 : le même écran, avec un code différent ([data]) ;
- lignes 176-180 : chaque route reçoit le garde commun, avant ses gardes propres. [runGuardsAndResolvers: 'always'] : les gardes sont exécutés à chaque navigation, même vers l'adresse courante (par défaut, seulement quand le chemin ou ses paramètres changent). C'est ce qui permet à [app.ts] de les réévaluer quand la session change.
Ces gardes ne sont qu'un CONFORT : ils évitent d'afficher un écran inutile. La protection des données est assurée par le serveur, qui revérifie le jeton et le rôle à chaque requête.
11.12. Les composants
11.12.1. src/app/composants/gabarit.ts
| // =========================================================================
// src/app/composants/gabarit.ts - le GABARIT commun à tous les écrans
// -------------------------------------------------------------------------
// Le seul composant du composant racine (cf. app.ts) ; il entoure
// l'emplacement des écrans (<ion-router-outlet>) :
// - la barre du haut : titre, langue (FR / EN), déconnexion ;
// - le menu latéral ("tiroir", <ion-menu>), ouvert par le bouton ☰ : les
// écrans permis au rôle de l'utilisateur. "Médecins" et "Clients"
// n'apparaissent que pour l'ADMIN : un simple confort d'affichage, la
// vraie protection étant celle du serveur ;
// - un message de succès en HAUT ;
// - l'écran courant (<ion-router-outlet>) ;
// - la zone d'erreur en BAS.
// Sur un grand écran (web, tablette), le menu est affiché en permanence
// à gauche : c'est le travail d'<ion-split-pane>.
// =========================================================================
import { Component, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink } from '@angular/router';
import {
IonButton, IonButtons, IonChip, IonContent, IonFooter, IonHeader, IonIcon, IonItem, IonLabel, IonList,
IonMenu, IonMenuButton, IonMenuToggle, IonPopover, IonRouterLink, IonRouterOutlet, IonSplitPane, IonTitle,
IonToolbar,
} from '@ionic/angular';
import { addIcons } from 'ionicons';
import {
calendar, checkmarkCircle, close, alertCircle, home, logOutOutline, medkit, people, personCircle,
} from 'ionicons/icons';
import { filter, map } from 'rxjs';
import { Utilisateur } from '../api/types';
import { Messages } from '../etat/messages';
import { estAdmin, estPatient, Session } from '../etat/session';
import { Langue, langues, Traductions } from '../i18n/traductions';
// une entrée du menu : chemin, clé du libellé, icône
interface EntreeMenu {
chemin: string;
cle: string;
icone: string;
}
export function entreesMenu(u: Utilisateur | null): EntreeMenu[] {
if (u === null) return [];
return [
...(estPatient(u) ? [{ chemin: '/mon-espace', cle: 'APP.MENU_MY_SPACE', icone: 'home' }] : []),
{ chemin: '/agenda', cle: 'APP.MENU_AGENDA', icone: 'calendar' },
...(estAdmin(u)
? [
{ chemin: '/medecins', cle: 'APP.MENU_DOCTORS', icone: 'medkit' },
{ chemin: '/clients', cle: 'APP.MENU_PATIENTS', icone: 'people' },
]
: []),
...(estPatient(u) ? [{ chemin: '/mon-compte', cle: 'APP.MENU_ACCOUNT', icone: 'person-circle' }] : []),
];
}
@Component({
selector: 'app-gabarit',
imports: [
RouterLink, IonRouterLink, IonSplitPane, IonMenu, IonMenuToggle, IonMenuButton, IonHeader, IonToolbar, IonTitle,
IonButtons, IonButton, IonIcon, IonContent, IonList, IonItem, IonLabel, IonChip, IonPopover, IonRouterOutlet,
IonFooter,
],
templateUrl: './gabarit.html',
styleUrl: './gabarit.scss',
})
export class Gabarit {
private readonly session = inject(Session);
private readonly traductions = inject(Traductions);
readonly messages = inject(Messages);
readonly t = this.traductions.t;
readonly langue = this.traductions.langue;
readonly langues = langues;
readonly utilisateur = this.session.utilisateur;
readonly entrees = computed(() => entreesMenu(this.utilisateur()));
// l'adresse courante, sans ses paramètres : un signal fabriqué à partir
// des événements du routeur (toSignal : Observable -> signal)
private readonly chemin = toSignal(
inject(Router).events.pipe(
filter((e) => e instanceof NavigationEnd),
map((e) => e.urlAfterRedirects.split('?')[0]),
),
{ initialValue: '' },
);
// le menu des langues : ouvert ou non, et sous quel élément
readonly languesOuvertes = signal(false);
evenementLangues?: Event;
constructor() {
addIcons({ calendar, checkmarkCircle, close, alertCircle, home, logOutOutline, medkit, people, personCircle });
}
// l'entrée du menu de l'écran courant
actif(e: EntreeMenu): boolean {
return this.chemin().startsWith(e.chemin);
}
// le bouton de la langue : le menu des langues s'ouvre sous lui
ouvrirLangues(evenement: Event): void {
this.evenementLangues = evenement;
this.languesOuvertes.set(true);
}
// FR / EN : aucune requête au serveur, l'écran se retraduit sur place
changerLangue(l: Langue): void {
void this.traductions.changerLangue(l);
}
// la session se vide ; les gardes du routeur, réévalués, affichent
// l'écran de connexion. catch : même si le serveur ne répond pas
seDeconnecter(): void {
this.session.deconnecter().catch(() => undefined);
}
}
|
Le composant qui entoure tous les écrans : la barre du haut, le menu, les messages. C'est le pendant du widget de la [ShellRoute] du client [Flutter].
- lignes 37-41 : une entrée du menu : une interface ;
- lignes 43-56 : les entrées permises au rôle de l'utilisateur. [...(condition ? [entrées] : [])] : l'opérateur de décomposition insère zéro, une ou plusieurs entrées dans le tableau (le [if] dans une liste de [Dart]) ;
- lignes 69-76 : les services, et ce que le gabarit en affiche : la langue, l'utilisateur, les entrées du menu (un signal calculé : il change avec l'utilisateur) ;
- lignes 80-86 : l'adresse courante, pour marquer l'entrée active du menu. [toSignal] transforme un [Observable] (les événements du routeur, réduits aux fins de navigation) en signal, lisible par le gabarit ; il faut lui donner une valeur initiale ;
- lignes 88-89 : le menu des langues : ouvert ou non, et l'événement (l'appui sur le bouton) sous lequel il s'ouvre ;
- ligne 92 : les icônes utilisées par le gabarit, enregistrées auprès d'[ionicons] ;
- lignes 107-109 : changer de langue : aucune requête au serveur ; le dictionnaire change, et tout ce qui affiche une traduction se met à jour ;
- lignes 113-115 : la déconnexion : la session se vide, et les gardes, réévalués (cf. [app.ts]), affichent l'écran de connexion. [catch] : même si le serveur ne répond pas, on considère l'utilisateur déconnecté.
11.12.2. src/app/composants/gabarit.html
| <!-- ======================================================================
gabarit.html - le gabarit commun à tous les écrans
====================================================================== -->
<!-- ion-split-pane : au-delà de 800 pixels de large (when), le menu est
affiché en permanence à gauche du contenu ; en deçà, c'est un tiroir.
contentId : l'identifiant du contenu principal. disabled : pas de menu
pour un visiteur non connecté -->
<ion-split-pane contentId="principal" when="(min-width: 800px)" [disabled]="!utilisateur()">
<ion-menu contentId="principal" [disabled]="!utilisateur()">
<ion-content>
@if (utilisateur(); as u) {
<!-- l'en-tête du menu : qui est connecté -->
<div class="qui">
<div class="nom">{{ u.nom }}</div>
<ion-chip [outline]="true">{{ u.role }}</ion-chip>
</div>
<ion-list lines="none">
@for (e of entrees(); track e.chemin) {
<!-- ion-menu-toggle : toucher l'entrée ferme le tiroir ;
routerDirection="root" : l'écran choisi remplace toute la
pile des écrans (pas de retour vers l'écran précédent) -->
<ion-menu-toggle [autoHide]="false">
<ion-item
[button]="true"
[detail]="false"
[routerLink]="e.chemin"
routerDirection="root"
[class.actif]="actif(e)"
>
<ion-icon slot="start" [name]="e.icone" />
<ion-label>{{ t(e.cle) }}</ion-label>
</ion-item>
</ion-menu-toggle>
}
</ion-list>
}
</ion-content>
</ion-menu>
<!-- le contenu principal : une "page" Ionic (classe ion-page : en colonne,
sur toute la hauteur) -->
<div class="ion-page" id="principal">
<ion-header>
<ion-toolbar color="primary">
<!-- ion-menu-button : le bouton ☰, qui ouvre le tiroir. Il se cache
de lui-même quand le menu est désactivé ou affiché en permanence -->
<ion-buttons slot="start">
<ion-menu-button />
</ion-buttons>
<ion-title>{{ t('APP.TITLE') }}</ion-title>
<ion-buttons slot="end">
<!-- le bouton de la langue ouvre le popover déclaré plus bas -->
<ion-button id="bouton-langue" [title]="t('APP.LANGUAGE')" (click)="ouvrirLangues($event)">
<strong>{{ langue().toUpperCase() }}</strong>
</ion-button>
@if (utilisateur()) {
<ion-button [title]="t('APP.LOGOUT')" (click)="seDeconnecter()">
<ion-icon slot="icon-only" name="log-out-outline" />
</ion-button>
}
</ion-buttons>
</ion-toolbar>
</ion-header>
<!-- ion-popover : une petite fenêtre, ouverte (isOpen) sous l'élément
touché (event) ; dismissOnSelect : elle se ferme quand on choisit ;
didDismiss : elle s'est fermée (choix, ou appui à côté) -->
<ion-popover
[isOpen]="languesOuvertes()"
[event]="evenementLangues"
[dismissOnSelect]="true"
(didDismiss)="languesOuvertes.set(false)"
>
<ng-template>
<ion-list lines="none">
@for (l of langues; track l) {
<ion-item [button]="true" [detail]="false" (click)="changerLangue(l)">
<ion-label>{{ l.toUpperCase() }}</ion-label>
</ion-item>
}
</ion-list>
</ng-template>
</ion-popover>
<!-- le message de succès, en haut -->
@if (messages.succes(); as succes) {
<ion-item class="message succes" lines="none">
<ion-icon slot="start" name="checkmark-circle" />
<ion-label class="ion-text-wrap">{{ t(succes.cle, succes.params) }}</ion-label>
<ion-button slot="end" fill="clear" color="dark" (click)="messages.effacer()">
<ion-icon slot="icon-only" name="close" />
</ion-button>
</ion-item>
}
<!-- l'écran courant -->
<div class="ecran">
<ion-router-outlet />
</div>
<!-- la zone d'erreur, en bas : toujours visible, même si l'écran défile.
ion-footer : au-dessus de la barre de navigation du téléphone -->
@if (messages.erreur(); as erreur) {
<ion-footer>
<ion-item class="message erreur" lines="none">
<ion-icon slot="start" name="alert-circle" />
<ion-label class="ion-text-wrap">{{ t(erreur.cle, erreur.params) }}</ion-label>
<ion-button slot="end" fill="clear" color="dark" (click)="messages.effacer()">
<ion-icon slot="icon-only" name="close" />
</ion-button>
</ion-item>
</ion-footer>
}
</div>
</ion-split-pane>
|
- ligne 9 : [ion-split-pane] : sur un grand écran (ici, au-delà de 800 pixels de large : [when], une « media query » CSS), le menu est affiché EN PERMANENCE à gauche du contenu ; sur un petit écran, c'est un tiroir, ouvert par le bouton ☰. C'est le choix entre [NavigationDrawer] et [NavigationRail] que faisait le gabarit [Flutter], fait ici par [Ionic] lui-même. [contentId] : l'identifiant du contenu principal (ligne 43). Pas de menu pour un visiteur non connecté ([disabled]) ;
- lignes 10-39 : [ion-menu] : le menu ; en haut, qui est connecté (le nom, et le rôle dans une « puce », [ion-chip]) ;
- lignes 23-34 : une entrée du menu. [ion-menu-toggle] ferme le tiroir quand on touche l'entrée ([autoHide] à [false] : l'entrée reste visible quand le menu est affiché en permanence). [routerLink] : l'adresse de l'écran (cf. exemple 17) ; [routerDirection="root"] : l'écran choisi REMPLACE toute la pile des écrans d'[Ionic] : le bouton Retour ne ramène pas à l'écran précédent (comme le [context.go] de [go_router]). [class.actif] : la classe CSS [actif] est posée sur l'entrée de l'écran courant ;
- ligne 43 : le contenu principal : une « page » d'[Ionic] (classe [ion-page] : en colonne, sur toute la hauteur) ;
- lignes 44-64 : la barre du haut : le bouton ☰ ([ion-menu-button], qui se cache de lui-même quand le menu est désactivé ou affiché en permanence), le titre, le bouton de la langue (son texte, « FR » ou « EN »), la déconnexion (seulement pour un utilisateur connecté) ;
- lignes 69-84 : [ion-popover] : une petite fenêtre, ouverte ([isOpen]) sous l'élément touché ([event]), qui se ferme quand on choisit ([dismissOnSelect]) ; [didDismiss] : elle s'est fermée, par un choix ou un appui à côté. Son contenu est dans un [ng-template] : il n'est construit qu'à l'ouverture ;
- lignes 87-95 : le message de succès, en haut, s'il y en a un. [@if (messages.succes(); as succes)] : la condition, et sa valeur rangée dans une variable du gabarit ;
- lignes 98-100 : l'emplacement des écrans, [ion-router-outlet] (cf. exemple 17) : le routeur y affiche l'écran de l'adresse courante. Celui d'[Ionic] anime le passage d'un écran à l'autre, et garde les écrans précédents en mémoire, cachés : le bouton Retour y revient sans les recréer ;
- lignes 104-114 : la zone d'erreur, en bas : [ion-footer], toujours visible, même si l'écran défile, et placée au-dessus de la barre de navigation du téléphone.
11.12.3. src/app/composants/gabarit.scss
| // =========================================================================
// gabarit.scss - les styles du gabarit
// =========================================================================
// l'en-tête du menu. Sur le téléphone, le tiroir s'étend sous la barre
// d'état d'Android (l'heure, la batterie) : on ajoute sa hauteur en haut
.qui {
padding: calc(16px + var(--ion-safe-area-top, 0px)) 16px 8px 20px;
.nom {
font-size: 18px;
font-weight: 500;
}
}
// l'entrée du menu de l'écran courant : sur un fond coloré, arrondi
ion-item.actif {
--background: rgba(var(--ion-color-primary-rgb), 0.14);
--border-radius: 28px;
margin: 0 8px;
font-weight: 500;
}
ion-menu-toggle ion-item {
margin: 0 8px;
}
// sur un grand écran : la largeur du menu affiché en permanence
ion-split-pane {
--side-width: 230px;
--side-min-width: 230px;
--side-max-width: 230px;
}
// l'emplacement des écrans : toute la hauteur restante. <ion-router-outlet>
// est positionné en "absolute" : il remplit ce conteneur "relative"
.ecran {
position: relative;
flex: 1;
}
// les bandeaux des messages
.message {
font-size: 17px;
}
.succes {
--background: #e8f5e9;
--color: #1b5e20;
ion-icon[slot='start'] {
color: #388e3c;
}
}
.erreur {
--background: #f8d7da;
--color: #b71c1c;
ion-icon[slot='start'] {
color: #c62828;
}
}
|
Les styles du gabarit. Ils ne s'appliquent qu'aux éléments de [gabarit.html] : [Angular] « encapsule » les styles d'un composant.
- lignes 7-8 : l'en-tête du menu (le nom et le rôle de l'utilisateur). Sur le téléphone, l'application occupe tout l'écran, y compris la bande du haut où [Android] affiche l'heure et la batterie (la barre d'état) : la barre d'outils d'[Ionic] ([ion-toolbar]) se décale d'elle-même, mais pas ce simple [div]. La variable CSS [--ion-safe-area-top], fournie par [Ionic], donne la hauteur de cette bande (0 dans un navigateur) : on l'ajoute à la marge du haut. Sans elle, le nom s'écrit par-dessus l'heure ;
- lignes 17-22 : l'entrée active du menu, sur un fond coloré et arrondi, comme dans un menu de [Material] 3. [rgba(var(--ion-color-primary-rgb), 0.14)] : la couleur principale, presque transparente ;
- lignes 29-33 : la largeur du menu affiché en permanence (sinon, 270 pixels) ;
- lignes 37-40 : [ion-router-outlet] est positionné en « absolute » (il remplit son parent) : on le place dans un conteneur qui prend toute la hauteur restante ([flex: 1]), entre les messages ;
- lignes 47-63 : les bandeaux des messages : vert pour un succès, rose pour une erreur, les couleurs des autres clients.
11.12.4. src/app/composants/titre-page.ts
| // =========================================================================
// src/app/composants/titre-page.ts - le titre de l'écran, traduit
// -------------------------------------------------------------------------
// Sur le web : le titre de l'onglet ; sur Android : le nom de l'application
// dans la liste des applications récentes. "Agenda - RdvMedecins"
//
// Le routeur d'Angular sait donner un titre à chaque écran : la propriété
// title d'une route (cf. app.routes.ts). Après chaque navigation, il
// appelle la "stratégie de titre" (TitleStrategy). On remplace celle
// d'Angular (cf. main.ts) par celle-ci : nos titres sont des CLÉS de
// traduction, et le titre doit changer aussi quand la langue change.
// =========================================================================
import { effect, inject, Injectable, signal } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
import { Traductions } from '../i18n/traductions';
@Injectable()
export class TitrePage extends TitleStrategy {
private readonly title = inject(Title);
private readonly t = inject(Traductions).t;
// la clé du titre de l'écran courant
private readonly cle = signal<string | undefined>(undefined);
constructor() {
super();
// réexécuté quand la clé change (navigation) OU quand la langue change
// (t lit le dictionnaire, un signal)
effect(() => {
const cle = this.cle();
const application = this.t('APP.TITLE');
this.title.setTitle(cle ? `${this.t(cle)} - ${application}` : application);
});
}
// appelée par le routeur après chaque navigation ; buildTitle : la
// propriété title de la route affichée
override updateTitle(etat: RouterStateSnapshot): void {
this.cle.set(this.buildTitle(etat));
}
}
|
Le titre de l'écran, traduit : le titre du document, celui de l'onglet sur le web. Le routeur d'[Angular] sait donner un titre à chaque écran : la propriété [title] de sa route (cf. [app.routes.ts]) ; il appelle, après chaque navigation, la « stratégie de titre » ([TitleStrategy]), que [main.ts] remplace par celle-ci.
- ligne 19 : [@Injectable()] sans [providedIn] : le service est fourni par [main.ts], à la place de celui d'[Angular] ;
- lignes 30-34 : un effet, réexécuté quand la clé du titre change (une navigation) OU quand la langue change ([t] lit le dictionnaire, un signal). [Title] : le service d'[Angular] qui écrit le titre de la page ;
- lignes 39-41 : appelée par le routeur après chaque navigation ; [buildTitle] donne la propriété [title] de la route affichée.
Le client [Flutter] enveloppait chaque écran dans un widget [TitrePage] ; ici, les titres sont déclarés avec les routes.
| // =========================================================================
// src/app/composants/envoi-formulaire.ts - envoyer un formulaire au serveur
// -------------------------------------------------------------------------
// Tous les formulaires de l'application suivent le même schéma que
// l'exemple 25 : aucune règle n'est vérifiée sur le téléphone (elles vivent
// en UN seul endroit, le serveur), et en cas de refus :
// - l'erreur de chaque champ (ErreurApi.champs) est affichée sous le champ ;
// - le message général (ErreurApi.cle) s'affiche en bas de l'écran.
// Cette classe regroupe ces gestes. Ce n'est pas un service : un écran en
// crée une par formulaire (new), avec ses propres signaux :
//
// readonly envoi = new EnvoiFormulaire(inject(Messages));
// if (await this.envoi.envoyer(() => api.inscrire(saisie))) { ... }
// <app-champ-saisie ... [erreur]="envoi.erreurs()['nom']" />
// =========================================================================
import { signal } from '@angular/core';
import { ErreurApi, Textes } from '../api/erreur-api';
import { Messages } from '../etat/messages';
export class EnvoiFormulaire {
// l'erreur de chaque champ : une CLÉ de traduction
readonly erreurs = signal<Textes>({});
// les paramètres des messages d'erreur ({{min}}...), renvoyés par le serveur
readonly params = signal<Textes>({});
readonly enCours = signal(false);
constructor(private readonly messages: Messages) {}
// exécute l'envoi ; renvoie true s'il a réussi. En cas d'ErreurApi, les
// erreurs sont affichées et on renvoie false
async envoyer(action: () => Promise<unknown>): Promise<boolean> {
if (this.enCours()) return false; // pas de double envoi
this.erreurs.set({});
this.params.set({});
this.enCours.set(true);
try {
await action();
return true;
} catch (e) {
// toute autre erreur est une anomalie : afficherErreurApi la relance
this.messages.afficherErreurApi(e);
const erreur = e as ErreurApi;
this.erreurs.set(erreur.champs);
this.params.set(erreur.params);
return false;
} finally {
this.enCours.set(false);
}
}
// l'utilisateur modifie un champ : son erreur disparaît
effacerErreur(champ: string): void {
if (champ in this.erreurs()) {
// un NOUVEL objet, sans ce champ : le signal change de valeur
const { [champ]: _efface, ...autres } = this.erreurs();
this.erreurs.set(autres);
}
}
}
|
L'envoi d'un formulaire au serveur : la [Commande] de l'exemple 23, complétée des erreurs des champs (exemple 25). Ce n'est pas un service : chaque formulaire crée la sienne ([new]), avec ses propres signaux.
- lignes 23-26 : l'erreur de chaque champ (une clé de traduction), les paramètres des messages, et l'envoi en cours (les boutons se désactivent). Trois signaux, que lisent les gabarits ;
- ligne 28 : [private readonly messages] : un paramètre du constructeur qui devient une propriété (cf. [erreur-api.ts]) ;
- lignes 32-50 : l'envoi : pas de double envoi ; en cas d'[ErreurApi], le message général en bas de l'écran et l'erreur de chaque champ. [finally] s'exécute dans tous les cas, même après un [return] ;
- lignes 53-59 : l'utilisateur corrige un champ : son erreur disparaît. On fabrique un NOUVEL objet, sans ce champ ([const { [champ]: _efface, ...autres } = ...] : une déstructuration, dont le reste, [autres], contient tous les champs sauf celui-là) : un signal ne prévient ses lecteurs que si sa valeur change, et modifier l'objet lui-même ne la changerait pas.
11.12.6. src/app/composants/champ-saisie.ts
| // =========================================================================
// src/app/composants/champ-saisie.ts - un champ de formulaire complet
// -------------------------------------------------------------------------
// Libellé + champ + message d'aide OU message d'erreur. Tous les
// formulaires de l'application l'utilisent :
//
// <app-champ-saisie libelle="PERSONNES.LASTNAME_LABEL" [longueurMax]="20"
// [valeur]="saisie().nom" (valeurChange)="modifier('nom', $event)"
// [erreur]="envoi.erreurs()['nom']" />
//
// C'est un composant CONTRÔLÉ (cf. exemple 11) : la valeur appartient au
// formulaire parent, qui la lui donne (entrée valeur) et la reçoit à
// chaque frappe (sortie valeurChange). libelle, aide et erreur sont des
// CLÉS de traduction ; params, les paramètres de leurs messages ({{min}}...).
// =========================================================================
import { Component, inject, input, output } from '@angular/core';
import { InputCustomEvent, IonInput } from '@ionic/angular';
import type { AutocompleteTypes } from '@ionic/core';
import { Textes } from '../api/erreur-api';
import { Traductions } from '../i18n/traductions';
@Component({
selector: 'app-champ-saisie',
imports: [IonInput],
templateUrl: './champ-saisie.html',
styles: `
:host {
display: block;
margin-bottom: 8px;
}
`,
})
export class ChampSaisie {
readonly t = inject(Traductions).t;
// input.required : une entrée obligatoire (erreur de compilation si le parent l'oublie)
readonly libelle = input.required<string>();
readonly valeur = input.required<string>();
readonly erreur = input<string>();
readonly aide = input<string>();
readonly params = input<Textes>({});
readonly secret = input(false); // un mot de passe : caractères masqués
readonly longueurMax = input<number>();
// pour le gestionnaire de mots de passe du téléphone ('username', 'current-password'...)
readonly autocomplete = input<AutocompleteTypes>('off');
readonly valeurChange = output<string>();
// ionInput : à chaque frappe (ionChange : seulement quand le champ perd le focus)
changer(evenement: InputCustomEvent): void {
this.valeurChange.emit(evenement.detail.value ?? '');
}
}
|
Un champ de formulaire complet : libellé, champ, message d'aide ou d'erreur. Tous les formulaires l'utilisent.
- lignes 38-46 : les entrées (cf. exemple 10). Le libellé, l'aide et l'erreur sont des CLÉS de traduction. [input.required] : une entrée obligatoire ; oubliée par le parent, c'est une erreur de compilation. [AutocompleteTypes] : le type, fourni par [Ionic], des valeurs permises de l'attribut HTML [autocomplete] ;
- ligne 48 : la sortie (cf. exemple 11) : la nouvelle valeur, à chaque frappe. Un composant CONTRÔLÉ : la valeur appartient au parent, qui la donne ([valeur]) et la reçoit ([valeurChange]). Quand le parent la change lui-même (le captcha effacé après un échec), le champ suit : il n'y a rien à écrire, contrairement au [didUpdateWidget] du client [Flutter] ;
- lignes 51-53 : [ionInput] : l'événement d'[ion-input] à chaque frappe ([ionChange] n'arrive que quand le champ perd le focus) ; [detail.value] peut être [null] ou [undefined] : on envoie alors une chaîne vide.
11.12.7. src/app/composants/champ-saisie.html
| <!-- ======================================================================
champ-saisie.html - le gabarit d'un champ de formulaire
====================================================================== -->
<!-- fill="outline" : un champ encadré ; labelPlacement="floating" : le
libellé est dans le champ vide, et "flotte" sur le cadre dès qu'il a
le focus ou une valeur ;
helperText / errorText : les messages sous le champ. errorText n'est
affiché (en rouge, à la place de l'aide) que si le champ porte les
classes ion-invalid ET ion-touched -->
<ion-input
[label]="t(libelle())"
fill="outline"
labelPlacement="floating"
[type]="secret() ? 'password' : 'text'"
[value]="valeur()"
[maxlength]="longueurMax()"
[autocomplete]="autocomplete()"
[autocorrect]="secret() ? 'off' : 'on'"
[helperText]="aide() ? t(aide()!, params()) : undefined"
[errorText]="erreur() ? t(erreur()!, params()) : undefined"
[class.ion-invalid]="!!erreur()"
[class.ion-touched]="!!erreur()"
(ionInput)="changer($event)"
/>
|
- lignes 13-14 : un champ encadré ([fill="outline"]), dont le libellé est dans le champ vide, et « flotte » sur le cadre dès qu'il a le focus ou une valeur ;
- ligne 15 : un mot de passe est masqué ;
- lignes 17-19 : [maxlength] limite la saisie ; [autocomplete] indique au gestionnaire de mots de passe du téléphone ce que contient le champ (un login, un mot de passe, un nouveau mot de passe) ; le correcteur orthographique ne se mêle pas des mots de passe ;
- lignes 20-23 : l'aide et l'erreur, sous le champ. [ion-input] n'affiche l'erreur (en rouge, à la place de l'aide) que si le champ porte les classes CSS [ion-invalid] et [ion-touched] : on les pose quand il y a une erreur. Ces classes sont d'ordinaire posées par les formulaires d'[Angular] (exemple 08) ; ici, c'est le SERVEUR qui décide de la validité. Ce que les exemples 07 et 11 déconseillaient (poser ces classes à la main) fonctionne ici : [Ionic] les retire bien à la frappe suivante, mais cette frappe efface aussi l'erreur ([effacerErreur]), qui ne doit justement plus s'afficher ;
- ligne 24 : chaque frappe appelle [changer].
11.12.8. src/app/composants/champs-personne.ts
| // =========================================================================
// src/app/composants/champs-personne.ts - titre, prénom, nom
// -------------------------------------------------------------------------
// Les champs d'identité, communs à trois formulaires : médecin / client de
// l'ADMIN, "Créer un compte client" et "Gérer mon compte". Le parent lui
// passe les valeurs, les erreurs, et reçoit chaque modification :
// <app-champs-personne [valeurs]="saisie()" [erreurs]="envoi.erreurs()"
// (modification)="modifier($event.champ, $event.valeur)" />
// =========================================================================
import { Component, inject, input, output } from '@angular/core';
import { IonSelect, IonSelectOption, SelectCustomEvent } from '@ionic/angular';
import { Textes } from '../api/erreur-api';
import { Donnees } from '../api/rdvmedecins-api';
import { Traductions } from '../i18n/traductions';
import { ChampSaisie } from './champ-saisie';
export const titres = ['Mr', 'Mme', 'Melle'];
export interface Modification {
champ: string;
valeur: string;
}
@Component({
selector: 'app-champs-personne',
imports: [IonSelect, IonSelectOption, ChampSaisie],
templateUrl: './champs-personne.html',
})
export class ChampsPersonne {
readonly t = inject(Traductions).t;
readonly titres = titres;
readonly valeurs = input.required<Donnees>();
readonly erreurs = input.required<Textes>();
readonly modification = output<Modification>();
// la valeur d'un champ, en texte
valeur(champ: string): string {
return String(this.valeurs()[champ] ?? '');
}
modifier(champ: string, valeur: string): void {
this.modification.emit({ champ, valeur });
}
choisirTitre(evenement: SelectCustomEvent<string>): void {
this.modifier('titre', evenement.detail.value);
}
}
|
Les champs d'identité (titre, prénom, nom), communs aux formulaires de l'ADMIN, de la création de compte et de « Gérer mon compte ».
- lignes 34-36 : le parent passe les valeurs et les erreurs, et reçoit UNE sortie pour n'importe quel champ : son nom et sa nouvelle valeur ;
- lignes 39-41 : la valeur d'un champ, en texte (les valeurs d'un formulaire sont de type [unknown]) ;
- lignes 47-49 : le titre choisi dans la liste ; [SelectCustomEvent<string>] : l'événement d'[ion-select], dont la valeur est un texte.
11.12.9. src/app/composants/champs-personne.html
| <!-- ======================================================================
champs-personne.html - titre, prénom, nom
====================================================================== -->
<!-- ion-select : une liste déroulante, avec libellé et message d'erreur ;
interface="popover" : la liste s'ouvre sous le champ -->
<ion-select
[label]="t('PERSONNES.TITLE_LABEL')"
fill="outline"
labelPlacement="floating"
interface="popover"
[value]="valeur('titre')"
[errorText]="erreurs()['titre'] ? t(erreurs()['titre']) : undefined"
[class.ion-invalid]="!!erreurs()['titre']"
[class.ion-touched]="!!erreurs()['titre']"
(ionChange)="choisirTitre($event)"
>
@for (titre of titres; track titre) {
<ion-select-option [value]="titre">{{ titre }}</ion-select-option>
}
</ion-select>
<app-champ-saisie
libelle="PERSONNES.FIRSTNAME_LABEL"
[longueurMax]="20"
[valeur]="valeur('prenom')"
(valeurChange)="modifier('prenom', $event)"
[erreur]="erreurs()['prenom']"
/>
<app-champ-saisie
libelle="PERSONNES.LASTNAME_LABEL"
[longueurMax]="20"
[valeur]="valeur('nom')"
(valeurChange)="modifier('nom', $event)"
[erreur]="erreurs()['nom']"
/>
|
- lignes 7-21 : le titre, dans une liste déroulante ([ion-select], cf. exemple 05), avec libellé et erreur, comme un champ de saisie ; [interface="popover"] : la liste s'ouvre sous le champ (par défaut, dans une boîte de dialogue) ;
- lignes 23-36 : le prénom et le nom : deux champs de saisie. Les erreurs arrivent sous le nom du champ attendu par le serveur ([erreurs()['prenom']]).
11.12.10. src/app/composants/champ-captcha.ts
| // =========================================================================
// src/app/composants/champ-captcha.ts - l'image du captcha et le champ de saisie
// -------------------------------------------------------------------------
// Un composant d'affichage : l'image (svg), le bouton "Autre image"
// (sortie recharger), et le champ, CONTRÔLÉ par le formulaire parent
// (valeur, valeurChange). L'image et son identifiant sont demandés au
// serveur par la page de connexion, qui en a besoin pour envoyer la réponse.
// =========================================================================
import { Component, computed, inject, input, output } from '@angular/core';
import { IonButton, IonIcon } from '@ionic/angular';
import { addIcons } from 'ionicons';
import { refresh } from 'ionicons/icons';
import { Traductions } from '../i18n/traductions';
import { ChampSaisie } from './champ-saisie';
@Component({
selector: 'app-champ-captcha',
imports: [IonButton, IonIcon, ChampSaisie],
templateUrl: './champ-captcha.html',
styles: `
.libelle {
font-size: 13px;
color: var(--ion-color-step-700, #444);
}
.ligne {
display: flex;
align-items: center;
gap: 8px;
}
.image {
width: 160px;
height: 54px;
background: #f5f7fa;
border: 1px solid #d0d0d0;
border-radius: 6px;
object-fit: contain;
}
`,
})
export class ChampCaptcha {
readonly t = inject(Traductions).t;
readonly svg = input<string>();
readonly valeur = input.required<string>();
readonly erreur = input<string>();
readonly valeurChange = output<string>();
readonly recharger = output<void>();
// l'image SVG (du texte) -> une adresse "data:" qu'une balise <img> sait
// afficher. Une image ne peut exécuter aucun script : c'est sans danger
readonly source = computed(() => {
const svg = this.svg();
return svg ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` : undefined;
});
constructor() {
addIcons({ refresh });
}
}
|
L'image du captcha, le bouton « Autre image », et le champ. Le composant n'appelle pas le serveur : c'est l'écran de connexion qui demande l'image, car il a besoin de son identifiant pour envoyer la réponse.
- lignes 21-39 : les styles du composant, écrits dans le composant lui-même ([styles]) : ils sont courts ;
- ligne 48 : une sortie sans valeur ([output<void>()]) : « l'utilisateur veut une autre image » ;
- lignes 52-55 : l'image SVG est reçue sous forme de texte (exemple 22). On en fait une adresse « data: » (le contenu du fichier, encodé dans l'adresse elle-même), qu'une balise [img] sait afficher. C'est sans danger : une image ne peut exécuter aucun script, alors qu'un SVG inséré dans la page par [innerHTML] le pourrait ([Angular] le refuserait d'ailleurs, sauf autorisation explicite).
11.12.11. src/app/composants/champ-captcha.html
| <!-- ======================================================================
champ-captcha.html - l'image du captcha et le champ de saisie
====================================================================== -->
<div class="libelle">{{ t('CAPTCHA.LABEL') }}</div>
<div class="ligne">
@if (source(); as source) {
<!-- alt : ce que lit un lecteur d'écran à la place de l'image -->
<img class="image" [src]="source" [alt]="t('CAPTCHA.IMAGE_ALT')" />
} @else {
<div class="image"></div>
}
<ion-button fill="clear" (click)="recharger.emit()">
<ion-icon slot="start" name="refresh" />
{{ t('CAPTCHA.NEW_IMAGE') }}
</ion-button>
</div>
<app-champ-saisie
libelle="CAPTCHA.LABEL"
aide="CAPTCHA.HELP"
[longueurMax]="10"
[valeur]="valeur()"
(valeurChange)="valeurChange.emit($event)"
[erreur]="erreur()"
/>
|
- ligne 9 : [alt] : ce que lit un lecteur d'écran (le [TalkBack] d'[Android]) à la place de l'image ;
- lignes 13-16 : « Autre image » émet la sortie [recharger] ;
- lignes 18-25 : le champ, lui aussi contrôlé par le parent : le composant transmet la valeur et ses changements.
11.12.12. src/app/composants/confirmation.ts
| // =========================================================================
// src/app/composants/confirmation.ts - « Êtes-vous sûr ? »
// -------------------------------------------------------------------------
// Repris de l'exemple 16. Un service, qui ouvre une boîte de dialogue
// d'Ionic (AlertController). Le message est une CLÉ de traduction :
// if (await this.confirmation.demander('AGENDA.CANCEL_CONFIRM')) { ... }
// "Non, revenir" plutôt qu'"Annuler" : la question est parfois « Annuler ce
// rendez-vous ? », et un bouton "Annuler" y serait ambigu.
// Une nouvelle question efface les messages de l'action précédente.
// =========================================================================
import { inject, Injectable } from '@angular/core';
import { AlertController } from '@ionic/angular';
import { Messages } from '../etat/messages';
import { Traductions } from '../i18n/traductions';
@Injectable({ providedIn: 'root' })
export class Confirmation {
private readonly alertes = inject(AlertController);
private readonly messages = inject(Messages);
private readonly t = inject(Traductions).t;
async demander(cle: string): Promise<boolean> {
this.messages.effacer();
const alerte = await this.alertes.create({
// une classe CSS pour la boîte : sa largeur (cf. styles.scss)
cssClass: 'confirmation',
header: this.t('CONFIRMATION.TITLE'),
message: this.t(cle),
buttons: [
// role 'cancel' : le bouton Non ; un appui hors de la fenêtre ou le bouton
// Retour du téléphone donnent le rôle 'backdrop' : seul 'confirm' dit oui
{ text: this.t('CONFIRMATION.NO'), role: 'cancel' },
// cssClass : le bouton rouge (cf. styles.scss)
{ text: this.t('CONFIRMATION.YES'), role: 'confirm', cssClass: 'bouton-danger' },
],
});
await alerte.present();
// onDidDismiss : une Promise, résolue à la fermeture, avec le rôle du bouton
const { role } = await alerte.onDidDismiss();
return role === 'confirm';
}
}
|
La fenêtre de confirmation de l'exemple 16, traduite : [if (await this.confirmation.demander('AGENDA.CANCEL_CONFIRM')) ...]. Un service, puisqu'il n'a rien à afficher lui-même : c'est l'[AlertController] d'[Ionic] qui fabrique la boîte.
- ligne 24 : une nouvelle question efface les messages de l'action précédente ;
- ligne 27 : une classe CSS pour la boîte : sa largeur (cf. [styles.scss]) ;
- lignes 33-35 : les boutons. Un appui hors de la boîte, ou le bouton Retour du téléphone, ferment la boîte avec le rôle [backdrop] (cf. exemple 16) : seul « Oui, confirmer » ([confirm]) dit oui ;
- lignes 38-41 : [present()] affiche la boîte ; [onDidDismiss()] renvoie une [Promise], résolue à sa fermeture, avec le rôle du bouton touché.
11.12.13. src/app/composants/fenetre-reservation.ts
| // =========================================================================
// src/app/composants/fenetre-reservation.ts - le contenu de la fenêtre de
// réservation d'un créneau
// -------------------------------------------------------------------------
// La fenêtre elle-même est un <ion-modal> de l'écran de l'agenda, ouvert
// quand son ADRESSE le demande (/agenda?...&reserver=7, cf.
// pages/agenda.ts) : l'adresse décrit donc tout l'écran, fenêtre comprise.
// Ce composant n'en est que le CONTENU.
//
// Il ne parle pas au serveur : il émet
// - confirmer, avec le patient choisi (undefined pour un patient qui
// réserve pour lui-même) ;
// - fermer.
// C'est l'écran de l'agenda qui décide quoi en faire.
// =========================================================================
import { Component, inject, input, output, signal } from '@angular/core';
import { IonButton, IonSelect, IonSelectOption, SelectCustomEvent } from '@ionic/angular';
import { libelle, Personne } from '../api/types';
import { Traductions } from '../i18n/traductions';
@Component({
selector: 'app-fenetre-reservation',
imports: [IonButton, IonSelect, IonSelectOption],
templateUrl: './fenetre-reservation.html',
styles: `
:host {
display: block;
padding: 20px;
}
h2 {
margin-top: 0;
font-size: 22px;
font-weight: 400;
}
.boutons {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
}
`,
})
export class FenetreReservation {
readonly t = inject(Traductions).t;
readonly libelle = libelle;
readonly horaire = input.required<string>(); // "8:00 - 8:20"
// ADMIN / DOCTOR : les patients parmi lesquels choisir
readonly clients = input<Personne[]>([]);
// USER : le patient, c'est lui-même
readonly patient = input<string>();
readonly envoiEnCours = input(false);
readonly confirmer = output<number | undefined>();
readonly fermer = output<void>();
// le patient choisi dans la liste
readonly idClient = signal<number | undefined>(undefined);
choisir(evenement: SelectCustomEvent<number>): void {
this.idClient.set(evenement.detail.value);
}
}
|
Le contenu de la fenêtre de réservation d'un créneau. La fenêtre elle-même est un [ion-modal] de l'écran de l'agenda, ouvert quand son adresse le demande ([&reserver=7], cf. [agenda.html]).
- lignes 49-56 : le composant ne parle pas au serveur : il reçoit ce qu'il affiche, et émet deux sorties, pour confirmer (avec le patient choisi) ou fermer. Un patient réserve pour lui-même : il reçoit son nom ([patient]), et pas de liste ;
- ligne 59 : le patient choisi dans la liste : l'état de la fenêtre.
11.12.14. src/app/composants/fenetre-reservation.html
| <!-- ======================================================================
fenetre-reservation.html - le contenu de la fenêtre de réservation
====================================================================== -->
<h2>{{ t('BOOKING_DIALOG.TITLE') }}</h2>
<p>{{ t('BOOKING_DIALOG.SLOT', { horaire: horaire() }) }}</p>
<!-- un patient réserve pour lui-même : pas de liste (et le serveur
ignorerait de toute façon un éventuel idClient) -->
@if (patient(); as patient) {
<p>{{ t('BOOKING_DIALOG.PATIENT_LABEL') }} : <strong>{{ patient }}</strong></p>
} @else {
<!-- placeholder : le texte affiché tant qu'aucun patient n'est choisi -->
<ion-select
[label]="t('BOOKING_DIALOG.PATIENT_LABEL')"
fill="outline"
labelPlacement="stacked"
interface="popover"
[placeholder]="t('BOOKING_DIALOG.CHOOSE_PATIENT')"
[value]="idClient()"
(ionChange)="choisir($event)"
>
@for (c of clients(); track c.id) {
<ion-select-option [value]="c.id">{{ libelle(c) }}</ion-select-option>
}
</ion-select>
}
<div class="boutons">
<ion-button fill="clear" (click)="fermer.emit()">{{ t('BOOKING_DIALOG.CANCEL') }}</ion-button>
<ion-button [disabled]="envoiEnCours()" (click)="confirmer.emit(idClient())">
{{ t('BOOKING_DIALOG.CONFIRM') }}
</ion-button>
</div>
|
- lignes 10-11 : un patient réserve pour lui-même : son nom, sans liste ;
- lignes 14-26 : l'ADMIN ou le médecin choisissent le patient dans une liste déroulante ; [placeholder] : le texte affiché tant qu'aucun patient n'est choisi ;
- lignes 29-34 : les boutons, alignés à droite ; « Confirmer » est désactivé pendant l'envoi.
11.13. Les écrans
Chaque écran est un composant, chargé à la demande par le routeur (cf. [app.routes.ts]), dont le gabarit commence par [ion-content] : la zone qui défile, entre la barre du haut et la zone d'erreur du gabarit.
11.13.1. src/app/pages/connexion.ts
| // =========================================================================
// src/app/pages/connexion.ts - l'écran de connexion (/login)
// -------------------------------------------------------------------------
// Même écran que dans les autres clients : login, mot de passe, captcha, et
// un second bouton "Créer un compte client" qui vérifie le MÊME captcha
// avant d'ouvrir la création de compte.
//
// /login?login=jbernard : le champ login est pré-rempli (juste après la
// création d'un compte).
// Après une connexion réussie, cet écran ne navigue PAS lui-même : la
// session change, les gardes du routeur sont réévalués (cf. app.ts), et
// c'est le garde qui quitte l'écran de connexion (vers ?redirect=... ou
// la page d'accueil du rôle, cf. app.routes.ts).
// =========================================================================
import { Component, inject, input, linkedSignal, signal } from '@angular/core';
import { Router } from '@angular/router';
import { IonButton, IonContent, ViewWillEnter } from '@ionic/angular';
import { RdvMedecinsApi } from '../api/rdvmedecins-api';
import { Captcha } from '../api/types';
import { ChampCaptcha } from '../composants/champ-captcha';
import { ChampSaisie } from '../composants/champ-saisie';
import { EnvoiFormulaire } from '../composants/envoi-formulaire';
import { Messages } from '../etat/messages';
import { Session } from '../etat/session';
import { Traductions } from '../i18n/traductions';
// les comptes de démonstration
const comptesDemo = [
{ comptes: 'admin', cle: 'LOGIN.DEMO_ADMIN' },
{ comptes: 'pelissier, tatou, dupont', cle: 'LOGIN.DEMO_DOCTOR' },
{ comptes: 'martin, durand, bistrou, german', cle: 'LOGIN.DEMO_USER' },
];
@Component({
selector: 'app-connexion',
imports: [IonContent, IonButton, ChampSaisie, ChampCaptcha],
templateUrl: './connexion.html',
})
export class PageConnexion implements ViewWillEnter {
private readonly api = inject(RdvMedecinsApi);
private readonly session = inject(Session);
private readonly router = inject(Router);
private readonly messages = inject(Messages);
readonly t = inject(Traductions).t;
readonly envoi = new EnvoiFormulaire(this.messages);
readonly comptesDemo = comptesDemo;
// le login de l'adresse (?login=...), donné par le routeur
// (withComponentInputBinding, cf. main.ts)
readonly login = input<string>();
// la saisie : un signal LIÉ à l'entrée login. Il démarre avec elle (30
// caractères au plus), est modifiable ensuite, et repart d'elle si
// l'adresse change
readonly saisie = linkedSignal(() => ({ login: (this.login() ?? '').slice(0, 30), password: '', captcha: '' }));
readonly captcha = signal<Captcha | null>(null);
// ionViewWillEnter : appelée par Ionic chaque fois que l'écran va
// s'afficher (la première fois, et quand on y revient)
ionViewWillEnter(): void {
void this.rechargerCaptcha();
}
// une nouvelle image : il le faut après CHAQUE tentative, car une image ne
// sert qu'UNE fois (le serveur l'oublie dès qu'il l'a vérifiée)
async rechargerCaptcha(): Promise<void> {
try {
this.captcha.set(await this.api.captcha());
} catch {
this.captcha.set(null); // serveur injoignable : la connexion échouera avec un message clair
}
}
modifier(champ: 'login' | 'password' | 'captcha', valeur: string): void {
// un NOUVEL objet : le signal change de valeur
this.saisie.update((s) => ({ ...s, [champ]: valeur }));
this.envoi.effacerErreur(champ);
}
// les deux boutons partagent ce déroulement : envoi, puis, en cas d'échec,
// message d'erreur et NOUVELLE image
private async tenter(action: () => Promise<void>): Promise<void> {
this.messages.effacer();
const reussi = await this.envoi.envoyer(action);
if (!reussi) {
this.saisie.update((s) => ({ ...s, captcha: '' }));
await this.rechargerCaptcha();
}
}
// en cas de succès, rien d'autre à faire ici : le garde change d'écran
seConnecter(): Promise<void> {
const s = this.saisie();
return this.tenter(() => this.session.connecter(s.login.trim(), s.password, this.captcha()?.id ?? '', s.captcha));
}
creerCompte(): Promise<void> {
return this.tenter(async () => {
await this.api.accesInscription(this.captcha()?.id ?? '', this.saisie().captcha);
await this.router.navigateByUrl('/inscription');
});
}
}
|
L'écran de connexion.
- lignes 29-33 : les comptes de démonstration, affichés sous le formulaire ;
- lignes 41-47 : les services (reçus par [inject()]), l'envoi du formulaire, les comptes de démonstration ;
- ligne 51 : le login de l'adresse ([/login?login=jbernard], après la création d'un compte), donné à cette entrée par le routeur ;
- ligne 55 : la saisie : un objet (nom du champ -> valeur) dans un [linkedSignal] : un signal modifiable, mais LIÉ à un calcul. Il DÉMARRE avec la valeur calculée (le login de l'adresse, 30 caractères au plus), on peut le modifier ensuite, et il repart du calcul si l'entrée [login] change. On ne peut pas lire une entrée dans l'initialisation d'un champ (elle n'a pas encore reçu sa valeur) : le [linkedSignal] résout la question ;
- ligne 56 : le captcha reçu (son identifiant et son image) ;
- lignes 60-62 : [ionViewWillEnter] : une méthode qu'[Ionic] appelle chaque fois que l'écran va s'afficher, la première fois ET quand on y revient (l'écran, gardé en mémoire, n'est pas recréé). On y demande une nouvelle image : celle de la visite précédente a peut-être déjà servi ;
- lignes 66-72 : une nouvelle image, à l'ouverture de l'écran, sur demande, et après CHAQUE tentative (une image ne sert qu'une fois) ;
- lignes 74-78 : un champ modifié : un NOUVEL objet ([{ ...s, [champ]: valeur }] : une copie, dont un champ change), et l'erreur du champ disparaît. Le paramètre [champ] n'accepte que les trois noms de la saisie : une faute de frappe serait une erreur de compilation ;
- lignes 82-89 : les deux boutons partagent ce déroulement : envoi, puis, en cas d'échec, captcha effacé et nouvelle image ;
- lignes 92-95 : la connexion. En cas de succès, l'écran ne fait RIEN d'autre : la session change, les gardes sont réévalués, et c'est le garde qui quitte l'écran de connexion (cf. [app.routes.ts]) ;
- lignes 97-102 : « Créer un compte client » : le serveur vérifie le captcha et délivre un laissez-passer (un cookie) ; on ouvre alors l'écran de création de compte.
11.13.2. src/app/pages/connexion.html
| <!-- ======================================================================
connexion.html - l'écran de connexion
====================================================================== -->
<ion-content class="ion-padding">
<h1>{{ t('LOGIN.TITLE') }}</h1>
<!-- autocomplete : le gestionnaire de mots de passe du téléphone peut
remplir le formulaire -->
<app-champ-saisie
libelle="LOGIN.LOGIN_LABEL"
autocomplete="username"
[valeur]="saisie().login"
(valeurChange)="modifier('login', $event)"
[erreur]="envoi.erreurs()['login']"
/>
<app-champ-saisie
libelle="LOGIN.PASSWORD_LABEL"
autocomplete="current-password"
[secret]="true"
[valeur]="saisie().password"
(valeurChange)="modifier('password', $event)"
[erreur]="envoi.erreurs()['password']"
/>
<app-champ-captcha
[svg]="captcha()?.svg"
[valeur]="saisie().captcha"
(valeurChange)="modifier('captcha', $event)"
(recharger)="rechargerCaptcha()"
[erreur]="envoi.erreurs()['captcha']"
/>
<!-- expand="block" : toute la largeur -->
<ion-button expand="block" shape="round" [disabled]="envoi.enCours()" (click)="seConnecter()">
{{ t('LOGIN.SUBMIT') }}
</ion-button>
<ion-button expand="block" shape="round" fill="outline" [disabled]="envoi.enCours()" (click)="creerCompte()">
{{ t('LOGIN.CREATE_ACCOUNT') }}
</ion-button>
<p class="ion-text-center aide">{{ t('LOGIN.CREATE_ACCOUNT_HELP') }}</p>
<!-- les comptes de démonstration -->
<div class="demo">
<div>{{ t('LOGIN.DEMO_ACCOUNTS') }}</div>
@for (d of comptesDemo; track d.cle) {
<div>• <code>{{ d.comptes }}</code> - {{ t(d.cle) }}</div>
}
</div>
</ion-content>
|
- lignes 10-24 : le login et le mot de passe. [autocomplete] : le gestionnaire de mots de passe du téléphone les remplit, et propose d'enregistrer le compte après la connexion ;
- lignes 25-31 : le captcha ; son image arrive du serveur après l'affichage de l'écran : [captcha()?.svg] vaut [undefined] jusque-là ;
- lignes 34-39 : les deux boutons, sur toute la largeur ([expand="block"]), désactivés pendant l'envoi ;
- lignes 43-48 : les comptes de démonstration : une boucle [@for] (cf. exemple 03) sur le tableau de [connexion.ts] ; [track d.cle] : ce qui identifie chaque élément.
11.13.3. src/app/pages/agenda.ts
| // =========================================================================
// src/app/pages/agenda.ts - l'écran de l'agenda (/agenda)
// -------------------------------------------------------------------------
// Trois blocs, comme dans les autres clients :
// 1. le choix du médecin et du jour ;
// 2. l'agenda de ce médecin ce jour-là ;
// 3. la fenêtre de réservation d'un créneau (un <ion-modal>).
//
// TOUT l'état de l'écran est dans son ADRESSE :
// /agenda?idMedecin=1&jour=2026-10-05 l'agenda
// /agenda?idMedecin=1&jour=2026-10-05&reserver=7 + la fenêtre de réservation
// Le routeur donne ces trois paramètres aux entrées du composant
// (withComponentInputBinding) ; l'écran en DÉDUIT ce qu'il affiche (des
// signaux calculés), et les actions de l'utilisateur changent l'adresse.
// Sur le web, on peut donc recharger la page, la mettre en favori,
// utiliser Précédent / Suivant ; sur le téléphone, le bouton Retour ferme
// la fenêtre de réservation.
//
// Ce que chacun voit et peut faire est décidé par le SERVEUR (champs
// patient, moi, reservable, idRv de chaque créneau) : l'écran se contente
// de l'afficher. Un patient ne reçoit même pas le nom des autres patients.
// =========================================================================
import { Location } from '@angular/common';
import { Component, computed, effect, inject, input, linkedSignal, resource, signal } from '@angular/core';
import { Router } from '@angular/router';
import {
DatetimeCustomEvent, IonButton, IonCard, IonCardContent, IonCardHeader, IonCardSubtitle, IonCardTitle, IonContent,
IonDatetime, IonIcon, IonItem, IonLabel, IonList, IonModal, IonSelect, IonSelectOption, IonSpinner,
SelectCustomEvent,
} from '@ionic/angular';
import { addIcons } from 'ionicons';
import { calendarOutline } from 'ionicons/icons';
import { ErreurApi } from '../api/erreur-api';
import { RdvMedecinsApi } from '../api/rdvmedecins-api';
import { CreneauAgenda, libelle } from '../api/types';
import { entierPositif } from '../app.routes';
import { Confirmation } from '../composants/confirmation';
import { FenetreReservation } from '../composants/fenetre-reservation';
import { Cache, relireApresInvalidation } from '../etat/cache';
import { Messages, signalerErreurs } from '../etat/messages';
import { estGestionnaire, estPatient, Session } from '../etat/session';
import { aujourdhui, estUneDateValide, horaire, jourLong } from '../formatage';
import { Traductions } from '../i18n/traductions';
// l'adresse de l'agenda (sans fenêtre de réservation, ou avec)
export function adresseAgenda(idMedecin: number | undefined, jour: string, reserver?: number): string {
const parametres = new URLSearchParams({ idMedecin: String(idMedecin ?? ''), jour });
if (reserver !== undefined) parametres.set('reserver', String(reserver));
return `/agenda?${parametres}`;
}
@Component({
selector: 'app-agenda',
imports: [
IonContent, IonCard, IonCardContent, IonCardHeader, IonCardTitle, IonCardSubtitle, IonSelect, IonSelectOption,
IonItem, IonLabel, IonIcon, IonButton, IonList, IonSpinner, IonModal, IonDatetime, FenetreReservation,
],
templateUrl: './agenda.html',
styleUrl: './agenda.scss',
})
export class PageAgenda {
private readonly api = inject(RdvMedecinsApi);
private readonly cache = inject(Cache);
private readonly messages = inject(Messages);
private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly confirmation = inject(Confirmation);
private readonly traductions = inject(Traductions);
readonly t = this.traductions.t;
readonly utilisateur = inject(Session).utilisateur;
readonly horaire = horaire;
readonly libelle = libelle;
// les paramètres de l'adresse, tels quels (des textes, peut-être invalides)
readonly idMedecin = input<string>();
readonly jour = input<string>();
readonly reserver = input<string>();
// ce qu'on en déduit : des signaux CALCULÉS (computed), recalculés quand
// l'adresse change
readonly idMedecinAdresse = computed(() => entierPositif(this.idMedecin()));
private readonly idReserver = computed(() => entierPositif(this.reserver()));
// le jour : celui de l'adresse s'il est valide, sinon aujourd'hui
readonly jourInvalide = computed(() => {
const jour = this.jour();
return jour !== undefined && !estUneDateValide(jour);
});
readonly jourAffiche = computed(() => (this.jourInvalide() ? aujourdhui() : (this.jour() ?? aujourdhui())));
// ------------------------------------------------ les données
// resource : une lecture asynchrone, décrite par des signaux. loader est
// rappelée chaque fois que params change ; un params undefined : rien à lire
readonly medecins = resource({
loader: () => this.cache.lire('medecins', () => this.api.lister('medecins')),
});
// les patients : seulement pour ceux qui ont le droit de les lire (fenêtre de réservation)
readonly clients = resource({
params: () => (estGestionnaire(this.utilisateur()) ? true : undefined),
loader: () => this.cache.lire('clients', () => this.api.lister('clients')),
});
// sans médecin dans l'adresse, un DOCTOR voit SON agenda (le serveur le
// retrouve à partir du jeton) ; les autres ne voient rien
readonly agenda = resource({
params: () => {
const id = this.idMedecinAdresse();
if (this.jourInvalide() || (id === undefined && this.utilisateur()?.role !== 'DOCTOR')) return undefined;
return { id, jour: this.jourAffiche() };
},
loader: ({ params: { id, jour } }) => this.cache.lire(`agenda/${id}/${jour}`, () => this.api.agenda(id, jour)),
});
// l'agenda reçu (undefined : pas encore arrivé, en échec, ou pas d'agenda)
readonly a = computed(() => (this.agenda.hasValue() ? this.agenda.value() : undefined));
// le médecin affiché : celui de l'adresse, ou celui de l'agenda reçu (DOCTOR)
readonly idMedecinAffiche = computed(() => this.idMedecinAdresse() ?? this.a()?.medecin.id);
// le créneau dont la fenêtre de réservation est ouverte (&reserver=7),
// seulement s'il est réservable par cet utilisateur
readonly aReserver = computed(() => this.a()?.creneaux.find((c) => c.id === this.idReserver() && c.reservable));
// patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de
// bouton "Réserver" ; inutile dans l'agenda qui contient ce rendez-vous
readonly info = computed(() => {
const a = this.a();
return a?.info && !a.creneaux.some((c) => c.moi) ? a.info : undefined;
});
// USER : le nom du patient, affiché dans la fenêtre de réservation
readonly patient = computed(() => {
const u = this.utilisateur();
return estPatient(u) ? u?.nom : undefined;
});
// ------------------------------------------------ 1. le choix du médecin et du jour
// un petit formulaire, dont l'état ne sert qu'avant l'envoi. linkedSignal :
// il DÉMARRE avec les valeurs de l'adresse, et y revient quand elle change
readonly choixMedecin = linkedSignal(() => this.idMedecinAffiche());
readonly choixJour = linkedSignal(() => this.jourAffiche());
readonly calendrierOuvert = signal(false);
// la langue du calendrier (ion-datetime) : 'fr-FR' ou 'en-US'
readonly locale = computed(() => (this.traductions.langue() === 'fr' ? 'fr-FR' : 'en-US'));
// le médecin choisi doit être dans la liste (sinon : aucun)
readonly idConnu = computed(() => {
const id = this.choixMedecin();
const liste = this.medecins.hasValue() ? this.medecins.value() : [];
return liste.some((m) => m.id === id) ? id : undefined;
});
readonly envoiEnCours = signal(false);
// vrai si la fenêtre a été ouverte par le bouton "Réserver" de cet écran
private ouverteIci = false;
constructor() {
addIcons({ calendarOutline });
// des données invalidées (réservation, annulation) : on les relit
relireApresInvalidation(this.medecins, this.clients, this.agenda);
// une lecture en échec : son message, en bas de l'écran
signalerErreurs(this.medecins, this.clients, this.agenda);
// un jour invalide dans l'adresse : un message, affiché APRÈS la fin de
// la navigation en cours (setTimeout), qui efface les messages de
// l'écran précédent (cf. app.ts)
effect(() => {
if (this.jourInvalide()) setTimeout(() => this.messages.afficherErreur('ERRORS.INVALID_DATE'));
});
}
jourLong(jour: string): string {
return jourLong(jour, this.traductions.langue());
}
choisirMedecin(evenement: SelectCustomEvent<number>): void {
this.choixMedecin.set(evenement.detail.value);
}
// le calendrier : "2026-10-05" (ou "2026-10-05T00:00:00") -> "2026-10-05"
choisirJour(evenement: DatetimeCustomEvent): void {
const valeur = evenement.detail.value;
if (typeof valeur === 'string') this.choixJour.set(valeur.slice(0, 10));
this.calendrierOuvert.set(false);
}
// "Voir l'agenda" change l'adresse ; le routeur RÉUTILISE ce composant
// (même route) : seules ses entrées changent, et tout se recalcule
voir(): void {
void this.router.navigateByUrl(adresseAgenda(this.idConnu(), this.choixJour()));
}
// "Réserver" change l'ADRESSE (&reserver=...) : la fenêtre s'ouvre
ouvrirReservation(c: CreneauAgenda): void {
this.ouverteIci = true;
void this.router.navigateByUrl(adresseAgenda(this.idMedecinAffiche(), this.jourAffiche(), c.id));
}
// fermer la fenêtre : revenir à l'adresse précédente
fermerReservation(): void {
// l'adresse n'a déjà plus de &reserver= (bouton Précédent du navigateur) : rien à faire
if (this.idReserver() === undefined) return;
if (this.ouverteIci) {
// comme le bouton Retour : l'adresse avec &reserver= est retirée de l'historique
this.ouverteIci = false;
this.location.back();
} else {
// écran ouvert directement sur cette adresse : on la remplace
void this.router.navigateByUrl(adresseAgenda(this.idMedecinAffiche(), this.jourAffiche()), { replaceUrl: true });
}
}
// c'est le serveur qui fait foi : après chaque action, on relit l'agenda
// (et l'espace du patient, qui montre son rendez-vous). L'invalidation
// prévient tous les écrans d'agenda vivants, dont celui-ci
private recharger(): void {
this.cache.invalider('mon-espace');
this.cache.invalider('agenda');
}
async confirmerReservation(creneau: CreneauAgenda, idClient: number | undefined): Promise<void> {
const gestionnaire = estGestionnaire(this.utilisateur());
this.envoiEnCours.set(true);
try {
await this.api.reserver(this.jourAffiche(), creneau.id, gestionnaire ? idClient : undefined);
this.messages.flash({ cle: 'AGENDA.BOOKED' });
} catch (e) {
if (!(e instanceof ErreurApi)) throw e;
if (e.cle === 'ERRORS.CHOOSE_PATIENT') {
// aucun patient choisi : la fenêtre reste ouverte
this.messages.afficherErreur(e.cle);
return; // (finally s'exécute quand même)
}
// créneau pris entre-temps, patient qui a déjà un rendez-vous... : le
// message s'affichera sur l'agenda, fenêtre fermée
this.messages.flash({ succes: false, cle: e.cle, params: e.params });
} finally {
this.envoiEnCours.set(false);
}
this.fermerReservation();
this.recharger();
}
async annuler(c: CreneauAgenda): Promise<void> {
if (c.idRv === null || !(await this.confirmation.demander('AGENDA.CANCEL_CONFIRM'))) return;
try {
await this.api.annulerRv(c.idRv);
this.messages.afficherSucces('AGENDA.CANCELLED');
} catch (e) {
this.messages.afficherErreurApi(e);
}
this.recharger();
}
allerMonEspace(): void {
void this.router.navigateByUrl('/mon-espace');
}
}
|
L'écran le plus riche : le choix du médecin et du jour, l'agenda, la fenêtre de réservation.
- lignes 47-51 : l'adresse de l'agenda. TOUT l'état de l'écran est dans son adresse ; les actions de l'utilisateur la changent, et l'écran en déduit ce qu'il affiche ;
- lignes 76-78 : les trois paramètres de l'adresse, donnés aux entrées par le routeur. Ce sont des textes, tels quels : peut-être absents, peut-être invalides ;
- lignes 82-89 : ce qu'on en déduit : des signaux CALCULÉS, recalculés quand l'adresse change. Le jour de l'adresse, s'il est valide, sinon aujourd'hui ;
- lignes 94-96 : [resource()] : une lecture asynchrone, décrite par des signaux (c'est le [FutureBuilder] de [Flutter], en mieux). Sa fonction [loader] renvoie une [Promise] ; la ressource expose des signaux : [value()], [isLoading()], [error()], [status()]... Les médecins sont lus depuis le cache : une fois pour toute la session ;
- lignes 98-101 : [params] : un calcul, dont la ressource dépend. Chaque fois que sa valeur change, [loader] est rappelée avec elle ; s'il vaut [undefined], il n'y a rien à lire. Ici : les patients, seulement si l'utilisateur a le droit de les lire (pour la fenêtre de réservation) ;
- lignes 104-111 : l'agenda : ses paramètres sont le médecin et le jour de l'adresse. « Voir l'agenda » change l'adresse ; le routeur RÉUTILISE l'écran (même route) avec de nouvelles entrées ; [params] change, et l'agenda est relu, sans rien écrire de plus (le client [Flutter] avait besoin de [didUpdateWidget]). Sans médecin dans l'adresse, un médecin connecté voit SON agenda (le serveur le retrouve grâce au jeton) ; les autres ne voient rien ;
- ligne 114 : l'agenda reçu. [hasValue()] d'abord : lire [value()] d'une ressource en échec provoquerait une erreur ;
- ligne 119 : le créneau de la fenêtre de réservation est celui de l'adresse ([&reserver=7]), seulement s'il est réservable PAR CET UTILISATEUR (un créneau pris entre-temps, une adresse fabriquée à la main : pas de fenêtre) ;
- lignes 122-125 : le serveur envoie ce message ([info]) avec TOUS les agendas, pour un patient qui a déjà un rendez-vous à venir ; mais dans l'agenda qui contient ce rendez-vous (un créneau [moi]), il est inutile, et même trompeur juste après une réservation : le patient voit son rendez-vous, sous son nom, avec son bouton « Annuler ». On ne l'affiche donc que dans les autres agendas. [some] : vrai si au moins un élément du tableau vérifie la condition ;
- lignes 135-136 : le choix du médecin et du jour : un petit formulaire, dont l'état DÉMARRE avec les valeurs de l'adresse, et y REVIENT quand l'adresse change (bouton Retour, Précédent du navigateur) : deux [linkedSignal]. Le client [Flutter] obtenait cela en recréant le widget (sa clé changeait avec l'adresse) ;
- ligne 139 : la langue du calendrier, au format qu'attend [ion-datetime] ;
- lignes 141-145 : le médecin choisi doit être dans la liste (sinon : aucun, et « Voir l'agenda » est désactivé) ;
- lignes 154-156 : des données invalidées (réservation, annulation) : les trois ressources sont relues ; une lecture en échec : son message en bas de l'écran (cf. [cache.ts] et [messages.ts]) ;
- lignes 160-162 : un jour invalide dans l'adresse : un message. On l'affiche APRÈS la fin de la navigation en cours ([setTimeout] : quand tout le code en cours, et ses suites, est terminé) : sinon, le passage à la « nouvelle page » (cf. [app.ts]) l'effacerait aussitôt ;
- lignes 174-178 : le jour choisi dans le calendrier ; [ion-datetime] donne une date au format ISO (« 2026-10-05 », parfois suivi de l'heure) ;
- lignes 182-184 : « Voir l'agenda » change l'adresse ;
- lignes 187-190 : « Réserver » change l'adresse lui aussi ([&reserver=...]) : une NOUVELLE entrée dans l'historique de navigation ; la fenêtre de réservation s'ouvre parce que l'adresse le demande. [ouverteIci] retient que c'est cet écran qui l'a ouverte ;
- lignes 193-204 : fermer la fenêtre : revenir à l'adresse précédente. Si c'est cet écran qui l'a ouverte, on recule dans l'historique ([location.back()]), exactement comme le bouton Retour du téléphone ou Précédent du navigateur. Si l'écran a été ouvert directement sur cette adresse (sur le web), il n'y a rien derrière : on REMPLACE l'adresse par celle de l'agenda ([replaceUrl]). Si l'adresse n'a déjà plus de [&reserver=] (on vient de reculer), il n'y a rien à faire ;
- lignes 209-212 : après une réservation ou une annulation, c'est le serveur qui fait foi : on invalide l'agenda (et l'espace du patient) ; le cache prévient les écrans d'agenda, qui se relisent ;
- lignes 214-235 : la réservation. Sans patient choisi, la fenêtre reste ouverte, avec le message. Toute autre issue ferme la fenêtre, et le message (succès ou erreur : un créneau pris entre-temps...) est un message flash, qui s'affiche quand l'adresse revient à celle de l'agenda ;
- lignes 237-246 : l'annulation, après confirmation.
11.13.4. src/app/pages/agenda.html
| <!-- ======================================================================
agenda.html - l'écran de l'agenda
====================================================================== -->
<ion-content class="ion-padding">
<!-- 1. le choix du médecin et du jour -->
<ion-card class="choix">
<ion-card-content>
<ion-select
[label]="t('DOCTOR_DAY_PICKER.DOCTOR_LABEL')"
fill="outline"
labelPlacement="stacked"
interface="popover"
[placeholder]="t('DOCTOR_DAY_PICKER.CHOOSE_DOCTOR')"
[value]="idConnu()"
(ionChange)="choisirMedecin($event)"
>
@if (medecins.hasValue()) {
@for (m of medecins.value(); track m.id) {
<ion-select-option [value]="m.id">{{ libelle(m) }}</ion-select-option>
}
}
</ion-select>
<!-- le jour : une ligne qu'on touche pour ouvrir le calendrier -->
<ion-item class="jour" [button]="true" [detail]="false" lines="full" (click)="calendrierOuvert.set(true)">
<ion-label>
<p>{{ t('DOCTOR_DAY_PICKER.DAY_LABEL') }}</p>
<h3>{{ jourLong(choixJour()) }}</h3>
</ion-label>
<ion-icon slot="end" name="calendar-outline" [attr.aria-label]="t('DOCTOR_DAY_PICKER.CHOOSE_DAY')" />
</ion-item>
<ion-button expand="block" shape="round" [disabled]="idConnu() === undefined" (click)="voir()">
{{ t('DOCTOR_DAY_PICKER.VIEW_AGENDA') }}
</ion-button>
</ion-card-content>
</ion-card>
<!-- le calendrier, dans une fenêtre ; locale : la langue des noms des
jours et des mois ; showDefaultButtons : les boutons Annuler / OK -->
<ion-modal class="fenetre" [isOpen]="calendrierOuvert()" (didDismiss)="calendrierOuvert.set(false)">
<ng-template>
<ion-datetime
presentation="date"
[value]="choixJour()"
[locale]="locale()"
[firstDayOfWeek]="1"
min="2020-01-01"
max="2035-12-31"
[showDefaultButtons]="true"
[cancelText]="t('BOOKING_DIALOG.CANCEL')"
doneText="OK"
(ionChange)="choisirJour($event)"
/>
</ng-template>
</ion-modal>
<!-- patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton "Réserver" -->
@if (info(); as info) {
<ion-card class="info">
<ion-item lines="none" color="none">
<ion-label class="ion-text-wrap">{{ t(info) }}</ion-label>
<ion-button slot="end" fill="clear" (click)="allerMonEspace()">{{ t('APP.MENU_MY_SPACE') }}</ion-button>
</ion-item>
</ion-card>
}
<!-- 2. l'agenda -->
@if (a(); as a) {
<ion-card class="agenda">
<ion-card-header>
<ion-card-title>{{ t('AGENDA.TITLE_PREFIX') }} {{ jourLong(a.jour) }}</ion-card-title>
<ion-card-subtitle>{{ libelle(a.medecin) }}</ion-card-subtitle>
</ion-card-header>
@if (a.creneaux.length === 0) {
<ion-card-content>{{ t('AGENDA.NO_SLOTS') }}</ion-card-content>
}
<ion-list lines="full">
@for (c of a.creneaux; track c.id) {
<!-- les créneaux réservables sont légèrement colorés -->
<ion-item [class.reservable]="c.reservable">
<div slot="start" class="horaire">{{ horaire(c) }}</div>
<ion-label>
@if (c.libre) {
<span class="etiquette libre">{{ t('AGENDA.FREE') }}</span>
} @else if (c.patient) {
<span class="etiquette" [class.moi]="c.moi">{{ libelle(c.patient) }}</span>
} @else {
<span class="etiquette">{{ t('AGENDA.BOOKED_BY_OTHER') }}</span>
}
</ion-label>
<!-- les boutons permis par le SERVEUR (reservable, idRv) -->
@if (c.reservable) {
<ion-button slot="end" fill="outline" shape="round" (click)="ouvrirReservation(c)">
{{ t('AGENDA.BOOK') }}
</ion-button>
} @else if (c.idRv !== null) {
<ion-button slot="end" fill="outline" shape="round" color="danger" (click)="annuler(c)">
{{ t('AGENDA.CANCEL_APPOINTMENT') }}
</ion-button>
}
</ion-item>
}
</ion-list>
</ion-card>
} @else if (agenda.isLoading()) {
<div class="ion-text-center ion-padding"><ion-spinner /></div>
} @else if (agenda.status() === 'idle') {
<p class="vide">{{ t('AGENDA.EMPTY_STATE') }}</p>
}
<!-- 3. la fenêtre de réservation, ouverte seulement si l'adresse contient
&reserver=... ; elle se ferme (bouton Retour, appui à côté...) :
didDismiss -->
<ion-modal class="fenetre" [isOpen]="aReserver() !== undefined" (didDismiss)="fermerReservation()">
<ng-template>
@if (aReserver(); as creneau) {
<app-fenetre-reservation
[horaire]="horaire(creneau)"
[clients]="clients.hasValue() ? clients.value() : []"
[patient]="patient()"
[envoiEnCours]="envoiEnCours()"
(confirmer)="confirmerReservation(creneau, $event)"
(fermer)="fermerReservation()"
/>
}
</ng-template>
</ion-modal>
</ion-content>
|
- lignes 9-23 : la liste des médecins ; [labelPlacement="stacked"] : le libellé reste au-dessus du champ, pour laisser voir le texte « -- choisir un médecin -- » ([placeholder]) tant qu'aucun médecin n'est choisi. Les options attendent la liste : [@if (medecins.hasValue())] ;
- lignes 25-31 : le jour s'affiche comme un champ (son libellé au-dessus, la date en toutes lettres, une icône de calendrier), dans un [ion-item] qu'on touche pour ouvrir le calendrier ;
- lignes 32-34 : « Voir l'agenda », désactivé tant qu'aucun médecin connu n'est choisi ;
- lignes 40-55 : le calendrier, [ion-datetime], dans une fenêtre ([ion-modal]) ouverte par le signal [calendrierOuvert]. [presentation="date"] : la date seule, sans l'heure ; [locale] : la langue des noms des jours et des mois ; [firstDayOfWeek] : la semaine commence le lundi ; [showDefaultButtons] : les boutons Annuler / OK. « OK » émet [ionChange] avec la date choisie, puis ferme la fenêtre ; [didDismiss] : la fenêtre s'est fermée (OK, Annuler, appui à côté, bouton Retour) ;
- lignes 58-65 : un patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton « Réserver » (cf. [info], plus haut) ;
- lignes 68-104 : l'agenda. Une ligne par créneau : l'horaire, une étiquette (libre, le nom du patient, ou « Réservé » pour un autre patient), et le bouton permis par le SERVEUR ([reservable], [idRv]). [@else if] : la suite des cas d'un [@if] ;
- lignes 105-109 : l'agenda en route : une roue qui tourne ([ion-spinner]) ; pas d'agenda à lire (la ressource est inactive, [idle]) : l'invitation à choisir un médecin et un jour ;
- lignes 114-127 : la fenêtre de réservation, ouverte ([isOpen]) seulement si l'adresse désigne un créneau réservable. Elle peut aussi se fermer d'elle-même : bouton Retour du téléphone, appui à côté ; [didDismiss] appelle alors [fermerReservation], qui met l'adresse en accord. Son contenu est un [ng-template] : il n'est construit qu'à l'ouverture, et détruit à la fermeture (le patient choisi est oublié).
Le client [Flutter] dessinait lui-même la fenêtre de réservation (un [Stack], un fond grisé, une carte) ; [ion-modal] apporte tout cela, avec l'animation d'ouverture et la gestion du bouton Retour.
11.13.5. src/app/pages/agenda.scss
| // =========================================================================
// agenda.scss - les styles de l'écran de l'agenda
// =========================================================================
ion-card {
margin: 0 0 12px;
}
// le jour : une ligne qui ressemble aux champs de saisie
ion-item.jour {
--padding-start: 0;
--inner-padding-end: 0;
margin-bottom: 12px;
p {
font-size: 13px;
}
h3 {
font-size: 16px;
}
}
// l'information destinée au patient
ion-card.info {
--background: #e1f5fe;
}
ion-card.agenda ion-card-title {
font-size: 19px;
}
// les créneaux réservables : un fond vert pâle
ion-item.reservable {
--background: #e8f5e9;
}
.horaire {
width: 96px;
font-size: 14px;
font-weight: 500;
}
// une étiquette colorée (le "badge" des autres clients) ; gris par défaut
.etiquette {
display: inline-block;
padding: 3px 8px;
border-radius: 6px;
color: white;
background: #757575;
font-size: 13px;
font-weight: bold;
white-space: normal;
&.libre {
background: #388e3c;
}
&.moi {
background: var(--ion-color-primary);
}
}
.vide {
padding: 0 16px;
color: var(--ion-color-medium-shade);
}
|
Les styles de l'écran : le jour, l'information du patient (fond bleu pâle), les créneaux réservables (fond vert pâle), l'horaire, et les étiquettes colorées (lignes 45-62 : grises par défaut, vertes pour un créneau libre, indigo pour le rendez-vous du patient connecté).
11.13.6. src/app/pages/personnes-config.ts
| // =========================================================================
// src/app/pages/personnes-config.ts - médecins ou clients ?
// -------------------------------------------------------------------------
// Les écrans de gestion des médecins et des clients sont IDENTIQUES : même
// liste, même formulaire. On les écrit UNE fois (personnes.ts,
// personne-formulaire.ts) ; seules changent l'adresse de l'API et les
// clés des libellés, décrites ici. Le routeur passe le type à chaque écran.
// =========================================================================
import { TypePersonnes } from '../api/rdvmedecins-api';
import { Personne } from '../api/types';
export interface ClesPersonnes {
titre: string; // "Médecins"
ajouter: string; // "Ajouter un médecin"
modifier: string; // "Modifier un médecin"
vide: string; // "Aucun médecin."
confirmerSuppression: string;
ajoute: string; // "Le médecin {{nom}} a été ajouté (login : {{login}})."
modifie: string;
supprime: string;
introuvable: string; // la fiche a été supprimée entre-temps
}
// Record<TypePersonnes, ...> : un objet qui a EXACTEMENT une propriété par
// type (le compilateur vérifie qu'aucun n'est oublié)
const toutesLesCles: Record<TypePersonnes, ClesPersonnes> = {
medecins: {
titre: 'MEDECINS.TITLE',
ajouter: 'MEDECINS.ADD',
modifier: 'MEDECINS.EDIT',
vide: 'MEDECINS.EMPTY',
confirmerSuppression: 'MEDECINS.DELETE_CONFIRM',
ajoute: 'MEDECINS.ADDED',
modifie: 'MEDECINS.UPDATED',
supprime: 'MEDECINS.DELETED',
introuvable: 'ERRORS.DOCTOR_NOT_FOUND',
},
clients: {
titre: 'CLIENTS.TITLE',
ajouter: 'CLIENTS.ADD',
modifier: 'CLIENTS.EDIT',
vide: 'CLIENTS.EMPTY',
confirmerSuppression: 'CLIENTS.DELETE_CONFIRM',
ajoute: 'CLIENTS.ADDED',
modifie: 'CLIENTS.UPDATED',
supprime: 'CLIENTS.DELETED',
introuvable: 'ERRORS.PATIENT_NOT_FOUND',
},
};
export const cles = (type: TypePersonnes): ClesPersonnes => toutesLesCles[type];
// {{nom}} et {{login}} des messages "ajouté / modifié / supprimé"
export const parametres = (p: Personne): Record<string, string> => ({
nom: `${p.prenom} ${p.nom}`,
login: p.login ?? '',
});
|
Les écrans des médecins et des clients sont identiques ; seules changent les clés des libellés, décrites ici.
- lignes 13-23 : les clés d'un type de personnes, décrites par une interface ;
- lignes 27-50 : [Record<TypePersonnes, ClesPersonnes>] : un objet qui a EXACTEMENT une propriété par type de personnes. Ajouter une valeur à [TypePersonnes] produirait une erreur de compilation ici, tant que ses clés ne sont pas décrites ;
- lignes 55-58 : les paramètres des messages « ajouté », « modifié », « supprimé ».
11.13.7. src/app/pages/personnes.ts
| // =========================================================================
// src/app/pages/personnes.ts - la liste des médecins OU des clients (ADMIN)
// -------------------------------------------------------------------------
// Un seul écran pour les deux : le type (médecins ou clients) arrive en
// entrée (la propriété data de la route, cf. app.routes.ts) ; les libellés
// propres à chacun sont dans personnes-config.ts.
// Sur un téléphone, un tableau à cinq colonnes ne tient pas : une ligne de
// liste par personne (nom, login), avec ses deux boutons ; le bouton rond
// "+" (<ion-fab>) ajoute une personne.
// =========================================================================
import { Component, computed, inject, input, resource } from '@angular/core';
import { Router } from '@angular/router';
import {
IonButton, IonCard, IonContent, IonFab, IonIcon, IonItem, IonLabel, IonSpinner,
} from '@ionic/angular';
import { addIcons } from 'ionicons';
import { add, createOutline, trashOutline } from 'ionicons/icons';
import { RdvMedecinsApi, TypePersonnes } from '../api/rdvmedecins-api';
import { libelle, Personne } from '../api/types';
import { Confirmation } from '../composants/confirmation';
import { Cache, relireApresInvalidation } from '../etat/cache';
import { Messages, signalerErreurs } from '../etat/messages';
import { Traductions } from '../i18n/traductions';
import { cles, parametres } from './personnes-config';
@Component({
selector: 'app-personnes',
imports: [IonContent, IonCard, IonItem, IonLabel, IonButton, IonIcon, IonFab, IonSpinner],
templateUrl: './personnes.html',
styleUrl: './personnes.scss',
})
export class PagePersonnes {
private readonly api = inject(RdvMedecinsApi);
private readonly cache = inject(Cache);
private readonly messages = inject(Messages);
private readonly router = inject(Router);
private readonly confirmation = inject(Confirmation);
readonly t = inject(Traductions).t;
readonly libelle = libelle;
readonly type = input.required<TypePersonnes>();
readonly c = computed(() => cles(this.type()));
// la liste, depuis le cache ; params : le type (le routeur pourrait
// réutiliser l'écran pour l'autre type, /medecins -> /clients)
readonly liste = resource({
params: () => this.type(),
loader: ({ params: type }) => this.cache.lire(type, () => this.api.lister(type)),
});
constructor() {
addIcons({ add, createOutline, trashOutline });
relireApresInvalidation(this.liste);
signalerErreurs(this.liste);
}
// "Mme Marie PELISSIER" -> "MP"
initiales(p: Personne): string {
return `${p.prenom.charAt(0)}${p.nom.charAt(0)}`;
}
ajouter(): void {
void this.router.navigateByUrl(`/${this.type()}/ajouter`);
}
modifier(p: Personne): void {
void this.router.navigateByUrl(`/${this.type()}/${p.id}/modifier`);
}
async supprimer(p: Personne): Promise<void> {
const c = this.c();
if (!(await this.confirmation.demander(c.confirmerSuppression))) return;
try {
await this.api.supprimer(this.type(), p.id);
this.messages.afficherSucces(c.supprime, parametres(p));
} catch (e) {
// un médecin qui a encore des rendez-vous : 409 ERRORS.DOCTOR_HAS_APPOINTMENTS
this.messages.afficherErreurApi(e);
}
// la liste est relue (relireApresInvalidation)
this.cache.invalider(this.type());
}
}
|
La liste des médecins ou des clients.
- ligne 42 : le type (médecins ou clients) : la propriété [data] de la route, donnée à cette entrée ;
- lignes 47-50 : la liste, depuis le cache. Si le routeur réutilisait l'écran pour l'autre type, [params] changerait, et l'autre liste serait lue ;
- lignes 63-69 : l'ajout et la modification ouvrent le formulaire, par son adresse ;
- lignes 71-83 : la suppression, après confirmation. Un médecin qui a des rendez-vous est refusé par le serveur (409 [DOCTOR_HAS_APPOINTMENTS]). Dans tous les cas, la liste est invalidée, et relue.
11.13.8. src/app/pages/personnes.html
| <!-- ======================================================================
personnes.html - la liste des médecins ou des clients
====================================================================== -->
<ion-content class="ion-padding">
<h1>{{ t(c().titre) }}</h1>
@if (liste.hasValue()) {
@for (p of liste.value(); track p.id) {
<ion-card>
<ion-item lines="none">
<!-- les initiales, dans un rond -->
<div slot="start" class="initiales">{{ initiales(p) }}</div>
<ion-label>
<h2>{{ libelle(p) }}</h2>
<p class="login">{{ p.login }}</p>
</ion-label>
<ion-button slot="end" fill="clear" color="medium" [title]="t('PERSONNES.EDIT')" (click)="modifier(p)">
<ion-icon slot="icon-only" name="create-outline" />
</ion-button>
<ion-button slot="end" fill="clear" color="danger" [title]="t('PERSONNES.DELETE')" (click)="supprimer(p)">
<ion-icon slot="icon-only" name="trash-outline" />
</ion-button>
</ion-item>
</ion-card>
} @empty {
<p>{{ t(c().vide) }}</p>
}
} @else if (liste.isLoading()) {
<!-- pas encore chargée (ou chargement en échec : le message est en bas de l'écran) -->
<div class="ion-text-center ion-padding"><ion-spinner /></div>
}
<!-- ion-fab : un bouton flottant, fixé (slot="fixed") en bas à droite,
au-dessus de la liste qui défile -->
<ion-fab slot="fixed" vertical="bottom" horizontal="end">
<ion-button shape="round" size="large" class="fab-etendu" (click)="ajouter()">
<ion-icon slot="start" name="add" />
{{ t(c().ajouter) }}
</ion-button>
</ion-fab>
</ion-content>
|
- lignes 9-28 : une carte par personne : ses initiales dans un rond, son nom, son login, et ses deux boutons. Un tableau à cinq colonnes, celui des clients web, ne tiendrait pas sur un téléphone. [@empty] : ce qu'affiche un [@for] sur un tableau vide ;
- lignes 36-41 : [ion-fab] : un bouton flottant, fixé en bas à droite ([slot="fixed"] : il ne défile pas avec la liste), « + Ajouter un médecin ». Le [FloatingActionButton.extended] de [Flutter] (une icône et un texte) n'a pas d'équivalent tout fait dans [Ionic] ([ion-fab-button] est rond, pour une icône seule) : on place un bouton arrondi ordinaire dans le [ion-fab].
11.13.9. src/app/pages/personnes.scss
| // =========================================================================
// personnes.scss - les styles de la liste des médecins ou des clients
// =========================================================================
ion-card {
margin: 0 0 10px;
}
// les initiales, dans un rond coloré
.initiales {
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #dde3fb;
color: var(--ion-color-primary);
font-weight: 500;
}
.login {
font-family: 'Roboto Mono', monospace;
}
// de la place en bas, pour ne pas cacher la dernière ligne sous le bouton flottant
ion-content {
--padding-bottom: 96px;
}
// un bouton flottant "étendu" : icône et texte, sur un fond clair
.fab-etendu {
--background: #dde3fb;
--color: var(--ion-color-primary);
--border-radius: 16px;
text-transform: none;
}
|
Les initiales dans un rond, le login en police à chasse fixe, de la place sous la liste (lignes 27-29 : sinon le bouton flottant cacherait la dernière carte), et le bouton flottant « étendu ».
11.13.10. src/app/pages/personne-formulaire.ts
| // =========================================================================
// src/app/pages/personne-formulaire.ts - création OU modification d'un
// médecin ou d'un client (ADMIN)
// -------------------------------------------------------------------------
// /medecins/ajouter -> création (pas d'id)
// /medecins/3/modifier -> modification (id = 3)
// (et les mêmes adresses sous /clients).
//
// En modification, l'écran lit d'abord la fiche ; le formulaire n'est
// affiché qu'une fois la fiche lue, et DÉMARRE avec ses valeurs (un
// linkedSignal, sans avoir à les recopier plus tard).
// Aucune règle n'est vérifiée ici : le serveur valide tout, et ses erreurs
// s'affichent sous les champs (cf. composants/envoi-formulaire.ts).
// La VERSION lue avec la fiche repart avec la modification : si un autre
// ADMIN l'a modifiée entre-temps, le serveur répond 409 CONCURRENT_UPDATE.
// =========================================================================
import { Component, computed, effect, inject, input, linkedSignal, resource, untracked } from '@angular/core';
import { Router } from '@angular/router';
import { IonButton, IonContent, IonSpinner } from '@ionic/angular';
import { ErreurApi } from '../api/erreur-api';
import { Donnees, RdvMedecinsApi, TypePersonnes } from '../api/rdvmedecins-api';
import { entierPositif } from '../app.routes';
import { ChampSaisie } from '../composants/champ-saisie';
import { ChampsPersonne } from '../composants/champs-personne';
import { EnvoiFormulaire } from '../composants/envoi-formulaire';
import { Cache } from '../etat/cache';
import { Messages } from '../etat/messages';
import { Traductions } from '../i18n/traductions';
import { cles, parametres } from './personnes-config';
const vide: Donnees = { titre: 'Mr', prenom: '', nom: '', login: '', password: '' };
@Component({
selector: 'app-personne-formulaire',
imports: [IonContent, IonButton, IonSpinner, ChampSaisie, ChampsPersonne],
templateUrl: './personne-formulaire.html',
})
export class PagePersonneFormulaire {
private readonly api = inject(RdvMedecinsApi);
private readonly cache = inject(Cache);
private readonly messages = inject(Messages);
private readonly router = inject(Router);
readonly t = inject(Traductions).t;
readonly envoi = new EnvoiFormulaire(this.messages);
// le type : la propriété data de la route ; l'id : le paramètre :id de
// l'adresse (absent en création ; sa validité est vérifiée par la route)
readonly type = input.required<TypePersonnes>();
readonly id = input<string>();
readonly idFiche = computed(() => entierPositif(this.id()));
readonly creation = computed(() => this.idFiche() === undefined);
readonly c = computed(() => cles(this.type()));
// modification : on lit la fiche (dont sa version), SANS cache : elle
// doit être à jour (sinon le formulaire démarrerait avec une version
// périmée : 409 CONCURRENT_UPDATE)
readonly fiche = resource({
params: () => {
const id = this.idFiche();
return id === undefined ? undefined : { type: this.type(), id };
},
loader: ({ params: { type, id } }) => this.api.lire(type, id),
});
// une COPIE modifiable des valeurs de départ (la fiche lue, ou vide)
readonly saisie = linkedSignal<Donnees>(() => {
if (!this.fiche.hasValue()) return { ...vide };
const p = this.fiche.value();
return { titre: p.titre, prenom: p.prenom, nom: p.nom, login: p.login ?? '', password: '', version: p.version };
});
// le formulaire s'affiche en création, ou une fois la fiche lue
readonly pret = computed(() => this.creation() || this.fiche.hasValue());
constructor() {
// fiche inexistante (supprimée entre-temps...) : retour à la liste
effect(() => {
const e = this.fiche.error();
if (e instanceof ErreurApi) {
untracked(() => {
this.messages.flash({ succes: false, cle: e.cle, params: e.params });
void this.router.navigateByUrl(`/${this.type()}`);
});
}
});
}
texte(champ: string): string {
return String(this.saisie()[champ] ?? '');
}
modifier(champ: string, valeur: string): void {
this.saisie.update((s) => ({ ...s, [champ]: valeur }));
this.envoi.effacerErreur(champ);
}
retour(): void {
void this.router.navigateByUrl(`/${this.type()}`);
}
async enregistrer(): Promise<void> {
const type = this.type();
const c = this.c();
const id = this.idFiche();
const reussi = await this.envoi.envoyer(async () => {
const p = id === undefined
? await this.api.ajouter(type, this.saisie())
: await this.api.modifier(type, id, this.saisie());
this.messages.flash({ cle: this.creation() ? c.ajoute : c.modifie, params: parametres(p) });
});
const cle = this.messages.erreur()?.cle;
if (reussi) {
this.cache.invalider(type); // la liste a changé
this.retour();
} else if (cle === c.introuvable) {
// supprimé entre-temps par un autre ADMIN : retour à la liste
this.messages.flash({ succes: false, cle });
this.retour();
}
}
}
|
L'ajout ou la modification d'un médecin ou d'un client.
- lignes 49-53 : le type ([data] de la route) et l'identifiant (le paramètre [:id] de l'adresse, absent pour un ajout) ;
- lignes 58-64 : pour une modification, on lit la fiche, SANS cache : sa version doit être à jour. Pour un ajout, [params] vaut [undefined] : rien à lire ;
- lignes 67-71 : la saisie DÉMARRE avec les valeurs de la fiche (ou des valeurs vides), une fois la fiche lue : un [linkedSignal], encore. Le client [Flutter] obtenait cela avec deux widgets (le second, créé une fois la fiche lue) ;
- ligne 74 : le formulaire n'est affiché qu'en création, ou une fois la fiche lue ;
- lignes 78-86 : une fiche introuvable (supprimée entre-temps) : retour à la liste, avec un message ;
- lignes 102-121 : l'enregistrement : [POST] ou [PUT], selon qu'il y a un identifiant. Le message de succès est un message flash : il s'affichera sur la liste. Une fiche supprimée entre-temps par un autre ADMIN : retour à la liste.
11.13.11. src/app/pages/personne-formulaire.html
| <!-- ======================================================================
personne-formulaire.html - création ou modification d'une personne
====================================================================== -->
<ion-content class="ion-padding">
<h1>{{ t(creation() ? c().ajouter : c().modifier) }}</h1>
@if (pret()) {
<app-champs-personne
[valeurs]="saisie()"
[erreurs]="envoi.erreurs()"
(modification)="modifier($event.champ, $event.valeur)"
/>
<h3>{{ t('PERSONNES.ACCOUNT_SECTION') }}</h3>
<app-champ-saisie
libelle="PERSONNES.LOGIN_LABEL"
[longueurMax]="30"
[valeur]="texte('login')"
(valeurChange)="modifier('login', $event)"
[erreur]="envoi.erreurs()['login']"
[aide]="creation() ? 'PERSONNES.LOGIN_HELP_CREATE' : 'PERSONNES.LOGIN_HELP_EDIT'"
/>
<!-- jamais pré-rempli : le mot de passe ne revient jamais du serveur -->
<app-champ-saisie
libelle="PERSONNES.PASSWORD_LABEL"
[secret]="true"
[longueurMax]="72"
[valeur]="texte('password')"
(valeurChange)="modifier('password', $event)"
[erreur]="envoi.erreurs()['password']"
[params]="envoi.params()"
[aide]="creation() ? 'PERSONNES.PASSWORD_HELP_CREATE' : 'PERSONNES.PASSWORD_HELP_EDIT'"
/>
<div class="boutons">
<ion-button shape="round" [disabled]="envoi.enCours()" (click)="enregistrer()">
{{ t('PERSONNES.SAVE') }}
</ion-button>
<ion-button shape="round" fill="outline" (click)="retour()">{{ t('PERSONNES.BACK') }}</ion-button>
</div>
} @else {
<div class="ion-text-center ion-padding"><ion-spinner /></div>
}
</ion-content>
|
- lignes 9-13 : les champs d'identité (cf. [champs-personne.ts]) ;
- lignes 16-34 : le login et le mot de passe, avec une aide différente en création et en modification (le mot de passe laissé vide ne change pas). Le mot de passe n'est jamais pré-rempli : le serveur ne le renvoie jamais. [params] : les paramètres des messages d'erreur du mot de passe ([{{min}}]...).
11.13.12. src/app/pages/inscription.ts
| // =========================================================================
// src/app/pages/inscription.ts - "Créer un compte client" (/inscription)
// -------------------------------------------------------------------------
// On n'arrive ici qu'après avoir recopié le captcha de l'écran de connexion
// (bouton "Créer un compte client") : le serveur a alors délivré un
// laissez-passer (un cookie), que le garde de la route a vérifié (cf.
// app.routes.ts). Mêmes champs que le formulaire de l'ADMIN, mais le login
// et le mot de passe sont obligatoires, avec confirmation.
// =========================================================================
import { Component, computed, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { IonButton, IonContent } from '@ionic/angular';
import { Donnees, RdvMedecinsApi } from '../api/rdvmedecins-api';
import { ChampSaisie } from '../composants/champ-saisie';
import { ChampsPersonne } from '../composants/champs-personne';
import { EnvoiFormulaire } from '../composants/envoi-formulaire';
import { Messages } from '../etat/messages';
import { Traductions } from '../i18n/traductions';
// longueur minimale d'un mot de passe choisi par un patient : même valeur
// que le serveur (qui renvoie aussi {min} avec l'erreur PASSWORD_TOO_SHORT)
const motDePasseMin = '6';
@Component({
selector: 'app-inscription',
imports: [IonContent, IonButton, ChampSaisie, ChampsPersonne],
templateUrl: './inscription.html',
})
export class PageInscription {
private readonly api = inject(RdvMedecinsApi);
private readonly messages = inject(Messages);
private readonly router = inject(Router);
readonly t = inject(Traductions).t;
readonly envoi = new EnvoiFormulaire(this.messages);
readonly saisie = signal<Donnees>({
titre: 'Mr',
prenom: '',
nom: '',
login: '',
password: '',
confirmation: '',
});
// les paramètres du message d'aide ET des erreurs du mot de passe
readonly paramsPassword = computed(() => ({ min: motDePasseMin, ...this.envoi.params() }));
texte(champ: string): string {
return String(this.saisie()[champ] ?? '');
}
modifier(champ: string, valeur: string): void {
this.saisie.update((s) => ({ ...s, [champ]: valeur }));
this.envoi.effacerErreur(champ);
}
retourConnexion(): void {
void this.router.navigateByUrl('/login');
}
async creer(): Promise<void> {
let login = '';
const reussi = await this.envoi.envoyer(async () => {
login = await this.api.inscrire(this.saisie());
});
if (reussi) {
// retour à l'écran de connexion, login pré-rempli
this.messages.flash({ cle: 'INSCRIPTION.DONE', params: { login } });
void this.router.navigate(['/login'], { queryParams: { login } });
} else if (this.messages.erreur()?.cle === 'ERRORS.CAPTCHA_BEFORE_ACCOUNT') {
// laissez-passer expiré (15 minutes) : retour au captcha
this.messages.flash({ succes: false, cle: 'ERRORS.CAPTCHA_BEFORE_ACCOUNT' });
this.retourConnexion();
}
}
}
|
La création d'un compte de patient, accessible avec le laissez-passer délivré par l'écran de connexion (cf. le garde de la route, dans [app.routes.ts]).
- ligne 23 : la longueur minimale d'un mot de passe, pour le message d'aide : la même valeur que le serveur (qui la renvoie aussi avec l'erreur [PASSWORD_TOO_SHORT]) ;
- ligne 47 : les paramètres du message d'aide et de l'erreur du mot de passe ([{{min}}]) : ceux du serveur, s'il y en a, l'emportent ;
- lignes 62-76 : la création. Réussie : retour à l'écran de connexion, login pré-rempli ([/login?login=...] : [queryParams] construit le paramètre), avec un message flash. Refusée parce que le laissez-passer a expiré (15 minutes) : retour au captcha. Toute autre erreur reste affichée sous les champs.
11.13.13. src/app/pages/inscription.html
| <!-- ======================================================================
inscription.html - "Créer un compte client"
====================================================================== -->
<ion-content class="ion-padding">
<h1>{{ t('INSCRIPTION.TITLE') }}</h1>
<p class="intro">{{ t('INSCRIPTION.INTRO') }}</p>
<app-champs-personne
[valeurs]="saisie()"
[erreurs]="envoi.erreurs()"
(modification)="modifier($event.champ, $event.valeur)"
/>
<h3>{{ t('PERSONNES.ACCOUNT_SECTION') }}</h3>
<app-champ-saisie
libelle="PERSONNES.LOGIN_LABEL"
autocomplete="username"
[longueurMax]="30"
[valeur]="texte('login')"
(valeurChange)="modifier('login', $event)"
[erreur]="envoi.erreurs()['login']"
aide="INSCRIPTION.LOGIN_HELP"
/>
<app-champ-saisie
libelle="PERSONNES.PASSWORD_LABEL"
autocomplete="new-password"
[secret]="true"
[longueurMax]="72"
[valeur]="texte('password')"
(valeurChange)="modifier('password', $event)"
[erreur]="envoi.erreurs()['password']"
[params]="paramsPassword()"
aide="INSCRIPTION.PASSWORD_HELP"
/>
<app-champ-saisie
libelle="COMPTE.CONFIRMATION_LABEL"
autocomplete="new-password"
[secret]="true"
[longueurMax]="72"
[valeur]="texte('confirmation')"
(valeurChange)="modifier('confirmation', $event)"
[erreur]="envoi.erreurs()['confirmation']"
/>
<div class="boutons">
<ion-button shape="round" [disabled]="envoi.enCours()" (click)="creer()">
{{ t('INSCRIPTION.SUBMIT') }}
</ion-button>
<ion-button shape="round" fill="outline" (click)="retourConnexion()">
{{ t('INSCRIPTION.BACK_TO_LOGIN') }}
</ion-button>
</div>
</ion-content>
|
- lignes 16-44 : un NOUVEAU compte : [autocomplete="new-password"] invite le gestionnaire de mots de passe à proposer un mot de passe robuste, et à l'enregistrer.
11.13.14. src/app/pages/espace-patient.ts
| // =========================================================================
// src/app/pages/espace-patient.ts - "Mon espace" (/mon-espace, rôle USER)
// -------------------------------------------------------------------------
// L'écran d'accueil du patient, juste après sa connexion :
// - deux options : [Gérer mon compte] et [Voir un agenda] ;
// - son rendez-vous à venir, s'il en a un, avec le moyen de l'annuler.
// =========================================================================
import { Component, inject, resource } from '@angular/core';
import { Router } from '@angular/router';
import {
IonButton, IonCard, IonCardContent, IonCardHeader, IonCardTitle, IonContent, IonIcon, IonItem, IonLabel,
IonSpinner,
} from '@ionic/angular';
import { addIcons } from 'ionicons';
import { calendar, personCircle } from 'ionicons/icons';
import { RdvMedecinsApi } from '../api/rdvmedecins-api';
import { libelle, RendezVous } from '../api/types';
import { Confirmation } from '../composants/confirmation';
import { Cache, relireApresInvalidation } from '../etat/cache';
import { Messages, signalerErreurs } from '../etat/messages';
import { horaire, jourLong } from '../formatage';
import { Traductions } from '../i18n/traductions';
import { adresseAgenda } from './agenda';
@Component({
selector: 'app-espace-patient',
imports: [
IonContent, IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonItem, IonLabel, IonIcon, IonButton,
IonSpinner,
],
templateUrl: './espace-patient.html',
styleUrl: './espace-patient.scss',
})
export class PageEspacePatient {
private readonly api = inject(RdvMedecinsApi);
private readonly cache = inject(Cache);
private readonly messages = inject(Messages);
private readonly router = inject(Router);
private readonly confirmation = inject(Confirmation);
private readonly traductions = inject(Traductions);
readonly t = this.traductions.t;
readonly libelle = libelle;
readonly horaire = horaire;
readonly espace = resource({
loader: () => this.cache.lire('mon-espace', () => this.api.monEspace()),
});
constructor() {
addIcons({ calendar, personCircle });
relireApresInvalidation(this.espace);
signalerErreurs(this.espace);
}
// "lundi 5 octobre 2026, 8:00 - 8:20"
quand(rv: RendezVous): string {
return `${jourLong(rv.jour, this.traductions.langue())}, ${horaire(rv.creneau)}`;
}
aller(adresse: string): void {
void this.router.navigateByUrl(adresse);
}
// l'agenda du médecin ce jour-là : le patient y voit son nom
voirDansAgenda(rv: RendezVous): void {
this.aller(adresseAgenda(rv.medecin.id, rv.jour));
}
async annuler(idRv: number): Promise<void> {
if (!(await this.confirmation.demander('AGENDA.CANCEL_CONFIRM'))) return;
try {
await this.api.annulerRv(idRv);
this.messages.afficherSucces('AGENDA.CANCELLED');
} catch (e) {
this.messages.afficherErreurApi(e);
}
// l'espace ET les agendas ont changé (l'espace est relu : relireApresInvalidation)
this.cache.invalider('mon-espace');
this.cache.invalider('agenda');
}
}
|
L'accueil du patient : deux options, et son rendez-vous à venir.
- lignes 46-48 : l'espace du patient, depuis le cache ;
- lignes 57-59 : « mardi 13 octobre 2026, 8:00 - 8:20 » ;
- lignes 66-68 : l'agenda du médecin ce jour-là : le patient y voit son nom ;
- lignes 70-81 : l'annulation de son rendez-vous : l'espace ET les agendas ont changé.
11.13.15. src/app/pages/espace-patient.html
| <!-- ======================================================================
espace-patient.html - "Mon espace"
====================================================================== -->
<ion-content class="ion-padding">
@if (espace.hasValue()) {
@let e = espace.value();
<h1>{{ t('ESPACE.HELLO', { nom: libelle(e.patient) }) }}</h1>
<!-- deux "cartes" d'option, qu'on touche pour ouvrir un écran ;
detail : le chevron > à droite -->
<ion-card>
<ion-item [button]="true" [detail]="true" lines="none" (click)="aller('/mon-compte')">
<ion-icon slot="start" name="person-circle" color="primary" />
<ion-label class="ion-text-wrap">
<h2>{{ t('ESPACE.MANAGE_ACCOUNT') }}</h2>
<p>{{ t('ESPACE.MANAGE_ACCOUNT_HELP') }}</p>
</ion-label>
</ion-item>
</ion-card>
<ion-card>
<ion-item [button]="true" [detail]="true" lines="none" (click)="aller('/agenda')">
<ion-icon slot="start" name="calendar" color="primary" />
<ion-label class="ion-text-wrap">
<h2>{{ t('ESPACE.VIEW_AGENDA') }}</h2>
<p>{{ t('ESPACE.VIEW_AGENDA_HELP') }}</p>
</ion-label>
</ion-item>
</ion-card>
<ion-card>
<ion-card-header>
<ion-card-title>{{ t('ESPACE.MY_APPOINTMENT') }}</ion-card-title>
</ion-card-header>
<ion-card-content>
<!-- normalement un seul (règle "un rendez-vous à venir à la fois"),
mais l'écran sait en afficher plusieurs -->
@for (rv of e.rendezVous; track rv.id) {
<div class="quand">{{ quand(rv) }}</div>
<div>{{ libelle(rv.medecin) }}</div>
<div class="boutons">
<ion-button fill="clear" (click)="voirDansAgenda(rv)">{{ t('ESPACE.SEE_IN_AGENDA') }}</ion-button>
<ion-button fill="outline" shape="round" color="danger" (click)="annuler(rv.id)">
{{ t('ESPACE.CANCEL_APPOINTMENT') }}
</ion-button>
</div>
} @empty {
<p class="aucun">{{ t('ESPACE.NO_APPOINTMENT') }}</p>
}
</ion-card-content>
</ion-card>
} @else if (espace.isLoading()) {
<div class="ion-text-center ion-padding"><ion-spinner /></div>
}
</ion-content>
|
- ligne 7 : [@let] : une variable du gabarit, pour ne pas répéter [espace.value()] ;
- lignes 12-29 : deux options : des cartes qu'on touche pour ouvrir un écran. [ion-item] avec [button] réagit au toucher ; [detail] ajoute le chevron > à droite ;
- lignes 38-49 : le rendez-vous, et deux boutons : voir l'agenda du médecin ce jour-là, annuler ; ou, s'il n'y en a pas ([@empty]), un message.
11.13.16. src/app/pages/espace-patient.scss
| // =========================================================================
// espace-patient.scss - les styles de "Mon espace"
// =========================================================================
ion-card {
margin: 0 0 12px;
}
// les icônes des options, plus grandes
ion-item ion-icon[slot='start'] {
font-size: 32px;
}
ion-item h2 {
font-weight: 500;
}
ion-card-title {
font-size: 19px;
}
.quand {
font-weight: 600;
color: var(--ion-text-color, #000);
}
.aucun {
color: var(--ion-color-medium);
}
|
Des cartes sans marge latérale (l'écran a déjà la sienne), des icônes plus grandes pour les options, le jour du rendez-vous en gras.
11.13.17. src/app/pages/mon-compte.ts
| // =========================================================================
// src/app/pages/mon-compte.ts - "Gérer mon compte" (/mon-compte, rôle USER)
// -------------------------------------------------------------------------
// Deux formulaires indépendants :
// 1. la modification (identité, login, nouveau mot de passe facultatif) ;
// 2. la suppression du compte - qui annule aussi ses rendez-vous.
// L'un comme l'autre exigent le mot de passe ACTUEL (vérifié par le serveur).
// Deux formulaires, donc deux EnvoiFormulaire (deux jeux d'erreurs). Comme
// pour le formulaire des personnes, la modification n'est affichée qu'une
// fois le compte lu, et démarre avec ses valeurs.
// =========================================================================
import { Component, inject, linkedSignal, resource, signal } from '@angular/core';
import { Router } from '@angular/router';
import { IonButton, IonCard, IonCardContent, IonContent, IonSpinner } from '@ionic/angular';
import { Donnees, RdvMedecinsApi } from '../api/rdvmedecins-api';
import { ChampSaisie } from '../composants/champ-saisie';
import { ChampsPersonne } from '../composants/champs-personne';
import { Confirmation } from '../composants/confirmation';
import { EnvoiFormulaire } from '../composants/envoi-formulaire';
import { Cache } from '../etat/cache';
import { Messages, signalerErreurs } from '../etat/messages';
import { Session } from '../etat/session';
import { Traductions } from '../i18n/traductions';
@Component({
selector: 'app-mon-compte',
imports: [IonContent, IonCard, IonCardContent, IonButton, IonSpinner, ChampSaisie, ChampsPersonne],
templateUrl: './mon-compte.html',
styleUrl: './mon-compte.scss',
})
export class PageMonCompte {
private readonly api = inject(RdvMedecinsApi);
private readonly session = inject(Session);
private readonly cache = inject(Cache);
private readonly messages = inject(Messages);
private readonly router = inject(Router);
private readonly confirmation = inject(Confirmation);
readonly t = inject(Traductions).t;
// ------------------------------------------------ 1. la modification
// relu à chaque visite, sans cache : sa version doit être à jour
readonly compte = resource({ loader: () => this.api.monCompte() });
readonly modification = new EnvoiFormulaire(this.messages);
readonly saisie = linkedSignal<Donnees>(() => {
if (!this.compte.hasValue()) return {};
const c = this.compte.value();
return {
titre: c.titre,
prenom: c.prenom,
nom: c.nom,
login: c.login,
password: '',
confirmation: '',
motDePasseActuel: '',
version: c.version,
};
});
// ------------------------------------------------ 2. la suppression
readonly suppression = new EnvoiFormulaire(this.messages);
readonly motDePasseSuppression = signal('');
constructor() {
signalerErreurs(this.compte);
}
texte(champ: string): string {
return String(this.saisie()[champ] ?? '');
}
modifier(champ: string, valeur: string): void {
this.saisie.update((s) => ({ ...s, [champ]: valeur }));
this.modification.effacerErreur(champ);
}
retour(): void {
void this.router.navigateByUrl('/mon-espace');
}
async enregistrer(): Promise<void> {
const reussi = await this.modification.envoyer(async () => {
// le serveur a délivré un NOUVEAU jeton (nom, login) : la barre du haut doit suivre
this.session.mettreAJour(await this.api.modifierMonCompte(this.saisie()));
this.messages.flash({ cle: 'MON_COMPTE.UPDATED' });
});
if (reussi) {
this.cache.invalider('mon-espace');
this.retour();
} else {
// jamais de mot de passe laissé dans le champ après un envoi refusé ;
// on change la valeur SANS passer par modifier(), pour ne pas effacer
// l'erreur que le serveur vient d'y attacher
this.saisie.update((s) => ({ ...s, motDePasseActuel: '' }));
}
}
changerMotDePasseSuppression(valeur: string): void {
this.motDePasseSuppression.set(valeur);
this.suppression.effacerErreur('motDePasseActuel');
}
async supprimer(): Promise<void> {
if (!(await this.confirmation.demander('MON_COMPTE.DELETE_CONFIRM'))) return;
const reussi = await this.suppression.envoyer(() => this.api.supprimerMonCompte(this.motDePasseSuppression()));
if (reussi) {
// le message d'abord : il s'affichera sur l'écran de connexion, où
// les gardes nous emmènent dès que la session est vide
this.messages.flash({ cle: 'MON_COMPTE.DELETED' });
this.session.oublier(true); // le serveur a effacé le cookie du jeton
}
}
}
|
« Gérer mon compte » : deux formulaires indépendants, chacun avec son [EnvoiFormulaire].
- ligne 43 : le compte, relu à chaque visite, sans cache (sa version doit être à jour) ;
- lignes 45-58 : la saisie démarre avec les valeurs du compte, une fois lu ;
- lignes 81-96 : la modification. Le serveur renvoie l'utilisateur modifié (et un nouveau jeton, dans son cookie) : la session est mise à jour, et le nom affiché dans le menu avec elle. Après un refus, le mot de passe actuel est effacé du champ, sans effacer l'erreur que le serveur vient d'y attacher ;
- lignes 103-112 : la suppression du compte, après confirmation. Le message d'abord (un message flash) ; puis la session se vide, et les gardes affichent l'écran de connexion, avec le message.
11.13.18. src/app/pages/mon-compte.html
| <!-- ======================================================================
mon-compte.html - "Gérer mon compte"
====================================================================== -->
<ion-content class="ion-padding">
<!-- 1. la modification -->
<ion-card>
<ion-card-content>
<h1>{{ t('MON_COMPTE.TITLE') }}</h1>
@if (compte.hasValue()) {
<app-champs-personne
[valeurs]="saisie()"
[erreurs]="modification.erreurs()"
(modification)="modifier($event.champ, $event.valeur)"
/>
<h3>{{ t('PERSONNES.ACCOUNT_SECTION') }}</h3>
<app-champ-saisie
libelle="PERSONNES.LOGIN_LABEL"
autocomplete="username"
[longueurMax]="30"
[valeur]="texte('login')"
(valeurChange)="modifier('login', $event)"
[erreur]="modification.erreurs()['login']"
/>
<app-champ-saisie
libelle="MON_COMPTE.NEW_PASSWORD_LABEL"
autocomplete="new-password"
[secret]="true"
[longueurMax]="72"
[valeur]="texte('password')"
(valeurChange)="modifier('password', $event)"
[erreur]="modification.erreurs()['password']"
[params]="modification.params()"
aide="PERSONNES.PASSWORD_HELP_EDIT"
/>
<app-champ-saisie
libelle="COMPTE.CONFIRMATION_LABEL"
autocomplete="new-password"
[secret]="true"
[longueurMax]="72"
[valeur]="texte('confirmation')"
(valeurChange)="modifier('confirmation', $event)"
[erreur]="modification.erreurs()['confirmation']"
/>
<app-champ-saisie
libelle="MON_COMPTE.CURRENT_PASSWORD_LABEL"
autocomplete="current-password"
[secret]="true"
[longueurMax]="72"
[valeur]="texte('motDePasseActuel')"
(valeurChange)="modifier('motDePasseActuel', $event)"
[erreur]="modification.erreurs()['motDePasseActuel']"
aide="MON_COMPTE.CURRENT_PASSWORD_HELP"
/>
<div class="boutons">
<ion-button shape="round" [disabled]="modification.enCours()" (click)="enregistrer()">
{{ t('PERSONNES.SAVE') }}
</ion-button>
<ion-button shape="round" fill="outline" (click)="retour()">{{ t('MON_COMPTE.BACK') }}</ion-button>
</div>
} @else {
<div class="ion-text-center ion-padding"><ion-spinner /></div>
}
</ion-card-content>
</ion-card>
<!-- 2. la suppression : un formulaire d'un seul champ -->
<ion-card class="danger">
<ion-card-content>
<h1>{{ t('MON_COMPTE.DELETE_TITLE') }}</h1>
<p class="aide">{{ t('MON_COMPTE.DELETE_HELP') }}</p>
<app-champ-saisie
libelle="MON_COMPTE.CURRENT_PASSWORD_LABEL"
autocomplete="current-password"
[secret]="true"
[longueurMax]="72"
[valeur]="motDePasseSuppression()"
(valeurChange)="changerMotDePasseSuppression($event)"
[erreur]="suppression.erreurs()['motDePasseActuel']"
/>
<ion-button shape="round" color="danger" [disabled]="suppression.enCours()" (click)="supprimer()">
{{ t('MON_COMPTE.DELETE_SUBMIT') }}
</ion-button>
</ion-card-content>
</ion-card>
</ion-content>
|
- lignes 7-65 : la modification, dans une carte ; le formulaire n'apparaît qu'une fois le compte lu ;
- lignes 68-85 : la suppression, dans une carte au cadre rouge : un formulaire d'un seul champ.
11.13.19. src/app/pages/mon-compte.scss
| // =========================================================================
// mon-compte.scss - les styles de "Gérer mon compte"
// =========================================================================
ion-card {
margin: 0 0 12px;
}
ion-card-content {
color: var(--ion-text-color, #000);
// les titres, comme ailleurs (Ionic leur donne d'autres marges dans une carte)
h1 {
font-size: 26px;
margin: 8px 0 16px;
}
h3 {
font-size: 15px;
font-weight: 500;
margin: 16px 0 12px;
}
}
// la carte de la suppression : un cadre rouge, un titre rouge
ion-card.danger {
border: 1px solid #e57373;
h1 {
color: #d32f2f;
}
}
|
Les titres dans les cartes ([Ionic] leur donne d'autres tailles et marges dans un [ion-card-content]), et le cadre rouge de la suppression.
11.13.20. src/app/pages/page-erreur.ts
| // =========================================================================
// src/app/pages/page-erreur.ts - l'écran d'erreur : 404 (adresse inconnue)
// ou 403 (rôle insuffisant, cf. app.routes.ts)
// -------------------------------------------------------------------------
// Le code (403 ou 404) est donné par la propriété data de la route.
// =========================================================================
import { Component, inject, input } from '@angular/core';
import { Router } from '@angular/router';
import { IonButton, IonContent } from '@ionic/angular';
import { Traductions } from '../i18n/traductions';
@Component({
selector: 'app-page-erreur',
imports: [IonContent, IonButton],
templateUrl: './page-erreur.html',
styles: `
.erreur {
color: #d32f2f;
}
`,
})
export class PageErreur {
private readonly router = inject(Router);
readonly t = inject(Traductions).t;
readonly code = input(404);
// "/" : le garde redirige vers la page d'accueil du rôle (ou la connexion)
accueil(): void {
void this.router.navigateByUrl('/');
}
}
|
| <!-- ======================================================================
page-erreur.html - l'écran d'erreur (403, 404)
====================================================================== -->
<ion-content class="ion-padding">
<h1 class="erreur">{{ t('ERROR_PAGE.TITLE') }} {{ code() }}</h1>
<p>{{ t(code() === 403 ? 'ERRORS.FORBIDDEN' : 'ERRORS.NOT_FOUND') }}</p>
<ion-button shape="round" (click)="accueil()">{{ t('ERROR_PAGE.BACK') }}</ion-button>
</ion-content>
|
La page d'erreur : 404 (adresse inconnue) ou 403 (rôle insuffisant). Le code est la propriété [data] de la route, donnée à l'entrée [code] (ligne 26 de [page-erreur.ts]). « Retour à l'accueil » mène à [/], que le garde redirige vers l'accueil du rôle.
11.14. Flutter et Ionic : le bilan
Les deux clients font exactement la même chose, avec la même architecture, fichier pour fichier. Quelques différences méritent d'être retenues :
- le cookie du jeton : le client [Flutter] devait le gérer lui-même sur le téléphone (un client HTTP qui lit [Set-Cookie] et écrit [Cookie]) ; le client [Ionic] s'en remet au navigateur sur le web, et au code natif d'[Android] ([CapacitorHttp]) sur le téléphone ;
- l'état et ses mises à jour : les [ChangeNotifier] de [Flutter] préviennent leurs auditeurs ([notifyListeners()]), et les widgets se reconstruisent ; les signaux d'[Angular] savent QUI les lit, et ne mettent à jour que ce qui en dépend. Les signaux calculés ([computed]), liés ([linkedSignal]) et les ressources ([resource]) remplacent une bonne partie des [initState], [didUpdateWidget] et [FutureBuilder] ;
- l'adresse : dans les deux cas, tout l'état de l'écran est dans l'adresse ; le routeur d'[Angular] la donne directement aux entrées des composants ([withComponentInputBinding]) ;
- les composants tout faits : [Ionic] fournit le menu permanent sur grand écran ([ion-split-pane]), les fenêtres ([ion-modal], [ion-popover], [AlertController]) et le calendrier ([ion-datetime]) ; [Flutter] fournit les siens ([NavigationRail], [showDatePicker]...), mais la fenêtre de réservation liée à l'adresse y était dessinée à la main ;
- le web : le client [Ionic] EST une application web (du HTML, quelques centaines de kilo-octets) ; le client [Flutter] compilé pour le web dessine chaque pixel dans un canevas, et télécharge d'abord son moteur de dessin (quelques mégaoctets). En revanche, sur le téléphone, l'application [Flutter] est compilée en code machine, quand l'application [Ionic] s'exécute dans la [WebView], le moteur du navigateur.