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