UX consultation, trajets aller/retour et rafraîchissement des totaux

- Totaux : rafraîchissement immédiat du panneau (jour/mois/année + montant
  prévisionnel) après création/modification/suppression/réordonnancement d'un
  trajet, sans changer de jour (ViewChild + méthode publique rafraichir()).
- Trajets Aller/Retour : case à cocher au formulaire ; la distance comptée est
  doublée (distance simple stockée + booléen AllerRetour, distance effective
  utilisée dans tous les agrégats : totaux, résumés, barème, régularisation, PDF).
  Migration TrajetAllerRetour. Badge « A/R » dans une colonne dédiée en début de
  ligne pour garder date/adresses alignées.
- Mise en page de la page Trajets : vue à hauteur fixe où seule la liste défile ;
  colonne gauche figée (sélecteur de date + navigation, totaux, carte) et bande
  fixe (véhicule + bouton d'ajout) au-dessus de la liste. Layout applicatif global
  en pleine hauteur (app.component).
- run-dev.sh : chargement de la clé JWT depuis .env (ou secret de dev aléatoire) et
  Auth__FrontendBaseUrl sur le frontend dev ; rappel du lien de reset journalisé.

Build backend (solution) et frontend (production) OK.
This commit is contained in:
2026-09-15 12:33:10 +02:00
parent dd36bbc425
commit 09c24d5f59
24 changed files with 759 additions and 109 deletions
+24 -2
View File
@@ -1,4 +1,12 @@
:host {
display: flex;
flex-direction: column;
height: 100vh;
min-height: 0;
}
.nav-bar {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -6,8 +14,6 @@
padding: 0.5rem 1rem;
background: #1f2937;
border-bottom: 1px solid #111827;
position: sticky;
top: 0;
z-index: 500;
&__lien {
@@ -30,3 +36,19 @@
}
}
}
/* Le contenu routé occupe toute la hauteur restante sous le menu.
Les vues qui le souhaitent (ex. consultation) peuvent ainsi figer leur mise en page
et ne faire défiler qu'une sous-zone interne. */
main {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
main > * {
flex: 1 1 auto;
min-height: 0;
}
+5 -1
View File
@@ -15,6 +15,7 @@ export interface CreateTrajet {
arriveeLongitude?: number | null;
commentaire?: string | null; // optionnel, max 500 car.
vehiculeId?: number | null; // null -> véhicule courant à la date du trajet
allerRetour?: boolean; // true -> distance comptée doublée (aller/retour)
}
/** Mise à jour d'un Trajet. */
@@ -28,6 +29,7 @@ export interface UpdateTrajet {
arriveeLongitude?: number | null;
commentaire?: string | null;
vehiculeId?: number | null;
allerRetour?: boolean; // true -> distance comptée doublée (aller/retour)
}
/** Réponse Trajet. */
@@ -36,11 +38,12 @@ export interface Trajet {
date: string; // ISO date (yyyy-MM-dd) renvoyée par l'API
adresseDepart: string;
adresseArrivee: string;
distance: number | null; // km, 2 décimales ; null si non calculable (Exigence 2.4)
distance: number | null; // DISTANCE EFFECTIVE (déjà doublée si A/R) ; null si non calculable
commentaire: string | null;
position: number; // rang dans la journée
vehiculeId: number;
puissanceFiscale: number;
allerRetour: boolean; // information aller/retour conservée par trajet
}
/**
@@ -59,6 +62,7 @@ export interface TrajetCarte {
arriveeLatitude?: number | null;
arriveeLongitude?: number | null;
geometrieJson: string | null; // GeoJSON LineString ou null
allerRetour?: boolean; // information aller/retour conservée par trajet
}
/** Position d'un trajet lors d'un réordonnancement. */
@@ -1,58 +1,71 @@
<section class="consultation" aria-label="Consultation des trajets d'une journée">
<!-- Barre supérieure : sélecteur temporel + navigation jour ± 1 + sélecteur véhicule -->
<header class="consultation__barre">
<app-selecteur-temporel
[date]="date()"
(dateSelectionnee)="onDateSelectionnee($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édent
</button>
<span class="consultation__date" aria-live="polite">
{{ dateFormatee }}
</span>
<button
type="button"
class="consultation__nav-bouton"
(click)="jourSuivant()"
[disabled]="jourSuivantDesactive"
>
Jour suivant ›
</button>
</div>
<label class="consultation__vehicule">
<span class="consultation__vehicule-libelle">Véhicule</span>
<select
class="consultation__vehicule-select"
[value]="vehiculeId() ?? ''"
(change)="onVehiculeChange($any($event.target).value)"
>
@for (v of vehicules(); track v.id) {
<option [value]="v.id">
{{ v.denomination }} — {{ v.plaque }} ({{ v.puissanceFiscale }} CV)
</option>
}
</select>
</label>
</header>
<div class="consultation__corps">
<!-- Zone principale : bouton d'ajout + liste des trajets (scroll vertical) -->
<!-- Colonne GAUCHE fixe (sticky) : sélecteur de date + navigation, puis totaux, puis carte.
Reste visible pendant que la liste défile avec la page. -->
<aside class="consultation__panneau">
<div class="consultation__dates">
<app-selecteur-temporel
[date]="date()"
(dateSelectionnee)="onDateSelectionnee($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
[date]="date()"
[vehiculeId]="vehiculeId()"
></app-panneau-totaux>
<app-carte-trajets
[trajets]="trajetsCarte()"
[trajetSurvoleId]="trajetSurvoleId()"
></app-carte-trajets>
</aside>
<!-- Colonne DROITE : bande fixe (véhicule + ajout) puis liste défilant avec la page. -->
<main class="consultation__principal">
<div class="consultation__actions">
<header class="consultation__barre-liste">
<label class="consultation__vehicule">
<span class="consultation__vehicule-libelle">Véhicule</span>
<select
class="consultation__vehicule-select"
[value]="vehiculeId() ?? ''"
(change)="onVehiculeChange($any($event.target).value)"
>
@for (v of vehicules(); track v.id) {
<option [value]="v.id">
{{ v.denomination }} — {{ v.plaque }} ({{ v.puissanceFiscale }} CV)
</option>
}
</select>
</label>
<button type="button" class="consultation__ajouter" (click)="ouvrirCreation()">
+ Ajouter un trajet
</button>
</div>
</header>
@if (erreur()) {
<p class="consultation__erreur" role="alert">{{ erreur() }}</p>
@@ -76,19 +89,6 @@
></app-trajet-list>
</div>
</main>
<!-- Panneau latéral sticky : totaux en haut, carte en dessous -->
<aside class="consultation__panneau">
<app-panneau-totaux
[date]="date()"
[vehiculeId]="vehiculeId()"
></app-panneau-totaux>
<app-carte-trajets
[trajets]="trajetsCarte()"
[trajetSurvoleId]="trajetSurvoleId()"
></app-carte-trajets>
</aside>
</div>
<!-- Formulaire en modale : la liste / carte / totaux restent visibles derrière. -->
@@ -1,21 +1,42 @@
:host {
display: block;
height: 100%;
min-height: 0;
}
.consultation {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 1rem;
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
box-sizing: border-box;
overflow: hidden; // aucun scroll global : seule la liste défile (voir &__liste)
&__barre {
// Zone dates (colonne gauche) : sélecteur temporel + navigation ± 1 jour, compacte.
&__dates {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: #f7f8fa;
border: 1px solid #e3e6ea;
border-radius: 8px;
}
// Bande fixe en haut de la colonne DROITE : sélecteur véhicule + bouton d'ajout.
// Reste visible pendant le scroll de la page (la liste défile en dessous).
&__barre-liste {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 1rem 1.5rem;
padding: 1rem;
justify-content: space-between;
gap: 0.75rem 1rem;
padding: 0.75rem;
background: #f7f8fa;
border: 1px solid #e3e6ea;
border-radius: 8px;
@@ -24,7 +45,8 @@
&__navigation {
display: flex;
align-items: center;
gap: 0.75rem;
justify-content: space-between;
gap: 0.5rem;
}
&__nav-bouton {
@@ -56,7 +78,6 @@
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-left: auto;
}
&__vehicule-libelle {
@@ -75,16 +96,20 @@
// --- Corps deux colonnes ---
&__corps {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(380px, 520px);
// Colonne GAUCHE = dates + totaux + carte (fixe) ; DROITE = bande + liste (liste défilante).
grid-template-columns: minmax(380px, 460px) minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
align-items: stretch;
flex: 1 1 auto;
min-height: 0; // indispensable pour que la colonne droite puisse contraindre la liste
}
&__principal {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.75rem;
min-width: 0;
min-height: 0; // permet à la liste enfant de scroller au lieu de pousser la hauteur
}
&__formulaire {
@@ -100,6 +125,7 @@
}
&__ajouter {
align-self: flex-end;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
@@ -107,6 +133,7 @@
color: #fff;
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
&:hover {
background: #135ba1;
@@ -123,9 +150,11 @@
margin: 0;
}
// Zone des trajets : scroll vertical quand la liste est haute (Exigence 12.6).
// Zone des trajets : SEULE zone défilante de la vue. Elle occupe la hauteur restante
// de la colonne droite et fait défiler uniquement la liste (tout le reste est figé).
&__liste {
max-height: 55vh;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
border: 1px solid #e3e6ea;
border-radius: 8px;
@@ -139,12 +168,16 @@
}
// Panneau latéral toujours visible (sticky) : totaux + carte (Exigence 12.9).
// Il fait partie de la zone FIXE : il se cale près du haut du viewport pendant que
// la liste des trajets défile avec la page. `top` réduit pour maximiser la visibilité ;
// borné en hauteur avec défilement interne si les totaux + carte dépassent l'écran.
&__panneau {
position: sticky;
top: 1rem;
// Colonne gauche FIGÉE : ne défile jamais. Alignée en haut.
align-self: start;
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 0;
}
}
@@ -179,5 +212,7 @@
.consultation__panneau {
position: static;
// Sur une colonne, le panneau (totaux + carte) s'affiche en premier, au-dessus de la liste.
order: -1;
}
}
@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild, inject, signal } from '@angular/core';
import { Subscription, forkJoin, of } from 'rxjs';
import { catchError, finalize } from 'rxjs/operators';
@@ -65,6 +65,9 @@ export class ConsultationJourComponent implements OnInit, OnDestroy {
private readonly vehiculeService = inject(VehiculeService);
private readonly cdr = inject(ChangeDetectorRef);
/** 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. */
readonly date = signal<string | null>(null);
@@ -232,6 +235,10 @@ export class ConsultationJourComponent implements OnInit, OnDestroy {
private rafraichirJournee(): void {
this.jourSub?.unsubscribe();
// Force le recalcul des totaux même si la date/le véhicule n'ont pas changé
// (ex. ajout/modification/suppression d'un trajet sur la journée affichée).
this.panneauTotaux?.rafraichir();
const date = this.date();
if (!date) {
this.trajets.set([]);
@@ -84,8 +84,13 @@ export class PanneauTotauxComponent implements OnChanges, OnDestroy {
* Recharge les totaux et le montant prévisionnel pour la date et le véhicule
* courants. Ne déclenche aucun appel tant que les deux entrées ne sont pas
* disponibles.
*
* Public afin que le conteneur (`ConsultationJourComponent`) puisse forcer un
* rafraîchissement après une création / modification / suppression / réordonnancement
* de trajet sur la MÊME journée (cas où ni `date` ni `vehiculeId` ne changent, donc
* `ngOnChanges` ne se déclenche pas).
*/
private rafraichir(): void {
rafraichir(): void {
this.totauxSub?.unsubscribe();
this.montantSub?.unsubscribe();
@@ -214,6 +214,14 @@
}
</div>
<!-- Aller/Retour : double la distance comptée, sans créer de trajet retour. -->
<div class="champ champ--aller-retour">
<label class="case-aller-retour">
<input type="checkbox" formControlName="allerRetour" />
<span>Aller/Retour (compte le double de la distance)</span>
</label>
</div>
<!-- Actions -->
<div class="actions">
<button type="submit" class="btn-primaire" [disabled]="soumissionEnCours()">
@@ -212,3 +212,21 @@
background: #f3f5f8;
}
}
/* Case Aller/Retour : compacte, sur une seule ligne pour gagner en hauteur. */
.champ--aller-retour {
margin: 0.25rem 0 0.5rem;
}
.case-aller-retour {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
cursor: pointer;
}
.case-aller-retour input {
width: 1rem;
height: 1rem;
margin: 0;
}
@@ -371,7 +371,8 @@ export class TrajetFormComponent implements OnInit, OnDestroy {
'',
[Validators.required, Validators.minLength(1), Validators.maxLength(ADRESSE_MAX)]
],
commentaire: ['', [Validators.maxLength(COMMENTAIRE_MAX)]]
commentaire: ['', [Validators.maxLength(COMMENTAIRE_MAX)]],
allerRetour: [false]
});
}
@@ -382,7 +383,8 @@ export class TrajetFormComponent implements OnInit, OnDestroy {
date: this.versIsoInput(this.trajet.date),
adresseDepart: this.trajet.adresseDepart,
adresseArrivee: this.trajet.adresseArrivee,
commentaire: this.trajet.commentaire ?? ''
commentaire: this.trajet.commentaire ?? '',
allerRetour: this.trajet.allerRetour ?? false
});
// En édition, on repart sans coordonnées locales : elles ne seront recalculées
// côté backend que si l'adresse est modifiée (Exigence 5.2/5.3). Les adresses du
@@ -549,7 +551,8 @@ export class TrajetFormComponent implements OnInit, OnDestroy {
arriveeLatitude: coordArrivee?.latitude ?? null,
arriveeLongitude: coordArrivee?.longitude ?? null,
commentaire: this.normaliserCommentaire(),
vehiculeId: this.vehiculeId
vehiculeId: this.vehiculeId,
allerRetour: !!this.form.get('allerRetour')?.value
};
}
@@ -565,7 +568,8 @@ export class TrajetFormComponent implements OnInit, OnDestroy {
arriveeLatitude: coordArrivee?.latitude ?? null,
arriveeLongitude: coordArrivee?.longitude ?? null,
commentaire: this.normaliserCommentaire(),
vehiculeId: this.vehiculeId
vehiculeId: this.vehiculeId,
allerRetour: !!this.form.get('allerRetour')?.value
};
}
@@ -618,7 +622,8 @@ export class TrajetFormComponent implements OnInit, OnDestroy {
date: '',
adresseDepart: '',
adresseArrivee: '',
commentaire: ''
commentaire: '',
allerRetour: false
});
this.coordDepart.set(null);
this.coordArrivee.set(null);
@@ -28,6 +28,13 @@
⠿
</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>
@@ -84,6 +84,29 @@
font-weight: 600;
color: #1f2937;
white-space: nowrap;
text-align: right;
}
// Colonne dédiée A/R en début de ligne (largeur fixe) : maintient l'alignement
// des dates/adresses, qu'un trajet soit aller/retour ou non.
&__ar-col {
flex: 0 0 2.4rem;
display: flex;
align-items: center;
justify-content: center;
}
// Badge « A/R » discret.
&__ar {
display: inline-block;
padding: 0 0.35rem;
font-size: 0.68rem;
font-weight: 700;
line-height: 1.4;
color: #1565c0;
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 4px;
}
&__actions {