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
@@ -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);
}
}