Skip to content

10. Étude de cas : le client Angular de l'application RdvMedecins

10.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. Nous en écrivons ici le client [Angular] : les MÊMES pages, fabriquées dans le navigateur, à partir des données JSON du serveur présenté au chapitre « Le serveur RdvMedecins, une boîte noire ».

Le client doit offrir tout ce qu'offrait l'application HTML :

  • une page de connexion, protégée 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 ;
  • des adresses propres ([/agenda?idMedecin=1&jour=...]), qu'on peut recharger, mettre en favori, et parcourir avec les boutons Précédent / Suivant du navigateur.

10.2. Installation et exécution

Le serveur JSON doit être installé et lancé (cf. chapitre « Le serveur RdvMedecins, une boîte noire »). Pour utiliser le client dans un navigateur, 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-angular] :

npm install
npm start

puis on ouvre http://localhost:4200 dans un navigateur. Les comptes de démonstration sont rappelés sur la page de connexion (mot de passe = login).

Les exemples utilisent aussi le port 4200 : on arrête d'abord l'exemple en cours ([Ctrl-C]). On peut aussi lancer le client sur un autre port : [npm start -- --port 4300].

10.3. Utilisation de l'application

10.3.1. La page de connexion

Image

Un mauvais mot de passe : le message s'affiche en bas de la page, et une nouvelle image est proposée (chacune ne sert qu'une fois) :

Image

10.3.2. L'agenda, vu par l'administrateur et les médecins

Connecté comme [admin], on arrive sur l'agenda :

Image

On choisit un médecin et un jour, puis « Voir l'agenda ». L'adresse devient [/agenda?idMedecin=1&jour=2026-10-05] :

Image

« Réserver » ouvre la fenêtre de réservation. L'adresse devient [/agenda?idMedecin=1&jour=2026-10-05&reserver=3] : recharger la page (F5) rouvre la même fenêtre.

Image

Le rendez-vous est enregistré :

Image

« Annuler » demande confirmation :

Image

Un médecin arrive directement sur SON agenda du jour :

Image

10.3.3. L'espace du patient

Un patient ([martin]) arrive sur « Mon espace » :

Image

Dans un agenda, il voit « Réservé » en face des créneaux des autres patients :

Image

Il réserve pour lui-même, sans choisir de patient :

Image

Son rendez-vous apparaît sous son nom ; il n'a plus de bouton « Réserver » (un seul rendez-vous à venir), et un message lui dit pourquoi :

Image

Son espace le montre :

Image

« Gérer mon compte » : toute modification exige le mot de passe actuel ; les erreurs sont celles du serveur :

Image

S'il tape l'adresse /medecins, réservée à l'administrateur :

Image

10.3.4. La gestion des médecins et des clients

Image

Un ajout mal rempli :

Image

Un ajout réussi :

Image

Un homonyme :

Image

La suppression d'un médecin qui a des rendez-vous est refusée :

Image

10.3.5. La création d'un compte

Sur la page de connexion, on recopie le captcha puis on clique sur « Créer un compte client ». Le formulaire, mal rempli, puis refusé par le serveur :

Image

Une fois le compte créé, retour à la page de connexion, login pré-rempli :

Image

10.3.6. En anglais

Les boutons FR / EN retraduisent la page sur place, sans rien demander au serveur ; le titre de l'onglet du navigateur est traduit lui aussi :

Image

10.3.7. Session expirée, adresse inconnue

Si le jeton expire (ou si le cookie disparaît), la requête suivante reçoit 401 ; le client renvoie à la page de connexion :

Image

Une adresse inconnue :

Image

10.4. L'architecture du client

rdvmedecins-angular/
├── angular.json, package.json, tsconfig.json, proxy.conf.json
├── public/favicon.svg                l'icône de l'onglet
└── src/
    ├── index.html                    LA page de l'application
    ├── main.ts                       le démarrage
    ├── styles.css
    └── app/
        ├── app.config.ts             la configuration de l'application
        ├── app.ts                    le gabarit commun à toutes les pages
        ├── app.routes.ts             les pages et leurs droits d'accès
        ├── gardes.ts                 les gardes du routeur
        ├── api/                      la couche d'accès à l'API
        ├── i18n/                     les langues
        ├── services/                 session, messages, confirmation, formulaires
        ├── pipes/                    la mise en forme
        ├── components/               les composants réutilisables
        └── pages/                    les pages

Image

Le tableau suivant met en regard l'application HTML du cours [NestJS] et ce client :

Application HTML (NestJS)
Client Angular
le serveur fabrique chaque page ([res.render])
le serveur renvoie du JSON ; les composants de [src/app/pages/] fabriquent les pages
[views/layout.hbs] et ses partiels
[app.ts] et les composants de [src/app/components/]
les routes des contrôleurs ([@Get('agenda')])
les routes du client ([app.routes.ts]) ; le serveur n'a que des routes /api/...
[JwtAuthGuard], [RolesGuard] renvoient vers /login ou une page 403
les gardes du routeur ; le serveur garde ses propres gardes, qui répondent 401 / 403
message flash dans un cookie
message flash dans un service, [MessagesService]
dictionnaires lus par le serveur, helper [{{t}}]
dictionnaires dans le client, [ngx-translate] ; le serveur ne renvoie que des clés
langue mémorisée dans un cookie
langue mémorisée dans le [localStorage]
formulaire refusé : le serveur réaffiche la page
formulaire refusé : réponse 400 / 409, le client affiche les erreurs sous les champs
fenêtre de confirmation : un petit script, [public/js/app.js]
un service et un composant, [FenetreConfirmation]
l'application marche sans JavaScript
l'application EST du JavaScript

Le client [Vue.js] du cours précédent a exactement la même structure : les « stores » [Pinia] y sont devenus des services, les « composables » des services ou des fonctions, les vues des pages.

Tous les fichiers du client sont présentés ci-dessous. Ils sont abondamment commentés : leurs commentaires font partie du cours.

10.4.1. La configuration du projet

10.4.1.1. package.json

{
  "name": "rdvmedecins-angular",
  "version": "1.0.0",
  "description": "Client Angular de l'application de prise de rendez-vous médicaux [RdvMedecins] - client du serveur JSON [rdvmedecins-nestjs-json]",
  "private": true,
  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build"
  },
  "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",
    "@ngx-translate/core": "^18.0.0",
    "bootstrap": "^5.3.8",
    "rxjs": "~7.8.0",
    "tslib": "^2.8.0"
  },
  "devDependencies": {
    "@angular/build": "^22.2.0",
    "@angular/cli": "^22.2.0",
    "@angular/compiler-cli": "^22.2.0",
    "typescript": "~6.0.2"
  }
}
  • lignes 8-9 : [npm start] lance le serveur de développement, [npm run build] construit la version de production ;
  • lignes 11-22 : les bibliothèques utilisées par l'application : celles d'[Angular], [ngx-translate], [Bootstrap], [RxJS] ;
  • lignes 23-28 : les outils de développement : le compilateur d'[Angular], la commande [ng], [TypeScript].

10.4.1.2. angular.json

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "cli": {
    "packageManager": "npm",
    "analytics": false
  },
  "projects": {
    "rdvmedecins-angular": {
      "projectType": "application",
      "root": "",
      "sourceRoot": "src",
      "prefix": "app",
      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {
            "browser": "src/main.ts",
            "tsConfig": "tsconfig.app.json",
            "assets": [{ "glob": "**/*", "input": "public" }],
            "styles": ["node_modules/bootstrap/dist/css/bootstrap.min.css", "src/styles.css"]
          },
          "configurations": {
            "production": {
              "budgets": [
                { "type": "initial", "maximumWarning": "800kB", "maximumError": "1MB" },
                { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
              ],
              "outputHashing": "all"
            },
            "development": {
              "optimization": false,
              "extractLicenses": false,
              "sourceMap": true
            }
          },
          "defaultConfiguration": "production"
        },
        "serve": {
          "builder": "@angular/build:dev-server",
          "options": {
            "proxyConfig": "proxy.conf.json"
          },
          "configurations": {
            "production": { "buildTarget": "rdvmedecins-angular:build:production" },
            "development": { "buildTarget": "rdvmedecins-angular:build:development" }
          },
          "defaultConfiguration": "development"
        }
      }
    }
  }
}

Un seul projet, cette fois, à la racine du dossier (ligne 11) :

  • ligne 18 : le point d'entrée de l'application ;
  • ligne 20 : le contenu du dossier [public] est copié tel quel dans l'application construite (l'icône) ;
  • ligne 21 : les feuilles de style globales : celle de [Bootstrap], puis la nôtre ;
  • lignes 24-30 : la configuration de production. Les budgets déclenchent un avertissement (ou une erreur) si l'application dépasse la taille indiquée ; [outputHashing] ajoute une empreinte du contenu au nom de chaque fichier (voir le chapitre « Déploiement ») ;
  • ligne 37 : [ng build] construit par défaut la version de production ;
  • ligne 42 : le proxy du serveur de développement ;
  • ligne 48 : [ng serve] utilise la configuration de développement (code non optimisé, « source maps » pour le débogueur).

10.4.1.3. tsconfig.json, tsconfig.app.json, proxy.conf.json

{
  "compilerOptions": {
    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "experimentalDecorators": true,
    "importHelpers": true,
    "resolveJsonModule": true,
    "target": "ES2022",
    "module": "preserve"
  },
  "angularCompilerOptions": {
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  },
  "files": [],
  "references": [
    {
      "path": "./tsconfig.app.json"
    }
  ]
}
  • ligne 3 : le mode strict de [TypeScript] ;
  • ligne 14 : l'import des fichiers JSON (les dictionnaires) ;
  • ligne 21 : le compilateur d'[Angular] vérifie aussi les types dans les templates.
1
2
3
4
{
  "extends": "./tsconfig.json",
  "include": ["src/**/*.ts"]
}
1
2
3
4
5
6
{
  "/api": {
    "target": "http://localhost:8080",
    "secure": false
  }
}

Le proxy de l'exemple 22 : les requêtes /api/... sont transmises au serveur JSON.

10.4.2. Le démarrage de l'application

10.4.2.1. index.html

<!doctype html>
<!--
  index.html - LA page de l'application (Single Page Application).
  Le serveur n'en envoie jamais d'autre : tout le reste est fabriqué dans
  le navigateur par Angular, dans <app-root>. lang et title sont mis à
  jour par l'application selon la langue et la page affichée.
-->
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>RdvMedecins</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="favicon.svg" type="image/svg+xml">
</head>
<body>
  <app-root></app-root>
  <!-- sans JavaScript, une application Angular ne peut rien afficher : on le dit -->
  <noscript>RdvMedecins a besoin de JavaScript. / RdvMedecins requires JavaScript.</noscript>
</body>
</html>
  • ligne 17 : l'élément où [Angular] affiche le composant racine ;
  • ligne 19 : sans [JavaScript], une application [Angular] ne peut rien afficher : on le dit.

10.4.2.2. src/main.ts

// =========================================================================
// main.ts - le démarrage de l'application
// -------------------------------------------------------------------------
// Le composant racine (App) et la configuration de l'application
// (app.config.ts). Les feuilles de style (Bootstrap, styles.css) sont
// déclarées dans angular.json.
// =========================================================================

import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';

bootstrapApplication(App, appConfig).catch((erreur) => console.error(erreur));

10.4.2.3. src/app/app.config.ts

// =========================================================================
// app.config.ts - la configuration de l'application
// -------------------------------------------------------------------------
//   - le routeur, ses routes (app.routes.ts), les paramètres des routes
//     transmis aux entrées des pages, et le titre traduit des onglets ;
//   - HttpClient, avec l'intercepteur des erreurs et du 401 ;
//   - ngx-translate (français et anglais).
// =========================================================================

import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { apiInterceptor } from './api/api.interceptor';
import { provideI18n } from './i18n/i18n';
import { TitreTraduit } from './i18n/titre.strategy';

export const appConfig: ApplicationConfig = {
  providers: [
    // les erreurs non traitées sont signalées dans la console du navigateur
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes, withComponentInputBinding()),
    { provide: TitleStrategy, useClass: TitreTraduit },
    provideHttpClient(withInterceptors([apiInterceptor])),
    provideI18n(),
  ],
};
  • ligne 21 : les erreurs non traitées (une promesse rejetée que personne n'attend, par exemple) sont signalées dans la console du navigateur ;
  • ligne 22 : le routeur, avec les paramètres des routes transmis aux entrées des pages (exemple 17) ;
  • ligne 23 : [TitleStrategy] est le service du routeur qui fixe le titre de l'onglet à chaque navigation. On remplace celui d'[Angular] par le nôtre ([i18n/titre.strategy.ts]), qui traduit le titre. C'est tout l'intérêt de l'injection de dépendances : on peut substituer sa propre classe à celle du framework, sans rien changer ailleurs ;
  • ligne 24 : le client HTTP, avec notre intercepteur ;
  • ligne 25 : [ngx-translate].

10.4.2.4. src/app/app.ts

// =========================================================================
// app.ts - le composant racine : le GABARIT commun à toutes les pages
// -------------------------------------------------------------------------
// L'équivalent de views/layout.hbs de l'application HTML :
//   - la barre de navigation ;
//   - un message de succès en HAUT ;
//   - la page courante (<router-outlet>) ;
//   - la zone d'erreur en BAS ;
//   - la fenêtre de confirmation, une fois pour toute l'application.
// =========================================================================

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { BarreNavigation } from './components/barre-navigation/barre-navigation';
import { MessageSucces } from './components/message-succes';
import { ZoneErreur } from './components/zone-erreur';
import { FenetreConfirmation } from './components/fenetre-confirmation/fenetre-confirmation';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, BarreNavigation, MessageSucces, ZoneErreur, FenetreConfirmation],
  template: `
    <div class="container py-4">
      <app-barre-navigation />
      <app-message-succes />
      <router-outlet />
      <app-zone-erreur />
    </div>
    <app-fenetre-confirmation />
  `,
})
export class App {}
  • lignes 22-30 : le gabarit commun à toutes les pages : la barre de navigation, le message de succès, la page courante, la zone d'erreur, et la fenêtre de confirmation (exemple 16).

10.4.2.5. src/styles.css

/* =========================================================================
   styles.css - quelques réglages en plus de Bootstrap
   (repris de public/css/styles.css de l'application HTML)
   ========================================================================= */

body {
  background-color: #f5f7fa;
}

.creneau-libre:hover {
  background-color: #e9f7ef;
}

/* -------------------------------------------------------------------------
   .zone-erreur : les messages d'erreur, en bas de page, sur fond rose léger
   (cf. components/zone-erreur.ts).
   position: sticky + bottom : si la page est plus haute que la fenêtre, la
   zone reste collée au bas de la fenêtre au lieu de disparaître sous la
   ligne de flottaison - sans jamais recouvrir le contenu quand la page est
   courte. z-index : au-dessus du fond grisé des fenêtres modales.
   pointer-events: none : la zone ne contient rien de cliquable ; si elle
   chevauche un bouton, le clic passe au travers.
   ------------------------------------------------------------------------- */
.zone-erreur {
  position: sticky;
  bottom: 1rem;
  z-index: 1060;
  pointer-events: none;
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef; /* rose léger */
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

/* les deux grandes options de la page "Mon espace" du patient */
.carte-option {
  transition: border-color 0.15s, box-shadow 0.15s;
}

.carte-option:hover {
  border-color: #0d6efd;
  box-shadow: 0 0.25rem 0.75rem rgba(13, 110, 253, 0.15);
}

/* l'image du captcha (SVG de 150 x 50 pixels), insérée par [innerHTML] :
   seul un style GLOBAL, comme celui-ci, peut l'atteindre */
.captcha-image svg {
  display: block;
  border: 1px solid #dee2e6;
  border-radius: 0.375rem;
}
  • lignes 24-35 : la zone d'erreur, en bas de page ;
  • lignes 49-53 : l'image du captcha. Comme à l'exemple 24, un [<svg>] inséré par [[innerHTML]] n'est atteint que par un style global.

10.4.3. La couche d'accès à l'API

10.4.3.1. src/app/api/types.ts

// =========================================================================
// api/types.ts - les objets JSON échangés avec le serveur
// -------------------------------------------------------------------------
// La traduction, en interfaces TypeScript, de l'API décrite au chapitre
// "Le serveur, une boîte noire". Le compilateur vérifie ainsi que le client
// n'utilise que des champs qui existent vraiment.
// =========================================================================

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

// un titre, un prénom, un nom
export interface Identite {
  titre: string;
  prenom: string;
  nom: string;
}

// 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
}

// ce qu'on envoie pour créer / modifier une personne (ADMIN)
export interface DonneesPersonne extends Identite {
  login: string; // '' = valeur par défaut (création) ou inchangé (modification)
  password: string; // idem
  version?: number;
}

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

// GET /api/mon-espace
export interface MonEspace {
  patient: Identite;
  rendezVous: { id: number; jour: string; creneau: Creneau; medecin: Personne }[];
}

// GET /api/mon-compte
export interface MonCompte extends Identite {
  login: string;
  version: number;
}

// PUT /api/mon-compte
export interface ModificationMonCompte extends Identite {
  login: string;
  password: string; // '' = inchangé
  confirmation: string;
  motDePasseActuel: string;
  version: number;
}

// POST /api/inscription
export interface Inscription extends Identite {
  login: string;
  password: string;
  confirmation: string;
}

La traduction en interfaces [TypeScript] de l'API décrite au chapitre « Le serveur RdvMedecins, une boîte noire ». Le mot-clé [extends] évite de répéter les champs communs : une [Personne] est une [Identite] (titre, prénom, nom) avec un identifiant et une version.

10.4.3.2. src/app/api/erreur-api.ts

// =========================================================================
// api/erreur-api.ts - UNE classe pour toutes les erreurs de l'API
// -------------------------------------------------------------------------
// Reprise de commun/erreur-api.ts des exemples. Toute erreur d'une requête
// (réponse 4xx / 5xx du serveur, serveur injoignable, réponse d'un proxy)
// devient une ErreurApi, qui porte la CLÉ de traduction du message, ses
// paramètres, et l'erreur de chaque champ d'un formulaire refusé.
// =========================================================================

import { HttpErrorResponse } from '@angular/common/http';

export class ErreurApi extends Error {
  constructor(
    public readonly statusCode: number, // 0 : le serveur n'a pas répondu
    public readonly cle: string, // clé de traduction : 'ERRORS.LOGIN_TAKEN'
    public readonly params: Record<string, string> = {},
    public readonly champs: Record<string, string> = {}, // erreur de chaque champ
  ) {
    super(cle);
    this.name = 'ErreurApi';
  }
}

export function versErreurApi(e: unknown): ErreurApi {
  if (e instanceof ErreurApi) {
    return e;
  }
  if (!(e instanceof HttpErrorResponse)) {
    return new ErreurApi(0, 'ERRORS.SERVER_ERROR');
  }
  const corps = (typeof e.error === 'object' && e.error !== null ? e.error : {}) as {
    cle?: string;
    params?: Record<string, string>;
    champs?: Record<string, string>;
  };
  return new ErreurApi(
    e.status,
    // pas de clé : ce n'est pas notre serveur qui a répondu, mais le réseau
    // ou un proxy (ng serve : 500 sans corps ; nginx : 502, 504...)
    corps.cle ?? (e.status === 0 || e.status >= 500 ? 'ERRORS.SERVER_UNREACHABLE' : 'ERRORS.SERVER_ERROR'),
    corps.params,
    corps.champs,
  );
}

Le fichier [commun/erreur-api.ts] de l'exemple 24.

10.4.3.3. src/app/api/api.interceptor.ts

// =========================================================================
// api/api.interceptor.ts - l'intercepteur de TOUTES les requêtes vers l'API
// -------------------------------------------------------------------------
// Repris de commun/erreurs.interceptor.ts des exemples, avec une nouveauté :
// la gestion centralisée de la réponse 401.
//
// Un 401 peut arriver n'importe quand : le jeton a expiré (2 heures), le
// compte a été supprimé, le serveur a redémarré avec une autre clé
// secrète... Plutôt que de traiter ce cas dans chaque composant, on le
// traite ICI, une fois : on oublie l'utilisateur, on prépare le message
// "session expirée" et on renvoie vers la page de connexion.
//
// Certaines requêtes traitent elles-mêmes leur 401 (la connexion, GET
// /api/auth/moi...) : elles le signalent par un JETON DE CONTEXTE
// (HttpContextToken), une information attachée à la requête, que
// l'intercepteur lit (cf. api/rdvmedecins.api.ts).
// =========================================================================

