Files
SchotzIAD/frontend/src/app/features/carnet-adresses/carnet-adresses.component.ts
T
MadJax 1c65c4dc74 Carnet d'adresses : ajout/modification via une popup modale
- Formulaire de création/modification affiché dans une fenêtre modale
  (bouton « Ajouter une adresse » + action « Modifier » d'une ligne)
- Fermeture au succès, sur « Annuler » ou clic sur l'arrière-plan
- Suggestions BAN rendues dans le flux du panneau pour éviter la troncature
- Specs : Exigence 15 (nouveau critère 17) + description CarnetAdressesComponent
2026-09-23 09:59:15 +02:00

280 lines
8.5 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);
/** Ouverture de la popup de création / modification. */
readonly modaleOuverte = signal(false);
/** 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,
});
this.modaleOuverte.set(true);
}
/** Ouvre la popup en mode création (formulaire vierge). */
ouvrirCreation(): void {
this.editionId.set(null);
this.latitude = null;
this.longitude = null;
this.suggestions.set([]);
this.form.reset({ denomination: '', adresse: '' });
this.modaleOuverte.set(true);
}
/** Ferme la popup uniquement lors d'un clic sur le fond (pas sur le panneau). */
fermerSurOverlay(event: MouseEvent): void {
if (event.target === event.currentTarget) {
this.annuler();
}
}
/** 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: '' });
this.modaleOuverte.set(false);
}
/** 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));
}),
);
}
}