Consultation par mois : liste mensuelle groupée par jour, carte par jour

Refonte de la navigation temporelle suite au retour d'usage :

- Sélecteur temporel limité à année/mois (suppression du segment jour).
  Nouvel Output moisSelectionne: {annee, mois} en remplacement de
  dateSelectionnee (jour ISO). Surbrillance des mois avec données conservée.

- Consultation : deux états distincts. Le signal mois pilote le chargement
  de la liste (tous les trajets du mois, triés par date), le signal date
  pilote la carte (trajets du jour) et la date par défaut à l'ajout/insertion.
  Bande « ‹ Jour préc. / date / Jour suiv. › » au-dessus de la carte, bornée
  au mois sélectionné. La navigation jour ne recharge que la carte.

- Liste : trajets du mois groupés par journée (en-tête de date). Un
  cdkDropList indépendant par jour (listes non connectées) : le
  réordonnancement par glisser-déposer est limité à chaque journée, avec
  recalcul des positions internes et rétablissement en cas d'échec.

Builds dev et prod OK. Backend inchangé (API résumé mensuel/réordonner
déjà en place).
This commit is contained in:
2026-09-15 20:53:25 +02:00
parent 40094a4fb1
commit 48d75b8e06
7 changed files with 385 additions and 290 deletions
@@ -6,31 +6,8 @@
<div class="consultation__dates"> <div class="consultation__dates">
<app-selecteur-temporel <app-selecteur-temporel
[date]="date()" [date]="date()"
(dateSelectionnee)="onDateSelectionnee($event)" (moisSelectionne)="onMoisSelectionne($event)"
></app-selecteur-temporel> ></app-selecteur-temporel>
<div class="consultation__navigation" role="group" aria-label="Navigation par jour">
<button
type="button"
class="consultation__nav-bouton"
(click)="jourPrecedent()"
>
‹ Jour préc.
</button>
<span class="consultation__date" aria-live="polite">
{{ dateFormatee }}
</span>
<button
type="button"
class="consultation__nav-bouton"
(click)="jourSuivant()"
[disabled]="jourSuivantDesactive"
>
Jour suiv. ›
</button>
</div>
</div> </div>
<app-panneau-totaux <app-panneau-totaux
@@ -38,6 +15,31 @@
[vehiculeId]="vehiculeId()" [vehiculeId]="vehiculeId()"
></app-panneau-totaux> ></app-panneau-totaux>
<!-- Bande de navigation jour (bornée au mois sélectionné), au-dessus de la carte. -->
<div class="consultation__navigation" role="group" aria-label="Navigation par jour">
<button
type="button"
class="consultation__nav-bouton"
(click)="jourPrecedent()"
[disabled]="jourPrecedentDesactive"
>
‹ Jour préc.
</button>
<span class="consultation__date" aria-live="polite">
{{ dateFormatee }}
</span>
<button
type="button"
class="consultation__nav-bouton"
(click)="jourSuivant()"
[disabled]="jourSuivantDesactive"
>
Jour suiv. ›
</button>
</div>
<app-carte-trajets <app-carte-trajets
[trajets]="trajetsCarte()" [trajets]="trajetsCarte()"
[trajetSurvoleId]="trajetSurvoleId()" [trajetSurvoleId]="trajetSurvoleId()"
@@ -77,7 +79,7 @@
<div class="consultation__liste"> <div class="consultation__liste">
@if (!chargement() && trajets().length === 0) { @if (!chargement() && trajets().length === 0) {
<p class="consultation__vide">Aucun trajet pour cette journée.</p> <p class="consultation__vide">Aucun trajet pour ce mois.</p>
} }
<app-trajet-list <app-trajet-list
[trajets]="trajets()" [trajets]="trajets()"
@@ -8,7 +8,10 @@ import { TrajetService } from '../../core/services/trajet.service';
import { VehiculeService } from '../../core/services/vehicule.service'; import { VehiculeService } from '../../core/services/vehicule.service';
import { CarteTrajetsComponent } from '../carte-trajets/carte-trajets.component'; import { CarteTrajetsComponent } from '../carte-trajets/carte-trajets.component';
import { PanneauTotauxComponent } from '../panneau-totaux/panneau-totaux.component'; import { PanneauTotauxComponent } from '../panneau-totaux/panneau-totaux.component';
import { SelecteurTemporelComponent } from '../navigation-temporelle/selecteur-temporel.component'; import {
MoisSelectionne,
SelecteurTemporelComponent
} from '../navigation-temporelle/selecteur-temporel.component';
import { TrajetFormComponent, TrajetInsertionPrefill } from '../trajet-form/trajet-form.component'; import { TrajetFormComponent, TrajetInsertionPrefill } from '../trajet-form/trajet-form.component';
import { import {
TrajetInsertionContext, TrajetInsertionContext,
@@ -68,7 +71,17 @@ export class ConsultationJourComponent implements OnInit, OnDestroy {
/** Référence au panneau des totaux, pour forcer son rafraîchissement à la même journée. */ /** Référence au panneau des totaux, pour forcer son rafraîchissement à la même journée. */
@ViewChild(PanneauTotauxComponent) private panneauTotaux?: PanneauTotauxComponent; @ViewChild(PanneauTotauxComponent) private panneauTotaux?: PanneauTotauxComponent;
/** Date sélectionnée au format ISO (`yyyy-MM-dd`). `null` tant qu'aucune date n'est choisie. */ /**
* Mois consulté `{ annee, mois }` : pilote le chargement mensuel de la liste et
* la synchronisation du sélecteur temporel. `null` tant qu'aucun mois n'est choisi.
*/
readonly mois = signal<MoisSelectionne | null>(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<string | null>(null); readonly date = signal<string | null>(null);
/** Véhicules disponibles pour le sélecteur. */ /** Véhicules disponibles pour le sélecteur. */
@@ -99,7 +112,9 @@ export class ConsultationJourComponent implements OnInit, OnDestroy {
private vehiculesSub?: Subscription; private vehiculesSub?: Subscription;
ngOnInit(): void { 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.date.set(this.isoAujourdHui());
this.chargerVehicules(); this.chargerVehicules();
} }
@@ -111,9 +126,14 @@ export class ConsultationJourComponent implements OnInit, OnDestroy {
// --- Sélection date / véhicule --- // --- 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 { * Réagit à la sélection d'un mois via le Sélecteur_Temporel (Exigence 12.14).
this.date.set(dateIso); * 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.formulaire.set(null);
this.rafraichirJournee(); this.rafraichirJournee();
} }
@@ -126,28 +146,50 @@ export class ConsultationJourComponent implements OnInit, OnDestroy {
this.rafraichirJournee(); 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 { jourPrecedent(): void {
if (this.jourPrecedentDesactive) {
return;
}
this.date.set(this.decalerJour(this.dateCourante(), -1)); this.date.set(this.decalerJour(this.dateCourante(), -1));
this.formulaire.set(null); 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 { jourSuivant(): void {
if (this.jourSuivantDesactive) { if (this.jourSuivantDesactive) {
return; return;
} }
this.date.set(this.decalerJour(this.dateCourante(), 1)); this.date.set(this.decalerJour(this.dateCourante(), 1));
this.formulaire.set(null); 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 { 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(); 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). // (ex. ajout/modification/suppression d'un trajet sur la journée affichée).
this.panneauTotaux?.rafraichir(); this.panneauTotaux?.rafraichir();
const moisCourant = this.mois();
const date = this.date(); const date = this.date();
if (!date) { if (!moisCourant || !date) {
this.trajets.set([]); this.trajets.set([]);
this.trajetsCarte.set([]); this.trajetsCarte.set([]);
return; return;
} }
const vehiculeId = this.vehiculeId() ?? undefined; const vehiculeId = this.vehiculeId() ?? undefined;
const annee = this.anneeDe(date); const annee = moisCourant.annee;
const mois = this.moisDe(date); const mois = moisCourant.mois;
this.chargement.set(true); this.chargement.set(true);
this.erreur.set(null); this.erreur.set(null);
@@ -279,8 +322,10 @@ export class ConsultationJourComponent implements OnInit, OnDestroy {
) )
.subscribe({ .subscribe({
next: ({ resume, carte }) => { next: ({ resume, carte }) => {
// Liste : trajets du mois restreints au jour sélectionné. // Liste : TOUS les trajets du mois (filtre véhicule déjà fait côté API),
this.trajets.set((resume.trajets ?? []).filter((t) => this.memeJour(t.date, date))); // 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é. // Carte : trajets du jour filtrés par le véhicule sélectionné.
this.trajetsCarte.set( this.trajetsCarte.set(
this.vehiculeId() == null this.vehiculeId() == null
@@ -288,7 +333,7 @@ export class ConsultationJourComponent implements OnInit, OnDestroy {
: this.filtrerCarteParVehicule(carte) : this.filtrerCarteParVehicule(carte)
); );
this.erreur.set(resumeEnErreur this.erreur.set(resumeEnErreur
? 'Impossible de charger les trajets de la journée.' ? 'Impossible de charger les trajets du mois.'
: null); : null);
// Force la mise à jour immédiate de la liste, des totaux et de la carte // 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. // 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. // robuste : on affiche l'erreur et on rafraîchit la vue.
this.trajets.set([]); this.trajets.set([]);
this.trajetsCarte.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(); 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 * 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). * 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(); 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. */ /** Décale une date ISO `yyyy-MM-dd` de `delta` jours, sans dérive de fuseau. */
private decalerJour(dateIso: string, delta: number): string { private decalerJour(dateIso: string, delta: number): string {
const [annee, mois, jour] = dateIso.slice(0, 10).split('-').map(Number); 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'); const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`; 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));
}
} }
@@ -39,36 +39,14 @@
type="button" type="button"
*ngFor="let m of mois" *ngFor="let m of mois"
class="pastille" class="pastille"
[class.selectionne]="m === moisSelectionne()" [class.selectionne]="m === moisSelectionneSignal()"
[class.avec-donnees]="moisADonnees(m)" [class.avec-donnees]="moisADonnees(m)"
[disabled]="estMoisFutur(anneeSelectionnee()!, m)" [disabled]="estMoisFutur(anneeSelectionnee()!, m)"
[attr.aria-pressed]="m === moisSelectionne()" [attr.aria-pressed]="m === moisSelectionneSignal()"
(click)="selectionnerMois(m)"> (click)="selectionnerMois(m)">
{{ nomsMois[m - 1] }} {{ nomsMois[m - 1] }}
</button> </button>
</div> </div>
</div> </div>
<!-- Sélecteur de jour : jours du mois, surbrillance des jours avec données (12.4) -->
<div
class="selecteur-groupe"
role="group"
aria-label="Sélection du jour"
*ngIf="moisSelectionne() !== null">
<span class="selecteur-titre">Jour</span>
<div class="selecteur-boutons selecteur-jours">
<button
type="button"
*ngFor="let j of jours()"
class="pastille pastille-jour"
[class.selectionne]="j === jourSelectionne()"
[class.avec-donnees]="jourADonnees(j)"
[disabled]="estJourFutur(anneeSelectionnee()!, moisSelectionne()!, j)"
[attr.aria-pressed]="j === jourSelectionne()"
(click)="selectionnerJour(j)">
{{ j }}
</button>
</div>
</div>
</div> </div>
</section> </section>
@@ -13,22 +13,30 @@ import {
import { TrajetService } from '../../core/services/trajet.service'; 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 * - Sélecteur d'année : année courante + années ayant des données
* (`TrajetService.getAnneesDisponibles`). L'année courante est toujours * (`TrajetService.getAnneesDisponibles`). L'année courante est toujours
* présente afin que le sélecteur ne soit jamais vide. (Exigence 12.2) * 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 * - Sélecteur de mois : les douze mois affichés, surbrillance des mois
* comportant des données (`TrajetService.getMoisDisponibles`). (Exigence 12.3) * comportant des données (`TrajetService.getMoisDisponibles`). (Exigence 12.3)
* - Sélecteur de jour : les jours du mois affichés, surbrillance des jours * - Aucun mois postérieur au mois courant n'est sélectionnable. (Exigence 12.5)
* comportant des données (`TrajetService.getJoursDisponibles`). (Exigence 12.4) * - Émet `moisSelectionne` (`{ annee, mois }`) à chaque sélection utilisateur.
* - Aucune date postérieure à aujourd'hui n'est sélectionnable. (Exigence 12.5) * - Se synchronise avec le mois affiché par le parent via `@Input date` (jour ISO
* - Émet `dateSelectionnee` (ISO `yyyy-MM-dd`) à chaque sélection utilisateur. * affiché sur la carte) : l'année / le mois affichés reflètent ce jour.
* - 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.
* *
* _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({ @Component({
selector: 'app-selecteur-temporel', selector: 'app-selecteur-temporel',
@@ -41,14 +49,14 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
private readonly trajetService = inject(TrajetService); private readonly trajetService = inject(TrajetService);
/** /**
* Date affichée par le parent (ISO `yyyy-MM-dd`). Quand elle change (navigation * Jour affiché par le parent (ISO `yyyy-MM-dd`). Quand il change (navigation
* ± 1 jour ou sélection), le sélecteur pré-sélectionne l'année, le mois et le * jour ± 1 ou sélection d'un mois), le sélecteur pré-sélectionne l'année et le
* jour correspondants sans réémettre `dateSelectionnee`. * mois correspondants sans réémettre `moisSelectionne`.
*/ */
@Input() date: string | null = null; @Input() date: string | null = null;
/** Émis à chaque changement de sélection complète (année + mois + jour). */ /** Émis à chaque changement de sélection (année + mois). */
@Output() readonly dateSelectionnee = new EventEmitter<string>(); @Output() readonly moisSelectionne = new EventEmitter<MoisSelectionne>();
/** Libellés des douze mois (index 0 = janvier). */ /** Libellés des douze mois (index 0 = janvier). */
readonly nomsMois = [ readonly nomsMois = [
@@ -63,17 +71,12 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
readonly annees = signal<number[]>([]); readonly annees = signal<number[]>([]);
/** Les douze mois (1..12), toujours affichés. */ /** Les douze mois (1..12), toujours affichés. */
readonly mois: number[] = Array.from({ length: 12 }, (_, i) => i + 1); readonly mois: number[] = Array.from({ length: 12 }, (_, i) => i + 1);
/** Jours du mois sélectionné (1..N). */
readonly jours = signal<number[]>([]);
/** Mois comportant au moins un trajet pour l'année sélectionnée. */ /** Mois comportant au moins un trajet pour l'année sélectionnée. */
private readonly moisAvecDonnees = signal<Set<number>>(new Set<number>()); private readonly moisAvecDonnees = signal<Set<number>>(new Set<number>());
/** Jours comportant au moins un trajet pour le mois sélectionné. */
private readonly joursAvecDonnees = signal<Set<number>>(new Set<number>());
readonly anneeSelectionnee = signal<number | null>(null); readonly anneeSelectionnee = signal<number | null>(null);
readonly moisSelectionne = signal<number | null>(null); readonly moisSelectionneSignal = signal<number | null>(null);
readonly jourSelectionne = signal<number | null>(null);
/** État d'ouverture du conteneur repliable (fermé par défaut). */ /** État d'ouverture du conteneur repliable (fermé par défaut). */
readonly ouvert = signal(false); readonly ouvert = signal(false);
@@ -85,7 +88,7 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
ngOnInit(): void { ngOnInit(): void {
// Affichage garanti dès le premier rendu : au minimum l'année courante et, // 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. // Ainsi le sélecteur n'est jamais vide même si l'appel réseau tarde ou échoue.
this.annees.set(this.avecAnneeCourante([])); this.annees.set(this.avecAnneeCourante([]));
this.synchroniserSurDate(this.date); 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, * Aligne la sélection (année, mois) sur le jour fourni par le parent, sans
* sans réémettre d'évènement. Charge les mois / jours avec données associés. * réémettre d'évènement. Charge les mois comportant des données associés.
*/ */
private synchroniserSurDate(dateIso: string | null): void { private synchroniserSurDate(dateIso: string | null): void {
if (!dateIso) { if (!dateIso) {
return; return;
} }
const [annee, mois, jour] = dateIso.slice(0, 10).split('-').map(Number); const [annee, mois] = dateIso.slice(0, 10).split('-').map(Number);
if (!annee || !mois || !jour) { if (!annee || !mois) {
return; return;
} }
@@ -126,16 +129,11 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
} }
this.anneeSelectionnee.set(annee); this.anneeSelectionnee.set(annee);
this.moisSelectionne.set(mois); this.moisSelectionneSignal.set(mois);
this.jours.set(this.genererJours(annee, mois));
this.jourSelectionne.set(jour);
this.trajetService.getMoisDisponibles(annee).subscribe((m) => { this.trajetService.getMoisDisponibles(annee).subscribe((m) => {
this.moisAvecDonnees.set(new Set<number>(m)); this.moisAvecDonnees.set(new Set<number>(m));
}); });
this.trajetService.getJoursDisponibles(annee, mois).subscribe((j) => {
this.joursAvecDonnees.set(new Set<number>(j));
});
} }
/** Sélectionne une année et charge les mois comportant des données. */ /** Sélectionne une année et charge les mois comportant des données. */
@@ -144,10 +142,7 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
return; return;
} }
this.anneeSelectionnee.set(annee); this.anneeSelectionnee.set(annee);
this.moisSelectionne.set(null); this.moisSelectionneSignal.set(null);
this.jourSelectionne.set(null);
this.jours.set([]);
this.joursAvecDonnees.set(new Set<number>());
this.moisAvecDonnees.set(new Set<number>()); this.moisAvecDonnees.set(new Set<number>());
this.trajetService.getMoisDisponibles(annee).subscribe((mois) => { 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 { selectionnerMois(mois: number): void {
const annee = this.anneeSelectionnee(); const annee = this.anneeSelectionnee();
if (annee == null || this.estMoisFutur(annee, mois)) { if (annee == null || this.estMoisFutur(annee, mois)) {
return; return;
} }
this.moisSelectionne.set(mois); this.moisSelectionneSignal.set(mois);
this.jourSelectionne.set(null); this.moisSelectionne.emit({ annee, mois });
this.jours.set(this.genererJours(annee, mois));
this.joursAvecDonnees.set(new Set<number>());
this.trajetService.getJoursDisponibles(annee, mois).subscribe((jours) => {
this.joursAvecDonnees.set(new Set<number>(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)
);
} }
/** Un mois est en surbrillance s'il comporte au moins un trajet. */ /** 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); 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. */ /** Une année est désactivée si elle est postérieure à l'année courante. */
estAnneeFuture(annee: number): boolean { estAnneeFuture(annee: number): boolean {
return annee > this.aujourdHui.getFullYear(); 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; 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. */ /** Complète la liste avec l'année courante et trie en ordre décroissant, sans doublon. */
private avecAnneeCourante(annees: number[]): number[] { private avecAnneeCourante(annees: number[]): number[] {
const ensemble = new Set<number>(annees); const ensemble = new Set<number>(annees);
@@ -220,19 +182,6 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
return [...ensemble].sort((a, b) => b - a); 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). */ /** Retourne une copie de la date à minuit (heure locale). */
private debutDeJournee(date: Date): Date { private debutDeJournee(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()); return new Date(date.getFullYear(), date.getMonth(), date.getDate());
@@ -1,107 +1,117 @@
@if (trajetsTries().length === 0) { @if (groupes().length === 0) {
<p class="liste-vide" role="status">Aucun trajet pour cette journée.</p> <p class="liste-vide" role="status">Aucun trajet pour ce mois.</p>
} @else { } @else {
<ul <!-- Un groupe par journée : en-tête de date + liste de dépôt indépendante.
class="trajets" Les listes NE sont PAS connectées entre elles → drag limité à chaque jour. -->
cdkDropList @for (groupe of groupes(); track groupe.date) {
(cdkDropListDropped)="onDrop($event)" <section class="jour" aria-label="Trajets d'une journée">
role="list" <h3 class="jour__entete">{{ groupe.date | date: 'dd/MM/yyyy' }}</h3>
>
@for (trajet of trajetsTries(); track trajet.id; let i = $index) { <ul
<li class="trajets"
class="trajet" cdkDropList
cdkDrag [cdkDropListData]="groupe.trajets"
[cdkDragDisabled]="operationEnCours()" (cdkDropListDropped)="onDrop(groupe, $event)"
role="listitem" role="list"
(mouseenter)="onSurvol(trajet.id)"
(mouseleave)="onSurvol(null)"
> >
<!-- Poignée de glisser-déposer (Exigence 11.9). --> @for (trajet of groupe.trajets; track trajet.id; let i = $index) {
<button <li
type="button" class="trajet"
class="poignee" cdkDrag
cdkDragHandle [cdkDragData]="trajet"
[disabled]="operationEnCours()" [cdkDragDisabled]="operationEnCours()"
aria-label="Déplacer le trajet pour le réordonner" role="listitem"
title="Glisser pour réordonner" (mouseenter)="onSurvol(trajet.id)"
> (mouseleave)="onSurvol(null)"
⠿ >
</button> <!-- Poignée de glisser-déposer (Exigence 11.9). -->
<!-- Colonne dédiée A/R (largeur fixe) : garde date/adresses alignées. -->
<div class="trajet__ar-col" aria-hidden="false">
@if (trajet.allerRetour) {
<span class="trajet__ar" title="Aller/Retour : distance comptée doublée">A/R</span>
}
</div>
<div class="trajet__infos">
<div class="trajet__ligne">
<span class="trajet__date">{{ trajet.date | date: 'dd/MM/yyyy' }}</span>
@if (trajet.commentaire) {
<span class="trajet__commentaire">{{ trajet.commentaire }}</span>
}
</div>
<div class="trajet__adresses">
<span class="trajet__depart">{{ trajet.adresseDepart }}</span>
<span class="trajet__fleche" aria-hidden="true">→</span>
<span class="trajet__arrivee">{{ trajet.adresseArrivee }}</span>
</div>
</div>
<div class="trajet__distance">{{ distanceAffichee(trajet) }}</div>
<div class="trajet__actions">
<div class="trajet__actions-ligne">
<button <button
type="button" type="button"
class="action action--icone" class="poignee"
(click)="modifier(trajet)" cdkDragHandle
[disabled]="operationEnCours()" [disabled]="operationEnCours()"
title="Modifier ce trajet" aria-label="Déplacer le trajet pour le réordonner"
aria-label="Modifier ce trajet" title="Glisser pour réordonner"
> >
✏️ ⠿
</button> </button>
<button
type="button" <!-- Colonne dédiée A/R (largeur fixe) : garde date/adresses alignées. -->
class="action action--icone action--danger" <div class="trajet__ar-col" aria-hidden="false">
(click)="demanderSuppression(trajet)" @if (trajet.allerRetour) {
[disabled]="operationEnCours()" <span class="trajet__ar" title="Aller/Retour : distance comptée doublée">A/R</span>
title="Supprimer ce trajet" }
aria-label="Supprimer ce trajet" </div>
>
🗑️ <div class="trajet__infos">
</button> <div class="trajet__ligne">
</div> <span class="trajet__date">{{ trajet.date | date: 'dd/MM/yyyy' }}</span>
<div class="trajet__actions-ligne"> @if (trajet.commentaire) {
@if (!estDernier(i)) { <span class="trajet__commentaire">{{ trajet.commentaire }}</span>
<button }
type="button" </div>
class="action action--icone" <div class="trajet__adresses">
(click)="insererAvantSuivant(i)" <span class="trajet__depart">{{ trajet.adresseDepart }}</span>
[disabled]="operationEnCours()" <span class="trajet__fleche" aria-hidden="true">→</span>
title="Insérer un trajet avant le suivant" <span class="trajet__arrivee">{{ trajet.adresseArrivee }}</span>
aria-label="Insérer un trajet avant le suivant" </div>
> </div>
↥
</button> <div class="trajet__distance">{{ distanceAffichee(trajet) }}</div>
}
<button <div class="trajet__actions">
type="button" <div class="trajet__actions-ligne">
class="action action--icone" <button
(click)="insererApres(trajet)" type="button"
[disabled]="operationEnCours()" class="action action--icone"
title="Insérer un trajet après celui-ci" (click)="modifier(trajet)"
aria-label="Insérer un trajet après celui-ci" [disabled]="operationEnCours()"
> title="Modifier ce trajet"
↧ aria-label="Modifier ce trajet"
</button> >
</div> ✏️
</div> </button>
</li> <button
} type="button"
</ul> class="action action--icone action--danger"
(click)="demanderSuppression(trajet)"
[disabled]="operationEnCours()"
title="Supprimer ce trajet"
aria-label="Supprimer ce trajet"
>
🗑️
</button>
</div>
<div class="trajet__actions-ligne">
@if (!estDernier(groupe, i)) {
<button
type="button"
class="action action--icone"
(click)="insererAvantSuivant(groupe, i)"
[disabled]="operationEnCours()"
title="Insérer un trajet avant le suivant"
aria-label="Insérer un trajet avant le suivant"
>
↥
</button>
}
<button
type="button"
class="action action--icone"
(click)="insererApres(trajet)"
[disabled]="operationEnCours()"
title="Insérer un trajet après celui-ci"
aria-label="Insérer un trajet après celui-ci"
>
↧
</button>
</div>
</div>
</li>
}
</ul>
</section>
}
} }
<!-- Confirmation de suppression (Exigence 5.4). --> <!-- Confirmation de suppression (Exigence 5.4). -->
@@ -6,6 +6,29 @@
font-style: italic; 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 { .trajets {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -9,6 +9,7 @@ import {
EventEmitter, EventEmitter,
Input, Input,
Output, Output,
computed,
inject, inject,
signal signal
} from '@angular/core'; } from '@angular/core';
@@ -40,6 +41,18 @@ export interface TrajetInsertionContext {
positionAvant?: number; 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). * `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); private readonly notifications = inject(NotificationService);
/** /**
* Trajets de la journée. Toujours affichés triés par `Position` croissante : * Trajets du mois. Ils sont regroupés par journée (`groupes`), chaque groupe
* une copie triée est maintenue dans `trajetsTries` à chaque changement d'entrée. * étant trié par `Position` croissante, les groupes eux-mêmes étant ordonnés par
* date croissante.
*/ */
@Input() set trajets(valeur: Trajet[] | null) { @Input() set trajets(valeur: Trajet[] | null) {
this._trajets = valeur ?? []; this._trajets = valeur ?? [];
this.trajetsTries.set([...this._trajets].sort((a, b) => a.position - b.position)); this.trajetsSource.set([...this._trajets]);
} }
get trajets(): Trajet[] { get trajets(): Trajet[] {
return this._trajets; return this._trajets;
@@ -115,8 +129,41 @@ export class TrajetListComponent {
this.survol.emit(id); this.survol.emit(id);
} }
/** Liste triée par Position (source d'affichage). */ /** Source brute des trajets du mois (mise à jour à chaque changement d'entrée). */
readonly trajetsTries = signal<Trajet[]>([]); private readonly trajetsSource = signal<Trajet[]>([]);
/**
* 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). */ /** Trajet en attente de confirmation de suppression (null si aucune). */
readonly trajetASupprimer = signal<Trajet | null>(null); readonly trajetASupprimer = signal<Trajet | null>(null);
@@ -126,9 +173,9 @@ export class TrajetListComponent {
// --- Helpers d'affichage --- // --- Helpers d'affichage ---
/** Vrai si le trajet est le dernier de la journée (pas d'« insérer avant le suivant »). */ /** Vrai si le trajet est le dernier DE SA JOURNÉE (pas d'« insérer avant le suivant »). */
estDernier(index: number): boolean { estDernier(groupe: GroupeJour, index: number): boolean {
return index === this.trajetsTries().length - 1; return index === groupe.trajets.length - 1;
} }
/** Distance formatée à 2 décimales, ou « — » si non calculée (Exigence 2.4). */ /** Distance formatée à 2 décimales, ou « — » si non calculée (Exigence 2.4). */
@@ -144,6 +191,11 @@ export class TrajetListComponent {
return trajet.id; return trajet.id;
} }
/** Identité stable pour le suivi d'un groupe/jour. */
trackByDate(_index: number, groupe: GroupeJour): string {
return groupe.date;
}
// --- Actions par trajet --- // --- Actions par trajet ---
/** Demande la modification d'un trajet (Exigence 5.1). */ /** 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 * du trajet courant et l'adresse d'arrivée avec le départ du trajet suivant
* (Exigence 11.3/11.4). * (Exigence 11.3/11.4).
*/ */
insererAvantSuivant(index: number): void { insererAvantSuivant(groupe: GroupeJour, index: number): void {
const trajetsTries = this.trajetsTries(); const courant = groupe.trajets[index];
const courant = trajetsTries[index]; const suivant = groupe.trajets[index + 1];
const suivant = trajetsTries[index + 1]; if (!courant || !suivant) {
if (!suivant) {
return; return;
} }
this.insertBetween.emit({ this.insertBetween.emit({
@@ -223,30 +274,39 @@ export class TrajetListComponent {
// --- Glisser-déposer (Exigence 11.9) --- // --- Glisser-déposer (Exigence 11.9) ---
/** /**
* Au dépôt d'un trajet : réordonne localement, recalcule les positions (pas de 1.0) * Au dépôt d'un trajet DANS SA JOURNÉE : réordonne localement les trajets de ce
* et persiste via `PATCH /api/trajets/reordonner`. En cas d'échec, l'ordre local est * jour uniquement, recalcule leurs positions séquentielles et persiste via
* rétabli à partir des positions d'origine. * `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<Trajet[]>): void { onDrop(groupe: GroupeJour, event: CdkDragDrop<Trajet[]>): void {
if (event.previousIndex === event.currentIndex) { if (event.previousIndex === event.currentIndex) {
return; return;
} }
const ordreInitial = [...this.trajetsTries()]; const ordreInitialSource = [...this.trajetsSource()];
const reordonnes = [...this.trajetsTries()];
moveItemInArray(reordonnes, event.previousIndex, event.currentIndex);
// Recalcule les positions séquentielles (1.0, 2.0, …) selon le nouvel ordre. // Réordonne uniquement les trajets de la journée concernée.
const positions: TrajetPosition[] = reordonnes.map((trajet, index) => ({ 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, id: trajet.id,
position: index + 1 position: index + 1
})); }));
// Reflète immédiatement le nouvel ordre dans l'UI (feedback < 1 000 ms). // Reflète immédiatement le nouvel ordre dans l'UI (feedback < 1 000 ms) :
this.trajetsTries.set(reordonnes.map((trajet, index) => ({ // on met à jour la source en réécrivant les positions des trajets de ce jour.
...trajet, const nouvellesPositions = new Map(positions.map((p) => [p.id, p.position]));
position: index + 1 this.trajetsSource.set(
}))); this.trajetsSource().map((t) =>
nouvellesPositions.has(t.id)
? { ...t, position: nouvellesPositions.get(t.id)! }
: t
)
);
this.operationEnCours.set(true); this.operationEnCours.set(true);
const dto: ReordonnerTrajets = { trajets: positions }; const dto: ReordonnerTrajets = { trajets: positions };
@@ -259,7 +319,7 @@ export class TrajetListComponent {
}, },
error: (err) => { error: (err) => {
// Rétablit l'ordre précédent en cas d'échec. // 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é.'); this.gererErreur(err, 'Le réordonnancement a échoué.');
} }
}); });