import { inject } from '@angular/core';
import { HttpContextToken, HttpInterceptorFn } from '@angular/common/http';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { versErreurApi } from './erreur-api';
import { SessionService } from '../services/session.service';
import { MessagesService } from '../services/messages.service';

// vrai : la requête traite elle-même un éventuel 401 (false par défaut)
export const IGNORER_401 = new HttpContextToken<boolean>(() => false);

export const apiInterceptor: HttpInterceptorFn = (req, next) => {
  // inject : les services dont on aura besoin (dans le contexte d'injection
  // de l'intercepteur, AVANT l'envoi de la requête)
  const session = inject(SessionService);
  const messages = inject(MessagesService);
  const router = inject(Router);

  return next(req).pipe(
    catchError((e: unknown) => {
      const erreur = versErreurApi(e);
      if (erreur.statusCode === 401 && !req.context.get(IGNORER_401)) {
        session.oublier();
        messages.flash('erreur', 'ERRORS.SESSION_EXPIRED');
        router.navigate(['/login']);
      }
      return throwError(() => erreur);
    }),
  );
};

L'intercepteur de l'exemple 24, avec une nouveauté : la gestion centralisée de la réponse 401. Un 401 peut arriver n'importe quand : le jeton a expiré, le compte a été supprimé... Plutôt que de traiter ce cas dans chaque page, on le traite ici, une fois.

  • ligne 28 : un jeton de contexte ([HttpContextToken]) : une information attachée à une requête, que l'intercepteur peut lire. Celui-ci vaut [false] par défaut ;
  • lignes 33-35 : l'intercepteur injecte les services dont il aura besoin. [inject] doit être appelé au début de la fonction : plus tard, dans [catchError], le contexte d'injection n'existe plus ;
  • ligne 39 : toute erreur devient une [ErreurApi] ;
  • lignes 40-44 : un 401, pour une requête qui ne le traite pas elle-même : on oublie l'utilisateur, on prépare le message « session expirée » (il s'affichera sur la page suivante) et l'on navigue vers la page de connexion ;
  • ligne 45 : l'erreur est transmise, dans tous les cas, au code qui a fait la requête.

10.4.3.4. src/app/api/rdvmedecins.api.ts

// =========================================================================
// api/rdvmedecins.api.ts - une méthode par route de l'API
// -------------------------------------------------------------------------
// La "couche d'accès aux données" du client : les composants appellent ces
// méthodes, typées, sans rien savoir des URL, de HttpClient ni du JSON. Si
// l'API change, seul ce fichier change.
// Chaque méthode renvoie une PROMESSE (firstValueFrom) : les composants
// enchaînent les requêtes avec async / await. En cas d'échec, la promesse
// est rejetée avec une ErreurApi (cf. api.interceptor.ts).
// =========================================================================

import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { IGNORER_401 } from './api.interceptor';
import {
  Agenda,
  Captcha,
  DonneesPersonne,
  Inscription,
  ModificationMonCompte,
  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';

type Vide = Record<string, never>;
type Methode = 'GET' | 'POST' | 'PUT' | 'DELETE';

@Injectable({ providedIn: 'root' })
export class RdvMedecinsApi {
  private readonly http = inject(HttpClient);

  // la requête elle-même ; publique : true pour les routes qui traitent
  // elles-mêmes le 401 (routes publiques, GET /api/auth/moi)
  private requete<T>(methode: Methode, url: string, corps?: unknown, publique = false, params?: HttpParams): Promise<T> {
    return firstValueFrom(
      this.http.request<T>(methode, `/api${url}`, {
        body: corps,
        params,
        context: new HttpContext().set(IGNORER_401, publique),
      }),
    );
  }

  // ------------------------------------------------ connexion (routes publiques)
  captcha(): Promise<Captcha> {
    return this.requete<Captcha>('GET', '/captcha', undefined, true);
  }
  moi(): Promise<Utilisateur> {
    return this.requete<Utilisateur>('GET', '/auth/moi', undefined, true);
  }
  connecter(login: string, password: string, captchaId: string, captcha: string): Promise<Utilisateur> {
    return this.requete<Utilisateur>('POST', '/auth/login', { login, password, captchaId, captcha }, true);
  }
  deconnecter(): Promise<Vide> {
    return this.requete<Vide>('POST', '/auth/logout', {}, true);
  }

  // ------------------------------------------------ création de compte (publique)
  accesInscription(captchaId: string, captcha: string): Promise<Vide> {
    return this.requete<Vide>('POST', '/inscription/acces', { captchaId, captcha }, true);
  }
  etatAccesInscription(): Promise<{ valide: boolean }> {
    return this.requete<{ valide: boolean }>('GET', '/inscription/acces', undefined, true);
  }
  inscrire(donnees: Inscription): Promise<{ login: string }> {
    return this.requete<{ login: string }>('POST', '/inscription', donnees, true);
  }

  // ------------------------------------------------ agenda et rendez-vous
  // idMedecin null : pour un médecin connecté, SON agenda
  agenda(idMedecin: number | null, jour: string): Promise<Agenda> {
    let params = new HttpParams().set('jour', jour);
    if (idMedecin !== null) {
      params = params.set('idMedecin', idMedecin);
    }
    return this.requete<Agenda>('GET', '/agenda', undefined, false, params);
  }
  reserver(jour: string, idCreneau: number, idClient: number | null): Promise<{ id: number }> {
    return this.requete<{ id: number }>('POST', '/rendez-vous', { jour, idCreneau, idClient: idClient ?? undefined });
  }
  annulerRv(idRv: number): Promise<Vide> {
    return this.requete<Vide>('DELETE', `/rendez-vous/${idRv}`);
  }

  // ------------------------------------------------ médecins et clients
  lister(type: TypePersonnes): Promise<Personne[]> {
    return this.requete<Personne[]>('GET', `/${type}`);
  }
  lire(type: TypePersonnes, id: number): Promise<Personne> {
    return this.requete<Personne>('GET', `/${type}/${id}`);
  }
  ajouter(type: TypePersonnes, donnees: DonneesPersonne): Promise<Personne> {
    return this.requete<Personne>('POST', `/${type}`, donnees);
  }
  modifier(type: TypePersonnes, id: number, donnees: DonneesPersonne): Promise<Personne> {
    return this.requete<Personne>('PUT', `/${type}/${id}`, donnees);
  }
  supprimer(type: TypePersonnes, id: number): Promise<Vide> {
    return this.requete<Vide>('DELETE', `/${type}/${id}`);
  }

  // ------------------------------------------------ l'espace du patient
  monEspace(): Promise<MonEspace> {
    return this.requete<MonEspace>('GET', '/mon-espace');
  }
  monCompte(): Promise<MonCompte> {
    return this.requete<MonCompte>('GET', '/mon-compte');
  }
  modifierMonCompte(donnees: ModificationMonCompte): Promise<Utilisateur> {
    return this.requete<Utilisateur>('PUT', '/mon-compte', donnees);
  }
  supprimerMonCompte(motDePasseActuel: string): Promise<Vide> {
    return this.requete<Vide>('DELETE', '/mon-compte', { motDePasseActuel });
  }
}

La couche d'accès à l'API : une méthode par route.

  • lignes 40-48 : une seule méthode fait toutes les requêtes. [http.request] accepte la méthode HTTP en paramètre ; [firstValueFrom] en fait une promesse ;
  • ligne 45 : la requête porte le jeton de contexte [IGNORER_401] : vrai pour les routes qui traitent elles-mêmes le 401. Un mauvais mot de passe renvoie 401 : ce n'est pas une session expirée ! De même, [GET /api/auth/moi] renvoie 401 quand personne n'est connecté, ce qui est normal ;
  • lignes 77-83 : [HttpParams] fabrique les paramètres de l'adresse (?jour=...&idMedecin=...). Un objet [HttpParams] ne se modifie pas : [set] rend un nouvel objet ;
  • ligne 85 : [idClient ?? undefined] : un [idClient] absent n'est pas envoyé du tout ;
  • lignes 92-106 : les médecins et les clients se gèrent de la même façon : le type (['medecins'] ou ['clients']) fait partie de l'adresse ;
  • ligne 119 : un DELETE avec un corps : le mot de passe actuel, exigé pour supprimer son compte.

10.4.4. Les langues

10.4.4.1. src/app/i18n/i18n.ts

// =========================================================================
// i18n/i18n.ts - le français et l'anglais (ngx-translate, cf. exemple 21)
// -------------------------------------------------------------------------
// Les dictionnaires fr.json et en.json sont ceux du cours NestJS
// (i18n/*.json), complétés de quelques clés propres au client
// (ERRORS.SERVER_UNREACHABLE...). Le serveur JSON ne renvoie que des CLÉS
// ('ERRORS.LOGIN_TAKEN') : ce sont les mêmes que celles des dictionnaires.
//
// La langue choisie est mémorisée dans localStorage (une préférence, pas
// une donnée sensible) ; à défaut, on prend celle du navigateur.
// =========================================================================

import { Provider } from '@angular/core';
import { provideTranslateLoader, provideTranslateService, TranslateLoader, TranslationObject } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import fr from './fr.json';
import en from './en.json';

export type Langue = 'fr' | 'en';
export const LANGUES: Langue[] = ['fr', 'en'];
export const CLE_STOCKAGE = 'rdvmedecins.langue';

// les dictionnaires font partie du code de l'application : pas de requête
class DictionnairesLoader implements TranslateLoader {
  getTranslation(langue: string): Observable<TranslationObject> {
    return of(langue === 'en' ? en : fr);
  }
}

export function langueDeDepart(): Langue {
  try {
    const memorisee = localStorage.getItem(CLE_STOCKAGE);
    if (memorisee === 'fr' || memorisee === 'en') {
      return memorisee;
    }
  } catch {
    // localStorage indisponible (navigation privée stricte...) : tant pis
  }
  return navigator.language.toLowerCase().startsWith('en') ? 'en' : 'fr';
}

export function provideI18n(): Provider[] {
  return provideTranslateService({
    loader: provideTranslateLoader(DictionnairesLoader),
    fallbackLang: 'fr',
    lang: langueDeDepart(),
  });
}

La configuration de [ngx-translate] de l'exemple 21 :

  • lignes 30-40 : la langue de départ : celle que l'utilisateur a choisie la dernière fois, sinon celle du navigateur. L'accès au [localStorage] peut échouer (certains modes de navigation privée) : on s'en passe alors.

10.4.4.2. src/app/i18n/langue.service.ts

// =========================================================================
// i18n/langue.service.ts - la langue courante, et le moyen d'en changer
// =========================================================================

import { inject, Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CLE_STOCKAGE, Langue } from './i18n';

@Injectable({ providedIn: 'root' })
export class LangueService {
  private readonly translate = inject(TranslateService);
  // un signal : 'fr' ou 'en'
  readonly langue = this.translate.currentLang;

  constructor() {
    document.documentElement.lang = this.translate.getCurrentLang() ?? 'fr';
  }

  // changer de langue : toute l'application se retraduit aussitôt
  changer(langue: Langue): void {
    this.translate.use(langue);
    document.documentElement.lang = langue;
    try {
      localStorage.setItem(CLE_STOCKAGE, langue);
    } catch {
      // sans importance
    }
  }
}
  • ligne 13 : la langue courante, un signal ;
  • lignes 20-28 : changer de langue : la page se retraduit (ligne 21), l'attribut [lang] de la page est mis à jour (ligne 22), le choix est mémorisé (ligne 24).

10.4.4.3. src/app/i18n/titre.strategy.ts

// =========================================================================
// i18n/titre.strategy.ts - le titre de l'onglet, traduit
// -------------------------------------------------------------------------
// Chaque route déclare un titre (title: 'AGENDA.PAGE_TITLE', cf.
// app.routes.ts). Par défaut, Angular l'écrit TEL QUEL dans l'onglet. Une
// TitleStrategy remplace ce comportement : updateTitle() est appelée après
// chaque navigation ; buildTitle() donne le titre de la route affichée
// (ici, une clé), que l'on traduit. Et quand la langue change, on retraduit
// le dernier titre.
// La classe est déclarée dans app.config.ts :
//     { provide: TitleStrategy, useClass: TitreTraduit }
// =========================================================================

import { inject, Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';

@Injectable({ providedIn: 'root' })
export class TitreTraduit extends TitleStrategy {
  private readonly title = inject(Title);
  private readonly translate = inject(TranslateService);
  private cle: string | undefined;

  constructor() {
    super();
    this.translate.onLangChange.subscribe(() => this.appliquer());
  }

  override updateTitle(snapshot: RouterStateSnapshot): void {
    this.cle = this.buildTitle(snapshot);
    this.appliquer();
  }

  private appliquer(): void {
    const application = this.translate.instant('APP.TITLE');
    this.title.setTitle(this.cle ? `${this.translate.instant(this.cle)} - ${application}` : application);
  }
}

Chaque route déclare un titre, qui est ici une clé de traduction ('AGENDA.PAGE_TITLE'). Par défaut, [Angular] l'écrirait telle quelle dans l'onglet. Cette classe remplace le service [TitleStrategy] du routeur (app.config.ts, ligne 23) :

  • ligne 20 : elle hérite de la classe [TitleStrategy] d'[Angular] ;
  • lignes 30-33 : [updateTitle] est appelée par le routeur après chaque navigation ; [buildTitle], héritée, donne le titre déclaré par la route affichée ;
  • lignes 35-38 : le titre traduit, suivi du nom de l'application : « Agenda - RdvMedecins » ;
  • ligne 27 : quand la langue change, on retraduit le dernier titre.

10.4.4.4. Les dictionnaires

Les dictionnaires [fr.json] et [en.json] sont ceux de l'application HTML du cours [NestJS], complétés de quelques clés propres au client ([ERRORS.SERVER_UNREACHABLE]...). Seule leur syntaxe des paramètres change : [{{min}}] pour [ngx-translate]. Le serveur JSON renvoie les mêmes clés.

{
  "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"
  },
  "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"
  },
  "AGENDA": {
    "PAGE_TITLE": "Agenda",
    "TITLE_PREFIX": "Agenda du",
    "COLUMN_SLOT": "Créneau",
    "COLUMN_STATUS": "Statut",
    "FREE": "Libre",
    "BOOK": "Réserver",
    "CANCEL_APPOINTMENT": "Annuler",
    "CANCEL_CONFIRM": "Annuler ce rendez-vous ?",
    "EMPTY_STATE": "Choisissez un médecin et un jour, puis cliquez sur « Voir l'agenda ».",
    "NO_SLOTS": "Ce médecin n'a aucun créneau horaire.",
    "BOOKED": "Rendez-vous enregistré.",
    "CANCELLED": "Rendez-vous annulé.",
    "BOOKED_BY_OTHER": "Réservé",
    "ALREADY_BOOKED_INFO": "Vous avez déjà un rendez-vous à venir : annulez-le pour en prendre un autre."
  },
  "BOOKING_DIALOG": {
    "TITLE": "Réserver ce créneau",
    "CLOSE_ARIA": "Fermer",
    "SLOT": "Créneau de {{horaire}}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choisir un patient --",
    "CANCEL": "Annuler",
    "CONFIRM": "Confirmer"
  },
  "PERSONNES": {
    "COLUMN_TITLE": "Titre",
    "COLUMN_FIRSTNAME": "Prénom",
    "COLUMN_LASTNAME": "Nom",
    "COLUMN_LOGIN": "Login",
    "EDIT": "Modifier",
    "DELETE": "Supprimer",
    "SAVE": "Enregistrer",
    "BACK": "Retour à la liste",
    "TITLE_LABEL": "Titre",
    "FIRSTNAME_LABEL": "Prénom",
    "LASTNAME_LABEL": "Nom",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Mot de passe",
    "LOGIN_HELP_CREATE": "Facultatif : par défaut, le nom en minuscules.",
    "LOGIN_HELP_EDIT": "Laisser vide pour ne pas le changer.",
    "PASSWORD_HELP_CREATE": "Facultatif : par défaut, identique au login.",
    "PASSWORD_HELP_EDIT": "Laisser vide pour ne pas le changer.",
    "ACCOUNT_SECTION": "Compte utilisateur"
  },
  "MEDECINS": {
    "TITLE": "Médecins",
    "ADD": "Ajouter un médecin",
    "EDIT": "Modifier un médecin",
    "EMPTY": "Aucun médecin.",
    "DELETE_CONFIRM": "Supprimer ce médecin, ses créneaux horaires et son compte utilisateur ?",
    "ADDED": "Le médecin {{nom}} a été ajouté (login : {{login}}).",
    "UPDATED": "Le médecin {{nom}} a été modifié.",
    "DELETED": "Le médecin {{nom}} a été supprimé."
  },
  "CLIENTS": {
    "TITLE": "Clients",
    "ADD": "Ajouter un client",
    "EDIT": "Modifier un client",
    "EMPTY": "Aucun client.",
    "DELETE_CONFIRM": "Supprimer ce client, son compte utilisateur et tous ses rendez-vous ?",
    "ADDED": "Le client {{nom}} a été ajouté (login : {{login}}).",
    "UPDATED": "Le client {{nom}} a été modifié.",
    "DELETED": "Le client {{nom}} a été supprimé."
  },
  "ERROR_PAGE": {
    "TITLE": "Erreur",
    "BACK": "Retour à l'accueil"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
    "DOCTOR_ALREADY_EXISTS": "Un médecin portant ce prénom et ce nom existe déjà.",
    "PATIENT_ALREADY_EXISTS": "Un client portant ce prénom et ce nom existe déjà.",
    "DOCTOR_HAS_APPOINTMENTS": "Ce médecin a des rendez-vous : annulez-les avant de le supprimer.",
    "SLOT_ALREADY_BOOKED": "Ce créneau vient d'être réservé par quelqu'un d'autre.",
    "CONCURRENT_UPDATE": "Cette fiche a été modifiée entre-temps par un autre utilisateur. Revenez à la liste et rouvrez-la avant de recommencer.",
    "DOCTOR_NOT_FOUND": "Ce médecin n'existe pas (ou plus).",
    "PATIENT_NOT_FOUND": "Ce client n'existe pas (ou plus).",
    "SLOT_NOT_FOUND": "Ce créneau n'existe pas (ou plus).",
    "APPOINTMENT_NOT_FOUND": "Ce rendez-vous n'existe pas (ou plus).",
    "INVALID_DATE": "La date demandée n'est pas valide.",
    "CHOOSE_PATIENT": "Choisissez un patient.",
    "INVALID_FORM": "Le formulaire contient des erreurs.",
    "INVALID_TITLE": "Choisissez un titre dans la liste.",
    "FIRSTNAME_REQUIRED": "Le prénom est obligatoire.",
    "FIRSTNAME_TOO_LONG": "Le prénom ne doit pas dépasser 20 caractères.",
    "LASTNAME_REQUIRED": "Le nom est obligatoire.",
    "LASTNAME_TOO_LONG": "Le nom ne doit pas dépasser 20 caractères.",
    "INVALID_LOGIN": "30 caractères au plus : lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
    "PASSWORD_TOO_LONG": "Le mot de passe ne doit pas dépasser 72 caractères.",
    "SESSION_EXPIRED": "Votre session a expiré : reconnectez-vous.",
    "BAD_REQUEST": "La requête est invalide.",
    "FORBIDDEN": "Votre rôle ne vous permet pas d'accéder à cette page.",
    "NOT_FOUND": "Cette page n'existe pas.",
    "SERVER_ERROR": "Une erreur inattendue s'est produite. Réessayez plus tard.",
    "TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {{minutes}} minute(s).",
    "WRONG_CAPTCHA": "Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.",
    "TOO_MANY_ACCOUNTS": "Trop de comptes ont été créés depuis votre connexion. Réessayez dans {{minutes}} minute(s).",
    "LOGIN_REQUIRED": "Le login est obligatoire.",
    "PASSWORD_REQUIRED": "Le mot de passe est obligatoire.",
    "PASSWORD_TOO_SHORT": "Le mot de passe doit comporter au moins {{min}} caractères.",
    "CONFIRMATION_REQUIRED": "Confirmez le mot de passe.",
    "PASSWORDS_DIFFER": "La confirmation ne correspond pas au mot de passe.",
    "CURRENT_PASSWORD_REQUIRED": "Saisissez votre mot de passe actuel.",
    "WRONG_CURRENT_PASSWORD": "Mot de passe actuel incorrect.",
    "PAST_DATE": "On ne peut pas prendre de rendez-vous à une date passée.",
    "PATIENT_HAS_UPCOMING_APPOINTMENT": "Ce patient a déjà un rendez-vous à venir : il faut l'annuler avant d'en prendre un autre.",
    "CAPTCHA_BEFORE_ACCOUNT": "Pour créer un compte, recopiez d'abord correctement les caractères de l'image, puis cliquez sur « Créer un compte client ».",
    "AUTH_REQUIRED": "Connectez-vous pour accéder à cette page.",
    "JSON_REQUIRED": "Requête refusée : le serveur attend du JSON.",
    "SERVER_UNREACHABLE": "Le serveur ne répond pas. Réessayez plus tard."
  },
  "CAPTCHA": {
    "LABEL": "Recopiez les caractères de l'image",
    "IMAGE_ALT": "Image de vérification anti-robots",
    "NEW_IMAGE": "Autre image",
    "HELP": "Majuscules ou minuscules, peu importe."
  },
  "INSCRIPTION": {
    "TITLE": "Créer un compte client",
    "INTRO": "Ce compte vous permettra de prendre vous-même vos rendez-vous.",
    "LOGIN_HELP": "Lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
    "PASSWORD_HELP": "Au moins {{min}} caractères.",
    "SUBMIT": "Créer mon compte",
    "BACK_TO_LOGIN": "Retour à la connexion",
    "DONE": "Votre compte a été créé. Vous pouvez vous connecter avec le login {{login}}."
  },
  "COMPTE": {
    "CONFIRMATION_LABEL": "Confirmation du mot de passe"
  },
  "ESPACE": {
    "TITLE": "Mon espace",
    "HELLO": "Bonjour {{nom}}",
    "MANAGE_ACCOUNT": "Gérer mon compte",
    "MANAGE_ACCOUNT_HELP": "Modifier mes informations, mon mot de passe, ou supprimer mon compte.",
    "VIEW_AGENDA": "Voir un agenda",
    "VIEW_AGENDA_HELP": "Consulter l'agenda d'un médecin et réserver un créneau.",
    "MY_APPOINTMENT": "Mon rendez-vous",
    "SEE_IN_AGENDA": "voir dans l'agenda",
    "CANCEL_APPOINTMENT": "Annuler ce rendez-vous",
    "NO_APPOINTMENT": "Vous n'avez aucun rendez-vous à venir."
  },
  "MON_COMPTE": {
    "TITLE": "Gérer mon compte",
    "NEW_PASSWORD_LABEL": "Nouveau mot de passe",
    "CURRENT_PASSWORD_LABEL": "Mot de passe actuel",
    "CURRENT_PASSWORD_HELP": "Obligatoire pour enregistrer une modification.",
    "BACK": "Retour à mon espace",
    "DELETE_TITLE": "Supprimer mon compte",
    "DELETE_HELP": "Votre compte sera définitivement supprimé, ainsi que tous vos rendez-vous.",
    "DELETE_CONFIRM": "Supprimer définitivement votre compte ? Votre rendez-vous éventuel sera annulé.",
    "DELETE_SUBMIT": "Supprimer mon compte",
    "UPDATED": "Votre compte a été modifié.",
    "DELETED": "Votre compte a été supprimé."
  },
  "CONFIRMATION": {
    "TITLE": "Confirmation",
    "NO": "Non, revenir",
    "YES": "Oui, confirmer"
  }
}
{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Log out",
    "MENU_AGENDA": "Schedule",
    "MENU_DOCTORS": "Doctors",
    "MENU_PATIENTS": "Patients",
    "MENU_MY_SPACE": "My space",
    "LOADING": "Loading...",
    "LANGUAGE": "Language"
  },
  "LOGIN": {
    "TITLE": "Login",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Password",
    "SUBMIT": "Log in",
    "ERROR": "Incorrect login or password.",
    "DEMO_ACCOUNTS": "Demo accounts (password = login):",
    "DEMO_ADMIN": "ADMIN role: appointments + doctor and patient management",
    "DEMO_DOCTOR": "DOCTOR role: booking and cancelling appointments",
    "DEMO_USER": "USER role (patients): booking for themselves, managing their account",
    "CREATE_ACCOUNT": "Create a patient account",
    "CREATE_ACCOUNT_HELP": "To create an account, first type the characters shown in the image."
  },
  "DOCTOR_DAY_PICKER": {
    "DOCTOR_LABEL": "Doctor",
    "CHOOSE_DOCTOR": "-- choose a doctor --",
    "DAY_LABEL": "Day",
    "VIEW_AGENDA": "View schedule"
  },
  "AGENDA": {
    "PAGE_TITLE": "Schedule",
    "TITLE_PREFIX": "Schedule for",
    "COLUMN_SLOT": "Slot",
    "COLUMN_STATUS": "Status",
    "FREE": "Free",
    "BOOK": "Book",
    "CANCEL_APPOINTMENT": "Cancel",
    "CANCEL_CONFIRM": "Cancel this appointment?",
    "EMPTY_STATE": "Choose a doctor and a day, then click \"View schedule\".",
    "NO_SLOTS": "This doctor has no time slots.",
    "BOOKED": "Appointment booked.",
    "CANCELLED": "Appointment cancelled.",
    "BOOKED_BY_OTHER": "Booked",
    "ALREADY_BOOKED_INFO": "You already have an upcoming appointment: cancel it to book another one."
  },
  "BOOKING_DIALOG": {
    "TITLE": "Book this slot",
    "CLOSE_ARIA": "Close",
    "SLOT": "Slot {{horaire}}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choose a patient --",
    "CANCEL": "Cancel",
    "CONFIRM": "Confirm"
  },
  "PERSONNES": {
    "COLUMN_TITLE": "Title",
    "COLUMN_FIRSTNAME": "First name",
    "COLUMN_LASTNAME": "Last name",
    "COLUMN_LOGIN": "Login",
    "EDIT": "Edit",
    "DELETE": "Delete",
    "SAVE": "Save",
    "BACK": "Back to the list",
    "TITLE_LABEL": "Title",
    "FIRSTNAME_LABEL": "First name",
    "LASTNAME_LABEL": "Last name",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Password",
    "LOGIN_HELP_CREATE": "Optional: defaults to the last name in lower case.",
    "LOGIN_HELP_EDIT": "Leave empty to keep it unchanged.",
    "PASSWORD_HELP_CREATE": "Optional: defaults to the login.",
    "PASSWORD_HELP_EDIT": "Leave empty to keep it unchanged.",
    "ACCOUNT_SECTION": "User account"
  },
  "MEDECINS": {
    "TITLE": "Doctors",
    "ADD": "Add a doctor",
    "EDIT": "Edit a doctor",
    "EMPTY": "No doctors.",
    "DELETE_CONFIRM": "Delete this doctor, their time slots and their user account?",
    "ADDED": "Doctor {{nom}} has been added (login: {{login}}).",
    "UPDATED": "Doctor {{nom}} has been updated.",
    "DELETED": "Doctor {{nom}} has been deleted."
  },
  "CLIENTS": {
    "TITLE": "Patients",
    "ADD": "Add a patient",
    "EDIT": "Edit a patient",
    "EMPTY": "No patients.",
    "DELETE_CONFIRM": "Delete this patient, their user account and all their appointments?",
    "ADDED": "Patient {{nom}} has been added (login: {{login}}).",
    "UPDATED": "Patient {{nom}} has been updated.",
    "DELETED": "Patient {{nom}} has been deleted."
  },
  "ERROR_PAGE": {
    "TITLE": "Error",
    "BACK": "Back to the home page"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "This login is already used by another account.",
    "DOCTOR_ALREADY_EXISTS": "A doctor with this first and last name already exists.",
    "PATIENT_ALREADY_EXISTS": "A patient with this first and last name already exists.",
    "DOCTOR_HAS_APPOINTMENTS": "This doctor has appointments: cancel them before deleting the doctor.",
    "SLOT_ALREADY_BOOKED": "This slot has just been booked by someone else.",
    "CONCURRENT_UPDATE": "This record was changed by another user in the meantime. Go back to the list and reopen it before trying again.",
    "DOCTOR_NOT_FOUND": "This doctor does not exist (any more).",
    "PATIENT_NOT_FOUND": "This patient does not exist (any more).",
    "SLOT_NOT_FOUND": "This slot does not exist (any more).",
    "APPOINTMENT_NOT_FOUND": "This appointment does not exist (any more).",
    "INVALID_DATE": "The requested date is not valid.",
    "CHOOSE_PATIENT": "Choose a patient.",
    "INVALID_FORM": "The form contains errors.",
    "INVALID_TITLE": "Choose a title from the list.",
    "FIRSTNAME_REQUIRED": "The first name is required.",
    "FIRSTNAME_TOO_LONG": "The first name must not exceed 20 characters.",
    "LASTNAME_REQUIRED": "The last name is required.",
    "LASTNAME_TOO_LONG": "The last name must not exceed 20 characters.",
    "INVALID_LOGIN": "At most 30 characters: unaccented lower-case letters, digits, dot, hyphen, underscore.",
    "PASSWORD_TOO_LONG": "The password must not exceed 72 characters.",
    "SESSION_EXPIRED": "Your session has expired: please log in again.",
    "BAD_REQUEST": "The request is invalid.",
    "FORBIDDEN": "Your role does not allow you to access this page.",
    "NOT_FOUND": "This page does not exist.",
    "SERVER_ERROR": "An unexpected error occurred. Please try again later.",
    "TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minute(s).",
    "WRONG_CAPTCHA": "The characters you typed do not match the image. A new image is shown.",
    "TOO_MANY_ACCOUNTS": "Too many accounts have been created from your connection. Try again in {{minutes}} minute(s).",
    "LOGIN_REQUIRED": "The login is required.",
    "PASSWORD_REQUIRED": "The password is required.",
    "PASSWORD_TOO_SHORT": "The password must be at least {{min}} characters long.",
    "CONFIRMATION_REQUIRED": "Please confirm the password.",
    "PASSWORDS_DIFFER": "The confirmation does not match the password.",
    "CURRENT_PASSWORD_REQUIRED": "Enter your current password.",
    "WRONG_CURRENT_PASSWORD": "Incorrect current password.",
    "PAST_DATE": "Appointments cannot be booked for a past date.",
    "PATIENT_HAS_UPCOMING_APPOINTMENT": "This patient already has an upcoming appointment: it must be cancelled before booking another one.",
    "CAPTCHA_BEFORE_ACCOUNT": "To create an account, first type the characters shown in the image correctly, then click \"Create a patient account\".",
    "AUTH_REQUIRED": "Please log in to access this page.",
    "JSON_REQUIRED": "Request refused: the server expects JSON.",
    "SERVER_UNREACHABLE": "The server does not respond. Please try again later."
  },
  "CAPTCHA": {
    "LABEL": "Type the characters shown in the image",
    "IMAGE_ALT": "Anti-robot verification image",
    "NEW_IMAGE": "Another image",
    "HELP": "Upper or lower case, it does not matter."
  },
  "INSCRIPTION": {
    "TITLE": "Create a patient account",
    "INTRO": "With this account you can book your own appointments.",
    "LOGIN_HELP": "Unaccented lower-case letters, digits, dot, hyphen, underscore.",
    "PASSWORD_HELP": "At least {{min}} characters.",
    "SUBMIT": "Create my account",
    "BACK_TO_LOGIN": "Back to login",
    "DONE": "Your account has been created. You can now log in with the login {{login}}."
  },
  "COMPTE": {
    "CONFIRMATION_LABEL": "Confirm password"
  },
  "ESPACE": {
    "TITLE": "My space",
    "HELLO": "Hello {{nom}}",
    "MANAGE_ACCOUNT": "Manage my account",
    "MANAGE_ACCOUNT_HELP": "Change my details or password, or delete my account.",
    "VIEW_AGENDA": "View a schedule",
    "VIEW_AGENDA_HELP": "See a doctor's schedule and book a slot.",
    "MY_APPOINTMENT": "My appointment",
    "SEE_IN_AGENDA": "see in the schedule",
    "CANCEL_APPOINTMENT": "Cancel this appointment",
    "NO_APPOINTMENT": "You have no upcoming appointment."
  },
  "MON_COMPTE": {
    "TITLE": "Manage my account",
    "NEW_PASSWORD_LABEL": "New password",
    "CURRENT_PASSWORD_LABEL": "Current password",
    "CURRENT_PASSWORD_HELP": "Required to save any change.",
    "BACK": "Back to my space",
    "DELETE_TITLE": "Delete my account",
    "DELETE_HELP": "Your account will be permanently deleted, together with all your appointments.",
    "DELETE_CONFIRM": "Permanently delete your account? Any appointment you have will be cancelled.",
    "DELETE_SUBMIT": "Delete my account",
    "UPDATED": "Your account has been updated.",
    "DELETED": "Your account has been deleted."
  },
  "CONFIRMATION": {
    "TITLE": "Confirmation",
    "NO": "No, go back",
    "YES": "Yes, confirm"
  }
}

