Écran unique « Synthèse Kms » fusionnant résumés et montants réglés

- Nouvelle page Synthèse Kms (/synthese-kms) : sélecteur temporel en
  mode année seule + véhicule + Afficher, barème appliqué, tableau 12
  mois (km, remb. réel du mois, indication 80%, montant réglé
  saisissable, export PDF mensuel), ligne TOTAL (km annuel, montant
  réel, total réglé, export PDF annuel), bouton Enregistrer global
  (seuls les montants modifiés), auto-affichage à l'arrivée. Aucun
  nouvel endpoint (composition d'endpoints existants).
- Suppression des écrans « Résumé mensuel », « Résumé annuel » et
  « Montants réglés » (routes et liens de menu retirés).
- SelecteurTemporelComponent : mode « année seule » (Input/Output
  dédiés) et repli automatique au clic sur le niveau le plus bas.
- Pleine largeur des écrans Trajets, Carnet d'adresses et Synthèse Kms
  (Specs conserve sa largeur de lecture).
- Liste des trajets : boutons d'action déplacés en début de ligne
  (après la poignée, avant A/R), alignés à droite.
- Specs (requirements/design-requirements/design) synchronisées :
  glossaire Synthèse_Kms, Exigences 3/4/13/14 adaptées, critères 12.25-27.
This commit is contained in:
2026-10-10 15:46:34 +02:00
parent e9f34e59e8
commit 3d9557f852
22 changed files with 706 additions and 1177 deletions
@@ -26,6 +26,7 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
- **Cache_Adresses** : La table de base de données stockant les couples (adresse textuelle normalisée BAN, coordonnées géographiques) des adresses déjà saisies, soumise à une politique de rétention LRU. - **Cache_Adresses** : La table de base de données stockant les couples (adresse textuelle normalisée BAN, coordonnées géographiques) des adresses déjà saisies, soumise à une politique de rétention LRU.
- **Mermaid** : Syntaxe de définition de diagrammes (ex. `graph TB`) rendue graphiquement lors de l'affichage des Documents_Specification. - **Mermaid** : Syntaxe de définition de diagrammes (ex. `graph TB`) rendue graphiquement lors de l'affichage des Documents_Specification.
- **ParametresApplication** : L'entité backend singleton (une seule ligne, `Id = 1`, sur le modèle de `Profil`/`Compte`) portant la colonne `PrecisionDistanceKm`, contrainte par un CHECK SQL `IN (0, 1, 2)`. Détaillée intégralement dans la spec `precision-distances-km`, `design.md`. - **ParametresApplication** : L'entité backend singleton (une seule ligne, `Id = 1`, sur le modèle de `Profil`/`Compte`) portant la colonne `PrecisionDistanceKm`, contrainte par un CHECK SQL `IN (0, 1, 2)`. Détaillée intégralement dans la spec `precision-distances-km`, `design.md`.
- **Synthèse_Kms** : Côté technique, l'écran unique (`SyntheseKmsComponent`, route `/synthese-kms`) qui ne requiert **aucun nouvel endpoint backend** : il compose des endpoints existants — régularisation annuelle (`GET /api/regularisation/{annee}`), montant prévisionnel mensuel (`GET /api/montant-previsionnel/{annee}/{mois}`), upsert du montant réglé (`PUT /api/montants-regles`) et exports PDF mensuel (`GET /api/exports/mensuel/{annee}/{mois}`) et annuel (`GET /api/exports/annuel/{annee}`). Il remplace les écrans supprimés `ResumeMensuelComponent`, `ResumeAnnuelComponent` et `MontantsReglesComponent`.
--- ---
@@ -63,7 +64,7 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
--- ---
### Requirement 3: Consultation du résumé mensuel — aspects techniques ### Requirement 3: Consultation du total kilométrique mensuel — aspects techniques
> Exigence fonctionnelle : voir `requirements.md`, Requirement 3. > Exigence fonctionnelle : voir `requirements.md`, Requirement 3.
@@ -171,6 +172,10 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
1. WHEN l'utilisateur change de mois via le Sélecteur_Temporel, survole un Trajet d'une autre journée, ou change de Véhicule, THE Frontend SHALL mettre à jour la liste, le Panneau_Totaux et la Carte concernés dans un délai de 1000 ms sans rechargement complet de la page. 1. WHEN l'utilisateur change de mois via le Sélecteur_Temporel, survole un Trajet d'une autre journée, ou change de Véhicule, THE Frontend SHALL mettre à jour la liste, le Panneau_Totaux et la Carte concernés dans un délai de 1000 ms sans rechargement complet de la page.
2. WHEN l'utilisateur change de Véhicule sélectionné, THE Frontend SHALL mettre à jour la liste mensuelle des Trajets, le Panneau_Totaux et la Carte dans un délai de 1000 ms sans rechargement complet de la page. 2. WHEN l'utilisateur change de Véhicule sélectionné, THE Frontend SHALL mettre à jour la liste mensuelle des Trajets, le Panneau_Totaux et la Carte dans un délai de 1000 ms sans rechargement complet de la page.
3. THE SelecteurTemporelComponent SHALL exposer un paramètre d'entrée commutant entre le mode « année + mois » (émission d'un événement `moisSelectionne { annee, mois }`) et le mode « année seule » (émission d'un événement portant l'année choisie, sans rendu de la grille des mois), le mode « année seule » étant utilisé par le SyntheseKmsComponent et le mode « année + mois » par le ConsultationJourComponent.
4. WHEN l'utilisateur sélectionne le niveau le plus bas du SelecteurTemporelComponent (le mois en mode « année + mois », l'année en mode « année seule »), THE SelecteurTemporelComponent SHALL basculer son état interne en position repliée.
5. THE Frontend SHALL rendre les écrans ConsultationJourComponent (« Trajets »), CarnetAdressesComponent (« Carnet d'adresses ») et SyntheseKmsComponent (« Synthèse Kms ») sur toute la largeur disponible (sans largeur maximale centrée), tandis que le SpecsComponent (« Specs ») SHALL conserver une largeur de lecture limitée.
6. WHEN le TrajetListComponent rend une ligne de Trajet, THE Frontend SHALL positionner les boutons d'action du Trajet (modifier, supprimer, insérer avant/après) en début de ligne, après la poignée de glisser-déposer et avant la colonne « A/R », alignés à droite au sein de leur colonne afin de conserver l'alignement vertical du bouton « insérer après ».
--- ---
@@ -181,6 +186,7 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
#### Acceptance Criteria #### Acceptance Criteria
1. WHEN le Backend calcule le Montant_Previsionnel_Mensuel ou le Montant_Previsionnel_Annuel, THE Backend SHALL utiliser des distances déjà arrondies selon Precision_Distance (et non les distances brutes), conformément à la spec `precision-distances-km`, Requirements 6 et 7. 1. WHEN le Backend calcule le Montant_Previsionnel_Mensuel ou le Montant_Previsionnel_Annuel, THE Backend SHALL utiliser des distances déjà arrondies selon Precision_Distance (et non les distances brutes), conformément à la spec `precision-distances-km`, Requirements 6 et 7.
2. WHEN le SyntheseKmsComponent affiche le barème appliqué et la colonne « indication 80% » du tableau mensuel, THE Frontend SHALL alimenter ces éléments à partir des endpoints existants (`GET /api/regularisation/{annee}` pour la tranche/coefficient/base et le Frais_Reel_Mensuel, `GET /api/montant-previsionnel/{annee}/{mois}` pour le Montant_Previsionnel_Mensuel), sans introduire de nouvel endpoint.
--- ---
@@ -191,6 +197,9 @@ Le document est organisé **en miroir** des exigences de `requirements.md` : cha
#### Acceptance Criteria #### Acceptance Criteria
1. IF l'utilisateur saisit un Montant_Regle négatif, THEN THE Backend SHALL rejeter la saisie avec une erreur HTTP 400. 1. IF l'utilisateur saisit un Montant_Regle négatif, THEN THE Backend SHALL rejeter la saisie avec une erreur HTTP 400.
2. THE SyntheseKmsComponent SHALL composer la synthèse à partir d'endpoints existants, sans aucun nouvel endpoint backend : régularisation annuelle (`GET /api/regularisation/{annee}?vehiculeId={id}`) pour le barème appliqué, les kilomètres mensuels, les Frais_Reel_Mensuel, le total annuel, le Montant_Reel et le total réglé ; montant prévisionnel mensuel (`GET /api/montant-previsionnel/{annee}/{mois}?vehiculeId={id}`) pour la colonne « indication 80% » ; upsert du montant réglé (`PUT /api/montants-regles`) à l'enregistrement ; exports PDF mensuel (`GET /api/exports/mensuel/{annee}/{mois}?vehiculeId={id}`) et annuel (`GET /api/exports/annuel/{annee}?vehiculeId={id}`).
3. WHEN l'utilisateur active le bouton « Enregistrer » global de la Synthèse_Kms, THE Frontend SHALL émettre un appel `PUT /api/montants-regles` uniquement pour les mois dont le Montant_Regle a été modifié depuis le dernier chargement, et n'émettre aucun appel pour les mois inchangés.
4. WHEN le SyntheseKmsComponent est initialisé, THE Frontend SHALL déclencher le chargement de la synthèse pour la pré-sélection (Véhicule_Courant et année courante) sans attendre d'action sur le bouton « Afficher ».
--- ---
+34 -36
View File
@@ -16,8 +16,8 @@
|---|----------------|-------------| |---|----------------|-------------|
| 1 | Saisie d'un trajet | Formulaire avec date (JJ/MM/AAAA), adresse départ, adresse arrivée, commentaire | | 1 | Saisie d'un trajet | Formulaire avec date (JJ/MM/AAAA), adresse départ, adresse arrivée, commentaire |
| 2 | Calcul automatique de distance | Via ORS (routier), fallback orthodromique | | 2 | Calcul automatique de distance | Via ORS (routier), fallback orthodromique |
| 3 | Résumé mensuel | Liste des trajets + total km du mois | | 3 | Synthèse Kms (mensuel) | Total km par mois agrégé dans l'écran unique Synthèse Kms (ex-« Résumé mensuel », fusionné) ; le détail trajet par trajet reste sur l'écran « Trajets » |
| 4 | Vue annuelle | 12 résumés mensuels + total annuel | | 4 | Synthèse Kms (annuel) | 12 lignes mensuelles + ligne TOTAL annuel dans l'écran unique Synthèse Kms (ex-« Résumé annuel », fusionné) |
| 5 | Modification / suppression | Édition avec recalcul conditionnel de distance | | 5 | Modification / suppression | Édition avec recalcul conditionnel de distance |
| 6 | Persistance et cohérence | Base relationnelle avec invariants d'agrégation et transactions | | 6 | Persistance et cohérence | Base relationnelle avec invariants d'agrégation et transactions |
| 7 | Export PDF | Fichiers `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` et `AAAA_Trajets_MATRICULE_PLAQUE.pdf` | | 7 | Export PDF | Fichiers `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf` et `AAAA_Trajets_MATRICULE_PLAQUE.pdf` |
@@ -31,7 +31,7 @@
| 15 | Profil enrichi & véhicules | Nom, prénom, matricule (8 car.), plaque, puissance fiscale ; historique des véhicules daté | | 15 | Profil enrichi & véhicules | Nom, prénom, matricule (8 car.), plaque, puissance fiscale ; historique des véhicules daté |
| 16 | Association véhicule par trajet | Chaque trajet référence un véhicule + puissance fiscale ; totaux filtrés par véhicule | | 16 | Association véhicule par trajet | Chaque trajet référence un véhicule + puissance fiscale ; totaux filtrés par véhicule |
| 17 | Barème kilométrique annuel | Saisie par tranches (puissance fiscale, bornes km, coefficient, part fixe) ; montant prévisionnel minoré 80% | | 17 | Barème kilométrique annuel | Saisie par tranches (puissance fiscale, bornes km, coefficient, part fixe) ; montant prévisionnel minoré 80% |
| 18 | Montants réglés & régularisation | Saisie du montant réglé par mois ; rapport annuel avec montant réel, versé, dû et régularisation | | 18 | Montants réglés & régularisation | Saisie du montant réglé par mois et régularisation intégrées à l'écran unique Synthèse Kms (ex-« Montants réglés », fusionné) ; rapport annuel avec montant réel, versé, dû et régularisation |
| 19 | Carnet d'adresses nommées | Menu CRUD d'Adresses_Nommées (Dénomination unique + adresse BAN) ; réutilisation par Dénomination dans la saisie et l'insertion de Trajets | | 19 | Carnet d'adresses nommées | Menu CRUD d'Adresses_Nommées (Dénomination unique + adresse BAN) ; réutilisation par Dénomination dans la saisie et l'insertion de Trajets |
| 20 | Onboarding première utilisation | Présentation de la saisie du profil (identité + véhicule) si le Profil n'est pas configuré, avant tout accès à la saisie / consultation des trajets | | 20 | Onboarding première utilisation | Présentation de la saisie du profil (identité + véhicule) si le Profil n'est pas configuré, avant tout accès à la saisie / consultation des trajets |
| 21 | Authentification | Compte unique (e-mail + mot de passe), connexion par JWT (12 h par défaut), réinitialisation de mot de passe par e-mail, garde de session serveur | | 21 | Authentification | Compte unique (e-mail + mot de passe), connexion par JWT (12 h par défaut), réinitialisation de mot de passe par e-mail, garde de session serveur |
@@ -40,6 +40,7 @@
| 24 | Consultation des specs | Lien « Specs » vers une page dédiée (menu latéral Exigences / Conception / Tâches) affichant les documents Markdown rendus (dont les diagrammes Mermaid), en lecture seule et derrière l'authentification | | 24 | Consultation des specs | Lien « Specs » vers une page dédiée (menu latéral Exigences / Conception / Tâches) affichant les documents Markdown rendus (dont les diagrammes Mermaid), en lecture seule et derrière l'authentification |
| 25 | Changement de mot de passe | Page Compte utilisateur : formulaire dédié (mot de passe actuel + nouveau + confirmation) ; endpoint protégé distinct de la réinitialisation par e-mail | | 25 | Changement de mot de passe | Page Compte utilisateur : formulaire dédié (mot de passe actuel + nouveau + confirmation) ; endpoint protégé distinct de la réinitialisation par e-mail |
| 26 | Navigation utilisateur | Avatar (initiales) à droite de la barre, sous-menu au survol (Prénom Nom, Paramétrage, Maintenance, Se déconnecter) ; Page_Parametrage (Compte / Véhicules / Barème) et Page_Maintenance dédiées ; barre masquée sur les pages publiques ; contrôle afficher/masquer sur tous les champs mot de passe | | 26 | Navigation utilisateur | Avatar (initiales) à droite de la barre, sous-menu au survol (Prénom Nom, Paramétrage, Maintenance, Se déconnecter) ; Page_Parametrage (Compte / Véhicules / Barème) et Page_Maintenance dédiées ; barre masquée sur les pages publiques ; contrôle afficher/masquer sur tous les champs mot de passe |
| 27 | Synthèse Kms (écran unique) | Fusion des ex-écrans « Résumé mensuel » / « Résumé annuel » / « Montants réglés » (supprimés) : critères sur une ligne (Sélecteur_Temporel année seule + sélecteur Véhicule + bouton Afficher), barème appliqué, tableau 12 mois (km, Frais_Reel_Mensuel, indication 80%, Montant_Regle saisissable, Export PDF mensuel), ligne TOTAL (km annuel, Montant_Reel, total réglé, Export PDF annuel), bouton « Enregistrer » global (seuls les montants modifiés), auto-affichage initial, aucun nouvel endpoint |
### Contraintes techniques ### Contraintes techniques
@@ -178,12 +179,9 @@ src/
│ │ ├── trajet-form/ │ │ ├── trajet-form/
│ │ │ ├── trajet-form.component.ts # Saisie + édition │ │ │ ├── trajet-form.component.ts # Saisie + édition
│ │ │ └── trajet-form.component.html │ │ │ └── trajet-form.component.html
│ │ ├── resume-mensuel/ │ │ ├── synthese-kms/
│ │ │ ├── resume-mensuel.component.ts │ │ │ ├── synthese-kms.component.ts # Écran unique : synthèse annuelle par mois + saisie Montant_Regle + exports PDF
│ │ │ └── resume-mensuel.component.html │ │ │ └── synthese-kms.component.html
│ │ ├── resume-annuel/
│ │ │ ├── resume-annuel.component.ts
│ │ │ └── resume-annuel.component.html
│ │ ├── trajet-list/ │ │ ├── trajet-list/
│ │ │ ├── trajet-list.component.ts │ │ │ ├── trajet-list.component.ts
│ │ │ └── trajet-list.component.html │ │ │ └── trajet-list.component.html
@@ -209,9 +207,6 @@ src/
│ │ ├── bareme/ │ │ ├── bareme/
│ │ │ ├── bareme.component.ts # Saisie du barème par année/tranches │ │ │ ├── bareme.component.ts # Saisie du barème par année/tranches
│ │ │ └── bareme.component.html │ │ │ └── bareme.component.html
│ │ ├── montants-regles/
│ │ │ ├── montants-regles.component.ts # Saisie montant réglé par mois
│ │ │ └── montants-regles.component.html
│ │ ├── carnet-adresses/ │ │ ├── carnet-adresses/
│ │ │ ├── carnet-adresses.component.ts # CRUD des Adresses_Nommées (liste + formulaire) │ │ │ ├── carnet-adresses.component.ts # CRUD des Adresses_Nommées (liste + formulaire)
│ │ │ └── carnet-adresses.component.html │ │ │ └── carnet-adresses.component.html
@@ -251,23 +246,24 @@ src/
- **Autocomplétion BAN** : appel à `https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5` dès 3 caractères saisis ; chaque suggestion affiche le libellé + code postal + commune ; à la sélection d'une suggestion, les coordonnées `(lat, lon)` sont stockées dans l'état local du composant et transmises avec le formulaire - **Autocomplétion BAN** : appel à `https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5` dès 3 caractères saisis ; chaque suggestion affiche le libellé + code postal + commune ; à la sélection d'une suggestion, les coordonnées `(lat, lon)` sont stockées dans l'état local du composant et transmises avec le formulaire
- Si l'utilisateur tape une adresse sans passer par BAN (pas de sélection), les coordonnées sont absentes et un avertissement discret s'affiche - Si l'utilisateur tape une adresse sans passer par BAN (pas de sélection), les coordonnées sont absentes et un avertissement discret s'affiche
**`ResumeMensuelComponent`** **`SyntheseKmsComponent`** (écran unique — Exigences 3, 4, 13, 14)
- Réutilise le même `SelecteurTemporelComponent` que `ConsultationJourComponent` (sélection successive année puis mois, surbrillance des périodes ayant des données) au lieu de sélecteurs distincts mois/année ; la sélection d'un mois (événement `moisSelectionne`) déclenche automatiquement le chargement du Résumé_Mensuel, sans bouton "Afficher" sur cet écran - **Fusionne et remplace** les anciens composants `ResumeMensuelComponent`, `ResumeAnnuelComponent` et `MontantsReglesComponent` (tous supprimés), ainsi que leurs routes `/resume-mensuel`, `/resume-annuel`, `/montants-regles` et leurs liens de menu. Route unique : `/synthese-kms`, lien de menu « Synthèse Kms »
- Affiche la liste des trajets, avec une colonne **Commentaire** entre les colonnes Date et Départ (valorisée avec le commentaire du Trajet, ou « — » si absent), et le total mensuel - **En-tête sur une même ligne** : le titre « Synthèse Kms » et la zone de critères — `SelecteurTemporelComponent` en **mode « année seule »**, sélecteur de Véhicule, bouton « Afficher »
- Bouton "Export PDF mensuel" positionné à droite du `SelecteurTemporelComponent` - **Auto-affichage initial** : à l'initialisation, la synthèse est chargée automatiquement pour la pré-sélection (Véhicule_Courant + année courante), sans attendre d'action sur « Afficher »
- La Distance de chaque Trajet et le total mensuel sont formatés selon `formatKm()` (pipe `number` alimenté par le signal `precisionDistance` de `ParametresApplicationService`), reflétant Precision_Distance (0, 1 ou 2 décimales) au lieu d'un affichage fixe à 2 décimales — voir spec `precision-distances-km` - **Barème appliqué** affiché au-dessus du tableau : tranche, Puissance_Fiscale, coefficient, base
- **Tableau une ligne par mois** : libellé du mois, kilomètres du mois, Frais_Reel_Mensuel (remboursement réel propre au mois = km du mois × coefficient de la tranche annuelle), indication 80% (Montant_Previsionnel_Mensuel), Montant_Regle **saisissable**, et colonne « Action » avec un bouton « Export PDF » (rapport mensuel du mois)
**`ResumeAnnuelComponent`** - **Barre d'action au-dessus du tableau** : un unique bouton « Enregistrer » qui persiste uniquement les Montant_Regle **modifiés** depuis le dernier chargement (plusieurs mois en une seule action) ; pas de bouton d'enregistrement par ligne
- Sélecteur année (2000–2100) et bouton "Afficher" **inchangés** (pas de remplacement par `SelecteurTemporelComponent` sur cet écran — décision explicite de l'utilisateur, à la différence du Résumé_Mensuel et de la Vue_Consultation) - **Ligne TOTAL** : total kilométrique annuel, Montant_Reel (total du remboursement réel), **cellule « indication 80% » vide**, total réglé (somme des Montant_Regle, non modifiable), bouton « Export PDF » qui exporte le rapport **annuel** ; aucun bouton « Enregistrer » sur cette ligne
- Tableau 12 lignes (mois, total km) + ligne total annuel (max 999 999,99) - **Aucun nouvel endpoint backend** : composition d'endpoints existants — `GET /api/regularisation/{annee}` (barème, km mensuels, Frais_Reel_Mensuel, total annuel, Montant_Reel, total réglé), `GET /api/montant-previsionnel/{annee}/{mois}` (indication 80%), `PUT /api/montants-regles` (upsert à l'enregistrement), `GET /api/exports/mensuel/{annee}/{mois}` et `GET /api/exports/annuel/{annee}` (exports PDF)
- Bouton "Export PDF annuel" positionné à droite du formulaire sélecteur/bouton "Afficher" (même agencement visuel que sur le Résumé_Mensuel), sans changement de comportement - **Pleine largeur** : l'écran occupe toute la largeur disponible (pas de largeur maximale centrée)
- Le total kilométrique de chaque mois et le total annuel sont formatés selon `formatKm()`, reflétant Precision_Distance au lieu d'un affichage fixe à 2 décimales — voir spec `precision-distances-km` - Tous les kilomètres sont formatés selon `formatKm()` (signal `precisionDistance` de `ParametresApplicationService`), reflétant Precision_Distance — voir spec `precision-distances-km` ; les montants en euros à 2 décimales
- Le **détail des Trajets un par un n'est plus affiché** dans cet écran de synthèse : il reste consultable sur `ConsultationJourComponent` (écran « Trajets »)
**`TrajetListComponent`** **`TrajetListComponent`**
- Affiche TOUS les trajets du mois, regroupés par journée : chaque groupe/jour a un **en-tête repliable** (jour de la semaine avant la date, ex. « Lundi 15/09/2026 », puis nombre de trajets) ; par défaut tout est replié sauf le dernier jour du mois ayant des trajets. - Affiche TOUS les trajets du mois, regroupés par journée : chaque groupe/jour a un **en-tête repliable** (jour de la semaine avant la date, ex. « Lundi 15/09/2026 », puis nombre de trajets) ; par défaut tout est replié sauf le dernier jour du mois ayant des trajets.
- **Bouton dédié « + » à droite de l'en-tête** (`Output ajouterSurJournee: string`) : ouvre le formulaire de création avec la date de CETTE journée, sans dépendre de la journée affichée sur la Carte (pilotée par le survol) — évite qu'un déplacement de souris à travers plusieurs journées ouvertes ne fasse dériver la date visée en se dirigeant vers le bouton principal d'ajout. Le conteneur fixe `date` sur cette valeur avant d'ouvrir la création (`ouvrirCreationSur`). - **Bouton dédié « + » à droite de l'en-tête** (`Output ajouterSurJournee: string`) : ouvre le formulaire de création avec la date de CETTE journée, sans dépendre de la journée affichée sur la Carte (pilotée par le survol) — évite qu'un déplacement de souris à travers plusieurs journées ouvertes ne fasse dériver la date visée en se dirigeant vers le bouton principal d'ajout. Le conteneur fixe `date` sur cette valeur avant d'ouvrir la création (`ouvrirCreationSur`).
- Ligne par trajet (triée par `Position` croissante au sein du jour) : colonne dédiée **A/R** en début de ligne pour les allers/retours, commentaire (si présent), adresses (taille réduite), distance effective - Ligne par trajet (triée par `Position` croissante au sein du jour) : les **boutons d'action** sont placés **en début de ligne** (après la poignée de glisser-déposer, avant la colonne A/R) et **alignés à droite au sein de leur colonne** pour conserver l'alignement vertical du bouton « insérer après » ; puis colonne dédiée **A/R** pour les allers/retours, commentaire (si présent), adresses (taille réduite), distance effective
- Actions par trajet : modifier (→ `TrajetFormComponent` pré-rempli en < 500 ms), supprimer (→ dialog de confirmation), **insérer après** (→ `TrajetFormComponent` en mode insertion avec `adresseDepart` = point de fin du trajet courant — son arrivée, ou son **départ** si le trajet est un aller/retour —, la **date de la journée** du trajet, `positionApres` = Position du trajet courant) - Actions par trajet (regroupées en début de ligne) : modifier (→ `TrajetFormComponent` pré-rempli en < 500 ms), supprimer (→ dialog de confirmation), **insérer avant/après** — l'insérer après ouvre `TrajetFormComponent` en mode insertion avec `adresseDepart` = point de fin du trajet courant (son arrivée, ou son **départ** si le trajet est un aller/retour), la **date de la journée** du trajet, `positionApres` = Position du trajet courant
- Si le trajet n'est pas le dernier de la journée : action **insérer avant le suivant** disponible, pré-remplissant l'adresse de départ avec le point de fin du trajet courant (départ si aller/retour) et l'adresse d'arrivée avec le départ du trajet suivant, en calculant la position d'insertion `(P_courant + P_suivant) / 2` - Si le trajet n'est pas le dernier de la journée : action **insérer avant le suivant** disponible, pré-remplissant l'adresse de départ avec le point de fin du trajet courant (départ si aller/retour) et l'adresse d'arrivée avec le départ du trajet suivant, en calculant la position d'insertion `(P_courant + P_suivant) / 2`
- Méthode publique `ouvrirJour(dateIso)` : déplie une journée donnée (appelée par le conteneur après ajout/insertion pour rendre le trajet visible) ; `ouvrirToutesLesJournees()` déplie tous les groupes ; `aDesJourneesFermees()` conditionne l'affichage du bouton « Ouvrir toutes les journées » - Méthode publique `ouvrirJour(dateIso)` : déplie une journée donnée (appelée par le conteneur après ajout/insertion pour rendre le trajet visible) ; `ouvrirToutesLesJournees()` déplie tous les groupes ; `aDesJourneesFermees()` conditionne l'affichage du bouton « Ouvrir toutes les journées »
- `@Input jourAffiche` : la journée affichée sur la Carte ; le groupe correspondant est mis en surbrillance (`estJourAffiche`) - `@Input jourAffiche` : la journée affichée sur la Carte ; le groupe correspondant est mis en surbrillance (`estJourAffiche`)
@@ -310,13 +306,16 @@ src/
- Deux actions : "Réaffecter au nouveau véhicule" (POST reaffectation accepter=true) / "Conserver l'existant" (accepter=false) - Deux actions : "Réaffecter au nouveau véhicule" (POST reaffectation accepter=true) / "Conserver l'existant" (accepter=false)
**`SelecteurTemporelComponent`** **`SelecteurTemporelComponent`**
- **Repliable** ; l'en-tête affiche le mois et l'année sélectionnés (ex. « Septembre 2026 ») - **Repliable** ; l'en-tête affiche la période sélectionnée (ex. « Septembre 2026 » en mode année + mois, « 2026 » en mode année seule)
- Sélecteur d'année : liste limitée à l'année courante + années ayant des données (endpoint `/api/annees-disponibles`) - **Deux modes** pilotés par un `@Input` :
- Sélecteur de mois : 12 mois affichés, surbrillance des mois avec données (endpoint `/api/mois-disponibles/{annee}`), avec le même code couleur bleu que la journée mise en avant dans la liste ; le mois sélectionné reste en bleu plein - **Mode « année + mois »** (par défaut, utilisé par `ConsultationJourComponent`) : choix successif année puis mois ; émet un événement `moisSelectionne: { annee: number, mois: number }` à chaque changement
- Pas de sélecteur de jour (la consultation se fait par mois) ; désactivation de tout mois > mois courant - **Mode « année seule »** (utilisé par `SyntheseKmsComponent`) : la grille des 12 mois **n'est pas affichée** ; seul le choix de l'année est proposé et le composant émet l'année choisie (`@Output` dédié)
- Émet un événement `moisSelectionne: { annee: number, mois: number }` à chaque changement - Sélecteur d'année (dans les deux modes) : liste limitée à l'année courante + années ayant des données (endpoint `/api/annees-disponibles`)
- Sélecteur de mois (mode année + mois uniquement) : 12 mois affichés, surbrillance des mois avec données (endpoint `/api/mois-disponibles/{annee}`), avec le même code couleur bleu que la journée mise en avant dans la liste ; le mois sélectionné reste en bleu plein ; désactivation de tout mois > mois courant
- **Repli automatique** : lors d'un clic sur le niveau le plus bas (le mois en mode année + mois, l'année en mode année seule), le composant se referme automatiquement (collapse). Comportement appliqué partout où le sélecteur est utilisé
**`ConsultationJourComponent`** (composant conteneur / layout principal) **`ConsultationJourComponent`** (composant conteneur / layout principal)
- **Pleine largeur** : l'écran « Trajets » occupe toute la largeur disponible (plus de largeur maximale centrée)
- Deux états : un signal `mois` (`{ annee, mois }`) pilote la **liste mensuelle** ; un signal `date` (ISO) pilote la **carte** et la date par défaut d'ajout/insertion - Deux états : un signal `mois` (`{ annee, mois }`) pilote la **liste mensuelle** ; un signal `date` (ISO) pilote la **carte** et la date par défaut d'ajout/insertion
- Colonne gauche (fixe) : `SelecteurTemporelComponent`, puis `PanneauTotauxComponent`, puis `CarteTrajetsComponent` (plus de bande de navigation par jour) - Colonne gauche (fixe) : `SelecteurTemporelComponent`, puis `PanneauTotauxComponent`, puis `CarteTrajetsComponent` (plus de bande de navigation par jour)
- Colonne droite : bande fixe (sélecteur de Véhicule + bouton « Ajouter un trajet »), puis `TrajetListComponent` du mois avec scroll vertical - Colonne droite : bande fixe (sélecteur de Véhicule + bouton « Ajouter un trajet »), puis `TrajetListComponent` du mois avec scroll vertical
@@ -348,14 +347,12 @@ src/
- Bouton **« Recopier le dernier barème connu »** (recopie l'année antérieure la plus récente, confirmation si l'année cible contient déjà des tranches, inaccessible sur l'année du dernier barème) - Bouton **« Recopier le dernier barème connu »** (recopie l'année antérieure la plus récente, confirmation si l'année cible contient déjà des tranches, inaccessible sur l'année du dernier barème)
- Validation du non-chevauchement des tranches pour une même puissance fiscale et année - Validation du non-chevauchement des tranches pour une même puissance fiscale et année
**`MontantsReglesComponent`** > **Saisie des Montant_Regle** : l'ancien composant `MontantsReglesComponent` (écran dédié « Montants réglés ») est supprimé ; la saisie des Montant_Regle par mois est désormais assurée directement dans le tableau mensuel du `SyntheseKmsComponent` ci-dessus, avec l'affichage en regard du Montant_Previsionnel_Mensuel (indication 80%, calculé sur le seul mois affiché — voir spec `precision-distances-km`, Requirement 7).
- Saisie du Montant_Regle par mois pour l'année et le véhicule sélectionnés
- Affichage en regard : Montant_Previsionnel_Mensuel minoré 80% (indicatif), désormais calculé sur le seul mois affiché (et non plus sur le cumul annuel) — voir spec `precision-distances-km`, Requirement 7
- Montants en euros à 2 décimales
**`CarnetAdressesComponent`** **`CarnetAdressesComponent`**
- Liste des Adresses_Nommées (Dénomination + adresse + indication si coordonnées disponibles), triée par Dénomination, avec un bouton « Ajouter une adresse » au-dessus de la liste - Liste des Adresses_Nommées (Dénomination + adresse + indication si coordonnées disponibles), triée par Dénomination, avec un bouton « Ajouter une adresse » au-dessus de la liste
- **Disposition en grille de cartes** : les adresses sont affichées les unes à côté des autres puis retournent à la ligne selon la largeur disponible (`display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))`). Chaque carte a une taille homogène (colonnes de largeur égale, cartes étirées à la même hauteur par rangée via `align-items: stretch`), un contour (`border: 1px solid #e0e0e0; border-radius: 10px`) et un effet d'ombre (`box-shadow: 0 2px 8px rgba(0,0,0,0.1)`) la détachant du fond. La zone de contenu de la page est élargie (`max-width: 1080px`) pour laisser la place à plusieurs colonnes ; la dénomination et l'adresse sont en haut de la carte, les actions Modifier/Supprimer en bas à droite - **Pleine largeur** : l'écran « Carnet d'adresses » occupe toute la largeur disponible (plus de largeur maximale centrée)
- **Disposition en grille de cartes** : les adresses sont affichées les unes à côté des autres puis retournent à la ligne selon la largeur disponible (`display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))`). Chaque carte a une taille homogène (colonnes de largeur égale, cartes étirées à la même hauteur par rangée via `align-items: stretch`), un contour (`border: 1px solid #e0e0e0; border-radius: 10px`) et un effet d'ombre (`box-shadow: 0 2px 8px rgba(0,0,0,0.1)`) la détachant du fond. La zone de contenu de la page occupe toute la largeur disponible pour laisser la place à plusieurs colonnes ; la dénomination et l'adresse sont en haut de la carte, les actions Modifier/Supprimer en bas à droite
- Formulaire de création / modification présenté dans une **fenêtre modale** (overlay `.modale-overlay` + panneau `.modale-panneau`, motif partagé avec `ConfirmDialogComponent` / `ConsultationJourComponent`), ouvert via le bouton « Ajouter une adresse » (mode création) ou l'action « Modifier » d'une ligne (mode édition) ; fermée par enregistrement réussi, par le bouton « Annuler », ou par un clic sur l'arrière-plan. Le signal `modaleOuverte` pilote l'affichage - Formulaire de création / modification présenté dans une **fenêtre modale** (overlay `.modale-overlay` + panneau `.modale-panneau`, motif partagé avec `ConfirmDialogComponent` / `ConsultationJourComponent`), ouvert via le bouton « Ajouter une adresse » (mode création) ou l'action « Modifier » d'une ligne (mode édition) ; fermée par enregistrement réussi, par le bouton « Annuler », ou par un clic sur l'arrière-plan. Le signal `modaleOuverte` pilote l'affichage
- Champ `denomination` (1–100 car.) et champ `adresse` avec autocomplétion BAN (1–255 car.) ; dans la modale, les suggestions BAN sont rendues **dans le flux** du panneau (et non en position absolue) pour éviter toute troncature par le défilement du panneau - Champ `denomination` (1–100 car.) et champ `adresse` avec autocomplétion BAN (1–255 car.) ; dans la modale, les suggestions BAN sont rendues **dans le flux** du panneau (et non en position absolue) pour éviter toute troncature par le défilement du panneau
- **Autocomplétion BAN** : appel à `https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5` dès 3 caractères saisis, avec debounce de 300 ms, maximum 5 suggestions ; à la sélection, le libellé normalisé remplit le champ et les coordonnées `(lat, lon)` sont conservées pour transmission - **Autocomplétion BAN** : appel à `https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5` dès 3 caractères saisis, avec debounce de 300 ms, maximum 5 suggestions ; à la sélection, le libellé normalisé remplit le champ et les coordonnées `(lat, lon)` sont conservées pour transmission
@@ -397,6 +394,7 @@ src/
- Rendu : le Markdown (`GET /api/specs/{cle}`) est converti en HTML par **`marked`** (GFM : titres, listes, tableaux, code), puis **assaini par `DOMPurify`** avant injection (`[innerHTML]`) — Exigence 19.7 - Rendu : le Markdown (`GET /api/specs/{cle}`) est converti en HTML par **`marked`** (GFM : titres, listes, tableaux, code), puis **assaini par `DOMPurify`** avant injection (`[innerHTML]`) — Exigence 19.7
- **Diagrammes Mermaid** : un renderer `marked` transforme les blocs ` ```mermaid ` en `<pre class="mermaid">` ; après injection du HTML (déclenchée via `setTimeout` pour laisser Angular résoudre le `@ViewChild`), **Mermaid.js** (importé dynamiquement) rend ces blocs en SVG (`securityLevel: 'loose'` pour accepter les libellés HTML type `<br/>`). En cas d'échec de rendu d'un diagramme, le bloc de code brut reste visible. - **Diagrammes Mermaid** : un renderer `marked` transforme les blocs ` ```mermaid ` en `<pre class="mermaid">` ; après injection du HTML (déclenchée via `setTimeout` pour laisser Angular résoudre le `@ViewChild`), **Mermaid.js** (importé dynamiquement) rend ces blocs en SVG (`securityLevel: 'loose'` pour accepter les libellés HTML type `<br/>`). En cas d'échec de rendu d'un diagramme, le bloc de code brut reste visible.
- Accès derrière `authGuard` (mêmes règles que les autres vues protégées) - Accès derrière `authGuard` (mêmes règles que les autres vues protégées)
- **Largeur de lecture limitée** : à la différence des écrans « Trajets », « Carnet d'adresses » et « Synthèse Kms » (passés en pleine largeur), le `SpecsComponent` conserve une largeur maximale centrée adaptée à la lecture (comportement inchangé)
**`ProfilConfigureGuard`** (garde de route Angular — `CanActivate` / `CanActivateFn`) **`ProfilConfigureGuard`** (garde de route Angular — `CanActivate` / `CanActivateFn`)
- Au chargement de l'application, appelle `ProfilService.getEtat()` (`GET /api/profil/etat`) - Au chargement de l'application, appelle `ProfilService.getEtat()` (`GET /api/profil/etat`)
@@ -406,7 +404,7 @@ src/
- Déclaré dans `app-routing.module.ts` sur les routes protégées (consultation, saisie, résumés, etc.) ; la route `/onboarding` reste accessible sans garde - Déclaré dans `app-routing.module.ts` sur les routes protégées (consultation, saisie, résumés, etc.) ; la route `/onboarding` reste accessible sans garde
**`AppComponent`** (coquille applicative — barre de navigation, Exigence 21) **`AppComponent`** (coquille applicative — barre de navigation, Exigence 21)
- Barre de navigation : logo (format horizontal, icône + texte), liens métier (Trajets, Carnet d'adresses, Montants réglés, Résumé mensuel, Résumé annuel), menu déroulant « Specs », puis l'Avatar_Utilisateur poussé à l'extrémité droite (`margin-left: auto`) - Barre de navigation : logo (format horizontal, icône + texte), liens métier (Trajets, Carnet d'adresses, Synthèse Kms), menu déroulant « Specs », puis l'Avatar_Utilisateur poussé à l'extrémité droite (`margin-left: auto`). Les anciens liens « Montants réglés », « Résumé mensuel » et « Résumé annuel » sont retirés : ils sont remplacés par l'unique lien « Synthèse Kms »
- **Menus déroulants (Specs, Avatar)** : s'ouvrent au survol (`mouseenter`) ET restent utilisables au clic (tactile, clavier) ; se ferment au `mouseleave` du bloc conteneur ou au clic extérieur (`@HostListener('document:click')`) - **Menus déroulants (Specs, Avatar)** : s'ouvrent au survol (`mouseenter`) ET restent utilisables au clic (tactile, clavier) ; se ferment au `mouseleave` du bloc conteneur ou au clic extérieur (`@HostListener('document:click')`)
- **Piège évité** : le panneau déroulant ne doit PAS être séparé du bouton par un espace (`top: calc(100% + Xpx)`), sous peine de déclencher un `mouseleave` prématuré lorsque le curseur traverse cet espace mort en descendant vers le panneau. Le panneau touche directement le bouton (`top: 100%`) et l'espacement visuel est reporté en `padding-top` À L'INTÉRIEUR du panneau (zone `__panneau-contenu` séparée pour le fond/l'ombre), de sorte que la zone de survol reste continue. - **Piège évité** : le panneau déroulant ne doit PAS être séparé du bouton par un espace (`top: calc(100% + Xpx)`), sous peine de déclencher un `mouseleave` prématuré lorsque le curseur traverse cet espace mort en descendant vers le panneau. Le panneau touche directement le bouton (`top: 100%`) et l'espacement visuel est reporté en `padding-top` À L'INTÉRIEUR du panneau (zone `__panneau-contenu` séparée pour le fond/l'ombre), de sorte que la zone de survol reste continue.
- **Affichage conditionnel** : la barre de navigation (avatar inclus) est masquée non seulement en l'absence de jeton, mais aussi sur les pages PUBLIQUES (`/login`, `/register`, `/forgot-password`, `/reset-password`), détectées via les événements du `Router` (`NavigationEnd`). Ce deuxième critère est nécessaire car un jeton résiduel expiré ou invalide peut rester en `localStorage` sans qu'aucun appel HTTP protégé ne le purge sur ces pages (aucune requête vers un endpoint protégé n'y est faite) ; se fier uniquement à la présence du jeton ferait réapparaître la barre à tort. - **Affichage conditionnel** : la barre de navigation (avatar inclus) est masquée non seulement en l'absence de jeton, mais aussi sur les pages PUBLIQUES (`/login`, `/register`, `/forgot-password`, `/reset-password`), détectées via les événements du `Router` (`NavigationEnd`). Ce deuxième critère est nécessaire car un jeton résiduel expiré ou invalide peut rester en `localStorage` sans qu'aucun appel HTTP protégé ne le purge sur ces pages (aucune requête vers un endpoint protégé n'y est faite) ; se fier uniquement à la présence du jeton ferait réapparaître la barre à tort.
@@ -35,11 +35,12 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
- **Profil_Configure** : L'état du Profil considéré comme configuré lorsqu'un Profil est enregistré avec un nom et un prénom non vides ET qu'au moins un Véhicule (plaque, Puissance_Fiscale, Date_Effet) est enregistré. À l'inverse, le Profil est dit « non configuré » lorsqu'aucun Profil n'est enregistré avec un nom et un prénom non vides, ou lorsqu'aucun Véhicule n'est enregistré. - **Profil_Configure** : L'état du Profil considéré comme configuré lorsqu'un Profil est enregistré avec un nom et un prénom non vides ET qu'au moins un Véhicule (plaque, Puissance_Fiscale, Date_Effet) est enregistré. À l'inverse, le Profil est dit « non configuré » lorsqu'aucun Profil n'est enregistré avec un nom et un prénom non vides, ou lorsqu'aucun Véhicule n'est enregistré.
- **Onboarding** : Le parcours d'initialisation présenté à la première utilisation de l'Application lorsque le Profil est non configuré, invitant l'utilisateur à renseigner son identité (nom, prénom, matricule), éventuellement son Adresse_Domicile, et au moins un Véhicule avant d'accéder aux fonctionnalités de saisie et de consultation des Trajets. - **Onboarding** : Le parcours d'initialisation présenté à la première utilisation de l'Application lorsque le Profil est non configuré, invitant l'utilisateur à renseigner son identité (nom, prénom, matricule), éventuellement son Adresse_Domicile, et au moins un Véhicule avant d'accéder aux fonctionnalités de saisie et de consultation des Trajets.
- **Vue_Consultation** : La vue principale de l'Application permettant la saisie et la consultation des Trajets (Sélecteur_Temporel, zone des Trajets, Panneau_Totaux, Carte). - **Vue_Consultation** : La vue principale de l'Application permettant la saisie et la consultation des Trajets (Sélecteur_Temporel, zone des Trajets, Panneau_Totaux, Carte).
- **Synthèse_Kms** : L'écran unique (lien de menu « Synthèse Kms ») regroupant la consultation annuelle agrégée par mois, la saisie des Montant_Regle et les exports PDF. Il fusionne et remplace les trois anciens écrans distincts « Résumé mensuel », « Résumé annuel » et « Montants réglés ». Il présente, pour une année et un Véhicule sélectionnés, le Bareme_Kilometrique appliqué puis un tableau d'une ligne par mois (kilomètres du mois, Frais_Reel_Mensuel, Montant_Previsionnel_Mensuel, Montant_Regle saisissable, Export_PDF mensuel) et une ligne TOTAL (total kilométrique annuel, Montant_Reel, total réglé, Export_PDF annuel). La Synthèse_Kms n'affiche pas le détail des Trajets un par un ; ce détail reste disponible sur la Vue_Consultation.
- **Commentaire** : Un texte optionnel associé à un Trajet, permettant à l'utilisateur d'annoter un déplacement (0 à 500 caractères). - **Commentaire** : Un texte optionnel associé à un Trajet, permettant à l'utilisateur d'annoter un déplacement (0 à 500 caractères).
- **Trajet_Précédent** : Le Trajet immédiatement antérieur dans la liste chronologique d'une journée, dont l'adresse d'arrivée peut servir de départ lors d'une insertion. - **Trajet_Précédent** : Le Trajet immédiatement antérieur dans la liste chronologique d'une journée, dont l'adresse d'arrivée peut servir de départ lors d'une insertion.
- **Trajet_Suivant** : Le Trajet immédiatement postérieur dans la liste chronologique d'une journée, dont l'adresse de départ peut servir d'arrivée lors d'une insertion. - **Trajet_Suivant** : Le Trajet immédiatement postérieur dans la liste chronologique d'une journée, dont l'adresse de départ peut servir d'arrivée lors d'une insertion.
- **Position** : Un entier positif associé à chaque Trajet au sein d'une même journée, déterminant son rang dans la liste affichée. L'ordre d'affichage est toujours trié par Position croissante, indépendamment de l'heure de création du Trajet. - **Position** : Un entier positif associé à chaque Trajet au sein d'une même journée, déterminant son rang dans la liste affichée. L'ordre d'affichage est toujours trié par Position croissante, indépendamment de l'heure de création du Trajet.
- **Sélecteur_Temporel** : Le composant de navigation (repliable) permettant de choisir successivement une année puis un mois pour consulter les Trajets correspondants. La journée affichée sur la Carte n'est pas choisie par des boutons de navigation mais suit le survol des Trajets dans la liste. - **Sélecteur_Temporel** : Le composant de navigation (repliable) permettant de choisir une période pour consulter les Trajets correspondants. Il fonctionne selon deux modes : un mode « année + mois » (choix successif d'une année puis d'un mois, utilisé sur la Vue_Consultation) et un mode « année seule » (choix d'une année uniquement, sans grille de mois, utilisé par la Synthèse_Kms). Dans les deux modes, la liste des années proposées se limite à l'année courante et aux années comportant au moins un Trajet. Il se referme automatiquement (repli) dès que l'utilisateur sélectionne son niveau le plus bas (le mois en mode « année + mois », l'année en mode « année seule »). La journée affichée sur la Carte n'est pas choisie par des boutons de navigation mais suit le survol des Trajets dans la liste.
- **Panneau_Totaux** : La zone latérale, toujours visible, affichant les totaux kilométriques du jour, du mois sélectionné et de l'année sélectionnée (cumul jusqu'au mois sélectionné inclus). - **Panneau_Totaux** : La zone latérale, toujours visible, affichant les totaux kilométriques du jour, du mois sélectionné et de l'année sélectionnée (cumul jusqu'au mois sélectionné inclus).
- **Total_Annuel_Partiel** : Le total kilométrique de l'année sélectionnée cumulant uniquement les Trajets jusqu'au mois sélectionné inclus, sans inclure les mois postérieurs. - **Total_Annuel_Partiel** : Le total kilométrique de l'année sélectionnée cumulant uniquement les Trajets jusqu'au mois sélectionné inclus, sans inclure les mois postérieurs.
- **Véhicule** : Un véhicule de l'utilisateur, caractérisé par une plaque d'immatriculation et une puissance fiscale, avec une date d'effet à partir de laquelle il devient le véhicule courant. L'historique des Véhicules est conservé. - **Véhicule** : Un véhicule de l'utilisateur, caractérisé par une plaque d'immatriculation et une puissance fiscale, avec une date d'effet à partir de laquelle il devient le véhicule courant. L'historique des Véhicules est conservé.
@@ -120,24 +121,23 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
--- ---
### Requirement 3: Consultation du résumé mensuel ### Requirement 3: Consultation du total kilométrique mensuel
**User Story:** En tant qu'utilisateur, je veux consulter le total des kilomètres parcourus pour un mois donné, afin de suivre mes déplacements mensuels. **User Story:** En tant qu'utilisateur, je veux consulter le total des kilomètres parcourus pour un mois donné, afin de suivre mes déplacements mensuels.
> **Évolution de navigation** : la consultation mensuelle agrégée ne dispose plus d'un écran dédié « Résumé mensuel » (écran supprimé). Le total kilométrique mensuel est désormais présenté, agrégé par mois, dans l'écran unique Synthèse_Kms (voir Exigences 13 et 14). Le détail des Trajets d'un mois (liste trajet par trajet) n'est plus présenté dans un écran de synthèse : il reste consultable sur la Vue_Consultation (écran « Trajets »). Les règles de calcul du Backend ci-dessous restent inchangées et continuent d'alimenter la Synthèse_Kms comme la Vue_Consultation.
>
> Les exigences techniques associées (codes HTTP) sont décrites dans `design-requirements.md`, Requirement 3. > Les exigences techniques associées (codes HTTP) sont décrites dans `design-requirements.md`, Requirement 3.
#### Acceptance Criteria #### Acceptance Criteria
1. WHEN l'utilisateur sélectionne un mois (valeur entre 1 et 12 inclus) et une année (valeur entre 2000 et 2100 inclus), THE Frontend SHALL afficher la liste des Trajets enregistrés pour ce mois ainsi que le Résumé_Mensuel correspondant. 1. WHEN le Backend reçoit une requête de Résumé_Mensuel pour un mois (entre 1 et 12 inclus) et une année (entre 2000 et 2100 inclus) donnés, THE Backend SHALL retourner la liste des Trajets de ce mois triés par date croissante puis par Position croissante au sein de chaque journée, avec leur Distance respective, leur Commentaire et le total kilométrique du mois.
2. WHEN le Backend reçoit une requête de Résumé_Mensuel pour un mois (entre 1 et 12 inclus) et une année (entre 2000 et 2100 inclus) donnés, THE Backend SHALL retourner la liste des Trajets de ce mois triés par date croissante puis par Position croissante au sein de chaque journée, avec leur Distance respective, leur Commentaire et le total kilométrique du mois. 2. WHEN le Backend reçoit une requête de Résumé_Mensuel, THE Backend SHALL calculer le total kilométrique du Résumé_Mensuel en additionnant les Distances, arrondies selon Precision_Distance (voir spec `precision-distances-km`, Requirement 6), de tous les Trajets du mois concerné.
3. WHEN le Backend reçoit une requête de Résumé_Mensuel, THE Backend SHALL calculer le total kilométrique du Résumé_Mensuel en additionnant les Distances, arrondies selon Precision_Distance (voir spec `precision-distances-km`, Requirement 6), de tous les Trajets du mois concerné. 3. IF aucun Trajet n'est enregistré pour le mois sélectionné, THEN THE Backend SHALL retourner un Résumé_Mensuel avec une liste vide et un total kilométrique de zéro.
4. IF aucun Trajet n'est enregistré pour le mois sélectionné, THEN THE Backend SHALL retourner un Résumé_Mensuel avec une liste vide et un total kilométrique de zéro. 4. IF le Backend reçoit une requête de Résumé_Mensuel avec un mois hors de la plage 1–12 ou une année hors de la plage 2000–2100, THEN THE Backend SHALL refuser la requête et retourner un message descriptif.
5. WHEN le Frontend affiche le Résumé_Mensuel, THE Frontend SHALL afficher la Distance de chaque Trajet et le total kilométrique mensuel avec le nombre de décimales défini par Precision_Distance (voir spec `precision-distances-km`, Requirements 1, 3, 4, 5). 5. IF le Backend retourne une erreur lors d'une requête de Résumé_Mensuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles.
6. IF le Backend reçoit une requête de Résumé_Mensuel avec un mois hors de la plage 1–12 ou une année hors de la plage 2000–2100, THEN THE Backend SHALL refuser la requête et retourner un message descriptif. 6. WHEN le Frontend affiche le total kilométrique d'un mois dans la Synthèse_Kms, THE Frontend SHALL afficher ce total avec le nombre de décimales défini par Precision_Distance (voir spec `precision-distances-km`, Requirements 1, 3, 4, 5).
7. IF le Backend retourne une erreur lors d'une requête de Résumé_Mensuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles. 7. WHEN l'utilisateur consulte le détail des Trajets d'un mois sur la Vue_Consultation, THE Frontend SHALL afficher, dans la liste des Trajets, une colonne "Commentaire" positionnée entre la colonne "Date" et la colonne "Départ", affichant le Commentaire du Trajet ou « — » lorsqu'il est absent.
8. THE Frontend SHALL afficher, dans la liste des Trajets du Résumé_Mensuel, une colonne "Commentaire" positionnée entre la colonne "Date" et la colonne "Départ", affichant le Commentaire du Trajet ou « — » lorsqu'il est absent.
9. THE Frontend SHALL utiliser le Sélecteur_Temporel (défini à l'Exigence 12) pour choisir l'année puis le mois du Résumé_Mensuel, au lieu de sélecteurs distincts de mois et d'année et d'un bouton de validation séparé.
10. WHEN l'utilisateur sélectionne un mois via le Sélecteur_Temporel sur l'écran de Résumé_Mensuel, THE Frontend SHALL charger et afficher automatiquement le Résumé_Mensuel correspondant, sans action supplémentaire de l'utilisateur.
--- ---
@@ -145,13 +145,15 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
**User Story:** En tant qu'utilisateur, je veux consulter le total des kilomètres parcourus pour une année entière, afin d'avoir une vision globale de mes déplacements annuels. **User Story:** En tant qu'utilisateur, je veux consulter le total des kilomètres parcourus pour une année entière, afin d'avoir une vision globale de mes déplacements annuels.
> **Évolution de navigation** : la consultation annuelle ne dispose plus d'un écran dédié « Résumé annuel » (écran supprimé). La vision annuelle (12 mois + total) est désormais présentée dans l'écran unique Synthèse_Kms (voir Exigence 14), via sa ligne par mois et sa ligne TOTAL. Les règles de calcul du Backend ci-dessous restent inchangées.
#### Acceptance Criteria #### Acceptance Criteria
1. WHEN l'utilisateur sélectionne une année comprise entre 2000 et 2100 inclus, THE Frontend SHALL afficher le Résumé_Annuel présentant le total kilométrique de chaque mois et le total annuel. 1. WHEN l'utilisateur sélectionne une année comprise entre 2000 et 2100 inclus dans la Synthèse_Kms, THE Frontend SHALL afficher le total kilométrique de chaque mois et le total annuel.
2. WHEN le Backend reçoit une requête de Résumé_Annuel pour une année donnée, THE Backend SHALL retourner les douze Résumés_Mensuels de cette année, chacun avec son total kilométrique. 2. WHEN le Backend reçoit une requête de Résumé_Annuel pour une année donnée, THE Backend SHALL retourner les douze Résumés_Mensuels de cette année, chacun avec son total kilométrique.
3. WHEN le Backend reçoit une requête de Résumé_Annuel pour une année donnée, THE Backend SHALL calculer le total kilométrique annuel en additionnant les totaux kilométriques des douze Résumés_Mensuels de l'année concernée. 3. WHEN le Backend reçoit une requête de Résumé_Annuel pour une année donnée, THE Backend SHALL calculer le total kilométrique annuel en additionnant les totaux kilométriques des douze Résumés_Mensuels de l'année concernée.
4. IF aucun Trajet n'est enregistré pour un mois d'une année, THEN THE Backend SHALL inclure ce mois dans le Résumé_Annuel avec un total kilométrique de zéro. 4. IF aucun Trajet n'est enregistré pour un mois d'une année, THEN THE Backend SHALL inclure ce mois dans le Résumé_Annuel avec un total kilométrique de zéro.
5. WHEN le Frontend affiche le Résumé_Annuel, THE Frontend SHALL afficher chaque total kilométrique mensuel et le total annuel avec le nombre de décimales défini par Precision_Distance (voir spec `precision-distances-km`, Requirements 1, 3, 4, 5) et une valeur maximale affichable de 999 999,99 km. 5. WHEN le Frontend affiche la vision annuelle dans la Synthèse_Kms, THE Frontend SHALL afficher chaque total kilométrique mensuel et le total annuel avec le nombre de décimales défini par Precision_Distance (voir spec `precision-distances-km`, Requirements 1, 3, 4, 5) et une valeur maximale affichable de 999 999,99 km.
6. IF le Backend retourne une erreur lors d'une requête de Résumé_Annuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles. 6. IF le Backend retourne une erreur lors d'une requête de Résumé_Annuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles.
7. THE Backend SHALL calculer le total kilométrique annuel exclusivement en additionnant les totaux kilométriques des douze Résumés_Mensuels de l'année concernée ; aucun kilométrage non rattaché à un mois ne peut être inclus dans le total annuel. 7. THE Backend SHALL calculer le total kilométrique annuel exclusivement en additionnant les totaux kilométriques des douze Résumés_Mensuels de l'année concernée ; aucun kilométrage non rattaché à un mois ne peut être inclus dans le total annuel.
@@ -342,6 +344,9 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
22. WHEN l'utilisateur change de Véhicule sélectionné, THE Frontend SHALL mettre à jour la liste mensuelle des Trajets, le Panneau_Totaux et la Carte sans rechargement complet de la page. 22. WHEN l'utilisateur change de Véhicule sélectionné, THE Frontend SHALL mettre à jour la liste mensuelle des Trajets, le Panneau_Totaux et la Carte sans rechargement complet de la page.
23. WHEN un Trajet est créé ou modifié avec une date appartenant à un mois différent du mois affiché, THE Frontend SHALL se repositionner automatiquement sur le mois de ce Trajet, cibler sa journée sur la Carte, et déplier le groupe de cette journée dans la liste afin que le Trajet ajouté soit visible. 23. WHEN un Trajet est créé ou modifié avec une date appartenant à un mois différent du mois affiché, THE Frontend SHALL se repositionner automatiquement sur le mois de ce Trajet, cibler sa journée sur la Carte, et déplier le groupe de cette journée dans la liste afin que le Trajet ajouté soit visible.
24. THE Frontend SHALL afficher, à l'extrémité droite de l'en-tête de chaque journée, un bouton dédié « Ajouter un trajet sur cette journée » qui ouvre le formulaire de création avec la date de CETTE journée pré-remplie, indépendamment de la journée actuellement affichée sur la Carte (afin d'éviter qu'un survol traversant plusieurs journées ouvertes ne fasse dériver la date visée par l'utilisateur). 24. THE Frontend SHALL afficher, à l'extrémité droite de l'en-tête de chaque journée, un bouton dédié « Ajouter un trajet sur cette journée » qui ouvre le formulaire de création avec la date de CETTE journée pré-remplie, indépendamment de la journée actuellement affichée sur la Carte (afin d'éviter qu'un survol traversant plusieurs journées ouvertes ne fasse dériver la date visée par l'utilisateur).
25. THE Sélecteur_Temporel SHALL proposer un mode « année seule » limitant la sélection au choix d'une année (sans afficher la grille des mois) et émettant l'année choisie, utilisé par la Synthèse_Kms ; sur la Vue_Consultation, le Sélecteur_Temporel SHALL conserver son mode « année + mois ». Dans les deux modes, la liste des années proposées se limite à l'année courante et aux années comportant au moins un Trajet.
26. WHEN l'utilisateur sélectionne le niveau le plus bas du Sélecteur_Temporel (le mois en mode « année + mois », l'année en mode « année seule »), THE Sélecteur_Temporel SHALL se replier automatiquement.
27. THE Frontend SHALL afficher les écrans « Trajets » (Vue_Consultation), « Carnet d'adresses » et Synthèse_Kms en occupant toute la largeur disponible, tandis que l'écran de consultation des Documents_Specification SHALL conserver une largeur de lecture limitée.
--- ---
@@ -363,6 +368,7 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
7. THE Frontend SHALL afficher tous les montants en euros avec une précision de deux décimales. 7. THE Frontend SHALL afficher tous les montants en euros avec une précision de deux décimales.
8. THE Frontend SHALL afficher, dans l'en-tête de chaque feuille mensuelle, le Coefficient_Base_Vehicule (coefficient et base de la tranche applicable selon la puissance fiscale du Véhicule et le total kilométrique annuel). 8. THE Frontend SHALL afficher, dans l'en-tête de chaque feuille mensuelle, le Coefficient_Base_Vehicule (coefficient et base de la tranche applicable selon la puissance fiscale du Véhicule et le total kilométrique annuel).
9. THE Frontend SHALL proposer un bouton « Recopier le dernier barème connu » qui, pour l'année affichée, recopie l'intégralité des Tranches_Bareme de l'année la plus récente antérieure comportant un barème. IF des Tranches_Bareme existent déjà pour l'année affichée, THEN THE Frontend SHALL demander une confirmation avant de les remplacer intégralement (annule et remplace). THE Frontend SHALL rendre ce bouton inaccessible lorsque l'année affichée est celle du dernier barème connu (aucune année antérieure à recopier). 9. THE Frontend SHALL proposer un bouton « Recopier le dernier barème connu » qui, pour l'année affichée, recopie l'intégralité des Tranches_Bareme de l'année la plus récente antérieure comportant un barème. IF des Tranches_Bareme existent déjà pour l'année affichée, THEN THE Frontend SHALL demander une confirmation avant de les remplacer intégralement (annule et remplace). THE Frontend SHALL rendre ce bouton inaccessible lorsque l'année affichée est celle du dernier barème connu (aucune année antérieure à recopier).
10. WHEN l'utilisateur consulte la Synthèse_Kms, THE Frontend SHALL afficher le Bareme_Kilometrique appliqué (tranche, Puissance_Fiscale, coefficient, base) au-dessus du tableau mensuel, puis présenter pour chaque mois une colonne affichant le Montant_Previsionnel_Mensuel (indication 80%) ; la ligne TOTAL SHALL laisser cette colonne « indication 80% » vide (aucune indication 80% au niveau annuel).
--- ---
@@ -370,11 +376,13 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
**User Story:** En tant qu'utilisateur, je veux saisir le montant réellement versé chaque mois et voir la régularisation annuelle, afin de connaître le solde restant à verser en fin d'année. **User Story:** En tant qu'utilisateur, je veux saisir le montant réellement versé chaque mois et voir la régularisation annuelle, afin de connaître le solde restant à verser en fin d'année.
> Les exigences techniques associées (code HTTP de rejet d'un montant négatif) sont décrites dans `design-requirements.md`, Requirement 14. > **Évolution de navigation** : la saisie des Montant_Regle ne dispose plus d'un écran dédié « Montants réglés » (écran supprimé). Elle se fait désormais dans l'écran unique Synthèse_Kms, directement dans le tableau mensuel, aux côtés de la consultation annuelle (ex-« Résumé annuel ») et du total mensuel (ex-« Résumé mensuel »).
>
> Les exigences techniques associées (code HTTP de rejet d'un montant négatif, composition d'endpoints, enregistrement multiple) sont décrites dans `design-requirements.md`, Requirement 14.
#### Acceptance Criteria #### Acceptance Criteria
1. THE Frontend SHALL permettre à l'utilisateur de saisir un Montant_Regle (valeur numérique positive en euros, deux décimales) pour un mois et une année donnés, associé à un Véhicule ; ce montant est saisi librement et peut différer d'un mois à l'autre. 1. THE Frontend SHALL permettre à l'utilisateur de saisir, dans le tableau mensuel de la Synthèse_Kms, un Montant_Regle (valeur numérique positive en euros, deux décimales) pour chaque mois de l'année et du Véhicule sélectionnés ; ce montant est saisi librement et peut différer d'un mois à l'autre.
2. WHEN l'utilisateur enregistre un Montant_Regle, THE Backend SHALL persister ce montant associé au mois, à l'année et au Véhicule concernés. 2. WHEN l'utilisateur enregistre un Montant_Regle, THE Backend SHALL persister ce montant associé au mois, à l'année et au Véhicule concernés.
3. IF un Montant_Regle existe déjà pour le même mois, la même année et le même Véhicule, THEN THE Backend SHALL mettre à jour le montant existant plutôt que d'en créer un nouveau. 3. IF un Montant_Regle existe déjà pour le même mois, la même année et le même Véhicule, THEN THE Backend SHALL mettre à jour le montant existant plutôt que d'en créer un nouveau.
4. IF l'utilisateur saisit un Montant_Regle négatif, THEN THE Backend SHALL rejeter la saisie. 4. IF l'utilisateur saisit un Montant_Regle négatif, THEN THE Backend SHALL rejeter la saisie.
@@ -382,6 +390,12 @@ L'application permet de gérer un profil (identité, matricule, adresse de domic
6. WHEN le Backend calcule le Frais_Reel_Mensuel de chaque mois, THE Backend SHALL multiplier les kilomètres du mois par le coefficient de la tranche annuelle applicable, de sorte que la somme des douze Frais_Reel_Mensuel plus la base de la tranche annuelle soit égale au Montant_Reel annuel ; les kilomètres du mois sont également des distances déjà arrondies selon Precision_Distance (spec `precision-distances-km`, Requirement 6). 6. WHEN le Backend calcule le Frais_Reel_Mensuel de chaque mois, THE Backend SHALL multiplier les kilomètres du mois par le coefficient de la tranche annuelle applicable, de sorte que la somme des douze Frais_Reel_Mensuel plus la base de la tranche annuelle soit égale au Montant_Reel annuel ; les kilomètres du mois sont également des distances déjà arrondies selon Precision_Distance (spec `precision-distances-km`, Requirement 6).
7. WHEN le Backend calcule la Regularisation d'un Véhicule pour une année, THE Backend SHALL soustraire le total des Montant_Regle de l'année du Montant_Reel annuel : `Regularisation = Montant_Reel − Total_Regle`. 7. WHEN le Backend calcule la Regularisation d'un Véhicule pour une année, THE Backend SHALL soustraire le total des Montant_Regle de l'année du Montant_Reel annuel : `Regularisation = Montant_Reel − Total_Regle`.
8. THE Frontend SHALL afficher le Montant_Regle, le Frais_Reel_Mensuel, le Montant_Reel annuel, le total réglé et la Regularisation avec une précision de deux décimales. 8. THE Frontend SHALL afficher le Montant_Regle, le Frais_Reel_Mensuel, le Montant_Reel annuel, le total réglé et la Regularisation avec une précision de deux décimales.
9. THE Frontend SHALL afficher la Synthèse_Kms avec, en en-tête sur une même ligne, le titre « Synthèse Kms » et une zone de critères comportant un Sélecteur_Temporel en mode « année seule », un sélecteur de Véhicule, puis un bouton « Afficher ».
10. WHEN l'utilisateur ouvre la Synthèse_Kms, THE Frontend SHALL afficher automatiquement la synthèse pour la pré-sélection (Véhicule_Courant et année courante), sans action supplémentaire de l'utilisateur.
11. THE Frontend SHALL présenter dans la Synthèse_Kms un tableau d'une ligne par mois affichant : le libellé du mois, les kilomètres parcourus du mois, le Frais_Reel_Mensuel (remboursement réel propre au mois), le Montant_Previsionnel_Mensuel (indication 80%), le Montant_Regle saisissable, et une colonne « Action » comportant un bouton « Export PDF » déclenchant l'export du rapport mensuel de ce mois.
12. THE Frontend SHALL présenter dans la Synthèse_Kms une ligne TOTAL affichant : le total kilométrique annuel, le Montant_Reel (total du remboursement réel), une cellule « indication 80% » vide, le total réglé (somme des Montant_Regle, non modifiable), et un bouton « Export PDF » déclenchant l'export du rapport annuel ; la ligne TOTAL SHALL ne comporter aucun bouton d'enregistrement.
13. THE Frontend SHALL afficher, au-dessus du tableau de la Synthèse_Kms, une barre d'action comportant un unique bouton « Enregistrer » ; il n'y a pas de bouton d'enregistrement par ligne.
14. WHEN l'utilisateur active le bouton « Enregistrer » de la Synthèse_Kms, THE Frontend SHALL enregistrer uniquement les Montant_Regle modifiés depuis le dernier chargement (plusieurs mois en une seule action), sans réenregistrer les montants inchangés.
--- ---
+2 -8
View File
@@ -13,14 +13,8 @@
<a class="nav-bar__lien" routerLink="/carnet-adresses" routerLinkActive="nav-bar__lien--actif"> <a class="nav-bar__lien" routerLink="/carnet-adresses" routerLinkActive="nav-bar__lien--actif">
Carnet d'adresses Carnet d'adresses
</a> </a>
<a class="nav-bar__lien" routerLink="/montants-regles" routerLinkActive="nav-bar__lien--actif"> <a class="nav-bar__lien" routerLink="/synthese-kms" routerLinkActive="nav-bar__lien--actif">
Montants réglés Synthèse Kms
</a>
<a class="nav-bar__lien" routerLink="/resume-mensuel" routerLinkActive="nav-bar__lien--actif">
Résumé mensuel
</a>
<a class="nav-bar__lien" routerLink="/resume-annuel" routerLinkActive="nav-bar__lien--actif">
Résumé annuel
</a> </a>
<!-- Menu « Specs » : sous-menu (Exigences / Conception technique / Plan de tâches) <!-- Menu « Specs » : sous-menu (Exigences / Conception technique / Plan de tâches)
+4 -8
View File
@@ -9,14 +9,12 @@ import { ResetPasswordComponent } from './features/auth/reset-password/reset-pas
import { BaremeComponent } from './features/bareme/bareme.component'; import { BaremeComponent } from './features/bareme/bareme.component';
import { CarnetAdressesComponent } from './features/carnet-adresses/carnet-adresses.component'; import { CarnetAdressesComponent } from './features/carnet-adresses/carnet-adresses.component';
import { ConsultationJourComponent } from './features/consultation-jour/consultation-jour.component'; import { ConsultationJourComponent } from './features/consultation-jour/consultation-jour.component';
import { MontantsReglesComponent } from './features/montants-regles/montants-regles.component';
import { MaintenanceComponent } from './features/maintenance/maintenance.component'; import { MaintenanceComponent } from './features/maintenance/maintenance.component';
import { OnboardingComponent } from './features/onboarding/onboarding.component'; import { OnboardingComponent } from './features/onboarding/onboarding.component';
import { ParametrageComponent } from './features/parametrage/parametrage.component'; import { ParametrageComponent } from './features/parametrage/parametrage.component';
import { ParametresApplicationComponent } from './features/parametres-application/parametres-application.component'; import { ParametresApplicationComponent } from './features/parametres-application/parametres-application.component';
import { ProfilComponent } from './features/profil/profil.component'; import { ProfilComponent } from './features/profil/profil.component';
import { ResumeAnnuelComponent } from './features/resume-annuel/resume-annuel.component'; import { SyntheseKmsComponent } from './features/synthese-kms/synthese-kms.component';
import { ResumeMensuelComponent } from './features/resume-mensuel/resume-mensuel.component';
import { VehiculesComponent } from './features/vehicules/vehicules.component'; import { VehiculesComponent } from './features/vehicules/vehicules.component';
/** /**
@@ -36,8 +34,8 @@ import { VehiculesComponent } from './features/vehicules/vehicules.component';
* `authGuard` (mêmes raisons que ci-dessus) ; seul l'enfant `bareme` ajoute * `authGuard` (mêmes raisons que ci-dessus) ; seul l'enfant `bareme` ajoute
* `profilConfigureGuard`. * `profilConfigureGuard`.
* - Routes de données PROTEGEES par `[authGuard, profilConfigureGuard]` : * - Routes de données PROTEGEES par `[authGuard, profilConfigureGuard]` :
* `consultation`, `carnet-adresses`, `resume-mensuel`, `resume-annuel`, * `consultation`, `carnet-adresses`, `synthese-kms`. L'authentification est
* `montants-regles`. L'authentification est contrôlée d'abord, puis la * contrôlée d'abord, puis la
* configuration du profil (redirection vers `/onboarding` si `estConfigure = false`). * configuration du profil (redirection vers `/onboarding` si `estConfigure = false`).
* *
* Redirections : `''` -> `consultation`, `parametrage` -> `parametrage/compte`, * Redirections : `''` -> `consultation`, `parametrage` -> `parametrage/compte`,
@@ -86,9 +84,7 @@ export const routes: Routes = [
// Routes de données PROTEGEES : authGuard d'abord, puis profilConfigureGuard // Routes de données PROTEGEES : authGuard d'abord, puis profilConfigureGuard
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] }, { path: 'consultation', component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [authGuard, profilConfigureGuard] }, { path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'resume-mensuel', component: ResumeMensuelComponent, canActivate: [authGuard, profilConfigureGuard] }, { path: 'synthese-kms', component: SyntheseKmsComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'resume-annuel', component: ResumeAnnuelComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: 'montants-regles', component: MontantsReglesComponent, canActivate: [authGuard, profilConfigureGuard] },
{ path: '**', redirectTo: 'consultation' } { path: '**', redirectTo: 'consultation' }
]; ];
@@ -1,7 +1,5 @@
.carnet { .carnet {
padding: 1.5rem; padding: 1.5rem;
max-width: 1080px;
margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
@@ -15,8 +15,6 @@
min-height: 0; min-height: 0;
gap: 1rem; gap: 1rem;
padding: 1.5rem; padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; // aucun scroll global : seule la liste défile (voir &__liste) overflow: hidden; // aucun scroll global : seule la liste défile (voir &__liste)
@@ -1,75 +0,0 @@
<section class="montants-regles">
<h1>Montants réglés mensuels</h1>
<form [formGroup]="selectionForm" class="selection-form" (ngSubmit)="charger()">
<div class="champ">
<label for="annee">Année</label>
<input id="annee" type="number" formControlName="annee" min="2000" max="2100" />
</div>
<div class="champ">
<label for="vehicule">Véhicule</label>
<select id="vehicule" formControlName="vehiculeId">
<option [ngValue]="null" disabled>Sélectionner…</option>
<option *ngFor="let v of vehicules()" [ngValue]="v.id">
{{ v.denomination }} — {{ v.plaque }} — {{ v.puissanceFiscale }} CV
</option>
</select>
</div>
<button type="submit" class="btn-principal" [disabled]="selectionForm.invalid || chargement()">
Afficher
</button>
</form>
<p class="indication">
Le montant prévisionnel (80% du barème) est une simple indication. Seul le montant réglé
saisi fait foi.
</p>
<table class="mois" *ngIf="vehiculeId() !== null; else selectionner">
<thead>
<tr>
<th>Mois</th>
<th>Montant réglé (€)</th>
<th>Prévisionnel 80% (€)</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ligne of lignes()">
<td>{{ ligne.libelle }}</td>
<td>
<input
type="number"
step="0.01"
min="0"
[(ngModel)]="ligne.montant"
[ngModelOptions]="{ standalone: true }"
[attr.aria-label]="'Montant réglé ' + ligne.libelle"
/>
</td>
<td class="previsionnel">
<ng-container *ngIf="ligne.previsionnel !== null; else nonConfigure">
{{ ligne.previsionnel | number: '1.2-2' }}
</ng-container>
<ng-template #nonConfigure>—</ng-template>
</td>
<td>
<button
type="button"
class="btn-lien"
[disabled]="chargement()"
(click)="enregistrer(ligne)"
>
Enregistrer
</button>
</td>
</tr>
</tbody>
</table>
<ng-template #selectionner>
<p class="vide">Sélectionnez une année et un véhicule pour saisir les montants.</p>
</ng-template>
</section>
@@ -1,109 +0,0 @@
.montants-regles {
padding: 1.5rem;
max-width: 720px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.selection-form {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.champ {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.champ label {
font-weight: 600;
margin-bottom: 0.25rem;
}
.champ input,
.champ select {
padding: 0.5rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
}
.indication {
color: #555;
font-style: italic;
margin: 0.5rem 0 1rem;
}
.btn-principal {
padding: 0.55rem 1.25rem;
background: var(--couleur-bleu);
color: #fff;
border: 1px solid var(--couleur-bleu);
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
}
.btn-principal:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-lien {
background: none;
border: none;
color: #1565c0;
cursor: pointer;
padding: 0 0.5rem;
font-size: 0.9rem;
}
.btn-lien:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.vide {
color: #777;
}
.mois {
width: 100%;
border-collapse: collapse;
}
.mois th,
.mois td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e0e0e0;
}
.mois th {
font-weight: 600;
background: #f5f5f5;
}
.mois input {
width: 8rem;
padding: 0.4rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
}
.previsionnel {
color: #555;
}
@@ -1,215 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import {
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { BaremeService } from '../../core/services/bareme.service';
import { NotificationService } from '../../core/services/notification.service';
import { RemboursementService } from '../../core/services/remboursement.service';
import { VehiculeService } from '../../core/services/vehicule.service';
import { MontantRegle, Vehicule } from '../../core/models';
/** Libellés des mois affichés dans le tableau. */
const NOMS_MOIS = [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre'
];
/** Ligne mensuelle de saisie (montant réglé + prévisionnel indicatif). */
interface LigneMois {
mois: number;
libelle: string;
montant: number | null;
previsionnel: number | null;
}
/**
* Saisie du Montant_Regle mensuel et affichage du Montant_Previsionnel
* indicatif (Exigences 14.1, 14.4, 14.8).
*
* - Sélection d'une année et d'un véhicule.
* - 12 lignes (une par mois) : saisie du Montant_Regle, affichage en regard
* du Montant_Previsionnel (80% du barème sur le Total_Annuel_Partiel),
* fourni par `BaremeService.getMontantPrevisionnel`.
* - Rejet client des montants négatifs (Exigence 14.4).
* - Tous les montants affichés avec deux décimales (Exigence 14.8).
* - Enregistrement via `RemboursementService.saveMontantRegle` (upsert).
*/
@Component({
selector: 'app-montants-regles',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FormsModule],
templateUrl: './montants-regles.component.html',
styleUrl: './montants-regles.component.scss'
})
export class MontantsReglesComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly baremeService = inject(BaremeService);
private readonly remboursementService = inject(RemboursementService);
private readonly vehiculeService = inject(VehiculeService);
private readonly notification = inject(NotificationService);
/** Formulaire de sélection (année + véhicule). */
selectionForm!: FormGroup;
/** Liste des véhicules disponibles. */
readonly vehicules = signal<Vehicule[]>([]);
/** 12 lignes mensuelles. */
readonly lignes = signal<LigneMois[]>([]);
/** Année affichée. */
readonly annee = signal(new Date().getFullYear());
/** Véhicule affiché. */
readonly vehiculeId = signal<number | null>(null);
/** true pendant un appel réseau. */
readonly chargement = signal(false);
ngOnInit(): void {
this.selectionForm = this.fb.group({
annee: [this.annee(), [Validators.required, Validators.min(2000), Validators.max(2100)]],
vehiculeId: [null, [Validators.required]]
});
this.initialiserLignes();
this.vehiculeService.getVehicules().subscribe({
next: (liste) => {
const vehicules = [...liste].sort((a, b) => b.dateEffet.localeCompare(a.dateEffet));
this.vehicules.set(vehicules);
if (vehicules.length) {
this.selectionForm.patchValue({ vehiculeId: vehicules[0].id });
this.charger();
}
},
error: () => {
this.notification.error('Impossible de charger les véhicules.');
}
});
}
/** Réinitialise les 12 lignes mensuelles (montant et prévisionnel vides). */
private initialiserLignes(): void {
this.lignes.set(NOMS_MOIS.map((libelle, index) => ({
mois: index + 1,
libelle,
montant: null,
previsionnel: null
})));
}
/** Charge les montants réglés et les montants prévisionnels de la sélection. */
charger(): void {
if (this.selectionForm.invalid) {
this.selectionForm.markAllAsTouched();
return;
}
const valeur = this.selectionForm.getRawValue();
const annee = Number(valeur.annee);
const vehiculeId = Number(valeur.vehiculeId);
this.annee.set(annee);
this.vehiculeId.set(vehiculeId);
this.initialiserLignes();
this.chargement.set(true);
const montants$ = this.remboursementService
.getMontantsRegles(annee, vehiculeId)
.pipe(catchError(() => of([] as MontantRegle[])));
const previsionnels$ = this.lignes().map((ligne) =>
this.baremeService
.getMontantPrevisionnel(annee, ligne.mois, vehiculeId)
.pipe(catchError(() => of(null)))
);
forkJoin({ montants: montants$, previsionnels: forkJoin(previsionnels$) }).subscribe({
next: ({ montants, previsionnels }) => {
this.chargement.set(false);
// Reconstruit un nouveau tableau pour notifier le signal (nouvelle référence).
const lignes = this.lignes().map((l) => ({ ...l }));
for (const m of montants) {
const ligne = lignes.find((l) => l.mois === m.mois);
if (ligne) {
ligne.montant = m.montant;
}
}
previsionnels.forEach((p, index) => {
lignes[index].previsionnel = p ? p.montantPrevisionnelMensuel : null;
});
this.lignes.set(lignes);
},
error: () => {
this.chargement.set(false);
this.notification.error('Impossible de charger les montants.');
}
});
}
/**
* Enregistre le Montant_Regle d'un mois (upsert).
* Rejette côté client tout montant absent ou négatif (Exigence 14.4).
*/
enregistrer(ligne: LigneMois): void {
const vehiculeId = this.vehiculeId();
if (vehiculeId === null || this.chargement()) {
return;
}
const montant = ligne.montant;
if (montant === null || montant === undefined || Number.isNaN(Number(montant))) {
this.notification.error('Veuillez saisir un montant.');
return;
}
if (Number(montant) < 0) {
this.notification.error('Le montant réglé ne peut pas être négatif.');
return;
}
const dto: MontantRegle = {
annee: this.annee(),
mois: ligne.mois,
vehiculeId,
montant: Number(montant)
};
this.chargement.set(true);
this.remboursementService.saveMontantRegle(dto).subscribe({
next: (enregistre) => {
this.chargement.set(false);
// Reconstruit le tableau pour notifier le signal de la valeur normalisée.
this.lignes.set(
this.lignes().map((l) =>
l.mois === ligne.mois ? { ...l, montant: enregistre.montant } : l
)
);
this.notification.success(`Montant de ${ligne.libelle} enregistré.`);
},
error: () => {
this.chargement.set(false);
this.notification.error("Échec de l'enregistrement du montant.");
}
});
}
}
@@ -6,7 +6,7 @@
[attr.aria-expanded]="ouvert()" [attr.aria-expanded]="ouvert()"
(click)="basculerOuverture()"> (click)="basculerOuverture()">
<span class="selecteur-entete__chevron" aria-hidden="true">{{ ouvert() ? '▾' : '▸' }}</span> <span class="selecteur-entete__chevron" aria-hidden="true">{{ ouvert() ? '▾' : '▸' }}</span>
<span class="selecteur-entete__libelle">📅 Choisir un mois</span> <span class="selecteur-entete__libelle">{{ libelleEntete }}</span>
@if (libelleMoisAnnee) { @if (libelleMoisAnnee) {
<span class="selecteur-entete__selection">{{ libelleMoisAnnee }}</span> <span class="selecteur-entete__selection">{{ libelleMoisAnnee }}</span>
} }
@@ -35,7 +35,7 @@
class="selecteur-groupe" class="selecteur-groupe"
role="group" role="group"
aria-label="Sélection du mois" aria-label="Sélection du mois"
*ngIf="anneeSelectionnee() !== null"> *ngIf="!modeAnneeSeule && anneeSelectionnee() !== null">
<span class="selecteur-titre">Mois</span> <span class="selecteur-titre">Mois</span>
<div class="selecteur-boutons"> <div class="selecteur-boutons">
<button <button
@@ -55,9 +55,19 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
*/ */
@Input() date: string | null = null; @Input() date: string | null = null;
/** Émis à chaque changement de sélection (année + mois). */ /**
* Mode « année seule » : masque la grille des mois et limite le sélecteur au
* choix d'une année. La sélection d'une année émet alors `anneeSelectionnee`
* (et non `moisSelectionne`). Par défaut faux (mode année + mois).
*/
@Input() modeAnneeSeule = false;
/** Émis à chaque changement de sélection (année + mois) — mode année + mois. */
@Output() readonly moisSelectionne = new EventEmitter<MoisSelectionne>(); @Output() readonly moisSelectionne = new EventEmitter<MoisSelectionne>();
/** Émis à chaque sélection d'année — mode « année seule ». */
@Output() readonly anneeChoisie = new EventEmitter<number>();
/** Libellés des douze mois (index 0 = janvier). */ /** Libellés des douze mois (index 0 = janvier). */
readonly nomsMois = [ readonly nomsMois = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
@@ -87,6 +97,9 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
*/ */
get libelleMoisAnnee(): string { get libelleMoisAnnee(): string {
const annee = this.anneeSelectionnee(); const annee = this.anneeSelectionnee();
if (this.modeAnneeSeule) {
return annee == null ? '' : `${annee}`;
}
const mois = this.moisSelectionneSignal(); const mois = this.moisSelectionneSignal();
if (annee == null || mois == null) { if (annee == null || mois == null) {
return ''; return '';
@@ -94,6 +107,11 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
return `${this.nomsMois[mois - 1]} ${annee}`; return `${this.nomsMois[mois - 1]} ${annee}`;
} }
/** Libellé du bouton d'en-tête, selon le mode. */
get libelleEntete(): string {
return this.modeAnneeSeule ? '📅 Choisir une année' : '📅 Choisir un mois';
}
/** Bascule l'ouverture / fermeture du sélecteur repliable. */ /** Bascule l'ouverture / fermeture du sélecteur repliable. */
basculerOuverture(): void { basculerOuverture(): void {
this.ouvert.update((v) => !v); this.ouvert.update((v) => !v);
@@ -158,6 +176,14 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
this.moisSelectionneSignal.set(null); this.moisSelectionneSignal.set(null);
this.moisAvecDonnees.set(new Set<number>()); this.moisAvecDonnees.set(new Set<number>());
// Mode « année seule » : l'année est le niveau le plus bas → on émet et on
// replie automatiquement le sélecteur. Pas de chargement des mois (non affichés).
if (this.modeAnneeSeule) {
this.anneeChoisie.emit(annee);
this.ouvert.set(false);
return;
}
this.trajetService.getMoisDisponibles(annee).subscribe((mois) => { this.trajetService.getMoisDisponibles(annee).subscribe((mois) => {
this.moisAvecDonnees.set(new Set<number>(mois)); this.moisAvecDonnees.set(new Set<number>(mois));
}); });
@@ -171,6 +197,8 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
} }
this.moisSelectionneSignal.set(mois); this.moisSelectionneSignal.set(mois);
this.moisSelectionne.emit({ annee, mois }); this.moisSelectionne.emit({ annee, mois });
// Le mois est le niveau le plus bas en mode année + mois → repli automatique.
this.ouvert.set(false);
} }
/** Un mois est en surbrillance s'il comporte au moins un trajet. */ /** Un mois est en surbrillance s'il comporte au moins un trajet. */
@@ -1,61 +0,0 @@
<section class="resume-annuel">
<h1>Résumé annuel</h1>
<form class="selecteurs" (ngSubmit)="charger()">
<div class="selecteurs__groupe">
<div class="champ">
<label for="annee">Année</label>
<input
id="annee"
name="annee"
type="number"
[(ngModel)]="annee"
[min]="anneeMin"
[max]="anneeMax"
/>
</div>
<button type="submit" class="btn-principal" [disabled]="chargement()">
Afficher
</button>
</div>
<div class="actions">
<button
type="button"
class="btn-secondaire"
(click)="exporterPdf()"
[disabled]="export() || chargement()"
>
Export PDF annuel
</button>
</div>
</form>
@if (chargement()) {
<p class="info">Chargement du résumé…</p>
} @else if (resumeCharge()) {
<table class="mois">
<thead>
<tr>
<th>Mois</th>
<th class="num">Total (km)</th>
</tr>
</thead>
<tbody>
@for (ligne of lignes(); track ligne.mois) {
<tr>
<td>{{ ligne.libelle }}</td>
<td class="num">{{ ligne.totalKm | number: formatKm() }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total annuel</td>
<td class="num total">{{ totalAnnuel() | number: formatKm() }} km</td>
</tr>
</tfoot>
</table>
}
</section>
@@ -1,119 +0,0 @@
.resume-annuel {
padding: 1.5rem;
max-width: 640px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.selecteurs {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.selecteurs__groupe {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 1rem;
}
.champ {
display: flex;
flex-direction: column;
}
.champ label {
font-weight: 600;
margin-bottom: 0.25rem;
}
.champ input {
padding: 0.5rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
}
.actions {
display: flex;
gap: 0.75rem;
}
.btn-principal,
.btn-secondaire {
padding: 0.55rem 1.25rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-principal {
background: var(--couleur-bleu);
color: #fff;
border: 1px solid var(--couleur-bleu);
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
}
.btn-secondaire {
background: #fff;
color: #1565c0;
border: 1px solid #1565c0;
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
color: #fff;
}
}
.btn-principal:disabled,
.btn-secondaire:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.mois {
width: 100%;
border-collapse: collapse;
}
.mois th,
.mois td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e0e0e0;
}
.mois th {
font-weight: 600;
background: #f5f5f5;
}
.mois .num {
text-align: right;
}
.mois tfoot td {
font-weight: 600;
border-top: 2px solid #bbb;
}
.mois .total {
color: #1565c0;
}
.info {
color: #555;
}
@@ -1,183 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NotificationService } from '../../core/services/notification.service';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
import { TrajetService } from '../../core/services/trajet.service';
import { ResumeAnnuel } from '../../core/models';
/** Libellés des mois affichés dans le tableau annuel (index 0 = janvier). */
const NOMS_MOIS = [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre'
];
/** Ligne du tableau annuel : un mois et son total kilométrique. */
export interface LigneAnnuelle {
mois: number;
libelle: string;
totalKm: number;
}
/**
* Résumé annuel (Exigences 4.1, 4.5, 4.6, 7.3).
*
* - Sélecteur année (2000–2100).
* - Tableau de 12 lignes (mois, total km) + ligne du total annuel à 2 décimales,
* plafonné à 999 999,99 (Exigence 4.5).
* - Bouton « Export PDF annuel » : Blob → téléchargement navigateur, repli
* `trajets_AAAA.pdf` ; échec de téléchargement → message d'erreur (Exigences 4.6, 7.3).
* - En cas d'erreur API, aucune donnée partielle n'est affichée.
*/
@Component({
selector: 'app-resume-annuel',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './resume-annuel.component.html',
styleUrl: './resume-annuel.component.scss'
})
export class ResumeAnnuelComponent implements OnInit {
private readonly trajetService = inject(TrajetService);
private readonly notification = inject(NotificationService);
readonly parametresService = inject(ParametresApplicationService);
/** Bornes de saisie de l'année (cohérentes avec la validation backend). */
readonly anneeMin = 2000;
readonly anneeMax = 2100;
/** Plafond d'affichage du total annuel (Exigence 4.5). */
readonly totalMax = 999999.99;
/** Année sélectionnée, initialisée à l'année en cours. */
annee: number = new Date().getFullYear();
/** 12 lignes (mois → total km) dérivées du résumé chargé. */
readonly lignes = signal<LigneAnnuelle[]>([]);
/** Total annuel plafonné à 999 999,99. */
readonly totalAnnuel = signal(0);
/** true tant qu'aucun chargement réussi (masque le tableau). */
readonly resumeCharge = signal(false);
/** true pendant le chargement du résumé. */
readonly chargement = signal(false);
/** true pendant la génération/téléchargement du PDF (désactive le bouton). */
readonly export = signal(false);
ngOnInit(): void {
this.charger();
}
/** Charge le résumé annuel pour l'année sélectionnée. */
charger(): void {
if (!this.anneeValide()) {
this.notification.error('Veuillez saisir une année valide (2000–2100).');
return;
}
this.chargement.set(true);
this.trajetService.getResumeAnnuel(this.annee).subscribe({
next: (resume) => {
this.appliquerResume(resume);
this.chargement.set(false);
this.resumeCharge.set(true);
},
error: () => {
// Ne pas afficher de données partielles en cas d'erreur.
this.lignes.set([]);
this.totalAnnuel.set(0);
this.resumeCharge.set(false);
this.chargement.set(false);
this.notification.error('Impossible de charger le résumé annuel.');
}
});
}
/**
* Déclenche l'export PDF annuel et le téléchargement navigateur (Exigences 4.6, 7.3). Le nom
* de fichier utilisé est celui suggéré par le backend (en-tête `Content-Disposition`, format
* `AAAA_Trajets_MATRICULE_PLAQUE.pdf`) ; à défaut (en-tête absent), un repli local est utilisé.
*/
exporterPdf(): void {
if (!this.anneeValide() || this.export()) {
return;
}
this.export.set(true);
this.trajetService.exportPdfAnnuel(this.annee).subscribe({
next: (reponse) => {
this.export.set(false);
const nom = this.trajetService.extraireNomFichier(reponse) ?? `${this.annee}_Trajets.pdf`;
this.declencherTelechargement(reponse.body!, nom);
},
error: () => {
this.export.set(false);
this.notification.error("Échec du téléchargement du PDF annuel.");
}
});
}
/**
* Construit les 12 lignes à partir du résumé (toujours 12 mois côté backend,
* mais on garantit l'ordre et la présence des 12 mois) et plafonne le total.
*/
private appliquerResume(resume: ResumeAnnuel): void {
const parMois = new Map<number, number>();
for (const m of resume.mois ?? []) {
parMois.set(m.mois, m.totalKm ?? 0);
}
this.lignes.set(NOMS_MOIS.map((libelle, index) => {
const mois = index + 1;
return { mois, libelle, totalKm: parMois.get(mois) ?? 0 };
}));
this.totalAnnuel.set(Math.min(resume.totalKm ?? 0, this.totalMax));
}
/**
* Crée une URL d'objet à partir du Blob et déclenche un téléchargement via
* une ancre temporaire, puis libère l'URL.
*/
private declencherTelechargement(blob: Blob, nomFichier: string): void {
const url = URL.createObjectURL(blob);
const ancre = document.createElement('a');
ancre.href = url;
ancre.download = nomFichier;
document.body.appendChild(ancre);
ancre.click();
document.body.removeChild(ancre);
URL.revokeObjectURL(url);
}
/**
* Chaîne de format `number` (`1.{p}-{p}`) basée sur la précision courante des
* distances, pour un affichage dynamique des totaux en km.
*/
formatKm(): string {
const precision = this.parametresService.precisionDistance();
return `1.${precision}-${precision}`;
}
/** Vérifie que l'année sélectionnée est dans les bornes attendues. */
private anneeValide(): boolean {
return (
Number.isInteger(this.annee) &&
this.annee >= this.anneeMin &&
this.annee <= this.anneeMax
);
}
}
@@ -1,60 +0,0 @@
<section class="resume-mensuel">
<h1>Résumé mensuel</h1>
<div class="selecteurs">
<app-selecteur-temporel
[date]="dateSelecteur"
(moisSelectionne)="onMoisSelectionne($event)"
></app-selecteur-temporel>
<div class="actions">
<button
type="button"
class="btn-secondaire"
(click)="exporterPdf()"
[disabled]="export() || chargement()"
>
Export PDF mensuel
</button>
</div>
</div>
@if (chargement()) {
<p class="info">Chargement du résumé…</p>
} @else if (resume(); as resume) {
<table class="trajets">
<thead>
<tr>
<th>Date</th>
<th>Commentaire</th>
<th>Départ</th>
<th>Arrivée</th>
<th class="num">Distance (km)</th>
</tr>
</thead>
<tbody>
@for (trajet of resume.trajets; track trajet.id) {
<tr>
<td>{{ trajet.date | date: 'dd/MM/yyyy' }}</td>
<td>{{ trajet.commentaire || '—' }}</td>
<td>{{ trajet.adresseDepart }}</td>
<td>{{ trajet.adresseArrivee }}</td>
<td class="num">
{{ trajet.distance != null ? (trajet.distance | number: formatKm()) : '—' }}
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="vide">Aucun trajet pour ce mois.</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4">Total mensuel</td>
<td class="num total">{{ resume.totalKm | number: formatKm() }} km</td>
</tr>
</tfoot>
</table>
}
</section>
@@ -1,100 +0,0 @@
.resume-mensuel {
padding: 1.5rem;
max-width: 900px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.selecteurs {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.actions {
display: flex;
gap: 0.75rem;
}
.btn-principal,
.btn-secondaire {
padding: 0.55rem 1.25rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-principal {
background: var(--couleur-bleu);
color: #fff;
border: 1px solid var(--couleur-bleu);
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
}
.btn-secondaire {
background: #fff;
color: #1565c0;
border: 1px solid #1565c0;
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
color: #fff;
}
}
.btn-principal:disabled,
.btn-secondaire:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.trajets {
width: 100%;
border-collapse: collapse;
}
.trajets th,
.trajets td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e0e0e0;
}
.trajets th {
font-weight: 600;
background: #f5f5f5;
}
.trajets .num {
text-align: right;
}
.trajets tfoot td {
font-weight: 600;
border-top: 2px solid #bbb;
}
.trajets .total {
color: #1565c0;
}
.vide {
color: #777;
text-align: center;
}
.info {
color: #555;
}
@@ -1,157 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NotificationService } from '../../core/services/notification.service';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
import { TrajetService } from '../../core/services/trajet.service';
import { ResumeMensuel } from '../../core/models';
import { MoisSelectionne, SelecteurTemporelComponent } from '../navigation-temporelle/selecteur-temporel.component';
/**
* Résumé mensuel (Exigences 3.1, 3.5, 3.7, 7.3).
*
* - Sélecteurs mois (1–12) et année (2000–2100).
* - Affiche la liste des trajets du mois et le total mensuel à 2 décimales.
* - En cas d'erreur API, affiche un message et n'affiche aucune donnée partielle
* (le résumé est remis à null, Exigence 3.7).
* - Bouton « Export PDF mensuel » : récupère le Blob via `TrajetService`, puis
* déclenche le téléchargement navigateur (URL d'objet + ancre). Le nom du
* fichier provient de l'en-tête `Content-Disposition` s'il est présent, sinon
* d'un repli `trajets_MM_AAAA.pdf`. Un échec de téléchargement affiche un
* message d'erreur (Exigence 7.3).
*/
@Component({
selector: 'app-resume-mensuel',
standalone: true,
imports: [CommonModule, FormsModule, SelecteurTemporelComponent],
templateUrl: './resume-mensuel.component.html',
styleUrl: './resume-mensuel.component.scss'
})
export class ResumeMensuelComponent implements OnInit {
private readonly trajetService = inject(TrajetService);
private readonly notification = inject(NotificationService);
readonly parametresService = inject(ParametresApplicationService);
/** Bornes de saisie de l'année (cohérentes avec la validation backend). */
readonly anneeMin = 2000;
readonly anneeMax = 2100;
/** Sélection courante, initialisée au mois/année en cours. */
annee: number = new Date().getFullYear();
mois: number = new Date().getMonth() + 1;
/** Date ISO (`yyyy-MM-dd`) transmise au sélecteur temporel (jour arbitraire du mois sélectionné). */
get dateSelecteur(): string {
const mm = String(this.mois).padStart(2, '0');
return `${this.annee}-${mm}-01`;
}
/** Réagit à la sélection d'un mois via le sélecteur temporel (même composant que la page trajets). */
onMoisSelectionne(selection: MoisSelectionne): void {
this.annee = selection.annee;
this.mois = selection.mois;
this.charger();
}
/**
* Chaîne de format `number` (`1.{p}-{p}`) basée sur la précision courante des
* distances, pour un affichage dynamique des distances en km.
*/
formatKm(): string {
const precision = this.parametresService.precisionDistance();
return `1.${precision}-${precision}`;
}
/** Résumé chargé (null tant qu'aucun chargement réussi ou après une erreur). */
readonly resume = signal<ResumeMensuel | null>(null);
/** true pendant le chargement du résumé. */
readonly chargement = signal(false);
/** true pendant la génération/téléchargement du PDF (désactive le bouton). */
readonly export = signal(false);
ngOnInit(): void {
this.charger();
}
/** Charge le résumé mensuel pour la sélection courante. */
charger(): void {
if (!this.selectionValide()) {
this.notification.error('Veuillez saisir un mois (1–12) et une année valides (2000–2100).');
return;
}
this.chargement.set(true);
this.trajetService.getResumeMensuel(this.annee, this.mois).subscribe({
next: (resume) => {
this.resume.set(resume);
this.chargement.set(false);
},
error: () => {
// Ne pas afficher de données partielles en cas d'erreur (Exigence 3.7).
this.resume.set(null);
this.chargement.set(false);
this.notification.error('Impossible de charger le résumé mensuel.');
}
});
}
/**
* Déclenche l'export PDF mensuel et le téléchargement navigateur (Exigence 7.3). Le nom de
* fichier utilisé est celui suggéré par le backend (en-tête `Content-Disposition`, format
* `AAAAMM_Trajets_MATRICULE_PLAQUE.pdf`) ; à défaut (en-tête absent), un repli local est utilisé.
*/
exporterPdf(): void {
if (!this.selectionValide() || this.export()) {
return;
}
this.export.set(true);
this.trajetService.exportPdfMensuel(this.annee, this.mois).subscribe({
next: (reponse) => {
this.export.set(false);
const nom = this.trajetService.extraireNomFichier(reponse) ?? this.nomFichierParDefaut();
this.declencherTelechargement(reponse.body!, nom);
},
error: () => {
this.export.set(false);
this.notification.error("Échec du téléchargement du PDF mensuel.");
}
});
}
/** Nom de fichier de repli si le backend n'a pas fourni de `Content-Disposition`. */
private nomFichierParDefaut(): string {
const mm = String(this.mois).padStart(2, '0');
return `${this.annee}${mm}_Trajets.pdf`;
}
/**
* Crée une URL d'objet à partir du Blob et déclenche un téléchargement via
* une ancre temporaire, puis libère l'URL.
*/
private declencherTelechargement(blob: Blob, nomFichier: string): void {
const url = URL.createObjectURL(blob);
const ancre = document.createElement('a');
ancre.href = url;
ancre.download = nomFichier;
document.body.appendChild(ancre);
ancre.click();
document.body.removeChild(ancre);
URL.revokeObjectURL(url);
}
/** Vérifie que la sélection mois/année est dans les bornes attendues. */
private selectionValide(): boolean {
return (
Number.isInteger(this.mois) &&
this.mois >= 1 &&
this.mois <= 12 &&
Number.isInteger(this.annee) &&
this.annee >= this.anneeMin &&
this.annee <= this.anneeMax
);
}
}
@@ -0,0 +1,119 @@
<section class="synthese-kms">
<!-- Titre et zone de critères sur une même ligne (comme l'en-tête du carnet d'adresses). -->
<div class="synthese-kms__entete">
<h1>Synthèse Kms</h1>
<!-- Sélection : année (sélecteur temporel en mode année seule) → véhicule → Afficher. -->
<div class="selection">
<app-selecteur-temporel
[modeAnneeSeule]="true"
[date]="dateSelecteur()"
(anneeChoisie)="onAnneeChoisie($event)"
></app-selecteur-temporel>
<div class="champ">
<label for="vehicule">Véhicule</label>
<select id="vehicule" [ngModel]="vehiculeId()" (ngModelChange)="vehiculeId.set($event)">
<option [ngValue]="null" disabled>Sélectionner…</option>
<option *ngFor="let v of vehicules()" [ngValue]="v.id">
{{ v.denomination }} — {{ v.plaque }} — {{ v.puissanceFiscale }} CV
</option>
</select>
</div>
<button
type="button"
class="btn-principal"
[disabled]="vehiculeId() === null || chargement()"
(click)="afficher()"
>
Afficher
</button>
</div>
</div>
<ng-container *ngIf="chargee() && synthese() as reg; else aucun">
<!-- Barème appliqué -->
<p class="bareme">
<strong>Barème appliqué :</strong>
tranche {{ reg.trancheLibelle }} ({{ reg.puissanceFiscale }} CV) —
coefficient {{ reg.coefficientTranche | number: '1.3-3' }} / base {{ reg.baseTranche | number: '1.0-0' }}
</p>
<!-- Barre d'action : enregistrement global des montants réglés modifiés. -->
<div class="actions">
<button type="button" class="btn-principal" [disabled]="chargement()" (click)="enregistrer()">
Enregistrer
</button>
</div>
<table class="synthese">
<thead>
<tr>
<th>Mois</th>
<th class="num">Kms parcourus</th>
<th class="num">Remb. calculé (mois)</th>
<th class="num">Indication 80%</th>
<th class="num">Montant réglé (€)</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ligne of lignes()">
<td>{{ ligne.libelle }}</td>
<td class="num">{{ ligne.kmMois | number: formatKm() }}</td>
<td class="num">{{ ligne.fraisReelMensuel | number: '1.2-2' }} €</td>
<td class="num">
<ng-container *ngIf="ligne.previsionnel !== null; else nonConfigure">
{{ ligne.previsionnel | number: '1.2-2' }} €
</ng-container>
<ng-template #nonConfigure>—</ng-template>
</td>
<td class="num">
<input
type="number"
step="0.01"
min="0"
[(ngModel)]="ligne.montantRegle"
[ngModelOptions]="{ standalone: true }"
[attr.aria-label]="'Montant réglé ' + ligne.libelle"
/>
</td>
<td>
<button
type="button"
class="btn-secondaire"
[disabled]="exportEnCours() !== null"
(click)="exporterMensuel(ligne.mois)"
>
Export PDF
</button>
</td>
</tr>
</tbody>
<tfoot>
<tr class="total">
<td>TOTAL</td>
<td class="num">{{ reg.totalKmAnnuel | number: formatKm() }}</td>
<td class="num">{{ reg.montantReel | number: '1.2-2' }} €</td>
<td class="num">—</td>
<td class="num">{{ reg.totalPaye | number: '1.2-2' }} €</td>
<td>
<button
type="button"
class="btn-secondaire"
[disabled]="exportEnCours() !== null"
(click)="exporterAnnuel()"
>
Export PDF
</button>
</td>
</tr>
</tfoot>
</table>
</ng-container>
<ng-template #aucun>
<p class="vide" *ngIf="!chargement()">Aucune donnée à afficher.</p>
</ng-template>
</section>
@@ -0,0 +1,143 @@
.synthese-kms {
padding: 1.5rem;
}
h1 {
font-size: 1.4rem;
margin: 0;
color: var(--couleur-anthracite);
}
/* En-tête : titre à gauche, zone de critères à droite (comme le carnet d'adresses). */
.synthese-kms__entete {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.selection {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 1rem;
}
.champ {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.champ label {
font-weight: 600;
font-size: 0.9rem;
}
.champ select {
padding: 0.5rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
}
.btn-principal {
padding: 0.55rem 1.25rem;
background: var(--couleur-bleu);
color: #fff;
border: 1px solid var(--couleur-bleu);
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.btn-secondaire {
padding: 0.4rem 0.9rem;
background: #fff;
color: #1565c0;
border: 1px solid #1565c0;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
&:hover:not(:disabled) {
background: var(--couleur-orange);
border-color: var(--couleur-orange);
color: #fff;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.bareme {
margin: 0.5rem 0 1rem;
color: #333;
}
.actions {
display: flex;
justify-content: flex-end;
margin-bottom: 0.5rem;
}
table.synthese {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
th,
td {
padding: 0.5rem 0.6rem;
border-bottom: 1px solid #eee;
text-align: left;
}
th.num,
td.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
thead th {
background: #f5f5f5;
border-bottom: 1px solid #ccc;
}
td input {
width: 7rem;
padding: 0.35rem 0.4rem;
border: 1px solid #bbb;
border-radius: 4px;
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot .total {
font-weight: 700;
td {
border-top: 2px solid #ccc;
border-bottom: none;
}
}
}
.vide {
color: #666;
font-style: italic;
}
@@ -0,0 +1,310 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { NotificationService } from '../../core/services/notification.service';
import { ParametresApplicationService } from '../../core/services/parametres-application.service';
import { BaremeService } from '../../core/services/bareme.service';
import { RemboursementService } from '../../core/services/remboursement.service';
import { TrajetService } from '../../core/services/trajet.service';
import { VehiculeService } from '../../core/services/vehicule.service';
import { SelecteurTemporelComponent } from '../navigation-temporelle/selecteur-temporel.component';
import {
MontantPrevisionnel,
MontantRegle,
RegularisationAnnuelle,
Vehicule
} from '../../core/models';
/** Libellés des mois (index 0 = janvier). */
const NOMS_MOIS = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
/** Ligne mensuelle de la synthèse Kms. */
interface LigneSynthese {
mois: number;
libelle: string;
kmMois: number;
/** Remboursement réel propre au mois (km_du_mois × coef de la tranche annuelle). */
fraisReelMensuel: number;
/** Indication de versement 80% (prévisionnel mensuel), ou null si barème non configuré. */
previsionnel: number | null;
/** Montant réglé saisi (éditable). */
montantRegle: number | null;
/** Montant réglé initialement chargé, pour détecter les modifications. */
montantRegleInitial: number | null;
}
/**
* Page « Synthèse Kms » : regroupe sur un seul écran la consultation annuelle,
* la saisie des montants réglés et l'export PDF (mensuel par ligne, annuel pour
* la ligne TOTAL).
*
* Haut de page : sélecteur temporel en mode « année seule », sélecteur de
* véhicule, bouton « Afficher ». La restitution présente le barème appliqué puis
* un tableau d'une ligne par mois (km parcourus, remboursement réel du mois,
* indication 80%, montant réglé saisissable, export PDF mensuel) et une ligne
* TOTAL (total km, total réel, total réglé non éditable, export PDF annuel).
*
* Aucune nouvelle API : composition des endpoints existants
* (`getRegularisation`, `getMontantPrevisionnel`, `saveMontantRegle`,
* `exportPdfMensuel`, `exportPdfAnnuel`).
*/
@Component({
selector: 'app-synthese-kms',
standalone: true,
imports: [CommonModule, FormsModule, SelecteurTemporelComponent],
templateUrl: './synthese-kms.component.html',
styleUrl: './synthese-kms.component.scss'
})
export class SyntheseKmsComponent implements OnInit {
private readonly vehiculeService = inject(VehiculeService);
private readonly remboursementService = inject(RemboursementService);
private readonly baremeService = inject(BaremeService);
private readonly trajetService = inject(TrajetService);
private readonly notification = inject(NotificationService);
readonly parametresService = inject(ParametresApplicationService);
readonly anneeMin = 2000;
readonly anneeMax = 2100;
/** Liste des véhicules (pour le sélecteur). */
readonly vehicules = signal<Vehicule[]>([]);
/** Année choisie dans le sélecteur temporel (mode année seule). */
readonly annee = signal<number>(new Date().getFullYear());
/** Date ISO fournie au sélecteur temporel pour pré-sélectionner l'année. */
readonly dateSelecteur = signal<string>(`${new Date().getFullYear()}-01-01`);
/** Véhicule sélectionné. */
readonly vehiculeId = signal<number | null>(null);
/** Synthèse chargée (barème appliqué + totaux). */
readonly synthese = signal<RegularisationAnnuelle | null>(null);
/** Lignes mensuelles affichées. */
readonly lignes = signal<LigneSynthese[]>([]);
/** true tant qu'aucune synthèse n'est chargée (masque le tableau). */
readonly chargee = signal(false);
/** true pendant un appel réseau (chargement ou enregistrement). */
readonly chargement = signal(false);
/** Mois en cours d'export (pour désactiver le bouton concerné). null = aucun ; 0 = annuel. */
readonly exportEnCours = signal<number | null>(null);
ngOnInit(): void {
this.vehiculeService.getVehicules().subscribe({
next: (liste) => {
const vehicules = [...liste].sort((a, b) => b.dateEffet.localeCompare(a.dateEffet));
this.vehicules.set(vehicules);
if (vehicules.length) {
// Pré-sélection : véhicule courant (le plus récent) + année courante,
// puis affichage immédiat sans attendre une action de l'utilisateur.
this.vehiculeId.set(vehicules[0].id);
this.afficher();
}
},
error: () => this.notification.error('Impossible de charger les véhicules.')
});
}
/** Reçoit l'année choisie par le sélecteur temporel (mode année seule). */
onAnneeChoisie(annee: number): void {
this.annee.set(annee);
this.dateSelecteur.set(`${annee}-01-01`);
}
/** Charge la synthèse annuelle + les indications 80% pour (année, véhicule). */
afficher(): void {
const vehiculeId = this.vehiculeId();
if (vehiculeId === null) {
this.notification.error('Veuillez sélectionner un véhicule.');
return;
}
const annee = this.annee();
this.chargement.set(true);
const regularisation$ = this.remboursementService
.getRegularisation(annee, vehiculeId)
.pipe(catchError(() => of(null)));
const previsionnels$ = NOMS_MOIS.map((_, index) =>
this.baremeService
.getMontantPrevisionnel(annee, index + 1, vehiculeId)
.pipe(catchError(() => of<MontantPrevisionnel | null>(null)))
);
forkJoin({ reg: regularisation$, prev: forkJoin(previsionnels$) }).subscribe({
next: ({ reg, prev }) => {
this.chargement.set(false);
if (!reg) {
this.synthese.set(null);
this.lignes.set([]);
this.chargee.set(false);
this.notification.error('Impossible de charger la synthèse.');
return;
}
this.appliquerSynthese(reg, prev);
this.chargee.set(true);
},
error: () => {
this.chargement.set(false);
this.synthese.set(null);
this.lignes.set([]);
this.chargee.set(false);
this.notification.error('Impossible de charger la synthèse.');
}
});
}
/** Construit les 12 lignes à partir de la régularisation et des prévisionnels. */
private appliquerSynthese(
reg: RegularisationAnnuelle,
previsionnels: (MontantPrevisionnel | null)[]
): void {
const parMois = new Map(reg.mois.map((m) => [m.mois, m]));
this.lignes.set(NOMS_MOIS.map((libelle, index) => {
const mois = index + 1;
const ligneMois = parMois.get(mois);
const regle = ligneMois ? ligneMois.fraisPaye : 0;
return {
mois,
libelle,
kmMois: ligneMois ? ligneMois.kmMois : 0,
fraisReelMensuel: ligneMois ? ligneMois.fraisReelMensuel : 0,
previsionnel: previsionnels[index] ? previsionnels[index]!.montantPrevisionnelMensuel : null,
montantRegle: regle,
montantRegleInitial: regle
};
}));
this.synthese.set(reg);
}
/**
* Enregistre uniquement les mois dont le montant réglé a été modifié
* (par rapport à la valeur initialement chargée). Rejette les montants négatifs.
*/
enregistrer(): void {
const vehiculeId = this.vehiculeId();
if (vehiculeId === null || this.chargement()) {
return;
}
const annee = this.annee();
const modifiees = this.lignes().filter((l) => !this.memeMontant(l.montantRegle, l.montantRegleInitial));
if (modifiees.length === 0) {
this.notification.info('Aucune modification à enregistrer.');
return;
}
// Validation : aucun montant négatif.
for (const l of modifiees) {
if (l.montantRegle !== null && Number(l.montantRegle) < 0) {
this.notification.error(`Le montant réglé de ${l.libelle} ne peut pas être négatif.`);
return;
}
}
const requetes = modifiees.map((l) => {
const dto: MontantRegle = {
annee,
mois: l.mois,
vehiculeId,
montant: Number(l.montantRegle ?? 0)
};
return this.remboursementService.saveMontantRegle(dto);
});
this.chargement.set(true);
forkJoin(requetes).subscribe({
next: () => {
this.chargement.set(false);
this.notification.success(
`${modifiees.length} montant(s) enregistré(s).`
);
// Recharge pour rafraîchir les totaux et le remboursement réel.
this.afficher();
},
error: () => {
this.chargement.set(false);
this.notification.error("Échec de l'enregistrement des montants.");
}
});
}
/** Export PDF du rapport mensuel d'un mois donné. */
exporterMensuel(mois: number): void {
const vehiculeId = this.vehiculeId();
if (vehiculeId === null || this.exportEnCours() !== null) {
return;
}
const annee = this.annee();
this.exportEnCours.set(mois);
this.trajetService.exportPdfMensuel(annee, mois, vehiculeId).subscribe({
next: (reponse) => {
this.exportEnCours.set(null);
const mm = `${mois}`.padStart(2, '0');
const nom = this.trajetService.extraireNomFichier(reponse) ?? `${annee}${mm}_Trajets.pdf`;
this.declencherTelechargement(reponse.body!, nom);
},
error: () => {
this.exportEnCours.set(null);
this.notification.error('Échec du téléchargement du PDF mensuel.');
}
});
}
/** Export PDF du rapport annuel (ligne TOTAL). */
exporterAnnuel(): void {
const vehiculeId = this.vehiculeId();
if (vehiculeId === null || this.exportEnCours() !== null) {
return;
}
const annee = this.annee();
this.exportEnCours.set(0);
this.trajetService.exportPdfAnnuel(annee, vehiculeId).subscribe({
next: (reponse) => {
this.exportEnCours.set(null);
const nom = this.trajetService.extraireNomFichier(reponse) ?? `${annee}_Trajets.pdf`;
this.declencherTelechargement(reponse.body!, nom);
},
error: () => {
this.exportEnCours.set(null);
this.notification.error('Échec du téléchargement du PDF annuel.');
}
});
}
/** Chaîne de format `number` basée sur la précision courante des distances. */
formatKm(): string {
const precision = this.parametresService.precisionDistance();
return `1.${precision}-${precision}`;
}
/** Deux montants (nullable) sont-ils égaux ? (null et 0 considérés distincts). */
private memeMontant(a: number | null, b: number | null): boolean {
if (a === null || b === null) {
return a === b;
}
return Number(a) === Number(b);
}
/** Crée une URL d'objet et déclenche le téléchargement via une ancre temporaire. */
private declencherTelechargement(blob: Blob, nomFichier: string): void {
const url = URL.createObjectURL(blob);
const ancre = document.createElement('a');
ancre.href = url;
ancre.download = nomFichier;
document.body.appendChild(ancre);
ancre.click();
document.body.removeChild(ancre);
URL.revokeObjectURL(url);
}
}
@@ -63,28 +63,6 @@
⠿ ⠿
</button> </button>
<!-- Colonne dédiée A/R (largeur fixe) : garde date/adresses alignées. -->
<div class="trajet__ar-col" aria-hidden="false">
@if (trajet.allerRetour) {
<span class="trajet__ar" title="Aller/Retour : distance comptée doublée">A/R</span>
}
</div>
<div class="trajet__infos">
@if (trajet.commentaire) {
<div class="trajet__ligne">
<span class="trajet__commentaire">{{ trajet.commentaire }}</span>
</div>
}
<div class="trajet__adresses">
<span class="trajet__depart">{{ trajet.adresseDepart }}</span>
<span class="trajet__fleche" aria-hidden="true">→</span>
<span class="trajet__arrivee">{{ trajet.adresseArrivee }}</span>
</div>
</div>
<div class="trajet__distance">{{ distanceAffichee(trajet) }}</div>
<div class="trajet__actions"> <div class="trajet__actions">
<div class="trajet__actions-ligne"> <div class="trajet__actions-ligne">
<button <button
@@ -133,6 +111,29 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Colonne dédiée A/R (largeur fixe) : garde date/adresses alignées. -->
<div class="trajet__ar-col" aria-hidden="false">
@if (trajet.allerRetour) {
<span class="trajet__ar" title="Aller/Retour : distance comptée doublée">A/R</span>
}
</div>
<div class="trajet__infos">
@if (trajet.commentaire) {
<div class="trajet__ligne">
<span class="trajet__commentaire">{{ trajet.commentaire }}</span>
</div>
}
<div class="trajet__adresses">
<span class="trajet__depart">{{ trajet.adresseDepart }}</span>
<span class="trajet__fleche" aria-hidden="true">→</span>
<span class="trajet__arrivee">{{ trajet.adresseArrivee }}</span>
</div>
</div>
<div class="trajet__distance">{{ distanceAffichee(trajet) }}</div>
</li> </li>
} }
</ul> </ul>