SchotzIAD KM Tracker : suivi des trajets kilométriques (Angular + .NET 8)

Application complète de saisie et de suivi des trajets kilométriques avec
calcul de distance routière (OpenRouteService), barème kilométrique, résumés
et régularisation annuelle.

Backend (ASP.NET Core .NET 8, EF Core, SQLite) :
- Trajets (CRUD, réordonnancement, insertion), calcul de distance ORS avec
  fallback Haversine, géométrie d'itinéraire stockée par trajet, recalcul en masse.
- Profil, véhicules (avec dénomination), carnet d'adresses nommées, cache d'adresses.
- Barème kilométrique : tranches multiples par puissance fiscale, borne max
  exclusive avec 'illimité' (KmMax nullable), coefficient à 3 décimales,
  recopie du dernier barème connu.
- Résumés mensuel/annuel, montants réglés, régularisation, export PDF.

Frontend (Angular 22, zoneless + signals) :
- Consultation par jour, navigation temporelle synchronisée, carte Leaflet,
  formulaire de trajet (adresses nommées, domicile, autocomplétion BAN).
- Détection de changement zoneless (indépendante de zone.js).

Hébergement : Docker Compose (nginx + backend), scripts run-dev.sh / run-docker.sh.
This commit is contained in:
2026-09-14 02:08:27 +02:00
commit e3f946f7d4
225 changed files with 38168 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
<!-- Coquille applicative : barre de navigation globale + contenu routé. -->
<nav class="nav-bar" aria-label="Navigation principale">
<a class="nav-bar__lien" routerLink="/consultation" routerLinkActive="nav-bar__lien--actif">
Trajets
</a>
<a class="nav-bar__lien" routerLink="/carnet-adresses" routerLinkActive="nav-bar__lien--actif">
Carnet d'adresses
</a>
<a class="nav-bar__lien" routerLink="/profil" routerLinkActive="nav-bar__lien--actif">
Profil
</a>
<a class="nav-bar__lien" routerLink="/vehicules" routerLinkActive="nav-bar__lien--actif">
Véhicules
</a>
<a class="nav-bar__lien" routerLink="/bareme" routerLinkActive="nav-bar__lien--actif">
Barème
</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>
</nav>
<main>
<router-outlet></router-outlet>
</main>
+32
View File
@@ -0,0 +1,32 @@
.nav-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
background: #1f2937;
border-bottom: 1px solid #111827;
position: sticky;
top: 0;
z-index: 500;
&__lien {
padding: 0.5rem 0.9rem;
border-radius: 6px;
color: #cbd5e1;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
&:hover {
background: #374151;
color: #fff;
}
&--actif {
background: #2b6cb0;
color: #fff;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app.component';
import { routes } from './app.routes';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [provideRouter(routes)]
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'trajet-km-tracker' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('trajet-km-tracker');
});
it('should render a router outlet', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('router-outlet')).not.toBeNull();
});
});
+13
View File
@@ -0,0 +1,13 @@
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {
title = 'trajet-km-tracker';
}
+19
View File
@@ -0,0 +1,19 @@
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { timeoutInterceptor } from './core/interceptors/timeout.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
// Détection de changement SANS zone.js (zoneless). L'état affiché est exposé via
// des signals Angular, qui déclenchent le rendu automatiquement. Décision prise
// car le dev-server (Vite) chargeait zone.js comme module pré-bundlé séparé, ce
// qui empêchait la détection de changement de se déclencher sur les réponses HTTP.
provideZonelessChangeDetection(),
provideRouter(routes),
// TimeoutInterceptor : timeout 10s + gestion des erreurs réseau (Exigence 1.9).
provideHttpClient(withInterceptors([timeoutInterceptor]))
]
};
+46
View File
@@ -0,0 +1,46 @@
import { Routes } from '@angular/router';
import { profilConfigureGuard } from './core/guards/profil-configure.guard';
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 { OnboardingComponent } from './features/onboarding/onboarding.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 { VehiculesComponent } from './features/vehicules/vehicules.component';
/**
* Routes de l'application (équivalent standalone de `app-routing.module.ts`).
*
* Placeholders pour le socle :
* - `/consultation` : vue principale de consultation des trajets (tâche 16.4).
* - `/onboarding` : parcours de première utilisation (tâche 21.1), accessible sans garde.
*
* La garde `profilConfigureGuard` (tâche 21.2) protège l'accès à la saisie /
* consultation des trajets et aux résumés tant que le profil n'est pas configuré.
*
* Décision de routage :
* - Routes protégées par la garde (bloquées si `estConfigure = false`, redirection
* vers `/onboarding`) : `consultation`, `carnet-adresses`, `resume-mensuel`,
* `resume-annuel`, `bareme`, `montants-regles`.
* - Routes laissées accessibles SANS garde pour permettre à l'utilisateur de terminer
* sa configuration : `onboarding` (toujours atteignable), `profil` et `vehicules`.
* Appliquer la garde à `/profil` / `/vehicules` risquerait de piéger un utilisateur
* partiellement configuré ; la garde ne vise que la saisie/consultation des trajets
* et les rapports (voir design « ProfilConfigureGuard »).
*/
export const routes: Routes = [
{ path: '', redirectTo: 'consultation', pathMatch: 'full' },
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [profilConfigureGuard] },
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [profilConfigureGuard] },
{ path: 'profil', component: ProfilComponent },
{ path: 'vehicules', component: VehiculesComponent },
{ path: 'resume-mensuel', component: ResumeMensuelComponent, canActivate: [profilConfigureGuard] },
{ path: 'resume-annuel', component: ResumeAnnuelComponent, canActivate: [profilConfigureGuard] },
{ path: 'bareme', component: BaremeComponent, canActivate: [profilConfigureGuard] },
{ path: 'montants-regles', component: MontantsReglesComponent, canActivate: [profilConfigureGuard] },
{ path: 'onboarding', component: OnboardingComponent },
{ path: '**', redirectTo: 'consultation' }
];
@@ -0,0 +1,43 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { NotificationService } from '../services/notification.service';
import { ProfilService } from '../services/profil.service';
/**
* Garde de route `CanActivateFn` protégeant l'accès à la saisie / consultation des trajets
* tant que le profil n'est pas configuré (onboarding première utilisation).
*
* Comportement (Exigences 16.3, 16.4, 16.11, 16.13) :
* - Appelle `ProfilService.getEtat()` (`GET /api/profil/etat`) à l'activation d'une route protégée.
* - `estConfigure === true` → navigation autorisée (retourne `true`). (Exigence 16.11)
* - `estConfigure === false` → redirection vers `/onboarding` (retourne un `UrlTree`) et
* blocage de l'accès aux trajets / consultation tant que le profil n'est pas configuré.
* (Exigences 16.3, 16.4)
* - En cas d'échec de récupération de l'indicateur (backend injoignable / erreur réseau),
* la garde n'affiche PAS la vue principale : elle notifie une erreur de connexion via
* `NotificationService` (avec une invitation à réessayer) et bloque la navigation en
* retournant `false`, plutôt que de rediriger arbitrairement vers la vue principale.
* L'utilisateur peut relancer la vérification en réessayant la navigation. (Exigence 16.13)
*/
export const profilConfigureGuard: CanActivateFn = (): Observable<boolean | UrlTree> => {
const profilService = inject(ProfilService);
const router = inject(Router);
const notifications = inject(NotificationService);
return profilService.getEtat().pipe(
map((etat) =>
etat.estConfigure ? true : router.createUrlTree(['/onboarding'])
),
catchError(() => {
// Backend injoignable / erreur réseau : ne pas afficher la vue principale.
// Notifier l'erreur de connexion avec une invitation à réessayer, puis bloquer.
notifications.error(
'Impossible de vérifier la configuration du profil (connexion au serveur indisponible). Veuillez réessayer.'
);
return of(false);
})
);
};
@@ -0,0 +1,61 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { TimeoutError, throwError, timeout } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import { NotificationService } from '../services/notification.service';
/** Message affiché lorsque le serveur ne répond pas dans les temps (Exigence 1.9). */
export const SERVEUR_INDISPONIBLE_MESSAGE =
'Le serveur est indisponible. Veuillez réessayer. Vos données saisies ont été conservées.';
/**
* Erreur applicative levée lorsque le backend est injoignable ou trop lent.
* Les composants peuvent la détecter (`error instanceof ServeurIndisponibleError`)
* pour conserver les données du formulaire (Exigence 1.9).
*/
export class ServeurIndisponibleError extends Error {
constructor(
message: string = SERVEUR_INDISPONIBLE_MESSAGE,
readonly origine?: unknown
) {
super(message);
this.name = 'ServeurIndisponibleError';
}
}
/**
* Intercepteur HTTP fonctionnel :
* - applique un timeout de 10 secondes (Exigence 1.9) à chaque requête vers l'API backend ;
* - transforme un dépassement de délai ou une erreur réseau (statut 0) en
* `ServeurIndisponibleError` et affiche une notification d'indisponibilité.
*
* Les appels externes (ex. autocomplétion BAN) ne sont pas soumis au timeout API
* afin de ne pas déclencher le message d'indisponibilité du backend pour un service tiers.
*/
export const timeoutInterceptor: HttpInterceptorFn = (req, next) => {
const notifications = inject(NotificationService);
// On ne borne que les appels vers notre API backend.
const cibleApi = req.url.startsWith(environment.apiUrl) || req.url.includes('/api/');
if (!cibleApi) {
return next(req);
}
return next(req).pipe(
timeout(environment.httpTimeoutMs),
catchError((error: unknown) => {
// Timeout (10s dépassées) ou erreur réseau (statut 0 = pas de réponse serveur).
const estTimeout = error instanceof TimeoutError;
const estReseau = error instanceof HttpErrorResponse && error.status === 0;
if (estTimeout || estReseau) {
notifications.error(SERVEUR_INDISPONIBLE_MESSAGE);
return throwError(() => new ServeurIndisponibleError(SERVEUR_INDISPONIBLE_MESSAGE, error));
}
return throwError(() => error);
})
);
};
@@ -0,0 +1,59 @@
// Modèles Adresses — miroir de AdresseDtos.cs + réponse BAN.
/** Résultat de recherche BAN proxifié via le backend — miroir de BANAdresseDto. */
export interface BANAdresse {
label: string;
latitude: number;
longitude: number;
postcode: string;
city: string;
}
/** Suggestion d'autocomplétion issue du cache local — miroir de AdresseCacheDto. */
export interface AdresseCache {
adresse: string;
latitude: number;
longitude: number;
}
/** Adresse nommée du carnet d'adresses — miroir de AdresseNommeeDto. */
export interface AdresseNommee {
id: number;
denomination: string;
adresse: string;
latitude: number | null; // null si le géocodage a échoué
longitude: number | null; // null si le géocodage a échoué
}
/** Création / mise à jour d'une adresse nommée — miroir de SaveAdresseNommeeDto. */
export interface SaveAdresseNommee {
denomination: string; // 1 à 100 car., unique après normalisation
adresse: string; // 1 à 255 car.
latitude?: number | null; // fournies par le frontend si sélection BAN
longitude?: number | null;
}
/**
* Réponse brute de l'API Base Adresse Nationale (Feature GeoJSON).
* Source primaire d'autocomplétion appelée directement par le frontend.
*/
export interface BANFeature {
properties: {
label: string; // libellé complet normalisé
score: number; // score de pertinence 0–1
postcode: string;
city: string;
context: string;
type: string; // 'housenumber' | 'street' | 'locality' | 'municipality'
x: number; // longitude
y: number; // latitude
};
geometry: {
coordinates: [number, number]; // [longitude, latitude]
};
}
/** Enveloppe de la réponse BAN (FeatureCollection GeoJSON). */
export interface BANFeatureCollection {
features: BANFeature[];
}
@@ -0,0 +1,64 @@
// Modèles Barème & montants — miroir de BaremeDtos.cs.
/** Tranche de barème kilométrique — miroir de TrancheBaremeDto. */
export interface TrancheBareme {
id: number;
annee: number;
puissanceFiscale: number;
kmMin: number;
kmMax: number | null; // null = illimité (borne max exclusive : [kmMin, kmMax[)
coefficient: number;
base: number;
}
/** Création / mise à jour d'une tranche — miroir de SaveTrancheBaremeDto. */
export interface SaveTrancheBareme {
annee: number;
puissanceFiscale: number;
kmMin: number;
kmMax: number | null; // null = illimité
coefficient: number;
base: number;
}
/** Montant réglé pour une période et un véhicule — miroir de MontantRegleDto. */
export interface MontantRegle {
annee: number;
mois: number;
vehiculeId: number;
montant: number;
}
/** Indication de versement mensuel prévisionnel — miroir de MontantPrevisionnelDto. */
export interface MontantPrevisionnel {
annee: number;
mois: number;
vehiculeId: number;
totalAnnuelPartielKm: number;
montantBaremePlein: number; // barème plein sur le total partiel
montantPrevisionnel: number; // = 80% (indicatif uniquement)
}
/** Ligne mensuelle de la synthèse annuelle — miroir de LigneSyntheseMoisDto. */
export interface LigneSyntheseMois {
mois: number;
kmMois: number;
fraisPaye: number; // Montant_Regle du mois (saisi)
fraisReelMensuel: number; // km_du_mois × coefficient de la tranche annuelle
}
/** Synthèse annuelle de régularisation — miroir de RegularisationAnnuelleDto. */
export interface RegularisationAnnuelle {
annee: number;
vehiculeId: number;
plaque: string;
puissanceFiscale: number;
coefficientTranche: number;
baseTranche: number;
trancheLibelle: string;
mois: LigneSyntheseMois[]; // 12 lignes
totalKmAnnuel: number;
totalPaye: number; // somme des Montant_Regle
montantReel: number; // barème plein : total_km × coef + base
regularisation: number; // montantReel − totalPaye
}
+9
View File
@@ -0,0 +1,9 @@
// Barrel d'export des modèles du domaine (miroir des DTOs backend).
export * from './trajet.model';
export * from './resume-mensuel.model';
export * from './resume-annuel.model';
export * from './totaux.model';
export * from './vehicule.model';
export * from './profil.model';
export * from './bareme.model';
export * from './adresse.model';
@@ -0,0 +1,27 @@
// Modèles Profil — miroir de ProfilDtos.cs.
/** Profil — miroir de ProfilDto. */
export interface Profil {
nom: string | null;
prenom: string | null;
matricule: string | null;
adresseDomicile: string | null;
domicileGeocode: boolean;
}
/** Création / mise à jour du profil — miroir de SaveProfilDto. */
export interface SaveProfil {
nom?: string | null;
prenom?: string | null;
matricule?: string | null; // max 8 car.
adresseDomicile: string;
}
/**
* État de configuration du profil (onboarding première utilisation).
* Miroir de EtatProfilDto.
* estConfigure = true ssi Profil.Nom et Profil.Prenom non vides ET au moins un Vehicule existe.
*/
export interface EtatProfil {
estConfigure: boolean;
}
@@ -0,0 +1,8 @@
import { ResumeMensuel } from './resume-mensuel.model';
/** Résumé annuel — miroir de ResumeAnnuelDto. */
export interface ResumeAnnuel {
annee: number;
mois: ResumeMensuel[]; // toujours 12 éléments
totalKm: number;
}
@@ -0,0 +1,9 @@
import { Trajet } from './trajet.model';
/** Résumé mensuel — miroir de ResumeMensuelDto. */
export interface ResumeMensuel {
annee: number;
mois: number;
trajets: Trajet[];
totalKm: number; // somme des distances, 2 décimales
}
@@ -0,0 +1,8 @@
/** Totaux affichés dans le Panneau_Totaux — miroir de TotauxDto. */
export interface Totaux {
date: string; // ISO date
vehiculeId: number; // véhicule sur lequel portent les totaux
totalJour: number;
totalMois: number;
totalAnnuelPartiel: number; // cumul de l'année jusqu'au mois inclus
}
@@ -0,0 +1,73 @@
// Modèles Trajet — miroir des DTOs backend (TrajetDtos.cs).
/**
* Création d'un Trajet.
* Les coordonnées sont transmises par le Frontend (issues de la sélection BAN) ;
* elles sont nulles si l'adresse a été saisie manuellement sans autocomplétion.
*/
export interface CreateTrajet {
date: string; // JJ/MM/AAAA en entrée
adresseDepart: string;
departLatitude?: number | null;
departLongitude?: number | null;
adresseArrivee: string;
arriveeLatitude?: number | null;
arriveeLongitude?: number | null;
commentaire?: string | null; // optionnel, max 500 car.
vehiculeId?: number | null; // null -> véhicule courant à la date du trajet
}
/** Mise à jour d'un Trajet. */
export interface UpdateTrajet {
date: string;
adresseDepart: string;
departLatitude?: number | null;
departLongitude?: number | null;
adresseArrivee: string;
arriveeLatitude?: number | null;
arriveeLongitude?: number | null;
commentaire?: string | null;
vehiculeId?: number | null;
}
/** Réponse Trajet. */
export interface Trajet {
id: number;
date: string; // ISO date (yyyy-MM-dd) renvoyée par l'API
adresseDepart: string;
adresseArrivee: string;
distance: number | null; // km, 2 décimales ; null si non calculable (Exigence 2.4)
commentaire: string | null;
position: number; // rang dans la journée
vehiculeId: number;
puissanceFiscale: number;
}
/**
* Trajet enrichi pour l'affichage carte (géométries GeoJSON).
* Note : ce DTO ne porte PAS de `commentaire` (le commentaire n'est affiché que
* dans la liste des trajets, via l'interface `Trajet`, jamais dans l'infobulle carte).
*/
export interface TrajetCarte {
id: number;
date: string;
adresseDepart: string;
adresseArrivee: string;
distance: number;
departLatitude?: number | null;
departLongitude?: number | null;
arriveeLatitude?: number | null;
arriveeLongitude?: number | null;
geometrieJson: string | null; // GeoJSON LineString ou null
}
/** Position d'un trajet lors d'un réordonnancement. */
export interface TrajetPosition {
id: number;
position: number;
}
/** Réordonnancement manuel des trajets d'une journée. */
export interface ReordonnerTrajets {
trajets: TrajetPosition[];
}
@@ -0,0 +1,34 @@
// Modèles Véhicule — miroir de VehiculeDtos.cs.
/** Véhicule — miroir de VehiculeDto. */
export interface Vehicule {
id: number;
denomination: string;
plaque: string;
puissanceFiscale: number;
dateEffet: string; // ISO date
}
/** Création d'un véhicule — miroir de CreateVehiculeDto. */
export interface CreateVehicule {
denomination: string;
plaque: string;
puissanceFiscale: number;
dateEffet: string; // JJ/MM/AAAA, <= date du jour
}
/**
* Réponse à la création d'un véhicule.
* Indique s'il faut proposer une réaffectation des trajets existants.
*/
export interface VehiculeCree {
vehicule: Vehicule;
reaffectationProposee: boolean; // true si des trajets >= DateEffet ont une autre puissance fiscale
nombreTrajetsConcernes: number;
}
/** Requête de réaffectation — miroir de ReaffectationDto. */
export interface Reaffectation {
vehiculeId: number; // nouveau véhicule
accepter: boolean; // true = réaffecter les trajets >= DateEffet ; false = ne rien changer
}
@@ -0,0 +1,37 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { environment } from '../../../environments/environment';
import { AdresseCache } from '../models';
/** Nombre minimal de caractères avant d'interroger le cache local. */
export const CACHE_MIN_CARACTERES = 3;
/**
* Service de recherche dans le cache local d'adresses.
*
* Le cache local est un fallback / optimisation : il retrouve instantanément
* des adresses déjà utilisées sans solliciter la BAN. La mise en cache est
* effectuée côté backend lors de la création d'un trajet ; aucun `saveAdresse`
* client n'est donc nécessaire.
*/
@Injectable({ providedIn: 'root' })
export class AdresseCacheService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/adresses`;
/**
* Recherche dans le cache local (GET /api/adresses/search?q=).
* Une requête de moins de 3 caractères renvoie une liste vide sans appel réseau.
*/
searchLocal(query: string): Observable<AdresseCache[]> {
const terme = (query ?? '').trim();
if (terme.length < CACHE_MIN_CARACTERES) {
return of([]);
}
const params = new HttpParams().set('q', terme);
return this.http.get<AdresseCache[]>(`${this.baseUrl}/search`, { params });
}
}
@@ -0,0 +1,36 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { AdresseNommee, SaveAdresseNommee } from '../models';
/**
* Service HTTP du carnet d'adresses (Adresses_Nommées).
* Supporte la gestion du carnet d'adresses (CRUD).
*/
@Injectable({ providedIn: 'root' })
export class AdresseNommeeService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/adresses-nommees`;
/** Liste des adresses nommées, triées par dénomination (GET /api/adresses-nommees). */
getAdressesNommees(): Observable<AdresseNommee[]> {
return this.http.get<AdresseNommee[]>(this.baseUrl);
}
/** Crée une adresse nommée (POST /api/adresses-nommees). */
create(dto: SaveAdresseNommee): Observable<AdresseNommee> {
return this.http.post<AdresseNommee>(this.baseUrl, dto);
}
/** Modifie une adresse nommée (PUT /api/adresses-nommees/{id}). */
update(id: number, dto: SaveAdresseNommee): Observable<AdresseNommee> {
return this.http.put<AdresseNommee>(`${this.baseUrl}/${id}`, dto);
}
/** Supprime une adresse nommée (DELETE /api/adresses-nommees/{id}). */
delete(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
@@ -0,0 +1,51 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import { BANFeature, BANFeatureCollection } from '../models';
/** Nombre minimal de caractères avant d'interroger la BAN (Exigence 1.13). */
export const BAN_MIN_CARACTERES = 3;
/** Nombre maximal de suggestions demandées à la BAN. */
export const BAN_LIMITE_SUGGESTIONS = 5;
/**
* Service d'autocomplétion via l'API Base Adresse Nationale (BAN).
*
* Source primaire d'autocomplétion : l'appel est fait directement au service
* externe (`environment.banUrl`), pas au backend. Le `timeoutInterceptor` ne
* borne pas ces appels (ils ne ciblent pas l'API backend), afin de ne pas
* déclencher le message d'indisponibilité du backend pour un service tiers.
*
* La logique de debounce et le déclenchement dès 3 caractères sont à la charge
* du composant appelant ; ce service applique néanmoins un garde-fou en retournant
* une liste vide pour toute requête de moins de 3 caractères.
*/
@Injectable({ providedIn: 'root' })
export class BANService {
private readonly http = inject(HttpClient);
private readonly baseUrl = environment.banUrl;
/**
* Recherche d'adresses françaises (GET {banUrl}/search/?q=...&limit=5).
* Retourne au plus 5 suggestions. Une requête de moins de 3 caractères
* renvoie une liste vide sans appel réseau.
*/
searchAdresses(query: string): Observable<BANFeature[]> {
const terme = (query ?? '').trim();
if (terme.length < BAN_MIN_CARACTERES) {
return of([]);
}
const params = new HttpParams()
.set('q', terme)
.set('limit', BAN_LIMITE_SUGGESTIONS);
return this.http
.get<BANFeatureCollection>(`${this.baseUrl}/search/`, { params })
.pipe(map((collection) => collection?.features ?? []));
}
}
@@ -0,0 +1,67 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { MontantPrevisionnel, SaveTrancheBareme, TrancheBareme } from '../models';
/**
* Service HTTP du barème kilométrique (tranches) et du montant prévisionnel.
*/
@Injectable({ providedIn: 'root' })
export class BaremeService {
private readonly http = inject(HttpClient);
private readonly baseUrl = environment.apiUrl;
/** Toutes les tranches du barème d'une année (GET /api/bareme/{annee}). */
getBareme(annee: number): Observable<TrancheBareme[]> {
return this.http.get<TrancheBareme[]>(`${this.baseUrl}/bareme/${annee}`);
}
/** Ajoute une tranche (POST /api/bareme). */
saveTranche(dto: SaveTrancheBareme): Observable<TrancheBareme> {
return this.http.post<TrancheBareme>(`${this.baseUrl}/bareme`, dto);
}
/** Modifie une tranche existante (PUT /api/bareme/{id}). */
updateTranche(id: number, dto: SaveTrancheBareme): Observable<TrancheBareme> {
return this.http.put<TrancheBareme>(`${this.baseUrl}/bareme/${id}`, dto);
}
/** Supprime une tranche (DELETE /api/bareme/{id}). */
deleteTranche(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/bareme/${id}`);
}
/** Années pour lesquelles un barème existe (GET /api/bareme/annees-disponibles). */
getAnneesAvecBareme(): Observable<number[]> {
return this.http.get<number[]>(`${this.baseUrl}/bareme/annees-disponibles`);
}
/**
* Copie le dernier barème connu vers l'année cible (POST /api/bareme/copier/{annee}).
* Remplace les tranches existantes de l'année cible. Retourne le nombre de tranches copiées.
*/
copierDernierBareme(anneeCible: number): Observable<{ tranchesCopiees: number }> {
return this.http.post<{ tranchesCopiees: number }>(
`${this.baseUrl}/bareme/copier/${anneeCible}`,
{}
);
}
/**
* Montant prévisionnel minoré (80%) pour une période et un véhicule
* (GET /api/montant-previsionnel/{annee}/{mois}?vehiculeId={id}).
*/
getMontantPrevisionnel(
annee: number,
mois: number,
vehiculeId: number
): Observable<MontantPrevisionnel> {
const params = new HttpParams().set('vehiculeId', vehiculeId);
return this.http.get<MontantPrevisionnel>(
`${this.baseUrl}/montant-previsionnel/${annee}/${mois}`,
{ params }
);
}
}
+10
View File
@@ -0,0 +1,10 @@
// Barrel d'export des services HTTP du domaine (core).
export * from './notification.service';
export * from './trajet.service';
export * from './vehicule.service';
export * from './ban.service';
export * from './adresse-cache.service';
export * from './adresse-nommee.service';
export * from './profil.service';
export * from './bareme.service';
export * from './remboursement.service';
@@ -0,0 +1,52 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
/** Niveaux de notification supportés. */
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
/** Notification émise vers le composant d'affichage (toast/snackbar). */
export interface Notification {
type: NotificationType;
message: string;
/** Durée d'affichage en ms (0 = persistant jusqu'à fermeture manuelle). */
durationMs: number;
}
/**
* Abstraction toast / snackbar.
*
* Le service ne dépend d'aucune librairie UI : il expose un flux `notifications$`
* qu'un composant d'affichage (à implémenter dans les tâches UI) consomme pour
* présenter les messages. Cela permet de découpler la logique métier du rendu.
*/
@Injectable({ providedIn: 'root' })
export class NotificationService {
private readonly subject = new Subject<Notification>();
/** Flux des notifications à afficher. */
readonly notifications$: Observable<Notification> = this.subject.asObservable();
/** Message de succès (confirmation d'action). */
success(message: string, durationMs = 4000): void {
this.emit('success', message, durationMs);
}
/** Message d'erreur (échec d'action, indisponibilité serveur, etc.). */
error(message: string, durationMs = 6000): void {
this.emit('error', message, durationMs);
}
/** Avertissement (ex. géocodage échoué, distance non calculée). */
warning(message: string, durationMs = 5000): void {
this.emit('warning', message, durationMs);
}
/** Information neutre. */
info(message: string, durationMs = 4000): void {
this.emit('info', message, durationMs);
}
private emit(type: NotificationType, message: string, durationMs: number): void {
this.subject.next({ type, message, durationMs });
}
}
@@ -0,0 +1,33 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { EtatProfil, Profil, SaveProfil } from '../models';
/**
* Service HTTP du profil utilisateur et de l'état de configuration (onboarding).
*/
@Injectable({ providedIn: 'root' })
export class ProfilService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/profil`;
/** Récupère le profil (GET /api/profil). */
getProfil(): Observable<Profil> {
return this.http.get<Profil>(this.baseUrl);
}
/** Sauvegarde le profil (PUT /api/profil). */
saveProfil(dto: SaveProfil): Observable<Profil> {
return this.http.put<Profil>(this.baseUrl, dto);
}
/**
* État de configuration du profil pour l'onboarding (GET /api/profil/etat).
* `estConfigure = true` ssi Nom + Prénom non vides ET au moins un véhicule (Exigence 16.2).
*/
getEtat(): Observable<EtatProfil> {
return this.http.get<EtatProfil>(`${this.baseUrl}/etat`);
}
}
@@ -0,0 +1,43 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { MontantRegle, RegularisationAnnuelle } from '../models';
/**
* Service HTTP des montants réglés et de la régularisation annuelle.
*/
@Injectable({ providedIn: 'root' })
export class RemboursementService {
private readonly http = inject(HttpClient);
private readonly baseUrl = environment.apiUrl;
/**
* Montants réglés d'une année pour un véhicule
* (GET /api/montants-regles/{annee}?vehiculeId={id}).
*/
getMontantsRegles(annee: number, vehiculeId: number): Observable<MontantRegle[]> {
const params = new HttpParams().set('vehiculeId', vehiculeId);
return this.http.get<MontantRegle[]>(`${this.baseUrl}/montants-regles/${annee}`, { params });
}
/**
* Enregistre ou met à jour un montant réglé (PUT /api/montants-regles).
* Upsert par (année, mois, véhicule).
*/
saveMontantRegle(dto: MontantRegle): Observable<MontantRegle> {
return this.http.put<MontantRegle>(`${this.baseUrl}/montants-regles`, dto);
}
/**
* Synthèse annuelle de régularisation d'un véhicule
* (GET /api/regularisation/{annee}?vehiculeId={id}).
*/
getRegularisation(annee: number, vehiculeId: number): Observable<RegularisationAnnuelle> {
const params = new HttpParams().set('vehiculeId', vehiculeId);
return this.http.get<RegularisationAnnuelle>(`${this.baseUrl}/regularisation/${annee}`, {
params
});
}
}
@@ -0,0 +1,137 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
CreateTrajet,
ReordonnerTrajets,
ResumeAnnuel,
ResumeMensuel,
Totaux,
Trajet,
TrajetCarte,
UpdateTrajet
} from '../models';
/**
* Service HTTP des trajets, résumés, exports PDF et navigation temporelle.
*
* Toutes les routes sont relatives à `environment.apiUrl` (`/api` en prod,
* `http://localhost:8080/api` en dev). Les appels sont soumis au
* `timeoutInterceptor` (10 s, Exigence 1.9).
*/
@Injectable({ providedIn: 'root' })
export class TrajetService {
private readonly http = inject(HttpClient);
private readonly baseUrl = environment.apiUrl;
/** Crée un trajet (POST /api/trajets). */
createTrajet(dto: CreateTrajet): Observable<Trajet> {
return this.http.post<Trajet>(`${this.baseUrl}/trajets`, dto);
}
/** Modifie un trajet (PUT /api/trajets/{id}). */
updateTrajet(id: number, dto: UpdateTrajet): Observable<Trajet> {
return this.http.put<Trajet>(`${this.baseUrl}/trajets/${id}`, dto);
}
/** Supprime un trajet (DELETE /api/trajets/{id}). */
deleteTrajet(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/trajets/${id}`);
}
/** Réordonne les trajets d'une journée (PATCH /api/trajets/reordonner). */
reordonner(dto: ReordonnerTrajets): Observable<void> {
return this.http.patch<void>(`${this.baseUrl}/trajets/reordonner`, dto);
}
/**
* Recalcule la distance de tous les trajets via ORS
* (POST /api/trajets/recalculer-distances). Retourne le nombre de trajets recalculés.
*/
recalculerDistances(): Observable<{ trajetsRecalcules: number }> {
return this.http.post<{ trajetsRecalcules: number }>(
`${this.baseUrl}/trajets/recalculer-distances`,
{}
);
}
/**
* Résumé mensuel (GET /api/resumes/mensuel/{annee}/{mois}).
* Filtre optionnel par véhicule ; véhicule courant si omis.
*/
getResumeMensuel(annee: number, mois: number, vehiculeId?: number): Observable<ResumeMensuel> {
return this.http.get<ResumeMensuel>(`${this.baseUrl}/resumes/mensuel/${annee}/${mois}`, {
params: this.vehiculeParams(vehiculeId)
});
}
/**
* Résumé annuel (GET /api/resumes/annuel/{annee}).
* Filtre optionnel par véhicule ; véhicule courant si omis.
*/
getResumeAnnuel(annee: number, vehiculeId?: number): Observable<ResumeAnnuel> {
return this.http.get<ResumeAnnuel>(`${this.baseUrl}/resumes/annuel/${annee}`, {
params: this.vehiculeParams(vehiculeId)
});
}
/** Export PDF mensuel (GET /api/exports/mensuel/{annee}/{mois}). */
exportPdfMensuel(annee: number, mois: number, vehiculeId?: number): Observable<Blob> {
return this.http.get(`${this.baseUrl}/exports/mensuel/${annee}/${mois}`, {
params: this.vehiculeParams(vehiculeId),
responseType: 'blob'
});
}
/** Export PDF annuel (GET /api/exports/annuel/{annee}). */
exportPdfAnnuel(annee: number, vehiculeId?: number): Observable<Blob> {
return this.http.get(`${this.baseUrl}/exports/annuel/${annee}`, {
params: this.vehiculeParams(vehiculeId),
responseType: 'blob'
});
}
/**
* 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).
*/
getTrajetsJournee(date: string): Observable<TrajetCarte[]> {
return this.http.get<TrajetCarte[]>(`${this.baseUrl}/trajets/journee/${date}`);
}
/** Années disponibles pour la navigation (GET /api/annees-disponibles). */
getAnneesDisponibles(): Observable<number[]> {
return this.http.get<number[]>(`${this.baseUrl}/annees-disponibles`);
}
/** Mois disponibles d'une année (GET /api/mois-disponibles/{annee}). */
getMoisDisponibles(annee: number): Observable<number[]> {
return this.http.get<number[]>(`${this.baseUrl}/mois-disponibles/${annee}`);
}
/** Jours disponibles d'un mois (GET /api/jours-disponibles/{annee}/{mois}). */
getJoursDisponibles(annee: number, mois: number): Observable<number[]> {
return this.http.get<number[]>(`${this.baseUrl}/jours-disponibles/${annee}/${mois}`);
}
/**
* Totaux jour / mois / année partiel d'un véhicule à une date donnée
* (GET /api/totaux/{date}?vehiculeId={id}). La date est au format ISO.
*/
getTotauxJour(date: string, vehiculeId: number): Observable<Totaux> {
return this.http.get<Totaux>(`${this.baseUrl}/totaux/${date}`, {
params: new HttpParams().set('vehiculeId', vehiculeId)
});
}
/** Construit les paramètres de requête avec un `vehiculeId` optionnel. */
private vehiculeParams(vehiculeId?: number): HttpParams {
let params = new HttpParams();
if (vehiculeId != null) {
params = params.set('vehiculeId', vehiculeId);
}
return params;
}
}
@@ -0,0 +1,39 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { CreateVehicule, Reaffectation, Vehicule, VehiculeCree } from '../models';
/**
* Service HTTP des véhicules (historique, véhicule courant, création,
* réaffectation des trajets).
*/
@Injectable({ providedIn: 'root' })
export class VehiculeService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/vehicules`;
/** Historique des véhicules (GET /api/vehicules). */
getVehicules(): Observable<Vehicule[]> {
return this.http.get<Vehicule[]>(this.baseUrl);
}
/** Véhicule courant (GET /api/vehicules/courant). */
getVehiculeCourant(): Observable<Vehicule> {
return this.http.get<Vehicule>(`${this.baseUrl}/courant`);
}
/**
* Enregistre un véhicule (POST /api/vehicules).
* La réponse indique si une réaffectation des trajets doit être proposée.
*/
createVehicule(dto: CreateVehicule): Observable<VehiculeCree> {
return this.http.post<VehiculeCree>(this.baseUrl, dto);
}
/** Accepte ou refuse la réaffectation des trajets (POST /api/vehicules/reaffectation). */
reaffecter(dto: Reaffectation): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/reaffectation`, dto);
}
}
@@ -0,0 +1,118 @@
<section class="bareme">
<h1>Barème kilométrique</h1>
<form [formGroup]="anneeForm" class="annee-form" (ngSubmit)="charger()">
<div class="champ">
<label for="annee">Année</label>
<input id="annee" type="number" formControlName="annee" min="2000" max="2100" />
</div>
<button type="submit" class="btn-principal" [disabled]="anneeForm.invalid || chargement()">
Afficher
</button>
</form>
<div class="bareme__entete-tranches">
<h2>Tranches {{ annee() }}</h2>
<button
type="button"
class="btn-secondaire"
(click)="recopierDernierBareme()"
[disabled]="!recopiePossible() || recopieEnCours() || chargement()"
[title]="recopiePossible()
? 'Recopier le barème ' + anneeSourceRecopie() + ' vers ' + annee() + ' (remplace la saisie existante)'
: 'Aucun barème antérieur à recopier'"
>
@if (recopieEnCours()) {
Recopie…
} @else {
Recopier le dernier barème connu
}
</button>
</div>
<table class="tranches" *ngIf="tranches().length; else aucune">
<thead>
<tr>
<th>Puissance fiscale (CV)</th>
<th>Km min</th>
<th>Km max</th>
<th>Coefficient (€/km)</th>
<th>Base (€)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let t of tranches()" [class.en-edition]="t.id === trancheEnEdition()">
<td>{{ t.puissanceFiscale }}</td>
<td>{{ t.kmMin | number: '1.0-0' }}</td>
<td>{{ t.kmMax === null ? 'illimité' : (t.kmMax | number: '1.0-0') }}</td>
<td>{{ t.coefficient | number: '1.3-3' }}</td>
<td>{{ t.base | number: '1.2-2' }}</td>
<td class="actions-cellule">
<button type="button" class="btn-lien" (click)="editer(t)">Modifier</button>
<button type="button" class="btn-lien danger" (click)="supprimer(t)">Supprimer</button>
</td>
</tr>
</tbody>
</table>
<ng-template #aucune>
<p class="vide">Aucune tranche définie pour cette année.</p>
</ng-template>
<h2>{{ trancheEnEdition() !== null ? 'Modifier la tranche' : 'Ajouter une tranche' }}</h2>
<form [formGroup]="trancheForm" class="tranche-form" (ngSubmit)="enregistrer()">
<div class="ligne">
<div class="champ">
<label for="pf">Puissance fiscale (CV)</label>
<input id="pf" type="number" formControlName="puissanceFiscale" min="1" />
<span
class="erreur"
*ngIf="trancheForm.get('puissanceFiscale')?.invalid && trancheForm.get('puissanceFiscale')?.touched"
>
Puissance fiscale requise (≥ 1).
</span>
</div>
<div class="champ">
<label for="kmMin">Km min</label>
<input id="kmMin" type="number" formControlName="kmMin" min="0" />
</div>
<div class="champ">
<label for="kmMax">Km max</label>
<input id="kmMax" type="number" formControlName="kmMax" min="0" placeholder="Vide = illimité" />
<small class="aide">Laisser vide pour une tranche sans borne supérieure (illimité).</small>
</div>
</div>
<div class="ligne">
<div class="champ">
<label for="coef">Coefficient (€/km)</label>
<input id="coef" type="number" step="0.001" formControlName="coefficient" min="0" />
</div>
<div class="champ">
<label for="base">Base (€)</label>
<input id="base" type="number" step="0.01" formControlName="base" min="0" />
</div>
</div>
<p class="erreur" *ngIf="erreurChevauchement()">{{ erreurChevauchement() }}</p>
<div class="actions">
<button type="submit" class="btn-principal" [disabled]="trancheForm.invalid || chargement()">
{{ trancheEnEdition() !== null ? 'Enregistrer les modifications' : 'Ajouter' }}
</button>
<button
type="button"
class="btn-secondaire"
*ngIf="trancheEnEdition() !== null"
(click)="annulerEdition()"
>
Annuler
</button>
</div>
</form>
</section>
@@ -0,0 +1,122 @@
.bareme {
padding: 1.5rem;
max-width: 960px;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
h2 {
font-size: 1.1rem;
margin: 1.5rem 0 0.75rem;
}
.annee-form {
display: flex;
align-items: flex-end;
gap: 1rem;
}
.ligne {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.champ {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.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;
}
.erreur {
color: #c62828;
margin-top: 0.25rem;
}
.actions {
margin-top: 0.5rem;
display: flex;
gap: 0.75rem;
}
.btn-principal {
padding: 0.55rem 1.25rem;
background: #1565c0;
color: #fff;
border: 1px solid #1565c0;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-principal:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-secondaire {
padding: 0.55rem 1.25rem;
background: #fff;
color: #1565c0;
border: 1px solid #1565c0;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-lien {
background: none;
border: none;
color: #1565c0;
cursor: pointer;
padding: 0 0.5rem;
font-size: 0.9rem;
}
.btn-lien.danger {
color: #c62828;
}
.vide {
color: #777;
}
.tranches {
width: 100%;
border-collapse: collapse;
}
.tranches th,
.tranches td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e0e0e0;
}
.tranches th {
font-weight: 600;
background: #f5f5f5;
}
.tranches tr.en-edition {
background: #e3f2fd;
}
.actions-cellule {
white-space: nowrap;
}
@@ -0,0 +1,291 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { BaremeService } from '../../core/services/bareme.service';
import { NotificationService } from '../../core/services/notification.service';
import { SaveTrancheBareme, TrancheBareme } from '../../core/models';
/**
* Saisie et édition du barème kilométrique (Exigences 13.1, 13.7).
*
* - Sélecteur d'année.
* - Tableau éditable des tranches par puissance fiscale : km min, km max,
* coefficient (€/km), base (€).
* - Ajout / modification / suppression des tranches via `BaremeService`.
* - Détection côté client du chevauchement des tranches pour une même
* (année, puissance fiscale) ; un message d'erreur est affiché et
* l'enregistrement est bloqué (le backend applique aussi la contrainte).
* - Tous les montants sont affichés avec deux décimales (pipe `number`).
*/
@Component({
selector: 'app-bareme',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './bareme.component.html',
styleUrl: './bareme.component.scss'
})
export class BaremeComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly baremeService = inject(BaremeService);
private readonly notification = inject(NotificationService);
/** Formulaire de sélection d'année. */
anneeForm!: FormGroup;
/** Formulaire d'ajout / édition d'une tranche. */
trancheForm!: FormGroup;
/** Tranches du barème de l'année sélectionnée. */
readonly tranches = signal<TrancheBareme[]>([]);
/** Année actuellement affichée. */
readonly annee = signal(new Date().getFullYear());
/** id de la tranche en cours d'édition, ou null si mode ajout. */
readonly trancheEnEdition = signal<number | null>(null);
/** Message de chevauchement détecté côté client (null si aucun). */
readonly erreurChevauchement = signal<string | null>(null);
/** true pendant un appel réseau. */
readonly chargement = signal(false);
/** Années pour lesquelles un barème existe (pour piloter la recopie). */
readonly anneesAvecBareme = signal<number[]>([]);
/** true pendant la recopie du dernier barème. */
readonly recopieEnCours = signal(false);
/**
* Année source de la recopie : année la plus récente STRICTEMENT antérieure à l'année
* affichée qui possède un barème. `null` si aucune (bouton désactivé).
*/
readonly anneeSourceRecopie = computed<number | null>(() => {
const cible = this.annee();
const anterieures = this.anneesAvecBareme().filter((a) => a < cible);
return anterieures.length ? Math.max(...anterieures) : null;
});
/** Le bouton de recopie est actif s'il existe un barème antérieur à recopier. */
readonly recopiePossible = computed<boolean>(() => this.anneeSourceRecopie() !== null);
ngOnInit(): void {
this.anneeForm = this.fb.group({
annee: [this.annee(), [Validators.required, Validators.min(2000), Validators.max(2100)]]
});
this.trancheForm = this.fb.group({
puissanceFiscale: [null, [Validators.required, Validators.min(1)]],
kmMin: [0, [Validators.required, Validators.min(0)]],
kmMax: [null, [Validators.min(0)]], // vide = illimité
coefficient: [null, [Validators.required, Validators.min(0)]],
base: [0, [Validators.required, Validators.min(0)]]
});
this.charger();
}
/** Charge les tranches du barème pour l'année sélectionnée. */
charger(): void {
const annee = Number(this.anneeForm.get('annee')?.value);
if (!annee) {
return;
}
this.annee.set(annee);
this.rafraichirAnneesAvecBareme();
this.chargement.set(true);
this.baremeService.getBareme(annee).subscribe({
next: (liste) => {
this.chargement.set(false);
this.tranches.set([...liste].sort(
(a, b) => a.puissanceFiscale - b.puissanceFiscale || a.kmMin - b.kmMin
));
},
error: () => {
this.chargement.set(false);
this.notification.error('Impossible de charger le barème.');
}
});
}
/** Passe le formulaire en mode édition d'une tranche existante. */
editer(tranche: TrancheBareme): void {
this.trancheEnEdition.set(tranche.id);
this.erreurChevauchement.set(null);
this.trancheForm.setValue({
puissanceFiscale: tranche.puissanceFiscale,
kmMin: tranche.kmMin,
kmMax: tranche.kmMax,
coefficient: tranche.coefficient,
base: tranche.base
});
}
/** Annule l'édition en cours et réinitialise le formulaire. */
annulerEdition(): void {
this.trancheEnEdition.set(null);
this.erreurChevauchement.set(null);
this.trancheForm.reset({
puissanceFiscale: null,
kmMin: 0,
kmMax: null,
coefficient: null,
base: 0
});
}
/**
* Enregistre (ajout ou mise à jour) une tranche après validation locale.
* Bloque l'enregistrement si un chevauchement est détecté (Exigence 13.1).
*/
enregistrer(): void {
if (this.trancheForm.invalid || this.chargement()) {
this.trancheForm.markAllAsTouched();
return;
}
const valeur = this.trancheForm.getRawValue();
const kmMin = Number(valeur.kmMin);
// km max vide / non renseigné = tranche ILLIMITÉE (borne supérieure ouverte).
const kmMaxBrut = valeur.kmMax;
const kmMax =
kmMaxBrut === null || kmMaxBrut === undefined || kmMaxBrut === ''
? null
: Number(kmMaxBrut);
if (kmMax !== null && kmMax <= kmMin) {
this.erreurChevauchement.set('La borne km max doit être supérieure à la borne km min (ou vide pour « illimité »).');
return;
}
const dto: SaveTrancheBareme = {
annee: this.annee(),
puissanceFiscale: Number(valeur.puissanceFiscale),
kmMin,
kmMax,
coefficient: Number(valeur.coefficient),
base: Number(valeur.base)
};
if (this.detecteChevauchement(dto)) {
this.erreurChevauchement.set(
'Cette tranche chevauche une tranche existante pour la même puissance fiscale.'
);
return;
}
this.erreurChevauchement.set(null);
const trancheEnEdition = this.trancheEnEdition();
this.chargement.set(true);
const requete$ =
trancheEnEdition !== null
? this.baremeService.updateTranche(trancheEnEdition, dto)
: this.baremeService.saveTranche(dto);
requete$.subscribe({
next: () => {
this.chargement.set(false);
this.notification.success(
trancheEnEdition !== null ? 'Tranche modifiée.' : 'Tranche ajoutée.'
);
this.annulerEdition();
this.charger();
},
error: () => {
this.chargement.set(false);
this.notification.error("Échec de l'enregistrement de la tranche.");
}
});
}
/** Supprime une tranche existante. */
supprimer(tranche: TrancheBareme): void {
if (this.chargement()) {
return;
}
this.chargement.set(true);
this.baremeService.deleteTranche(tranche.id).subscribe({
next: () => {
this.chargement.set(false);
this.notification.success('Tranche supprimée.');
if (this.trancheEnEdition() === tranche.id) {
this.annulerEdition();
}
this.charger();
},
error: () => {
this.chargement.set(false);
this.notification.error('Échec de la suppression de la tranche.');
}
});
}
/** Recharge la liste des années disposant d'un barème (pour l'état du bouton de recopie). */
private rafraichirAnneesAvecBareme(): void {
this.baremeService.getAnneesAvecBareme().subscribe({
next: (annees) => this.anneesAvecBareme.set(annees),
error: () => this.anneesAvecBareme.set([])
});
}
/**
* Recopie le dernier barème connu (année antérieure la plus récente) vers l'année affichée.
* Si l'année affichée contient déjà des tranches, demande confirmation (annule et remplace).
*/
recopierDernierBareme(): void {
const source = this.anneeSourceRecopie();
if (source === null || this.recopieEnCours()) {
return;
}
// Confirmation si l'année cible a déjà des tranches (écrasement).
if (this.tranches().length > 0) {
const ok = window.confirm(
`Le barème ${this.annee()} contient déjà des tranches. ` +
`Recopier le barème ${source} va les remplacer (annule et remplace). Continuer ?`
);
if (!ok) {
return;
}
}
this.recopieEnCours.set(true);
this.baremeService.copierDernierBareme(this.annee()).subscribe({
next: (resultat) => {
this.recopieEnCours.set(false);
this.notification.success(
`Barème ${source} recopié vers ${this.annee()} (${resultat.tranchesCopiees} tranche(s)).`
);
this.annulerEdition();
this.charger();
},
error: () => {
this.recopieEnCours.set(false);
this.notification.error('La recopie du barème a échoué.');
}
});
}
/**
* Détecte un chevauchement de la tranche candidate avec les tranches
* existantes de la même (année, puissance fiscale), en excluant la tranche
* en cours d'édition. Deux intervalles [aMin, aMax] et [bMin, bMax] se
* chevauchent si aMin <= bMax ET bMin <= aMax.
*/
private detecteChevauchement(candidate: SaveTrancheBareme): boolean {
return this.tranches().some((t) => {
if (t.id === this.trancheEnEdition()) {
return false;
}
if (t.puissanceFiscale !== candidate.puissanceFiscale) {
return false;
}
// Convention [kmMin, kmMax[ (borne max EXCLUSIVE) ; kmMax null = +infini.
// [a1,a2[ et [b1,b2[ se chevauchent ssi a1 < b2 ET b1 < a2.
const condA = t.kmMax === null || candidate.kmMin < t.kmMax; // a1 < b2
const condB = candidate.kmMax === null || t.kmMin < candidate.kmMax; // b1 < a2
return condA && condB;
});
}
}
@@ -0,0 +1,98 @@
<section class="carnet">
<h1>Carnet d'adresses</h1>
<!-- Formulaire de création / modification -->
<form class="carnet__form" [formGroup]="form" (ngSubmit)="enregistrer()">
<h2>{{ enEdition ? 'Modifier une adresse' : 'Ajouter une adresse' }}</h2>
<div class="field">
<label for="denomination">Dénomination</label>
<input
id="denomination"
type="text"
formControlName="denomination"
maxlength="100"
autocomplete="off"
placeholder="Ex. Bureau, Client X…"
/>
<p
class="field__error"
*ngIf="form.get('denomination')?.touched && form.get('denomination')?.invalid"
>
La dénomination est obligatoire (1 à 100 caractères).
</p>
</div>
<div class="field field--autocomplete">
<label for="adresse">Adresse</label>
<input
id="adresse"
type="text"
formControlName="adresse"
maxlength="255"
autocomplete="off"
placeholder="Saisir au moins 3 caractères…"
(input)="onAdresseInput($any($event.target).value)"
/>
<p
class="field__error"
*ngIf="form.get('adresse')?.touched && form.get('adresse')?.invalid"
>
L'adresse est obligatoire (1 à 255 caractères).
</p>
<ul class="suggestions" *ngIf="suggestions().length > 0">
<li
*ngFor="let s of suggestions()"
class="suggestions__item"
(click)="choisirSuggestion(s)"
>
{{ s.properties.label }}
</li>
</ul>
</div>
<div class="carnet__actions">
<button type="submit" [disabled]="form.invalid || enregistrement()">
{{ enEdition ? 'Enregistrer' : 'Ajouter' }}
</button>
<button type="button" class="btn--secondary" (click)="annuler()" *ngIf="enEdition">
Annuler
</button>
</div>
</form>
<!-- Liste des adresses nommées, triée par dénomination -->
<div class="carnet__liste">
<h2>Adresses enregistrées</h2>
<p *ngIf="chargement()" class="carnet__vide">Chargement…</p>
<p *ngIf="!chargement() && adresses().length === 0" class="carnet__vide">
Aucune adresse enregistrée pour le moment.
</p>
<ul *ngIf="!chargement() && adresses().length > 0">
<li *ngFor="let adresse of adresses()" class="ligne">
<div class="ligne__infos">
<span class="ligne__denomination">{{ adresse.denomination }}</span>
<span class="ligne__adresse">{{ adresse.adresse }}</span>
<span
class="ligne__warning"
*ngIf="!aCoordonnees(adresse)"
title="Coordonnées non disponibles (géocodage échoué)"
>
⚠ Coordonnées indisponibles
</span>
</div>
<div class="ligne__actions">
<button type="button" class="btn--secondary" (click)="editer(adresse)">
Modifier
</button>
<button type="button" class="btn--danger" (click)="supprimer(adresse)">
Supprimer
</button>
</div>
</li>
</ul>
</div>
</section>
@@ -0,0 +1,163 @@
.carnet {
padding: 1.5rem;
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 2rem;
h1 {
margin: 0;
font-size: 1.5rem;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
}
}
.carnet__form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
border: 1px solid #dcdcdc;
border-radius: 8px;
background: #fafafa;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
label {
font-weight: 600;
font-size: 0.9rem;
}
input {
padding: 0.5rem 0.65rem;
border: 1px solid #c4c4c4;
border-radius: 6px;
font-size: 0.95rem;
}
}
.field--autocomplete {
position: relative;
}
.field__error {
margin: 0;
color: #c0392b;
font-size: 0.8rem;
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
margin: 0.25rem 0 0;
padding: 0;
list-style: none;
border: 1px solid #c4c4c4;
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
max-height: 220px;
overflow-y: auto;
}
.suggestions__item {
padding: 0.5rem 0.65rem;
cursor: pointer;
font-size: 0.9rem;
&:hover {
background: #eef3fb;
}
}
.carnet__actions {
display: flex;
gap: 0.5rem;
}
button {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
background: #2f6fd8;
color: #fff;
font-size: 0.9rem;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn--secondary {
background: #e0e0e0;
color: #333;
}
.btn--danger {
background: #c0392b;
}
.carnet__liste ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.carnet__vide {
color: #666;
font-style: italic;
}
.ligne {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.75rem;
border: 1px solid #e4e4e4;
border-radius: 6px;
}
.ligne__infos {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.ligne__denomination {
font-weight: 600;
}
.ligne__adresse {
color: #555;
font-size: 0.9rem;
}
.ligne__warning {
color: #b8860b;
font-size: 0.8rem;
}
.ligne__actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
@@ -0,0 +1,257 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { Subject, Subscription } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
filter,
switchMap,
} from 'rxjs/operators';
import { of } from 'rxjs';
import {
BAN_LIMITE_SUGGESTIONS,
BAN_MIN_CARACTERES,
BANService,
} from '../../core/services/ban.service';
import { AdresseNommeeService } from '../../core/services/adresse-nommee.service';
import { NotificationService } from '../../core/services/notification.service';
import { AdresseNommee, BANFeature, SaveAdresseNommee } from '../../core/models';
/** Délai d'inactivité avant interrogation de la BAN (Exigence 15.3). */
const BAN_DEBOUNCE_MS = 300;
/**
* Carnet d'adresses nommées (Exigence 15).
*
* Liste triée par dénomination, formulaire de création / modification avec
* autocomplétion BAN (≥ 3 car., debounce 300 ms, max 5 suggestions),
* suppression avec confirmation, messages de confirmation via NotificationService.
*/
@Component({
selector: 'app-carnet-adresses',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './carnet-adresses.component.html',
styleUrls: ['./carnet-adresses.component.scss'],
})
export class CarnetAdressesComponent implements OnInit, OnDestroy {
private readonly fb = inject(FormBuilder);
private readonly service = inject(AdresseNommeeService);
private readonly banService = inject(BANService);
private readonly notifications = inject(NotificationService);
/** Liste des adresses nommées, triée par dénomination. */
readonly adresses = signal<AdresseNommee[]>([]);
/** Chargement de la liste en cours. */
readonly chargement = signal(false);
/** Enregistrement (create/update) en cours. */
readonly enregistrement = signal(false);
/** Identifiant de l'adresse en cours d'édition (null = création). */
readonly editionId = signal<number | null>(null);
/** Suggestions BAN courantes pour le champ adresse. */
readonly suggestions = signal<BANFeature[]>([]);
/** Coordonnées associées à l'adresse saisie (issues d'une sélection BAN). */
private latitude: number | null = null;
private longitude: number | null = null;
readonly form: FormGroup = this.fb.group({
denomination: ['', [Validators.required, Validators.maxLength(100)]],
adresse: ['', [Validators.required, Validators.maxLength(255)]],
});
private readonly adresseInput$ = new Subject<string>();
private readonly subscriptions = new Subscription();
ngOnInit(): void {
this.chargerAdresses();
this.configurerAutocompletion();
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
/** Recharge la liste des adresses nommées (triée par dénomination). */
chargerAdresses(): void {
this.chargement.set(true);
this.subscriptions.add(
this.service
.getAdressesNommees()
.pipe(
catchError(() => {
this.notifications.error(
"Impossible de charger le carnet d'adresses.",
);
return of([] as AdresseNommee[]);
}),
)
.subscribe((adresses) => {
this.adresses.set(
[...adresses].sort((a, b) =>
a.denomination.localeCompare(b.denomination, 'fr', {
sensitivity: 'base',
}),
),
);
this.chargement.set(false);
}),
);
}
/** Indique si l'adresse nommée a des coordonnées géocodées. */
aCoordonnees(adresse: AdresseNommee): boolean {
return adresse.latitude != null && adresse.longitude != null;
}
/** true en mode édition, false en mode création. */
get enEdition(): boolean {
return this.editionId() != null;
}
/** Passe le formulaire en mode édition, pré-rempli avec l'adresse choisie. */
editer(adresse: AdresseNommee): void {
this.editionId.set(adresse.id);
this.latitude = adresse.latitude;
this.longitude = adresse.longitude;
this.suggestions.set([]);
this.form.setValue({
denomination: adresse.denomination,
adresse: adresse.adresse,
});
}
/** Réinitialise le formulaire en mode création. */
annuler(): void {
this.editionId.set(null);
this.latitude = null;
this.longitude = null;
this.suggestions.set([]);
this.form.reset({ denomination: '', adresse: '' });
}
/** Réagit à la saisie du champ adresse pour alimenter l'autocomplétion BAN. */
onAdresseInput(valeur: string): void {
// Toute saisie manuelle invalide les coordonnées d'une sélection précédente.
this.latitude = null;
this.longitude = null;
this.adresseInput$.next(valeur ?? '');
}
/** Applique une suggestion BAN : remplit le champ et conserve les coordonnées. */
choisirSuggestion(feature: BANFeature): void {
const [lon, lat] = feature.geometry.coordinates;
this.longitude = lon;
this.latitude = lat;
this.form.get('adresse')?.setValue(feature.properties.label);
this.suggestions.set([]);
}
/** Crée ou met à jour l'adresse nommée. */
enregistrer(): void {
if (this.form.invalid || this.enregistrement()) {
this.form.markAllAsTouched();
return;
}
const dto: SaveAdresseNommee = {
denomination: (this.form.value.denomination ?? '').trim(),
adresse: (this.form.value.adresse ?? '').trim(),
latitude: this.latitude,
longitude: this.longitude,
};
this.enregistrement.set(true);
const editionId = this.editionId();
const requete$ =
editionId != null
? this.service.update(editionId, dto)
: this.service.create(dto);
const enCreation = editionId == null;
this.subscriptions.add(
requete$.subscribe({
next: (adresse) => {
this.enregistrement.set(false);
this.notifications.success(
enCreation
? `Adresse « ${adresse.denomination} » créée.`
: `Adresse « ${adresse.denomination} » modifiée.`,
);
if (!this.aCoordonnees(adresse)) {
this.notifications.warning(
`Les coordonnées de « ${adresse.denomination} » n'ont pas pu être obtenues (géocodage indisponible).`,
);
}
this.annuler();
this.chargerAdresses();
},
error: () => {
this.enregistrement.set(false);
this.notifications.error(
enCreation
? "La création de l'adresse a échoué."
: "La modification de l'adresse a échoué.",
);
},
}),
);
}
/** Supprime une adresse nommée après confirmation. */
supprimer(adresse: AdresseNommee): void {
const confirme = window.confirm(
`Supprimer l'adresse « ${adresse.denomination} » du carnet ?`,
);
if (!confirme) {
return;
}
this.subscriptions.add(
this.service.delete(adresse.id).subscribe({
next: () => {
this.notifications.success(
`Adresse « ${adresse.denomination} » supprimée.`,
);
if (this.editionId() === adresse.id) {
this.annuler();
}
this.chargerAdresses();
},
error: () => {
this.notifications.error("La suppression de l'adresse a échoué.");
},
}),
);
}
private configurerAutocompletion(): void {
this.subscriptions.add(
this.adresseInput$
.pipe(
debounceTime(BAN_DEBOUNCE_MS),
distinctUntilChanged(),
filter((terme) => (terme ?? '').trim().length >= BAN_MIN_CARACTERES),
switchMap((terme) =>
this.banService.searchAdresses(terme).pipe(catchError(() => of([]))),
),
)
.subscribe((features) => {
this.suggestions.set(features.slice(0, BAN_LIMITE_SUGGESTIONS));
}),
);
}
}
@@ -0,0 +1,20 @@
<div class="carte-trajets">
<!-- Conteneur Leaflet : toujours présent pour permettre l'initialisation. -->
<div #mapContainer class="carte-trajets__map" [class.carte-trajets__map--cache]="etat() === 'erreur'"></div>
<!-- Message : aucune donnée pour la journée (Exigence 8.4). -->
<div class="carte-trajets__overlay carte-trajets__overlay--info" *ngIf="etat() === 'vide'">
<p>Aucun trajet disponible pour cette journée.</p>
</div>
<!-- Chargement en cours. -->
<div class="carte-trajets__overlay carte-trajets__overlay--info" *ngIf="etat() === 'chargement'">
<p>Chargement de la carte…</p>
</div>
<!-- Erreur Leaflet / tuiles OSM avec bouton « Réessayer » (Exigence 8.8). -->
<div class="carte-trajets__erreur" *ngIf="etat() === 'erreur'">
<p class="carte-trajets__erreur-message">{{ messageErreur() || 'La carte ne peut pas être chargée.' }}</p>
<button type="button" class="carte-trajets__retry" (click)="reessayer()">Réessayer</button>
</div>
</div>
@@ -0,0 +1,73 @@
:host {
display: block;
width: 100%;
height: 460px;
}
.carte-trajets {
position: relative;
width: 100%;
height: 460px;
min-height: 460px;
&__map {
width: 100%;
height: 100%;
min-height: 460px;
z-index: 0;
&--cache {
visibility: hidden;
}
}
&__overlay {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
padding: 6px 12px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
font-size: 0.85rem;
color: #333;
pointer-events: none;
p {
margin: 0;
}
}
&__erreur {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
text-align: center;
background: #f5f5f5;
border: 1px solid #e0e0e0;
}
&__erreur-message {
margin: 0;
color: #b71c1c;
}
&__retry {
padding: 6px 16px;
border: none;
border-radius: 4px;
background: #1976d2;
color: #fff;
cursor: pointer;
&:hover {
background: #1565c0;
}
}
}
@@ -0,0 +1,450 @@
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnChanges,
OnDestroy,
SimpleChanges,
ViewChild,
inject,
signal
} from '@angular/core';
import { Subscription } from 'rxjs';
import { TrajetCarte } from '../../core/models';
import { TrajetService } from '../../core/services';
/**
* Palette de couleurs distinctes attribuée aux tracés successifs d'une journée.
* On cycle sur la palette si le nombre de trajets la dépasse.
*/
const COULEURS_TRACES = [
'#1976d2', // bleu
'#e53935', // rouge
'#43a047', // vert
'#fb8c00', // orange
'#8e24aa', // violet
'#00897b', // teal
'#c0ca33', // lime
'#6d4c41' // brun
];
/** Centre par défaut (France métropolitaine) et zoom lorsqu'aucun trajet n'est disponible. */
const CENTRE_DEFAUT: [number, number] = [46.6, 2.4];
const ZOOM_DEFAUT = 6;
/** État de rendu de la carte, pour piloter les messages affichés dans le template. */
type EtatCarte = 'chargement' | 'ok' | 'vide' | 'erreur';
/**
* `CarteTrajetsComponent` — visualisation Leaflet des trajets d'une journée.
*
* Intègre Leaflet avec les tuiles OpenStreetMap et affiche, pour la date fournie,
* un tracé par trajet :
* - tracé GeoJSON (LineString) issu du Cache_Adresses quand la géométrie existe
* (Exigence 8.2) ;
* - à défaut, une ligne droite pointillée entre départ et arrivée signalant un
* itinéraire approximatif (Exigence 8.5).
*
* Un marqueur de départ et un marqueur d'arrivée sont posés pour chaque trajet, et
* une infobulle (survol/clic) présente l'adresse de départ, l'adresse d'arrivée et
* la distance (Exigence 8.3). La carte s'ajuste aux trajets du jour, ou se centre
* sur une position par défaut si la journée est vide (Exigences 8.1, 8.4). Le zoom
* et le déplacement restent interactifs (Exigence 8.6). En cas d'échec de Leaflet
* ou des tuiles OSM, un message d'erreur avec bouton « Réessayer » est proposé
* (Exigence 8.8).
*
* On peut fournir soit une `date` (le composant charge alors les trajets via
* `TrajetService.getTrajetsJournee`), soit directement une liste de `trajets`.
*/
@Component({
selector: 'app-carte-trajets',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './carte-trajets.component.html',
styleUrl: './carte-trajets.component.scss'
})
export class CarteTrajetsComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly trajetService = inject(TrajetService);
private readonly cdr = inject(ChangeDetectorRef);
/** Date de la journée à visualiser, au format ISO (yyyy-MM-dd). */
@Input() date: string | null = null;
/**
* Trajets à afficher directement. Si fourni, prime sur le chargement par `date`.
* Permet au conteneur (ConsultationJourComponent) de partager les trajets déjà
* chargés et filtrés par véhicule.
*/
@Input() trajets: TrajetCarte[] | null = null;
/**
* Id du trajet actuellement survolé dans la liste (ou `null`). Quand il change,
* le tracé correspondant est mis en évidence (weight/opacité renforcés) sans
* re-rendre toute la carte ; les autres tracés sont légèrement estompés.
*/
@Input() trajetSurvoleId: number | null = null;
@ViewChild('mapContainer') private mapContainer?: ElementRef<HTMLDivElement>;
/** État courant piloté dans le template. */
readonly etat = signal<EtatCarte>('chargement');
readonly messageErreur = signal('');
/** Référence Leaflet, chargée dynamiquement pour isoler les échecs d'import. */
private L: typeof import('leaflet') | null = null;
private map: import('leaflet').Map | null = null;
private layerGroup: import('leaflet').LayerGroup | null = null;
private tileLayer: import('leaflet').TileLayer | null = null;
private vueInitialisee = false;
private trajetsCourants: TrajetCarte[] = [];
private chargementSub?: Subscription;
private tuilesEnErreur = false;
/**
* Référence id de trajet → polyline rendue, conservée entre deux rendus pour
* ajuster les styles au survol sans re-rendre toute la carte.
*/
private readonly polylignesParTrajet = new Map<number, import('leaflet').Polyline>();
ngAfterViewInit(): void {
this.vueInitialisee = true;
void this.initialiserCarte();
}
ngOnChanges(changes: SimpleChanges): void {
if (!this.vueInitialisee || !this.map) {
return;
}
if (changes['trajets'] || changes['date']) {
this.chargerDonnees();
}
// Survol : n'ajuste que les styles des polylignes existantes (pas de re-rendu).
if (changes['trajetSurvoleId'] && !changes['trajets'] && !changes['date']) {
this.appliquerSurbrillanceSurvol();
}
}
ngOnDestroy(): void {
this.chargementSub?.unsubscribe();
this.detruireCarte();
}
/** Recharge la carte après un échec (bouton « Réessayer », Exigence 8.8). */
reessayer(): void {
this.etat.set('chargement');
this.messageErreur.set('');
this.tuilesEnErreur = false;
void this.initialiserCarte();
}
/**
* Charge dynamiquement Leaflet puis initialise la carte et la couche de tuiles.
* Toute défaillance (import, création de carte, tuiles) bascule en état d'erreur.
*/
private async initialiserCarte(): Promise<void> {
if (!this.mapContainer) {
this.basculerErreur('Conteneur de carte introuvable.');
return;
}
this.detruireCarte();
try {
if (!this.L) {
this.L = await import('leaflet');
}
} catch {
this.basculerErreur('La librairie de carte n\'a pas pu être chargée.');
return;
}
const L = this.L;
try {
this.map = L.map(this.mapContainer.nativeElement, {
center: CENTRE_DEFAUT,
zoom: ZOOM_DEFAUT,
zoomControl: true
});
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; contributeurs OpenStreetMap'
});
// Une erreur de chargement des tuiles (réseau/OSM indisponible) est signalée.
this.tileLayer.on('tileerror', () => {
if (!this.tuilesEnErreur) {
this.tuilesEnErreur = true;
this.basculerErreur('Les tuiles de la carte (OpenStreetMap) sont indisponibles.');
}
});
this.tileLayer.addTo(this.map);
this.layerGroup = L.layerGroup().addTo(this.map);
} catch {
this.basculerErreur('La carte n\'a pas pu être initialisée.');
return;
}
this.chargerDonnees();
}
/** Résout la source des trajets (Input direct ou chargement par date). */
private chargerDonnees(): void {
this.chargementSub?.unsubscribe();
if (this.trajets != null) {
this.trajetsCourants = this.trajets;
this.rendreTrajets();
return;
}
if (!this.date) {
this.trajetsCourants = [];
this.rendreTrajets();
return;
}
this.etat.set('chargement');
this.cdr.markForCheck();
this.chargementSub = this.trajetService.getTrajetsJournee(this.date).subscribe({
next: (trajets) => {
this.trajetsCourants = trajets ?? [];
this.rendreTrajets();
},
error: () => {
this.basculerErreur('Impossible de charger les trajets de la journée.');
}
});
}
/**
* Dessine les tracés, marqueurs et infobulles, puis ajuste le cadrage.
* Journée vide → carte interactive centrée par défaut + message (Exigences 8.1, 8.4).
*/
private rendreTrajets(): void {
if (!this.L || !this.map || !this.layerGroup) {
return;
}
if (this.tuilesEnErreur) {
return;
}
const L = this.L;
this.layerGroup.clearLayers();
this.polylignesParTrajet.clear();
if (this.trajetsCourants.length === 0) {
this.map.setView(CENTRE_DEFAUT, ZOOM_DEFAUT);
this.etat.set('vide');
this.cdr.markForCheck();
this.rafraichirTaille();
return;
}
const bornes = L.latLngBounds([]);
this.trajetsCourants.forEach((trajet, index) => {
const couleur = COULEURS_TRACES[index % COULEURS_TRACES.length];
const points = this.pointsGeometrie(trajet);
const infobulle = this.contenuInfobulle(trajet);
if (points && points.length >= 2) {
// Tracé d'itinéraire routier réel (GeoJSON), Exigence 8.2.
const ligne = L.polyline(points, { color: couleur, weight: 4, opacity: 0.85 });
ligne.bindPopup(infobulle);
ligne.bindTooltip(infobulle, { sticky: true });
ligne.addTo(this.layerGroup!);
this.polylignesParTrajet.set(trajet.id, ligne);
points.forEach((p) => bornes.extend(p));
this.ajouterMarqueurs(L, points[0], points[points.length - 1], trajet, infobulle, couleur, bornes);
} else {
// Fallback : ligne droite pointillée entre départ et arrivée, Exigence 8.5.
const depart = this.pointDepart(trajet);
const arrivee = this.pointArrivee(trajet);
if (depart && arrivee) {
const ligne = L.polyline([depart, arrivee], {
color: couleur,
weight: 3,
opacity: 0.7,
dashArray: '8, 8'
});
const contenu = `${infobulle}<div class="carte-approx">Itinéraire approximatif</div>`;
ligne.bindPopup(contenu);
ligne.bindTooltip(contenu, { sticky: true });
ligne.addTo(this.layerGroup!);
this.polylignesParTrajet.set(trajet.id, ligne);
bornes.extend(depart);
bornes.extend(arrivee);
this.ajouterMarqueurs(L, depart, arrivee, trajet, contenu, couleur, bornes);
}
// Si aucune coordonnée n'est disponible, le trajet ne peut pas être tracé.
}
});
if (bornes.isValid()) {
this.map.fitBounds(bornes, { padding: [30, 30], maxZoom: 15 });
} else {
// Trajets présents mais sans aucune coordonnée exploitable.
this.map.setView(CENTRE_DEFAUT, ZOOM_DEFAUT);
}
this.etat.set('ok');
this.cdr.markForCheck();
this.rafraichirTaille();
// Réapplique la surbrillance de survol éventuelle sur les nouvelles polylignes.
this.appliquerSurbrillanceSurvol();
}
/**
* Met en évidence la polyline du trajet survolé (weight/opacité renforcés) et
* estompe légèrement les autres. Sans survol, restaure les styles nominaux.
* N'agit que sur les styles des polylignes existantes (pas de re-rendu carte).
*/
private appliquerSurbrillanceSurvol(): void {
const survole = this.trajetSurvoleId;
this.polylignesParTrajet.forEach((ligne, id) => {
if (survole == null) {
ligne.setStyle({ weight: 4, opacity: 0.85 });
} else if (id === survole) {
ligne.setStyle({ weight: 7, opacity: 1 });
ligne.bringToFront();
} else {
ligne.setStyle({ weight: 3, opacity: 0.35 });
}
});
}
/**
* Pose un marqueur simple (petite pastille de la couleur du trajet, sans texte) au
* départ et à l'arrivée. On ne numérote plus les points : le survol d'un trajet (liste
* ou carte) suffit à l'identifier (surbrillance + infobulle), et cela évite que le
* marqueur de départ (souvent confondu avec l'arrivée précédente) n'en masque un autre.
*/
private ajouterMarqueurs(
L: typeof import('leaflet'),
depart: [number, number],
arrivee: [number, number],
trajet: TrajetCarte,
infobulle: string,
couleur: string,
bornes: import('leaflet').LatLngBounds
): void {
const icone = this.creerIcone(L, couleur);
L.marker(depart, { icon: icone, title: `Départ : ${trajet.adresseDepart}` })
.bindPopup(infobulle)
.addTo(this.layerGroup!);
L.marker(arrivee, { icon: icone, title: `Arrivée : ${trajet.adresseArrivee}` })
.bindPopup(infobulle)
.addTo(this.layerGroup!);
bornes.extend(depart);
bornes.extend(arrivee);
}
/**
* Crée une petite pastille « divIcon » de la couleur du trajet (sans texte), pour
* éviter le problème de chemin d'accès aux images de marqueur PNG au bundling.
*/
private creerIcone(
L: typeof import('leaflet'),
couleur: string
): import('leaflet').DivIcon {
return L.divIcon({
className: 'carte-marqueur',
html: `<span class="carte-marqueur__pastille" style="background:${couleur};border:2px solid #fff;"></span>`,
iconSize: [14, 14],
iconAnchor: [7, 7],
popupAnchor: [0, -8]
});
}
/**
* Extrait les points [lat, lng] depuis la géométrie GeoJSON (LineString) du trajet.
* GeoJSON encode les positions en [lng, lat], inversées ici pour Leaflet.
*/
private pointsGeometrie(trajet: TrajetCarte): [number, number][] | null {
if (!trajet.geometrieJson) {
return null;
}
try {
const geo = JSON.parse(trajet.geometrieJson);
const coords = geo?.type === 'LineString' ? geo.coordinates : geo?.geometry?.coordinates;
if (!Array.isArray(coords)) {
return null;
}
const points = coords
.filter((c: unknown) => Array.isArray(c) && c.length >= 2)
.map((c: [number, number]) => [c[1], c[0]] as [number, number]);
return points.length >= 2 ? points : null;
} catch {
return null;
}
}
private pointDepart(trajet: TrajetCarte): [number, number] | null {
return trajet.departLatitude != null && trajet.departLongitude != null
? [trajet.departLatitude, trajet.departLongitude]
: null;
}
private pointArrivee(trajet: TrajetCarte): [number, number] | null {
return trajet.arriveeLatitude != null && trajet.arriveeLongitude != null
? [trajet.arriveeLatitude, trajet.arriveeLongitude]
: null;
}
/** Contenu HTML de l'infobulle : départ, arrivée, distance (Exigence 8.3). */
private contenuInfobulle(trajet: TrajetCarte): string {
const distance =
trajet.distance != null ? `${trajet.distance.toFixed(2)} km` : 'distance non calculée';
return `
<div class="carte-infobulle">
<div><strong>Départ :</strong> ${this.echapper(trajet.adresseDepart)}</div>
<div><strong>Arrivée :</strong> ${this.echapper(trajet.adresseArrivee)}</div>
<div><strong>Distance :</strong> ${distance}</div>
</div>`;
}
/** Échappe le texte utilisateur inséré dans le HTML de l'infobulle. */
private echapper(valeur: string): string {
return (valeur ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** Force Leaflet à recalculer la taille du conteneur (utile en layout dynamique). */
private rafraichirTaille(): void {
if (!this.map) {
return;
}
setTimeout(() => this.map?.invalidateSize(), 0);
}
private basculerErreur(message: string): void {
this.messageErreur.set(message);
this.etat.set('erreur');
this.detruireCarte();
this.cdr.markForCheck();
}
private detruireCarte(): void {
this.layerGroup = null;
this.tileLayer = null;
if (this.map) {
this.map.remove();
this.map = null;
}
}
}
@@ -0,0 +1,137 @@
<section class="consultation" aria-label="Consultation des trajets d'une journée">
<!-- Barre supérieure : sélecteur temporel + navigation jour ± 1 + sélecteur véhicule -->
<header class="consultation__barre">
<app-selecteur-temporel
[date]="date()"
(dateSelectionnee)="onDateSelectionnee($event)"
></app-selecteur-temporel>
<div class="consultation__navigation" role="group" aria-label="Navigation par jour">
<button
type="button"
class="consultation__nav-bouton"
(click)="jourPrecedent()"
>
‹ Jour précédent
</button>
<span class="consultation__date" aria-live="polite">
{{ dateFormatee }}
</span>
<button
type="button"
class="consultation__nav-bouton"
(click)="jourSuivant()"
[disabled]="jourSuivantDesactive"
>
Jour suivant ›
</button>
</div>
<label class="consultation__vehicule">
<span class="consultation__vehicule-libelle">Véhicule</span>
<select
class="consultation__vehicule-select"
[value]="vehiculeId() ?? ''"
(change)="onVehiculeChange($any($event.target).value)"
>
@for (v of vehicules(); track v.id) {
<option [value]="v.id">
{{ v.denomination }} — {{ v.plaque }} ({{ v.puissanceFiscale }} CV)
</option>
}
</select>
</label>
</header>
<div class="consultation__corps">
<!-- Zone principale : bouton d'ajout + liste des trajets (scroll vertical) -->
<main class="consultation__principal">
<div class="consultation__actions">
<button type="button" class="consultation__ajouter" (click)="ouvrirCreation()">
+ Ajouter un trajet
</button>
</div>
@if (erreur()) {
<p class="consultation__erreur" role="alert">{{ erreur() }}</p>
}
@if (chargement()) {
<p class="consultation__chargement">Chargement des trajets…</p>
}
<div class="consultation__liste">
@if (!chargement() && trajets().length === 0) {
<p class="consultation__vide">Aucun trajet pour cette journée.</p>
}
<app-trajet-list
[trajets]="trajets()"
(edit)="onEdit($event)"
(insertAfter)="onInsert($event)"
(insertBetween)="onInsert($event)"
(changed)="onListeChangee()"
(survol)="onSurvolTrajet($event)"
></app-trajet-list>
</div>
</main>
<!-- Panneau latéral sticky : totaux en haut, carte en dessous -->
<aside class="consultation__panneau">
<app-panneau-totaux
[date]="date()"
[vehiculeId]="vehiculeId()"
></app-panneau-totaux>
<app-carte-trajets
[trajets]="trajetsCarte()"
[trajetSurvoleId]="trajetSurvoleId()"
></app-carte-trajets>
</aside>
</div>
<!-- Formulaire en modale : la liste / carte / totaux restent visibles derrière. -->
@if (formulaire(); as f) {
<div
class="modale-overlay"
role="presentation"
(click)="onOverlayClick($event)"
>
<div
class="modale-panneau"
role="dialog"
aria-modal="true"
aria-label="Formulaire de trajet"
>
@switch (f.type) {
@case ('edit') {
<app-trajet-form
[trajet]="f.trajet"
[vehiculeId]="vehiculeId()"
(updated)="onFormEnregistre()"
(cancelled)="fermerFormulaire()"
></app-trajet-form>
}
@case ('insert') {
<app-trajet-form
[prefill]="f.prefill"
[vehiculeId]="vehiculeId()"
[dateParDefaut]="date()"
(created)="onFormEnregistre()"
(cancelled)="fermerFormulaire()"
></app-trajet-form>
}
@default {
<app-trajet-form
[vehiculeId]="vehiculeId()"
[dateParDefaut]="date()"
(created)="onFormEnregistre()"
(cancelled)="fermerFormulaire()"
></app-trajet-form>
}
}
</div>
</div>
}
</section>
@@ -0,0 +1,183 @@
:host {
display: block;
}
.consultation {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
&__barre {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 1rem 1.5rem;
padding: 1rem;
background: #f7f8fa;
border: 1px solid #e3e6ea;
border-radius: 8px;
}
&__navigation {
display: flex;
align-items: center;
gap: 0.75rem;
}
&__nav-bouton {
padding: 0.4rem 0.9rem;
border: 1px solid #c7ccd3;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 0.9rem;
&:hover:not(:disabled) {
background: #eef1f5;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__date {
min-width: 6.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
&__vehicule {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-left: auto;
}
&__vehicule-libelle {
font-size: 0.8rem;
color: #5a616b;
}
&__vehicule-select {
padding: 0.4rem 0.6rem;
border: 1px solid #c7ccd3;
border-radius: 6px;
background: #fff;
min-width: 12rem;
}
// --- Corps deux colonnes ---
&__corps {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(380px, 520px);
gap: 1.5rem;
align-items: start;
}
&__principal {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
&__formulaire {
padding: 1rem;
border: 1px solid #e3e6ea;
border-radius: 8px;
background: #fff;
}
&__actions {
display: flex;
justify-content: flex-end;
}
&__ajouter {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
background: #1976d2;
color: #fff;
cursor: pointer;
font-size: 0.9rem;
&:hover {
background: #135ba1;
}
}
&__erreur {
color: #b3261e;
margin: 0;
}
&__chargement {
color: #5a616b;
margin: 0;
}
// Zone des trajets : scroll vertical quand la liste est haute (Exigence 12.6).
&__liste {
max-height: 55vh;
overflow-y: auto;
border: 1px solid #e3e6ea;
border-radius: 8px;
background: #fff;
}
&__vide {
padding: 1rem;
margin: 0;
color: #5a616b;
}
// Panneau latéral toujours visible (sticky) : totaux + carte (Exigence 12.9).
&__panneau {
position: sticky;
top: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
}
// --- Modale du formulaire de trajet (réutilise le motif d'overlay du ConfirmDialog) ---
.modale-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
background: rgba(15, 23, 42, 0.45);
z-index: 1000;
padding: 2rem 1rem;
overflow-y: auto;
}
.modale-panneau {
background: #fff;
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
max-width: 640px;
width: 100%;
padding: 1.5rem;
margin: auto;
}
// Repli en une colonne sur petits écrans : le panneau redevient statique.
@media (max-width: 900px) {
.consultation__corps {
grid-template-columns: 1fr;
}
.consultation__panneau {
position: static;
}
}
@@ -0,0 +1,349 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { Subscription, forkJoin, of } from 'rxjs';
import { catchError, finalize } from 'rxjs/operators';
import { ResumeMensuel, Trajet, TrajetCarte, Vehicule } from '../../core/models';
import { TrajetService } from '../../core/services/trajet.service';
import { VehiculeService } from '../../core/services/vehicule.service';
import { CarteTrajetsComponent } from '../carte-trajets/carte-trajets.component';
import { PanneauTotauxComponent } from '../panneau-totaux/panneau-totaux.component';
import { SelecteurTemporelComponent } from '../navigation-temporelle/selecteur-temporel.component';
import { TrajetFormComponent, TrajetInsertionPrefill } from '../trajet-form/trajet-form.component';
import {
TrajetInsertionContext,
TrajetListComponent
} from '../trajet-list/trajet-list.component';
/** Mode courant du formulaire embarqué, ou `null` si le formulaire est masqué. */
type FormulaireMode =
| { type: 'create' }
| { type: 'edit'; trajet: Trajet }
| { type: 'insert'; prefill: TrajetInsertionPrefill };
/**
* `ConsultationJourComponent` — layout principal de consultation d'une journée.
*
* Disposition en deux colonnes (Exigences 12.6, 12.9) :
* - Zone principale (gauche/centre) : `SelecteurTemporelComponent`, le
* `TrajetFormComponent` (création / édition / insertion) et la
* `TrajetListComponent` des trajets du jour, avec scroll vertical lorsque la
* liste est haute.
* - Panneau latéral sticky (droite) : `PanneauTotauxComponent` en haut,
* `CarteTrajetsComponent` en dessous.
*
* Sélecteur de Véhicule (Exigences 12.17, 12.18) : par défaut le Véhicule_Courant ;
* changer de véhicule filtre la liste, les totaux et la carte.
*
* Navigation jour ±1 (Exigences 12.10–12.13) : « Jour précédent » / « Jour
* suivant » ; « Jour suivant » est désactivé si le jour suivant est postérieur à
* la date courante.
*
* À chaque changement de date (sélecteur ou ±1 jour) ou de véhicule, la liste, les
* totaux et la carte sont rafraîchis sans rechargement complet de la page, dans un
* délai < 1000 ms (Exigences 12.14, 12.20). Les totaux et la carte sont pilotés par
* `@Input` ; seule la liste (et donc la carte partagée) est rechargée ici.
*
* _Requirements: 12.6, 12.9, 12.10, 12.11, 12.12, 12.13, 12.14, 12.17, 12.18, 12.20_
*/
@Component({
selector: 'app-consultation-jour',
standalone: true,
imports: [
CommonModule,
SelecteurTemporelComponent,
TrajetListComponent,
TrajetFormComponent,
PanneauTotauxComponent,
CarteTrajetsComponent
],
templateUrl: './consultation-jour.component.html',
styleUrl: './consultation-jour.component.scss'
})
export class ConsultationJourComponent implements OnInit, OnDestroy {
private readonly trajetService = inject(TrajetService);
private readonly vehiculeService = inject(VehiculeService);
private readonly cdr = inject(ChangeDetectorRef);
/** Date sélectionnée au format ISO (`yyyy-MM-dd`). `null` tant qu'aucune date n'est choisie. */
readonly date = signal<string | null>(null);
/** Véhicules disponibles pour le sélecteur. */
readonly vehicules = signal<Vehicule[]>([]);
/** Véhicule sélectionné ; par défaut le Véhicule_Courant (Exigence 12.17). */
readonly vehiculeId = signal<number | null>(null);
/** Trajets du jour (filtrés par véhicule) affichés dans la liste. */
readonly trajets = signal<Trajet[]>([]);
/** Trajets du jour avec géométries (filtrés par véhicule) transmis à la carte. */
readonly trajetsCarte = signal<TrajetCarte[]>([]);
/** Formulaire embarqué (création / édition / insertion) ou `null` s'il est masqué. */
readonly formulaire = signal<FormulaireMode | null>(null);
/** Id du trajet survolé dans la liste, propagé à la carte pour surbrillance. */
readonly trajetSurvoleId = signal<number | null>(null);
/** Chargement de la journée en cours. */
readonly chargement = signal(false);
/** Message d'erreur de chargement de la journée. */
readonly erreur = signal<string | null>(null);
private jourSub?: Subscription;
private vehiculesSub?: Subscription;
ngOnInit(): void {
// Journée courante par défaut.
this.date.set(this.isoAujourdHui());
this.chargerVehicules();
}
ngOnDestroy(): void {
this.jourSub?.unsubscribe();
this.vehiculesSub?.unsubscribe();
}
// --- Sélection date / véhicule ---
/** Réagit à la sélection d'une date via le Sélecteur_Temporel (Exigence 12.14). */
onDateSelectionnee(dateIso: string): void {
this.date.set(dateIso);
this.formulaire.set(null);
this.rafraichirJournee();
}
/** Réagit au changement de véhicule dans le sélecteur (Exigences 12.18, 12.20). */
onVehiculeChange(valeur: string): void {
const id = Number(valeur);
this.vehiculeId.set(Number.isNaN(id) ? null : id);
this.formulaire.set(null);
this.rafraichirJournee();
}
// --- Navigation jour ±1 (Exigences 12.10–12.13) ---
/** Sélectionne la journée précédente et rafraîchit l'affichage (Exigence 12.11). */
jourPrecedent(): void {
this.date.set(this.decalerJour(this.dateCourante(), -1));
this.formulaire.set(null);
this.rafraichirJournee();
}
/** Sélectionne la journée suivante et rafraîchit l'affichage (Exigence 12.12). */
jourSuivant(): void {
if (this.jourSuivantDesactive) {
return;
}
this.date.set(this.decalerJour(this.dateCourante(), 1));
this.formulaire.set(null);
this.rafraichirJournee();
}
/** « Jour suivant » désactivé si le jour suivant est postérieur à aujourd'hui (Exigence 12.13). */
get jourSuivantDesactive(): boolean {
const suivant = this.decalerJour(this.dateCourante(), 1);
return suivant > this.isoAujourdHui();
}
/** Date courante affichée au format JJ/MM/AAAA (lisible), ou `—` si absente. */
get dateFormatee(): string {
const date = this.date();
if (!date) {
return '—';
}
const [annee, mois, jour] = date.slice(0, 10).split('-');
return `${jour}/${mois}/${annee}`;
}
// --- Formulaire (création / édition / insertion) ---
/** Ouvre le formulaire en mode création. */
ouvrirCreation(): void {
this.formulaire.set({ type: 'create' });
}
/** Ouvre le formulaire en mode édition (Output `edit` de la liste). */
onEdit(trajet: Trajet): void {
this.formulaire.set({ type: 'edit', trajet });
}
/** Ouvre le formulaire en mode insertion (Output `insertAfter` / `insertBetween`). */
onInsert(contexte: TrajetInsertionContext): void {
this.formulaire.set({ type: 'insert', prefill: contexte.prefill });
}
/** Ferme le formulaire (annulation utilisateur). */
fermerFormulaire(): void {
this.formulaire.set(null);
}
/**
* Clic sur l'overlay de la modale : ferme le formulaire comme une annulation.
* On ne ferme que si le clic vise réellement l'overlay (et non le panneau).
*/
onOverlayClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
this.fermerFormulaire();
}
}
/** Un trajet a été créé ou modifié : ferme le formulaire et recharge la journée. */
onFormEnregistre(): void {
this.formulaire.set(null);
this.rafraichirJournee();
}
/** La liste a changé (suppression, réordonnancement) : recharge la journée (Exigence 12.14). */
onListeChangee(): void {
this.rafraichirJournee();
}
/** Survol d'une ligne de trajet : met en évidence le tracé correspondant sur la carte. */
onSurvolTrajet(id: number | null): void {
this.trajetSurvoleId.set(id);
}
// --- Chargement des données ---
/** Charge les véhicules et sélectionne le véhicule courant par défaut (Exigence 12.17). */
private chargerVehicules(): void {
this.vehiculesSub = forkJoin({
vehicules: this.vehiculeService.getVehicules().pipe(catchError(() => of([] as Vehicule[]))),
courant: this.vehiculeService.getVehiculeCourant().pipe(catchError(() => of(null)))
}).subscribe(({ vehicules, courant }) => {
this.vehicules.set(vehicules);
this.vehiculeId.set(courant?.id ?? vehicules[0]?.id ?? null);
this.rafraichirJournee();
});
}
/**
* Recharge la liste et la carte de la journée pour le véhicule sélectionné, sans
* rechargement complet de la page (Exigences 12.14, 12.20). Le Panneau_Totaux se
* met à jour de lui-même via ses `@Input` `date` / `vehiculeId`.
*
* La liste est dérivée du résumé mensuel filtré par jour et véhicule (`Trajet[]`),
* et la carte des trajets de la journée avec géométries (`TrajetCarte[]`), filtrés
* par le même véhicule.
*/
private rafraichirJournee(): void {
this.jourSub?.unsubscribe();
const date = this.date();
if (!date) {
this.trajets.set([]);
this.trajetsCarte.set([]);
return;
}
const vehiculeId = this.vehiculeId() ?? undefined;
const annee = this.anneeDe(date);
const mois = this.moisDe(date);
this.chargement.set(true);
this.erreur.set(null);
// Chaque observable du forkJoin porte son propre `catchError` afin que le
// forkJoin complète TOUJOURS (il n'émet qu'une fois tous les flux complétés).
// Ainsi un échec réseau sur le résumé ou la carte ne fige plus l'UI sur
// « Chargement des trajets… ». On mémorise l'erreur du résumé pour l'afficher.
let resumeEnErreur = false;
this.jourSub = forkJoin({
resume: this.trajetService.getResumeMensuel(annee, mois, vehiculeId).pipe(
catchError(() => {
resumeEnErreur = true;
return of({ annee, mois, trajets: [], totalKm: 0 } as ResumeMensuel);
})
),
carte: this.trajetService
.getTrajetsJournee(date)
.pipe(catchError(() => of([] as TrajetCarte[])))
})
.pipe(
finalize(() => {
this.chargement.set(false);
// Déclenche la détection de changement même si `finalize` s'exécute
// en dehors du cycle Angular (ex. complétion synchrone / erreur).
this.cdr.markForCheck();
})
)
.subscribe({
next: ({ resume, carte }) => {
// Liste : trajets du mois restreints au jour sélectionné.
this.trajets.set((resume.trajets ?? []).filter((t) => this.memeJour(t.date, date)));
// Carte : trajets du jour filtrés par le véhicule sélectionné.
this.trajetsCarte.set(
this.vehiculeId() == null
? carte
: this.filtrerCarteParVehicule(carte)
);
this.erreur.set(resumeEnErreur
? 'Impossible de charger les trajets de la journée.'
: null);
// Force la mise à jour immédiate de la liste, des totaux et de la carte
// après une navigation Jour ± 1, sans attendre une autre interaction.
this.cdr.markForCheck();
},
error: () => {
// Ne devrait pas survenir (catchError sur chaque source), mais on reste
// robuste : on affiche l'erreur et on rafraîchit la vue.
this.trajets.set([]);
this.trajetsCarte.set([]);
this.erreur.set('Impossible de charger les trajets de la journée.');
this.cdr.markForCheck();
}
});
}
/**
* Restreint les trajets carte au véhicule sélectionné en s'appuyant sur les ids
* de la liste déjà filtrée par véhicule (le DTO carte ne porte pas le vehiculeId).
*/
private filtrerCarteParVehicule(carte: TrajetCarte[]): TrajetCarte[] {
const idsAutorises = new Set(this.trajets().map((t) => t.id));
return carte.filter((c) => idsAutorises.has(c.id));
}
// --- Utilitaires de date ---
/** Date courante de travail (fallback sur aujourd'hui si aucune sélection). */
private dateCourante(): string {
return this.date() ?? this.isoAujourdHui();
}
/** Vrai si deux dates ISO désignent le même jour (tolère un suffixe horaire). */
private memeJour(a: string, b: string): boolean {
return (a ?? '').slice(0, 10) === (b ?? '').slice(0, 10);
}
/** Décale une date ISO `yyyy-MM-dd` de `delta` jours, sans dérive de fuseau. */
private decalerJour(dateIso: string, delta: number): string {
const [annee, mois, jour] = dateIso.slice(0, 10).split('-').map(Number);
const d = new Date(annee, mois - 1, jour);
d.setDate(d.getDate() + delta);
return this.versIso(d);
}
/** Date du jour au format ISO local `yyyy-MM-dd`. */
private isoAujourdHui(): string {
return this.versIso(new Date());
}
/** Formate une Date en ISO local `yyyy-MM-dd`. */
private versIso(d: Date): string {
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
private anneeDe(dateIso: string): number {
return Number(dateIso.slice(0, 4));
}
private moisDe(dateIso: string): number {
return Number(dateIso.slice(5, 7));
}
}
@@ -0,0 +1,75 @@
<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>
@@ -0,0 +1,103 @@
.montants-regles {
padding: 1.5rem;
max-width: 720px;
}
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: #1565c0;
color: #fff;
border: 1px solid #1565c0;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.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;
}
@@ -0,0 +1,215 @@
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.montantPrevisionnel : 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.");
}
});
}
}
@@ -0,0 +1,74 @@
<section class="selecteur-temporel">
<!-- En-tête repliable : n'affiche que le bouton quand fermé. -->
<button
type="button"
class="selecteur-entete"
[attr.aria-expanded]="ouvert()"
(click)="basculerOuverture()">
<span class="selecteur-entete__chevron" aria-hidden="true">{{ ouvert() ? '▾' : '▸' }}</span>
<span class="selecteur-entete__libelle">📅 Choisir une date</span>
</button>
<div class="selecteur-contenu" *ngIf="ouvert()">
<!-- Sélecteur d'année : année courante + années ayant des données (12.2) -->
<div class="selecteur-groupe" role="group" aria-label="Sélection de l'année">
<span class="selecteur-titre">Année</span>
<div class="selecteur-boutons">
<button
type="button"
*ngFor="let annee of annees()"
class="pastille"
[class.selectionne]="annee === anneeSelectionnee()"
[disabled]="estAnneeFuture(annee)"
[attr.aria-pressed]="annee === anneeSelectionnee()"
(click)="selectionnerAnnee(annee)">
{{ annee }}
</button>
</div>
</div>
<!-- Sélecteur de mois : 12 mois, surbrillance des mois avec données (12.3) -->
<div
class="selecteur-groupe"
role="group"
aria-label="Sélection du mois"
*ngIf="anneeSelectionnee() !== null">
<span class="selecteur-titre">Mois</span>
<div class="selecteur-boutons">
<button
type="button"
*ngFor="let m of mois"
class="pastille"
[class.selectionne]="m === moisSelectionne()"
[class.avec-donnees]="moisADonnees(m)"
[disabled]="estMoisFutur(anneeSelectionnee()!, m)"
[attr.aria-pressed]="m === moisSelectionne()"
(click)="selectionnerMois(m)">
{{ nomsMois[m - 1] }}
</button>
</div>
</div>
<!-- Sélecteur de jour : jours du mois, surbrillance des jours avec données (12.4) -->
<div
class="selecteur-groupe"
role="group"
aria-label="Sélection du jour"
*ngIf="moisSelectionne() !== null">
<span class="selecteur-titre">Jour</span>
<div class="selecteur-boutons selecteur-jours">
<button
type="button"
*ngFor="let j of jours()"
class="pastille pastille-jour"
[class.selectionne]="j === jourSelectionne()"
[class.avec-donnees]="jourADonnees(j)"
[disabled]="estJourFutur(anneeSelectionnee()!, moisSelectionne()!, j)"
[attr.aria-pressed]="j === jourSelectionne()"
(click)="selectionnerJour(j)">
{{ j }}
</button>
</div>
</div>
</div>
</section>
@@ -0,0 +1,106 @@
.selecteur-temporel {
display: flex;
flex-direction: column;
gap: 1rem;
}
// En-tête du conteneur repliable (collapse).
.selecteur-entete {
display: inline-flex;
align-items: center;
gap: 0.5rem;
align-self: flex-start;
border: 1px solid #cbd5e1;
background: #fff;
color: #1f2937;
border-radius: 8px;
padding: 0.5rem 0.9rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
border-color: #2563eb;
background: #eff6ff;
}
&__chevron {
font-size: 0.85rem;
color: #64748b;
}
}
// Contenu déplié : les trois groupes de pastilles.
.selecteur-contenu {
display: flex;
flex-direction: column;
gap: 1rem;
}
.selecteur-groupe {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.selecteur-titre {
font-size: 0.85rem;
font-weight: 600;
color: #555;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.selecteur-boutons {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.selecteur-jours {
display: grid;
grid-template-columns: repeat(7, minmax(2.2rem, 1fr));
}
.pastille {
border: 1px solid #cbd5e1;
background: #fff;
color: #1f2937;
border-radius: 6px;
padding: 0.4rem 0.7rem;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover:not(:disabled) {
border-color: #2563eb;
background: #eff6ff;
}
&:disabled {
color: #b0b7c3;
background: #f4f5f7;
border-color: #e2e5ea;
cursor: not-allowed;
}
// Surbrillance des périodes comportant des données (12.3, 12.4).
&.avec-donnees:not(:disabled) {
border-color: #16a34a;
background: #ecfdf5;
font-weight: 600;
}
&.selectionne {
border-color: #2563eb;
background: #2563eb;
color: #fff;
font-weight: 600;
}
}
.pastille-jour {
text-align: center;
padding: 0.4rem 0;
}
@@ -0,0 +1,240 @@
import { CommonModule } from '@angular/common';
import {
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
inject,
signal
} from '@angular/core';
import { TrajetService } from '../../core/services/trajet.service';
/**
* Sélecteur temporel : navigation successive année → mois → jour.
*
* - Sélecteur d'année : année courante + années ayant des données
* (`TrajetService.getAnneesDisponibles`). L'année courante est toujours
* présente afin que le sélecteur ne soit jamais vide. (Exigence 12.2)
* - Sélecteur de mois : les douze mois affichés, surbrillance des mois
* comportant des données (`TrajetService.getMoisDisponibles`). (Exigence 12.3)
* - Sélecteur de jour : les jours du mois affichés, surbrillance des jours
* comportant des données (`TrajetService.getJoursDisponibles`). (Exigence 12.4)
* - Aucune date postérieure à aujourd'hui n'est sélectionnable. (Exigence 12.5)
* - Émet `dateSelectionnee` (ISO `yyyy-MM-dd`) à chaque sélection utilisateur.
* - Se synchronise avec la date affichée par le parent via `@Input date` : lors de
* la navigation ± 1 jour, l'année / mois / jour affichés reflètent cette date.
*
* _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.14_
*/
@Component({
selector: 'app-selecteur-temporel',
standalone: true,
imports: [CommonModule],
templateUrl: './selecteur-temporel.component.html',
styleUrl: './selecteur-temporel.component.scss'
})
export class SelecteurTemporelComponent implements OnInit, OnChanges {
private readonly trajetService = inject(TrajetService);
/**
* Date affichée par le parent (ISO `yyyy-MM-dd`). Quand elle change (navigation
* ± 1 jour ou sélection), le sélecteur pré-sélectionne l'année, le mois et le
* jour correspondants sans réémettre `dateSelectionnee`.
*/
@Input() date: string | null = null;
/** Émis à chaque changement de sélection complète (année + mois + jour). */
@Output() readonly dateSelectionnee = new EventEmitter<string>();
/** Libellés des douze mois (index 0 = janvier). */
readonly nomsMois = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
/** Date du jour, tronquée à minuit, pour borner les sélections futures. */
private readonly aujourdHui = this.debutDeJournee(new Date());
/** Années proposées (année courante + années ayant des données). */
readonly annees = signal<number[]>([]);
/** Les douze mois (1..12), toujours affichés. */
readonly mois: number[] = Array.from({ length: 12 }, (_, i) => i + 1);
/** Jours du mois sélectionné (1..N). */
readonly jours = signal<number[]>([]);
/** Mois comportant au moins un trajet pour l'année sélectionnée. */
private readonly moisAvecDonnees = signal<Set<number>>(new Set<number>());
/** Jours comportant au moins un trajet pour le mois sélectionné. */
private readonly joursAvecDonnees = signal<Set<number>>(new Set<number>());
readonly anneeSelectionnee = signal<number | null>(null);
readonly moisSelectionne = signal<number | null>(null);
readonly jourSelectionne = signal<number | null>(null);
/** État d'ouverture du conteneur repliable (fermé par défaut). */
readonly ouvert = signal(false);
/** Bascule l'ouverture / fermeture du sélecteur repliable. */
basculerOuverture(): void {
this.ouvert.update((v) => !v);
}
ngOnInit(): void {
// Affichage garanti dès le premier rendu : au minimum l'année courante et,
// si le parent a déjà fourni une date, la pré-sélection année/mois/jour.
// Ainsi le sélecteur n'est jamais vide même si l'appel réseau tarde ou échoue.
this.annees.set(this.avecAnneeCourante([]));
this.synchroniserSurDate(this.date);
this.trajetService.getAnneesDisponibles().subscribe({
next: (annees) => {
this.annees.set(this.avecAnneeCourante(annees));
// Réaligner l'affichage sur la date du parent une fois les années connues.
this.synchroniserSurDate(this.date);
},
// En cas d'échec réseau, on conserve l'année courante déjà affichée.
error: () => {}
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['date'] && !changes['date'].firstChange) {
this.synchroniserSurDate(this.date);
}
}
/**
* Aligne la sélection (année, mois, jour) sur la date fournie par le parent,
* sans réémettre d'évènement. Charge les mois / jours avec données associés.
*/
private synchroniserSurDate(dateIso: string | null): void {
if (!dateIso) {
return;
}
const [annee, mois, jour] = dateIso.slice(0, 10).split('-').map(Number);
if (!annee || !mois || !jour) {
return;
}
// Garantit que l'année affichée figure dans la liste des pastilles.
if (!this.annees().includes(annee)) {
this.annees.set(this.avecAnneeCourante([...this.annees(), annee]));
}
this.anneeSelectionnee.set(annee);
this.moisSelectionne.set(mois);
this.jours.set(this.genererJours(annee, mois));
this.jourSelectionne.set(jour);
this.trajetService.getMoisDisponibles(annee).subscribe((m) => {
this.moisAvecDonnees.set(new Set<number>(m));
});
this.trajetService.getJoursDisponibles(annee, mois).subscribe((j) => {
this.joursAvecDonnees.set(new Set<number>(j));
});
}
/** Sélectionne une année et charge les mois comportant des données. */
selectionnerAnnee(annee: number): void {
if (this.estAnneeFuture(annee)) {
return;
}
this.anneeSelectionnee.set(annee);
this.moisSelectionne.set(null);
this.jourSelectionne.set(null);
this.jours.set([]);
this.joursAvecDonnees.set(new Set<number>());
this.moisAvecDonnees.set(new Set<number>());
this.trajetService.getMoisDisponibles(annee).subscribe((mois) => {
this.moisAvecDonnees.set(new Set<number>(mois));
});
}
/** Sélectionne un mois et charge les jours comportant des données. */
selectionnerMois(mois: number): void {
const annee = this.anneeSelectionnee();
if (annee == null || this.estMoisFutur(annee, mois)) {
return;
}
this.moisSelectionne.set(mois);
this.jourSelectionne.set(null);
this.jours.set(this.genererJours(annee, mois));
this.joursAvecDonnees.set(new Set<number>());
this.trajetService.getJoursDisponibles(annee, mois).subscribe((jours) => {
this.joursAvecDonnees.set(new Set<number>(jours));
});
}
/** Sélectionne un jour et émet la date ISO correspondante. */
selectionnerJour(jour: number): void {
const annee = this.anneeSelectionnee();
const mois = this.moisSelectionne();
if (
annee == null ||
mois == null ||
this.estJourFutur(annee, mois, jour)
) {
return;
}
this.jourSelectionne.set(jour);
this.dateSelectionnee.emit(
this.versIso(annee, mois, jour)
);
}
/** Un mois est en surbrillance s'il comporte au moins un trajet. */
moisADonnees(mois: number): boolean {
return this.moisAvecDonnees().has(mois);
}
/** Un jour est en surbrillance s'il comporte au moins un trajet. */
jourADonnees(jour: number): boolean {
return this.joursAvecDonnees().has(jour);
}
/** Une année est désactivée si elle est postérieure à l'année courante. */
estAnneeFuture(annee: number): boolean {
return annee > this.aujourdHui.getFullYear();
}
/** Un mois est désactivé si son premier jour est postérieur à aujourd'hui. */
estMoisFutur(annee: number, mois: number): boolean {
return this.debutDeJournee(new Date(annee, mois - 1, 1)) > this.aujourdHui;
}
/** Un jour est désactivé s'il est postérieur à la date courante. */
estJourFutur(annee: number, mois: number, jour: number): boolean {
return this.debutDeJournee(new Date(annee, mois - 1, jour)) > this.aujourdHui;
}
/** Complète la liste avec l'année courante et trie en ordre décroissant, sans doublon. */
private avecAnneeCourante(annees: number[]): number[] {
const ensemble = new Set<number>(annees);
ensemble.add(this.aujourdHui.getFullYear());
return [...ensemble].sort((a, b) => b - a);
}
/** Génère la liste 1..N des jours du mois donné. */
private genererJours(annee: number, mois: number): number[] {
const nbJours = new Date(annee, mois, 0).getDate();
return Array.from({ length: nbJours }, (_, i) => i + 1);
}
/** Formate une date en ISO `yyyy-MM-dd` sans dérive de fuseau horaire. */
private versIso(annee: number, mois: number, jour: number): string {
const mm = String(mois).padStart(2, '0');
const dd = String(jour).padStart(2, '0');
return `${annee}-${mm}-${dd}`;
}
/** Retourne une copie de la date à minuit (heure locale). */
private debutDeJournee(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
}
@@ -0,0 +1,113 @@
<section class="onboarding">
<header class="onboarding__intro">
<h1>Bienvenue</h1>
<p>
Renseignez votre identité et votre premier véhicule pour commencer à saisir vos trajets.
</p>
</header>
<form [formGroup]="form" (ngSubmit)="commencer()" novalidate>
<fieldset class="bloc">
<legend>Identité</legend>
<div class="champ">
<label for="nom">Nom</label>
<input id="nom" type="text" formControlName="nom" maxlength="100" autocomplete="family-name" />
@if (form.get('nom')?.touched && form.get('nom')?.invalid) {
<small class="erreur">Le nom est obligatoire (1 à 100 caractères).</small>
}
</div>
<div class="champ">
<label for="prenom">Prénom</label>
<input id="prenom" type="text" formControlName="prenom" maxlength="100" autocomplete="given-name" />
@if (form.get('prenom')?.touched && form.get('prenom')?.invalid) {
<small class="erreur">Le prénom est obligatoire (1 à 100 caractères).</small>
}
</div>
<div class="champ">
<label for="matricule">Matricule</label>
<input id="matricule" type="text" formControlName="matricule" [maxlength]="matriculeMaxLength" />
@if (form.get('matricule')?.touched && form.get('matricule')?.invalid) {
<small class="erreur">Le matricule est obligatoire (1 à 8 caractères).</small>
}
</div>
</fieldset>
<fieldset class="bloc">
<legend>Adresse de domicile <span class="optionnel">(optionnelle)</span></legend>
<div class="champ champ-adresse">
<label for="adresseDomicile">Adresse</label>
<input
id="adresseDomicile"
type="text"
formControlName="adresseDomicile"
maxlength="255"
autocomplete="off"
(input)="onDomicileInput($any($event.target).value)"
/>
@if (form.get('adresseDomicile')?.touched && form.get('adresseDomicile')?.errors?.['maxlength']) {
<small class="erreur">L'adresse ne peut pas dépasser 255 caractères.</small>
}
@if (suggestions().length > 0) {
<ul class="suggestions">
@for (feature of suggestions(); track feature.properties.label) {
<li>
<button type="button" (click)="choisirSuggestion(feature)">
{{ feature.properties.label }}
</button>
</li>
}
</ul>
}
</div>
</fieldset>
<fieldset class="bloc">
<legend>Premier véhicule</legend>
<div class="champ">
<label for="denomination">Dénomination</label>
<input id="denomination" type="text" formControlName="denomination" maxlength="100" placeholder="Ex. Marque, Modèle" />
@if (form.get('denomination')?.touched && form.get('denomination')?.invalid) {
<small class="erreur">La dénomination est obligatoire (1 à 100 caractères).</small>
}
</div>
<div class="champ">
<label for="plaque">Plaque d'immatriculation</label>
<input id="plaque" type="text" formControlName="plaque" maxlength="15" />
@if (form.get('plaque')?.touched && form.get('plaque')?.invalid) {
<small class="erreur">La plaque est obligatoire (1 à 15 caractères).</small>
}
</div>
<div class="champ">
<label for="puissanceFiscale">Puissance fiscale</label>
<input id="puissanceFiscale" type="number" formControlName="puissanceFiscale" min="1" step="1" />
@if (form.get('puissanceFiscale')?.touched && form.get('puissanceFiscale')?.invalid) {
<small class="erreur">La puissance fiscale doit être un entier positif.</small>
}
</div>
<div class="champ">
<label for="dateEffet">Date d'effet</label>
<input id="dateEffet" type="date" formControlName="dateEffet" [max]="aujourdhuiIso" />
@if (form.get('dateEffet')?.touched && form.get('dateEffet')?.errors?.['required']) {
<small class="erreur">La date d'effet est obligatoire.</small>
}
@if (form.get('dateEffet')?.touched && form.get('dateEffet')?.errors?.['dateFuture']) {
<small class="erreur">La date d'effet ne peut pas être postérieure à aujourd'hui.</small>
}
</div>
</fieldset>
<div class="actions">
<button type="submit" class="btn-principal" [disabled]="form.invalid || enregistrement()">
Commencer
</button>
</div>
</form>
</section>
@@ -0,0 +1,115 @@
.onboarding {
padding: 1.5rem;
max-width: 640px;
margin: 0 auto;
}
.onboarding__intro {
margin-bottom: 1.5rem;
}
.onboarding__intro h1 {
font-size: 1.6rem;
margin-bottom: 0.5rem;
}
.onboarding__intro p {
color: #555;
margin: 0;
}
.bloc {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem 1.25rem 0.5rem;
margin-bottom: 1.25rem;
}
.bloc legend {
font-weight: 600;
padding: 0 0.5rem;
}
.optionnel {
font-weight: 400;
color: #777;
font-size: 0.85rem;
}
.champ {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.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;
}
.erreur {
color: #c62828;
margin-top: 0.25rem;
}
.champ-adresse {
position: relative;
}
.suggestions {
list-style: none;
margin: 0.25rem 0 0;
padding: 0;
border: 1px solid #ccc;
border-radius: 6px;
background: #fff;
max-height: 220px;
overflow-y: auto;
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 20;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.suggestions li button {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.9rem;
}
.suggestions li button:hover {
background: #f0f4ff;
}
.actions {
margin-top: 1rem;
}
.btn-principal {
padding: 0.55rem 1.25rem;
background: #1565c0;
color: #fff;
border: 1px solid #1565c0;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-principal:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@@ -0,0 +1,187 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { BANService, BAN_MIN_CARACTERES } from '../../core/services/ban.service';
import { NotificationService } from '../../core/services/notification.service';
import { ProfilService } from '../../core/services/profil.service';
import { VehiculeService } from '../../core/services/vehicule.service';
import { BANFeature, CreateVehicule, SaveProfil } from '../../core/models';
/**
* Parcours d'onboarding présenté à la première utilisation lorsque le Profil
* n'est pas encore configuré (Exigence 16.3).
*
* Formulaire en une étape regroupant :
* - Identité : nom (1–100 car.), prénom (1–100 car.), matricule (1–8 car.) — requis (Exigence 16.5)
* - Adresse de domicile : optionnelle (1–255 car.) avec autocomplétion BAN (Exigence 16.7)
* - Premier véhicule : plaque (1–15 car.), puissance fiscale (entier positif),
* date d'effet <= aujourd'hui — au moins un véhicule requis (Exigence 16.6)
*
* Le bouton « Commencer » reste désactivé tant que les champs requis (identité +
* véhicule valide) ne sont pas renseignés (Exigences 16.5, 16.6, 16.8).
*
* À la validation : `ProfilService.saveProfil` puis `VehiculeService.createVehicule`
* (l'ordre importe : profil d'abord, véhicule ensuite). En cas de succès des deux
* persistances, navigation vers `/consultation` (Exigences 16.9, 16.10). En cas
* d'échec de persistance, un message d'erreur est affiché, aucune navigation n'est
* effectuée et les données saisies sont conservées (Exigence 16.12).
*/
@Component({
selector: 'app-onboarding',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './onboarding.component.html',
styleUrl: './onboarding.component.scss'
})
export class OnboardingComponent implements OnInit, OnDestroy {
private readonly fb = inject(FormBuilder);
private readonly profilService = inject(ProfilService);
private readonly vehiculeService = inject(VehiculeService);
private readonly banService = inject(BANService);
private readonly notification = inject(NotificationService);
private readonly router = inject(Router);
/** Longueur maximale du matricule (Exigence 16.5). */
readonly matriculeMaxLength = 8;
/** Date du jour au format ISO (yyyy-MM-dd) pour borner le sélecteur de date. */
readonly aujourdhuiIso = new Date().toISOString().slice(0, 10);
form!: FormGroup;
/** Suggestions d'adresses issues de la BAN pour l'autocomplétion du domicile. */
readonly suggestions = signal<BANFeature[]>([]);
/** true pendant l'appel de persistance (désactive le bouton). */
readonly enregistrement = signal(false);
private readonly domicileInput$ = new Subject<string>();
private readonly destroy$ = new Subject<void>();
ngOnInit(): void {
this.form = this.fb.group({
// Identité (Exigence 16.5) — tous requis.
nom: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]],
prenom: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]],
matricule: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(8)]],
// Adresse de domicile optionnelle (Exigence 16.7).
adresseDomicile: ['', [Validators.maxLength(255)]],
// Premier véhicule (Exigence 16.6) — tous requis.
denomination: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]],
plaque: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(15)]],
puissanceFiscale: [null, [Validators.required, Validators.min(1)]],
dateEffet: ['', [Validators.required, this.dateNonFutureValidator()]]
});
// Autocomplétion BAN (déclenchée dès 3 caractères, debounce 300 ms).
this.domicileInput$
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((terme) => this.banService.searchAdresses(terme)),
takeUntil(this.destroy$)
)
.subscribe((features) => {
this.suggestions.set(features);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Validateur bloquant les dates postérieures à aujourd'hui (Exigence 16.6).
* La valeur du contrôle est au format ISO (yyyy-MM-dd) issu de l'input date.
*/
private dateNonFutureValidator() {
return (control: { value: unknown }) => {
const valeur = control.value as string;
if (!valeur) {
return null;
}
return valeur > this.aujourdhuiIso ? { dateFuture: true } : null;
};
}
/** Alimente le flux d'autocomplétion à chaque frappe dans le champ adresse. */
onDomicileInput(valeur: string): void {
if ((valeur ?? '').trim().length >= BAN_MIN_CARACTERES) {
this.domicileInput$.next(valeur);
} else {
this.suggestions.set([]);
}
}
/** Sélection d'une suggestion BAN : remplit l'adresse et ferme la liste. */
choisirSuggestion(feature: BANFeature): void {
this.form.get('adresseDomicile')?.setValue(feature.properties.label);
this.suggestions.set([]);
}
/**
* Valide le parcours d'onboarding : enregistre le profil puis le véhicule,
* et navigue vers la vue de consultation en cas de succès des deux appels.
*/
commencer(): void {
if (this.form.invalid || this.enregistrement()) {
// Affiche les erreurs sur chaque champ manquant/invalide sans soumettre (Exigence 16.8).
this.form.markAllAsTouched();
return;
}
const valeur = this.form.getRawValue();
const profilDto: SaveProfil = {
nom: (valeur.nom ?? '').trim() || null,
prenom: (valeur.prenom ?? '').trim() || null,
matricule: (valeur.matricule ?? '').trim() || null,
adresseDomicile: (valeur.adresseDomicile ?? '').trim()
};
const vehiculeDto: CreateVehicule = {
denomination: (valeur.denomination ?? '').trim(),
plaque: (valeur.plaque ?? '').trim(),
puissanceFiscale: Number(valeur.puissanceFiscale),
dateEffet: this.isoVersJjMmAAAA(valeur.dateEffet)
};
this.enregistrement.set(true);
// Ordre imposé : profil d'abord (PUT /api/profil), puis véhicule (POST /api/vehicules).
this.profilService.saveProfil(profilDto).subscribe({
next: () => {
this.vehiculeService.createVehicule(vehiculeDto).subscribe({
next: () => {
this.enregistrement.set(false);
// Succès des deux persistances → navigation vers la vue de consultation (Exigences 16.9, 16.10).
this.notification.success('Configuration terminée.');
this.router.navigate(['/consultation']);
},
error: () => this.gererEchecPersistance()
});
},
error: () => this.gererEchecPersistance()
});
}
/**
* Gestion d'un échec de persistance (Exigence 16.12) : message d'erreur,
* pas de navigation, conservation des données saisies.
*/
private gererEchecPersistance(): void {
this.enregistrement.set(false);
this.notification.error(
"Échec de l'enregistrement. Vos informations ont été conservées, veuillez réessayer."
);
}
/** Conversion ISO (yyyy-MM-dd) → JJ/MM/AAAA attendu par le backend. */
private isoVersJjMmAAAA(iso: string): string {
const [annee, mois, jour] = iso.split('-');
return `${jour}/${mois}/${annee}`;
}
}
@@ -0,0 +1,44 @@
<section class="panneau-totaux" aria-label="Totaux kilométriques">
<h2 class="panneau-totaux__titre">Totaux</h2>
@if (erreur()) {
<p class="panneau-totaux__erreur" role="alert">{{ erreur() }}</p>
}
@if (chargement() && !totaux()) {
<p class="panneau-totaux__chargement">Chargement des totaux…</p>
}
<dl class="panneau-totaux__liste">
<div class="panneau-totaux__ligne">
<dt>Total du jour</dt>
<dd>{{ (totaux()?.totalJour ?? 0) | number: '1.2-2' }} km</dd>
</div>
<div class="panneau-totaux__ligne">
<dt>Total du mois</dt>
<dd>{{ (totaux()?.totalMois ?? 0) | number: '1.2-2' }} km</dd>
</div>
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
<dt>Total annuel partiel</dt>
<dd>{{ (totaux()?.totalAnnuelPartiel ?? 0) | number: '1.2-2' }} km</dd>
</div>
</dl>
@if (montantPrevisionnel(); as montantPrevisionnel) {
<div class="panneau-totaux__previsionnel">
<div class="panneau-totaux__ligne panneau-totaux__ligne--fort">
<span class="panneau-totaux__previsionnel-libelle">
Indication de versement (80%)
</span>
<span class="panneau-totaux__previsionnel-montant">
{{ montantPrevisionnel.montantPrevisionnel | number: '1.2-2' }} €
</span>
</div>
<p class="panneau-totaux__previsionnel-note">
Simple indication de versement, calculée à 80% du barème sur le total
annuel partiel. Le montant réellement versé est le montant réglé que
vous saisissez.
</p>
</div>
}
</section>
@@ -0,0 +1,83 @@
:host {
display: block;
width: 100%;
}
.panneau-totaux {
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #fff;
&__titre {
margin: 0 0 12px;
font-size: 1.05rem;
color: #333;
}
&__erreur {
margin: 0 0 12px;
color: #b71c1c;
font-size: 0.9rem;
}
&__chargement {
margin: 0 0 12px;
color: #666;
font-size: 0.9rem;
}
&__liste {
margin: 0;
}
&__ligne {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid #f0f0f0;
dt,
span:first-child {
color: #555;
}
dd,
span:last-child {
margin: 0;
font-variant-numeric: tabular-nums;
color: #222;
}
&--fort {
font-weight: 600;
dt,
dd,
span {
color: #111;
}
}
}
&__previsionnel {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e0e0e0;
&-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;
}
}
}
@@ -0,0 +1,149 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnChanges,
OnDestroy,
SimpleChanges,
inject,
signal
} from '@angular/core';
import { Subscription } from 'rxjs';
import { MontantPrevisionnel, Totaux } from '../../core/models';
import { BaremeService } from '../../core/services/bareme.service';
import { TrajetService } from '../../core/services/trajet.service';
/**
* `PanneauTotauxComponent` — panneau latéral des totaux kilométriques et de
* l'indication de versement.
*
* Reçoit en entrée une `date` (ISO `yyyy-MM-dd`) et un `vehiculeId`, puis affiche :
* - le total du jour, le total du mois et le Total_Annuel_Partiel (cumul de
* l'année jusqu'au mois sélectionné inclus), tous à deux décimales
* (Exigences 12.7, 12.8) ;
* - le Montant_Previsionnel minoré à 80%, calculé sur le Total_Annuel_Partiel du
* véhicule sélectionné, présenté explicitement comme une simple indication de
* versement — le montant réellement versé étant le Montant_Regle saisi par
* l'utilisateur (Exigences 13.5, 13.6). Les montants sont exprimés en euros à
* deux décimales (Exigence 13.7).
*
* Les totaux et le montant prévisionnel sont recalculés à chaque changement de
* `date` ou de `vehiculeId` (recalcul au changement, sans rechargement complet).
*
* _Requirements: 12.7, 12.8, 13.5, 13.6, 13.7, 13.8_
*/
@Component({
selector: 'app-panneau-totaux',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './panneau-totaux.component.html',
styleUrl: './panneau-totaux.component.scss'
})
export class PanneauTotauxComponent implements OnChanges, OnDestroy {
private readonly trajetService = inject(TrajetService);
private readonly baremeService = inject(BaremeService);
private readonly cdr = inject(ChangeDetectorRef);
/** Date sélectionnée au format ISO (`yyyy-MM-dd`). */
@Input() date: string | null = null;
/** Véhicule sur lequel portent les totaux et le montant prévisionnel. */
@Input() vehiculeId: number | null = null;
/** Totaux jour / mois / année partiel chargés pour la date et le véhicule. */
readonly totaux = signal<Totaux | null>(null);
/** Indication de versement minorée (80%) sur le Total_Annuel_Partiel. */
readonly montantPrevisionnel = signal<MontantPrevisionnel | null>(null);
/** Chargement en cours (totaux et/ou montant prévisionnel). */
readonly chargement = signal(false);
/** Message d'erreur affiché si le chargement échoue. */
readonly erreur = signal<string | null>(null);
private totauxSub?: Subscription;
private montantSub?: Subscription;
ngOnChanges(changes: SimpleChanges): void {
if (changes['date'] || changes['vehiculeId']) {
this.rafraichir();
}
}
ngOnDestroy(): void {
this.totauxSub?.unsubscribe();
this.montantSub?.unsubscribe();
}
/**
* Recharge les totaux et le montant prévisionnel pour la date et le véhicule
* courants. Ne déclenche aucun appel tant que les deux entrées ne sont pas
* disponibles.
*/
private rafraichir(): void {
this.totauxSub?.unsubscribe();
this.montantSub?.unsubscribe();
if (!this.date || this.vehiculeId == null) {
this.totaux.set(null);
this.montantPrevisionnel.set(null);
this.chargement.set(false);
this.erreur.set(null);
this.cdr.markForCheck();
return;
}
const date = this.date;
const vehiculeId = this.vehiculeId;
const annee = this.anneeDe(date);
const mois = this.moisDe(date);
this.chargement.set(true);
this.erreur.set(null);
this.cdr.markForCheck();
this.totauxSub = this.trajetService.getTotauxJour(date, vehiculeId).subscribe({
next: (totaux) => {
this.totaux.set(totaux);
this.chargement.set(false);
this.cdr.markForCheck();
},
error: () => {
this.totaux.set(null);
this.chargement.set(false);
this.erreur.set('Impossible de charger les totaux.');
this.cdr.markForCheck();
}
});
// Le Montant_Previsionnel est purement indicatif : son échec n'empêche pas
// l'affichage des totaux, on masque simplement l'indication.
this.montantSub = this.baremeService
.getMontantPrevisionnel(annee, mois, vehiculeId)
.subscribe({
next: (montant) => {
this.montantPrevisionnel.set(montant);
this.cdr.markForCheck();
},
error: () => {
this.montantPrevisionnel.set(null);
this.cdr.markForCheck();
}
});
}
/** Année (nombre) extraite d'une date ISO `yyyy-MM-dd`. */
private anneeDe(dateIso: string): number {
return Number(dateIso.slice(0, 4));
}
/** Mois (1..12) extrait d'une date ISO `yyyy-MM-dd`. */
private moisDe(dateIso: string): number {
return Number(dateIso.slice(5, 7));
}
}
@@ -0,0 +1,86 @@
<section class="profil">
<h1>Profil</h1>
<form [formGroup]="form" (ngSubmit)="enregistrer()" novalidate>
<div class="champ">
<label for="nom">Nom</label>
<input id="nom" type="text" formControlName="nom" maxlength="100" autocomplete="family-name" />
@if (form.get('nom')?.touched && form.get('nom')?.invalid) {
<small class="erreur">Le nom est obligatoire (1 à 100 caractères).</small>
}
</div>
<div class="champ">
<label for="prenom">Prénom</label>
<input id="prenom" type="text" formControlName="prenom" maxlength="100" autocomplete="given-name" />
@if (form.get('prenom')?.touched && form.get('prenom')?.invalid) {
<small class="erreur">Le prénom est obligatoire (1 à 100 caractères).</small>
}
</div>
<div class="champ">
<label for="matricule">Matricule</label>
<input
id="matricule"
type="text"
formControlName="matricule"
[maxlength]="matriculeMaxLength"
/>
<small class="aide">8 caractères maximum.</small>
</div>
<div class="champ champ-adresse">
<label for="adresseDomicile">Adresse de domicile</label>
<input
id="adresseDomicile"
type="text"
formControlName="adresseDomicile"
maxlength="255"
autocomplete="off"
(input)="onDomicileInput($any($event.target).value)"
/>
@if (form.get('adresseDomicile')?.touched && form.get('adresseDomicile')?.errors?.['maxlength']) {
<small class="erreur">L'adresse ne peut pas dépasser 255 caractères.</small>
}
@if (suggestions().length > 0) {
<ul class="suggestions">
@for (feature of suggestions(); track feature.properties.label) {
<li>
<button type="button" (click)="choisirSuggestion(feature)">
{{ feature.properties.label }}
</button>
</li>
}
</ul>
}
</div>
<div class="actions">
<button type="submit" class="btn-principal" [disabled]="form.invalid || enregistrement()">
Enregistrer
</button>
</div>
</form>
<!-- Maintenance : recalcul des distances routières via OpenRouteService. -->
<section class="maintenance">
<h2>Maintenance</h2>
<p class="aide">
Recalcule la distance <strong>par la route</strong> des trajets qui n'ont pas encore
d'itinéraire routier (calculés à vol d'oiseau). Les trajets déjà corrects ne sont pas
resollicités, ce qui limite les appels à OpenRouteService.
</p>
<button
type="button"
class="btn-secondaire"
(click)="recalculerDistances()"
[disabled]="recalculEnCours()"
>
@if (recalculEnCours()) {
Recalcul en cours…
} @else {
Recalculer les distances des trajets
}
</button>
</section>
</section>
@@ -0,0 +1,92 @@
.profil {
padding: 1.5rem;
max-width: 640px;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.champ {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.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;
}
.aide {
color: #777;
margin-top: 0.25rem;
}
.erreur {
color: #c62828;
margin-top: 0.25rem;
}
.champ-adresse {
position: relative;
}
.suggestions {
list-style: none;
margin: 0.25rem 0 0;
padding: 0;
border: 1px solid #ccc;
border-radius: 6px;
background: #fff;
max-height: 220px;
overflow-y: auto;
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 20;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.suggestions li button {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.9rem;
}
.suggestions li button:hover {
background: #f0f4ff;
}
.actions {
margin-top: 1rem;
}
.btn-principal {
padding: 0.55rem 1.25rem;
background: #1565c0;
color: #fff;
border: 1px solid #1565c0;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-principal:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@@ -0,0 +1,188 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { BANService, BAN_MIN_CARACTERES } from '../../core/services/ban.service';
import { NotificationService } from '../../core/services/notification.service';
import { ProfilService } from '../../core/services/profil.service';
import { TrajetService } from '../../core/services/trajet.service';
import { BANFeature, Profil, SaveProfil } from '../../core/models';
/**
* Page de profil utilisateur (Exigences 10.1, 10.2, 10.8).
*
* Formulaire réactif :
* - nom (1–100 car.), prénom (1–100 car.) : obligatoires
* - matricule (max 8 car., limite de saisie stricte via `maxlength`)
* - adresse de domicile (1–255 car., optionnelle) avec autocomplétion BAN
*
* "Enregistrer" appelle `ProfilService.saveProfil`. Un avertissement est affiché
* si le géocodage de l'adresse a échoué (profil.domicileGeocode = false alors
* qu'une adresse est renseignée).
*/
@Component({
selector: 'app-profil',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './profil.component.html',
styleUrl: './profil.component.scss'
})
export class ProfilComponent implements OnInit, OnDestroy {
private readonly fb = inject(FormBuilder);
private readonly profilService = inject(ProfilService);
private readonly banService = inject(BANService);
private readonly notification = inject(NotificationService);
private readonly trajetService = inject(TrajetService);
/** Longueur maximale du matricule (limite de saisie stricte, Exigence 10.2). */
readonly matriculeMaxLength = 8;
form!: FormGroup;
/** Suggestions d'adresses issues de la BAN pour l'autocomplétion. */
readonly suggestions = signal<BANFeature[]>([]);
/** true pendant l'appel de sauvegarde (désactive le bouton). */
readonly enregistrement = signal(false);
/** true pendant le recalcul des distances (désactive le bouton dédié). */
readonly recalculEnCours = signal(false);
private readonly domicileInput$ = new Subject<string>();
private readonly destroy$ = new Subject<void>();
ngOnInit(): void {
this.form = this.fb.group({
nom: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]],
prenom: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]],
matricule: ['', [Validators.maxLength(8)]],
adresseDomicile: ['', [Validators.maxLength(255)]]
});
// Autocomplétion BAN (déclenchée dès 3 caractères, debounce 300 ms).
this.domicileInput$
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((terme) => this.banService.searchAdresses(terme)),
takeUntil(this.destroy$)
)
.subscribe((features) => {
this.suggestions.set(features);
});
this.chargerProfil();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/** Charge le profil existant et alimente le formulaire. */
private chargerProfil(): void {
this.profilService.getProfil().subscribe({
next: (profil) => {
this.form.patchValue({
nom: profil.nom ?? '',
prenom: profil.prenom ?? '',
matricule: profil.matricule ?? '',
adresseDomicile: profil.adresseDomicile ?? ''
});
this.verifierGeocodage(profil);
},
error: () => {
this.notification.error('Impossible de charger le profil.');
}
});
}
/** Alimente le flux d'autocomplétion à chaque frappe dans le champ adresse. */
onDomicileInput(valeur: string): void {
if ((valeur ?? '').trim().length >= BAN_MIN_CARACTERES) {
this.domicileInput$.next(valeur);
} else {
this.suggestions.set([]);
}
}
/** Sélection d'une suggestion BAN : remplit l'adresse et ferme la liste. */
choisirSuggestion(feature: BANFeature): void {
this.form.get('adresseDomicile')?.setValue(feature.properties.label);
this.suggestions.set([]);
}
/** Enregistre le profil via `ProfilService.saveProfil`. */
enregistrer(): void {
if (this.form.invalid || this.enregistrement()) {
this.form.markAllAsTouched();
return;
}
const valeur = this.form.getRawValue();
const dto: SaveProfil = {
nom: valeur.nom?.trim() || null,
prenom: valeur.prenom?.trim() || null,
matricule: valeur.matricule?.trim() || null,
adresseDomicile: (valeur.adresseDomicile ?? '').trim()
};
this.enregistrement.set(true);
this.profilService.saveProfil(dto).subscribe({
next: (profil) => {
this.enregistrement.set(false);
this.notification.success('Profil enregistré.');
this.verifierGeocodage(profil);
},
error: () => {
this.enregistrement.set(false);
this.notification.error("Échec de l'enregistrement du profil.");
}
});
}
/**
* Recalcule la distance routière de tous les trajets via ORS (action de maintenance).
* Utile après correction de la configuration ORS pour remplacer d'anciennes distances
* calculées à vol d'oiseau par la distance par la route.
*/
recalculerDistances(): void {
if (this.recalculEnCours()) {
return;
}
this.recalculEnCours.set(true);
this.trajetService.recalculerDistances().subscribe({
next: (resultat) => {
this.recalculEnCours.set(false);
if (resultat.trajetsRecalcules === 0) {
this.notification.success(
'Toutes les distances sont déjà à jour (itinéraire routier calculé). Aucun appel ORS nécessaire.'
);
} else {
this.notification.success(
`Distances recalculées pour ${resultat.trajetsRecalcules} trajet(s).`
);
}
},
error: () => {
this.recalculEnCours.set(false);
this.notification.error('Le recalcul des distances a échoué.');
}
});
}
/**
* Affiche un avertissement si une adresse de domicile est renseignée mais
* n'a pas pu être géocodée (Exigence 10.8).
*/
private verifierGeocodage(profil: Profil): void {
const adresse = (profil.adresseDomicile ?? '').trim();
if (adresse.length > 0 && !profil.domicileGeocode) {
this.notification.warning(
"L'adresse de domicile n'a pas pu être vérifiée (géocodage indisponible)."
);
}
}
}
@@ -0,0 +1,58 @@
<section class="resume-annuel">
<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="actions">
<button type="submit" class="btn-principal" [disabled]="chargement()">
Afficher
</button>
<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: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total annuel</td>
<td class="num total">{{ totalAnnuel() | number: '1.2-2' }} km</td>
</tr>
</tfoot>
</table>
}
</section>
@@ -0,0 +1,99 @@
.resume-annuel {
padding: 1.5rem;
max-width: 640px;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.selecteurs {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 1rem;
margin-bottom: 1.5rem;
}
.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: #1565c0;
color: #fff;
border: 1px solid #1565c0;
}
.btn-secondaire {
background: #fff;
color: #1565c0;
border: 1px solid #1565c0;
}
.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;
}
@@ -0,0 +1,167 @@
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 { 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);
/** 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). */
exporterPdf(): void {
if (!this.anneeValide() || this.export()) {
return;
}
this.export.set(true);
this.trajetService.exportPdfAnnuel(this.annee).subscribe({
next: (blob) => {
this.export.set(false);
this.declencherTelechargement(blob, `trajets_${this.annee}.pdf`);
},
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);
}
/** 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
);
}
}
@@ -0,0 +1,77 @@
<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="actions">
<button type="submit" class="btn-principal" [disabled]="chargement()">
Afficher
</button>
<button
type="button"
class="btn-secondaire"
(click)="exporterPdf()"
[disabled]="export() || chargement()"
>
Export PDF mensuel
</button>
</div>
</form>
@if (chargement()) {
<p class="info">Chargement du résumé…</p>
} @else if (resume(); as resume) {
<table class="trajets">
<thead>
<tr>
<th>Date</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.adresseDepart }}</td>
<td>{{ trajet.adresseArrivee }}</td>
<td class="num">
{{ trajet.distance != null ? (trajet.distance | number: '1.2-2') : '—' }}
</td>
</tr>
} @empty {
<tr>
<td colspan="4" 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>
</tr>
</tfoot>
</table>
}
</section>
@@ -0,0 +1,105 @@
.resume-mensuel {
padding: 1.5rem;
max-width: 900px;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.selecteurs {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
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;
}
.btn-principal,
.btn-secondaire {
padding: 0.55rem 1.25rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-principal {
background: #1565c0;
color: #fff;
border: 1px solid #1565c0;
}
.btn-secondaire {
background: #fff;
color: #1565c0;
border: 1px solid #1565c0;
}
.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;
}
@@ -0,0 +1,131 @@
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 { TrajetService } from '../../core/services/trajet.service';
import { ResumeMensuel } from '../../core/models';
/**
* 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],
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);
/** 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;
/** 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). */
exporterPdf(): void {
if (!this.selectionValide() || this.export()) {
return;
}
this.export.set(true);
this.trajetService.exportPdfMensuel(this.annee, this.mois).subscribe({
next: (blob) => {
this.export.set(false);
const nom = this.nomFichierParDefaut();
this.declencherTelechargement(blob, nom);
},
error: () => {
this.export.set(false);
this.notification.error("Échec du téléchargement du PDF mensuel.");
}
});
}
/** Nom de fichier de repli au format `trajets_MM_AAAA.pdf`. */
private nomFichierParDefaut(): string {
const mm = String(this.mois).padStart(2, '0');
return `trajets_${mm}_${this.annee}.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,226 @@
<form class="trajet-form" [formGroup]="form" (ngSubmit)="soumettre()" novalidate>
<!-- Avertissement d'indisponibilité de l'autocomplétion BAN (Exigence 1.16). -->
@if (banIndisponible()) {
<p class="alerte alerte--info" role="status">
L'autocomplétion d'adresses est temporairement indisponible. Vous pouvez saisir
l'adresse manuellement.
</p>
}
<!-- Date : contrôle calendrier natif (valeur ISO yyyy-MM-dd), borné à aujourd'hui. -->
<div class="champ">
<label for="date">Date du trajet</label>
<input
id="date"
type="date"
formControlName="date"
[attr.max]="maxDate"
autocomplete="off"
[attr.aria-invalid]="dateCtrl.invalid && dateCtrl.touched"
/>
@if (dateCtrl.touched && dateCtrl.errors) {
<p class="erreur" role="alert">
@if (dateCtrl.errors['required']) {
La date est obligatoire.
} @else if (dateCtrl.errors['dateFuture']) {
La date ne peut pas être dans le futur.
} @else if (dateCtrl.errors['dateInvalide']) {
La date est invalide.
}
</p>
}
</div>
<!-- Adresse de départ -->
<div class="champ champ--adresse">
<label for="adresseDepart">Adresse de départ</label>
<!-- Sélecteur d'Adresse_Nommee (Exigences 1.18, 1.19, 11.10, 11.11) : disponible
en création, insertion ET modification. Une sélection remplit le champ ci-dessous. -->
<div class="ligne-carnet">
<label for="carnetDepart" class="label-carnet">
Depuis le carnet d'adresses
</label>
<select
id="carnetDepart"
class="select-carnet"
[disabled]="adressesNommees().length === 0"
(change)="choisirAdresseNommeeDepart($any($event.target).value); $any($event.target).value = ''"
>
<option value="">
@if (adressesNommees().length === 0) {
Aucune adresse enregistrée
} @else {
Sélectionner une adresse nommée…
}
</option>
@for (adresse of adressesNommees(); track adresse.id) {
<option [value]="adresse.id">{{ adresse.denomination }}</option>
}
</select>
</div>
<div class="ligne-adresse">
<input
id="adresseDepart"
type="text"
formControlName="adresseDepart"
autocomplete="off"
[maxlength]="adresseMax"
(input)="onSaisieDepart(departCtrl.value)"
[attr.aria-invalid]="departCtrl.invalid && departCtrl.touched"
/>
<button
type="button"
class="btn-domicile"
(click)="utiliserDomicileDepart()"
[disabled]="!domicileDisponible"
[title]="domicileDisponible
? 'Utiliser mon adresse de domicile'
: 'Aucune adresse de domicile configurée dans votre profil'"
>
Utiliser mon domicile
</button>
</div>
@if (suggestionsDepart().length > 0) {
<ul class="suggestions" role="listbox">
@for (feature of suggestionsDepart(); track feature.properties.label) {
<li>
<button type="button" (click)="choisirSuggestionDepart(feature)">
<span class="suggestion-label">{{ feature.properties.label }}</span>
<span class="suggestion-detail">
{{ feature.properties.postcode }} {{ feature.properties.city }}
</span>
</button>
</li>
}
</ul>
}
@if (departCtrl.touched && departCtrl.errors) {
<p class="erreur" role="alert">
@if (departCtrl.errors['required']) {
L'adresse de départ est obligatoire.
} @else if (departCtrl.errors['maxlength']) {
L'adresse de départ ne doit pas dépasser {{ adresseMax }} caractères.
}
</p>
}
</div>
<!-- Adresse d'arrivée -->
<div class="champ champ--adresse">
<label for="adresseArrivee">Adresse d'arrivée</label>
<!-- Sélecteur d'Adresse_Nommee (Exigences 1.18, 1.19, 11.10, 11.11). Disponible
aussi en modification pour remplacer une adresse par une adresse du carnet. -->
<div class="ligne-carnet">
<label for="carnetArrivee" class="label-carnet">
Depuis le carnet d'adresses
</label>
<select
id="carnetArrivee"
class="select-carnet"
[disabled]="adressesNommees().length === 0"
(change)="choisirAdresseNommeeArrivee($any($event.target).value); $any($event.target).value = ''"
>
<option value="">
@if (adressesNommees().length === 0) {
Aucune adresse enregistrée
} @else {
Sélectionner une adresse nommée…
}
</option>
@for (adresse of adressesNommees(); track adresse.id) {
<option [value]="adresse.id">{{ adresse.denomination }}</option>
}
</select>
</div>
<div class="ligne-adresse">
<input
id="adresseArrivee"
type="text"
formControlName="adresseArrivee"
autocomplete="off"
[maxlength]="adresseMax"
(input)="onSaisieArrivee(arriveeCtrl.value)"
[attr.aria-invalid]="arriveeCtrl.invalid && arriveeCtrl.touched"
/>
<button
type="button"
class="btn-domicile"
(click)="utiliserDomicileArrivee()"
[disabled]="!domicileDisponible"
[title]="domicileDisponible
? 'Utiliser mon adresse de domicile'
: 'Aucune adresse de domicile configurée dans votre profil'"
>
Utiliser mon domicile
</button>
</div>
@if (suggestionsArrivee().length > 0) {
<ul class="suggestions" role="listbox">
@for (feature of suggestionsArrivee(); track feature.properties.label) {
<li>
<button type="button" (click)="choisirSuggestionArrivee(feature)">
<span class="suggestion-label">{{ feature.properties.label }}</span>
<span class="suggestion-detail">
{{ feature.properties.postcode }} {{ feature.properties.city }}
</span>
</button>
</li>
}
</ul>
}
@if (arriveeCtrl.touched && arriveeCtrl.errors) {
<p class="erreur" role="alert">
@if (arriveeCtrl.errors['required']) {
L'adresse d'arrivée est obligatoire.
} @else if (arriveeCtrl.errors['maxlength']) {
L'adresse d'arrivée ne doit pas dépasser {{ adresseMax }} caractères.
}
</p>
}
</div>
<!-- Avertissement coordonnées manquantes (Exigence 1.17). -->
@if (avertissementCoordonneesManquantes) {
<p class="alerte alerte--warning" role="status">
Une adresse a été saisie sans être sélectionnée dans les suggestions : la distance
pourrait ne pas être calculée automatiquement.
</p>
}
<!-- Commentaire optionnel (Exigence 1.10). -->
<div class="champ">
<label for="commentaire">Commentaire (optionnel)</label>
<textarea
id="commentaire"
formControlName="commentaire"
rows="2"
[maxlength]="commentaireMax"
></textarea>
<small class="compteur">
{{ (commentaireCtrl.value?.length ?? 0) }} / {{ commentaireMax }}
</small>
@if (commentaireCtrl.touched && commentaireCtrl.errors?.['maxlength']) {
<p class="erreur" role="alert">
Le commentaire ne doit pas dépasser {{ commentaireMax }} caractères.
</p>
}
</div>
<!-- Actions -->
<div class="actions">
<button type="submit" class="btn-primaire" [disabled]="soumissionEnCours()">
{{ libelleAction }}
</button>
<button type="button" class="btn-secondaire" (click)="annuler()" [disabled]="soumissionEnCours()">
Annuler
</button>
</div>
</form>
@@ -0,0 +1,214 @@
.trajet-form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 640px;
}
.champ {
display: flex;
flex-direction: column;
gap: 0.35rem;
label {
font-weight: 600;
font-size: 0.9rem;
}
input,
textarea {
padding: 0.5rem 0.65rem;
border: 1px solid #c4c9d1;
border-radius: 6px;
font: inherit;
&[aria-invalid='true'] {
border-color: #d13438;
}
&:focus {
outline: 2px solid #2b6cb0;
outline-offset: 1px;
}
}
}
.champ--adresse {
position: relative;
}
.ligne-adresse {
display: flex;
gap: 0.5rem;
align-items: stretch;
input {
flex: 1 1 auto;
}
}
.btn-domicile {
flex: 0 0 auto;
white-space: nowrap;
padding: 0 0.75rem;
border: 1px solid #2b6cb0;
border-radius: 6px;
background: #fff;
color: #2b6cb0;
font-size: 0.85rem;
cursor: pointer;
&:hover:not(:disabled) {
background: #ebf3fb;
}
&:disabled {
border-color: #c4c9d1;
color: #9aa1ab;
cursor: not-allowed;
}
}
.ligne-carnet {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.35rem;
}
.label-carnet {
font-size: 0.8rem;
font-weight: 500;
color: #6b7280;
}
.select-carnet {
padding: 0.4rem 0.55rem;
border: 1px solid #c4c9d1;
border-radius: 6px;
background: #fff;
font: inherit;
cursor: pointer;
&:focus {
outline: 2px solid #2b6cb0;
outline-offset: 1px;
}
&:disabled {
background: #f3f5f8;
color: #9aa1ab;
cursor: not-allowed;
}
}
.suggestions {
list-style: none;
margin: 0.25rem 0 0;
padding: 0;
border: 1px solid #c4c9d1;
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
max-height: 220px;
overflow-y: auto;
z-index: 10;
li + li {
border-top: 1px solid #eef0f3;
}
button {
display: flex;
flex-direction: column;
gap: 0.1rem;
width: 100%;
padding: 0.5rem 0.65rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
&:hover,
&:focus {
background: #ebf3fb;
outline: none;
}
}
}
.suggestion-label {
font-weight: 500;
}
.suggestion-detail {
font-size: 0.8rem;
color: #6b7280;
}
.compteur {
align-self: flex-end;
font-size: 0.75rem;
color: #6b7280;
}
.erreur {
margin: 0;
font-size: 0.82rem;
color: #d13438;
}
.alerte {
margin: 0;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
&--info {
background: #eef2f7;
color: #33475b;
}
&--warning {
background: #fff4e5;
color: #8a5a00;
border: 1px solid #f0c36d;
}
}
.actions {
display: flex;
gap: 0.75rem;
button {
padding: 0.55rem 1.1rem;
border-radius: 6px;
font: inherit;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.btn-primaire {
border: none;
background: #2b6cb0;
color: #fff;
&:hover:not(:disabled) {
background: #245a94;
}
}
.btn-secondaire {
border: 1px solid #c4c9d1;
background: #fff;
color: #33475b;
&:hover:not(:disabled) {
background: #f3f5f8;
}
}
@@ -0,0 +1,698 @@
import { CommonModule } from '@angular/common';
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
inject,
signal
} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import { Subject, of } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
filter,
map,
switchMap,
takeUntil
} from 'rxjs/operators';
import {
AdresseNommee,
BANFeature,
CreateTrajet,
Profil,
Trajet,
UpdateTrajet
} from '../../core/models';
import { AdresseNommeeService } from '../../core/services/adresse-nommee.service';
import { BAN_MIN_CARACTERES, BANService } from '../../core/services/ban.service';
import { NotificationService } from '../../core/services/notification.service';
import { ProfilService } from '../../core/services/profil.service';
import { TrajetService } from '../../core/services/trajet.service';
import { ServeurIndisponibleError } from '../../core/interceptors/timeout.interceptor';
import { estDateJjMmAAAAValide } from '../../shared/validators/date-jj-mm-aaaa.validator';
/** Longueur maximale d'une adresse (Exigence 1.1, 6.2). */
export const ADRESSE_MAX = 255;
/** Longueur maximale d'un commentaire (Exigence 1.10). */
export const COMMENTAIRE_MAX = 500;
/** Délai de debounce avant interrogation de la BAN (design.md). */
export const BAN_DEBOUNCE_MS = 300;
/** Mode d'utilisation du formulaire. */
export type TrajetFormMode = 'create' | 'edit' | 'insert';
/**
* Données de pré-remplissage en mode insertion : adresse de départ et/ou d'arrivée
* issues du Trajet_Précédent / Trajet_Suivant (Exigences 11.1–11.4). Les champs
* restent modifiables. La position d'insertion est gérée par le composant appelant
* (liste), le formulaire ne fait que porter les valeurs pré-remplies.
*/
export interface TrajetInsertionPrefill {
adresseDepart?: string | null;
adresseArrivee?: string | null;
vehiculeId?: number | null;
}
/** Identifie lequel des deux champs adresse est concerné. */
type ChampAdresse = 'depart' | 'arrivee';
/** Coordonnées géographiques mémorisées après une sélection BAN. */
interface Coordonnees {
latitude: number;
longitude: number;
}
/**
* `TrajetFormComponent` — saisie, édition et insertion d'un Trajet.
*
* Modes (Exigence 5.1, 11.1–11.4) :
* - `create` : formulaire vierge.
* - `edit` : pré-rempli depuis `@Input() trajet`.
* - `insert` : pré-rempli depuis `@Input() prefill` (adresses du Trajet_Précédent /
* Trajet_Suivant) ; les champs restent modifiables.
*
* Autocomplétion BAN (Exigences 1.13–1.15) : dès 3 caractères, avec debounce 300 ms ;
* à la sélection d'une suggestion, les coordonnées (lat, lon) sont mémorisées localement
* et le libellé normalisé remplit le champ ; elles sont transmises avec le trajet.
*
* Saisie manuelle sans sélection BAN (Exigences 1.16, 1.17) : les coordonnées sont
* absentes et un avertissement discret indique que la distance pourrait ne pas être
* calculée automatiquement.
*
* Le composant est réutilisable : il émet le trajet créé/modifié via `@Output`
* (`created` / `updated`) afin que la liste / consultation puisse réagir.
*
* Sélecteur d'Adresse_Nommee (Exigences 1.18, 1.19, 11.10, 11.11, 15.16) : pour chaque
* champ adresse (départ / arrivée), un sélecteur liste les Adresses_Nommées du
* Carnet_Adresses (par Dénomination), chargées à l'initialisation. À la sélection, le
* champ est rempli avec le texte d'adresse et les coordonnées associées sont mémorisées
* (réutilisation du helper `appliquerAdresse`). Disponible en modes `create` ET `insert` ;
* en insertion, la sélection remplace le contenu pré-rempli. Si l'Adresse_Nommee n'a pas
* de coordonnées (géocodage échoué), le texte est rempli mais les coordonnées restent
* nulles (le Backend les résoudra, Exigence 2.4).
*/
@Component({
selector: 'app-trajet-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './trajet-form.component.html',
styleUrls: ['./trajet-form.component.scss']
})
export class TrajetFormComponent implements OnInit, OnDestroy {
private readonly fb = inject(FormBuilder);
private readonly banService = inject(BANService);
private readonly trajetService = inject(TrajetService);
private readonly profilService = inject(ProfilService);
private readonly adresseNommeeService = inject(AdresseNommeeService);
private readonly notifications = inject(NotificationService);
/** Trajet à éditer (active le mode `edit`). */
@Input() trajet: Trajet | null = null;
/** Pré-remplissage pour le mode insertion (adresses Trajet_Précédent / Suivant). */
@Input() prefill: TrajetInsertionPrefill | null = null;
/** Véhicule cible du trajet (null → véhicule courant côté backend). */
@Input() vehiculeId: number | null = null;
/**
* Date par défaut (ISO `yyyy-MM-dd`) à pré-remplir en création / insertion :
* typiquement la journée actuellement affichée dans la consultation. En édition,
* la date du trajet prime (voir `appliquerPreRemplissage`).
*/
@Input() dateParDefaut: string | null = null;
/** Émis après une création réussie (Exigence 1.8) — réutilisabilité du composant. */
@Output() created = new EventEmitter<Trajet>();
/** Émis après une modification réussie. */
@Output() updated = new EventEmitter<Trajet>();
/** Émis lorsque l'utilisateur annule la saisie. */
@Output() cancelled = new EventEmitter<void>();
/** Mode courant, dérivé des entrées. */
mode: TrajetFormMode = 'create';
/** Formulaire réactif. */
form!: FormGroup;
/** Constantes exposées au template. */
readonly adresseMax = ADRESSE_MAX;
readonly commentaireMax = COMMENTAIRE_MAX;
/**
* Date du jour au format ISO `yyyy-MM-dd`, utilisée comme borne `max` de l'input
* date natif afin d'empêcher la sélection d'une date future côté UI (Exigence 12.5).
*/
readonly maxDate = this.isoAujourdHui();
/** Suggestions BAN par champ. */
readonly suggestionsDepart = signal<BANFeature[]>([]);
readonly suggestionsArrivee = signal<BANFeature[]>([]);
/**
* Adresses_Nommées du Carnet_Adresses proposées dans le sélecteur (Exigences 1.18,
* 11.10). Chargées à l'initialisation, triées par Dénomination côté backend.
*/
readonly adressesNommees = signal<AdresseNommee[]>([]);
/** Indique si l'autocomplétion BAN est temporairement indisponible (Exigence 1.16). */
readonly banIndisponible = signal(false);
/** Soumission en cours (désactive le bouton). */
readonly soumissionEnCours = signal(false);
/** Profil (pour le bouton « Utiliser mon domicile »). */
private readonly profil = signal<Profil | null>(null);
/** Coordonnées mémorisées après sélection BAN (absentes si saisie manuelle). */
private readonly coordDepart = signal<Coordonnees | null>(null);
private readonly coordArrivee = signal<Coordonnees | null>(null);
/**
* Indique que l'adresse du champ provient d'une source fiable et résolvable
* (suggestion BAN, Adresse_Nommee du carnet, domicile du profil, ou pré-remplissage
* depuis un trajet existant en insertion) — même si les coordonnées ne sont pas
* disponibles côté frontend, le Backend saura les résoudre (Exigence 2.4). Dans ce
* cas, l'avertissement de coordonnées manquantes (Exigence 1.17) ne doit PAS s'afficher.
* Toute frappe manuelle repasse le champ en source non fiable.
*/
private readonly sourceFiableDepart = signal(false);
private readonly sourceFiableArrivee = signal(false);
/** Flux de saisie utilisateur par champ, pour l'autocomplétion débouncée. */
private readonly saisieDepart$ = new Subject<string>();
private readonly saisieArrivee$ = new Subject<string>();
private readonly destroy$ = new Subject<void>();
ngOnInit(): void {
this.mode = this.determinerMode();
this.form = this.construireFormulaire();
this.appliquerPreRemplissage();
this.configurerAutocompletion('depart', this.saisieDepart$);
this.configurerAutocompletion('arrivee', this.saisieArrivee$);
this.chargerProfil();
this.chargerAdressesNommees();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// --- Getters template ---
get dateCtrl() {
return this.form.get('date')!;
}
get departCtrl() {
return this.form.get('adresseDepart')!;
}
get arriveeCtrl() {
return this.form.get('adresseArrivee')!;
}
get commentaireCtrl() {
return this.form.get('commentaire')!;
}
/** Vrai si le domicile est configuré (bouton « Utiliser mon domicile » actif, Exigences 10.5, 10.6). */
get domicileDisponible(): boolean {
return !!this.profil()?.adresseDomicile?.trim();
}
/** Libellé de l'action principale selon le mode. */
get libelleAction(): string {
return this.mode === 'edit' ? 'Enregistrer les modifications' : 'Ajouter le trajet';
}
/**
* Avertissement (Exigence 1.17) : une adresse a été saisie sans coordonnées
* (ni sélection BAN, ni domicile résolu). La distance pourrait ne pas être calculée.
*/
get avertissementCoordonneesManquantes(): boolean {
// On n'alerte que pour une adresse SAISIE MANUELLEMENT (source non fiable) sans
// coordonnées. Les adresses issues du domicile, du carnet, d'une suggestion BAN ou
// d'un pré-remplissage d'insertion sont résolvables côté Backend : pas d'alerte.
const departManquant =
!!this.departCtrl.value?.trim() && !this.coordDepart() && !this.sourceFiableDepart();
const arriveeManquante =
!!this.arriveeCtrl.value?.trim() && !this.coordArrivee() && !this.sourceFiableArrivee();
return departManquant || arriveeManquante;
}
// --- Autocomplétion BAN ---
/** Signale une frappe dans le champ adresse de départ. */
onSaisieDepart(valeur: string): void {
// Toute frappe manuelle invalide la sélection BAN précédente (coordonnées obsolètes)
// et repasse le champ en source non fiable (adresse potentiellement non résolvable).
this.coordDepart.set(null);
this.sourceFiableDepart.set(false);
this.saisieDepart$.next(valeur);
}
/** Signale une frappe dans le champ adresse d'arrivée. */
onSaisieArrivee(valeur: string): void {
this.coordArrivee.set(null);
this.sourceFiableArrivee.set(false);
this.saisieArrivee$.next(valeur);
}
/** Applique une suggestion BAN sélectionnée sur le champ départ (Exigence 1.14). */
choisirSuggestionDepart(feature: BANFeature): void {
this.appliquerAdresse('depart', feature.properties.label, {
latitude: feature.geometry.coordinates[1],
longitude: feature.geometry.coordinates[0]
});
this.suggestionsDepart.set([]);
}
/** Applique une suggestion BAN sélectionnée sur le champ arrivée (Exigence 1.14). */
choisirSuggestionArrivee(feature: BANFeature): void {
this.appliquerAdresse('arrivee', feature.properties.label, {
latitude: feature.geometry.coordinates[1],
longitude: feature.geometry.coordinates[0]
});
this.suggestionsArrivee.set([]);
}
/** Remplit le champ départ avec l'Adresse_Domicile du profil (Exigence 10.4). */
utiliserDomicileDepart(): void {
this.remplirDepuisDomicile('depart');
}
/** Remplit le champ arrivée avec l'Adresse_Domicile du profil (Exigence 10.4). */
utiliserDomicileArrivee(): void {
this.remplirDepuisDomicile('arrivee');
}
// --- Sélecteur d'Adresse_Nommee (carnet d'adresses) ---
/**
* Applique l'Adresse_Nommee sélectionnée (par son id) sur le champ départ
* (Exigences 1.19, 11.11, 15.16). En insertion, remplace le contenu pré-rempli.
*/
choisirAdresseNommeeDepart(id: string): void {
this.appliquerAdresseNommee('depart', id);
}
/**
* Applique l'Adresse_Nommee sélectionnée (par son id) sur le champ arrivée
* (Exigences 1.19, 11.11, 15.16). En insertion, remplace le contenu pré-rempli.
*/
choisirAdresseNommeeArrivee(id: string): void {
this.appliquerAdresseNommee('arrivee', id);
}
// --- Soumission ---
/** Soumet le formulaire (création ou modification selon le mode). */
soumettre(): void {
if (this.form.invalid) {
// Affiche les erreurs synchrones sur tous les champs (Exigences 1.4, 1.5).
this.form.markAllAsTouched();
return;
}
this.soumissionEnCours.set(true);
if (this.mode === 'edit' && this.trajet) {
this.modifier(this.trajet.id, this.construireUpdateDto());
return;
}
this.creer(this.construireCreateDto());
}
/** Annule la saisie et émet l'événement d'annulation. */
annuler(): void {
this.cancelled.emit();
}
// --- Interne ---
private determinerMode(): TrajetFormMode {
if (this.trajet) {
return 'edit';
}
if (this.prefill) {
return 'insert';
}
return 'create';
}
private construireFormulaire(): FormGroup {
return this.fb.group({
// Le contrôle stocke la valeur ISO `yyyy-MM-dd` (format natif de l'input date) ;
// la conversion vers JJ/MM/AAAA attendue par le backend est faite dans les DTO.
date: ['', [Validators.required, dateFutureInterditeValidator(() => this.maxDate)]],
adresseDepart: [
'',
[Validators.required, Validators.minLength(1), Validators.maxLength(ADRESSE_MAX)]
],
adresseArrivee: [
'',
[Validators.required, Validators.minLength(1), Validators.maxLength(ADRESSE_MAX)]
],
commentaire: ['', [Validators.maxLength(COMMENTAIRE_MAX)]]
});
}
private appliquerPreRemplissage(): void {
if (this.mode === 'edit' && this.trajet) {
this.form.patchValue({
// L'input date natif attend une valeur ISO `yyyy-MM-dd`.
date: this.versIsoInput(this.trajet.date),
adresseDepart: this.trajet.adresseDepart,
adresseArrivee: this.trajet.adresseArrivee,
commentaire: this.trajet.commentaire ?? ''
});
// En édition, on repart sans coordonnées locales : elles ne seront recalculées
// côté backend que si l'adresse est modifiée (Exigence 5.2/5.3). Les adresses du
// trajet existant sont déjà résolues → source fiable, pas d'avertissement tant
// que l'utilisateur ne les modifie pas (une frappe repassera en source non fiable).
if (this.trajet.adresseDepart?.trim()) {
this.sourceFiableDepart.set(true);
}
if (this.trajet.adresseArrivee?.trim()) {
this.sourceFiableArrivee.set(true);
}
this.vehiculeId = this.trajet.vehiculeId ?? this.vehiculeId;
return;
}
// Création / insertion : pré-remplit la date avec la journée affichée si fournie.
const dateDefaut = this.versIsoInput(this.dateParDefaut ?? '');
if (dateDefaut) {
this.form.patchValue({ date: dateDefaut });
}
if (this.mode === 'insert' && this.prefill) {
this.form.patchValue({
adresseDepart: this.prefill.adresseDepart ?? '',
adresseArrivee: this.prefill.adresseArrivee ?? ''
});
// Les adresses pré-remplies proviennent de trajets existants (déjà résolus par le
// Backend) : source fiable, pas d'avertissement de coordonnées manquantes.
if (this.prefill.adresseDepart?.trim()) {
this.sourceFiableDepart.set(true);
}
if (this.prefill.adresseArrivee?.trim()) {
this.sourceFiableArrivee.set(true);
}
if (this.prefill.vehiculeId != null) {
this.vehiculeId = this.prefill.vehiculeId;
}
}
}
private configurerAutocompletion(champ: ChampAdresse, source$: Subject<string>): void {
source$
.pipe(
debounceTime(BAN_DEBOUNCE_MS),
map((valeur) => (valeur ?? '').trim()),
distinctUntilChanged(),
filter((valeur) => valeur.length >= BAN_MIN_CARACTERES),
switchMap((valeur) =>
this.banService.searchAdresses(valeur).pipe(
map((features) => ({ ok: true as const, features })),
catchError(() => of({ ok: false as const, features: [] as BANFeature[] }))
)
),
takeUntil(this.destroy$)
)
.subscribe((resultat) => {
// Indisponibilité BAN → mode dégradé, saisie libre autorisée (Exigence 1.16).
this.banIndisponible.set(!resultat.ok);
if (champ === 'depart') {
this.suggestionsDepart.set(resultat.features);
} else {
this.suggestionsArrivee.set(resultat.features);
}
});
}
private appliquerAdresse(
champ: ChampAdresse,
libelle: string,
coord: Coordonnees | null
): void {
const controle = champ === 'depart' ? this.departCtrl : this.arriveeCtrl;
controle.setValue(libelle);
controle.markAsTouched();
if (champ === 'depart') {
this.coordDepart.set(coord);
this.sourceFiableDepart.set(true);
this.suggestionsDepart.set([]);
} else {
this.coordArrivee.set(coord);
this.sourceFiableArrivee.set(true);
this.suggestionsArrivee.set([]);
}
}
/**
* Applique une Adresse_Nommee sur le champ visé (Exigences 1.19, 11.11, 15.16) :
* remplit le texte d'adresse et associe ses coordonnées via `appliquerAdresse`.
* Si l'Adresse_Nommee n'a pas de coordonnées (géocodage échoué), le texte est rempli
* mais les coordonnées restent nulles — le Backend les résoudra (Exigence 2.4).
* En mode insertion, remplace le contenu pré-rempli (Exigence 11.11).
*/
private appliquerAdresseNommee(champ: ChampAdresse, id: string): void {
if (!id) {
return;
}
const adresse = this.adressesNommees().find((a) => String(a.id) === id);
if (!adresse) {
return;
}
const coord =
adresse.latitude != null && adresse.longitude != null
? { latitude: adresse.latitude, longitude: adresse.longitude }
: null;
this.appliquerAdresse(champ, adresse.adresse, coord);
}
private remplirDepuisDomicile(champ: ChampAdresse): void {
const adresse = this.profil()?.adresseDomicile?.trim();
if (!adresse) {
return;
}
// On remplit le libellé du domicile ; les coordonnées seront résolues côté
// backend via le Cache_Adresses ou la BAN (Exigence 2.4). On ne dispose pas
// des coordonnées du domicile dans le profil exposé au frontend.
const controle = champ === 'depart' ? this.departCtrl : this.arriveeCtrl;
controle.setValue(adresse);
controle.markAsTouched();
if (champ === 'depart') {
this.coordDepart.set(null);
this.sourceFiableDepart.set(true);
this.suggestionsDepart.set([]);
} else {
this.coordArrivee.set(null);
this.sourceFiableArrivee.set(true);
this.suggestionsArrivee.set([]);
}
}
private chargerProfil(): void {
this.profilService
.getProfil()
.pipe(
catchError(() => of(null)),
takeUntil(this.destroy$)
)
.subscribe((profil) => this.profil.set(profil));
}
/**
* Charge les Adresses_Nommées du Carnet_Adresses pour alimenter le sélecteur
* (Exigences 1.18, 11.10). En cas d'échec, on reste en mode saisie manuelle : le
* sélecteur affiche simplement une liste vide, sans bloquer le formulaire.
*/
private chargerAdressesNommees(): void {
this.adresseNommeeService
.getAdressesNommees()
.pipe(
catchError(() => of([] as AdresseNommee[])),
takeUntil(this.destroy$)
)
.subscribe((adresses) => this.adressesNommees.set(adresses));
}
private construireCreateDto(): CreateTrajet {
const coordDepart = this.coordDepart();
const coordArrivee = this.coordArrivee();
return {
date: this.isoVersJjMmAAAA(this.dateCtrl.value),
adresseDepart: this.departCtrl.value.trim(),
departLatitude: coordDepart?.latitude ?? null,
departLongitude: coordDepart?.longitude ?? null,
adresseArrivee: this.arriveeCtrl.value.trim(),
arriveeLatitude: coordArrivee?.latitude ?? null,
arriveeLongitude: coordArrivee?.longitude ?? null,
commentaire: this.normaliserCommentaire(),
vehiculeId: this.vehiculeId
};
}
private construireUpdateDto(): UpdateTrajet {
const coordDepart = this.coordDepart();
const coordArrivee = this.coordArrivee();
return {
date: this.isoVersJjMmAAAA(this.dateCtrl.value),
adresseDepart: this.departCtrl.value.trim(),
departLatitude: coordDepart?.latitude ?? null,
departLongitude: coordDepart?.longitude ?? null,
adresseArrivee: this.arriveeCtrl.value.trim(),
arriveeLatitude: coordArrivee?.latitude ?? null,
arriveeLongitude: coordArrivee?.longitude ?? null,
commentaire: this.normaliserCommentaire(),
vehiculeId: this.vehiculeId
};
}
private normaliserCommentaire(): string | null {
const valeur = (this.commentaireCtrl.value ?? '').trim();
return valeur.length > 0 ? valeur : null;
}
private creer(dto: CreateTrajet): void {
this.trajetService
.createTrajet(dto)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (trajet) => {
this.soumissionEnCours.set(false);
this.notifications.success('Trajet enregistré.');
this.created.emit(trajet);
this.reinitialiser();
},
error: (err) => this.gererErreurSoumission(err)
});
}
private modifier(id: number, dto: UpdateTrajet): void {
this.trajetService
.updateTrajet(id, dto)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (trajet) => {
this.soumissionEnCours.set(false);
this.notifications.success('Trajet modifié.');
this.updated.emit(trajet);
},
error: (err) => this.gererErreurSoumission(err)
});
}
private gererErreurSoumission(erreur: unknown): void {
this.soumissionEnCours.set(false);
// Cas serveur indisponible (timeout 10 s, Exigence 1.9) : on conserve la saisie.
// La notification d'indisponibilité est déjà émise par l'intercepteur.
if (erreur instanceof ServeurIndisponibleError) {
return;
}
this.notifications.error("L'enregistrement du trajet a échoué. Veuillez vérifier les données.");
}
private reinitialiser(): void {
this.form.reset({
date: '',
adresseDepart: '',
adresseArrivee: '',
commentaire: ''
});
this.coordDepart.set(null);
this.coordArrivee.set(null);
this.sourceFiableDepart.set(false);
this.sourceFiableArrivee.set(false);
this.suggestionsDepart.set([]);
this.suggestionsArrivee.set([]);
this.banIndisponible.set(false);
}
/** Convertit une date ISO (yyyy-MM-dd) en JJ/MM/AAAA attendue par le backend. */
private isoVersJjMmAAAA(iso: string): string {
const valeur = (iso ?? '').trim();
if (!valeur) {
return '';
}
// Date déjà au format JJ/MM/AAAA : renvoyée telle quelle.
if (estDateJjMmAAAAValide(valeur)) {
return valeur;
}
const correspondance = /^(\d{4})-(\d{2})-(\d{2})/.exec(valeur);
if (!correspondance) {
return valeur;
}
const [, annee, mois, jour] = correspondance;
return `${jour}/${mois}/${annee}`;
}
/**
* Convertit une date (ISO `yyyy-MM-dd` ou JJ/MM/AAAA) vers la valeur ISO
* `yyyy-MM-dd` attendue par l'input date natif. Retourne `''` si non convertible.
*/
private versIsoInput(valeur: string): string {
const v = (valeur ?? '').trim();
if (!v) {
return '';
}
// Déjà ISO (éventuellement avec suffixe horaire) : on tronque à 10 caractères.
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(v);
if (iso) {
return `${iso[1]}-${iso[2]}-${iso[3]}`;
}
// Format JJ/MM/AAAA → ISO.
const jjmm = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(v);
if (jjmm) {
return `${jjmm[3]}-${jjmm[2]}-${jjmm[1]}`;
}
return '';
}
/** Date du jour au format ISO local `yyyy-MM-dd`. */
private isoAujourdHui(): string {
const d = new Date();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
}
/**
* Validateur pour un contrôle de date ISO `yyyy-MM-dd` : rejette une date postérieure
* à `maxDateIso()` (aujourd'hui) via l'erreur `{ dateFuture: true }` (Exigence 12.5).
* Une valeur vide est laissée à `Validators.required`.
*/
function dateFutureInterditeValidator(maxDateIso: () => string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const valeur = control.value;
if (valeur === null || valeur === undefined || valeur === '') {
return null;
}
if (typeof valeur !== 'string') {
return { dateInvalide: true };
}
// Comparaison lexicographique valide sur des dates ISO `yyyy-MM-dd`.
return valeur.slice(0, 10) > maxDateIso() ? { dateFuture: true } : null;
};
}
@@ -0,0 +1,110 @@
@if (trajetsTries().length === 0) {
<p class="liste-vide" role="status">Aucun trajet pour cette journée.</p>
} @else {
<ul
class="trajets"
cdkDropList
(cdkDropListDropped)="onDrop($event)"
role="list"
>
@for (trajet of trajetsTries(); track trajet.id; let i = $index) {
<li
class="trajet"
cdkDrag
[cdkDragDisabled]="operationEnCours()"
role="listitem"
(mouseenter)="onSurvol(trajet.id)"
(mouseleave)="onSurvol(null)"
>
<!-- Poignée de glisser-déposer (Exigence 11.9). -->
<button
type="button"
class="poignee"
cdkDragHandle
[disabled]="operationEnCours()"
aria-label="Déplacer le trajet pour le réordonner"
title="Glisser pour réordonner"
>
⠿
</button>
<div class="trajet__infos">
<div class="trajet__ligne">
<span class="trajet__date">{{ trajet.date | date: 'dd/MM/yyyy' }}</span>
@if (trajet.commentaire) {
<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
type="button"
class="action action--icone"
(click)="modifier(trajet)"
[disabled]="operationEnCours()"
title="Modifier ce trajet"
aria-label="Modifier ce trajet"
>
✏️
</button>
<button
type="button"
class="action action--icone action--danger"
(click)="demanderSuppression(trajet)"
[disabled]="operationEnCours()"
title="Supprimer ce trajet"
aria-label="Supprimer ce trajet"
>
🗑️
</button>
</div>
<div class="trajet__actions-ligne">
@if (!estDernier(i)) {
<button
type="button"
class="action action--icone"
(click)="insererAvantSuivant(i)"
[disabled]="operationEnCours()"
title="Insérer un trajet avant le suivant"
aria-label="Insérer un trajet avant le suivant"
>
↥
</button>
}
<button
type="button"
class="action action--icone"
(click)="insererApres(trajet)"
[disabled]="operationEnCours()"
title="Insérer un trajet après celui-ci"
aria-label="Insérer un trajet après celui-ci"
>
↧
</button>
</div>
</div>
</li>
}
</ul>
}
<!-- Confirmation de suppression (Exigence 5.4). -->
<app-confirm-dialog
[open]="trajetASupprimer() !== null"
titre="Supprimer le trajet"
message="Cette action est irréversible. Voulez-vous vraiment supprimer ce trajet ?"
libelleConfirmer="Supprimer"
libelleAnnuler="Annuler"
[danger]="true"
(confirmed)="confirmerSuppression()"
(cancelled)="annulerSuppression()"
></app-confirm-dialog>
@@ -0,0 +1,164 @@
.liste-vide {
margin: 0;
padding: 1.5rem;
text-align: center;
color: #6b7280;
font-style: italic;
}
.trajets {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.trajet {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 0.75rem;
border: 1px solid #e2e6ea;
border-radius: 8px;
background: #fff;
&.cdk-drag-preview {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
}
&.cdk-drag-placeholder {
opacity: 0.4;
}
&__infos {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
&__ligne {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__date {
font-weight: 600;
font-size: 0.85rem;
color: #475569;
}
&__commentaire {
font-size: 0.78rem;
color: #6b7280;
font-style: italic;
line-height: 1.3;
word-break: break-word;
// Séparateur visuel discret entre la date et le commentaire.
&::before {
content: '— ';
color: #cbd5e1;
}
}
&__adresses {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
// Adresses en taille réduite (secondaires par rapport à la date/commentaire).
font-size: 0.8rem;
color: #334155;
}
&__fleche {
color: #94a3b8;
}
&__distance {
flex: 0 0 auto;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
}
&__actions {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: flex-end;
}
&__actions-ligne {
display: flex;
gap: 0.4rem;
justify-content: flex-end;
}
}
.cdk-drag-animating {
transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
}
.poignee {
flex: 0 0 auto;
border: none;
background: transparent;
color: #94a3b8;
font-size: 1.1rem;
cursor: grab;
padding: 0.25rem;
&:active {
cursor: grabbing;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.action {
padding: 0.35rem 0.65rem;
border: 1px solid #c4c9d1;
border-radius: 6px;
background: #fff;
color: #33475b;
font-size: 0.8rem;
cursor: pointer;
&:hover:not(:disabled) {
background: #f3f5f8;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--danger {
border-color: #e3a3a5;
color: #d13438;
&:hover:not(:disabled) {
background: #fdecec;
}
}
&--icone {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
font-size: 1rem;
line-height: 1;
}
}
@@ -0,0 +1,276 @@
import { CommonModule } from '@angular/common';
import {
CdkDragDrop,
DragDropModule,
moveItemInArray
} from '@angular/cdk/drag-drop';
import {
Component,
EventEmitter,
Input,
Output,
inject,
signal
} from '@angular/core';
import { ReordonnerTrajets, Trajet, TrajetPosition } from '../../core/models';
import { NotificationService } from '../../core/services/notification.service';
import { TrajetService } from '../../core/services/trajet.service';
import { ServeurIndisponibleError } from '../../core/interceptors/timeout.interceptor';
import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component';
import { TrajetInsertionPrefill } from '../trajet-form/trajet-form.component';
/**
* Contexte d'insertion émis vers le conteneur (ConsultationJour) : le pré-remplissage
* du `TrajetFormComponent` et le repère de position.
*
* - `insérer après` (Exigence 11.1/11.2) : `adresseDepart` = arrivée du trajet courant,
* `positionApres` = Position du trajet courant.
* - `insérer avant le suivant` (Exigence 11.3/11.4) : les deux adresses sont pré-remplies
* et la position d'insertion cible se situe entre `positionApres` et `positionAvant`.
*
* Le composant conteneur reste responsable de l'ouverture du formulaire en mode
* `insert` et du calcul final de position côté backend.
*/
export interface TrajetInsertionContext {
prefill: TrajetInsertionPrefill;
/** Position du trajet après lequel on insère (borne basse). */
positionApres: number;
/** Position du trajet suivant (borne haute) — présente pour « insérer avant le suivant ». */
positionAvant?: number;
}
/**
* `TrajetListComponent` — liste des trajets d'une journée (Exigences 5.1, 5.4, 5.7, 11.6, 11.9).
*
* Affichage : une ligne par trajet, triée par `Position` croissante, avec date,
* adresse de départ, adresse d'arrivée, distance et une icône commentaire (avec
* tooltip) lorsqu'un commentaire est présent.
*
* Actions par trajet :
* - modifier → émet `edit(trajet)` (le conteneur ouvre `TrajetFormComponent` pré-rempli) ;
* - supprimer → confirmation puis `TrajetService.deleteTrajet` (Exigence 5.4) ;
* - insérer après → émet `insertAfter` avec `adresseDepart` = arrivée du trajet courant ;
* - insérer avant le suivant (si le trajet n'est pas le dernier) → émet `insertBetween`
* avec les deux adresses pré-remplies.
*
* Réordonnancement par glisser-déposer (Angular CDK DragDrop, Exigence 11.9) : au dépôt,
* les positions sont recalculées et `TrajetService.reordonner` est appelé.
*
* Rafraîchissement (Exigences 5.7, 11.6) : après suppression ou réordonnancement réussi,
* le composant émet `changed` afin que le conteneur recharge la journée sans rechargement
* complet de la page (< 1 000 ms).
*
* Le composant est réutilisable : la sélection de véhicule, le chargement des trajets et
* l'ouverture du formulaire sont gérés par le conteneur `ConsultationJourComponent` (16.4).
*/
@Component({
selector: 'app-trajet-list',
standalone: true,
imports: [CommonModule, DragDropModule, ConfirmDialogComponent],
templateUrl: './trajet-list.component.html',
styleUrls: ['./trajet-list.component.scss']
})
export class TrajetListComponent {
private readonly trajetService = inject(TrajetService);
private readonly notifications = inject(NotificationService);
/**
* Trajets de la journée. Toujours affichés triés par `Position` croissante :
* une copie triée est maintenue dans `trajetsTries` à chaque changement d'entrée.
*/
@Input() set trajets(valeur: Trajet[] | null) {
this._trajets = valeur ?? [];
this.trajetsTries.set([...this._trajets].sort((a, b) => a.position - b.position));
}
get trajets(): Trajet[] {
return this._trajets;
}
private _trajets: Trajet[] = [];
/** Émis lorsque l'utilisateur demande la modification d'un trajet. */
@Output() edit = new EventEmitter<Trajet>();
/** Émis pour insérer un nouveau trajet après le trajet courant (Exigence 11.1/11.2). */
@Output() insertAfter = new EventEmitter<TrajetInsertionContext>();
/** Émis pour insérer un trajet entre le courant et le suivant (Exigence 11.3/11.4). */
@Output() insertBetween = new EventEmitter<TrajetInsertionContext>();
/**
* Émis lorsque la liste a changé (suppression, réordonnancement) et que le
* conteneur doit recharger la journée / le résumé mensuel (Exigences 5.7, 11.6).
*/
@Output() changed = new EventEmitter<void>();
/**
* Émis au survol d'une ligne de trajet : l'id du trajet survolé au `mouseenter`,
* `null` au `mouseleave`. Permet au conteneur de mettre en évidence le tracé
* correspondant sur la carte.
*/
@Output() survol = new EventEmitter<number | null>();
/** Émet l'id du trajet survolé (mouseenter) ou null (mouseleave). */
onSurvol(id: number | null): void {
this.survol.emit(id);
}
/** Liste triée par Position (source d'affichage). */
readonly trajetsTries = signal<Trajet[]>([]);
/** Trajet en attente de confirmation de suppression (null si aucune). */
readonly trajetASupprimer = signal<Trajet | null>(null);
/** True pendant l'appel de suppression / réordonnancement (désactive les actions). */
readonly operationEnCours = signal(false);
// --- Helpers d'affichage ---
/** Vrai si le trajet est le dernier de la journée (pas d'« insérer avant le suivant »). */
estDernier(index: number): boolean {
return index === this.trajetsTries().length - 1;
}
/** Distance formatée à 2 décimales, ou « — » si non calculée (Exigence 2.4). */
distanceAffichee(trajet: Trajet): string {
if (trajet.distance == null) {
return '—';
}
return `${trajet.distance.toFixed(2)} km`;
}
/** Identité stable pour le suivi de la liste (drag & drop). */
trackById(_index: number, trajet: Trajet): number {
return trajet.id;
}
// --- Actions par trajet ---
/** Demande la modification d'un trajet (Exigence 5.1). */
modifier(trajet: Trajet): void {
this.edit.emit(trajet);
}
/**
* Insérer après le trajet courant : pré-remplit l'adresse de départ avec
* l'adresse d'arrivée du trajet courant (Exigence 11.1/11.2).
*/
insererApres(trajet: Trajet): void {
this.insertAfter.emit({
prefill: {
adresseDepart: trajet.adresseArrivee,
vehiculeId: trajet.vehiculeId
},
positionApres: trajet.position
});
}
/**
* Insérer avant le trajet suivant : pré-remplit l'adresse de départ avec l'arrivée
* du trajet courant et l'adresse d'arrivée avec le départ du trajet suivant
* (Exigence 11.3/11.4).
*/
insererAvantSuivant(index: number): void {
const trajetsTries = this.trajetsTries();
const courant = trajetsTries[index];
const suivant = trajetsTries[index + 1];
if (!suivant) {
return;
}
this.insertBetween.emit({
prefill: {
adresseDepart: courant.adresseArrivee,
adresseArrivee: suivant.adresseDepart,
vehiculeId: courant.vehiculeId
},
positionApres: courant.position,
positionAvant: suivant.position
});
}
// --- Suppression (avec confirmation, Exigence 5.4) ---
/** Ouvre la confirmation de suppression. */
demanderSuppression(trajet: Trajet): void {
this.trajetASupprimer.set(trajet);
}
/** Annule la suppression. */
annulerSuppression(): void {
this.trajetASupprimer.set(null);
}
/** Confirme et exécute la suppression (Exigence 5.4), puis notifie le rafraîchissement. */
confirmerSuppression(): void {
const trajet = this.trajetASupprimer();
if (!trajet) {
return;
}
this.trajetASupprimer.set(null);
this.operationEnCours.set(true);
this.trajetService.deleteTrajet(trajet.id).subscribe({
next: () => {
this.operationEnCours.set(false);
this.notifications.success('Trajet supprimé.');
// Rafraîchissement sans rechargement de page (Exigences 5.7, 11.6).
this.changed.emit();
},
error: (err) => this.gererErreur(err, 'La suppression du trajet a échoué.')
});
}
// --- Glisser-déposer (Exigence 11.9) ---
/**
* Au dépôt d'un trajet : réordonne localement, recalcule les positions (pas de 1.0)
* et persiste via `PATCH /api/trajets/reordonner`. En cas d'échec, l'ordre local est
* rétabli à partir des positions d'origine.
*/
onDrop(event: CdkDragDrop<Trajet[]>): void {
if (event.previousIndex === event.currentIndex) {
return;
}
const ordreInitial = [...this.trajetsTries()];
const reordonnes = [...this.trajetsTries()];
moveItemInArray(reordonnes, event.previousIndex, event.currentIndex);
// Recalcule les positions séquentielles (1.0, 2.0, …) selon le nouvel ordre.
const positions: TrajetPosition[] = reordonnes.map((trajet, index) => ({
id: trajet.id,
position: index + 1
}));
// Reflète immédiatement le nouvel ordre dans l'UI (feedback < 1 000 ms).
this.trajetsTries.set(reordonnes.map((trajet, index) => ({
...trajet,
position: index + 1
})));
this.operationEnCours.set(true);
const dto: ReordonnerTrajets = { trajets: positions };
this.trajetService.reordonner(dto).subscribe({
next: () => {
this.operationEnCours.set(false);
// Recharge la journée / le résumé mensuel (Exigences 5.7, 11.6).
this.changed.emit();
},
error: (err) => {
// Rétablit l'ordre précédent en cas d'échec.
this.trajetsTries.set(ordreInitial);
this.gererErreur(err, 'Le réordonnancement a échoué.');
}
});
}
private gererErreur(erreur: unknown, message: string): void {
this.operationEnCours.set(false);
// L'intercepteur affiche déjà le message d'indisponibilité serveur (Exigence 1.9).
if (erreur instanceof ServeurIndisponibleError) {
return;
}
this.notifications.error(message);
}
}
@@ -0,0 +1,115 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
/**
* Dialog (modale) de proposition de réaffectation des trajets à un nouveau véhicule.
*
* Affichée lorsque la création d'un véhicule renvoie `reaffectationProposee = true`
* (des trajets dont la date >= date d'effet ont une puissance fiscale différente).
*
* Deux choix (Exigences 10.16 / 10.17) :
* - "Réaffecter au nouveau véhicule" → émet `decision` avec `accepter = true`.
* - "Conserver l'existant" → émet `decision` avec `accepter = false`.
*
* Composant modale minimaliste sans dépendance UI externe.
*/
@Component({
selector: 'app-reaffectation-dialog',
standalone: true,
imports: [CommonModule],
template: `
<div class="reaffectation-overlay" role="dialog" aria-modal="true" aria-labelledby="reaffectation-titre">
<div class="reaffectation-dialog">
<h2 id="reaffectation-titre">Réaffectation des trajets</h2>
<p class="reaffectation-message">
{{ nombreTrajetsConcernes }}
{{ nombreTrajetsConcernes > 1 ? 'trajets existants' : 'trajet existant' }}
{{ nombreTrajetsConcernes > 1 ? 'ont' : 'a' }} une date supérieure ou égale à la date
d'effet du nouveau véhicule mais {{ nombreTrajetsConcernes > 1 ? 'sont associés' : 'est associé' }}
à une puissance fiscale différente.
</p>
<p class="reaffectation-question">
Souhaitez-vous réaffecter {{ nombreTrajetsConcernes > 1 ? 'ces trajets' : 'ce trajet' }} au nouveau véhicule&nbsp;?
</p>
<div class="reaffectation-actions">
<button type="button" class="btn-secondaire" (click)="conserver()">
Conserver l'existant
</button>
<button type="button" class="btn-principal" (click)="reaffecter()">
Réaffecter au nouveau véhicule
</button>
</div>
</div>
</div>
`,
styles: [`
.reaffectation-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.reaffectation-dialog {
background: #fff;
border-radius: 8px;
padding: 1.5rem;
max-width: 480px;
width: calc(100% - 2rem);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.reaffectation-dialog h2 {
margin-top: 0;
font-size: 1.15rem;
}
.reaffectation-message {
color: #444;
}
.reaffectation-question {
font-weight: 600;
}
.reaffectation-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.25rem;
}
button {
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-principal {
background: #1565c0;
color: #fff;
border: 1px solid #1565c0;
}
.btn-secondaire {
background: #fff;
color: #333;
border: 1px solid #bbb;
}
`]
})
export class ReaffectationDialogComponent {
/** Nombre de trajets concernés par la réaffectation (Exigence 10.15). */
@Input() nombreTrajetsConcernes = 0;
/** Émis avec la décision de l'utilisateur : true = réaffecter, false = conserver. */
@Output() decision = new EventEmitter<boolean>();
/** "Réaffecter au nouveau véhicule" (accepter = true, Exigence 10.16). */
reaffecter(): void {
this.decision.emit(true);
}
/** "Conserver l'existant" (accepter = false, Exigence 10.17). */
conserver(): void {
this.decision.emit(false);
}
}
@@ -0,0 +1,79 @@
<section class="vehicules">
<h1>Véhicules</h1>
<h2>Ajouter un véhicule</h2>
<form [formGroup]="form" (ngSubmit)="enregistrer()" novalidate>
<div class="champ">
<label for="denomination">Dénomination</label>
<input id="denomination" type="text" formControlName="denomination" maxlength="100" placeholder="Ex. Marque, Modèle" />
@if (form.get('denomination')?.touched && form.get('denomination')?.invalid) {
<small class="erreur">La dénomination est obligatoire (1 à 100 caractères).</small>
}
</div>
<div class="champ">
<label for="plaque">Plaque d'immatriculation</label>
<input id="plaque" type="text" formControlName="plaque" maxlength="15" />
@if (form.get('plaque')?.touched && form.get('plaque')?.invalid) {
<small class="erreur">La plaque est obligatoire (1 à 15 caractères).</small>
}
</div>
<div class="champ">
<label for="puissanceFiscale">Puissance fiscale (CV)</label>
<input id="puissanceFiscale" type="number" min="1" step="1" formControlName="puissanceFiscale" />
@if (form.get('puissanceFiscale')?.touched && form.get('puissanceFiscale')?.invalid) {
<small class="erreur">La puissance fiscale doit être un entier positif.</small>
}
</div>
<div class="champ">
<label for="dateEffet">Date d'effet</label>
<input id="dateEffet" type="date" formControlName="dateEffet" [max]="aujourdhuiIso" />
@if (form.get('dateEffet')?.touched && form.get('dateEffet')?.errors?.['dateFuture']) {
<small class="erreur">La date d'effet ne peut pas être dans le futur.</small>
} @else if (form.get('dateEffet')?.touched && form.get('dateEffet')?.errors?.['required']) {
<small class="erreur">La date d'effet est obligatoire.</small>
}
</div>
<div class="actions">
<button type="submit" class="btn-principal" [disabled]="form.invalid || enregistrement">
Enregistrer
</button>
</div>
</form>
<h2>Historique des véhicules</h2>
@if (vehicules().length === 0) {
<p class="vide">Aucun véhicule enregistré.</p>
} @else {
<table class="historique">
<thead>
<tr>
<th>Dénomination</th>
<th>Plaque</th>
<th>Puissance fiscale</th>
<th>Date d'effet</th>
</tr>
</thead>
<tbody>
@for (vehicule of vehicules(); track vehicule.id) {
<tr>
<td>{{ vehicule.denomination }}</td>
<td>{{ vehicule.plaque }}</td>
<td>{{ vehicule.puissanceFiscale }} CV</td>
<td>{{ vehicule.dateEffet | date: 'dd/MM/yyyy' }}</td>
</tr>
}
</tbody>
</table>
}
</section>
@if (reaffectationEnAttente) {
<app-reaffectation-dialog
[nombreTrajetsConcernes]="nombreTrajetsConcernes"
(decision)="onDecisionReaffectation($event)"
></app-reaffectation-dialog>
}
@@ -0,0 +1,77 @@
.vehicules {
padding: 1.5rem;
max-width: 720px;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
h2 {
font-size: 1.1rem;
margin: 1.5rem 0 0.75rem;
}
.champ {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.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;
}
.erreur {
color: #c62828;
margin-top: 0.25rem;
}
.actions {
margin-top: 0.5rem;
}
.btn-principal {
padding: 0.55rem 1.25rem;
background: #1565c0;
color: #fff;
border: 1px solid #1565c0;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.btn-principal:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.vide {
color: #777;
}
.historique {
width: 100%;
border-collapse: collapse;
}
.historique th,
.historique td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e0e0e0;
}
.historique th {
font-weight: 600;
background: #f5f5f5;
}
@@ -0,0 +1,154 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { NotificationService } from '../../core/services/notification.service';
import { VehiculeService } from '../../core/services/vehicule.service';
import { CreateVehicule, Reaffectation, Vehicule, VehiculeCree } from '../../core/models';
import { ReaffectationDialogComponent } from './reaffectation-dialog.component';
/**
* Gestion des véhicules (Exigences 10.9, 10.10, 10.11, 10.15).
*
* - Affiche l'historique des véhicules (plaque, puissance fiscale, date d'effet),
* trié par date d'effet décroissante.
* - Formulaire d'ajout : plaque, puissance fiscale, date d'effet <= aujourd'hui
* (le sélecteur de date bloque les dates futures via l'attribut `max`).
* - À la création, si `reaffectationProposee` est true, ouvre le dialog de
* réaffectation.
*/
@Component({
selector: 'app-vehicules',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, ReaffectationDialogComponent],
templateUrl: './vehicules.component.html',
styleUrl: './vehicules.component.scss'
})
export class VehiculesComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly vehiculeService = inject(VehiculeService);
private readonly notification = inject(NotificationService);
form!: FormGroup;
/** Historique des véhicules trié par date d'effet décroissante (signal). */
readonly vehicules = signal<Vehicule[]>([]);
/** Date du jour au format ISO (yyyy-MM-dd) pour borner le sélecteur de date. */
readonly aujourdhuiIso = new Date().toISOString().slice(0, 10);
/** true pendant l'appel de création. */
enregistrement = false;
/** Résultat de création en attente de décision de réaffectation (dialog ouvert si non nul). */
reaffectationEnAttente: VehiculeCree | null = null;
ngOnInit(): void {
this.form = this.fb.group({
denomination: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]],
plaque: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(15)]],
puissanceFiscale: [null, [Validators.required, Validators.min(1)]],
dateEffet: ['', [Validators.required, this.dateNonFutureValidator()]]
});
this.chargerVehicules();
}
/** Charge et trie l'historique des véhicules. */
private chargerVehicules(): void {
this.vehiculeService.getVehicules().subscribe({
next: (liste) => {
this.vehicules.set(
[...liste].sort((a, b) => b.dateEffet.localeCompare(a.dateEffet))
);
},
error: () => {
this.notification.error("Impossible de charger l'historique des véhicules.");
}
});
}
/**
* Validateur bloquant les dates postérieures à aujourd'hui (Exigence 10.11).
* La valeur du contrôle est au format ISO (yyyy-MM-dd) issu de l'input date.
*/
private dateNonFutureValidator() {
return (control: { value: unknown }) => {
const valeur = control.value as string;
if (!valeur) {
return null;
}
return valeur > this.aujourdhuiIso ? { dateFuture: true } : null;
};
}
/** Enregistre un nouveau véhicule via `VehiculeService.createVehicule`. */
enregistrer(): void {
if (this.form.invalid || this.enregistrement) {
this.form.markAllAsTouched();
return;
}
const valeur = this.form.getRawValue();
const dto: CreateVehicule = {
denomination: (valeur.denomination ?? '').trim(),
plaque: (valeur.plaque ?? '').trim(),
puissanceFiscale: Number(valeur.puissanceFiscale),
dateEffet: this.isoVersJjMmAAAA(valeur.dateEffet)
};
this.enregistrement = true;
this.vehiculeService.createVehicule(dto).subscribe({
next: (resultat) => {
this.enregistrement = false;
this.notification.success('Véhicule enregistré.');
this.form.reset({ denomination: '', plaque: '', puissanceFiscale: null, dateEffet: '' });
this.chargerVehicules();
if (resultat.reaffectationProposee) {
this.reaffectationEnAttente = resultat;
}
},
error: () => {
this.enregistrement = false;
this.notification.error("Échec de l'enregistrement du véhicule.");
}
});
}
/** Nombre de trajets concernés par la réaffectation en attente. */
get nombreTrajetsConcernes(): number {
return this.reaffectationEnAttente?.nombreTrajetsConcernes ?? 0;
}
/**
* Décision de l'utilisateur dans le dialog de réaffectation.
* @param accepter true = réaffecter (Exigence 10.16), false = conserver (Exigence 10.17).
*/
onDecisionReaffectation(accepter: boolean): void {
const cree = this.reaffectationEnAttente;
if (!cree) {
return;
}
const dto: Reaffectation = { vehiculeId: cree.vehicule.id, accepter };
this.vehiculeService.reaffecter(dto).subscribe({
next: () => {
this.reaffectationEnAttente = null;
this.notification.success(
accepter ? 'Trajets réaffectés au nouveau véhicule.' : 'Trajets existants conservés.'
);
},
error: () => {
this.reaffectationEnAttente = null;
this.notification.error('Échec du traitement de la réaffectation.');
}
});
}
/** Conversion ISO (yyyy-MM-dd) → JJ/MM/AAAA attendu par le backend. */
private isoVersJjMmAAAA(iso: string): string {
const [annee, mois, jour] = iso.split('-');
return `${jour}/${mois}/${annee}`;
}
}
@@ -0,0 +1,31 @@
@if (open) {
<div
class="overlay"
role="presentation"
(click)="annuler()"
>
<div
class="dialog"
role="dialog"
aria-modal="true"
[attr.aria-label]="titre"
(click)="$event.stopPropagation()"
>
<h2 class="dialog__titre">{{ titre }}</h2>
<p class="dialog__message">{{ message }}</p>
<div class="dialog__actions">
<button type="button" class="btn-secondaire" (click)="annuler()">
{{ libelleAnnuler }}
</button>
<button
type="button"
class="btn-confirmer"
[class.btn-confirmer--danger]="danger"
(click)="confirmer()"
>
{{ libelleConfirmer }}
</button>
</div>
</div>
</div>
}
@@ -0,0 +1,72 @@
.overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.45);
z-index: 1000;
padding: 1rem;
}
.dialog {
background: #fff;
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
max-width: 420px;
width: 100%;
padding: 1.25rem 1.5rem 1.5rem;
&__titre {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: #1f2937;
}
&__message {
margin: 0 0 1.25rem;
color: #374151;
line-height: 1.4;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
button {
padding: 0.55rem 1.1rem;
border-radius: 6px;
font: inherit;
cursor: pointer;
}
}
}
.btn-secondaire {
border: 1px solid #c4c9d1;
background: #fff;
color: #33475b;
&:hover {
background: #f3f5f8;
}
}
.btn-confirmer {
border: none;
background: #2b6cb0;
color: #fff;
&:hover {
background: #245a94;
}
&--danger {
background: #d13438;
&:hover {
background: #b12b2e;
}
}
}
@@ -0,0 +1,56 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
/**
* `ConfirmDialogComponent` — boîte de dialogue de confirmation réutilisable.
*
* Affichée en surimpression (overlay modal) lorsque `open` est `true`. Émet
* `confirmed` si l'utilisateur valide et `cancelled` s'il annule (ou ferme via
* l'arrière-plan / la touche Échap). Le composant est purement présentiel : le
* composant parent contrôle l'ouverture/fermeture via `[open]` et réagit aux
* événements.
*
* Utilisé notamment pour la confirmation de suppression d'un trajet (Exigence 5.4).
*/
@Component({
selector: 'app-confirm-dialog',
standalone: true,
imports: [CommonModule],
templateUrl: './confirm-dialog.component.html',
styleUrls: ['./confirm-dialog.component.scss']
})
export class ConfirmDialogComponent {
/** Contrôle l'affichage de la boîte de dialogue. */
@Input() open = false;
/** Titre affiché en en-tête. */
@Input() titre = 'Confirmation';
/** Message principal (question posée à l'utilisateur). */
@Input() message = 'Êtes-vous sûr ?';
/** Libellé du bouton de confirmation. */
@Input() libelleConfirmer = 'Confirmer';
/** Libellé du bouton d'annulation. */
@Input() libelleAnnuler = 'Annuler';
/** Style d'accentuation du bouton de confirmation (danger pour suppression). */
@Input() danger = false;
/** Émis lorsque l'utilisateur confirme l'action. */
@Output() confirmed = new EventEmitter<void>();
/** Émis lorsque l'utilisateur annule ou ferme la boîte de dialogue. */
@Output() cancelled = new EventEmitter<void>();
/** Valide l'action. */
confirmer(): void {
this.confirmed.emit();
}
/** Annule l'action. */
annuler(): void {
this.cancelled.emit();
}
}
@@ -0,0 +1,64 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
/**
* Expression validant strictement le format JJ/MM/AAAA
* (2 chiffres / 2 chiffres / 4 chiffres).
*/
const FORMAT_JJ_MM_AAAA = /^(\d{2})\/(\d{2})\/(\d{4})$/;
/**
* Vérifie qu'une chaîne au format JJ/MM/AAAA correspond à une date réelle
* (mois 1–12, jour cohérent avec le mois et l'année bissextile).
*
* @returns `true` si le format est correct ET la date existe, sinon `false`.
*/
export function estDateJjMmAAAAValide(valeur: string): boolean {
const correspondance = FORMAT_JJ_MM_AAAA.exec(valeur);
if (!correspondance) {
return false;
}
const jour = Number(correspondance[1]);
const mois = Number(correspondance[2]);
const annee = Number(correspondance[3]);
if (mois < 1 || mois > 12) {
return false;
}
if (jour < 1) {
return false;
}
// Nombre de jours du mois (tient compte des années bissextiles via Date).
const joursDansMois = new Date(annee, mois, 0).getDate();
if (jour > joursDansMois) {
return false;
}
return true;
}
/**
* Validateur Angular pour un contrôle de date au format JJ/MM/AAAA.
*
* Retourne `{ dateInvalide: true }` si la valeur est présente mais mal formatée
* ou correspond à une date inexistante (Exigence 1.5). Une valeur vide est
* considérée valide ici : l'obligation de présence relève du validateur
* `Validators.required`.
*/
export function dateJjMmAAAAValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const valeur = control.value;
// Champ vide : on laisse `required` gérer l'obligation.
if (valeur === null || valeur === undefined || valeur === '') {
return null;
}
if (typeof valeur !== 'string' || !estDateJjMmAAAAValide(valeur)) {
return { dateInvalide: true };
}
return null;
};
}
View File
@@ -0,0 +1,12 @@
// Environnement de production.
// L'application est servie par nginx qui reverse-proxy `/api` vers le backend.
// L'API est donc consommée en same-origin via le chemin relatif `/api`.
export const environment = {
production: true,
// Same-origin : nginx proxifie /api -> backend:8080 (cf. nginx.conf / docker-compose).
apiUrl: '/api',
// Timeout HTTP appliqué par le TimeoutInterceptor (Exigence 1.9 : 10 secondes).
httpTimeoutMs: 10000,
// API Base Adresse Nationale (autocomplétion adresses françaises).
banUrl: 'https://api-adresse.data.gouv.fr'
};
+13
View File
@@ -0,0 +1,13 @@
// Environnement de développement.
// En dev, le frontend Angular tourne sur son propre serveur (ng serve, port 4200)
// et adresse directement le backend ASP.NET Core.
export const environment = {
production: false,
// URL de base de l'API backend en développement.
// Le backend écoute par défaut sur http://localhost:8080 (cf. Docker / Program.cs).
apiUrl: 'http://localhost:8080/api',
// Timeout HTTP appliqué par le TimeoutInterceptor (Exigence 1.9 : 10 secondes).
httpTimeoutMs: 10000,
// API Base Adresse Nationale (autocomplétion adresses françaises).
banUrl: 'https://api-adresse.data.gouv.fr'
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>TrajetKmTracker</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
// Application en mode ZONELESS (cf. app.config.ts : provideZonelessChangeDetection).
// Aucun polyfill zone.js n'est chargé pour l'exécution (polyfills vide dans
// angular.json). La détection de changement repose sur les signals Angular.
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));
+55
View File
@@ -0,0 +1,55 @@
/* You can add global styles to this file, and also import other style files */
/* Styles globaux des éléments de carte injectés par Leaflet (hors encapsulation
des composants) : pastilles de marqueurs et contenu des infobulles. */
.carte-marqueur {
background: transparent;
border: none;
}
.carte-marqueur__pastille {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
/* La couleur de fond et la bordure sont fournies en style inline par le composant
(couleur du tracé). Marqueur simple, sans texte : les points ne sont plus
numérotés (le survol identifie le trajet via surbrillance + infobulle). */
}
.carte-infobulle {
font-size: 0.8rem;
line-height: 1.35;
/* Autorise le retour à la ligne des adresses longues (pas de débordement horizontal). */
overflow-wrap: break-word;
word-break: break-word;
div {
margin: 2px 0;
}
}
/* Contrainte de taille des popups Leaflet pour éviter tout débordement de la zone
d'affichage. On borne la largeur ET on autorise le retour à la ligne du contenu
(Leaflet fixe sinon une largeur au contenu, ce qui tronquait les adresses longues). */
.leaflet-popup-content {
max-width: 240px;
max-height: 220px;
overflow-y: auto;
overflow-x: hidden;
overflow-wrap: break-word;
word-break: break-word;
white-space: normal;
}
/* Le conteneur du popup ne doit pas imposer une largeur supérieure à la zone visible. */
.leaflet-popup-content-wrapper {
max-width: 260px;
}
.carte-approx {
margin-top: 4px;
font-style: italic;
color: #b71c1c;
}