10.4.5. Les services

10.4.5.1. src/app/services/session.service.ts

// =========================================================================
// services/session.service.ts - QUI est connecté ?
// -------------------------------------------------------------------------
// L'utilisateur connecté (ou null), et ce qu'on en déduit (est-il ADMIN ?
// patient ?), dans un service racine à signaux (cf. exemple 20).
//
// Le jeton JWT lui-même n'est PAS ici : il est dans un cookie httpOnly que
// seul le navigateur manipule. Le service ne garde que la DESCRIPTION de
// l'utilisateur, renvoyée par le serveur ({ login, nom, role }).
//
// Au démarrage (et après un F5), le service est vide : verifier() demande
// au serveur, une seule fois, qui est connecté (GET /api/auth/moi).
// =========================================================================

import { computed, inject, Injectable, signal } from '@angular/core';
import { RdvMedecinsApi } from '../api/rdvmedecins.api';
import { Role, Utilisateur } from '../api/types';

@Injectable({ providedIn: 'root' })
export class SessionService {
  private readonly api = inject(RdvMedecinsApi);
  private readonly _utilisateur = signal<Utilisateur | null>(null);
  // la question "qui est connecté ?" a-t-elle déjà été posée au serveur ?
  private verification: Promise<void> | null = null;

  readonly utilisateur = this._utilisateur.asReadonly();
  readonly connecte = computed(() => this._utilisateur() !== null);
  readonly role = computed<Role | null>(() => this._utilisateur()?.role ?? null);
  readonly estAdmin = computed(() => this.role() === 'ADMIN');
  readonly estPatient = computed(() => this.role() === 'USER');
  // ADMIN et DOCTOR : prennent et annulent les rendez-vous de n'importe quel patient
  readonly estGestionnaire = computed(() => this.role() === 'ADMIN' || this.role() === 'DOCTOR');

  // la page d'accueil de chaque rôle, juste après la connexion
  readonly pageAccueil = computed(() => (this.estPatient() ? '/mon-espace' : '/agenda'));

  // appelée par le garde du routeur avant CHAQUE navigation ; ne questionne
  // le serveur que la première fois (la promesse est gardée et réutilisée)
  verifier(): Promise<void> {
    this.verification ??= this.api
      .moi()
      .then((u) => this._utilisateur.set(u))
      .catch(() => this._utilisateur.set(null)); // 401 : personne ; ou serveur injoignable
    return this.verification;
  }

  async connecter(login: string, password: string, captchaId: string, captcha: string): Promise<void> {
    this._utilisateur.set(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._utilisateur.set(u);
  }

  async deconnecter(): Promise<void> {
    try {
      await this.api.deconnecter();
    } finally {
      this.oublier();
    }
  }

  // sans rien demander au serveur : session expirée, compte supprimé
  oublier(): void {
    this._utilisateur.set(null);
  }
}

L'utilisateur connecté, dans un service à signaux (exemple 20) :

