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