Skip to content

10. Étude de cas : le client Vue.js 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 [Vue.js] : 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 précédent). 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-vuejs] :

npm install
npm run dev

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

Le serveur de [Vite] des exemples utilise aussi le port 5173. Si ce port est déjà occupé, [Vite] en prend un autre (5174...) et l'affiche dans la console : il faut alors ouvrir cette adresse-là.

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-09-28] :

Image

« Réserver » ouvre la fenêtre de réservation. L'adresse devient [/agenda?idMedecin=1&jour=2026-09-28&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 :

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

Image

rdvmedecins-vuejs/
├── index.html             LA page de l'application
├── public/favicon.svg     l'icône de l'onglet
├── package.json, vite.config.ts, tsconfig.json, env.d.ts
└── src/
    ├── main.ts            le démarrage
    ├── App.vue            le gabarit commun à toutes les pages
    ├── styles.css
    ├── router.ts          les pages et leurs droits d'accès
    ├── i18n.ts            les langues
    ├── locales/           fr.json, en.json
    ├── api/               la couche d'accès à l'API
    ├── stores/            session.ts, messages.ts
    ├── composables/       useConfirmation, useFormatage, useFormulaire
    ├── components/        les composants réutilisables
    └── views/             les pages

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

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

10.5. La configuration du projet

10.5.1. package.json

{
  "name": "rdvmedecins-vuejs",
  "version": "1.0.0",
  "description": "Client Vue.js de l'application de prise de rendez-vous médicaux [RdvMedecins] - client du serveur JSON [rdvmedecins-nestjs-json]",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview",
    "verifier": "vue-tsc --noEmit"
  },
  "dependencies": {
    "bootstrap": "^5.3.8",
    "pinia": "^3.0.4",
    "vue": "^3.5.22",
    "vue-i18n": "~11.3.2",
    "vue-router": "^4.6.3"
  },
  "devDependencies": {
    "@types/node": "^22.10.0",
    "@vitejs/plugin-vue": "^6.0.1",
    "@vue/tsconfig": "^0.8.1",
    "typescript": "~5.9.3",
    "vite": "^7.1.12",
    "vue-tsc": "^3.1.3"
  }
}

Les mêmes bibliothèques que les exemples. Une commande de plus :

  • ligne 10 : [npm run preview] sert la version compilée de l'application (dossier [dist/], produit par [npm run build]), pour la vérifier avant de la déployer.

10.5.2. vite.config.ts

// =========================================================================
// vite.config.ts
// -------------------------------------------------------------------------
//   - npm run dev   : serveur de développement sur http://localhost:5173 ;
//                     les requêtes /api/... sont transmises au serveur JSON
//                     (port 8080) par le proxy : page et API ont la même
//                     origine, le cookie du jeton circule normalement ;
//   - npm run build : l'application compilée, dans dist/. Pour la mettre en
//                     production, on copie le CONTENU de dist/ dans le dossier
//                     client/ du serveur JSON, qui la sert alors lui-même
//                     (cf. chapitre "Déploiement").
// =========================================================================

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5173,
    proxy: {
      '/api': 'http://localhost:8080',
    },
  },
  // npm run preview : sert dist/ (après npm run build), avec le même proxy
  preview: {
    port: 4173,
    proxy: {
      '/api': 'http://localhost:8080',
    },
  },
});
  • lignes 21-23 : le proxy des requêtes [/api] vers le serveur JSON ;
  • lignes 26-31 : le même proxy pour [npm run preview].

Un détail d'importance : l'application utilise des adresses « propres » ([/agenda], [/mon-espace]). Si l'on recharge la page [/agenda], le navigateur demande [/agenda] au serveur de [Vite]. Celui-ci, ne trouvant aucun fichier de ce nom, renvoie [index.html] (c'est son comportement par défaut pour une application à page unique) ; l'application démarre, et son routeur affiche la page de l'agenda.

10.5.3. tsconfig.json et env.d.ts

Identiques à ceux des exemples, à la liste des fichiers près :

1
2
3
4
5
6
7
8
9
{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "compilerOptions": {
    "types": ["vite/client"],
    "noUnusedLocals": true,
    "noUnusedParameters": true
  },
  "include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"]
}

10.6. Le démarrage de l'application

10.6.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 Vue.js, dans <div id="app">. 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">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
  <title>RdvMedecins</title>
</head>
<body>
  <div id="app"></div>
  <!-- sans JavaScript, une application Vue.js ne peut rien afficher : on le dit -->
  <noscript>RdvMedecins a besoin de JavaScript. / RdvMedecins requires JavaScript.</noscript>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>
  • ligne 12 : l'icône de l'onglet. Les fichiers du dossier [public/] sont servis tels quels, à la racine du site ;
  • ligne 18 : sans [JavaScript], une application [Vue.js] ne peut rien afficher. L'application HTML du cours [NestJS], elle, fonctionnait sans [JavaScript] : c'est le prix d'une application à page unique ;
  • ligne 19 : le programme.

10.6.2. src/main.ts

// =========================================================================
// main.ts - le démarrage de l'application
// -------------------------------------------------------------------------
//   1. Bootstrap (CSS) et nos quelques styles ;
//   2. Pinia (stores), vue-i18n (langues), le routeur ;
//   3. la réaction à une réponse 401 venue de n'importe quelle requête :
//      la session a expiré - on oublie l'utilisateur et on retourne à la
//      page de connexion, avec un message ;
//   4. l'affichage dans <div id="app">.
// =========================================================================

import { createApp } from 'vue';
import { createPinia } from 'pinia';
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles.css';
import App from './App.vue';
import { i18n } from './i18n';
import { router } from './router';
import { quandNonAuthentifie } from './api/client-http';
import { useSessionStore } from './stores/session';
import { useMessagesStore } from './stores/messages';

const app = createApp(App);
app.use(createPinia());
app.use(i18n);
app.use(router);

quandNonAuthentifie(() => {
  useSessionStore().oublier();
  useMessagesStore().flash('erreur', 'ERRORS.SESSION_EXPIRED');
  router.push({ name: 'login' });
});

app.mount('#app');
  • lignes 24-26 : les trois extensions : les stores, les langues, le routeur. [Pinia] doit être installée AVANT le routeur : le garde du routeur utilise un store ;
  • lignes 28-32 : ce qu'il faut faire quand une requête, n'importe laquelle, reçoit la réponse 401 (session expirée, compte supprimé...) : oublier l'utilisateur, préparer le message « Votre session a expiré » pour la page suivante, et aller à la page de connexion. Cette réaction est déclarée UNE fois, ici ; la fonction [quandNonAuthentifie] est décrite avec [client-http.ts].

10.6.3. src/App.vue

<!--
  =========================================================================
  App.vue - 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 (<RouterView>) ;
    - la zone d'erreur en BAS ;
    - la fenêtre de confirmation, une fois pour toute l'application.

  :key="String(route.name)" sur <RouterView> : quand on passe d'une route à
  une AUTRE (de /medecins à /clients, par exemple), le composant de la page
  est recréé ; quand seule la query change (/agenda?jour=...), il est
  conservé, et c'est lui qui suit l'URL.
  =========================================================================
-->
<script setup lang="ts">
import { RouterView, useRoute } from 'vue-router';
import BarreNavigation from './components/BarreNavigation.vue';
import MessageSucces from './components/MessageSucces.vue';
import ZoneErreur from './components/ZoneErreur.vue';
import FenetreConfirmation from './components/FenetreConfirmation.vue';

const route = useRoute();
</script>

<template>
  <div class="container py-4">
    <BarreNavigation />
    <MessageSucces />
    <RouterView :key="String(route.name)" />
    <ZoneErreur />
  </div>
  <FenetreConfirmation />
</template>

Le gabarit commun à toutes les pages, équivalent de [views/layout.hbs] :

  • ligne 30 : la barre de navigation ;
  • ligne 31 : un message de succès, en haut ;
  • ligne 32 : la page courante. L'attribut [:key] : quand la clé d'un composant change, [Vue.js] le détruit et en crée un nouveau. Avec le NOM de la route pour clé, passer de [/medecins] à [/clients] (deux routes différentes, affichées par le même composant) recrée la page, qui recharge ses données. Quand seule la [query] change ([/agenda?jour=...]), la page est conservée, et c'est elle qui suit son URL ;
  • ligne 33 : la zone d'erreur, en bas ;
  • ligne 35 : la fenêtre de confirmation, une fois pour toute l'application.

10.6.4. 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/ZoneErreur.vue).
   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) */
.captcha-image svg {
  display: block;
  border: 1px solid #dee2e6;
  border-radius: 0.375rem;
}

Les styles de l'application HTML, avec un ajout :

  • ligne 28 : la zone d'erreur, « collante » en bas de la fenêtre, peut chevaucher un bouton (une fenêtre de confirmation au centre d'une page courte, par exemple) ; elle ne contient rien de cliquable, et un clic la traverse.

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

10.7.1. src/api/types.ts

Les objets JSON de l'API, décrits au chapitre sur la boîte noire, traduits en interfaces [TypeScript] :

// =========================================================================
// 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;
}
  • ligne 32 : une interface peut en étendre une autre : une [Personne] est une [Identite] qui a en plus un id, une version et, pour l'ADMIN, un login ;
  • ligne 54 : une ligne de l'agenda : un créneau, plus les droits de l'utilisateur connecté ;
  • ligne 39 et suivantes : ce que le client ENVOIE au serveur.

10.7.2. src/api/client-http.ts

La fonction [requete] de l'exemple 19 ([commun/api.ts]), avec la gestion centralisée de la réponse 401 :

// =========================================================================
// api/client-http.ts - la fonction qui envoie TOUTES les requêtes à l'API
// -------------------------------------------------------------------------
// Reprise de commun/api.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
// prévient UNE fonction, enregistrée au démarrage (cf. main.ts), qui oublie
// l'utilisateur et renvoie vers la page de connexion.
// =========================================================================

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

// la fonction appelée à chaque réponse 401 (cf. main.ts)
let surNonAuthentifie: (erreur: ErreurApi) => void = () => {};

export function quandNonAuthentifie(fonction: (erreur: ErreurApi) => void): void {
  surNonAuthentifie = fonction;
}

type Methode = 'GET' | 'POST' | 'PUT' | 'DELETE';

export interface OptionsRequete {
  // les routes publiques (connexion, captcha...) et GET /api/auth/moi
  // traitent le 401 elles-mêmes : pas de redirection automatique
  ignorer401?: boolean;
}