  • ligne 22 : l'état : l'utilisateur, ou [null]. Le jeton, lui, n'est pas ici : il est dans un cookie que seul le navigateur manipule ;
  • lignes 26-32 : ce que les composants lisent : l'utilisateur, et ce qu'on en déduit (est-il connecté ? ADMIN ? patient ?) ;
  • ligne 35 : la page d'accueil de chaque rôle ;
  • lignes 39-45 : au démarrage (et après un F5), le service ne sait pas qui est connecté : il le demande au serveur. [verifier] est appelée par le garde du routeur avant CHAQUE navigation, mais ne questionne le serveur que la première fois : la promesse est gardée (ligne 40, [??=] : « si elle n'existe pas encore ») et rendue à chaque appel suivant. Si deux navigations se suivent de près, elles attendent la même réponse ;
  • ligne 43 : 401 (personne) ou serveur injoignable : pas d'utilisateur ;
  • lignes 56-62 : la déconnexion ; [finally] : on oublie l'utilisateur même si le serveur ne répond pas ;
  • lignes 65-67 : oublier l'utilisateur sans rien demander au serveur : c'est ce que fait l'intercepteur sur un 401.

10.4.5.2. src/app/services/messages.service.ts

// =========================================================================
// services/messages.service.ts - les messages affichés à l'utilisateur
// -------------------------------------------------------------------------
// Même présentation que l'application HTML :
//   - un SUCCÈS s'affiche en HAUT de la page (bandeau vert) ;
//   - une ERREUR en BAS, sur fond rose léger (zone "collante").
// 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 de
// page (réservation, ajout d'un médecin...) dépose son message avec flash() ;
// il s'affichera sur la page SUIVANTE, puis disparaîtra à la navigation
// d'après. Le service le sait parce qu'il écoute les événements du routeur
// (fin de chaque navigation). C'était, côté serveur, le rôle du cookie de
// courte durée "rdvmedecins_flash".
// =========================================================================

import { Injectable, inject, signal } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { ErreurApi } from '../api/erreur-api';

export interface Message {
  cle: string;
  params?: Record<string, string>;
}

@Injectable({ providedIn: 'root' })
export class MessagesService {
  readonly succes = signal<Message | null>(null);
  readonly erreur = signal<Message | null>(null);
  // les messages à afficher sur la page suivante
  private enAttente: { succes: Message | null; erreur: Message | null } | null = null;

  constructor() {
    // une nouvelle page (y compris quand seule la query change, /agenda?...) :
    // les messages de la page précédente disparaissent, les messages flash
    // apparaissent. Un service racine vit aussi longtemps que l'application :
    // pas besoin de se désabonner.
    inject(Router).events.subscribe((e) => {
      if (e instanceof NavigationEnd) {
        this.nouvellePage();
      }
    });
  }

  afficherSucces(cle: string, params?: Record<string, string>): void {
    this.succes.set({ cle, params });
    this.erreur.set(null);
  }

  afficherErreur(cle: string, params?: Record<string, string>): void {
    this.erreur.set({ cle, params });
    this.succes.set(null);
  }

  // une ErreurApi -> le message de la page ; toute autre erreur est une
  // anomalie du client, qu'on laisse remonter (console du navigateur)
  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 la page SUIVANTE
  flash(type: 'succes' | 'erreur', cle: string, params?: Record<string, string>): void {
    this.enAttente ??= { succes: null, erreur: null };
    this.enAttente[type] = { cle, params };
  }

  private nouvellePage(): void {
    this.succes.set(this.enAttente?.succes ?? null);
    this.erreur.set(this.enAttente?.erreur ?? null);
    this.enAttente = null;
  }
}

Les messages affichés à l'utilisateur, comme dans l'application HTML : un succès en haut de la page, une erreur en bas. Un message est une clé de traduction : il se retraduit tout seul si l'utilisateur change de langue.

  • lignes 29-30 : les deux messages de la page courante ;
  • lignes 58-63 : une [ErreurApi] devient le message d'erreur de la page ; toute autre erreur est une anomalie du client, qu'on laisse remonter ;
  • lignes 71-74 : un message flash, pour la page suivante. Une action qui se termine par un changement de page (un médecin ajouté, retour à la liste) dépose ainsi son message ;
  • lignes 39-43 : le service écoute les événements du routeur (exemple 17). À chaque fin de navigation (lignes 76-80), les messages de la page précédente disparaissent et les messages flash apparaissent. C'était, côté serveur, le rôle d'un cookie de courte durée.

10.4.5.3. src/app/services/confirmation.service.ts

// =========================================================================
// services/confirmation.service.ts - « Êtes-vous sûr ? »
// -------------------------------------------------------------------------
// Repris de l'exemple 16 : confirmer(cle) ouvre la fenêtre de confirmation
// (components/fenetre-confirmation.ts, placée une fois dans App) et renvoie
// une promesse résolue à true (Oui) ou false (Non). Le message est une CLÉ
// de traduction : la fenêtre le traduit dans la langue courante.
//
//     if (await this.confirmation.confirmer('AGENDA.CANCEL_CONFIRM')) { ... }
// =========================================================================

import { inject, Injectable, signal } from '@angular/core';
import { MessagesService } from './messages.service';

@Injectable({ providedIn: 'root' })
export class ConfirmationService {
  private readonly messages = inject(MessagesService);
  private readonly _cleMessage = signal<string | null>(null); // null : fenêtre fermée
  readonly cleMessage = this._cleMessage.asReadonly();
  private repondre: ((oui: boolean) => void) | null = null;

  confirmer(cle: string): Promise<boolean> {
    this.repondre?.(false); // une question encore ouverte ? elle vaut "non"
    // une nouvelle question : les messages de l'action précédente sont périmés
    this.messages.effacer();
    this._cleMessage.set(cle);
    return new Promise<boolean>((resolve) => {
      this.repondre = resolve;
    });
  }

  reponse(oui: boolean): void {
    this._cleMessage.set(null);
    const r = this.repondre;
    this.repondre = null;
    r?.(oui);
  }
}

Le service de l'exemple 16. Le message est désormais une clé de traduction (ligne 18), et une nouvelle question efface les messages de l'action précédente (ligne 25).

10.4.5.4. src/app/services/envoi-formulaire.ts

// =========================================================================
// services/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 dans le navigateur (elles
// vivent en UN seul endroit, le serveur), et en cas de refus :
//   - l'erreur de chaque champ (ErreurApi.champs) est attachée au champ du
//     formulaire à signaux, qui l'affiche encadré de rouge ;
//   - le message général (ErreurApi.cle) s'affiche en bas de la page.
// Cette classe regroupe ces gestes. Une page en crée une par formulaire :
//
//     protected readonly envoi = new EnvoiFormulaire(this.f);
//     if (await this.envoi.envoyer(() => this.api.inscrire(this.modele()))) { ... }
//
// Elle s'utilise dans un contexte d'injection (initialisation d'un champ
// de la classe du composant) : elle y obtient le service des messages.
// =========================================================================

import { inject, signal } from '@angular/core';
import { FieldTree, isFieldTree, submit, ValidationError } from '@angular/forms/signals';
import { ErreurApi } from '../api/erreur-api';
import { MessagesService } from './messages.service';

export class EnvoiFormulaire<T> {
  private readonly messages = inject(MessagesService);
  // les paramètres des messages d'erreur ({min}...), renvoyés par le serveur
  readonly params = signal<Record<string, string>>({});

  constructor(private readonly formulaire: FieldTree<T>) {}

  // exécute l'envoi ; renvoie true s'il a réussi. En cas d'ErreurApi, les
  // erreurs sont affichées et on renvoie false.
  // ignorerValidateurs : envoyer même si les règles du schéma du formulaire
  // (s'il en a) ne sont pas respectées
  async envoyer(action: () => Promise<unknown>, ignorerValidateurs = false): Promise<boolean> {
    this.params.set({});
    let reussi = false;
    await submit(this.formulaire, {
      ignoreValidators: ignorerValidateurs ? 'all' : 'pending',
      action: async () => {
        try {
          await action();
          reussi = true;
          return null; // pas d'erreur
        } catch (e) {
          this.messages.afficherErreurApi(e); // (relance toute erreur qui n'est pas une ErreurApi)
          const erreur = e as ErreurApi;
          this.params.set(erreur.params);
          return this.erreursDesChamps(erreur);
        }
      },
    });
    return reussi;
  }

  // { nom: 'ERRORS.LASTNAME_REQUIRED' } -> une erreur attachée au champ f.nom
  private erreursDesChamps(e: ErreurApi): ValidationError.WithOptionalFieldTree[] {
    const champs = this.formulaire as unknown as Record<string, unknown>;
    return Object.entries(e.champs)
      .filter(([nom]) => isFieldTree(champs[nom]))
      .map(([nom, cle]) => ({ kind: 'serveur', message: cle, fieldTree: champs[nom] as FieldTree<unknown> }));
  }
}

Tous les formulaires de l'application suivent le schéma de l'exemple 25 : les règles sont vérifiées par le serveur, et en cas de refus, l'erreur de chaque champ s'affiche sous ce champ, le message général en bas de la page. Cette classe regroupe ces gestes, pour ne pas les répéter dans chaque page :

  • ligne 24 : une classe générique : [T] est le type du modèle du formulaire ;
  • ligne 25 : [inject] dans l'initialisation d'un champ : la classe doit être créée dans un contexte d'injection, c'est-à-dire lors de l'initialisation d'un champ d'un composant (ligne 12) ;
  • ligne 27 : les paramètres des messages d'erreur ([{{min}}]...), renvoyés par le serveur ;
  • lignes 35-54 : l'envoi. [submit] (exemple 25) exécute l'action ; la méthode rend [true] si l'action a réussi ;
  • ligne 39 : [ignoreValidators] : par défaut (['pending']), [submit] n'exécute pas l'action si le formulaire a des erreurs de validation (la page de connexion a un schéma, avec deux champs obligatoires). ['all'] : on envoie quand même (voir la page de connexion) ;
  • ligne 46 : le message général ;
  • lignes 57-62 : les erreurs des champs. [isFieldTree] vérifie que le formulaire a bien un champ de ce nom : le serveur peut signaler l'erreur d'un champ que le formulaire n'affiche pas.

10.4.6. La mise en forme

10.4.6.1. src/app/pipes/formatage.ts

// =========================================================================
// pipes/formatage.ts - l'affichage des personnes, heures et 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. Des fonctions, utilisées par le code, et des pipes (fichier
// formatage.pipes.ts), utilisés par les templates.
// =========================================================================

import { Creneau, Identite } from '../api/types';

// "Mme Alice DURAND"
export function libelle(p: Identite): string {
  return `${p.titre} ${p.prenom} ${p.nom}`;
}

// "8:05" plutôt que "8:5"
export function heure(h: number, m: number): string {
  return `${h}:${String(m).padStart(2, '0')}`;
}

// "8:00 - 8:20"
export function horaire(c: Creneau): string {
  return `${heure(c.hdebut, c.mdebut)} - ${heure(c.hfin, c.mfin)}`;
}

// la date du jour, au format "yyyy-MM-dd" (celui de <input type="date"> et de l'API)
export function aujourdhui(): string {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

// vrai si [jour] est une date qui existe au format yyyy-MM-dd
export function estUneDateValide(jour: unknown): jour is string {
  if (typeof jour !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(jour)) {
    return false;
  }
  const date = new Date(`${jour}T00:00:00Z`);
  return !isNaN(date.getTime()) && date.toISOString().slice(0, 10) === jour;
}

// "lundi 5 octobre 2026" / "Monday 5 October 2026"
export function jourLong(jour: string, langue: string | null): string {
  return new Intl.DateTimeFormat(langue === 'en' ? 'en-GB' : 'fr-FR', {
    dateStyle: 'full',
    timeZone: 'UTC', // "2026-10-05T00:00:00Z" doit rester le 5, quel que soit le fuseau
  }).format(new Date(`${jour}T00:00:00Z`));
}

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 :

  • lignes 34-40 : une date valide ? [2026-02-30] a la bonne forme, mais n'existe pas : [new Date] la transformerait en 2 mars. On vérifie donc qu'elle se relit à l'identique. [jour is string] : si la fonction rend vrai, [TypeScript] sait que [jour] est une chaîne ;
  • lignes 43-48 : [Intl.DateTimeFormat], fourni par le navigateur, écrit la date dans la langue demandée. Le fuseau UTC évite qu'un navigateur situé à l'ouest de Greenwich affiche la veille.

10.4.6.2. src/app/pipes/formatage.pipes.ts

// =========================================================================
// pipes/formatage.pipes.ts - les fonctions de formatage.ts, en pipes
// -------------------------------------------------------------------------
//     {{ medecin | libelle }}          "Mme Marie PELISSIER"
//     {{ creneau | horaire }}          "8:00 - 8:20"
//     {{ jour | jourLong: langue() }}  "lundi 5 octobre 2026"
// jourLong reçoit la langue en paramètre : un pipe pur n'est recalculé que
// si l'un de ses arguments change - changer de langue le recalcule donc.
// =========================================================================

import { Pipe, PipeTransform } from '@angular/core';
import { Creneau, Identite } from '../api/types';
import { horaire, jourLong, libelle } from './formatage';

@Pipe({ name: 'libelle' })
export class LibellePipe implements PipeTransform {
  transform(p: Identite): string {
    return libelle(p);
  }
}

@Pipe({ name: 'horaire' })
export class HorairePipe implements PipeTransform {
  transform(c: Creneau): string {
    return horaire(c);
  }
}

@Pipe({ name: 'jourLong' })
export class JourLongPipe implements PipeTransform {
  transform(jour: string, langue: string | null): string {
    return jourLong(jour, langue);
  }
}

Les mêmes fonctions, en pipes, pour les templates. [jourLong] reçoit la langue en paramètre : un pipe pur (exemple 09) n'est recalculé que si l'un de ses arguments change ; changer de langue le recalcule donc.

10.4.7. Le routeur

10.4.7.1. src/app/app.routes.ts

// =========================================================================
// app.routes.ts - les pages de l'application et leurs droits d'accès
// -------------------------------------------------------------------------
// Les MÊMES adresses que l'application HTML (/login, /agenda, /medecins,
// /mon-espace...), mais ce sont maintenant des routes du CLIENT. Le
// navigateur n'envoie une requête au serveur que pour la toute première
// page ; ensuite, le routeur change l'adresse et le composant affiché.
//
// Revers de la médaille : si l'utilisateur recharge /agenda (F5), le
// navigateur demande bel et bien /agenda au serveur, qui doit alors
// renvoyer index.html (ng serve le fait en développement ; en production,
// cf. web/client-spa.ts du serveur JSON).
//
// Toutes les pages sont les ENFANTS (children) d'une route sans composant,
// qui porte le garde gardeSession (canActivateChild) : il s'applique ainsi
// à chacune d'elles. Chaque route déclare :
//   - title : clé de traduction du titre de l'onglet (cf. i18n/titre.strategy.ts) ;
//   - data  : public (accessible sans être connecté), roles (les rôles
//             autorisés), type ('medecins' ou 'clients'), code (403, 404).
// Grâce à withComponentInputBinding (app.config.ts), les paramètres de
// l'adresse (:id, ?jour=...) ET les données (type, code) arrivent dans les
// ENTRÉES des composants des pages.
// =========================================================================

import { Routes } from '@angular/router';
import { TypePersonnes } from './api/rdvmedecins.api';
import { Role } from './api/types';
import { gardeInscription, gardeSession, idNumerique } from './gardes';
import { ConnexionPage } from './pages/connexion/connexion';
import { AgendaPage } from './pages/agenda/agenda';

// les routes de gestion des médecins ET des clients (ADMIN), sur le même
// modèle ; les pages sont chargées à la demande (loadComponent)
function routesPersonnes(type: TypePersonnes, titre: string, titreAjout: string, titreModif: string): Routes {
  const data = { roles: ['ADMIN'] as Role[], type };
  return [
    { path: type, loadComponent: () => import('./pages/personnes/personnes'), title: titre, data },
    { path: `${type}/ajouter`, loadComponent: () => import('./pages/personne-formulaire/personne-formulaire'), title: titreAjout, data },
    {
      path: `${type}/:id/modifier`,
      canMatch: [idNumerique],
      loadComponent: () => import('./pages/personne-formulaire/personne-formulaire'),
      title: titreModif,
      data,
    },
  ];
}

export const routes: Routes = [
  {
    path: '',
    canActivateChild: [gardeSession],
    children: [
      // "/" : vers /login, qui renvoie lui-même à la page d'accueil du rôle si l'on est connecté
      { path: '', pathMatch: 'full', redirectTo: 'login' },
      { path: 'login', component: ConnexionPage, title: 'LOGIN.TITLE', data: { public: true } },
      { path: 'agenda', component: AgendaPage, title: 'AGENDA.PAGE_TITLE' },
      ...routesPersonnes('medecins', 'MEDECINS.TITLE', 'MEDECINS.ADD', 'MEDECINS.EDIT'),
      ...routesPersonnes('clients', 'CLIENTS.TITLE', 'CLIENTS.ADD', 'CLIENTS.EDIT'),
      {
        path: 'inscription',
        loadComponent: () => import('./pages/inscription/inscription'),
        title: 'INSCRIPTION.TITLE',
        data: { public: true },
        canActivate: [gardeInscription],
      },
      {
        path: 'mon-espace',
        loadComponent: () => import('./pages/espace-patient/espace-patient'),
        title: 'ESPACE.TITLE',
        data: { roles: ['USER'] },
      },
      {
        path: 'mon-compte',
        loadComponent: () => import('./pages/mon-compte/mon-compte'),
        title: 'MON_COMPTE.TITLE',
        data: { roles: ['USER'] },
      },
      { path: 'interdit', loadComponent: () => import('./pages/erreur/erreur'), title: 'ERROR_PAGE.TITLE', data: { code: 403 } },
      {
        path: '**',
        loadComponent: () => import('./pages/erreur/erreur'),
        title: 'ERROR_PAGE.TITLE',
        data: { code: 404, public: true },
      },
    ],
  },
];

Les mêmes adresses que l'application HTML (/login, /agenda, /medecins, /mon-espace...), mais ce sont maintenant des routes du client :

  • lignes 50-53 : toutes les pages sont les enfants ([children]) d'une route sans composant, qui porte le garde [gardeSession] : [canActivateChild] l'applique à chacune d'elles, sans avoir à le répéter ;
  • ligne 55 : l'adresse / mène à la page de connexion, qui renvoie elle-même à la page d'accueil du rôle si l'on est connecté ;
  • lignes 56-57 : les deux pages les plus visitées font partie du fichier principal ; les autres sont chargées à la demande ([loadComponent]) ;
  • [title] : la clé de traduction du titre de l'onglet ;
  • [data] : [public] (accessible sans être connecté), [roles] (les rôles autorisés ; absent : tous les rôles connectés), [type] (['medecins'] ou ['clients']), [code] (403, 404). Grâce à [withComponentInputBinding], les données de la route arrivent, elles aussi, dans les entrées du composant de la page, comme les paramètres de l'adresse ;
  • lignes 34-47 : les trois routes de la gestion des médecins et celles des clients sont fabriquées par la même fonction : les deux gestions utilisent les mêmes pages, avec un [type] différent ;
  • ligne 41 : [canMatch] : un garde qui décide si la route correspond à l'adresse. S'il refuse, le routeur essaie les routes suivantes : /medecins/abc/modifier mène ainsi à la page 404 ;
  • ligne 65 : un garde propre à la page de création de compte ;
  • ligne 79 : la page 403, vers laquelle le garde renvoie ;
  • lignes 80-85 : toute autre adresse : la page 404.

10.4.7.2. src/app/gardes.ts

// =========================================================================
// gardes.ts - les gardes du routeur
// -------------------------------------------------------------------------
// gardeSession : le garde de TOUTES les pages (cf. app.routes.ts). Avant
// chaque navigation :
//   1. la première fois (démarrage, F5), il demande au serveur qui est
//      connecté (SessionService.verifier) ;
//   2. il vérifie les droits décrits dans les données (data) de la route :
//        - public : accessible sans être connecté ;
//        - roles  : les rôles autorisés (absent = tous les rôles connectés).
//
// RAPPEL : ces contrôles ne sont qu'un CONFORT (ne pas afficher une page
// inutile). La protection des données est assurée par le serveur, qui
// revérifie le jeton et le rôle à CHAQUE requête.
//
// ATTENTION : inject() n'est permis qu'au DÉBUT d'un garde, avant tout
// await (après, le "contexte d'injection" est perdu).
// =========================================================================

import { inject } from '@angular/core';
import { CanActivateChildFn, CanActivateFn, CanMatchFn, Router } from '@angular/router';
import { Role } from './api/types';
import { RdvMedecinsApi } from './api/rdvmedecins.api';
import { SessionService } from './services/session.service';
import { MessagesService } from './services/messages.service';

export const gardeSession: CanActivateChildFn = async (route, etat) => {
  const session = inject(SessionService);
  const router = inject(Router);
  await session.verifier();

  if (route.data['public']) {
    // déjà connecté : la page de connexion n'a plus de sens
    if (route.routeConfig?.path === 'login' && session.connecte()) {
      return router.parseUrl(session.pageAccueil());
    }
    return true;
  }
  if (!session.connecte()) {
    // on retient la page demandée, pour y revenir après la connexion
    return router.createUrlTree(['/login'], { queryParams: { redirect: etat.url } });
  }
  const roles = route.data['roles'] as Role[] | undefined;
  if (roles && !roles.includes(session.role()!)) {
    return router.createUrlTree(['/interdit']);
  }
  return true;
};

// un garde PROPRE à la route /inscription : sans laissez-passer (captcha
// résolu sur la page de connexion), retour à la page de connexion
export const gardeInscription: CanActivateFn = async () => {
  const api = inject(RdvMedecinsApi);
  const messages = inject(MessagesService);
  const router = inject(Router);
  const { valide } = await api.etatAccesInscription().catch(() => ({ valide: false }));
  if (!valide) {
    messages.flash('erreur', 'ERRORS.CAPTCHA_BEFORE_ACCOUNT');
    return router.createUrlTree(['/login']);
  }
  return true;
};

// /medecins/3/modifier : la route ne CORRESPOND que si le 2e segment de
// l'adresse est un nombre (/medecins/abc/modifier mène à la page 404)
export const idNumerique: CanMatchFn = (_route, segments) => /^\d+$/.test(segments[1]?.path ?? '');
  • ligne 27 : un garde peut être [async] : le routeur attend sa réponse ;
  • lignes 28-30 : ATTENTION : [inject] n'est permis qu'au début du garde, avant tout [await] : après, le contexte d'injection est perdu ;
  • ligne 30 : la première fois, on demande au serveur qui est connecté ;
  • lignes 32-38 : une page publique ; mais la page de connexion n'a plus de sens pour un utilisateur connecté (ligne 34). [parseUrl] transforme une adresse en [UrlTree], la valeur qui commande une redirection ;
  • lignes 39-42 : pas connecté : à la page de connexion, en retenant la page demandée ;
  • lignes 43-46 : un rôle qui ne convient pas : à la page 403 ;
  • lignes 52-62 : la création d'un compte exige un « laissez-passer », obtenu en résolvant le captcha sur la page de connexion. Sans lui, retour à la page de connexion, avec un message ;
  • ligne 66 : le garde [canMatch] reçoit les segments de l'adresse : [['medecins', '3', 'modifier']].

Ces gardes ne sont qu'un confort : la protection des données est assurée par le serveur, qui revérifie le jeton et le rôle à chaque requête.

10.4.8. Les composants

10.4.8.1. src/app/components/barre-navigation

// =========================================================================
// components/barre-navigation/barre-navigation.ts - la barre de navigation
// -------------------------------------------------------------------------
// Même contenu que celle de l'application HTML (views/partials/navbar.hbs) :
// titre, menus selon le rôle, FR / EN, rôle, nom, déconnexion.
// "Médecins" et "Clients" n'apparaissent que pour l'ADMIN : un simple
// confort d'affichage, la vraie protection étant celle du serveur.
// =========================================================================

import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterLinkActive } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { SessionService } from '../../services/session.service';
import { LangueService } from '../../i18n/langue.service';
import { LANGUES } from '../../i18n/i18n';

@Component({
  selector: 'app-barre-navigation',
  imports: [RouterLink, RouterLinkActive, TranslatePipe],
  templateUrl: './barre-navigation.html',
})
export class BarreNavigation {
  protected readonly session = inject(SessionService);
  protected readonly langues = inject(LangueService);
  protected readonly LANGUES = LANGUES;
  private readonly router = inject(Router);

  protected async seDeconnecter(): Promise<void> {
    await this.session.deconnecter().catch(() => {}); // même si le serveur ne répond pas
    this.router.navigate(['/login']);
  }
}
  • ligne 29 : la déconnexion, même si le serveur ne répond pas ; puis retour à la page de connexion.
<nav class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
  <span class="navbar-brand mb-0">{{ 'APP.TITLE' | translate }}</span>

  @if (session.connecte()) {
    <ul class="navbar-nav me-auto">
      @if (session.estPatient()) {
        <li class="nav-item">
          <!-- routerLinkActive : la classe Bootstrap du menu de la page affichée -->
          <a class="nav-link" routerLink="/mon-espace" routerLinkActive="active">{{ 'APP.MENU_MY_SPACE' | translate }}</a>
        </li>
      }
      <li class="nav-item">
        <a class="nav-link" routerLink="/agenda" routerLinkActive="active">{{ 'APP.MENU_AGENDA' | translate }}</a>
      </li>
      @if (session.estAdmin()) {
        <li class="nav-item">
          <a class="nav-link" routerLink="/medecins" routerLinkActive="active">{{ 'APP.MENU_DOCTORS' | translate }}</a>
        </li>
        <li class="nav-item">
          <a class="nav-link" routerLink="/clients" routerLinkActive="active">{{ 'APP.MENU_PATIENTS' | translate }}</a>
        </li>
      }
    </ul>
  } @else {
    <div class="me-auto"></div>
  }

  <div class="d-flex align-items-center gap-2">
    <!-- FR / EN : aucune requête au serveur, la page se retraduit sur place -->
    <div class="btn-group btn-group-sm" role="group" [attr.aria-label]="'APP.LANGUAGE' | translate">
      @for (l of LANGUES; track l) {
        <button type="button" class="btn" [class]="langues.langue() === l ? 'btn-light' : 'btn-outline-light'"
                [attr.aria-pressed]="langues.langue() === l" (click)="langues.changer(l)">
          {{ l.toUpperCase() }}
        </button>
      }
    </div>

    @if (session.utilisateur(); as u) {
      <span class="badge text-bg-light text-primary">{{ u.role }}</span>
      <span class="text-white">{{ u.nom }}</span>
      <button type="button" class="btn btn-sm btn-outline-light" (click)="seDeconnecter()">{{ 'APP.LOGOUT' | translate }}</button>
    }
  </div>
</nav>
  • lignes 4-23 : les menus, selon le rôle ;
  • lignes 31-36 : les boutons FR / EN ; [aria-pressed] indique aux lecteurs d'écran le bouton enfoncé ;
  • lignes 39-43 : le rôle, le nom, la déconnexion.

10.4.8.2. src/app/components/message-succes.ts et zone-erreur.ts

// =========================================================================
// components/message-succes.ts - le message de SUCCÈS, en haut de la page
// (rendez-vous enregistré, médecin ajouté...) - cf. services/messages.service.ts.
// role="status" : un lecteur d'écran l'annonce sans interrompre l'utilisateur.
// =========================================================================

import { Component, inject } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { MessagesService } from '../services/messages.service';

@Component({
  selector: 'app-message-succes',
  imports: [TranslatePipe],
  template: `
    @if (messages.succes(); as m) {
      <div class="alert alert-success" role="status">{{ m.cle | translate: m.params }}</div>
    }
  `,
})
export class MessageSucces {
  protected readonly messages = inject(MessagesService);
}
// =========================================================================
// components/zone-erreur.ts - la zone d'erreur, en BAS de chaque page, sur
// fond rose léger (classe .zone-erreur, cf. styles.css) - comme
// views/partials/erreurs.hbs de l'application HTML.
// role="alert" : un lecteur d'écran annonce le message dès son apparition.
// =========================================================================

import { Component, inject } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { MessagesService } from '../services/messages.service';

@Component({
  selector: 'app-zone-erreur',
  imports: [TranslatePipe],
  template: `
    @if (messages.erreur(); as m) {
      <div class="zone-erreur" role="alert">{{ m.cle | translate: m.params }}</div>
    }
  `,
})
export class ZoneErreur {
  protected readonly messages = inject(MessagesService);
}

Deux petits composants qui affichent les messages du service [MessagesService], traduits avec leurs paramètres. Les rôles [status] et [alert] font annoncer les messages par les lecteurs d'écran.

10.4.8.3. src/app/components/fenetre-confirmation

// =========================================================================
// components/fenetre-confirmation/fenetre-confirmation.ts
// -------------------------------------------------------------------------
// La fenêtre « Êtes-vous sûr ? » de l'exemple 16, complétée pour le clavier
// comme public/js/app.js de l'application HTML :
//   - à l'ouverture, le focus va sur "Non, revenir" : un appui distrait sur
//     Entrée ne supprime rien ;
//   - Échap ferme la fenêtre (= Non) ;
//   - Tab et Maj+Tab restent DANS la fenêtre (sinon le focus partirait sur
//     la page grisée, derrière) ;
//   - à la fermeture, le focus revient à l'élément qui l'avait (le bouton
//     "Supprimer" cliqué, par exemple).
// "Non, revenir" plutôt qu'"Annuler" : la question est parfois « Annuler ce
// rendez-vous ? », et un bouton "Annuler" y serait ambigu.
// =========================================================================

import { Component, effect, ElementRef, inject, untracked, viewChild } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { ConfirmationService } from '../../services/confirmation.service';

@Component({
  selector: 'app-fenetre-confirmation',
  imports: [TranslatePipe],
  templateUrl: './fenetre-confirmation.html',
})
export class FenetreConfirmation {
  protected readonly confirmation = inject(ConfirmationService);
  private readonly fenetre = viewChild<ElementRef<HTMLElement>>('fenetre');
  private readonly boutonNon = viewChild<ElementRef<HTMLButtonElement>>('boutonNon');
  private focusPrecedent: HTMLElement | null = null;

  constructor() {
    // la fenêtre s'ouvre (le bouton "Non" apparaît) ou se ferme
    effect(() => {
      const bouton = this.boutonNon();
      untracked(() => {
        if (bouton) {
          this.focusPrecedent = document.activeElement as HTMLElement | null;
          bouton.nativeElement.focus();
        } else {
          this.focusPrecedent?.focus();
          this.focusPrecedent = null;
        }
      });
    });
  }

  protected clavier(e: KeyboardEvent): void {
    const fenetre = this.fenetre()?.nativeElement;
    if (e.key === 'Escape') {
      this.confirmation.reponse(false);
    } else if (e.key === 'Tab' && fenetre) {
      const boutons = [...fenetre.querySelectorAll<HTMLButtonElement>('button')];
      const premier = boutons[0];
      const dernier = boutons[boutons.length - 1];
      if (e.shiftKey && document.activeElement === premier) {
        e.preventDefault();
        dernier?.focus();
      } else if (!e.shiftKey && document.activeElement === dernier) {
        e.preventDefault();
        premier?.focus();
      }
    }
  }

  // un clic sur le fond grisé (et non dans la fenêtre elle-même) = Non
  protected clicFond(e: MouseEvent): void {
    if (e.target === e.currentTarget) {
      this.confirmation.reponse(false);
    }
  }
}

La fenêtre de l'exemple 16, complétée pour le clavier :

  • lignes 34-45 : un effet qui suit l'apparition et la disparition du bouton « Non ». À l'ouverture, on retient l'élément qui avait le focus et l'on donne le focus au bouton « Non » ; à la fermeture, le focus revient à l'élément retenu (le bouton « Supprimer » cliqué, par exemple). [untracked] : les signaux lus dans cette fonction ne deviennent pas des dépendances de l'effet ;
  • lignes 48-64 : le clavier. Échap répond « non » ; Tab et Maj+Tab restent dans la fenêtre : du dernier bouton, on revient au premier, et inversement. Sinon, le focus partirait sur la page grisée, derrière ;
  • lignes 67-71 : un clic sur le fond grisé répond « non ».
@if (confirmation.cleMessage(); as cle) {
  <div (keydown)="clavier($event)">
    <div class="modal-backdrop fade show"></div>
    <div #fenetre class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true"
         aria-labelledby="confirmation-titre" aria-describedby="confirmation-message" (click)="clicFond($event)">
      <div class="modal-dialog modal-dialog-centered">
        <div class="modal-content">
          <div class="modal-header">
            <h5 id="confirmation-titre" class="modal-title">{{ 'CONFIRMATION.TITLE' | translate }}</h5>
            <button type="button" class="btn-close" [attr.aria-label]="'BOOKING_DIALOG.CLOSE_ARIA' | translate"
                    (click)="confirmation.reponse(false)"></button>
          </div>
          <div class="modal-body">
            <p id="confirmation-message" class="mb-0">{{ cle | translate }}</p>
          </div>
          <div class="modal-footer">
            <button #boutonNon type="button" class="btn btn-secondary" (click)="confirmation.reponse(false)">
              {{ 'CONFIRMATION.NO' | translate }}
            </button>
            <button type="button" class="btn btn-danger" (click)="confirmation.reponse(true)">{{ 'CONFIRMATION.YES' | translate }}</button>
          </div>
        </div>
      </div>
    </div>
  </div>
}
  • ligne 2 : les touches sont écoutées sur l'élément qui contient la fenêtre ;
  • ligne 14 : le message, une clé traduite.

10.4.8.4. src/app/components/champ-saisie.ts

// =========================================================================
// components/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, avec un formulaire à signaux :
//
//   <app-champ-saisie [formField]="f.nom" idChamp="nom" libelle="PERSONNES.LASTNAME_LABEL" [longueurMax]="20" />
//
// C'est un CONTRÔLE DE FORMULAIRE "maison" : en implémentant l'interface
// FormValueControl, il accepte [formField] comme un <input> ordinaire.
// Le contrat est simple :
//   - value (un model()) : la valeur du champ, lue ET écrite par le
//     formulaire ;
//   - errors (une entrée facultative du contrat) : si le composant la
//     déclare, la directive [formField] y dépose les erreurs du champ -
//     ici, celles renvoyées par le SERVEUR (cf. pages/personne-formulaire.ts).
// libelle, aide et le message des erreurs sont des CLÉS de traduction.
// =========================================================================

import { booleanAttribute, Component, computed, input, model } from '@angular/core';
import { FormValueControl, ValidationError } from '@angular/forms/signals';
import { TranslatePipe } from '@ngx-translate/core';

@Component({
  selector: 'app-champ-saisie',
  imports: [TranslatePipe],
  // un composant est affiché "en ligne" par défaut : on en fait un bloc,
  // pour que les classes posées par le parent (mb-3, col-sm-6) s'appliquent
  styles: `
    :host {
      display: block;
    }
  `,
  template: `
    <label class="form-label" [for]="idChamp()">{{ libelle() | translate }}</label>
    <input class="form-control" [id]="idChamp()" [type]="type()" [value]="value()" (input)="saisir($event)"
           [class.is-invalid]="erreur()" [attr.maxlength]="longueurMax() ?? null" [autocomplete]="autocomplete()"
           [required]="requis()" [attr.aria-invalid]="erreur() ? 'true' : null" [attr.aria-describedby]="idChamp() + '-message'">
    @if (erreur(); as e) {
      <div class="invalid-feedback" [id]="idChamp() + '-message'">{{ e | translate: params() }}</div>
    } @else if (aide(); as a) {
      <div class="form-text" [id]="idChamp() + '-message'">{{ a | translate: params() }}</div>
    }
  `,
})
export class ChampSaisie implements FormValueControl<string> {
  // le contrat FormValueControl
  readonly value = model('');
  readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);

  // nos propres entrées
  // l'id du <input> (pas "id" : un attribut id resterait AUSSI posé sur
  // <app-champ-saisie>, et la page aurait deux éléments de même id)
  readonly idChamp = input.required<string>();
  readonly libelle = input.required<string>(); // clé de traduction
  readonly type = input<'text' | 'password'>('text');
  readonly aide = input<string>(); // clé du message d'aide
  readonly params = input<Record<string, string>>({}); // paramètres des messages ({min}...)
  readonly longueurMax = input<number>();
  readonly autocomplete = input('off');
  readonly requis = input(false, { transform: booleanAttribute });

  // la première erreur du champ (sa clé de traduction), s'il y en a une
  protected readonly erreur = computed(() => this.errors()[0]?.message);

  protected saisir(evenement: Event): void {
    this.value.set((evenement.target as HTMLInputElement).value);
  }
}

Un champ de formulaire complet : libellé, champ, message d'aide ou message d'erreur. Tous les formulaires de l'application l'utilisent. C'est un contrôle de formulaire personnalisé : en respectant le contrat [FormValueControl], il accepte la directive [[formField]] comme un [<input>] ordinaire :

