diff --git a/frontend/src/app/features/consultation-jour/consultation-jour.component.html b/frontend/src/app/features/consultation-jour/consultation-jour.component.html index 863b9cd..414abb3 100644 --- a/frontend/src/app/features/consultation-jour/consultation-jour.component.html +++ b/frontend/src/app/features/consultation-jour/consultation-jour.component.html @@ -6,31 +6,8 @@
- -
- - - - {{ dateFormatee }} - - - -
+ +
+ + + + {{ dateFormatee }} + + + +
+ @if (!chargement() && trajets().length === 0) { -

Aucun trajet pour cette journée.

+

Aucun trajet pour ce mois.

} (null); + + /** + * Jour affiché au format ISO (`yyyy-MM-dd`) : pilote la CARTE et la date par + * défaut d'ajout. Toujours contenu dans le mois sélectionné. `null` tant + * qu'aucun jour n'est choisi. + */ readonly date = signal(null); /** Véhicules disponibles pour le sélecteur. */ @@ -99,7 +112,9 @@ export class ConsultationJourComponent implements OnInit, OnDestroy { private vehiculesSub?: Subscription; ngOnInit(): void { - // Journée courante par défaut. + // Par défaut : mois courant, et jour = aujourd'hui (contenu dans le mois courant). + const auj = new Date(); + this.mois.set({ annee: auj.getFullYear(), mois: auj.getMonth() + 1 }); this.date.set(this.isoAujourdHui()); this.chargerVehicules(); } @@ -111,9 +126,14 @@ export class ConsultationJourComponent implements OnInit, OnDestroy { // --- Sélection date / véhicule --- - /** Réagit à la sélection d'une date via le Sélecteur_Temporel (Exigence 12.14). */ - onDateSelectionnee(dateIso: string): void { - this.date.set(dateIso); + /** + * Réagit à la sélection d'un mois via le Sélecteur_Temporel (Exigence 12.14). + * Met à jour `mois`, positionne `date` sur un jour valide du mois (aujourd'hui si + * c'est le mois courant, sinon le 1er jour ; jamais un jour futur) et recharge. + */ + onMoisSelectionne(selection: MoisSelectionne): void { + this.mois.set(selection); + this.date.set(this.jourParDefautDuMois(selection.annee, selection.mois)); this.formulaire.set(null); this.rafraichirJournee(); } @@ -126,28 +146,50 @@ export class ConsultationJourComponent implements OnInit, OnDestroy { this.rafraichirJournee(); } - // --- Navigation jour ±1 (Exigences 12.10–12.13) --- + // --- Navigation jour ±1, BORNÉE AU MOIS sélectionné --- - /** Sélectionne la journée précédente et rafraîchit l'affichage (Exigence 12.11). */ + /** + * Jour affiché sur la carte : navigue vers le jour précédent DANS LE MOIS. Seul + * `date` change (le mois reste inchangé) ; la carte est rechargée. + */ jourPrecedent(): void { + if (this.jourPrecedentDesactive) { + return; + } this.date.set(this.decalerJour(this.dateCourante(), -1)); this.formulaire.set(null); - this.rafraichirJournee(); + this.rafraichirCarteJour(); } - /** Sélectionne la journée suivante et rafraîchit l'affichage (Exigence 12.12). */ + /** + * Jour affiché sur la carte : navigue vers le jour suivant DANS LE MOIS. Seul + * `date` change (le mois reste inchangé) ; la carte est rechargée. + */ jourSuivant(): void { if (this.jourSuivantDesactive) { return; } this.date.set(this.decalerJour(this.dateCourante(), 1)); this.formulaire.set(null); - this.rafraichirJournee(); + this.rafraichirCarteJour(); } - /** « Jour suivant » désactivé si le jour suivant est postérieur à aujourd'hui (Exigence 12.13). */ + /** « Jour préc. » désactivé si la date affichée est le 1er jour du mois sélectionné. */ + get jourPrecedentDesactive(): boolean { + return Number(this.dateCourante().slice(8, 10)) <= 1; + } + + /** + * « Jour suiv. » désactivé si la date affichée est le dernier jour du mois + * sélectionné, OU si le jour suivant serait dans le futur. + */ get jourSuivantDesactive(): boolean { - const suivant = this.decalerJour(this.dateCourante(), 1); + const courante = this.dateCourante(); + const suivant = this.decalerJour(courante, 1); + // Sortie du mois affiché (le mois du jour suivant diffère de la date courante). + if (suivant.slice(0, 7) !== courante.slice(0, 7)) { + return true; + } return suivant > this.isoAujourdHui(); } @@ -239,16 +281,17 @@ export class ConsultationJourComponent implements OnInit, OnDestroy { // (ex. ajout/modification/suppression d'un trajet sur la journée affichée). this.panneauTotaux?.rafraichir(); + const moisCourant = this.mois(); const date = this.date(); - if (!date) { + if (!moisCourant || !date) { this.trajets.set([]); this.trajetsCarte.set([]); return; } const vehiculeId = this.vehiculeId() ?? undefined; - const annee = this.anneeDe(date); - const mois = this.moisDe(date); + const annee = moisCourant.annee; + const mois = moisCourant.mois; this.chargement.set(true); this.erreur.set(null); @@ -279,8 +322,10 @@ export class ConsultationJourComponent implements OnInit, OnDestroy { ) .subscribe({ next: ({ resume, carte }) => { - // Liste : trajets du mois restreints au jour sélectionné. - this.trajets.set((resume.trajets ?? []).filter((t) => this.memeJour(t.date, date))); + // Liste : TOUS les trajets du mois (filtre véhicule déjà fait côté API), + // sans restriction au jour. Le tri final (date puis position) est assuré + // par la liste elle-même. + this.trajets.set(resume.trajets ?? []); // Carte : trajets du jour filtrés par le véhicule sélectionné. this.trajetsCarte.set( this.vehiculeId() == null @@ -288,7 +333,7 @@ export class ConsultationJourComponent implements OnInit, OnDestroy { : this.filtrerCarteParVehicule(carte) ); this.erreur.set(resumeEnErreur - ? 'Impossible de charger les trajets de la journée.' + ? 'Impossible de charger les trajets du mois.' : null); // Force la mise à jour immédiate de la liste, des totaux et de la carte // après une navigation Jour ± 1, sans attendre une autre interaction. @@ -299,12 +344,53 @@ export class ConsultationJourComponent implements OnInit, OnDestroy { // robuste : on affiche l'erreur et on rafraîchit la vue. this.trajets.set([]); this.trajetsCarte.set([]); - this.erreur.set('Impossible de charger les trajets de la journée.'); + this.erreur.set('Impossible de charger les trajets du mois.'); this.cdr.markForCheck(); } }); } + /** + * Recharge uniquement la carte pour le jour `date` (navigation jour ± 1 dans le + * mois). La liste mensuelle reste inchangée ; seuls la carte et les totaux du + * jour sont recalculés. + */ + private rafraichirCarteJour(): void { + this.jourSub?.unsubscribe(); + this.panneauTotaux?.rafraichir(); + + const date = this.date(); + if (!date) { + this.trajetsCarte.set([]); + return; + } + + this.jourSub = this.trajetService + .getTrajetsJournee(date) + .pipe( + catchError(() => of([] as TrajetCarte[])), + finalize(() => this.cdr.markForCheck()) + ) + .subscribe((carte) => { + this.trajetsCarte.set( + this.vehiculeId() == null ? carte : this.filtrerCarteParVehicule(carte) + ); + this.cdr.markForCheck(); + }); + } + + /** + * Jour par défaut d'un mois : aujourd'hui si le mois affiché est le mois courant, + * sinon le 1er jour du mois. Jamais un jour futur. + */ + private jourParDefautDuMois(annee: number, mois: number): string { + const auj = new Date(); + if (annee === auj.getFullYear() && mois === auj.getMonth() + 1) { + return this.isoAujourdHui(); + } + return this.versIso(new Date(annee, mois - 1, 1)); + } + /** * Restreint les trajets carte au véhicule sélectionné en s'appuyant sur les ids * de la liste déjà filtrée par véhicule (le DTO carte ne porte pas le vehiculeId). @@ -321,11 +407,6 @@ export class ConsultationJourComponent implements OnInit, OnDestroy { return this.date() ?? this.isoAujourdHui(); } - /** Vrai si deux dates ISO désignent le même jour (tolère un suffixe horaire). */ - private memeJour(a: string, b: string): boolean { - return (a ?? '').slice(0, 10) === (b ?? '').slice(0, 10); - } - /** Décale une date ISO `yyyy-MM-dd` de `delta` jours, sans dérive de fuseau. */ private decalerJour(dateIso: string, delta: number): string { const [annee, mois, jour] = dateIso.slice(0, 10).split('-').map(Number); @@ -345,12 +426,4 @@ export class ConsultationJourComponent implements OnInit, OnDestroy { const dd = String(d.getDate()).padStart(2, '0'); return `${d.getFullYear()}-${mm}-${dd}`; } - - private anneeDe(dateIso: string): number { - return Number(dateIso.slice(0, 4)); - } - - private moisDe(dateIso: string): number { - return Number(dateIso.slice(5, 7)); - } } diff --git a/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.html b/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.html index 3cdfcf6..a704eef 100644 --- a/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.html +++ b/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.html @@ -39,36 +39,14 @@ type="button" *ngFor="let m of mois" class="pastille" - [class.selectionne]="m === moisSelectionne()" + [class.selectionne]="m === moisSelectionneSignal()" [class.avec-donnees]="moisADonnees(m)" [disabled]="estMoisFutur(anneeSelectionnee()!, m)" - [attr.aria-pressed]="m === moisSelectionne()" + [attr.aria-pressed]="m === moisSelectionneSignal()" (click)="selectionnerMois(m)"> {{ nomsMois[m - 1] }} - - -
- Jour -
- -
-
diff --git a/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.ts b/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.ts index a28b930..bf85ffe 100644 --- a/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.ts +++ b/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.ts @@ -13,22 +13,30 @@ import { import { TrajetService } from '../../core/services/trajet.service'; +/** Mois sélectionné émis par le sélecteur temporel. */ +export interface MoisSelectionne { + annee: number; + mois: number; +} + /** - * Sélecteur temporel : navigation successive année → mois → jour. + * Sélecteur temporel : navigation successive année → mois. + * + * Depuis le passage à une consultation PAR MOIS, la sélection du jour a été + * retirée : l'utilisateur choisit une année puis un mois, et la carte reste + * pilotée par un jour précis géré par le composant conteneur. * * - Sélecteur d'année : année courante + années ayant des données * (`TrajetService.getAnneesDisponibles`). L'année courante est toujours * présente afin que le sélecteur ne soit jamais vide. (Exigence 12.2) * - Sélecteur de mois : les douze mois affichés, surbrillance des mois * comportant des données (`TrajetService.getMoisDisponibles`). (Exigence 12.3) - * - Sélecteur de jour : les jours du mois affichés, surbrillance des jours - * comportant des données (`TrajetService.getJoursDisponibles`). (Exigence 12.4) - * - Aucune date postérieure à aujourd'hui n'est sélectionnable. (Exigence 12.5) - * - Émet `dateSelectionnee` (ISO `yyyy-MM-dd`) à chaque sélection utilisateur. - * - Se synchronise avec la date affichée par le parent via `@Input date` : lors de - * la navigation ± 1 jour, l'année / mois / jour affichés reflètent cette date. + * - Aucun mois postérieur au mois courant n'est sélectionnable. (Exigence 12.5) + * - Émet `moisSelectionne` (`{ annee, mois }`) à chaque sélection utilisateur. + * - Se synchronise avec le mois affiché par le parent via `@Input date` (jour ISO + * affiché sur la carte) : l'année / le mois affichés reflètent ce jour. * - * _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.14_ + * _Requirements: 12.1, 12.2, 12.3, 12.5, 12.14_ */ @Component({ selector: 'app-selecteur-temporel', @@ -41,14 +49,14 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges { private readonly trajetService = inject(TrajetService); /** - * Date affichée par le parent (ISO `yyyy-MM-dd`). Quand elle change (navigation - * ± 1 jour ou sélection), le sélecteur pré-sélectionne l'année, le mois et le - * jour correspondants sans réémettre `dateSelectionnee`. + * Jour affiché par le parent (ISO `yyyy-MM-dd`). Quand il change (navigation + * jour ± 1 ou sélection d'un mois), le sélecteur pré-sélectionne l'année et le + * mois correspondants sans réémettre `moisSelectionne`. */ @Input() date: string | null = null; - /** Émis à chaque changement de sélection complète (année + mois + jour). */ - @Output() readonly dateSelectionnee = new EventEmitter(); + /** Émis à chaque changement de sélection (année + mois). */ + @Output() readonly moisSelectionne = new EventEmitter(); /** Libellés des douze mois (index 0 = janvier). */ readonly nomsMois = [ @@ -63,17 +71,12 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges { readonly annees = signal([]); /** Les douze mois (1..12), toujours affichés. */ readonly mois: number[] = Array.from({ length: 12 }, (_, i) => i + 1); - /** Jours du mois sélectionné (1..N). */ - readonly jours = signal([]); /** Mois comportant au moins un trajet pour l'année sélectionnée. */ private readonly moisAvecDonnees = signal>(new Set()); - /** Jours comportant au moins un trajet pour le mois sélectionné. */ - private readonly joursAvecDonnees = signal>(new Set()); readonly anneeSelectionnee = signal(null); - readonly moisSelectionne = signal(null); - readonly jourSelectionne = signal(null); + readonly moisSelectionneSignal = signal(null); /** État d'ouverture du conteneur repliable (fermé par défaut). */ readonly ouvert = signal(false); @@ -85,7 +88,7 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges { ngOnInit(): void { // Affichage garanti dès le premier rendu : au minimum l'année courante et, - // si le parent a déjà fourni une date, la pré-sélection année/mois/jour. + // si le parent a déjà fourni une date, la pré-sélection année/mois. // Ainsi le sélecteur n'est jamais vide même si l'appel réseau tarde ou échoue. this.annees.set(this.avecAnneeCourante([])); this.synchroniserSurDate(this.date); @@ -108,15 +111,15 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges { } /** - * Aligne la sélection (année, mois, jour) sur la date fournie par le parent, - * sans réémettre d'évènement. Charge les mois / jours avec données associés. + * Aligne la sélection (année, mois) sur le jour fourni par le parent, sans + * réémettre d'évènement. Charge les mois comportant des données associés. */ private synchroniserSurDate(dateIso: string | null): void { if (!dateIso) { return; } - const [annee, mois, jour] = dateIso.slice(0, 10).split('-').map(Number); - if (!annee || !mois || !jour) { + const [annee, mois] = dateIso.slice(0, 10).split('-').map(Number); + if (!annee || !mois) { return; } @@ -126,16 +129,11 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges { } this.anneeSelectionnee.set(annee); - this.moisSelectionne.set(mois); - this.jours.set(this.genererJours(annee, mois)); - this.jourSelectionne.set(jour); + this.moisSelectionneSignal.set(mois); this.trajetService.getMoisDisponibles(annee).subscribe((m) => { this.moisAvecDonnees.set(new Set(m)); }); - this.trajetService.getJoursDisponibles(annee, mois).subscribe((j) => { - this.joursAvecDonnees.set(new Set(j)); - }); } /** Sélectionne une année et charge les mois comportant des données. */ @@ -144,10 +142,7 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges { return; } this.anneeSelectionnee.set(annee); - this.moisSelectionne.set(null); - this.jourSelectionne.set(null); - this.jours.set([]); - this.joursAvecDonnees.set(new Set()); + this.moisSelectionneSignal.set(null); this.moisAvecDonnees.set(new Set()); this.trajetService.getMoisDisponibles(annee).subscribe((mois) => { @@ -155,37 +150,14 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges { }); } - /** Sélectionne un mois et charge les jours comportant des données. */ + /** Sélectionne un mois et émet `{ annee, mois }`. */ selectionnerMois(mois: number): void { const annee = this.anneeSelectionnee(); if (annee == null || this.estMoisFutur(annee, mois)) { return; } - this.moisSelectionne.set(mois); - this.jourSelectionne.set(null); - this.jours.set(this.genererJours(annee, mois)); - this.joursAvecDonnees.set(new Set()); - - this.trajetService.getJoursDisponibles(annee, mois).subscribe((jours) => { - this.joursAvecDonnees.set(new Set(jours)); - }); - } - - /** Sélectionne un jour et émet la date ISO correspondante. */ - selectionnerJour(jour: number): void { - const annee = this.anneeSelectionnee(); - const mois = this.moisSelectionne(); - if ( - annee == null || - mois == null || - this.estJourFutur(annee, mois, jour) - ) { - return; - } - this.jourSelectionne.set(jour); - this.dateSelectionnee.emit( - this.versIso(annee, mois, jour) - ); + this.moisSelectionneSignal.set(mois); + this.moisSelectionne.emit({ annee, mois }); } /** Un mois est en surbrillance s'il comporte au moins un trajet. */ @@ -193,11 +165,6 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges { return this.moisAvecDonnees().has(mois); } - /** Un jour est en surbrillance s'il comporte au moins un trajet. */ - jourADonnees(jour: number): boolean { - return this.joursAvecDonnees().has(jour); - } - /** Une année est désactivée si elle est postérieure à l'année courante. */ estAnneeFuture(annee: number): boolean { return annee > this.aujourdHui.getFullYear(); @@ -208,11 +175,6 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges { return this.debutDeJournee(new Date(annee, mois - 1, 1)) > this.aujourdHui; } - /** Un jour est désactivé s'il est postérieur à la date courante. */ - estJourFutur(annee: number, mois: number, jour: number): boolean { - return this.debutDeJournee(new Date(annee, mois - 1, jour)) > this.aujourdHui; - } - /** Complète la liste avec l'année courante et trie en ordre décroissant, sans doublon. */ private avecAnneeCourante(annees: number[]): number[] { const ensemble = new Set(annees); @@ -220,19 +182,6 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges { return [...ensemble].sort((a, b) => b - a); } - /** Génère la liste 1..N des jours du mois donné. */ - private genererJours(annee: number, mois: number): number[] { - const nbJours = new Date(annee, mois, 0).getDate(); - return Array.from({ length: nbJours }, (_, i) => i + 1); - } - - /** Formate une date en ISO `yyyy-MM-dd` sans dérive de fuseau horaire. */ - private versIso(annee: number, mois: number, jour: number): string { - const mm = String(mois).padStart(2, '0'); - const dd = String(jour).padStart(2, '0'); - return `${annee}-${mm}-${dd}`; - } - /** Retourne une copie de la date à minuit (heure locale). */ private debutDeJournee(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); diff --git a/frontend/src/app/features/trajet-list/trajet-list.component.html b/frontend/src/app/features/trajet-list/trajet-list.component.html index afc7046..f8fc233 100644 --- a/frontend/src/app/features/trajet-list/trajet-list.component.html +++ b/frontend/src/app/features/trajet-list/trajet-list.component.html @@ -1,107 +1,117 @@ -@if (trajetsTries().length === 0) { -

Aucun trajet pour cette journée.

+@if (groupes().length === 0) { +

Aucun trajet pour ce mois.

} @else { -
    - @for (trajet of trajetsTries(); track trajet.id; let i = $index) { -
  • + @for (groupe of groupes(); track groupe.date) { +
    +

    {{ groupe.date | date: 'dd/MM/yyyy' }}

    + +
      - - - - -
      - @if (trajet.allerRetour) { - A/R - } -
      - -
      -
      - {{ trajet.date | date: 'dd/MM/yyyy' }} - @if (trajet.commentaire) { - {{ trajet.commentaire }} - } -
      -
      - {{ trajet.adresseDepart }} - - {{ trajet.adresseArrivee }} -
      -
      - -
      {{ distanceAffichee(trajet) }}
      - -
      -
      + @for (trajet of groupe.trajets; track trajet.id; let i = $index) { +
    • + - -
    • -
      - @if (!estDernier(i)) { - - } - -
      -
      - - } -
    + + +
    + @if (trajet.allerRetour) { + A/R + } +
    + +
    +
    + {{ trajet.date | date: 'dd/MM/yyyy' }} + @if (trajet.commentaire) { + {{ trajet.commentaire }} + } +
    +
    + {{ trajet.adresseDepart }} + + {{ trajet.adresseArrivee }} +
    +
    + +
    {{ distanceAffichee(trajet) }}
    + +
    +
    + + +
    +
    + @if (!estDernier(groupe, i)) { + + } + +
    +
    +
  • + } +
+ + } } diff --git a/frontend/src/app/features/trajet-list/trajet-list.component.scss b/frontend/src/app/features/trajet-list/trajet-list.component.scss index cce758c..e2b68e5 100644 --- a/frontend/src/app/features/trajet-list/trajet-list.component.scss +++ b/frontend/src/app/features/trajet-list/trajet-list.component.scss @@ -6,6 +6,29 @@ font-style: italic; } +// Groupe d'une journée : en-tête de date + sa liste de trajets. +.jour { + padding: 0.5rem 0; + + &__entete { + margin: 0 0 0.4rem; + padding: 0.35rem 0.5rem; + font-size: 0.85rem; + font-weight: 700; + color: #334155; + background: #f1f5f9; + border-radius: 6px; + text-transform: none; + position: sticky; + top: 0; + z-index: 1; + } + + & + .jour { + border-top: 1px solid #eef1f4; + } +} + .trajets { list-style: none; margin: 0; diff --git a/frontend/src/app/features/trajet-list/trajet-list.component.ts b/frontend/src/app/features/trajet-list/trajet-list.component.ts index ad908c2..ce18b5a 100644 --- a/frontend/src/app/features/trajet-list/trajet-list.component.ts +++ b/frontend/src/app/features/trajet-list/trajet-list.component.ts @@ -9,6 +9,7 @@ import { EventEmitter, Input, Output, + computed, inject, signal } from '@angular/core'; @@ -40,6 +41,18 @@ export interface TrajetInsertionContext { 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). * @@ -76,12 +89,13 @@ export class TrajetListComponent { private readonly notifications = inject(NotificationService); /** - * Trajets de la journée. Toujours affichés triés par `Position` croissante : - * une copie triée est maintenue dans `trajetsTries` à chaque changement d'entrée. + * 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.trajetsTries.set([...this._trajets].sort((a, b) => a.position - b.position)); + this.trajetsSource.set([...this._trajets]); } get trajets(): Trajet[] { return this._trajets; @@ -115,8 +129,41 @@ export class TrajetListComponent { this.survol.emit(id); } - /** Liste triée par Position (source d'affichage). */ - readonly trajetsTries = signal([]); + /** Source brute des trajets du mois (mise à jour à chaque changement d'entrée). */ + private readonly trajetsSource = signal([]); + + /** + * 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); @@ -126,9 +173,9 @@ export class TrajetListComponent { // --- Helpers d'affichage --- - /** Vrai si le trajet est le dernier de la journée (pas d'« insérer avant le suivant »). */ - estDernier(index: number): boolean { - return index === this.trajetsTries().length - 1; + /** 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). */ @@ -144,6 +191,11 @@ export class TrajetListComponent { 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). */ @@ -170,11 +222,10 @@ export class TrajetListComponent { * du trajet courant et l'adresse d'arrivée avec le départ du trajet suivant * (Exigence 11.3/11.4). */ - insererAvantSuivant(index: number): void { - const trajetsTries = this.trajetsTries(); - const courant = trajetsTries[index]; - const suivant = trajetsTries[index + 1]; - if (!suivant) { + insererAvantSuivant(groupe: GroupeJour, index: number): void { + const courant = groupe.trajets[index]; + const suivant = groupe.trajets[index + 1]; + if (!courant || !suivant) { return; } this.insertBetween.emit({ @@ -223,30 +274,39 @@ export class TrajetListComponent { // --- Glisser-déposer (Exigence 11.9) --- /** - * Au dépôt d'un trajet : réordonne localement, recalcule les positions (pas de 1.0) - * et persiste via `PATCH /api/trajets/reordonner`. En cas d'échec, l'ordre local est - * rétabli à partir des positions d'origine. + * 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(event: CdkDragDrop): void { + onDrop(groupe: GroupeJour, event: CdkDragDrop): void { if (event.previousIndex === event.currentIndex) { return; } - const ordreInitial = [...this.trajetsTries()]; - const reordonnes = [...this.trajetsTries()]; - moveItemInArray(reordonnes, event.previousIndex, event.currentIndex); + const ordreInitialSource = [...this.trajetsSource()]; - // Recalcule les positions séquentielles (1.0, 2.0, …) selon le nouvel ordre. - const positions: TrajetPosition[] = reordonnes.map((trajet, index) => ({ + // 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). - this.trajetsTries.set(reordonnes.map((trajet, index) => ({ - ...trajet, - 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 }; @@ -259,7 +319,7 @@ export class TrajetListComponent { }, error: (err) => { // Rétablit l'ordre précédent en cas d'échec. - this.trajetsTries.set(ordreInitial); + this.trajetsSource.set(ordreInitialSource); this.gererErreur(err, 'Le réordonnancement a échoué.'); } });