export async function requete<T>(methode: Methode, url: string, corps?: unknown, options: OptionsRequete = {}): Promise<T> {
  let reponse: Response;
  try {
    reponse = await fetch(`/api${url}`, {
      method: methode,
      headers: corps !== undefined ? { 'Content-Type': 'application/json', Accept: 'application/json' } : { Accept: 'application/json' },
      body: corps !== undefined ? JSON.stringify(corps) : undefined,
    });
  } catch {
    throw new ErreurApi(0, 'ERRORS.SERVER_UNREACHABLE');
  }

  // lu comme du texte puis converti : une réponse vide, ou qui n'est pas du
  // JSON (serveur arrêté : c'est le proxy de Vite qui répond), ne fait pas
  // planter l'application
  const texte = await reponse.text();
  let donnees: unknown = null;
  try {
    donnees = texte ? JSON.parse(texte) : null;
  } catch {
    donnees = null;
  }

  if (reponse.ok) {
    return donnees as T;
  }

  const e = (donnees ?? {}) as { cle?: string; params?: Record<string, string>; champs?: Record<string, string> };
  const erreur = new ErreurApi(
    reponse.status,
    // pas de clé : ce n'est pas notre serveur qui a répondu, mais un proxy
    // (Vite : 500 sans corps ; nginx : 502, 504...)
    e.cle ?? (reponse.status >= 500 ? 'ERRORS.SERVER_UNREACHABLE' : 'ERRORS.SERVER_ERROR'),
    e.params,
    e.champs,
  );
  if (reponse.status === 401 && !options.ignorer401) {
    surNonAuthentifie(erreur);
  }
  throw erreur;
}
  • lignes 14-23 : l'erreur d'une requête : code HTTP, clé du message, paramètres, erreur de chaque champ ;
  • ligne 27 : la fonction à appeler à chaque réponse 401. Elle ne fait rien au départ ; [main.ts] en fournit une, par [quandNonAuthentifie]. Pourquoi ne pas appeler directement le routeur ici ? Parce que la couche d'accès à l'API ne doit rien savoir des pages : elle signale, l'application décide ;
  • lignes 35-39 : certaines requêtes attendent un 401 et le traitent elles-mêmes : un mauvais mot de passe ([POST /api/auth/login] répond 401), ou la question « qui est connecté ? » ([GET /api/auth/moi], 401 si personne). Pour elles, pas de redirection automatique ;
  • ligne 46 : l'entête [Accept] indique au serveur le format de réponse souhaité ;
  • ligne 50 : pas de réponse du tout ;
  • ligne 73 : une réponse sans clé ne vient pas de notre serveur : quand le serveur JSON est arrêté, c'est le proxy qui répond (celui de [Vite] par une erreur 500 sans corps ; un proxy de production comme [nginx], par 502 ou 504) ;
  • lignes 77-79 : un 401 inattendu : on prévient l'application, puis on rejette la promesse comme pour toute erreur.

10.7.3. src/api/rdvmedecins.api.ts

Une fonction par route de l'API :

// =========================================================================
// api/rdvmedecins.api.ts - une fonction par route de l'API
// -------------------------------------------------------------------------
// La "couche d'accès aux données" du client : les composants appellent ces
// fonctions, typées, sans rien savoir des URL, de fetch ni du JSON. Si l'API
// change, seul ce fichier change.
// =========================================================================

import { requete } from './client-http';
import type {
  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>;

export const rdvApi = {
  // ------------------------------------------------ connexion (routes publiques)
  captcha: () => requete<Captcha>('GET', '/captcha', undefined, { ignorer401: true }),
  moi: () => requete<Utilisateur>('GET', '/auth/moi', undefined, { ignorer401: true }),
  connecter: (login: string, password: string, captchaId: string, captcha: string) =>
    requete<Utilisateur>('POST', '/auth/login', { login, password, captchaId, captcha }, { ignorer401: true }),
  deconnecter: () => requete<Vide>('POST', '/auth/logout', {}, { ignorer401: true }),

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

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

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

  // ------------------------------------------------ l'espace du patient
  monEspace: () => requete<MonEspace>('GET', '/mon-espace'),
  monCompte: () => requete<MonCompte>('GET', '/mon-compte'),
  modifierMonCompte: (donnees: ModificationMonCompte) => requete<Utilisateur>('PUT', '/mon-compte', donnees),
  supprimerMonCompte: (motDePasseActuel: string) => requete<Vide>('DELETE', '/mon-compte', { motDePasseActuel }),
};
  • ligne 23 : les médecins et les clients se gèrent de la même façon, seule l'adresse change : [/api/medecins] ou [/api/clients] ;
  • ligne 25 : le type d'un objet vide, [{}] ;
  • lignes 29-33 : les routes de la connexion, qui traitent elles-mêmes leur 401 ([ignorer401]) ;
  • lignes 43-49 : sans médecin, le paramètre [idMedecin] est omis : pour un médecin connecté, le serveur renvoie SON agenda ;
  • lignes 50-51 : [idClient ?? undefined] : [null] devient [undefined], et [JSON.stringify] omet les propriétés [undefined] : un patient n'envoie pas d'[idClient] ;
  • lignes 55-60 : les cinq opérations sur les personnes, paramétrées par leur type ;
  • ligne 66 : une requête DELETE peut, elle aussi, avoir un corps.

10.8. Les langues

10.8.1. src/i18n.ts

// =========================================================================
// i18n.ts - le français et l'anglais
// -------------------------------------------------------------------------
// Les dictionnaires locales/fr.json et locales/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 { createI18n } from 'vue-i18n';
import fr from './locales/fr.json';
import en from './locales/en.json';

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

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 const i18n = createI18n({
  legacy: false,
  locale: langueDeDepart(),
  fallbackLocale: 'fr',
  messages: { fr, en },
  // pas d'avertissement dans la console pour une clé traduite par repli
  fallbackWarn: false,
  missingWarn: false,
});

// changer de langue : toute l'application se retraduit aussitôt
export function changerLangue(langue: Langue): void {
  i18n.global.locale.value = langue;
  document.documentElement.lang = langue;
  try {
    localStorage.setItem(CLE_STOCKAGE, langue);
  } catch {
    // sans importance
  }
}

document.documentElement.lang = i18n.global.locale.value;
  • lignes 21-31 : la langue mémorisée, sinon celle du navigateur. L'accès à [localStorage] est protégé par [try / catch] : certains navigateurs, en navigation privée, le refusent ;
  • lignes 39-40 : pas d'avertissement dans la console quand une clé manque ou vient du dictionnaire de repli ;
  • lignes 44-52 : changer de langue : [i18n.global.locale] est la langue de toute l'application ; on met aussi à jour l'attribut [lang] de la page et l'on mémorise le choix.

10.8.2. Les dictionnaires

Ce sont ceux du cours [NestJS], complétés de quelques clés propres au client ([APP.LOADING], [ERRORS.AUTH_REQUIRED], [ERRORS.JSON_REQUIRED], [ERRORS.SERVER_UNREACHABLE]). Les clés d'erreur sont celles que renvoie le serveur.

10.8.2.1. src/locales/fr.json

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Se déconnecter",
    "MENU_AGENDA": "Agenda",
    "MENU_DOCTORS": "Médecins",
    "MENU_PATIENTS": "Clients",
    "MENU_MY_SPACE": "Mon espace",
    "LOADING": "Chargement...",
    "LANGUAGE": "Langue"
  },
  "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"
  }
}

10.8.2.2. src/locales/en.json

