É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:
@@ -13,14 +13,8 @@
|
||||
<a class="nav-bar__lien" routerLink="/carnet-adresses" routerLinkActive="nav-bar__lien--actif">
|
||||
Carnet d'adresses
|
||||
</a>
|
||||
<a class="nav-bar__lien" routerLink="/montants-regles" routerLinkActive="nav-bar__lien--actif">
|
||||
Montants réglés
|
||||
</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 class="nav-bar__lien" routerLink="/synthese-kms" routerLinkActive="nav-bar__lien--actif">
|
||||
Synthèse Kms
|
||||
</a>
|
||||
|
||||
<!-- Menu « Specs » : sous-menu (Exigences / Conception technique / Plan de tâches)
|
||||
|
||||
@@ -9,14 +9,12 @@ import { ResetPasswordComponent } from './features/auth/reset-password/reset-pas
|
||||
import { BaremeComponent } from './features/bareme/bareme.component';
|
||||
import { CarnetAdressesComponent } from './features/carnet-adresses/carnet-adresses.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 { OnboardingComponent } from './features/onboarding/onboarding.component';
|
||||
import { ParametrageComponent } from './features/parametrage/parametrage.component';
|
||||
import { ParametresApplicationComponent } from './features/parametres-application/parametres-application.component';
|
||||
import { ProfilComponent } from './features/profil/profil.component';
|
||||
import { ResumeAnnuelComponent } from './features/resume-annuel/resume-annuel.component';
|
||||
import { ResumeMensuelComponent } from './features/resume-mensuel/resume-mensuel.component';
|
||||
import { SyntheseKmsComponent } from './features/synthese-kms/synthese-kms.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
|
||||
* `profilConfigureGuard`.
|
||||
* - Routes de données PROTEGEES par `[authGuard, profilConfigureGuard]` :
|
||||
* `consultation`, `carnet-adresses`, `resume-mensuel`, `resume-annuel`,
|
||||
* `montants-regles`. L'authentification est contrôlée d'abord, puis la
|
||||
* `consultation`, `carnet-adresses`, `synthese-kms`. L'authentification est
|
||||
* contrôlée d'abord, puis la
|
||||
* configuration du profil (redirection vers `/onboarding` si `estConfigure = false`).
|
||||
*
|
||||
* Redirections : `''` -> `consultation`, `parametrage` -> `parametrage/compte`,
|
||||
@@ -86,9 +84,7 @@ export const routes: Routes = [
|
||||
// Routes de données PROTEGEES : authGuard d'abord, puis profilConfigureGuard
|
||||
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] },
|
||||
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [authGuard, profilConfigureGuard] },
|
||||
{ path: 'resume-mensuel', component: ResumeMensuelComponent, canActivate: [authGuard, profilConfigureGuard] },
|
||||
{ path: 'resume-annuel', component: ResumeAnnuelComponent, canActivate: [authGuard, profilConfigureGuard] },
|
||||
{ path: 'montants-regles', component: MontantsReglesComponent, canActivate: [authGuard, profilConfigureGuard] },
|
||||
{ path: 'synthese-kms', component: SyntheseKmsComponent, canActivate: [authGuard, profilConfigureGuard] },
|
||||
|
||||
{ path: '**', redirectTo: 'consultation' }
|
||||
];
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
.carnet {
|
||||
padding: 1.5rem;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
min-height: 0;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
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()"
|
||||
(click)="basculerOuverture()">
|
||||
<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) {
|
||||
<span class="selecteur-entete__selection">{{ libelleMoisAnnee }}</span>
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
class="selecteur-groupe"
|
||||
role="group"
|
||||
aria-label="Sélection du mois"
|
||||
*ngIf="anneeSelectionnee() !== null">
|
||||
*ngIf="!modeAnneeSeule && anneeSelectionnee() !== null">
|
||||
<span class="selecteur-titre">Mois</span>
|
||||
<div class="selecteur-boutons">
|
||||
<button
|
||||
|
||||
@@ -55,9 +55,19 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
|
||||
*/
|
||||
@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>();
|
||||
|
||||
/** É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). */
|
||||
readonly nomsMois = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
@@ -87,6 +97,9 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
|
||||
*/
|
||||
get libelleMoisAnnee(): string {
|
||||
const annee = this.anneeSelectionnee();
|
||||
if (this.modeAnneeSeule) {
|
||||
return annee == null ? '' : `${annee}`;
|
||||
}
|
||||
const mois = this.moisSelectionneSignal();
|
||||
if (annee == null || mois == null) {
|
||||
return '';
|
||||
@@ -94,6 +107,11 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
|
||||
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. */
|
||||
basculerOuverture(): void {
|
||||
this.ouvert.update((v) => !v);
|
||||
@@ -158,6 +176,14 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
|
||||
this.moisSelectionneSignal.set(null);
|
||||
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.moisAvecDonnees.set(new Set<number>(mois));
|
||||
});
|
||||
@@ -171,6 +197,8 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
|
||||
}
|
||||
this.moisSelectionneSignal.set(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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 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-ligne">
|
||||
<button
|
||||
@@ -133,6 +111,29 @@
|
||||
</button>
|
||||
</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>
|
||||
}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user