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.
258 lines
7.9 KiB
TypeScript
258 lines
7.9 KiB
TypeScript
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));
|
|
}),
|
|
);
|
|
}
|
|
}
|