{
  "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.9. Les stores

10.9.1. src/stores/session.ts

// =========================================================================
// stores/session.ts - QUI est connecté ?
// -------------------------------------------------------------------------
// Le store Pinia de la session : l'utilisateur connecté (ou null), et ce
// qu'on en déduit (est-il ADMIN ? patient ?).
//
// Le jeton JWT lui-même n'est PAS ici : il est dans un cookie httpOnly que
// seul le navigateur manipule. Le store 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 store est vide : verifier() demande au
// serveur, une seule fois, qui est connecté (GET /api/auth/moi).
// =========================================================================

import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { rdvApi } from '../api/rdvmedecins.api';
import type { Role, Utilisateur } from '../api/types';

export const useSessionStore = defineStore('session', () => {
  const utilisateur = ref<Utilisateur | null>(null);
  // la question "qui est connecté ?" a-t-elle déjà été posée au serveur ?
  let verification: Promise<void> | null = null;

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

  // la page d'accueil de chaque rôle, juste après la connexion
  const pageAccueil = computed(() => (estPatient.value ? '/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)
  function verifier(): Promise<void> {
    verification ??= rdvApi
      .moi()
      .then((u) => {
        utilisateur.value = u;
      })
      .catch(() => {
        utilisateur.value = null; // 401 : personne ; ou serveur injoignable
      });
    return verification;
  }

  async function connecter(login: string, password: string, captchaId: string, captcha: string): Promise<void> {
    utilisateur.value = await rdvApi.connecter(login, password, captchaId, captcha);
  }

  // l'utilisateur a changé de nom ou de login (PUT /api/mon-compte)
  function mettreAJour(u: Utilisateur): void {
    utilisateur.value = u;
  }

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

  // sans rien demander au serveur : session expirée, compte supprimé
  function oublier(): void {
    utilisateur.value = null;
  }

  return {
    utilisateur,
    connecte,
    role,
    estAdmin,
    estPatient,
    estGestionnaire,
    pageAccueil,
    verifier,
    connecter,
    mettreAJour,
    deconnecter,
    oublier,
  };
});
  • ligne 21 : l'utilisateur connecté, tel que le serveur le décrit. Le jeton, lui, n'est pas là : il est dans un cookie que seul le navigateur manipule ;
  • lignes 25-30 : ce qu'on en déduit, utilisé par les pages et la barre de navigation ;
  • ligne 33 : la page d'accueil de chaque rôle : « Mon espace » pour un patient, l'agenda pour les autres ;
  • lignes 37-47 : la question « qui est connecté ? », posée au serveur la PREMIÈRE fois seulement. La promesse est gardée dans [verification] : les appels suivants la réutilisent (l'opérateur [??=] n'affecte que si la variable vaut [null] ou [undefined]). Elle ne peut pas échouer : une erreur (401, serveur injoignable) signifie « personne » ;
  • lignes 49-51 : la connexion. En cas d'échec, [rdvApi.connecter] rejette sa promesse avec une [ErreurApi], qui traverse cette fonction jusqu'à la page de connexion ;
  • ligne 54 : après une modification de son compte, le patient a peut-être changé de nom ;
  • lignes 58-64 : [finally] : on oublie l'utilisateur, même si le serveur n'a pas répondu ;
  • ligne 67 : sans rien demander au serveur (session expirée, compte supprimé).

10.9.2. src/stores/messages.ts

// =========================================================================
// stores/messages.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 (cf. router.afterEach dans router.ts). C'était, côté serveur, le
// rôle du cookie de courte durée "rdvmedecins_flash".
// =========================================================================

import { ref } from 'vue';
import { defineStore } from 'pinia';
import { ErreurApi } from '../api/client-http';

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

export const useMessagesStore = defineStore('messages', () => {
  const succes = ref<Message | null>(null);
  const erreur = ref<Message | null>(null);
  // les messages à afficher sur la page suivante
  let enAttente: { succes: Message | null; erreur: Message | null } | null = null;

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

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

  // une ErreurApi -> le message de la page ; toute autre erreur est une
  // anomalie du client, qu'on laisse remonter (console du navigateur)
  function afficherErreurApi(e: unknown): void {
    if (!(e instanceof ErreurApi)) {
      throw e;
    }
    afficherErreur(e.cle, e.params);
  }

  function effacer(): void {
    succes.value = null;
    erreur.value = null;
  }

  // pour la page SUIVANTE
  function flash(type: 'succes' | 'erreur', cle: string, params?: Record<string, string>): void {
    enAttente ??= { succes: null, erreur: null };
    enAttente[type] = { cle, params };
  }

  // appelée après chaque navigation (router.afterEach)
  function nouvellePage(): void {
    succes.value = enAttente?.succes ?? null;
    erreur.value = enAttente?.erreur ?? null;
    enAttente = null;
  }

  return { succes, erreur, afficherSucces, afficherErreur, afficherErreurApi, effacer, flash, nouvellePage };
});
  • lignes 21-24 : un message est une CLÉ de traduction et ses paramètres. Il se retraduit donc tout seul si l'utilisateur change de langue ;
  • lignes 27-28 : le message de succès (affiché en haut) et le message d'erreur (en bas) de la page courante ;
  • lignes 44-49 : une [ErreurApi] devient le message de la page. Toute autre erreur est une anomalie du CLIENT (une faute de programmation) : on la laisse remonter, elle s'affichera dans la console du navigateur ;
  • lignes 57-60 : un message pour la page SUIVANTE. Une action qui se termine par un changement de page (réservation, ajout d'un médecin, connexion après création de compte) le dépose avant de naviguer ;
  • lignes 63-67 : appelée après chaque navigation (cf. [router.ts]) : les messages de la page précédente disparaissent, les messages en attente apparaissent. C'était, dans l'application HTML, le rôle du cookie de courte durée [rdvmedecins_flash].

10.10. Les composables

10.10.1. src/composables/useConfirmation.ts

Le composable de l'exemple 12, à trois différences près :

// =========================================================================
// composables/useConfirmation.ts - « Êtes-vous sûr ? »
// -------------------------------------------------------------------------
// Repris de l'exemple 12 : confirmer(cle) ouvre la fenêtre de confirmation
// (components/FenetreConfirmation.vue, placée une fois dans App.vue) 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 confirmer('AGENDA.CANCEL_CONFIRM')) { ... }
// =========================================================================

import { readonly, ref } from 'vue';
import { useMessagesStore } from '../stores/messages';

const cleMessage = ref<string | null>(null); // null : fenêtre fermée
let repondre: ((oui: boolean) => void) | null = null;

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

  function reponse(oui: boolean): void {
    cleMessage.value = null;
    const r = repondre;
    repondre = null;
    r?.(oui);
  }

  return { cleMessage: readonly(cleMessage), confirmer, reponse };
}
  • ligne 19 : le message est une CLÉ de traduction : la fenêtre le traduit dans la langue courante ;
  • ligne 20 : une question encore ouverte (cas improbable) reçoit la réponse « non » ;
  • ligne 22 : une nouvelle question rend périmés les messages de l'action précédente.

10.10.2. src/composables/useFormatage.ts

// =========================================================================
// composables/useFormatage.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. jourLong() suit la langue courante de vue-i18n.
// =========================================================================

import { useI18n } from 'vue-i18n';
import type { 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;
}

export function useFormatage() {
  const { locale } = useI18n();

  // "lundi 5 octobre 2026" / "Monday 5 October 2026"
  function jourLong(jour: string): string {
    return new Intl.DateTimeFormat(locale.value === '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`));
  }

  return { libelle, heure, horaire, jourLong };
}

Le serveur envoie des données brutes ([hdebut: 8, mdebut: 0], [jour: "2026-10-05"]) ; leur mise en forme, qui dépend de la langue, est l'affaire du client :

  • lignes 13-39 : des fonctions ordinaires, sans réactivité, exportées une à une ;
  • lignes 42-54 : un composable, parce que [jourLong] a besoin de la langue COURANTE, qu'il obtient par [useI18n()]. Quand la langue change, un template qui affiche [jourLong(...)] est recalculé ;
  • ligne 49 : [new Date("2026-10-05T00:00:00Z")] est minuit en temps universel ; affichée dans le fuseau de Paris, ce serait encore le 5 octobre, mais à New York, le 4 ! On demande donc un affichage dans le fuseau UTC.

10.10.3. src/composables/useFormulaire.ts

// =========================================================================
// composables/useFormulaire.ts - les erreurs d'un formulaire envoyé au serveur
// -------------------------------------------------------------------------
// Tous les formulaires de l'application suivent le même schéma que
// l'exemple 20 : 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) s'affiche sous le champ,
//     encadré de rouge ;
//   - le message général (ErreurApi.cle) s'affiche en bas de la page.
// Ce composable regroupe cet état et ces gestes.
// =========================================================================

import { ref } from 'vue';
import { ErreurApi } from '../api/client-http';
import { useMessagesStore } from '../stores/messages';

export function useFormulaire() {
  const messages = useMessagesStore();
  const erreurs = ref<Record<string, string>>({}); // champ -> clé du message
  const params = ref<Record<string, string>>({}); // paramètres des messages ({min}...)
  const envoiEnCours = ref(false);

  function effacerErreurs(): void {
    erreurs.value = {};
    params.value = {};
  }

  // exécute l'envoi ; renvoie true s'il a réussi. En cas d'ErreurApi, les
  // erreurs sont affichées et on renvoie false.
  async function envoyer(action: () => Promise<void>): Promise<boolean> {
    effacerErreurs();
    envoiEnCours.value = true;
    try {
      await action();
      return true;
    } catch (e) {
      if (e instanceof ErreurApi) {
        erreurs.value = e.champs;
        params.value = e.params;
      }
      messages.afficherErreurApi(e);
      return false;
    } finally {
      envoiEnCours.value = false;
    }
  }

  return { erreurs, params, envoiEnCours, envoyer, effacerErreurs };
}

Tous les formulaires de l'application suivent la démarche de l'exemple 20 : aucune règle n'est vérifiée dans le navigateur, le serveur valide tout, et ses erreurs s'affichent sous les champs. Ce composable regroupe l'état et les gestes communs :

  • lignes 19-21 : l'erreur de chaque champ, les paramètres des messages, et un indicateur d'envoi (le bouton est désactivé pendant l'envoi : pas de double clic) ;
  • lignes 30-46 : exécute l'envoi (une fonction fournie par la page) ; en cas d'[ErreurApi], range les erreurs des champs, affiche le message général en bas de la page, et renvoie [false].

10.11. Le routeur

// =========================================================================
// router.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 :
// createWebHistory produit des adresses "propres", sans #. 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 (Vite le fait en développement ; en production, cf.
// web/client-spa.ts du serveur JSON).
//
// Chaque route porte des métadonnées (meta) :
//   - titre   : clé de traduction du titre de l'onglet ;
//   - public  : accessible sans être connecté ;
//   - roles   : les rôles autorisés (absent = tous les rôles connectés) ;
//   - type    : 'medecins' ou 'clients' pour les pages communes aux deux.
//
// 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.
// =========================================================================

import { createRouter, createWebHistory } from 'vue-router';
import type { Role } from './api/types';
import type { TypePersonnes } from './api/rdvmedecins.api';
import { i18n } from './i18n';
import { useSessionStore } from './stores/session';
import { useMessagesStore } from './stores/messages';
import { rdvApi } from './api/rdvmedecins.api';
import ConnexionView from './views/ConnexionView.vue';
import AgendaView from './views/AgendaView.vue';

declare module 'vue-router' {
  interface RouteMeta {
    titre: string;
    public?: boolean;
    roles?: Role[];
    type?: TypePersonnes;
  }
}

// les routes de gestion des médecins ET des clients (ADMIN), sur le même modèle
function routesPersonnes(type: TypePersonnes, titre: string, titreAjout: string, titreModif: string) {
  const roles: Role[] = ['ADMIN'];
  return [
    { path: `/${type}`, name: type, component: () => import('./views/PersonnesView.vue'), meta: { titre, roles, type } },
    {
      path: `/${type}/ajouter`,
      name: `${type}-ajouter`,
      component: () => import('./views/PersonneFormulaireView.vue'),
      meta: { titre: titreAjout, roles, type },
    },
    {
      path: `/${type}/:id(\\d+)/modifier`, // (\\d+) : l'id doit être un nombre
      name: `${type}-modifier`,
      component: () => import('./views/PersonneFormulaireView.vue'),
      props: (route: { params: { id?: string | string[] } }) => ({ id: Number(route.params.id) }),
      meta: { titre: titreModif, roles, type },
    },
  ];
}

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    // "/" : vers /login, qui renvoie lui-même à la page d'accueil du rôle si l'on est connecté
    { path: '/', redirect: '/login' },
    { path: '/login', name: 'login', component: ConnexionView, meta: { titre: 'LOGIN.TITLE', public: true } },
    { path: '/agenda', name: 'agenda', component: AgendaView, meta: { titre: 'AGENDA.PAGE_TITLE' } },
    ...routesPersonnes('medecins', 'MEDECINS.TITLE', 'MEDECINS.ADD', 'MEDECINS.EDIT'),
    ...routesPersonnes('clients', 'CLIENTS.TITLE', 'CLIENTS.ADD', 'CLIENTS.EDIT'),
    {
      path: '/inscription',
      name: 'inscription',
      component: () => import('./views/InscriptionView.vue'),
      meta: { titre: 'INSCRIPTION.TITLE', public: true },
      // un garde PROPRE à la route : sans laissez-passer (captcha résolu sur la
      // page de connexion), retour à la page de connexion
      beforeEnter: async () => {
        const { valide } = await rdvApi.etatAccesInscription().catch(() => ({ valide: false }));
        if (!valide) {
          useMessagesStore().flash('erreur', 'ERRORS.CAPTCHA_BEFORE_ACCOUNT');
          return { name: 'login' };
        }
        return true;
      },
    },
    {
      path: '/mon-espace',
      name: 'mon-espace',
      component: () => import('./views/EspacePatientView.vue'),
      meta: { titre: 'ESPACE.TITLE', roles: ['USER'] },
    },
    {
      path: '/mon-compte',
      name: 'mon-compte',
      component: () => import('./views/MonCompteView.vue'),
      meta: { titre: 'MON_COMPTE.TITLE', roles: ['USER'] },
    },
    {
      path: '/interdit',
      name: 'interdit',
      component: () => import('./views/ErreurView.vue'),
      props: { code: 403 },
      meta: { titre: 'ERROR_PAGE.TITLE' },
    },
    {
      path: '/:pathMatch(.*)*',
      name: 'introuvable',
      component: () => import('./views/ErreurView.vue'),
      props: { code: 404 },
      meta: { titre: 'ERROR_PAGE.TITLE', public: true },
    },
  ],
});

// -------------------------------------------------------------------------
// le garde GLOBAL, avant chaque navigation
// -------------------------------------------------------------------------
router.beforeEach(async (vers) => {
  const session = useSessionStore();
  // la première fois (démarrage, F5) : on demande au serveur qui est connecté
  await session.verifier();

  if (vers.meta.public) {
    // déjà connecté : la page de connexion n'a plus de sens
    if (vers.name === 'login' && session.connecte) {
      return session.pageAccueil;
    }
    return true;
  }
  if (!session.connecte) {
    // on retient la page demandée, pour y revenir après la connexion
    return { name: 'login', query: { redirect: vers.fullPath } };
  }
  if (vers.meta.roles && !vers.meta.roles.includes(session.role!)) {
    return { name: 'interdit' };
  }
  return true;
});

// -------------------------------------------------------------------------
// après chaque navigation : messages "flash", titre de l'onglet
// -------------------------------------------------------------------------
router.afterEach((vers, depuis) => {
  // une navigation qui ne change que la query (/agenda?...) reste une
  // nouvelle "page" : comme avec l'application HTML, les messages de la
  // page précédente disparaissent, les messages flash apparaissent
  if (vers.fullPath !== depuis.fullPath) {
    useMessagesStore().nouvellePage();
  }
  document.title = `${i18n.global.t(vers.meta.titre)} - ${i18n.global.t('APP.TITLE')}`;
});
  • lignes 36-43 : les métadonnées de nos routes : le titre de l'onglet (une clé de traduction), l'accès public, les rôles autorisés, le type de personnes ;
  • lignes 46-64 : les trois routes de gestion des médecins (liste, ajout, modification), et les trois mêmes pour les clients, fabriquées par une fonction. Les pages sont chargées à la demande ([() => import(...)]) : un patient ne téléchargera jamais le code de la gestion des médecins ;
  • ligne 57 : [(\d+)] : une expression régulière restreint le paramètre [id] aux nombres. [/medecins/abc/modifier] ne correspond pas à cette route : c'est la page « introuvable » qui s'affiche ;
  • ligne 60 : la prop [id] du composant, convertie en nombre ;
  • ligne 67 : des adresses « propres », sans [#] ;
  • ligne 70 : l'adresse [/] mène à [/login], qui mène elle-même à la page d'accueil du rôle quand on est connecté ;
  • lignes 82-89 : un garde PROPRE à une route, exécuté avant d'y entrer : sans laissez-passer (captcha résolu sur la page de connexion), la création de compte renvoie à la page de connexion, avec un message ;
  • ligne 107 : [props] peut aussi être un objet fixe : la même page d'erreur sert pour 403 et 404 ;
  • lignes 123-143 : le garde GLOBAL, avant chaque navigation :
    • [await session.verifier()] : la première fois (démarrage, F5), on demande au serveur qui est connecté. Le garde est [async] : la navigation attend la réponse ;
    • une page publique est toujours accessible ; mais la page de connexion, pour un utilisateur connecté, renvoie à sa page d'accueil ;
    • pas connecté : page de connexion, en retenant la page demandée ;
    • mauvais rôle : page 403 ;
  • lignes 148-156 : après chaque navigation : les messages flash, et le titre de l'onglet, traduit. [i18n.global.t] : en dehors d'un composant, on utilise directement l'objet [i18n].

10.12. Les composants

10.12.1. src/components/BarreNavigation.vue

<!--
  =========================================================================
  components/BarreNavigation.vue - 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.
  =========================================================================
-->
<script setup lang="ts">
import { RouterLink, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useSessionStore } from '../stores/session';
import { changerLangue, LANGUES } from '../i18n';

const { t, locale } = useI18n();
const session = useSessionStore();
const router = useRouter();

async function seDeconnecter(): Promise<void> {
  await session.deconnecter().catch(() => {}); // même si le serveur ne répond pas
  router.push({ name: 'login' });
}
</script>

<template>
  <nav class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
    <span class="navbar-brand mb-0">{{ t('APP.TITLE') }}</span>

    <ul v-if="session.connecte" class="navbar-nav me-auto">
      <li v-if="session.estPatient" class="nav-item">
        <!-- active-class : la classe Bootstrap du menu de la page affichée -->
        <RouterLink class="nav-link" active-class="active" to="/mon-espace">{{ t('APP.MENU_MY_SPACE') }}</RouterLink>
      </li>
      <li class="nav-item">
        <RouterLink class="nav-link" active-class="active" to="/agenda">{{ t('APP.MENU_AGENDA') }}</RouterLink>
      </li>
      <template v-if="session.estAdmin">
        <li class="nav-item">
          <RouterLink class="nav-link" active-class="active" to="/medecins">{{ t('APP.MENU_DOCTORS') }}</RouterLink>
        </li>
        <li class="nav-item">
          <RouterLink class="nav-link" active-class="active" to="/clients">{{ t('APP.MENU_PATIENTS') }}</RouterLink>
        </li>
      </template>
    </ul>
    <div v-else 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" :aria-label="t('APP.LANGUAGE')">
        <button
          v-for="l in LANGUES"
          :key="l"
          type="button"
          class="btn"
          :class="locale === l ? 'btn-light' : 'btn-outline-light'"
          :aria-pressed="locale === l"
          @click="changerLangue(l)"
        >
          {{ l.toUpperCase() }}
        </button>
      </div>

      <template v-if="session.utilisateur">
        <span class="badge text-bg-light text-primary">{{ session.utilisateur.role }}</span>
        <span class="text-white">{{ session.utilisateur.nom }}</span>
        <button type="button" class="btn btn-sm btn-outline-light" @click="seDeconnecter">{{ t('APP.LOGOUT') }}</button>
      </template>
    </div>
  </nav>
</template>
  • lignes 21-24 : la déconnexion, puis la page de connexion. [.catch(() => {})] : même si le serveur ne répond pas, on se déconnecte côté client ;
  • ligne 31 : les menus n'apparaissent qu'une fois connecté, selon le rôle : « Mon espace » pour un patient, « Médecins » et « Clients » pour l'ADMIN. Un simple confort d'affichage : la vraie protection est celle du serveur ;
  • ligne 34 : le menu de la page affichée reçoit la classe [active] ;
  • lignes 53-63 : les boutons FR / EN. [:aria-pressed] indique aux lecteurs d'écran le bouton enfoncé ;
  • ligne 66 : le rôle, le nom et la déconnexion.

10.12.2. src/components/MessageSucces.vue et ZoneErreur.vue

<!--
  components/MessageSucces.vue - le message de SUCCÈS, en haut de la page
  (rendez-vous enregistré, médecin ajouté...) - cf. stores/messages.ts.
  role="status" : un lecteur d'écran l'annonce sans interrompre l'utilisateur.
-->
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useMessagesStore } from '../stores/messages';

const { t } = useI18n();
const messages = useMessagesStore();
</script>

<template>
  <div v-if="messages.succes" class="alert alert-success" role="status">
    {{ t(messages.succes.cle, messages.succes.params ?? {}) }}
  </div>
</template>
<!--
  components/ZoneErreur.vue - 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.
-->
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useMessagesStore } from '../stores/messages';

const { t } = useI18n();
const messages = useMessagesStore();
</script>

<template>
  <div v-if="messages.erreur" class="zone-erreur" role="alert">
    {{ t(messages.erreur.cle, messages.erreur.params ?? {}) }}
  </div>
</template>

Deux composants très courts, qui affichent les messages du store [messages], traduits dans la langue courante :

  • ligne 16 : un lecteur d'écran annonce le message d'erreur dès son apparition, même s'il est en bas de la page ; le message de succès porte [role="status"], annoncé sans interrompre l'utilisateur.

10.12.3. src/components/FenetreConfirmation.vue

<!--
  =========================================================================
  components/FenetreConfirmation.vue - la fenêtre « Êtes-vous sûr ? »
  -------------------------------------------------------------------------
  Celle de l'exemple 12, 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.
  =========================================================================
-->
<script setup lang="ts">
import { nextTick, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useConfirmation } from '../composables/useConfirmation';

const { t } = useI18n();
const { cleMessage, reponse } = useConfirmation();
const fenetre = useTemplateRef<HTMLElement>('fenetre');
const boutonNon = useTemplateRef<HTMLButtonElement>('boutonNon');
let focusPrecedent: HTMLElement | null = null;

watch(cleMessage, async (cle, ancienne) => {
  if (cle !== null && ancienne === null) {
    focusPrecedent = document.activeElement as HTMLElement | null;
    await nextTick(); // la fenêtre est maintenant dans la page
    boutonNon.value?.focus();
  } else if (cle === null) {
    focusPrecedent?.focus();
    focusPrecedent = null;
  }
});

function clavier(e: KeyboardEvent): void {
  if (e.key === 'Escape') {
    reponse(false);
  } else if (e.key === 'Tab' && fenetre.value) {
    const boutons = [...fenetre.value.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();
    }
  }
}
</script>

<template>
  <Teleport to="body">
    <div v-if="cleMessage !== null" @keydown="clavier">
      <div class="modal-backdrop fade show"></div>
      <div
        ref="fenetre"
        class="modal fade show d-block"
        tabindex="-1"
        role="alertdialog"
        aria-modal="true"
        aria-labelledby="confirmation-titre"
        aria-describedby="confirmation-message"
        @click.self="reponse(false)"
      >
        <div class="modal-dialog modal-dialog-centered">
          <div class="modal-content">
            <div class="modal-header">
              <h5 id="confirmation-titre" class="modal-title">{{ t('CONFIRMATION.TITLE') }}</h5>
              <button type="button" class="btn-close" :aria-label="t('BOOKING_DIALOG.CLOSE_ARIA')" @click="reponse(false)"></button>
            </div>
            <div class="modal-body">
              <p id="confirmation-message" class="mb-0">{{ t(cleMessage) }}</p>
            </div>
            <div class="modal-footer">
              <button ref="boutonNon" type="button" class="btn btn-secondary" @click="reponse(false)">{{ t('CONFIRMATION.NO') }}</button>
              <button type="button" class="btn btn-danger" @click="reponse(true)">{{ t('CONFIRMATION.YES') }}</button>
            </div>
          </div>
        </div>
      </div>
    </div>
  </Teleport>
</template>

La fenêtre de l'exemple 12, complétée pour le clavier comme le script [public/js/app.js] de l'application HTML :

  • lignes 29-38 : à l'ouverture, on retient l'élément qui avait le focus (le bouton « Supprimer » cliqué, par exemple), puis on donne le focus au bouton « Non » ; à la fermeture, on rend le focus à l'élément retenu. Un utilisateur au clavier retrouve ainsi sa place dans la page ;
  • lignes 40-55 : Échap vaut « non » ; Tab et Maj+Tab restent DANS la fenêtre : arrivé au dernier bouton, Tab revient au premier (sinon, le focus partirait sur la page grisée, derrière) ;
  • ligne 60 : le clavier est écouté sur la fenêtre elle-même : pas d'écouteur à poser sur le document, ni à retirer ;
  • ligne 68 : pour les lecteurs d'écran, le titre et le message de la fenêtre ;
  • ligne 79 : le message, traduit.

10.12.4. src/components/ChampSaisie.vue

Un champ de formulaire complet, réutilisé par tous les formulaires de l'application :

<!--
  =========================================================================
  components/ChampSaisie.vue - un champ de formulaire complet
  -------------------------------------------------------------------------
  Libellé + champ + message d'aide OU message d'erreur, avec v-model (cf.
  exemple 08). Tous les formulaires de l'application l'utilisent :

    <ChampSaisie v-model="saisie.nom" id="nom" libelle="PERSONNES.LASTNAME_LABEL"
                 :erreur="erreurs.nom" :params="params" :maxlength="20" />

  libelle, aide et erreur sont des CLÉS de traduction. Une erreur encadre le
  champ de rouge (classe Bootstrap is-invalid) et remplace l'aide.
  =========================================================================
-->
<script setup lang="ts">
import { useI18n } from 'vue-i18n';

const modele = defineModel<string>({ required: true });

withDefaults(
  defineProps<{
    id: string;
    libelle: string;
    type?: 'text' | 'password';
    erreur?: string; // clé du message d'erreur de ce champ
    params?: Record<string, string>; // paramètres des messages ({min}...)
    aide?: string; // clé du message d'aide
    maxlength?: number;
    autocomplete?: string;
    requis?: boolean;
  }>(),
  { type: 'text', erreur: undefined, params: () => ({}), aide: undefined, maxlength: undefined, autocomplete: 'off', requis: false },
);

const { t } = useI18n();
</script>

<template>
  <div>
    <label class="form-label" :for="id">{{ t(libelle) }}</label>
    <input
      :id="id"
      v-model="modele"
      :type="type"
      class="form-control"
      :class="{ 'is-invalid': erreur }"
      :maxlength="maxlength"
      :autocomplete="autocomplete"
      :required="requis"
      :aria-invalid="erreur ? 'true' : undefined"
      :aria-describedby="`${id}-message`"
    >
    <div v-if="erreur" :id="`${id}-message`" class="invalid-feedback">{{ t(erreur, params) }}</div>
    <div v-else-if="aide" :id="`${id}-message`" class="form-text">{{ t(aide, params) }}</div>
  </div>
</template>
  • ligne 18 : le champ s'utilise avec [v-model] (cf. exemple 08) ;
  • lignes 20-33 : les props : l'identifiant du champ, et des CLÉS de traduction pour le libellé, l'aide et l'erreur ;
  • ligne 46 : une erreur encadre le champ de rouge ;
  • ligne 51 : le message d'aide ou d'erreur est rattaché au champ, pour les lecteurs d'écran ;
  • lignes 53-54 : le message d'erreur REMPLACE l'aide.

Placé dans un parent, le composant reçoit aussi les attributs que le parent lui donne sans qu'ils soient des props : [<ChampSaisie class="col-sm-6" ...>] ajoute la classe [col-sm-6] à son élément racine, le [<div>] (ce sont les attributs dits « transmis », [fallthrough attributes]).

10.12.5. src/components/ChampsPersonne.vue

Le titre, le prénom et le nom, communs à trois formulaires :

<!--
  =========================================================================
  components/ChampsPersonne.vue - 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). Trois v-model nommés :
      <ChampsPersonne v-model:titre="s.titre" v-model:prenom="s.prenom"
                      v-model:nom="s.nom" :erreurs="erreurs" />
  =========================================================================
