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:
@@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user