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