-->
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import ChampSaisie from './ChampSaisie.vue';

const titre = defineModel<string>('titre', { required: true });
const prenom = defineModel<string>('prenom', { required: true });
const nom = defineModel<string>('nom', { required: true });
defineProps<{ erreurs: Record<string, string> }>();

const { t } = useI18n();
const TITRES = ['Mr', 'Mme', 'Melle'];
</script>

<template>
  <div class="mb-3">
    <label class="form-label" for="titre">{{ t('PERSONNES.TITLE_LABEL') }}</label>
    <select id="titre" v-model="titre" class="form-select" :class="{ 'is-invalid': erreurs.titre }">
      <option v-for="tt in TITRES" :key="tt" :value="tt">{{ tt }}</option>
    </select>
    <div v-if="erreurs.titre" class="invalid-feedback">{{ t(erreurs.titre) }}</div>
  </div>
  <div class="row g-3 mb-3">
    <ChampSaisie v-model="prenom" class="col-sm-6" id="prenom" libelle="PERSONNES.FIRSTNAME_LABEL" :erreur="erreurs.prenom" :maxlength="20" requis />
    <ChampSaisie v-model="nom" class="col-sm-6" id="nom" libelle="PERSONNES.LASTNAME_LABEL" :erreur="erreurs.nom" :maxlength="20" requis />
  </div>
