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">
<app-selecteur-temporel
[date]="date()"
(dateSelectionnee)="onDateSelectionnee($event)"
(moisSelectionne)="onMoisSelectionne($event)"
></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>
<app-panneau-totaux
@@ -38,6 +15,31 @@
[vehiculeId]="vehiculeId()"
></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
[trajets]="trajetsCarte()"
[trajetSurvoleId]="trajetSurvoleId()"
@@ -77,7 +79,7 @@
<div class="consultation__liste">
@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
[trajets]="trajets()"
@@ -8,7 +8,10 @@ import { TrajetService } from '../../core/services/trajet.service';
import { VehiculeService } from '../../core/services/vehicule.service';
import { CarteTrajetsComponent } from '../carte-trajets/carte-trajets.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 {
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. */
@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);
/** 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));
}
}
@@ -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] }}
</button>
</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>
</section>
@@ -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<string>();
/** Émis à chaque changement de sélection (année + mois). */
@Output() readonly moisSelectionne = new EventEmitter<MoisSelectionne>();
/** Libellés des douze mois (index 0 = janvier). */
readonly nomsMois = [
@@ -63,17 +71,12 @@ export class SelecteurTemporelComponent implements OnInit, OnChanges {
readonly annees = signal<number[]>([]);
/** 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<number[]>([]);
/** Mois comportant au moins un trajet pour l'année sélectionnée. */
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 moisSelectionne = signal<number | null>(null);
readonly jourSelectionne = signal<number | null>(null);
readonly moisSelectionneSignal = signal<number | null>(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<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. */
@@ -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<number>());
this.moisSelectionneSignal.set(null);
this.moisAvecDonnees.set(new Set<number>());
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<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)
);
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<number>(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());
@@ -1,107 +1,117 @@
@if (trajetsTries().length === 0) {
<p class="liste-vide" role="status">Aucun trajet pour cette journée.</p>
@if (groupes().length === 0) {
<p class="liste-vide" role="status">Aucun trajet pour ce mois.</p>
} @else {
<ul
class="trajets"
cdkDropList
(cdkDropListDropped)="onDrop($event)"
role="list"
>
@for (trajet of trajetsTries(); track trajet.id; let i = $index) {
<li
class="trajet"
cdkDrag
[cdkDragDisabled]="operationEnCours()"
role="listitem"
(mouseenter)="onSurvol(trajet.id)"
(mouseleave)="onSurvol(null)"
<!-- Un groupe par journée : en-tête de date + liste de dépôt indépendante.
Les listes NE sont PAS connectées entre elles → drag limité à chaque jour. -->
@for (groupe of groupes(); track groupe.date) {
<section class="jour" aria-label="Trajets d'une journée">
<h3 class="jour__entete">{{ groupe.date | date: 'dd/MM/yyyy' }}</h3>
<ul
class="trajets"
cdkDropList
[cdkDropListData]="groupe.trajets"
(cdkDropListDropped)="onDrop(groupe, $event)"
role="list"
>
<!-- Poignée de glisser-déposer (Exigence 11.9). -->
<button
type="button"
class="poignee"
cdkDragHandle
[disabled]="operationEnCours()"
aria-label="Déplacer le trajet pour le réordonner"
title="Glisser pour réordonner"
>
⠿
</button>
<!-- 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">
@for (trajet of groupe.trajets; track trajet.id; let i = $index) {
<li
class="trajet"
cdkDrag
[cdkDragData]="trajet"
[cdkDragDisabled]="operationEnCours()"
role="listitem"
(mouseenter)="onSurvol(trajet.id)"
(mouseleave)="onSurvol(null)"
>
<!-- Poignée de glisser-déposer (Exigence 11.9). -->
<button
type="button"
class="action action--icone"
(click)="modifier(trajet)"
class="poignee"
cdkDragHandle
[disabled]="operationEnCours()"
title="Modifier ce trajet"
aria-label="Modifier ce trajet"
aria-label="Déplacer le trajet pour le réordonner"
title="Glisser pour réordonner"
>
✏️
⠿
</button>
<button
type="button"
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(i)) {
<button
type="button"
class="action action--icone"
(click)="insererAvantSuivant(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>
<!-- 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
type="button"
class="action action--icone"
(click)="modifier(trajet)"
[disabled]="operationEnCours()"
title="Modifier ce trajet"
aria-label="Modifier ce trajet"
>
✏️
</button>
<button
type="button"
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). -->
@@ -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;
@@ -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<Trajet[]>([]);
/** Source brute des trajets du mois (mise à jour à chaque changement d'entrée). */
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). */
readonly trajetASupprimer = signal<Trajet | null>(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<Trajet[]>): void {
onDrop(groupe: GroupeJour, event: CdkDragDrop<Trajet[]>): 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é.');
}
});