  • ligne 46 : la classe implémente l'interface [FormValueControl<string>] : le compilateur vérifie qu'elle respecte le contrat ;
  • ligne 48 : [value], un [model()] (exemple 11) : la valeur du champ, que la directive [[formField]] lit ET écrit ;
  • ligne 49 : [errors], une entrée facultative du contrat : si le composant la déclare, la directive y dépose les erreurs du champ ;
  • lignes 54-61 : les entrées propres au composant. L'identifiant de l'élément [<input>] s'appelle [idChamp], et non [id] : un attribut [id] écrit sur [<app-champ-saisie>] resterait aussi posé sur cet élément, et la page aurait deux éléments de même identifiant ;
  • ligne 61 : [booleanAttribute] transforme la présence de l'attribut ([requis], sans valeur) en [true] ;
  • ligne 64 : la première erreur du champ : sa clé de traduction ;
  • lignes 36-38 : l'élément [<input>] affiche la valeur, et la renvoie à chaque frappe (lignes 66-68). [aria-invalid] et [aria-describedby] relient le champ à son message, pour les lecteurs d'écran ;
  • lignes 39-43 : le message d'erreur, sinon le message d'aide, traduits avec leurs paramètres ;
  • lignes 29-33 : [:host] désigne l'élément du composant lui-même ([<app-champ-saisie>]). Un élément inconnu du navigateur est affiché « en ligne » : on en fait un bloc, pour que les classes posées par le parent ([col-sm-6]) s'appliquent.

10.4.8.5. src/app/components/champs-personne.ts

// =========================================================================
// components/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" (l'équivalent du
// partiel views/partials/champsPersonne.hbs). Le parent lui passe les trois
// CHAMPS de son formulaire à signaux :
//     <app-champs-personne [titre]="f.titre" [prenom]="f.prenom" [nom]="f.nom" />
// Un champ (FieldTree) est une valeur comme une autre : on peut le passer
// en entrée d'un composant, qui le relie à ses propres éléments HTML.
// =========================================================================

import { Component, input } from '@angular/core';
import { FieldTree, FormField } from '@angular/forms/signals';
import { TranslatePipe } from '@ngx-translate/core';
import { ChampSaisie } from './champ-saisie';

@Component({
  selector: 'app-champs-personne',
  imports: [FormField, TranslatePipe, ChampSaisie],
  template: `
    <div class="mb-3">
      <label class="form-label" for="titre">{{ 'PERSONNES.TITLE_LABEL' | translate }}</label>
      <!-- titre() : le champ reçu en entrée ; titre()() : son état -->
      @let erreurTitre = titre()().errors()[0]?.message;
      <select id="titre" class="form-select" [formField]="titre()" [class.is-invalid]="erreurTitre">
        @for (t of TITRES; track t) {
          <option [value]="t">{{ t }}</option>
        }
      </select>
      @if (erreurTitre) {
        <div class="invalid-feedback">{{ erreurTitre | translate }}</div>
      }
    </div>
    <div class="row g-3 mb-3">
      <app-champ-saisie class="col-sm-6" idChamp="prenom" libelle="PERSONNES.FIRSTNAME_LABEL" [formField]="prenom()" [longueurMax]="20" requis />
      <app-champ-saisie class="col-sm-6" idChamp="nom" libelle="PERSONNES.LASTNAME_LABEL" [formField]="nom()" [longueurMax]="20" requis />
    </div>
  `,
})
export class ChampsPersonne {
  readonly titre = input.required<FieldTree<string>>();
  readonly prenom = input.required<FieldTree<string>>();
  readonly nom = input.required<FieldTree<string>>();
  protected readonly TITRES = ['Mr', 'Mme', 'Melle'];
}

Les champs titre, prénom et nom, communs à trois formulaires. Le parent passe à ce composant les trois champs de son formulaire à signaux :

  • lignes 42-44 : un champ ([FieldTree]) est une valeur comme une autre : on peut le recevoir en entrée ;
  • ligne 25 : [titre()] est le champ reçu en entrée ; [titre()()], son état ;
  • ligne 26 : la liste des titres est reliée au champ par [[formField]] ;
  • lignes 36-37 : les deux autres champs sont passés aux composants [ChampSaisie].

10.4.8.6. src/app/components/champ-captcha.ts

// =========================================================================
// components/champ-captcha.ts - l'image du captcha et le champ de saisie
// -------------------------------------------------------------------------
// Un contrôle de formulaire "maison" (FormValueControl, cf. champ-saisie.ts)
// qui charge un captcha (GET /api/captcha) dès sa création. Le parent :
//   - relie la saisie à son formulaire : [formField]="f.captcha" ;
//   - obtient le composant lui-même par viewChild(ChampCaptcha), pour
//     appeler ses méthodes PUBLIQUES : captchaId(), et recharger() 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).
//
// [innerHTML] + bypassSecurityTrustHtml : l'image SVG est insérée telle
// quelle. Sans danger ICI parce qu'elle est fabriquée par notre serveur
// (bibliothèque svg-captcha) et ne contient rien qui vienne d'un
// utilisateur - cf. exemple 22.
// =========================================================================

import { Component, computed, inject, input, model, signal } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { FormValueControl, ValidationError } from '@angular/forms/signals';
import { TranslatePipe } from '@ngx-translate/core';
import { RdvMedecinsApi } from '../api/rdvmedecins.api';
import { Captcha } from '../api/types';

@Component({
  selector: 'app-champ-captcha',
  imports: [TranslatePipe],
  styles: `
    :host {
      display: block;
    }
  `,
  template: `
    <div class="mb-3">
      <label class="form-label" for="captcha">{{ 'CAPTCHA.LABEL' | translate }}</label>
      <div class="d-flex align-items-center gap-3 mb-2">
        <div class="captcha-image" role="img" [attr.aria-label]="'CAPTCHA.IMAGE_ALT' | translate" [innerHTML]="svg()"></div>
        <a href="#" class="small" (click)="$event.preventDefault(); recharger()">{{ 'CAPTCHA.NEW_IMAGE' | translate }}</a>
      </div>
      <input id="captcha" type="text" maxlength="10" autocomplete="off" autocapitalize="off" spellcheck="false"
             class="form-control" [class.is-invalid]="erreur()" [value]="value()" (input)="saisir($event)">
      @if (erreur(); as e) {
        <div class="invalid-feedback">{{ e | translate }}</div>
      } @else {
        <div class="form-text">{{ 'CAPTCHA.HELP' | translate }}</div>
      }
    </div>
  `,
})
export class ChampCaptcha implements FormValueControl<string> {
  readonly value = model('');
  readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);

  private readonly api = inject(RdvMedecinsApi);
  private readonly sanitizer = inject(DomSanitizer);
  private readonly captcha = signal<Captcha | null>(null);
  protected readonly svg = computed(() => this.sanitizer.bypassSecurityTrustHtml(this.captcha()?.svg ?? ''));
  protected readonly erreur = computed(() => this.errors()[0]?.message);

  constructor() {
    this.recharger();
  }

  // une nouvelle image, et un champ vide
  async recharger(): Promise<void> {
    this.value.set('');
    try {
      this.captcha.set(await this.api.captcha());
    } catch {
      this.captcha.set(null); // serveur injoignable : la connexion échouera avec un message clair
    }
  }