</template>
  • lignes 16-18 : trois [v-model] nommés : le parent écrit [v-model:titre="..."], [v-model:prenom="..."], [v-model:nom="..."] ;
  • ligne 34 : les deux champs de texte sont des [ChampSaisie]. [prenom] est la [ref] de [defineModel] : l'enchaînement parent -> ChampsPersonne -> ChampSaisie -> [<input>] fonctionne dans les deux sens.

Un composant peut avoir plusieurs éléments racines (ici le [<div>] du titre et la ligne des deux champs) : on parle de « fragment ».

10.12.6. src/components/ChampCaptcha.vue

<!--
  =========================================================================
  components/ChampCaptcha.vue - l'image du captcha et le champ de saisie
  -------------------------------------------------------------------------
  Charge un captcha (GET /api/captcha) à l'affichage. Le parent récupère
  la saisie par v-model, et l'identifiant du captcha par la méthode exposée
  captchaId() ; il appelle 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).

  defineExpose : ce qu'un composant PARENT peut appeler sur ce composant,
  grâce à une référence de template (cf. ConnexionView.vue).

  v-html : 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 17.
  =========================================================================
-->
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { rdvApi } from '../api/rdvmedecins.api';
import type { Captcha } from '../api/types';

const saisie = defineModel<string>({ required: true });
defineProps<{ erreur?: string }>();

const { t } = useI18n();
const captcha = ref<Captcha | null>(null);

async function recharger(): Promise<void> {
  saisie.value = '';
  try {
    captcha.value = await rdvApi.captcha();
  } catch {
    captcha.value = null; // serveur injoignable : la connexion échouera avec un message clair
  }
}

defineExpose({ recharger, captchaId: () => captcha.value?.id ?? '' });
onMounted(recharger);
</script>

<template>
  <div class="mb-3">
    <label class="form-label" for="captcha">{{ t('CAPTCHA.LABEL') }}</label>
    <div class="d-flex align-items-center gap-3 mb-2">
      <div class="captcha-image" role="img" :aria-label="t('CAPTCHA.IMAGE_ALT')" v-html="captcha?.svg ?? ''"></div>
      <a href="#" class="small" @click.prevent="recharger">{{ t('CAPTCHA.NEW_IMAGE') }}</a>
    </div>
    <input id="captcha" v-model="saisie" type="text" maxlength="10" autocomplete="off" autocapitalize="off" spellcheck="false"
           class="form-control" :class="{ 'is-invalid': erreur }">
    <div v-if="erreur" class="invalid-feedback">{{ t(erreur) }}</div>
    <div v-else class="form-text">{{ t('CAPTCHA.HELP') }}</div>
  </div>
</template>
  • lignes 30-37 : un nouveau captcha ; la saisie précédente est effacée ;
  • ligne 39 : ce que le PARENT peut appeler sur ce composant, par une référence de template (cf. [ConnexionView.vue]). Sans [defineExpose], un composant [<script setup>] n'expose rien ;
  • ligne 47 : l'image SVG, insérée telle quelle : elle vient de notre serveur (cf. exemple 17).

10.12.7. src/components/FenetreReservation.vue

<!--
  =========================================================================
  components/FenetreReservation.vue - la fenêtre de réservation d'un créneau
  -------------------------------------------------------------------------
  Même fenêtre que views/partials/reservation.hbs de l'application HTML
  (et que le composant booking-dialog du client Angular) : 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 (AgendaView.vue).
  =========================================================================
-->
<script setup lang="ts">
import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Personne } from '../api/types';
import { libelle } from '../composables/useFormatage';

const props = defineProps<{
  horaire: string; // "8:00 - 8:20"
  clients: Personne[]; // ADMIN / DOCTOR : les patients parmi lesquels choisir
  patient: string | null; // USER : le patient, c'est lui-même
  envoiEnCours: boolean;
}>();

const emit = defineEmits<{ confirmer: [idClient: number | null]; fermer: [] }>();

const { t } = useI18n();
const idClient = ref<number | null>(null);
const liste = useTemplateRef<HTMLSelectElement>('liste');
const boutonConfirmer = useTemplateRef<HTMLButtonElement>('boutonConfirmer');

function echap(e: KeyboardEvent): void {
  if (e.key === 'Escape') {
    emit('fermer');
  }
}

onMounted(() => {
  // le focus sur le premier élément utile de la fenêtre
  (props.patient === null ? liste.value : boutonConfirmer.value)?.focus();
  document.addEventListener('keydown', echap);
});
onUnmounted(() => document.removeEventListener('keydown', echap));
</script>

<template>
  <Teleport to="body">
    <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.self="emit('fermer')">
      <div class="modal-dialog modal-dialog-centered">
        <form class="modal-content" @submit.prevent="emit('confirmer', idClient)">
          <div class="modal-header">
            <h5 id="titre-reservation" class="modal-title">{{ t('BOOKING_DIALOG.TITLE') }}</h5>
            <button type="button" class="btn-close" :aria-label="t('BOOKING_DIALOG.CLOSE_ARIA')" @click="emit('fermer')"></button>
          </div>

          <div class="modal-body">
            <p>{{ t('BOOKING_DIALOG.SLOT', { horaire }) }}</p>
            <!-- un patient réserve pour lui-même : pas de liste (et le serveur
                 ignorerait de toute façon un éventuel idClient) -->
            <p v-if="patient !== null" class="mb-0">{{ t('BOOKING_DIALOG.PATIENT_LABEL') }} : <strong>{{ patient }}</strong></p>
            <template v-else>
              <label class="form-label" for="idClient">{{ t('BOOKING_DIALOG.PATIENT_LABEL') }}</label>
              <select id="idClient" ref="liste" v-model="idClient" class="form-select">
                <option :value="null">{{ t('BOOKING_DIALOG.CHOOSE_PATIENT') }}</option>
                <option v-for="c in clients" :key="c.id" :value="c.id">{{ libelle(c) }}</option>
              </select>
            </template>
          </div>

          <div class="modal-footer">
            <button type="button" class="btn btn-secondary" @click="emit('fermer')">{{ t('BOOKING_DIALOG.CANCEL') }}</button>
            <button ref="boutonConfirmer" type="submit" class="btn btn-primary" :disabled="envoiEnCours">{{ t('BOOKING_DIALOG.CONFIRM') }}</button>
          </div>
        </form>
      </div>
    </div>
  </Teleport>
</template>

La fenêtre de réservation d'un créneau. Elle ne parle pas au serveur : elle émet [confirmer] avec le patient choisi, ou [fermer]. C'est la page de l'agenda qui décide quoi en faire.

  • lignes 22-27 : l'horaire du créneau, et soit la liste des patients (ADMIN, DOCTOR), soit le nom du patient connecté (USER) ;
  • lignes 42-46 : à l'ouverture, le focus va au premier élément utile (la liste des patients, ou le bouton « Confirmer ») ; la touche Échap ferme la fenêtre ;
  • ligne 56 : un vrai formulaire : la touche Entrée confirme ;
  • ligne 66 : un patient réserve pour lui-même : pas de liste.

10.13. Les pages

10.13.1. src/views/ConnexionView.vue

<!--
  =========================================================================
  views/ConnexionView.vue - 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.
  =========================================================================
-->
<script setup lang="ts">
import { onMounted, reactive, ref, useTemplateRef } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { ErreurApi } from '../api/client-http';
import { rdvApi } from '../api/rdvmedecins.api';
import { useSessionStore } from '../stores/session';
import { useMessagesStore } from '../stores/messages';
import ChampCaptcha from '../components/ChampCaptcha.vue';

const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const messages = useMessagesStore();

const saisie = reactive({
  login: typeof route.query.login === 'string' ? route.query.login.slice(0, 30) : '',
  password: '',
  captcha: '',
});
const erreurCaptcha = ref<string | undefined>(undefined);
const envoiEnCours = ref(false);
const champCaptcha = useTemplateRef<InstanceType<typeof ChampCaptcha>>('champCaptcha');
const champLogin = useTemplateRef<HTMLInputElement>('champLogin');
const champPassword = useTemplateRef<HTMLInputElement>('champPassword');

// le focus : sur le mot de passe si le login est déjà rempli
onMounted(() => (saisie.login ? champPassword.value : champLogin.value)?.focus());

// 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)
async function tenter(action: () => Promise<void>): Promise<void> {
  messages.effacer();
  erreurCaptcha.value = undefined;
  envoiEnCours.value = true;
  try {
    await action();
  } catch (e) {
    messages.afficherErreurApi(e);
    if (e instanceof ErreurApi) {
      erreurCaptcha.value = e.champs.captcha;
    }
    await champCaptcha.value?.recharger();
  } finally {
    envoiEnCours.value = false;
  }
}

function seConnecter(): Promise<void> {
  return tenter(async () => {
    await session.connecter(saisie.login.trim(), saisie.password, champCaptcha.value?.captchaId() ?? '', saisie.captcha);
    const redirect = route.query.redirect;
    // seulement un chemin INTERNE : jamais de redirection vers un autre site
    router.push(typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : session.pageAccueil);
  });
}

function creerCompte(): Promise<void> {
  return tenter(async () => {
    await rdvApi.accesInscription(champCaptcha.value?.captchaId() ?? '', saisie.captcha);
    router.push({ name: 'inscription' });
  });
}
</script>

<template>
  <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">{{ t('LOGIN.TITLE') }}</h5>

        <form @submit.prevent="seConnecter">
          <div class="mb-3">
            <label class="form-label" for="login">{{ t('LOGIN.LOGIN_LABEL') }}</label>
            <input id="login" ref="champLogin" v-model="saisie.login" type="text" class="form-control" autocomplete="username" required>
          </div>
          <div class="mb-3">
            <label class="form-label" for="password">{{ t('LOGIN.PASSWORD_LABEL') }}</label>
            <input id="password" ref="champPassword" v-model="saisie.password" type="password" class="form-control"
                   autocomplete="current-password" required>
          </div>

          <ChampCaptcha ref="champCaptcha" v-model="saisie.captcha" :erreur="erreurCaptcha" />

          <!-- type="submit" : Entrée dans un champ déclenche "Se connecter" -->
          <button type="submit" class="btn btn-primary w-100" :disabled="envoiEnCours">{{ t('LOGIN.SUBMIT') }}</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="envoiEnCours" @click="creerCompte">
            {{ t('LOGIN.CREATE_ACCOUNT') }}
          </button>
          <div class="form-text text-center">{{ t('LOGIN.CREATE_ACCOUNT_HELP') }}</div>
        </form>

        <div class="text-muted small mt-3">
          {{ t('LOGIN.DEMO_ACCOUNTS') }}
          <ul class="mb-0 mt-1">
            <li><code>admin</code> - {{ t('LOGIN.DEMO_ADMIN') }}</li>
            <li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - {{ t('LOGIN.DEMO_DOCTOR') }}</li>
            <li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - {{ t('LOGIN.DEMO_USER') }}</li>
          </ul>
        </div>
      </div>
    </div>
  </div>
