Refonte export PDF, colonne commentaire et UX résumés

- Export PDF: nouveau nommage AAAAMM/AAAA_Trajets_MATRICULE_PLAQUE.pdf,
  logo carré embarqué en en-tête, km formatés selon Precision_Distance,
  restructuration en-têtes (Domicile déplacé, période/année fusionnées
  au titre)
- Correction bug: le nom de fichier suggéré au téléchargement ne
  remontait pas au frontend (CORS WithExposedHeaders manquant +
  composants résumé ne lisaient pas Content-Disposition)
- Résumé mensuel: colonne Commentaire, sélecteur temporel unifié
  (auto-chargement), km selon précision
- Résumé annuel: bouton Export PDF repositionné, km selon précision
- Panneau totaux: renommage "Remboursement indicatif" + infobulles
  d'aide (icône) au lieu du texte permanent
- Ajout de PdfServiceIntegrationTests (3 tests), 65/65 tests backend OK
- Mise à jour requirements.md et design.md en conséquence
This commit is contained in:
2026-09-28 08:58:23 +02:00
parent 9db3c91471
commit 61298d6384
17 changed files with 614 additions and 266 deletions
@@ -1,4 +1,4 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
@@ -77,22 +77,46 @@ export class TrajetService {
});
}
/** Export PDF mensuel (GET /api/exports/mensuel/{annee}/{mois}). */
exportPdfMensuel(annee: number, mois: number, vehiculeId?: number): Observable<Blob> {
/**
* Export PDF mensuel (GET /api/exports/mensuel/{annee}/{mois}). Réponse complète
* (et non le seul Blob) afin que l'appelant puisse lire le nom de fichier suggéré
* par le backend depuis l'en-tête `Content-Disposition`.
*/
exportPdfMensuel(annee: number, mois: number, vehiculeId?: number): Observable<HttpResponse<Blob>> {
return this.http.get(`${this.baseUrl}/exports/mensuel/${annee}/${mois}`, {
params: this.vehiculeParams(vehiculeId),
responseType: 'blob'
responseType: 'blob',
observe: 'response'
});
}
/** Export PDF annuel (GET /api/exports/annuel/{annee}). */
exportPdfAnnuel(annee: number, vehiculeId?: number): Observable<Blob> {
/**
* Export PDF annuel (GET /api/exports/annuel/{annee}). Réponse complète (et non le
* seul Blob) afin que l'appelant puisse lire le nom de fichier suggéré par le backend
* depuis l'en-tête `Content-Disposition`.
*/
exportPdfAnnuel(annee: number, vehiculeId?: number): Observable<HttpResponse<Blob>> {
return this.http.get(`${this.baseUrl}/exports/annuel/${annee}`, {
params: this.vehiculeParams(vehiculeId),
responseType: 'blob'
responseType: 'blob',
observe: 'response'
});
}
/**
* Extrait le nom de fichier suggéré par le backend depuis l'en-tête
* `Content-Disposition: attachment; filename="..."` d'une réponse HTTP.
* Retourne `null` si l'en-tête est absent ou ne contient pas de nom de fichier.
*/
extraireNomFichier(reponse: HttpResponse<Blob>): string | null {
const entete = reponse.headers.get('Content-Disposition');
if (!entete) {
return null;
}
const correspondance = /filename\*?=(?:UTF-8'')?"?([^;"]+)"?/i.exec(entete);
return correspondance ? decodeURIComponent(correspondance[1]) : null;
}
/**
* Trajets d'une journée avec géométries pour la carte
* (GET /api/trajets/journee/{date}). La date est au format ISO (yyyy-MM-dd).
@@ -24,32 +24,46 @@
<div class="panneau-totaux__previsionnel">
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
<span class="panneau-totaux__previsionnel-libelle">
Indication de versement (80%)
Remboursement indicatif
<button
type="button"
class="panneau-totaux__aide"
aria-label="En savoir plus sur le remboursement indicatif"
title="Simple indication de versement, calculée à 80% du barème sur le mois affiché. Le montant réellement versé est le montant réglé que vous saisissez."
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</button>
</span>
<span class="panneau-totaux__previsionnel-montant">
{{ montantPrevisionnel.montantPrevisionnelMensuel | number: '1.2-2' }} €
</span>
</div>
<p class="panneau-totaux__previsionnel-note">
Simple indication de versement, calculée à 80% du barème sur le mois
affiché. Le montant réellement versé est le montant réglé que vous
saisissez.
</p>
</div>
<div class="panneau-totaux__previsionnel">
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
<span class="panneau-totaux__previsionnel-libelle">
Montant prévisionnel annuel
<button
type="button"
class="panneau-totaux__aide"
aria-label="En savoir plus sur le montant prévisionnel annuel"
title="Projection à 100% du barème sur le total annuel partiel, sans minoration. Cette projection ne se substitue pas au montant réel calculé en fin d'année."
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</button>
</span>
<span class="panneau-totaux__previsionnel-montant">
{{ montantPrevisionnel.montantPrevisionnelAnnuel | number: '1.2-2' }} €
</span>
</div>
<p class="panneau-totaux__previsionnel-note">
Projection à 100% du barème sur le total annuel partiel, sans
minoration. Cette projection ne se substitue pas au montant réel
calculé en fin d'année.
</p>
</div>
}
</section>
@@ -13,6 +13,8 @@
margin: 0 0 12px;
font-size: 1.05rem;
color: #333;
text-align: center;
text-transform: uppercase;
}
&__erreur {
@@ -67,17 +69,32 @@
padding-top: 12px;
border-top: 1px solid #e0e0e0;
&-libelle {
display: inline-flex;
align-items: center;
gap: 6px;
}
&-montant {
font-variant-numeric: tabular-nums;
color: #1565c0;
}
}
&-note {
margin: 8px 0 0;
font-size: 0.8rem;
color: #777;
font-style: italic;
line-height: 1.35;
&__aide {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: #888;
cursor: help;
line-height: 0;
&:hover,
&:focus-visible {
color: #1565c0;
}
}
}
@@ -2,22 +2,25 @@
<h1>Résumé annuel</h1>
<form class="selecteurs" (ngSubmit)="charger()">
<div class="champ">
<label for="annee">Année</label>
<input
id="annee"
name="annee"
type="number"
[(ngModel)]="annee"
[min]="anneeMin"
[max]="anneeMax"
/>
</div>
<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>
<div class="actions">
<button type="submit" class="btn-principal" [disabled]="chargement()">
Afficher
</button>
</div>
<div class="actions">
<button
type="button"
class="btn-secondaire"
@@ -43,14 +46,14 @@
@for (ligne of lignes(); track ligne.mois) {
<tr>
<td>{{ ligne.libelle }}</td>
<td class="num">{{ ligne.totalKm | number: '1.2-2' }}</td>
<td class="num">{{ ligne.totalKm | number: formatKm() }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total annuel</td>
<td class="num total">{{ totalAnnuel() | number: '1.2-2' }} km</td>
<td class="num total">{{ totalAnnuel() | number: formatKm() }} km</td>
</tr>
</tfoot>
</table>
@@ -13,10 +13,18 @@ h1 {
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;
@@ -3,6 +3,7 @@ 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';
@@ -49,6 +50,7 @@ export interface LigneAnnuelle {
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;
@@ -104,7 +106,11 @@ export class ResumeAnnuelComponent implements OnInit {
});
}
/** Déclenche l'export PDF annuel et le téléchargement navigateur (Exigences 4.6, 7.3). */
/**
* 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;
@@ -112,9 +118,10 @@ export class ResumeAnnuelComponent implements OnInit {
this.export.set(true);
this.trajetService.exportPdfAnnuel(this.annee).subscribe({
next: (blob) => {
next: (reponse) => {
this.export.set(false);
this.declencherTelechargement(blob, `trajets_${this.annee}.pdf`);
const nom = this.trajetService.extraireNomFichier(reponse) ?? `${this.annee}_Trajets.pdf`;
this.declencherTelechargement(reponse.body!, nom);
},
error: () => {
this.export.set(false);
@@ -156,6 +163,15 @@ export class ResumeAnnuelComponent implements OnInit {
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 (
@@ -1,32 +1,13 @@
<section class="resume-mensuel">
<h1>Résumé mensuel</h1>
<form class="selecteurs" (ngSubmit)="charger()">
<div class="champ">
<label for="mois">Mois</label>
<select id="mois" name="mois" [(ngModel)]="mois">
@for (m of moisDisponibles; track m) {
<option [ngValue]="m">{{ m }}</option>
}
</select>
</div>
<div class="champ">
<label for="annee">Année</label>
<input
id="annee"
name="annee"
type="number"
[(ngModel)]="annee"
[min]="anneeMin"
[max]="anneeMax"
/>
</div>
<div class="selecteurs">
<app-selecteur-temporel
[date]="dateSelecteur"
(moisSelectionne)="onMoisSelectionne($event)"
></app-selecteur-temporel>
<div class="actions">
<button type="submit" class="btn-principal" [disabled]="chargement()">
Afficher
</button>
<button
type="button"
class="btn-secondaire"
@@ -36,7 +17,7 @@
Export PDF mensuel
</button>
</div>
</form>
</div>
@if (chargement()) {
<p class="info">Chargement du résumé…</p>
@@ -45,6 +26,7 @@
<thead>
<tr>
<th>Date</th>
<th>Commentaire</th>
<th>Départ</th>
<th>Arrivée</th>
<th class="num">Distance (km)</th>
@@ -54,22 +36,23 @@
@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: '1.2-2') : '—' }}
{{ trajet.distance != null ? (trajet.distance | number: formatKm()) : '—' }}
</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="vide">Aucun trajet pour ce mois.</td>
<td colspan="5" class="vide">Aucun trajet pour ce mois.</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">Total mensuel</td>
<td class="num total">{{ resume.totalKm | number: '1.2-2' }} km</td>
<td colspan="4">Total mensuel</td>
<td class="num total">{{ resume.totalKm | number: formatKm() }} km</td>
</tr>
</tfoot>
</table>
@@ -12,29 +12,12 @@ h1 {
.selecteurs {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.champ {
display: flex;
flex-direction: column;
}
.champ label {
font-weight: 600;
margin-bottom: 0.25rem;
}
.champ select,
.champ input {
padding: 0.5rem;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 0.95rem;
}
.actions {
display: flex;
gap: 0.75rem;
@@ -3,8 +3,10 @@ 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).
@@ -22,25 +24,45 @@ import { ResumeMensuel } from '../../core/models';
@Component({
selector: 'app-resume-mensuel',
standalone: true,
imports: [CommonModule, FormsModule],
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;
/** Liste des mois pour le sélecteur (1–12). */
readonly moisDisponibles = Array.from({ length: 12 }, (_, i) => i + 1);
/** 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);
@@ -76,7 +98,11 @@ export class ResumeMensuelComponent implements OnInit {
});
}
/** Déclenche l'export PDF mensuel et le téléchargement navigateur (Exigence 7.3). */
/**
* 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;
@@ -84,10 +110,10 @@ export class ResumeMensuelComponent implements OnInit {
this.export.set(true);
this.trajetService.exportPdfMensuel(this.annee, this.mois).subscribe({
next: (blob) => {
next: (reponse) => {
this.export.set(false);
const nom = this.nomFichierParDefaut();
this.declencherTelechargement(blob, nom);
const nom = this.trajetService.extraireNomFichier(reponse) ?? this.nomFichierParDefaut();
this.declencherTelechargement(reponse.body!, nom);
},
error: () => {
this.export.set(false);
@@ -96,10 +122,10 @@ export class ResumeMensuelComponent implements OnInit {
});
}
/** Nom de fichier de repli au format `trajets_MM_AAAA.pdf`. */
/** 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 `trajets_${mm}_${this.annee}.pdf`;
return `${this.annee}${mm}_Trajets.pdf`;
}
/**