  // l'identifiant de l'image affichée, à envoyer avec la réponse
  captchaId(): string {
    return this.captcha()?.id ?? '';
  }

  protected saisir(evenement: Event): void {
    this.value.set((evenement.target as HTMLInputElement).value);
  }
}

L'image du captcha et son champ de saisie : un autre contrôle de formulaire personnalisé.

  • ligne 61 : une image est chargée dès la création du composant ;
  • lignes 65-72 : [recharger] est publique : la page de connexion l'appelle après chaque tentative, car une image ne sert qu'une fois ;
  • lignes 75-77 : l'identifiant de l'image affichée, à envoyer avec la réponse ;
  • ligne 37 : l'image SVG (exemple 22), avec un texte de remplacement pour les lecteurs d'écran ;
  • ligne 40 : [autocapitalize] et [spellcheck] : pas de majuscule automatique ni de correction orthographique sur un téléphone.

La page parente obtient ce composant par [viewChild(ChampCaptcha)] (exemple 13), pour appeler ses méthodes publiques.

10.4.8.7. src/app/components/fenetre-reservation

// =========================================================================
// components/fenetre-reservation/fenetre-reservation.ts
// -------------------------------------------------------------------------
// La fenêtre de réservation d'un créneau. Même fenêtre que
// views/partials/reservation.hbs de l'application HTML : les classes
// Bootstrap d'une fenêtre modale, affichées "en dur" (show d-block).
//
// Le composant ne parle pas au serveur : il émet
//   - confirmer, avec le patient choisi (null pour un patient qui réserve
//     pour lui-même) ;
//   - fermer.
// C'est la page de l'agenda qui décide quoi en faire (pages/agenda.ts).
// =========================================================================

import { afterNextRender, Component, ElementRef, input, output, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '@ngx-translate/core';
import { Personne } from '../../api/types';
import { LibellePipe } from '../../pipes/formatage.pipes';

@Component({
  selector: 'app-fenetre-reservation',
  imports: [FormsModule, TranslatePipe, LibellePipe],
  // Échap, n'importe où dans la page : on ferme
  host: { '(document:keydown.escape)': 'fermer.emit()' },
  templateUrl: './fenetre-reservation.html',
})
export class FenetreReservation {
  readonly horaire = input.required<string>(); // "8:00 - 8:20"
  readonly clients = input<readonly Personne[]>([]); // ADMIN / DOCTOR : les patients parmi lesquels choisir
  readonly patient = input<string | null>(null); // USER : le patient, c'est lui-même
  readonly envoiEnCours = input(false);

  readonly confirmer = output<number | null>();
  readonly fermer = output<void>();

  protected readonly idClient = signal<number | null>(null);
  private readonly liste = viewChild<ElementRef<HTMLSelectElement>>('liste');
  private readonly boutonConfirmer = viewChild.required<ElementRef<HTMLButtonElement>>('boutonConfirmer');

  constructor() {
    // à l'affichage, le focus sur le premier élément utile de la fenêtre
    afterNextRender(() => (this.liste() ?? this.boutonConfirmer()).nativeElement.focus());
  }

  // un clic sur le fond grisé (et non dans la fenêtre elle-même) ferme la fenêtre
  protected clicFond(e: MouseEvent): void {
    if (e.target === e.currentTarget) {
      this.fermer.emit();
    }
  }
}

La fenêtre de réservation d'un créneau. Elle ne parle pas au serveur : elle reçoit ce qu'elle affiche, et émet la décision de l'utilisateur ; c'est la page de l'agenda qui agit.

  • lignes 29-32 : les entrées : l'horaire du créneau, les patients parmi lesquels choisir (ADMIN, DOCTOR), ou le patient lui-même (USER), et un indicateur d'envoi en cours ;
  • lignes 34-35 : les sorties : confirmer, avec le patient choisi ; fermer ;
  • ligne 25 : Échap ferme la fenêtre ;
  • ligne 43 : [afterNextRender] (exemple 13) : une fois la fenêtre affichée, le focus va sur la liste des patients, ou sinon sur le bouton de confirmation.
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="titre-reservation"
     (click)="clicFond($event)">
  <div class="modal-dialog modal-dialog-centered">
    <form class="modal-content" (ngSubmit)="confirmer.emit(idClient())">
      <div class="modal-header">
        <h5 id="titre-reservation" class="modal-title">{{ 'BOOKING_DIALOG.TITLE' | translate }}</h5>
        <button type="button" class="btn-close" [attr.aria-label]="'BOOKING_DIALOG.CLOSE_ARIA' | translate"
                (click)="fermer.emit()"></button>
      </div>

      <div class="modal-body">
        <p>{{ 'BOOKING_DIALOG.SLOT' | translate: { 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() !== null) {
          <p class="mb-0">{{ 'BOOKING_DIALOG.PATIENT_LABEL' | translate }} : <strong>{{ patient() }}</strong></p>
        } @else {
          <label class="form-label" for="idClient">{{ 'BOOKING_DIALOG.PATIENT_LABEL' | translate }}</label>
          <select #liste id="idClient" name="idClient" class="form-select" [(ngModel)]="idClient">
            <option [ngValue]="null">{{ 'BOOKING_DIALOG.CHOOSE_PATIENT' | translate }}</option>
            @for (c of clients(); track c.id) {
              <option [ngValue]="c.id">{{ c | libelle }}</option>
            }
          </select>
        }
      </div>

      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" (click)="fermer.emit()">{{ 'BOOKING_DIALOG.CANCEL' | translate }}</button>
        <button #boutonConfirmer type="submit" class="btn btn-primary" [disabled]="envoiEnCours()">
          {{ 'BOOKING_DIALOG.CONFIRM' | translate }}
        </button>
      </div>
    </form>
  </div>
</div>
  • ligne 5 : un formulaire : la touche Entrée confirme ;
  • lignes 16-26 : un patient réserve pour lui-même : pas de liste ;
  • lignes 21-24 : [[ngValue]] (exemple 05) : la valeur de chaque option est un nombre, ou [null] ;
  • ligne 31 : le bouton est désactivé pendant l'envoi : pas de double réservation par un double clic.

10.4.9. Les pages

10.4.9.1. src/app/pages/connexion

// =========================================================================
// pages/connexion/connexion.ts - la page de connexion (/login)
// -------------------------------------------------------------------------
// Même page que views/login.hbs de l'application HTML : 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).
// /login?redirect=/medecins : après la connexion, on ouvre cette page
// (déposé par le garde du routeur) plutôt que la page d'accueil du rôle.
// Ces deux paramètres arrivent en ENTRÉES du composant.
// =========================================================================

import { afterNextRender, Component, inject, input, signal, viewChild } from '@angular/core';
import { Router } from '@angular/router';
import { form, FormField, required } from '@angular/forms/signals';
import { TranslatePipe } from '@ngx-translate/core';
import { RdvMedecinsApi } from '../../api/rdvmedecins.api';
import { SessionService } from '../../services/session.service';
import { MessagesService } from '../../services/messages.service';
import { EnvoiFormulaire } from '../../services/envoi-formulaire';
import { ChampCaptcha } from '../../components/champ-captcha';

@Component({
  selector: 'app-connexion',
  imports: [FormField, TranslatePipe, ChampCaptcha],
  templateUrl: './connexion.html',
})
export class ConnexionPage {
  readonly login = input<string>(); // ?login=...
  readonly redirect = input<string>(); // ?redirect=...

  private readonly api = inject(RdvMedecinsApi);
  private readonly session = inject(SessionService);
  private readonly messages = inject(MessagesService);
  private readonly router = inject(Router);

  protected readonly modele = signal({ login: '', password: '', captcha: '' });
  // deux règles : login et mot de passe obligatoires. [formField] pose alors
  // l'attribut HTML required sur les deux champs, et le navigateur refuse
  // d'envoyer le formulaire tant qu'ils sont vides
  protected readonly f = form(this.modele, (p) => {
    required(p.login);
    required(p.password);
  });
  private readonly envoi = new EnvoiFormulaire(this.f);
  // le composant du captcha, pour appeler ses méthodes captchaId() et recharger()
  private readonly champCaptcha = viewChild.required(ChampCaptcha);

  constructor() {
    afterNextRender(() => {
      // les entrées sont maintenant connues : le login de l'URL, s'il y en a un
      const login = (this.login() ?? '').slice(0, 30);
      this.modele.update((m) => ({ ...m, login }));
      // le focus : sur le mot de passe si le login est déjà rempli
      (login ? this.f.password : this.f.login)().focusBoundControl();
    });
  }

  // les deux boutons partagent ce déroulement : envoi, puis, en cas d'échec,
  // message d'erreur et NOUVELLE image (chacune ne sert qu'une fois)
  private async tenter(action: () => Promise<unknown>, ignorerValidateurs = false): Promise<void> {
    this.messages.effacer();
    await this.envoi.envoyer(async () => {
      try {
        await action();
      } catch (e) {
        // la nouvelle image AVANT que l'erreur ne soit attachée au champ :
        // recharger() vide le champ, ce qui effacerait l'erreur
        await this.champCaptcha().recharger();
        throw e;
      }
    }, ignorerValidateurs);
  }

  protected async seConnecter(evenement: Event): Promise<void> {
    evenement.preventDefault();
    const { login, password, captcha } = this.modele();
    await this.tenter(async () => {
      await this.session.connecter(login.trim(), password, this.champCaptcha().captchaId(), captcha);
      const redirect = this.redirect();
      // seulement un chemin INTERNE : jamais de redirection vers un autre site
      const interne = redirect !== undefined && redirect.startsWith('/') && !redirect.startsWith('//');
      await this.router.navigateByUrl(interne ? redirect : this.session.pageAccueil());
    });
  }

  protected async creerCompte(): Promise<void> {
    await this.tenter(async () => {
      await this.api.accesInscription(this.champCaptcha().captchaId(), this.modele().captcha);
      await this.router.navigate(['/inscription']);
    }, true); // login et mot de passe ne servent pas ici : leurs règles sont ignorées
  }
}

La page de connexion : 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.

  • lignes 31-32 : deux paramètres de l'adresse : /login?login=jbernard pré-remplit le login (juste après la création d'un compte) ; /login?redirect=/medecins, déposé par le garde, indique la page à ouvrir après la connexion ;
  • lignes 43-46 : le seul formulaire de l'application qui a un schéma : login et mot de passe obligatoires. La directive [[formField]] pose alors l'attribut HTML [required] sur les deux champs, et le navigateur refuse d'envoyer le formulaire tant qu'ils sont vides ;
  • ligne 47 : l'envoi du formulaire (services/envoi-formulaire.ts) ;
  • ligne 49 : le composant du captcha, pour appeler ses méthodes ;
  • lignes 52-58 : [afterNextRender] : une fois la page affichée, les entrées sont connues. Le login de l'adresse est recopié dans le formulaire (limité à 30 caractères), et le focus est donné au premier champ vide. [focusBoundControl()] donne le focus à l'élément relié au champ par [[formField]] ;
  • lignes 63-75 : les deux boutons partagent ce déroulement : envoi, puis, en cas d'échec, une nouvelle image (chacune ne sert qu'une fois). L'ordre compte : [recharger] vide le champ du captcha, ce qui effacerait l'erreur attachée à ce champ ; on recharge donc l'image AVANT de relancer l'erreur, que [EnvoiFormulaire] attachera au champ ;
  • lignes 77-87 : la connexion, puis la navigation vers la page demandée, ou vers la page d'accueil du rôle. Comme à l'exemple 18, on n'accepte qu'un chemin interne (ligne 84) ;
  • lignes 89-94 : le second bouton. Le serveur vérifie le captcha et dépose un « laissez-passer » (un cookie) ; on ouvre alors la page de création de compte. Login et mot de passe ne servent pas ici : les règles du schéma sont ignorées (ligne 93).
<div class="row justify-content-center">
  <div class="col-sm-8 col-md-6 col-lg-5">
    <div class="card p-4">
      <h5 class="card-title mb-3">{{ 'LOGIN.TITLE' | translate }}</h5>

      <form (submit)="seConnecter($event)">
        <div class="mb-3">
          <label class="form-label" for="login">{{ 'LOGIN.LOGIN_LABEL' | translate }}</label>
          <input id="login" type="text" class="form-control" autocomplete="username" [formField]="f.login">
        </div>
        <div class="mb-3">
          <label class="form-label" for="password">{{ 'LOGIN.PASSWORD_LABEL' | translate }}</label>
          <input id="password" type="password" class="form-control" autocomplete="current-password"
                 [formField]="f.password">
        </div>

        <!-- un contrôle "maison", relié au champ captcha du formulaire -->
        <app-champ-captcha [formField]="f.captcha" />

        <!-- type="submit" : Entrée dans un champ déclenche "Se connecter" -->
        <button type="submit" class="btn btn-primary w-100" [disabled]="f().submitting()">{{ 'LOGIN.SUBMIT' | translate }}</button>

        <!-- type="button" : ce bouton n'envoie pas le formulaire (pas de
             contrôle required sur login et mot de passe, inutiles ici) -->
        <button type="button" class="btn btn-outline-primary w-100 mt-3" [disabled]="f().submitting()" (click)="creerCompte()">
          {{ 'LOGIN.CREATE_ACCOUNT' | translate }}
        </button>
        <div class="form-text text-center">{{ 'LOGIN.CREATE_ACCOUNT_HELP' | translate }}</div>
      </form>

      <div class="text-muted small mt-3">
        {{ 'LOGIN.DEMO_ACCOUNTS' | translate }}
        <ul class="mb-0 mt-1">
          <li><code>admin</code> - {{ 'LOGIN.DEMO_ADMIN' | translate }}</li>
          <li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - {{ 'LOGIN.DEMO_DOCTOR' | translate }}</li>
          <li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - {{ 'LOGIN.DEMO_USER' | translate }}</li>
        </ul>
      </div>
    </div>
  </div>
</div>
  • ligne 6 : pas de [novalidate] ici : c'est le navigateur qui signale un login ou un mot de passe vide ;
  • ligne 18 : le contrôle personnalisé du captcha, relié au champ [captcha] du formulaire ;
  • ligne 21 : le bouton est désactivé pendant l'envoi ([submitting()]) ;
  • ligne 25 : [type="button"] : ce bouton n'envoie pas le formulaire, et le navigateur n'exige donc pas le login et le mot de passe.

10.4.9.2. src/app/pages/agenda

// =========================================================================
// pages/agenda/agenda.ts - la page de l'agenda (/agenda)
// -------------------------------------------------------------------------
// Trois blocs, comme views/agenda.hbs de l'application HTML :
//   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.
//
// Comme dans l'application HTML, TOUT l'état de la page est dans son URL :
//     /agenda?idMedecin=1&jour=2026-10-05             l'agenda
//     /agenda?idMedecin=1&jour=2026-10-05&reserver=7  + la fenêtre de réservation
// On peut donc recharger la page, la mettre en favori, utiliser les boutons
// Précédent / Suivant du navigateur. La page SUIT son URL : les trois
// paramètres arrivent en ENTRÉES (des signaux), et un effect recharge
// l'agenda quand le médecin ou le jour change ; les actions de
// l'utilisateur, elles, changent l'URL (router.navigate).
//
// Ce que chacun voit et peut faire est décidé par le SERVEUR (champs
// patient, moi, reservable, idRv de chaque créneau) : la page se contente de
// l'afficher. Un patient ne reçoit même pas le nom des autres patients.
// =========================================================================

import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '@ngx-translate/core';
import { ErreurApi } from '../../api/erreur-api';
import { RdvMedecinsApi } from '../../api/rdvmedecins.api';
import { Agenda, CreneauAgenda, Personne } from '../../api/types';
import { aujourdhui, estUneDateValide, horaire } from '../../pipes/formatage';
import { HorairePipe, JourLongPipe, LibellePipe } from '../../pipes/formatage.pipes';
import { LangueService } from '../../i18n/langue.service';
import { SessionService } from '../../services/session.service';
import { MessagesService } from '../../services/messages.service';
import { ConfirmationService } from '../../services/confirmation.service';
import { FenetreReservation } from '../../components/fenetre-reservation/fenetre-reservation';

// "3" -> 3 ; "", "abc", "-2", absent -> null
function entierPositif(valeur: string | undefined): number | null {
  const n = Number(valeur);
  return valeur !== undefined && valeur !== '' && Number.isInteger(n) && n > 0 ? n : null;
}

@Component({
  selector: 'app-agenda',
  imports: [FormsModule, RouterLink, TranslatePipe, HorairePipe, JourLongPipe, LibellePipe, FenetreReservation],
  templateUrl: './agenda.html',
})
export class AgendaPage {
  // les paramètres de l'URL (undefined s'ils sont absents)
  readonly idMedecin = input<string>();
  readonly jour = input<string>();
  readonly reserver = input<string>();

  private readonly api = inject(RdvMedecinsApi);
  private readonly router = inject(Router);
  protected readonly session = inject(SessionService);
  private readonly messages = inject(MessagesService);
  private readonly confirmation = inject(ConfirmationService);
  protected readonly langue = inject(LangueService).langue;

  protected readonly medecins = signal<Personne[]>([]);
  protected readonly clients = signal<Personne[]>([]); // ADMIN / DOCTOR : pour la fenêtre de réservation
  protected readonly agenda = signal<Agenda | null>(null);
  protected readonly chargement = signal(false);
  protected readonly envoiEnCours = signal(false);

  // les valeurs du formulaire "médecin + jour", recopiées depuis l'URL
  protected readonly choixMedecin = signal<number | null>(null);
  protected readonly choixJour = signal(aujourdhui());

  // l'agenda affiché, sans fenêtre de réservation : la query de "retour"
  protected readonly queryAgenda = computed(() => ({ idMedecin: String(this.choixMedecin() ?? ''), jour: this.choixJour() }));

  // le créneau dont la fenêtre de réservation est ouverte (?reserver=7),
  // seulement s'il est réservable par cet utilisateur
  protected readonly creneauAReserver = computed<CreneauAgenda | null>(() => {
    const id = entierPositif(this.reserver());
    return this.agenda()?.creneaux.find((c) => c.id === id && c.reservable) ?? null;
  });

  // le nom du patient connecté (USER), ou null pour ADMIN et DOCTOR
  protected readonly patient = computed(() => (this.session.estPatient() ? (this.session.utilisateur()?.nom ?? '') : null));

  constructor() {
    this.chargerListes();
    // l'agenda suit l'URL (médecin et jour) : l'effect LIT les deux entrées,
    // et le chargement se fait hors suivi (untracked)
    effect(() => {
      const idMedecin = this.idMedecin();
      const jour = this.jour();
      untracked(() => this.chargerAgenda(idMedecin, jour));
    });
  }

  // -------------------------------------------------------------------------
  // chargements
  // -------------------------------------------------------------------------
  private async chargerListes(): Promise<void> {
    try {
      this.medecins.set(await this.api.lister('medecins'));
      // la liste des patients : seulement pour ceux qui ont le droit de la lire
      if (this.session.estGestionnaire()) {
        this.clients.set(await this.api.lister('clients'));
      }
    } catch (e) {
      this.messages.afficherErreurApi(e);
    }
  }

  private async chargerAgenda(idMedecinUrl: string | undefined, jourUrl: string | undefined): Promise<void> {
    // le jour : celui de l'URL s'il est valide, sinon aujourd'hui
    this.choixJour.set(aujourdhui());
    if (jourUrl !== undefined) {
      if (!estUneDateValide(jourUrl)) {
        this.agenda.set(null);
        this.messages.afficherErreur('ERRORS.INVALID_DATE');
        return;
      }
      this.choixJour.set(jourUrl);
    }
    // le médecin : celui de l'URL ; sans médecin, un DOCTOR voit SON agenda
    // (le serveur le retrouve à partir du jeton)
    const idMedecin = entierPositif(idMedecinUrl);
    this.choixMedecin.set(idMedecin);
    if (idMedecin === null && this.session.role() !== 'DOCTOR') {
      this.agenda.set(null);
      return;
    }
    this.chargement.set(true);
    try {
      const agenda = await this.api.agenda(idMedecin, this.choixJour());
      this.agenda.set(agenda);
      this.choixMedecin.set(agenda.medecin.id);
    } catch (e) {
      this.agenda.set(null);
      this.messages.afficherErreurApi(e);
    } finally {
      this.chargement.set(false);
    }
  }