</template>
  • ligne 32 : [/login?login=jbernard] pré-remplit le champ login (juste après la création d'un compte) ;
  • ligne 38 : une référence au COMPOSANT [ChampCaptcha] : on pourra appeler ce qu'il expose ([recharger()], [captchaId()]). [InstanceType<typeof ChampCaptcha>] est son type ;
  • ligne 43 : le focus sur le mot de passe si le login est déjà rempli ;
  • lignes 47-62 : les deux boutons partagent le même déroulement : envoi ; en cas d'échec, message d'erreur, erreur sous le champ captcha s'il y a lieu, et NOUVELLE image ;
  • lignes 64-71 : la connexion, puis la page demandée avant la connexion (paramètre [redirect], déposé par le garde du routeur), ou la page d'accueil du rôle. On n'accepte qu'un chemin interne : il doit commencer par [/], mais pas par [//] ([//site.com/...] désigne un AUTRE site) ;
  • lignes 73-78 : « Créer un compte client » : le serveur vérifie le captcha et délivre un laissez-passer ; on ouvre alors la page de création de compte ;
  • ligne 105 : [type="button"] : ce bouton n'envoie pas le formulaire. Dans l'application HTML, c'était un second bouton d'envoi du même formulaire, vers une autre adresse ([formaction]).

10.13.2. src/views/AgendaView.vue

La page principale. Comme dans l'application HTML, TOUT son état est dans son URL :

1
2
3
/agenda                                             choix du médecin et du jour
/agenda?idMedecin=1&jour=2026-10-05                 l'agenda
/agenda?idMedecin=1&jour=2026-10-05&reserver=7      + la fenêtre de réservation du créneau 7
<!--
  =========================================================================
  views/AgendaView.vue - 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 : un watch sur la
  query recharge l'agenda ; les actions de l'utilisateur, elles, changent
  l'URL (router.push).

  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.
  =========================================================================
-->
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { ErreurApi } from '../api/client-http';
import { rdvApi } from '../api/rdvmedecins.api';
import type { Agenda, CreneauAgenda, Personne } from '../api/types';
import { aujourdhui, estUneDateValide, libelle, useFormatage } from '../composables/useFormatage';
import { useConfirmation } from '../composables/useConfirmation';
import { useSessionStore } from '../stores/session';
import { useMessagesStore } from '../stores/messages';
import FenetreReservation from '../components/FenetreReservation.vue';

const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const messages = useMessagesStore();
const { horaire, jourLong } = useFormatage();
const { confirmer } = useConfirmation();

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

// les valeurs du formulaire "médecin + jour", recopiées depuis l'URL
const choix = reactive<{ idMedecin: number | null; jour: string }>({ idMedecin: null, jour: aujourdhui() });

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

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

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

// -------------------------------------------------------------------------
// chargements
// -------------------------------------------------------------------------
onMounted(async () => {
  try {
    medecins.value = await rdvApi.lister('medecins');
    // la liste des patients : seulement pour ceux qui ont le droit de la lire
    if (session.estGestionnaire) {
      clients.value = await rdvApi.lister('clients');
    }
  } catch (e) {
    messages.afficherErreurApi(e);
  }
});

// l'agenda suit l'URL (médecin et jour)
watch(() => [route.query.idMedecin, route.query.jour], chargerAgenda, { immediate: true });

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

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

// "Voir l'agenda" : on change l'URL, l'agenda suit (watch ci-dessus)
function voirAgenda(): void {
  router.push({ query: queryAgenda.value });
}

function fermerReservation(): void {
  router.push({ query: queryAgenda.value });
}

async function reserver(idClient: number | null): Promise<void> {
  const creneau = creneauAReserver.value;
  if (creneau === null) {
    return;
  }
  envoiEnCours.value = true;
  try {
    await rdvApi.reserver(choix.jour, creneau.id, session.estGestionnaire ? idClient : null);
    messages.flash('succes', 'AGENDA.BOOKED');
  } catch (e) {
    if (e instanceof ErreurApi && e.cle === 'ERRORS.CHOOSE_PATIENT') {
      // aucun patient choisi : la fenêtre reste ouverte
      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
    messages.flash('erreur', e.cle, e.params);
  } finally {
    envoiEnCours.value = false;
  }
  await router.push({ query: queryAgenda.value });
  await chargerAgenda(); // c'est le serveur qui fait foi : on relit l'agenda
}

async function annuler(c: CreneauAgenda): Promise<void> {
  if (c.idRv === null || !(await confirmer('AGENDA.CANCEL_CONFIRM'))) {
    return;
  }
  try {
    await rdvApi.annulerRv(c.idRv);
    messages.afficherSucces('AGENDA.CANCELLED');
  } catch (e) {
    messages.afficherErreurApi(e);
  }
  await chargerAgenda();
}
</script>

<template>
  <!-- 1. le choix du médecin et du jour -->
  <form class="card p-3 mb-3" @submit.prevent="voirAgenda">
    <div class="row g-2 align-items-end">
      <div class="col-sm-5">
        <label class="form-label" for="idMedecin">{{ t('DOCTOR_DAY_PICKER.DOCTOR_LABEL') }}</label>
        <select id="idMedecin" v-model="choix.idMedecin" class="form-select" required>
          <option :value="null">{{ t('DOCTOR_DAY_PICKER.CHOOSE_DOCTOR') }}</option>
          <option v-for="m in medecins" :key="m.id" :value="m.id">{{ libelle(m) }}</option>
        </select>
      </div>
      <div class="col-sm-4">
        <label class="form-label" for="jour">{{ t('DOCTOR_DAY_PICKER.DAY_LABEL') }}</label>
        <input id="jour" v-model="choix.jour" type="date" class="form-control" required>
      </div>
      <div class="col-sm-3">
        <button type="submit" class="btn btn-primary w-100">{{ t('DOCTOR_DAY_PICKER.VIEW_AGENDA') }}</button>
      </div>
    </div>
  </form>

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

  <!-- 2. l'agenda -->
  <p v-if="chargement && !agenda" class="text-muted">{{ t('APP.LOADING') }}</p>
  <div v-else-if="agenda" class="card p-3">
    <h5>{{ t('AGENDA.TITLE_PREFIX') }} {{ jourLong(agenda.jour) }} - {{ libelle(agenda.medecin) }}</h5>
    <table v-if="agenda.creneaux.length > 0" class="table table-hover align-middle">
      <thead>
        <tr>
          <th>{{ t('AGENDA.COLUMN_SLOT') }}</th>
          <th>{{ t('AGENDA.COLUMN_STATUS') }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="c in agenda.creneaux" :key="c.id" :class="{ 'creneau-libre': c.reservable }">
          <td>{{ horaire(c) }}</td>
          <td>
            <span v-if="c.libre" class="badge text-bg-success">{{ t('AGENDA.FREE') }}</span>
            <span v-else-if="c.patient" class="badge" :class="c.moi ? 'text-bg-primary' : 'text-bg-secondary'">{{ libelle(c.patient) }}</span>
            <span v-else class="badge text-bg-secondary">{{ t('AGENDA.BOOKED_BY_OTHER') }}</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 -->
            <RouterLink v-if="c.reservable" class="btn btn-sm btn-outline-success" :to="{ query: { ...queryAgenda, reserver: c.id } }">
              {{ t('AGENDA.BOOK') }}
            </RouterLink>
            <button v-if="c.idRv !== null" type="button" class="btn btn-sm btn-outline-danger" @click="annuler(c)">
              {{ t('AGENDA.CANCEL_APPOINTMENT') }}
            </button>
          </td>
        </tr>
      </tbody>
    </table>
    <p v-else class="text-muted mb-0">{{ t('AGENDA.NO_SLOTS') }}</p>
  </div>
  <p v-else class="text-muted">{{ t('AGENDA.EMPTY_STATE') }}</p>

  <!-- 3. la fenêtre de réservation, seulement si l'URL contient &reserver=... -->
  <FenetreReservation
    v-if="creneauAReserver"
    :horaire="horaire(creneauAReserver)"
    :clients="clients"
    :patient="session.estPatient ? (session.utilisateur?.nom ?? '') : null"
    :envoi-en-cours="envoiEnCours"
    @confirmer="reserver"
    @fermer="fermerReservation"
  />
</template>

Les données de la page :

  • lignes 44-48 : les listes, l'agenda affiché, les indicateurs de chargement et d'envoi ;
  • ligne 51 : les valeurs du formulaire « médecin + jour ». Elles sont recopiées DEPUIS l'URL ; l'utilisateur peut les modifier sans que rien ne se passe, jusqu'au clic sur « Voir l'agenda » ;
  • ligne 60 : la [query] de l'agenda affiché, sans fenêtre de réservation ;
  • lignes 64-67 : le créneau dont la fenêtre est ouverte : celui de [?reserver=...], s'il existe ET s'il est réservable par cet utilisateur. Une URL fabriquée à la main ([reserver=] sur un créneau pris) n'ouvre donc aucune fenêtre.

Les chargements :

  • lignes 72-82 : la liste des médecins pour tous, celle des patients seulement pour ceux qui ont le droit de la lire ;
  • ligne 85 : la page SUIT son URL. [immediate] : l'agenda est chargé dès l'affichage de la page (une URL mise en favori, un F5) ; puis à chaque changement du médecin ou du jour de l'URL (clic sur « Voir l'agenda », boutons Précédent / Suivant). Un changement de [reserver] seul ne recharge pas l'agenda : il ne fait qu'ouvrir ou fermer la fenêtre (cf. [creneauAReserver]) ;
  • lignes 87-116 : le jour de l'URL (aujourd'hui par défaut, erreur s'il n'existe pas) ; le médecin de l'URL ; sans médecin, un médecin connecté voit SON agenda (le serveur le retrouve à partir du jeton), et l'on recopie ensuite dans le formulaire le médecin renvoyé par le serveur.

Les actions :

  • lignes 123-125 : « Voir l'agenda » ne charge rien lui-même : il change l'URL, et l'agenda suit ;
  • lignes 131-157 : la réservation :
    • un patient n'envoie pas de patient : le serveur prend celui du jeton ;
    • aucun patient choisi (ADMIN, DOCTOR) : la fenêtre reste ouverte, avec le message ;
    • succès ou autre refus (créneau pris entre-temps, patient qui a déjà un rendez-vous) : un message flash, puis l'URL sans [reserver] (la fenêtre se ferme), puis un rechargement de l'agenda : c'est le serveur qui fait foi ;
  • lignes 159-170 : l'annulation, après confirmation. Le serveur renvoie [idRv] pour les créneaux que l'utilisateur peut annuler : pour un ADMIN ou un DOCTOR, tous ; pour un patient, le sien (l'application HTML ne lui proposait l'annulation que depuis « Mon espace » ; l'API le permet aussi depuis l'agenda).

Le template :

  • ligne 175 : le choix du médecin et du jour ;
  • ligne 195 : un patient qui a déjà un rendez-vous à venir : pourquoi il n'a pas de bouton « Réserver » ;
  • ligne 202 : le jour, mis en forme dans la langue courante ;
  • ligne 216 et ligne 217 : le nom du patient, s'il a été envoyé ; sinon « Réservé » ;
  • ligne 222 : « Réserver » est un LIEN : il ajoute [reserver=...] à l'URL. Le bouton Précédent du navigateur referme donc la fenêtre ;
  • lignes 237-244 : la fenêtre, quand l'URL la demande.

10.13.3. src/views/personnes.config.ts

Les pages de gestion des médecins et des clients sont communes. Ce qui les distingue :

// =========================================================================
// views/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 (PersonnesView.vue,
// PersonneFormulaireView.vue) ; seules changent l'adresse de l'API et les
// clés des libellés, décrites ici. Le routeur indique le type dans la
// propriété meta de chaque route (cf. router.ts).
// =========================================================================

import type { 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 ?? '' };
}
  • ligne 25 : les clés des libellés propres à chaque type, comme dans les contrôleurs [MedecinsController] et [ClientsController] de l'application HTML ;
  • lignes 49-51 : les paramètres [{nom}] et [{login}] des messages « ajouté / modifié / supprimé ».

