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.
468 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|