  // l'agenda de l'URL actuelle, relu (après une réservation, une annulation)
  private recharger(): Promise<void> {
    return this.chargerAgenda(this.idMedecin(), this.jour());
  }

  // -------------------------------------------------------------------------
  // actions
  // -------------------------------------------------------------------------

  // "Voir l'agenda" : on change l'URL, l'agenda suit (effect ci-dessus)
  protected voirAgenda(): void {
    this.router.navigate(['/agenda'], { queryParams: this.queryAgenda() });
  }

  protected fermerReservation(): void {
    this.router.navigate(['/agenda'], { queryParams: this.queryAgenda() });
  }

  protected async confirmerReservation(idClient: number | null): Promise<void> {
    const creneau = this.creneauAReserver();
    if (creneau === null) {
      return;
    }
    this.envoiEnCours.set(true);
    try {
      await this.api.reserver(this.choixJour(), creneau.id, this.session.estGestionnaire() ? idClient : null);
      this.messages.flash('succes', 'AGENDA.BOOKED');
    } catch (e) {
      if (e instanceof ErreurApi && 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)
      }
      if (!(e instanceof ErreurApi)) {
        throw e;
      }
      // 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('erreur', e.cle, e.params);
    } finally {
      this.envoiEnCours.set(false);
    }
    await this.router.navigate(['/agenda'], { queryParams: this.queryAgenda() });
    await this.recharger(); // c'est le serveur qui fait foi : on relit l'agenda
  }

  protected async annuler(c: CreneauAgenda): Promise<void> {
    if (c.idRv === null || !(await this.confirmation.confirmer('AGENDA.CANCEL_CONFIRM'))) {
      return;
    }
    try {
      await this.api.annulerRv(c.idRv);
      this.messages.afficherSucces('AGENDA.CANCELLED');
    } catch (e) {
      this.messages.afficherErreurApi(e);
    }
    await this.recharger();
  }

  protected horaire(c: CreneauAgenda): string {
    return horaire(c);
  }
}

La page la plus riche de l'application. Comme dans l'application HTML, TOUT l'état de la page est dans son adresse : /agenda?idMedecin=1&jour=2026-10-05 affiche un agenda, &reserver=7 y ajoute la fenêtre de réservation. On peut donc recharger la page, la mettre en favori, utiliser les boutons Précédent / Suivant. Le principe : la page suit son adresse, et les actions de l'utilisateur changent l'adresse.

  • lignes 51-53 : les trois paramètres de l'adresse, en entrées ;
  • lignes 39-42 : un paramètre d'adresse peut contenir n'importe quoi : il est vérifié ;
  • lignes 69-70 : les valeurs du formulaire « médecin + jour », recopiées depuis l'adresse ;
  • ligne 73 : les paramètres de l'agenda affiché, sans fenêtre de réservation ;
  • lignes 77-80 : le créneau dont la fenêtre de réservation est ouverte, calculé à partir de l'adresse ([?reserver=7]) et de l'agenda : seulement s'il est réservable par cet utilisateur. Un [computed] : quand l'adresse change, la fenêtre s'ouvre ou se ferme d'elle-même ;
  • lignes 89-93 : un effet qui dépend des deux entrées [idMedecin] et [jour] : l'agenda est rechargé quand l'une d'elles change. Le chargement est exécuté dans [untracked] : les signaux qu'il lit et modifie ne deviennent pas des dépendances de l'effet. L'entrée [reserver] n'est pas lue : ouvrir ou fermer la fenêtre ne recharge pas l'agenda ;
  • lignes 99-109 : les médecins, et les patients pour ceux qui ont le droit de les lire ;
  • lignes 111-141 : le chargement de l'agenda. Une date invalide est signalée (lignes 115-118) ; sans médecin, un DOCTOR voit SON agenda : le serveur le retrouve à partir du jeton (lignes 126-129) ;
  • lignes 153-159 : « Voir l'agenda » et la fermeture de la fenêtre changent l'adresse ; l'agenda suit ;
  • lignes 161-187 : la réservation. En cas de succès, un message flash (ligne 169) : il s'affichera après la navigation de la ligne 185, qui ferme la fenêtre. Si aucun patient n'a été choisi, la fenêtre reste ouverte (lignes 171-175). Toute autre erreur (créneau pris entre-temps...) s'affichera sur l'agenda, fenêtre fermée. Dans tous les cas, l'agenda est relu : c'est le serveur qui fait foi (ligne 186) ;
  • lignes 189-200 : l'annulation, après confirmation.
<!-- 1. le choix du médecin et du jour -->
<form class="card p-3 mb-3" (ngSubmit)="voirAgenda()">
  <div class="row g-2 align-items-end">
    <div class="col-sm-5">
      <label class="form-label" for="idMedecin">{{ 'DOCTOR_DAY_PICKER.DOCTOR_LABEL' | translate }}</label>
      <select id="idMedecin" name="idMedecin" class="form-select" required [(ngModel)]="choixMedecin">
        <option [ngValue]="null">{{ 'DOCTOR_DAY_PICKER.CHOOSE_DOCTOR' | translate }}</option>
        @for (m of medecins(); track m.id) {
          <option [ngValue]="m.id">{{ m | libelle }}</option>
        }
      </select>
    </div>
    <div class="col-sm-4">
      <label class="form-label" for="jour">{{ 'DOCTOR_DAY_PICKER.DAY_LABEL' | translate }}</label>
      <input id="jour" name="jour" type="date" class="form-control" required [(ngModel)]="choixJour">
    </div>
    <div class="col-sm-3">
      <button type="submit" class="btn btn-primary w-100">{{ 'DOCTOR_DAY_PICKER.VIEW_AGENDA' | translate }}</button>
    </div>
  </div>
</form>

@let a = agenda();

<!-- patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton "Réserver" -->
@if (a?.info; as info) {
  <div class="alert alert-info">
    {{ info | translate }} <a routerLink="/mon-espace" class="alert-link">{{ 'APP.MENU_MY_SPACE' | translate }}</a>
  </div>
}

<!-- 2. l'agenda -->
@if (chargement() && !a) {
  <p class="text-muted">{{ 'APP.LOADING' | translate }}</p>
} @else if (a) {
  <div class="card p-3">
    <h5>{{ 'AGENDA.TITLE_PREFIX' | translate }} {{ a.jour | jourLong: langue() }} - {{ a.medecin | libelle }}</h5>
    @if (a.creneaux.length > 0) {
      <table class="table table-hover align-middle">
        <thead>
          <tr>
            <th>{{ 'AGENDA.COLUMN_SLOT' | translate }}</th>
            <th>{{ 'AGENDA.COLUMN_STATUS' | translate }}</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          @for (c of a.creneaux; track c.id) {
            <tr [class.creneau-libre]="c.reservable">
              <td>{{ c | horaire }}</td>
              <td>
                @if (c.libre) {
                  <span class="badge text-bg-success">{{ 'AGENDA.FREE' | translate }}</span>
                } @else if (c.patient; as p) {
                  <span class="badge" [class]="c.moi ? 'text-bg-primary' : 'text-bg-secondary'">{{ p | libelle }}</span>
                } @else {
                  <span class="badge text-bg-secondary">{{ 'AGENDA.BOOKED_BY_OTHER' | translate }}</span>
                }
              </td>
              <td class="text-end">
                <!-- "Réserver" est un LIEN : il ajoute &reserver=... à l'URL, ce qui
                     ouvre la fenêtre de réservation -->
                @if (c.reservable) {
                  <a class="btn btn-sm btn-outline-success" routerLink="/agenda" [queryParams]="{ idMedecin: queryAgenda().idMedecin, jour: queryAgenda().jour, reserver: c.id }">
                    {{ 'AGENDA.BOOK' | translate }}
                  </a>
                }
                @if (c.idRv !== null) {
                  <button type="button" class="btn btn-sm btn-outline-danger" (click)="annuler(c)">
                    {{ 'AGENDA.CANCEL_APPOINTMENT' | translate }}
                  </button>
                }
              </td>
            </tr>
          }
        </tbody>
      </table>
    } @else {
      <p class="text-muted mb-0">{{ 'AGENDA.NO_SLOTS' | translate }}</p>
    }
  </div>
} @else {
  <p class="text-muted">{{ 'AGENDA.EMPTY_STATE' | translate }}</p>
}

<!-- 3. la fenêtre de réservation, seulement si l'URL contient &reserver=... -->
@if (creneauAReserver(); as c) {
  <app-fenetre-reservation [horaire]="horaire(c)" [clients]="clients()" [patient]="patient()"
                           [envoiEnCours]="envoiEnCours()" (confirmer)="confirmerReservation($event)"
                           (fermer)="fermerReservation()" />
}
  • lignes 2-21 : le choix du médecin et du jour, avec [ngModel] (exemple 05) ;
  • ligne 23 : [@let] : l'agenda, lu une fois ;
  • lignes 26-30 : un patient qui a déjà un rendez-vous à venir : le serveur envoie la clé d'un message, qui explique pourquoi il n'y a pas de bouton « Réserver » ;
  • ligne 37 : la date en toutes lettres, dans la langue courante ;
  • lignes 52-58 : le statut du créneau : libre, le patient (le sien en bleu), ou « Réservé » quand le serveur n'a pas envoyé le nom du patient ;
  • lignes 63-67 : « Réserver » est un lien : il ajoute &reserver=... à l'adresse, ce qui ouvre la fenêtre de réservation ;
  • lignes 87-91 : la fenêtre de réservation, seulement si l'adresse contient &reserver=... et que le créneau est réservable.

10.4.9.3. src/app/pages/personnes/personnes.config.ts

// =========================================================================
// pages/personnes/personnes.config.ts - médecins ou clients ?
// -------------------------------------------------------------------------
// Les pages de gestion des médecins et des clients sont IDENTIQUES : même
// liste, même formulaire. Comme dans l'application HTML (classe abstraite
// PersonnesController), 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 indique le type dans les
// données (data) de chaque route (cf. app.routes.ts).
// =========================================================================

import { TypePersonnes } from '../../api/rdvmedecins.api';

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

export const CLES_PERSONNES: 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',
  },
  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',
  },
};

// {nom} et {login} des messages "ajouté / modifié / supprimé"
export function parametres(p: { prenom: string; nom: string; login?: string }): Record<string, string> {
  return { nom: `${p.prenom} ${p.nom}`, login: p.login ?? '' };
}

Les pages de gestion des médecins et des clients sont identiques : même liste, même formulaire. Comme dans l'application HTML (classe abstraite [PersonnesController]), on les écrit une seule fois ; seules changent l'adresse de l'API et les clés des libellés, décrites ici :

  • ligne 25 : [Record<TypePersonnes, ClesPersonnes>] : le compilateur exige une entrée pour chaque type, ni plus ni moins ;
  • lignes 49-51 : les paramètres des messages « ajouté / modifié / supprimé ».

10.4.9.4. src/app/pages/personnes

// =========================================================================
// pages/personnes/personnes.ts - la liste des médecins OU des clients (ADMIN)
// -------------------------------------------------------------------------
// Une seule page pour les deux : le type ('medecins' ou 'clients') est dans
// les données de la route (data.type, cf. app.routes.ts) et arrive en
// ENTRÉE du composant ; les libellés propres à chacun sont dans
// personnes.config.ts. Équivalent de views/personnes/liste.hbs de
// l'application HTML.
// "export default" : la route charge la page à la demande (loadComponent).
// =========================================================================

