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[] = []; /** Émis lorsque l'utilisateur demande la modification d'un trajet. */ @Output() edit = new EventEmitter(); /** Émis pour insérer un nouveau trajet après le trajet courant (Exigence 11.1/11.2). */ @Output() insertAfter = new EventEmitter(); /** Émis pour insérer un trajet entre le courant et le suivant (Exigence 11.3/11.4). */ @Output() insertBetween = new EventEmitter(); /** * É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(); /** * É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(); /** É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([]); /** * 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>(new Set()); /** 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(() => { const parJour = new Map(); 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(() => this.groupes().map((g) => `drop-${g.date}`) ); /** Trajet en attente de confirmation de suppression (null si aucune). */ readonly trajetASupprimer = signal(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([dernierJour]) : new Set()); } /** Vrai si la journée est dépliée (ses trajets sont visibles). */ estOuvert(groupe: GroupeJour): boolean { return this.joursOuverts().has(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; }); } // --- 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 * l'adresse d'arrivée du trajet courant (Exigence 11.1/11.2). */ insererApres(trajet: Trajet): void { this.insertAfter.emit({ prefill: { adresseDepart: trajet.adresseArrivee, vehiculeId: trajet.vehiculeId }, 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; } this.insertBetween.emit({ prefill: { adresseDepart: courant.adresseArrivee, adresseArrivee: suivant.adresseDepart, vehiculeId: courant.vehiculeId }, 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): 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); } }