10.13.4. src/views/PersonnesView.vue

<!--
  =========================================================================
  views/PersonnesView.vue - la liste des médecins OU des clients (ADMIN)
  -------------------------------------------------------------------------
  Une seule page pour les deux : le type ('medecins' ou 'clients') est dans
  les métadonnées de la route (route.meta.type, cf. router.ts), les libellés
  propres à chacun dans personnes.config.ts. Équivalent de
  views/personnes/liste.hbs de l'application HTML.
  =========================================================================
-->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { rdvApi, type TypePersonnes } from '../api/rdvmedecins.api';
import type { Personne } from '../api/types';
import { useConfirmation } from '../composables/useConfirmation';
import { useMessagesStore } from '../stores/messages';
import { CLES_PERSONNES, parametres } from './personnes.config';

const { t } = useI18n();
const route = useRoute();
const messages = useMessagesStore();
const { confirmer } = useConfirmation();

const type = route.meta.type as TypePersonnes;
const cles = CLES_PERSONNES[type];
const personnes = ref<Personne[] | null>(null); // null : pas encore chargées
const vide = computed(() => personnes.value !== null && personnes.value.length === 0);

async function charger(): Promise<void> {
  try {
    personnes.value = await rdvApi.lister(type);
  } catch (e) {
    messages.afficherErreurApi(e);
  }
}

async function supprimer(p: Personne): Promise<void> {
  if (!(await confirmer(cles.confirmerSuppression))) {
    return;
  }
  try {
    await rdvApi.supprimer(type, p.id);
    messages.afficherSucces(cles.supprime, parametres(p));
  } catch (e) {
    // un médecin qui a encore des rendez-vous : 409 ERRORS.DOCTOR_HAS_APPOINTMENTS
    messages.afficherErreurApi(e);
  }
  await charger();
}

onMounted(charger);
</script>