import { Component, computed, inject, input, OnInit, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { RdvMedecinsApi, TypePersonnes } from '../../api/rdvmedecins.api';
import { Personne } from '../../api/types';
import { MessagesService } from '../../services/messages.service';
import { ConfirmationService } from '../../services/confirmation.service';
import { CLES_PERSONNES, parametres } from './personnes.config';

@Component({
  selector: 'app-personnes',
  imports: [RouterLink, TranslatePipe],
  templateUrl: './personnes.html',
})
export default class PersonnesPage implements OnInit {
  readonly type = input.required<TypePersonnes>(); // data.type de la route

  private readonly api = inject(RdvMedecinsApi);
  private readonly messages = inject(MessagesService);
  private readonly confirmation = inject(ConfirmationService);

  protected readonly cles = computed(() => CLES_PERSONNES[this.type()]);
  protected readonly personnes = signal<Personne[] | null>(null); // null : pas encore chargées

  // ngOnInit plutôt que le constructeur : les entrées (type) sont alors connues
  ngOnInit(): void {
    this.charger();
  }

  private async charger(): Promise<void> {
    try {
      this.personnes.set(await this.api.lister(this.type()));
    } catch (e) {
      this.messages.afficherErreurApi(e);
    }
  }

  protected async supprimer(p: Personne): Promise<void> {
    if (!(await this.confirmation.confirmer(this.cles().confirmerSuppression))) {
      return;
    }
    try {
      await this.api.supprimer(this.type(), p.id);
      this.messages.afficherSucces(this.cles().supprime, parametres(p));
    } catch (e) {
      // un médecin qui a encore des rendez-vous : 409 ERRORS.DOCTOR_HAS_APPOINTMENTS
      this.messages.afficherErreurApi(e);
    }
    await this.charger();
  }
}
  • ligne 26 : [export default] : la route charge la page à la demande (exemple 17) ;
  • ligne 27 : le type (['medecins'] ou ['clients']) vient des données de la route, en entrée ;
  • ligne 33 : les clés des libellés de ce type ;
  • ligne 34 : [null] : la liste n'est pas encore chargée ;
  • lignes 37-39 : [ngOnInit] (exemple 13) plutôt que le constructeur : les entrées sont alors connues ;
  • lignes 49-61 : la suppression, après confirmation. Un médecin qui a encore des rendez-vous ne peut pas être supprimé : le serveur répond 409, et son message s'affiche.
<div class="d-flex justify-content-between align-items-center mb-3">
  <h4 class="mb-0">{{ cles().titre | translate }}</h4>
  <a class="btn btn-primary" [routerLink]="['/', type(), 'ajouter']">{{ cles().ajouter | translate }}</a>
</div>

<div class="card p-3">
  @let liste = personnes();
  @if (liste === null) {
    <p class="text-muted mb-0">{{ 'APP.LOADING' | translate }}</p>
  } @else if (liste.length === 0) {
    <p class="text-muted mb-0">{{ cles().vide | translate }}</p>
  } @else {
    <table class="table table-hover align-middle mb-0">
      <thead>
        <tr>
          <th>{{ 'PERSONNES.COLUMN_TITLE' | translate }}</th>
          <th>{{ 'PERSONNES.COLUMN_FIRSTNAME' | translate }}</th>
          <th>{{ 'PERSONNES.COLUMN_LASTNAME' | translate }}</th>
          <th>{{ 'PERSONNES.COLUMN_LOGIN' | translate }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        @for (p of liste; track p.id) {
          <tr>
            <td>{{ p.titre }}</td>
            <td>{{ p.prenom }}</td>
            <td>{{ p.nom }}</td>
            <td><code>{{ p.login }}</code></td>
            <td class="text-end">
              <div class="d-inline-flex gap-2">
                <a class="btn btn-sm btn-outline-primary" [routerLink]="['/', type(), p.id, 'modifier']">{{ 'PERSONNES.EDIT' | translate }}</a>
                <button type="button" class="btn btn-sm btn-outline-danger" (click)="supprimer(p)">{{ 'PERSONNES.DELETE' | translate }}</button>
              </div>
            </td>
          </tr>
        }
      </tbody>
    </table>
  }
</div>
  • ligne 3 : [['/', type(), 'ajouter']] : /medecins/ajouter ou /clients/ajouter ;
  • lignes 8-12 : pendant le chargement, une liste vide, une liste ;
  • ligne 32 : /medecins/3/modifier.

10.4.9.5. src/app/pages/personne-formulaire

// =========================================================================
// pages/personne-formulaire/personne-formulaire.ts - création OU modification
// d'un médecin ou d'un client (ADMIN)
// -------------------------------------------------------------------------
//     /medecins/ajouter          -> création (pas d'entrée id)
//     /medecins/3/modifier       -> modification (entrée id = 3)
// (et les mêmes adresses sous /clients). Équivalent de
// views/personnes/formulaire.hbs de l'application HTML.
//
// Aucune règle n'est vérifiée ici : le serveur valide tout, et ses erreurs
// s'affichent sous les champs (cf. services/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, inject, input, numberAttribute, OnInit, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { form, FormField } from '@angular/forms/signals';
import { TranslatePipe } from '@ngx-translate/core';
import { ErreurApi } from '../../api/erreur-api';
import { RdvMedecinsApi, TypePersonnes } from '../../api/rdvmedecins.api';
import { DonneesPersonne } from '../../api/types';
import { MessagesService } from '../../services/messages.service';
import { EnvoiFormulaire } from '../../services/envoi-formulaire';
import { ChampsPersonne } from '../../components/champs-personne';
import { ChampSaisie } from '../../components/champ-saisie';
import { CLES_PERSONNES, parametres } from '../personnes/personnes.config';

@Component({
  selector: 'app-personne-formulaire',
  imports: [RouterLink, FormField, TranslatePipe, ChampsPersonne, ChampSaisie],
  templateUrl: './personne-formulaire.html',
})
export default class PersonneFormulairePage implements OnInit {
  readonly type = input.required<TypePersonnes>(); // data.type de la route
  // le paramètre :id de l'adresse, converti en nombre ; absent pour une création
  readonly id = input(undefined, { transform: (v: string | undefined) => (v === undefined ? undefined : numberAttribute(v)) });

  private readonly api = inject(RdvMedecinsApi);
  private readonly messages = inject(MessagesService);
  private readonly router = inject(Router);

  protected readonly cles = computed(() => CLES_PERSONNES[this.type()]);
  protected readonly creation = computed(() => this.id() === undefined);

  protected readonly modele = signal<DonneesPersonne>({ titre: 'Mr', prenom: '', nom: '', login: '', password: '', version: undefined });
  protected readonly f = form(this.modele); // aucun validateur : le serveur valide
  protected readonly envoi = new EnvoiFormulaire(this.f);

  // modification : on lit la fiche (dont sa version)
  async ngOnInit(): Promise<void> {
    const id = this.id();
    if (id === undefined) {
      return;
    }
    try {
      const p = await this.api.lire(this.type(), id);
      this.modele.set({ titre: p.titre, prenom: p.prenom, nom: p.nom, login: p.login ?? '', password: '', version: p.version });
    } catch (e) {
      // fiche inexistante (supprimée entre-temps...) : retour à la liste
      if (!(e instanceof ErreurApi)) {
        throw e;
      }
      this.messages.flash('erreur', e.cle, e.params);
      this.router.navigate(['/', this.type()]);
    }
  }

  protected async enregistrer(evenement: Event): Promise<void> {
    evenement.preventDefault();
    const id = this.id();
    const reussi = await this.envoi.envoyer(async () => {
      const donnees = this.modele();
      const p = id === undefined ? await this.api.ajouter(this.type(), donnees) : await this.api.modifier(this.type(), id, donnees);
      this.messages.flash('succes', this.creation() ? this.cles().ajoute : this.cles().modifie, parametres(p));
    });
    const cle = this.messages.erreur()?.cle;
    if (reussi) {
      this.router.navigate(['/', this.type()]);
    } else if (cle === 'ERRORS.DOCTOR_NOT_FOUND' || cle === 'ERRORS.PATIENT_NOT_FOUND') {
      // supprimé entre-temps par un autre ADMIN : retour à la liste
      this.messages.flash('erreur', cle);
      this.router.navigate(['/', this.type()]);
    }
  }
}

La création (/medecins/ajouter) OU la modification (/medecins/3/modifier) d'un médecin ou d'un client :

  • ligne 37 : le paramètre [:id] de l'adresse, absent pour une création. L'option [transform] le convertit en nombre ([numberAttribute]) ;
  • ligne 44 : création ou modification ?
  • lignes 46-48 : le formulaire, sans validateur (le serveur valide tout), et son envoi ;
  • lignes 51-67 : pour une modification, on lit la fiche, version comprise : elle repartira avec la modification (verrou optimiste). Une fiche inexistante (/medecins/999/modifier) renvoie à la liste, avec un message ;
  • lignes 69-85 : l'enregistrement. En cas de succès, un message flash et le retour à la liste (lignes 75 et 78-79). Une fiche supprimée entre-temps par un autre administrateur renvoie aussi à la liste (lignes 80-84) ; toute autre erreur laisse le formulaire affiché, avec ses erreurs.
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <div class="card p-4">
      <h5 class="card-title mb-3">{{ (creation() ? cles().ajouter : cles().modifier) | translate }}</h5>
      <!-- novalidate : c'est le SERVEUR qui valide (il fait foi) -->
      <form novalidate (submit)="enregistrer($event)">
        <app-champs-personne [titre]="f.titre" [prenom]="f.prenom" [nom]="f.nom" />

        <h6 class="mt-4 text-muted">{{ 'PERSONNES.ACCOUNT_SECTION' | translate }}</h6>
        <app-champ-saisie class="mb-3" idChamp="login" libelle="PERSONNES.LOGIN_LABEL" [longueurMax]="30" [formField]="f.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 class="mb-4" idChamp="password" type="password" libelle="PERSONNES.PASSWORD_LABEL" [longueurMax]="72"
                          autocomplete="new-password" [formField]="f.password" [params]="envoi.params()"
                          [aide]="creation() ? 'PERSONNES.PASSWORD_HELP_CREATE' : 'PERSONNES.PASSWORD_HELP_EDIT'" />

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary" [disabled]="f().submitting()">{{ 'PERSONNES.SAVE' | translate }}</button>
          <a class="btn btn-secondary" [routerLink]="['/', type()]">{{ 'PERSONNES.BACK' | translate }}</a>
        </div>
      </form>
    </div>
  </div>
</div>
  • ligne 6 : [novalidate] : c'est le serveur qui valide ;
  • ligne 7 : titre, prénom et nom, en un composant ;
  • lignes 10-15 : le login et le mot de passe, avec un message d'aide différent en création et en modification. Le mot de passe n'est jamais pré-rempli : il ne revient jamais du serveur ;
  • ligne 14 : les paramètres des messages d'erreur renvoyés par le serveur (la longueur minimale du mot de passe, par exemple).

10.4.9.6. src/app/pages/inscription

// =========================================================================
// pages/inscription/inscription.ts - "Créer un compte client" (/inscription)
// -------------------------------------------------------------------------
// On n'arrive ici qu'après avoir recopié le captcha de la page de connexion
// (bouton "Créer un compte client") : le serveur a alors délivré un
// laissez-passer, que le garde de la route a vérifié (cf. gardes.ts).
// Mêmes champs que le formulaire de l'ADMIN, mais le login et le mot de
// passe sont obligatoires, avec confirmation. Équivalent de
// views/compte/inscription.hbs de l'application HTML.
// =========================================================================

import { Component, computed, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { form, FormField } from '@angular/forms/signals';
import { TranslatePipe } from '@ngx-translate/core';
import { RdvMedecinsApi } from '../../api/rdvmedecins.api';
import { Inscription } from '../../api/types';
import { MessagesService } from '../../services/messages.service';
import { EnvoiFormulaire } from '../../services/envoi-formulaire';
import { ChampsPersonne } from '../../components/champs-personne';
import { ChampSaisie } from '../../components/champ-saisie';

// 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 MOT_DE_PASSE_MIN = '6';

@Component({
  selector: 'app-inscription',
  imports: [RouterLink, FormField, TranslatePipe, ChampsPersonne, ChampSaisie],
  templateUrl: './inscription.html',
})
export default class InscriptionPage {
  private readonly api = inject(RdvMedecinsApi);
  private readonly messages = inject(MessagesService);
  private readonly router = inject(Router);

  protected readonly modele = signal<Inscription>({ titre: 'Mr', prenom: '', nom: '', login: '', password: '', confirmation: '' });
  protected readonly f = form(this.modele);
  protected readonly envoi = new EnvoiFormulaire(this.f);
  // les paramètres du message d'aide ET des erreurs du mot de passe
  protected readonly paramsPassword = computed(() => ({ min: MOT_DE_PASSE_MIN, ...this.envoi.params() }));

  protected async creer(evenement: Event): Promise<void> {
    evenement.preventDefault();
    let login = '';
    const reussi = await this.envoi.envoyer(async () => {
      login = (await this.api.inscrire(this.modele())).login;
    });
    if (reussi) {
      // retour à la page de connexion, login pré-rempli
      this.messages.flash('succes', 'INSCRIPTION.DONE', { login });
      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('erreur', 'ERRORS.CAPTCHA_BEFORE_ACCOUNT');
      this.router.navigate(['/login']);
    }
  }
}

La création d'un compte de patient. On n'arrive ici qu'avec le laissez-passer délivré par le serveur après la résolution du captcha (gardes.ts) :

  • ligne 25 : la longueur minimale d'un mot de passe, pour le message d'aide : la même valeur que le serveur ;
  • ligne 41 : les paramètres du message d'aide et des erreurs du mot de passe. Ceux du serveur, s'il en renvoie, l'emportent (ils sont placés en dernier) ;
  • lignes 46-48 : l'envoi ; le serveur renvoie le login du compte créé ;
  • lignes 49-52 : retour à la page de connexion, login pré-rempli ;
  • lignes 53-57 : le laissez-passer a expiré (15 minutes) : retour au captcha.
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <div class="card p-4">
      <h5 class="card-title mb-1">{{ 'INSCRIPTION.TITLE' | translate }}</h5>
      <p class="text-muted small mb-3">{{ 'INSCRIPTION.INTRO' | translate }}</p>
      <form novalidate (submit)="creer($event)">
        <app-champs-personne [titre]="f.titre" [prenom]="f.prenom" [nom]="f.nom" />

        <h6 class="mt-4 text-muted">{{ 'PERSONNES.ACCOUNT_SECTION' | translate }}</h6>
        <app-champ-saisie class="mb-3" idChamp="login" libelle="PERSONNES.LOGIN_LABEL" [longueurMax]="30" autocomplete="username"
                          requis [formField]="f.login" aide="INSCRIPTION.LOGIN_HELP" />
        <div class="row g-3 mb-3">
          <app-champ-saisie class="col-sm-6" idChamp="password" type="password" libelle="PERSONNES.PASSWORD_LABEL" [longueurMax]="72"
                            autocomplete="new-password" requis [formField]="f.password" [params]="paramsPassword()"
                            aide="INSCRIPTION.PASSWORD_HELP" />
          <app-champ-saisie class="col-sm-6" idChamp="confirmation" type="password" libelle="COMPTE.CONFIRMATION_LABEL"
                            [longueurMax]="72" autocomplete="new-password" requis [formField]="f.confirmation" />
        </div>
        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary" [disabled]="f().submitting()">{{ 'INSCRIPTION.SUBMIT' | translate }}</button>
          <a class="btn btn-secondary" routerLink="/login">{{ 'INSCRIPTION.BACK_TO_LOGIN' | translate }}</a>
        </div>
      </form>
    </div>
  </div>
</div>
  • ligne 7 : titre, prénom, nom ;
  • lignes 10-17 : login, mot de passe et confirmation, obligatoires cette fois (l'attribut [requis] n'affiche qu'une indication : c'est le serveur qui décide).

10.4.9.7. src/app/pages/espace-patient

// =========================================================================
// pages/espace-patient/espace-patient.ts - "Mon espace" (/mon-espace, rôle USER)
// -------------------------------------------------------------------------
// La page 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.
// Équivalent de views/patient/espace.hbs de l'application HTML.
// =========================================================================

import { Component, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { RdvMedecinsApi } from '../../api/rdvmedecins.api';
import { MonEspace } from '../../api/types';
import { HorairePipe, JourLongPipe, LibellePipe } from '../../pipes/formatage.pipes';
import { LangueService } from '../../i18n/langue.service';
import { MessagesService } from '../../services/messages.service';
import { ConfirmationService } from '../../services/confirmation.service';

@Component({
  selector: 'app-espace-patient',
  imports: [RouterLink, TranslatePipe, HorairePipe, JourLongPipe, LibellePipe],
  templateUrl: './espace-patient.html',
})
export default class EspacePatientPage {
  private readonly api = inject(RdvMedecinsApi);
  private readonly messages = inject(MessagesService);
  private readonly confirmation = inject(ConfirmationService);
  protected readonly langue = inject(LangueService).langue;

  protected readonly espace = signal<MonEspace | null>(null);

  constructor() {
    this.charger();
  }

  private async charger(): Promise<void> {
    try {
      this.espace.set(await this.api.monEspace());
    } catch (e) {
      this.messages.afficherErreurApi(e);
    }
  }

  protected async annuler(idRv: number): Promise<void> {
    if (!(await this.confirmation.confirmer('AGENDA.CANCEL_CONFIRM'))) {
      return;
    }
    try {
      await this.api.annulerRv(idRv);
      this.messages.afficherSucces('AGENDA.CANCELLED');
    } catch (e) {
      this.messages.afficherErreurApi(e);
    }
    await this.charger();
  }
}

La page d'accueil du patient : deux options (« Gérer mon compte », « Voir un agenda ») et son rendez-vous à venir, qu'il peut annuler (lignes 45-56).

@if (espace(); as e) {
  <h4 class="mb-3">{{ 'ESPACE.HELLO' | translate: { nom: (e.patient | libelle) } }}</h4>
  <div class="row g-3 mb-4">
    <div class="col-sm-6">
      <a class="card p-4 h-100 text-decoration-none carte-option" routerLink="/mon-compte">
        <h5 class="mb-1">{{ 'ESPACE.MANAGE_ACCOUNT' | translate }}</h5>
        <span class="text-muted">{{ 'ESPACE.MANAGE_ACCOUNT_HELP' | translate }}</span>
      </a>
    </div>
    <div class="col-sm-6">
      <a class="card p-4 h-100 text-decoration-none carte-option" routerLink="/agenda">
        <h5 class="mb-1">{{ 'ESPACE.VIEW_AGENDA' | translate }}</h5>
        <span class="text-muted">{{ 'ESPACE.VIEW_AGENDA_HELP' | translate }}</span>
      </a>
    </div>
  </div>

  <div class="card p-3">
    <h5>{{ 'ESPACE.MY_APPOINTMENT' | translate }}</h5>
    <!-- normalement un seul (règle "un rendez-vous à venir à la fois"),
         mais la page sait en afficher plusieurs -->
    @for (rv of e.rendezVous; track rv.id; let dernier = $last) {
      <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2" [class.border-bottom]="!dernier">
        <div>
          <div class="fw-semibold">{{ rv.jour | jourLong: langue() }}, {{ rv.creneau | horaire }}</div>
          <div class="text-muted">
            {{ rv.medecin | libelle }} -
            <!-- l'agenda du médecin ce jour-là : le patient y voit son nom -->
            <a routerLink="/agenda" [queryParams]="{ idMedecin: rv.medecin.id, jour: rv.jour }">{{ 'ESPACE.SEE_IN_AGENDA' | translate }}</a>
          </div>
        </div>
        <button type="button" class="btn btn-outline-danger" (click)="annuler(rv.id)">{{ 'ESPACE.CANCEL_APPOINTMENT' | translate }}</button>
      </div>
    } @empty {
      <p class="text-muted mb-0">{{ 'ESPACE.NO_APPOINTMENT' | translate }}</p>
    }
  </div>
} @else {
  <p class="text-muted">{{ 'APP.LOADING' | translate }}</p>
}
  • ligne 2 : un paramètre de traduction calculé par un pipe ;
  • ligne 22 : [$last] : la dernière ligne n'a pas de bordure ;
  • ligne 29 : un lien vers l'agenda du médecin, ce jour-là.

10.4.9.8. src/app/pages/mon-compte

// =========================================================================
// pages/mon-compte/mon-compte.ts - "Gérer mon compte" (/mon-compte, rôle USER)
// -------------------------------------------------------------------------
// Deux formulaires indépendants, comme views/patient/compte.hbs :
//   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 modèles, deux arbres de champs et deux
// EnvoiFormulaire (deux jeux d'erreurs).
// =========================================================================

import { Component, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { form, FormField } from '@angular/forms/signals';
import { TranslatePipe } from '@ngx-translate/core';
import { RdvMedecinsApi } from '../../api/rdvmedecins.api';
import { ModificationMonCompte } from '../../api/types';
import { SessionService } from '../../services/session.service';
import { MessagesService } from '../../services/messages.service';
import { ConfirmationService } from '../../services/confirmation.service';
import { EnvoiFormulaire } from '../../services/envoi-formulaire';
import { ChampsPersonne } from '../../components/champs-personne';
import { ChampSaisie } from '../../components/champ-saisie';

@Component({
  selector: 'app-mon-compte',
  imports: [RouterLink, FormField, TranslatePipe, ChampsPersonne, ChampSaisie],
  templateUrl: './mon-compte.html',
})
export default class MonComptePage {
  private readonly api = inject(RdvMedecinsApi);
  private readonly session = inject(SessionService);
  private readonly messages = inject(MessagesService);
  private readonly confirmation = inject(ConfirmationService);
  private readonly router = inject(Router);

  // 1. la modification
  protected readonly modele = signal<ModificationMonCompte>({
    titre: 'Mr',
    prenom: '',
    nom: '',
    login: '',
    password: '',
    confirmation: '',
    motDePasseActuel: '',
    version: 0,
  });
  protected readonly f = form(this.modele);
  protected readonly modification = new EnvoiFormulaire(this.f);

  // 2. la suppression : un formulaire d'un seul champ
  protected readonly modeleSuppression = signal({ motDePasseActuel: '' });
  protected readonly fs = form(this.modeleSuppression);
  protected readonly suppression = new EnvoiFormulaire(this.fs);

  constructor() {
    this.charger();
  }

  private async charger(): Promise<void> {
    try {
      const compte = await this.api.monCompte();
      this.modele.update((m) => ({ ...m, ...compte }));
    } catch (e) {
      this.messages.afficherErreurApi(e);
    }
  }

  protected async enregistrer(evenement: Event): Promise<void> {
    evenement.preventDefault();
    const reussi = await this.modification.envoyer(async () => {
      // le serveur a délivré un NOUVEAU jeton (nom, login) : la barre de
      // navigation doit suivre
      this.session.mettreAJour(await this.api.modifierMonCompte(this.modele()));
      this.messages.flash('succes', 'MON_COMPTE.UPDATED');
    });
    if (reussi) {
      this.router.navigate(['/mon-espace']);
    } else {
      // jamais de mot de passe laissé dans le champ après un envoi refusé ;
      // on modifie le champ lui-même (et non le modèle) pour ne pas effacer
      // les erreurs que le serveur vient d'y attacher... sauf la sienne
      this.f.motDePasseActuel().value.set('');
    }
  }

  protected async supprimer(evenement: Event): Promise<void> {
    evenement.preventDefault();
    if (!(await this.confirmation.confirmer('MON_COMPTE.DELETE_CONFIRM'))) {
      return;
    }
    const reussi = await this.suppression.envoyer(() => this.api.supprimerMonCompte(this.modeleSuppression().motDePasseActuel));
    if (reussi) {
      this.session.oublier(); // le serveur a effacé le cookie du jeton
      this.messages.flash('succes', 'MON_COMPTE.DELETED');
      this.router.navigate(['/login']);
    }
  }
}

Deux formulaires indépendants : la modification du compte et sa suppression. L'un comme l'autre exigent le mot de passe actuel, vérifié par le serveur. Deux formulaires, donc deux modèles, deux arbres de champs et deux [EnvoiFormulaire] : les erreurs de l'un ne s'affichent pas dans l'autre.

  • lignes 60-67 : on lit le compte, et on en recopie les valeurs dans le modèle (version comprise) ;
  • ligne 74 : après une modification, le serveur renvoie l'utilisateur modifié (et a déposé un nouveau jeton) : la barre de navigation doit suivre ;
  • ligne 83 : après un refus, le mot de passe actuel est effacé. On modifie le champ (et non le modèle entier) : seule l'erreur de ce champ disparaît, les autres restent affichées ;
  • lignes 87-98 : la suppression du compte, après confirmation. Le serveur a effacé le cookie du jeton : on oublie l'utilisateur, et retour à la page de connexion.
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <!-- 1. modification -->
    <div class="card p-4 mb-4">
      <h5 class="card-title mb-3">{{ 'MON_COMPTE.TITLE' | translate }}</h5>
      <form novalidate (submit)="enregistrer($event)">
        <app-champs-personne [titre]="f.titre" [prenom]="f.prenom" [nom]="f.nom" />

        <h6 class="mt-4 text-muted">{{ 'PERSONNES.ACCOUNT_SECTION' | translate }}</h6>
        <app-champ-saisie class="mb-3" idChamp="login" libelle="PERSONNES.LOGIN_LABEL" [longueurMax]="30" autocomplete="username"
                          requis [formField]="f.login" />
        <div class="row g-3 mb-3">
          <app-champ-saisie class="col-sm-6" idChamp="password" type="password" libelle="MON_COMPTE.NEW_PASSWORD_LABEL"
                            [longueurMax]="72" autocomplete="new-password" [formField]="f.password"
                            [params]="modification.params()" aide="PERSONNES.PASSWORD_HELP_EDIT" />
          <app-champ-saisie class="col-sm-6" idChamp="confirmation" type="password" libelle="COMPTE.CONFIRMATION_LABEL"
                            [longueurMax]="72" autocomplete="new-password" [formField]="f.confirmation" />
        </div>
        <app-champ-saisie class="mb-4" idChamp="motDePasseActuel" type="password" libelle="MON_COMPTE.CURRENT_PASSWORD_LABEL"
                          [longueurMax]="72" autocomplete="current-password" requis [formField]="f.motDePasseActuel"
                          aide="MON_COMPTE.CURRENT_PASSWORD_HELP" />
        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary" [disabled]="f().submitting()">{{ 'PERSONNES.SAVE' | translate }}</button>
          <a class="btn btn-secondary" routerLink="/mon-espace">{{ 'MON_COMPTE.BACK' | translate }}</a>
        </div>
      </form>
    </div>

    <!-- 2. suppression -->
    <div class="card p-4 border-danger">
      <h5 class="card-title text-danger mb-1">{{ 'MON_COMPTE.DELETE_TITLE' | translate }}</h5>
      <p class="text-muted small">{{ 'MON_COMPTE.DELETE_HELP' | translate }}</p>
      <form class="d-flex flex-wrap gap-2 align-items-end" novalidate (submit)="supprimer($event)">
        <app-champ-saisie class="flex-grow-1" idChamp="motDePasseSuppression" type="password" libelle="MON_COMPTE.CURRENT_PASSWORD_LABEL"
                          [longueurMax]="72" autocomplete="current-password" requis [formField]="fs.motDePasseActuel" />
        <button type="submit" class="btn btn-danger" [disabled]="fs().submitting()">{{ 'MON_COMPTE.DELETE_SUBMIT' | translate }}</button>
      </form>
    </div>
  </div>
</div>

10.4.9.9. src/app/pages/erreur/erreur.ts

// =========================================================================
// pages/erreur/erreur.ts - la page d'erreur : 404 (adresse inconnue) ou 403
// (rôle insuffisant, cf. gardes.ts). Équivalent de views/erreur.hbs.
// Le code arrive en entrée, depuis les données de la route (data.code).
// =========================================================================

import { Component, computed, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';

@Component({
  selector: 'app-erreur',
  imports: [RouterLink, TranslatePipe],
  template: `
    <div class="card p-4">
      <h4 class="text-danger">{{ 'ERROR_PAGE.TITLE' | translate }} {{ code() }}</h4>
      <p>{{ message() | translate }}</p>
      <a routerLink="/" class="btn btn-primary align-self-start">{{ 'ERROR_PAGE.BACK' | translate }}</a>
    </div>
  `,
})
export default class ErreurPage {
  readonly code = input.required<403 | 404>();
  protected readonly message = computed(() => (this.code() === 403 ? 'ERRORS.FORBIDDEN' : 'ERRORS.NOT_FOUND'));
}

La page d'erreur, 403 ou 404 : le code arrive en entrée, depuis les données de la route ([data.code]).