Files
SchotzIAD/frontend/src/app/features/trajet-list/trajet-list.component.ts
T
MadJax 058481c192 Liste : jour de semaine dans l'en-tête + bouton dédié d'ajout par journée
Retour d'usage : avec plusieurs journées ouvertes, viser le bouton principal
d'ajout en traversant la liste faisait dériver la date ciblée (le survol pilote
la journée affichée sur la carte).

- En-tête de journée réorganisé : jour de la semaine avant la date (ex. « Lundi
  15/09/2026 »), nombre de trajets à la suite, puis un bouton dédié « + » à
  l'extrémité droite (infobulle « Ajouter un trajet sur cette journée »).
- Le bouton « + » ouvre la création avec la date de SA journée, indépendamment
  de la date affichée sur la carte (Output ajouterSurJournee -> conteneur fixe
  lun. 21 sept. 2026 12:54:31 CEST avant ouverture via ouvrirCreationSur). stopPropagation pour ne pas
  déclencher le repliage/dépliage au clic.
- Style : fond bleu IAD / texte blanc par défaut, survol orange (cohérent avec
  les autres boutons d'action).
- Locale française enregistrée (registerLocaleData(localeFr) + LOCALE_ID
  'fr-FR' dans app.config.ts) pour l'affichage du jour de la semaine.

Specs : requirements.md (exigence 12, critères 6 et 24) et design.md
(TrajetListComponent, locale) mis à jour.
2026-09-21 12:54:31 +02:00

468 lines
17 KiB
TypeScript

import { CommonModule } from '@angular/common';
import {
CdkDragDrop,
DragDropModule,
moveItemInArray
} from '@angular/cdk/drag-drop';
import {
Component,
EventEmitter,
Input,
Output,
computed,
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;
}
/**
* Groupe de trajets d'une même journée. La liste mensuelle est affichée en groupes
* ordonnés par date croissante ; à l'intérieur d'un groupe, les trajets sont triés
* par `Position` croissante. Le glisser-déposer est limité à chaque groupe.
*/
export interface GroupeJour {
/** Date ISO `yyyy-MM-dd` de la journée. */
date: string;
/** Trajets de la journée, triés par position croissante. */
trajets: Trajet[];
}
/**
* `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 du mois. Ils sont regroupés par journée (`groupes`), chaque groupe
* étant trié par `Position` croissante, les groupes eux-mêmes étant ordonnés par
* date croissante.
*/
@Input() set trajets(valeur: Trajet[] | null) {
this._trajets = valeur ?? [];
this.trajetsSource.set([...this._trajets]);
this.reinitialiserRepliageSiMoisChange();
}
get trajets(): Trajet[] {
return this._trajets;
}
private _trajets: Trajet[] = [];
/**
* Journée (`yyyy-MM-dd`) actuellement affichée sur la carte : son groupe est mis
* en surbrillance dans la liste pour la repérer visuellement. `null` si aucune.
*/
@Input() set jourAffiche(valeur: string | null) {
this._jourAffiche.set((valeur ?? '').slice(0, 10) || null);
}
private readonly _jourAffiche = signal<string | null>(null);
/** É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 pour ajouter un Trajet sur une journée précise (date ISO `yyyy-MM-dd`), via
* le bouton « + » de l'en-tête de journée. Évite de dépendre de la date affichée sur
* la carte (pilotée par le survol), qui peut avoir dérivé pendant le déplacement de
* la souris vers le bouton.
*/
@Output() ajouterSurJournee = new EventEmitter<string>();
/**
* É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);
}
/** Source brute des trajets du mois (mise à jour à chaque changement d'entrée). */
private readonly trajetsSource = signal<Trajet[]>([]);
/**
* Dates (`yyyy-MM-dd`) des journées dépliées. Par défaut toutes les journées
* sont repliées ; au changement de mois, seule la dernière journée comportant
* des trajets est dépliée.
*/
private readonly joursOuverts = signal<Set<string>>(new Set<string>());
/** Préfixe `yyyy-MM` du mois actuellement affiché, pour détecter un changement de mois. */
private moisAffiche: string | null = null;
/**
* Trajets regroupés par journée, source d'affichage. Les groupes sont triés par
* date croissante ; à l'intérieur d'un groupe, les trajets sont triés par position
* croissante. Chaque groupe est une `cdkDropList` indépendante (drag limité au jour).
*/
readonly groupes = computed<GroupeJour[]>(() => {
const parJour = new Map<string, Trajet[]>();
for (const trajet of this.trajetsSource()) {
const cle = (trajet.date ?? '').slice(0, 10);
const liste = parJour.get(cle);
if (liste) {
liste.push(trajet);
} else {
parJour.set(cle, [trajet]);
}
}
return [...parJour.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, trajets]) => ({
date,
trajets: trajets.sort((a, b) => a.position - b.position)
}));
});
/**
* Identifiants des listes de dépôt, un par groupe/jour. Bien qu'on NE connecte PAS
* les listes entre elles (pas de déplacement inter-journées), fournir des ids
* distincts stabilise le suivi CDK et l'accessibilité.
*/
readonly dropListIds = computed<string[]>(() =>
this.groupes().map((g) => `drop-${g.date}`)
);
/** 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);
// --- Repliage par journée ---
/**
* Recalcule l'état de repliage quand le mois affiché change (nouvel ensemble de
* trajets appartenant à un autre mois). Toutes les journées sont repliées, sauf la
* dernière journée comportant des trajets qui est dépliée. Les mises à jour au sein
* du même mois (ajout / suppression / réordonnancement) conservent l'état courant.
*/
private reinitialiserRepliageSiMoisChange(): void {
const dates = this.trajetsSource()
.map((t) => (t.date ?? '').slice(0, 10))
.filter((d) => d.length === 10)
.sort();
const moisCourant = dates.length > 0 ? dates[0].slice(0, 7) : null;
if (moisCourant === this.moisAffiche) {
// Même mois : purge les dates disparues, conserve le reste de l'état.
const presentes = new Set(dates);
const conserves = new Set(
[...this.joursOuverts()].filter((d) => presentes.has(d))
);
this.joursOuverts.set(conserves);
return;
}
this.moisAffiche = moisCourant;
const dernierJour = dates.length > 0 ? dates[dates.length - 1] : null;
this.joursOuverts.set(dernierJour ? new Set<string>([dernierJour]) : new Set<string>());
}
/** Vrai si la journée est dépliée (ses trajets sont visibles). */
estOuvert(groupe: GroupeJour): boolean {
return this.joursOuverts().has(groupe.date);
}
/** Vrai si ce groupe correspond à la journée affichée sur la carte (surbrillance). */
estJourAffiche(groupe: GroupeJour): boolean {
return this._jourAffiche() === groupe.date;
}
/** Bascule le repliage d'une journée (déplie / replie). */
basculerJour(groupe: GroupeJour): void {
this.joursOuverts.update((ouverts) => {
const suivant = new Set(ouverts);
if (suivant.has(groupe.date)) {
suivant.delete(groupe.date);
} else {
suivant.add(groupe.date);
}
return suivant;
});
}
/**
* Demande l'ajout d'un Trajet sur cette journée (bouton « + » de l'en-tête). Empêche
* la propagation du clic vers le bouton de repliage (l'en-tête est elle-même cliquable).
*/
ajouterSur(groupe: GroupeJour, event: MouseEvent): void {
event.stopPropagation();
this.ajouterSurJournee.emit(groupe.date);
}
/**
* Déplie explicitement la journée `dateIso` (`yyyy-MM-dd`), sans toucher aux autres.
* Appelé par le conteneur après l'ajout/insertion d'un trajet pour rendre la journée
* concernée visible. Sans effet si la date n'est pas dans le mois affiché.
*/
ouvrirJour(dateIso: string): void {
const cle = (dateIso ?? '').slice(0, 10);
if (!cle) {
return;
}
this.joursOuverts.update((ouverts) => {
const suivant = new Set(ouverts);
suivant.add(cle);
return suivant;
});
}
/** Déplie TOUTES les journées du mois affiché (bouton « Ouvrir toutes les journées »). */
ouvrirToutesLesJournees(): void {
this.joursOuverts.set(new Set<string>(this.groupes().map((g) => g.date)));
}
/** Vrai si au moins une journée est repliée (pour n'afficher le bouton que si utile). */
aDesJourneesFermees(): boolean {
return this.groupes().some((g) => !this.joursOuverts().has(g.date));
}
// --- Helpers d'affichage ---
/** Vrai si le trajet est le dernier DE SA JOURNÉE (pas d'« insérer avant le suivant »). */
estDernier(groupe: GroupeJour, index: number): boolean {
return index === groupe.trajets.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;
}
/** Identité stable pour le suivi d'un groupe/jour. */
trackByDate(_index: number, groupe: GroupeJour): string {
return groupe.date;
}
// --- 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 le point
* où l'on se trouve à la fin de ce trajet (Exigence 11.1/11.2).
*
* - Trajet simple : on se trouve à l'ADRESSE D'ARRIVÉE → départ pré-rempli = arrivée.
* - Trajet aller/retour : le véhicule est revenu à son point de départ → départ
* pré-rempli = ADRESSE DE DÉPART du trajet (et non son arrivée).
*/
insererApres(trajet: Trajet): void {
const pointDeFin = trajet.allerRetour ? trajet.adresseDepart : trajet.adresseArrivee;
this.insertAfter.emit({
prefill: {
adresseDepart: pointDeFin,
vehiculeId: trajet.vehiculeId,
// Rattache l'insertion à la JOURNÉE du trajet concerné (et non à la date de
// la carte), pour éviter les erreurs de saisie de date.
date: (trajet.date ?? '').slice(0, 10)
},
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(groupe: GroupeJour, index: number): void {
const courant = groupe.trajets[index];
const suivant = groupe.trajets[index + 1];
if (!courant || !suivant) {
return;
}
// Point de fin du trajet courant : son arrivée, ou son départ s'il est aller/retour
// (le véhicule est alors revenu à son point d'origine).
const finCourant = courant.allerRetour ? courant.adresseDepart : courant.adresseArrivee;
this.insertBetween.emit({
prefill: {
adresseDepart: finCourant,
// Le trajet suivant part toujours de son adresse de départ (indépendant de l'A/R).
adresseArrivee: suivant.adresseDepart,
vehiculeId: courant.vehiculeId,
// Journée du trajet courant (insertion au sein de la même journée).
date: (courant.date ?? '').slice(0, 10)
},
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 DANS SA JOURNÉE : réordonne localement les trajets de ce
* jour uniquement, recalcule leurs positions séquentielles et persiste via
* `PATCH /api/trajets/reordonner`. Le glisser-déposer est limité à chaque journée
* (aucune liste connectée), donc `event.container === event.previousContainer`.
* En cas d'échec, l'ordre local est rétabli.
*/
onDrop(groupe: GroupeJour, event: CdkDragDrop<Trajet[]>): void {
if (event.previousIndex === event.currentIndex) {
return;
}
const ordreInitialSource = [...this.trajetsSource()];
// Réordonne uniquement les trajets de la journée concernée.
const reordonnesJour = [...groupe.trajets];
moveItemInArray(reordonnesJour, event.previousIndex, event.currentIndex);
// Recalcule les positions séquentielles (1, 2, …) AU SEIN de la journée.
const positions: TrajetPosition[] = reordonnesJour.map((trajet, index) => ({
id: trajet.id,
position: index + 1
}));
// Reflète immédiatement le nouvel ordre dans l'UI (feedback < 1 000 ms) :
// on met à jour la source en réécrivant les positions des trajets de ce jour.
const nouvellesPositions = new Map(positions.map((p) => [p.id, p.position]));
this.trajetsSource.set(
this.trajetsSource().map((t) =>
nouvellesPositions.has(t.id)
? { ...t, position: nouvellesPositions.get(t.id)! }
: t
)
);
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.trajetsSource.set(ordreInitialSource);
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);
}
}