<template>
  <div class="d-flex justify-content-between align-items-center mb-3">
    <h4 class="mb-0">{{ t(cles.titre) }}</h4>
    <RouterLink class="btn btn-primary" :to="`/${type}/ajouter`">{{ t(cles.ajouter) }}</RouterLink>
  </div>

  <div class="card p-3">
    <p v-if="personnes === null" class="text-muted mb-0">{{ t('APP.LOADING') }}</p>
    <p v-else-if="vide" class="text-muted mb-0">{{ t(cles.vide) }}</p>
    <table v-else class="table table-hover align-middle mb-0">
      <thead>
        <tr>
          <th>{{ t('PERSONNES.COLUMN_TITLE') }}</th>
          <th>{{ t('PERSONNES.COLUMN_FIRSTNAME') }}</th>
          <th>{{ t('PERSONNES.COLUMN_LASTNAME') }}</th>
          <th>{{ t('PERSONNES.COLUMN_LOGIN') }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="p in personnes" :key="p.id">
          <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">
              <RouterLink class="btn btn-sm btn-outline-primary" :to="`/${type}/${p.id}/modifier`">{{ t('PERSONNES.EDIT') }}</RouterLink>
              <button type="button" class="btn btn-sm btn-outline-danger" @click="supprimer(p)">{{ t('PERSONNES.DELETE') }}</button>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>
  • ligne 26 : le type de personnes, lu dans les métadonnées de la route. Il ne changera pas pendant la vie du composant : quand on passe de [/medecins] à [/clients], la clé [:key] de [<RouterView>] recrée la page ;
  • ligne 28 : [null] tant que la liste n'est pas chargée : on distingue ainsi « chargement » de « liste vide » ;
  • lignes 39-51 : confirmation, suppression, message, rechargement de la liste. Un médecin qui a encore des rendez-vous : le serveur répond 409, et le message s'affiche en bas de la page.

10.13.5. src/views/PersonneFormulaireView.vue

<!--
  =========================================================================
  views/PersonneFormulaireView.vue - création OU modification d'un médecin
  ou d'un client (ADMIN)
  -------------------------------------------------------------------------
    /medecins/ajouter          -> création (pas de prop id)
    /medecins/3/modifier       -> modification (prop id = 3, cf. router.ts)
  (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. composables/useFormulaire.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.
  =========================================================================
-->
<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { ErreurApi } from '../api/client-http';
import { rdvApi, type TypePersonnes } from '../api/rdvmedecins.api';
import type { DonneesPersonne } from '../api/types';
import { useFormulaire } from '../composables/useFormulaire';
import { useMessagesStore } from '../stores/messages';
import ChampsPersonne from '../components/ChampsPersonne.vue';
import ChampSaisie from '../components/ChampSaisie.vue';
import { CLES_PERSONNES, parametres } from './personnes.config';

const props = defineProps<{ id?: number }>();

const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const messages = useMessagesStore();
const { erreurs, params, envoiEnCours, envoyer } = useFormulaire();

const type = route.meta.type as TypePersonnes;
const cles = CLES_PERSONNES[type];
const creation = computed(() => props.id === undefined);

const saisie = reactive<DonneesPersonne>({ titre: 'Mr', prenom: '', nom: '', login: '', password: '', version: undefined });

// modification : on lit la fiche (dont sa version)
onMounted(async () => {
  if (props.id === undefined) {
    return;
  }
  try {
    const p = await rdvApi.lire(type, props.id);
    Object.assign(saisie, { titre: p.titre, prenom: p.prenom, nom: p.nom, login: p.login ?? '', version: p.version });
  } catch (e) {
    // fiche inexistante (supprimée entre-temps...) : retour à la liste
    if (e instanceof ErreurApi) {
      messages.flash('erreur', e.cle, e.params);
      router.push(`/${type}`);
      return;
    }
    throw e;
  }
});

async function enregistrer(): Promise<void> {
  const reussi = await envoyer(async () => {
    const p = props.id === undefined ? await rdvApi.ajouter(type, saisie) : await rdvApi.modifier(type, props.id, saisie);
    messages.flash('succes', creation.value ? cles.ajoute : cles.modifie, parametres(p));
  });
  if (reussi) {
    router.push(`/${type}`);
  } else if (messages.erreur?.cle === 'ERRORS.DOCTOR_NOT_FOUND' || messages.erreur?.cle === 'ERRORS.PATIENT_NOT_FOUND') {
    // supprimé entre-temps par un autre ADMIN : retour à la liste
    messages.flash('erreur', messages.erreur.cle);
    router.push(`/${type}`);
  }
}
</script>

<template>
  <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">{{ t(creation ? cles.ajouter : cles.modifier) }}</h5>
        <!-- novalidate : c'est le SERVEUR qui valide (il fait foi) -->
        <form novalidate @submit.prevent="enregistrer">
          <ChampsPersonne v-model:titre="saisie.titre" v-model:prenom="saisie.prenom" v-model:nom="saisie.nom" :erreurs="erreurs" />

          <h6 class="mt-4 text-muted">{{ t('PERSONNES.ACCOUNT_SECTION') }}</h6>
          <ChampSaisie v-model="saisie.login" class="mb-3" id="login" libelle="PERSONNES.LOGIN_LABEL" :maxlength="30"
                       :erreur="erreurs.login" :aide="creation ? 'PERSONNES.LOGIN_HELP_CREATE' : 'PERSONNES.LOGIN_HELP_EDIT'" />
          <!-- jamais pré-rempli : le mot de passe ne revient jamais du serveur -->
          <ChampSaisie v-model="saisie.password" class="mb-4" id="password" type="password" libelle="PERSONNES.PASSWORD_LABEL"
                       :maxlength="72" autocomplete="new-password" :erreur="erreurs.password" :params="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="envoiEnCours">{{ t('PERSONNES.SAVE') }}</button>
            <RouterLink class="btn btn-secondary" :to="`/${type}`">{{ t('PERSONNES.BACK') }}</RouterLink>
          </div>
        </form>
      </div>
    </div>
  </div>
</template>
  • ligne 30 : création sans [id], modification avec (la prop vient de la route, cf. [router.ts]) ;
  • ligne 36 : les erreurs du formulaire (cf. [useFormulaire.ts]) ;
  • lignes 45-61 : en modification, on lit la fiche, version comprise. Si elle n'existe pas (supprimée entre-temps), retour à la liste avec un message ;
  • lignes 63-75 : POST ou PUT ; en cas de succès, message flash et retour à la liste. En cas d'échec, [useFormulaire] a déjà affiché les erreurs ; une fiche supprimée entre-temps ramène à la liste ;
  • ligne 84 : [novalidate] : c'est le serveur qui valide ;
  • ligne 85 : les trois [v-model] nommés ;
  • ligne 91 : le mot de passe n'est jamais pré-rempli : il ne revient jamais du serveur.

10.13.6. src/views/InscriptionView.vue

<!--
  =========================================================================
  views/InscriptionView.vue - "Créer un compte client" (/inscription, public)
  -------------------------------------------------------------------------
  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. router.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.
  =========================================================================
-->
<script setup lang="ts">
import { reactive } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { rdvApi } from '../api/rdvmedecins.api';
import type { Inscription } from '../api/types';
import { useFormulaire } from '../composables/useFormulaire';
import { useMessagesStore } from '../stores/messages';
import ChampsPersonne from '../components/ChampsPersonne.vue';
import ChampSaisie from '../components/ChampSaisie.vue';

const { t } = useI18n();
const router = useRouter();
const messages = useMessagesStore();
const { erreurs, params, envoiEnCours, envoyer } = useFormulaire();

// 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';

const saisie = reactive<Inscription>({ titre: 'Mr', prenom: '', nom: '', login: '', password: '', confirmation: '' });

async function creer(): Promise<void> {
  let login = '';
  const reussi = await envoyer(async () => {
    login = (await rdvApi.inscrire(saisie)).login;
  });
  if (reussi) {
    // retour à la page de connexion, login pré-rempli
    messages.flash('succes', 'INSCRIPTION.DONE', { login });
    router.push({ name: 'login', query: { login } });
  } else if (messages.erreur?.cle === 'ERRORS.CAPTCHA_BEFORE_ACCOUNT') {
    // laissez-passer expiré (15 minutes) : retour au captcha
    messages.flash('erreur', 'ERRORS.CAPTCHA_BEFORE_ACCOUNT');
    router.push({ name: 'login' });
  }
}
</script>

<template>
  <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">{{ t('INSCRIPTION.TITLE') }}</h5>
        <p class="text-muted small mb-3">{{ t('INSCRIPTION.INTRO') }}</p>
        <form novalidate @submit.prevent="creer">
          <ChampsPersonne v-model:titre="saisie.titre" v-model:prenom="saisie.prenom" v-model:nom="saisie.nom" :erreurs="erreurs" />

          <h6 class="mt-4 text-muted">{{ t('PERSONNES.ACCOUNT_SECTION') }}</h6>
          <ChampSaisie v-model="saisie.login" class="mb-3" id="login" libelle="PERSONNES.LOGIN_LABEL" :maxlength="30"
                       autocomplete="username" requis :erreur="erreurs.login" aide="INSCRIPTION.LOGIN_HELP" />
          <div class="row g-3 mb-3">
            <ChampSaisie v-model="saisie.password" class="col-sm-6" id="password" type="password" libelle="PERSONNES.PASSWORD_LABEL"
                         :maxlength="72" autocomplete="new-password" requis :erreur="erreurs.password"
                         :params="{ min: MOT_DE_PASSE_MIN, ...params }" aide="INSCRIPTION.PASSWORD_HELP" />
            <ChampSaisie v-model="saisie.confirmation" class="col-sm-6" id="confirmation" type="password"
                         libelle="COMPTE.CONFIRMATION_LABEL" :maxlength="72" autocomplete="new-password" requis
                         :erreur="erreurs.confirmation" />
          </div>
          <div class="d-flex gap-2">
            <button type="submit" class="btn btn-primary" :disabled="envoiEnCours">{{ t('INSCRIPTION.SUBMIT') }}</button>
            <RouterLink class="btn btn-secondary" to="/login">{{ t('INSCRIPTION.BACK_TO_LOGIN') }}</RouterLink>
          </div>
        </form>
      </div>
    </div>
  </div>
</template>
  • ligne 31 : la longueur minimale d'un mot de passe, pour le message d'aide (le serveur la renvoie aussi dans [params], avec l'erreur) ;
  • lignes 35-49 : la création, puis la page de connexion, login pré-rempli. Si le laissez-passer a expiré (15 minutes), le serveur répond 403 : retour à la page de connexion, pour résoudre un nouveau captcha ;
  • ligne 67 : les paramètres des messages du champ ; [...params] (l'opérateur de décomposition) recopie ceux renvoyés par le serveur.

L'accès à cette page est contrôlé par le garde [beforeEnter] de sa route (cf. [router.ts]).

10.13.7. src/views/EspacePatientView.vue

<!--
  =========================================================================
  views/EspacePatientView.vue - "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.
  =========================================================================
-->
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { rdvApi } from '../api/rdvmedecins.api';
import type { MonEspace } from '../api/types';
import { useFormatage } from '../composables/useFormatage';
import { useConfirmation } from '../composables/useConfirmation';
import { useMessagesStore } from '../stores/messages';

const { t } = useI18n();
const messages = useMessagesStore();
const { libelle, horaire, jourLong } = useFormatage();
const { confirmer } = useConfirmation();

const espace = ref<MonEspace | null>(null);

async function charger(): Promise<void> {
  try {
    espace.value = await rdvApi.monEspace();
  } catch (e) {
    messages.afficherErreurApi(e);
  }
}

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

onMounted(charger);
</script>

<template>
  <template v-if="espace">
    <h4 class="mb-3">{{ t('ESPACE.HELLO', { nom: libelle(espace.patient) }) }}</h4>
    <div class="row g-3 mb-4">
      <div class="col-sm-6">
        <RouterLink class="card p-4 h-100 text-decoration-none carte-option" to="/mon-compte">
          <h5 class="mb-1">{{ t('ESPACE.MANAGE_ACCOUNT') }}</h5>
          <span class="text-muted">{{ t('ESPACE.MANAGE_ACCOUNT_HELP') }}</span>
        </RouterLink>
      </div>
      <div class="col-sm-6">
        <RouterLink class="card p-4 h-100 text-decoration-none carte-option" to="/agenda">
          <h5 class="mb-1">{{ t('ESPACE.VIEW_AGENDA') }}</h5>
          <span class="text-muted">{{ t('ESPACE.VIEW_AGENDA_HELP') }}</span>
        </RouterLink>
      </div>
    </div>

    <div class="card p-3">
      <h5>{{ t('ESPACE.MY_APPOINTMENT') }}</h5>
      <!-- normalement un seul (règle "un rendez-vous à venir à la fois"),
           mais la page sait en afficher plusieurs -->
      <div
        v-for="(rv, i) in espace.rendezVous"
        :key="rv.id"
        class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2"
        :class="{ 'border-bottom': i < espace.rendezVous.length - 1 }"
      >
        <div>
          <div class="fw-semibold">{{ jourLong(rv.jour) }}, {{ horaire(rv.creneau) }}</div>
          <div class="text-muted">
            {{ libelle(rv.medecin) }} -
            <!-- l'agenda du médecin ce jour-là : le patient y voit son nom -->
            <RouterLink :to="{ path: '/agenda', query: { idMedecin: rv.medecin.id, jour: rv.jour } }">{{ t('ESPACE.SEE_IN_AGENDA') }}</RouterLink>
          </div>
        </div>
        <button type="button" class="btn btn-outline-danger" @click="annuler(rv.id)">{{ t('ESPACE.CANCEL_APPOINTMENT') }}</button>
      </div>
      <p v-if="espace.rendezVous.length === 0" class="text-muted mb-0">{{ t('ESPACE.NO_APPOINTMENT') }}</p>
    </div>
  </template>
  <p v-else class="text-muted">{{ t('APP.LOADING') }}</p>
</template>
  • lignes 36-47 : le patient annule SON rendez-vous (le serveur refuserait celui d'un autre) ;
  • ligne 57 : une carte entière est un lien ;
  • ligne 75 : normalement un seul rendez-vous (règle « un rendez-vous à venir à la fois »), mais la page sait en afficher plusieurs ;
  • ligne 85 : l'agenda du médecin ce jour-là, où le patient voit son nom.

10.13.8. src/views/MonCompteView.vue

<!--
  =========================================================================
  views/MonCompteView.vue - "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).
  =========================================================================
-->
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { rdvApi } from '../api/rdvmedecins.api';
import type { ModificationMonCompte } from '../api/types';
import { useFormulaire } from '../composables/useFormulaire';
import { useConfirmation } from '../composables/useConfirmation';
import { useSessionStore } from '../stores/session';
import { useMessagesStore } from '../stores/messages';
import ChampsPersonne from '../components/ChampsPersonne.vue';
import ChampSaisie from '../components/ChampSaisie.vue';

const { t } = useI18n();
const router = useRouter();
const session = useSessionStore();
const messages = useMessagesStore();
const { confirmer } = useConfirmation();
// deux formulaires, donc deux jeux d'erreurs
const modification = useFormulaire();
const suppression = useFormulaire();

const saisie = reactive<ModificationMonCompte>({
  titre: 'Mr',
  prenom: '',
  nom: '',
  login: '',
  password: '',
  confirmation: '',
  motDePasseActuel: '',
  version: 0,
});
const motDePasseSuppression = ref('');

onMounted(async () => {
  try {
    Object.assign(saisie, await rdvApi.monCompte());
  } catch (e) {
    messages.afficherErreurApi(e);
  }
});

async function enregistrer(): Promise<void> {
  const reussi = await modification.envoyer(async () => {
    // le serveur a délivré un NOUVEAU jeton (nom, login) : la barre de
    // navigation doit suivre
    session.mettreAJour(await rdvApi.modifierMonCompte(saisie));
    messages.flash('succes', 'MON_COMPTE.UPDATED');
  });
  // jamais de mot de passe laissé dans les champs après un envoi
  saisie.motDePasseActuel = '';
  if (reussi) {
    router.push('/mon-espace');
  }
}

async function supprimer(): Promise<void> {
  if (!(await confirmer('MON_COMPTE.DELETE_CONFIRM'))) {
    return;
  }
  const reussi = await suppression.envoyer(async () => {
    await rdvApi.supprimerMonCompte(motDePasseSuppression.value);
  });
  motDePasseSuppression.value = '';
  if (reussi) {
    session.oublier(); // le serveur a effacé le cookie du jeton
    messages.flash('succes', 'MON_COMPTE.DELETED');
    router.push({ name: 'login' });
  }
}
</script>

<template>
  <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">{{ t('MON_COMPTE.TITLE') }}</h5>
        <form novalidate @submit.prevent="enregistrer">
          <ChampsPersonne v-model:titre="saisie.titre" v-model:prenom="saisie.prenom" v-model:nom="saisie.nom" :erreurs="modification.erreurs.value" />

          <h6 class="mt-4 text-muted">{{ t('PERSONNES.ACCOUNT_SECTION') }}</h6>
          <ChampSaisie v-model="saisie.login" class="mb-3" id="login" libelle="PERSONNES.LOGIN_LABEL" :maxlength="30"
                       autocomplete="username" requis :erreur="modification.erreurs.value.login" />
          <div class="row g-3 mb-3">
            <ChampSaisie v-model="saisie.password" class="col-sm-6" id="password" type="password" libelle="MON_COMPTE.NEW_PASSWORD_LABEL"
                         :maxlength="72" autocomplete="new-password" :erreur="modification.erreurs.value.password"
                         :params="modification.params.value" aide="PERSONNES.PASSWORD_HELP_EDIT" />
            <ChampSaisie v-model="saisie.confirmation" class="col-sm-6" id="confirmation" type="password"
                         libelle="COMPTE.CONFIRMATION_LABEL" :maxlength="72" autocomplete="new-password"
                         :erreur="modification.erreurs.value.confirmation" />
          </div>
          <ChampSaisie v-model="saisie.motDePasseActuel" class="mb-4" id="motDePasseActuel" type="password"
                       libelle="MON_COMPTE.CURRENT_PASSWORD_LABEL" :maxlength="72" autocomplete="current-password" requis
                       :erreur="modification.erreurs.value.motDePasseActuel" aide="MON_COMPTE.CURRENT_PASSWORD_HELP" />
          <div class="d-flex gap-2">
            <button type="submit" class="btn btn-primary" :disabled="modification.envoiEnCours.value">{{ t('PERSONNES.SAVE') }}</button>
            <RouterLink class="btn btn-secondary" to="/mon-espace">{{ t('MON_COMPTE.BACK') }}</RouterLink>
          </div>
        </form>
      </div>

      <!-- 2. suppression -->
      <div class="card p-4 border-danger">
        <h5 class="card-title text-danger mb-1">{{ t('MON_COMPTE.DELETE_TITLE') }}</h5>
        <p class="text-muted small">{{ t('MON_COMPTE.DELETE_HELP') }}</p>
        <form class="d-flex flex-wrap gap-2 align-items-end" novalidate @submit.prevent="supprimer">
          <ChampSaisie v-model="motDePasseSuppression" class="flex-grow-1" id="motDePasseSuppression" type="password"
                       libelle="MON_COMPTE.CURRENT_PASSWORD_LABEL" :maxlength="72" autocomplete="current-password" requis
                       :erreur="suppression.erreurs.value.motDePasseActuel" />
          <button type="submit" class="btn btn-danger" :disabled="suppression.envoiEnCours.value">{{ t('MON_COMPTE.DELETE_SUBMIT') }}</button>
        </form>
      </div>
    </div>
  </div>
</template>
  • lignes 30-31 : deux formulaires, donc deux jeux d'erreurs : un composable s'appelle autant de fois qu'on veut, et chaque appel a son propre état ;
  • ligne 47 : les valeurs lues sont recopiées dans le formulaire, version comprise ;
  • lignes 53-65 : le serveur a délivré un nouveau jeton (nom et login ont pu changer) ; il renvoie l'utilisateur tel que ce jeton le décrit, et l'on met la session à jour : la barre de navigation suit. Le mot de passe actuel est effacé après chaque envoi ;
  • lignes 67-80 : confirmation, suppression ; le serveur a effacé le cookie du jeton : on oublie l'utilisateur, et retour à la page de connexion ;
  • ligne 90 : [modification] est un objet ordinaire qui CONTIENT des [ref] : dans le template, il faut écrire [.value] (cf. exemple 11).

10.13.9. src/views/ErreurView.vue

<!--
  views/ErreurView.vue - la page d'erreur : 404 (adresse inconnue) ou 403
  (rôle insuffisant, cf. le garde du routeur). Équivalent de views/erreur.hbs.
-->
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';

const props = defineProps<{ code: 403 | 404 }>();
const { t } = useI18n();
const message = computed(() => (props.code === 403 ? 'ERRORS.FORBIDDEN' : 'ERRORS.NOT_FOUND'));
</script>

<template>
  <div class="card p-4">
    <h4 class="text-danger">{{ t('ERROR_PAGE.TITLE') }} {{ code }}</h4>
    <p>{{ t(message) }}</p>
    <RouterLink to="/" class="btn btn-primary align-self-start">{{ t('ERROR_PAGE.BACK') }}</RouterLink>
  </div>
</template>

La page d'erreur, pour une adresse inconnue (404) ou une page interdite au rôle de l'utilisateur (403).