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
@@ -104,11 +104,13 @@ public class TrajetsController : ControllerBase
trajet.Date,
trajet.AdresseDepart,
trajet.AdresseArrivee,
trajet.Distance,
// Distance EFFECTIVE (doublée si aller/retour) exposée au frontend.
trajet.DistanceEffective,
trajet.Commentaire,
trajet.Position,
trajet.VehiculeId,
trajet.PuissanceFiscale));
trajet.PuissanceFiscale,
trajet.AllerRetour));
}
/// <summary>
@@ -227,12 +229,14 @@ public class TrajetsController : ControllerBase
trajet.Date,
trajet.AdresseDepart,
trajet.AdresseArrivee,
trajet.Distance ?? 0m,
// Distance EFFECTIVE (doublée si aller/retour), cohérente avec la liste.
trajet.DistanceEffective ?? 0m,
depart?.Latitude,
depart?.Longitude,
arrivee?.Latitude,
arrivee?.Longitude,
geometrie));
geometrie,
trajet.AllerRetour));
}
return Ok(resultats);
@@ -0,0 +1,393 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SchotzIadKmTracker.Api.Data;
#nullable disable
namespace SchotzIadKmTracker.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260915073357_TrajetAllerRetour")]
partial class TrajetAllerRetour
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.AdresseCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Adresse")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("DerniereUtilisation")
.HasColumnType("TEXT");
b.Property<string>("GeometrieJson")
.HasColumnType("TEXT");
b.Property<double>("Latitude")
.HasColumnType("REAL");
b.Property<double>("Longitude")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("Adresse")
.IsUnique();
b.ToTable("AdresseCache", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.AdresseNommee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Adresse")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Denomination")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DenominationNormalisee")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("GeocodageReussi")
.HasColumnType("INTEGER");
b.Property<double?>("Latitude")
.HasColumnType("REAL");
b.Property<double?>("Longitude")
.HasColumnType("REAL");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
.IsUnique();
b.ToTable("AdressesNommees", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Compte", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("MotDePasseHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Email" }, "UX_Comptes_Email")
.IsUnique();
b.ToTable("Comptes", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.MontantRegle", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Annee")
.HasColumnType("INTEGER");
b.Property<int>("Mois")
.HasColumnType("INTEGER");
b.Property<decimal>("Montant")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VehiculeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VehiculeId");
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
.IsUnique();
b.ToTable("MontantsRegles", null, t =>
{
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Profil", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("AdresseDomicile")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("DomicileGeocode")
.HasColumnType("INTEGER");
b.Property<double?>("DomicileLatitude")
.HasColumnType("REAL");
b.Property<double?>("DomicileLongitude")
.HasColumnType("REAL");
b.Property<string>("Matricule")
.HasMaxLength(8)
.HasColumnType("TEXT");
b.Property<string>("Nom")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Prenom")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
.IsUnique();
b.ToTable("Profil", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.ResetPasswordToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("Expiration")
.HasColumnType("TEXT");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UsedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Expiration" }, "IX_ResetTokens_Expiration");
b.HasIndex(new[] { "TokenHash" }, "UX_ResetTokens_TokenHash")
.IsUnique();
b.ToTable("ResetPasswordTokens", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Trajet", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdresseArrivee")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AdresseDepart")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("AllerRetour")
.HasColumnType("INTEGER");
b.Property<string>("Commentaire")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<decimal?>("Distance")
.HasColumnType("TEXT");
b.Property<string>("GeometrieJson")
.HasColumnType("TEXT");
b.Property<decimal>("Position")
.HasColumnType("TEXT");
b.Property<int>("PuissanceFiscale")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VehiculeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
b.ToTable("Trajets", null, t =>
{
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.TrancheBareme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Annee")
.HasColumnType("INTEGER");
b.Property<decimal>("Base")
.HasColumnType("TEXT");
b.Property<decimal>("Coefficient")
.HasColumnType("TEXT");
b.Property<decimal?>("KmMax")
.HasColumnType("TEXT");
b.Property<decimal>("KmMin")
.HasColumnType("TEXT");
b.Property<int>("PuissanceFiscale")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
b.ToTable("TranchesBareme", null, t =>
{
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMax\" IS NULL OR CAST(\"KmMin\" AS REAL) < CAST(\"KmMax\" AS REAL)");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Vehicule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateOnly>("DateEffet")
.HasColumnType("TEXT");
b.Property<string>("Denomination")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Plaque")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("TEXT");
b.Property<int>("PuissanceFiscale")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
b.ToTable("Vehicules", null, t =>
{
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.MontantRegle", b =>
{
b.HasOne("SchotzIadKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
.WithMany()
.HasForeignKey("VehiculeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Vehicule");
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Trajet", b =>
{
b.HasOne("SchotzIadKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
.WithMany("Trajets")
.HasForeignKey("VehiculeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Vehicule");
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Vehicule", b =>
{
b.Navigation("Trajets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SchotzIadKmTracker.Api.Data.Migrations
{
/// <inheritdoc />
public partial class TrajetAllerRetour : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllerRetour",
table: "Trajets",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllerRetour",
table: "Trajets");
}
}
}
@@ -242,6 +242,9 @@ namespace SchotzIadKmTracker.Api.Data.Migrations
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("AllerRetour")
.HasColumnType("INTEGER");
b.Property<string>("Commentaire")
.HasMaxLength(500)
.HasColumnType("TEXT");
@@ -10,7 +10,8 @@ public record CreateTrajetDto(
double? ArriveeLatitude, // null si adresse saisie manuellement sans BAN
double? ArriveeLongitude,
string? Commentaire, // optionnel, max 500 car.
int? VehiculeId // optionnel ; null → véhicule courant à la date du trajet
int? VehiculeId, // optionnel ; null → véhicule courant à la date du trajet
bool AllerRetour = false // true → distance comptée doublée (aller/retour)
);
// Mise à jour
@@ -23,7 +24,8 @@ public record UpdateTrajetDto(
double? ArriveeLatitude,
double? ArriveeLongitude,
string? Commentaire,
int? VehiculeId // optionnel ; null → véhicule courant à la date du trajet
int? VehiculeId, // optionnel ; null → véhicule courant à la date du trajet
bool AllerRetour = false // true → distance comptée doublée (aller/retour)
);
// Réponse trajet
@@ -32,11 +34,16 @@ public record TrajetDto(
DateOnly Date,
string AdresseDepart,
string AdresseArrivee,
decimal? Distance, // km, 2 décimales ; null si la distance n'a pas pu être calculée (Exigence 2.4)
// IMPORTANT : ce champ expose la DISTANCE EFFECTIVE (distance comptée), c'est-à-dire la
// distance simple doublée lorsque AllerRetour vaut true. Le doublement est appliqué à la
// source du DTO afin que la liste, la carte et le PDF affichent directement la distance
// comptée sans logique supplémentaire. null si la distance n'a pas pu être calculée (Exigence 2.4).
decimal? Distance,
string? Commentaire,
decimal Position, // rang dans la journée
int VehiculeId,
int PuissanceFiscale
int PuissanceFiscale,
bool AllerRetour // information aller/retour conservée par trajet
);
// Trajet enrichi pour la carte
@@ -45,12 +52,14 @@ public record TrajetCarteDto(
DateOnly Date,
string AdresseDepart,
string AdresseArrivee,
// Distance EFFECTIVE (doublée si AllerRetour), pour cohérence avec l'affichage de la liste.
decimal Distance,
double? DepartLatitude,
double? DepartLongitude,
double? ArriveeLatitude,
double? ArriveeLongitude,
string? GeometrieJson // GeoJSON LineString ou null
string? GeometrieJson, // GeoJSON LineString ou null
bool AllerRetour = false // information aller/retour conservée par trajet
);
// Réordonnancement manuel des trajets d'une journée
@@ -25,6 +25,23 @@ public class Trajet
/// </summary>
public decimal? Distance { get; set; }
/// <summary>
/// Indique un trajet aller/retour. Lorsqu'il vaut <c>true</c>, la distance COMPTÉE est le
/// double de la distance simple (aller) stockée dans <see cref="Distance"/> ; la ligne de
/// trajet reste unique. Valeur par défaut <c>false</c> : les trajets existants conservent
/// leur comportement (distance simple).
/// </summary>
public bool AllerRetour { get; set; }
/// <summary>
/// Distance comptée (effective), doublée lorsque <see cref="AllerRetour"/> vaut <c>true</c>.
/// Propriété calculée NON MAPPÉE : elle n'est pas persistée mais dérivée de
/// <see cref="Distance"/> et <see cref="AllerRetour"/>. <c>null</c> lorsque la distance simple
/// est inconnue (non calculable), afin de préserver le comportement existant dans les sommes.
/// </summary>
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public decimal? DistanceEffective => Distance.HasValue ? Distance.Value * (AllerRetour ? 2m : 1m) : (decimal?)null;
/// <summary>Commentaire optionnel (0–500 caractères).</summary>
[MaxLength(500)]
public string? Commentaire { get; set; }
@@ -94,9 +94,11 @@ public class MontantRegleService : IMontantRegleService
.Where(t => t.VehiculeId == vehiculeId
&& t.Date.Year == annee
&& t.Date.Month <= mois)
.Select(t => t.Distance)
// On ramène la distance simple et l'indicateur aller/retour, puis on somme la
// distance EFFECTIVE (doublée si A/R) côté client (null → 0).
.Select(t => new { t.Distance, t.AllerRetour })
.ToListAsync(cancellationToken);
var totalAnnuelPartiel = distancesPartielles.Sum(d => d ?? 0m);
var totalAnnuelPartiel = distancesPartielles.Sum(x => x.Distance.GetValueOrDefault() * (x.AllerRetour ? 2m : 1m));
// Barème plein appliqué au total partiel (sans minoration).
var bareme = await _baremeService.CalculerMontantAsync(
@@ -423,7 +423,11 @@ public class PdfService : IPdfService
foreach (var trajet in modele.Trajets)
{
var motif = string.IsNullOrWhiteSpace(trajet.Commentaire) ? "—" : trajet.Commentaire!;
var itineraire = $"{trajet.AdresseDepart} → {trajet.AdresseArrivee}";
// Suffixe « (A/R) » pour les trajets aller/retour ; la distance affichée est
// déjà la distance effective (doublée) portée par le TrajetDto.
var itineraire = trajet.AllerRetour
? $"{trajet.AdresseDepart} → {trajet.AdresseArrivee} (A/R)"
: $"{trajet.AdresseDepart} → {trajet.AdresseArrivee}";
var km = trajet.Distance ?? 0m;
table.Cell().Element(Cellule).Text(trajet.Date.ToString("dd/MM/yyyy", FrCulture));
@@ -38,12 +38,14 @@ public class RemboursementService : IRemboursementService
var trajetsAnnee = await _context.Trajets
.AsNoTracking()
.Where(t => t.VehiculeId == vehiculeId && t.Date.Year == annee)
.Select(t => new { t.Date.Month, t.Distance })
// On ramène la distance simple et l'indicateur aller/retour, puis on agrège la
// distance EFFECTIVE (doublée si A/R) côté client.
.Select(t => new { t.Date.Month, t.Distance, t.AllerRetour })
.ToListAsync(cancellationToken);
var kmMoisMap = trajetsAnnee
.GroupBy(t => t.Month)
.ToDictionary(g => g.Key, g => g.Sum(t => t.Distance ?? 0m));
.ToDictionary(g => g.Key, g => g.Sum(t => (t.Distance ?? 0m) * (t.AllerRetour ? 2m : 1m)));
// Total kilométrique annuel COMPLET (détermine la tranche — Exigence 14.5, Property 27).
var totalKmAnnuel = kmMoisMap.Values.Sum();
@@ -98,7 +98,8 @@ public class ResumeService : IResumeService
private static ResumeMensuelDto ConstruireResumeMensuel(int annee, int mois, IReadOnlyList<Trajet> trajets)
{
var trajetsDto = trajets.Select(ToDto).ToList();
var totalKm = Arrondir(trajets.Sum(t => t.Distance ?? 0m));
// Total = somme des distances EFFECTIVES (doublées si aller/retour), null → 0 (Property 1).
var totalKm = Arrondir(trajets.Sum(t => t.DistanceEffective ?? 0m));
return new ResumeMensuelDto(annee, mois, trajetsDto, totalKm);
}
@@ -130,9 +131,11 @@ public class ResumeService : IResumeService
t.Date,
t.AdresseDepart,
t.AdresseArrivee,
t.Distance,
// Distance EFFECTIVE (doublée si aller/retour), alignée sur le TrajetDto commun.
t.DistanceEffective,
t.Commentaire,
t.Position,
t.VehiculeId,
t.PuissanceFiscale);
t.PuissanceFiscale,
t.AllerRetour);
}
@@ -29,24 +29,30 @@ public class TotauxService : ITotauxService
var trajetsAnnee = await _context.Trajets
.AsNoTracking()
.Where(t => t.VehiculeId == vehiculeCible && t.Date.Year == date.Year)
.Select(t => new { t.Date, Distance = (decimal?)t.Distance ?? 0m })
// Projection : on ramène la distance simple et l'indicateur aller/retour, puis on
// calcule la distance EFFECTIVE (doublée si A/R) côté client lors des sommes.
.Select(t => new { t.Date, t.Distance, t.AllerRetour })
.ToListAsync(cancellationToken);
// TotalJour : somme des distances de la journée sélectionnée (Exigence 12.7).
// Distance comptée (effective) : distance simple × 2 si aller/retour, null → 0.
static decimal Effective(decimal? distance, bool allerRetour) =>
distance.GetValueOrDefault() * (allerRetour ? 2m : 1m);
// TotalJour : somme des distances effectives de la journée sélectionnée (Exigence 12.7).
var totalJour = trajetsAnnee
.Where(t => t.Date == date)
.Sum(t => t.Distance);
.Sum(t => Effective(t.Distance, t.AllerRetour));
// TotalMois : somme des distances du mois de la date sélectionnée (Exigence 12.7).
// TotalMois : somme des distances effectives du mois de la date sélectionnée (Exigence 12.7).
var totalMois = trajetsAnnee
.Where(t => t.Date.Month == date.Month)
.Sum(t => t.Distance);
.Sum(t => Effective(t.Distance, t.AllerRetour));
// Total_Annuel_Partiel : cumul de l'année jusqu'au mois sélectionné INCLUS,
// exclusion des mois strictement postérieurs (Exigence 12.8, Property 17).
var totalAnnuelPartiel = trajetsAnnee
.Where(t => t.Date.Month <= date.Month)
.Sum(t => t.Distance);
.Sum(t => Effective(t.Distance, t.AllerRetour));
return new TotauxDto(
Date: date,
@@ -49,6 +49,9 @@ public class TrajetService : ITrajetService
AdresseDepart = dto.AdresseDepart,
AdresseArrivee = dto.AdresseArrivee,
Commentaire = dto.Commentaire,
// Aller/retour : recopie du choix de saisie ; le doublement de la distance est dérivé
// (DistanceEffective) et ne modifie pas la distance simple stockée.
AllerRetour = dto.AllerRetour,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
@@ -184,6 +187,9 @@ public class TrajetService : ITrajetService
trajet.AdresseDepart = dto.AdresseDepart;
trajet.AdresseArrivee = dto.AdresseArrivee;
trajet.Commentaire = dto.Commentaire;
// Aller/retour : champ simple, modifiable même sans changement d'adresse (aucun
// recalcul ORS nécessaire, le doublement est dérivé de DistanceEffective).
trajet.AllerRetour = dto.AllerRetour;
trajet.UpdatedAt = DateTime.UtcNow;
if (trajet.Date != nouvelleDate)
@@ -438,9 +444,11 @@ public class TrajetService : ITrajetService
t.Date,
t.AdresseDepart,
t.AdresseArrivee,
t.Distance,
// Distance EFFECTIVE (doublée si aller/retour) exposée au frontend.
t.DistanceEffective,
t.Commentaire,
t.Position,
t.VehiculeId,
t.PuissanceFiscale);
t.PuissanceFiscale,
t.AllerRetour);
}
+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 {
+36
View File
@@ -29,6 +29,38 @@ if [[ -z "${ORS__ApiKey:-}" && -f "$RACINE/.env" ]]; then
fi
fi
# --- Authentification (dev) -------------------------------------------------
# Clé de signature JWT : lue depuis .env (JWT_SECRET_KEY). Si absente, on génère
# un secret ALÉATOIRE de développement (valable pour cette session). En dev, cela
# signifie que les sessions existantes sont invalidées à chaque relance du script,
# ce qui est acceptable ; en production, définis JWT_SECRET_KEY dans .env.
if [[ -z "${JWT__SecretKey:-}" ]]; then
JWT_KEY_FROM_ENV=""
if [[ -f "$RACINE/.env" ]]; then
JWT_KEY_FROM_ENV="$(grep -E '^JWT_SECRET_KEY=' "$RACINE/.env" | head -n1 | cut -d= -f2- || true)"
fi
# Ignore la valeur placeholder du .env.example si elle a été copiée telle quelle.
if [[ -n "$JWT_KEY_FROM_ENV" && "$JWT_KEY_FROM_ENV" != "change-me-generate-a-long-random-secret" ]]; then
export JWT__SecretKey="$JWT_KEY_FROM_ENV"
echo "→ Clé JWT chargée depuis .env."
else
# Secret de dev aléatoire (>= 32 octets) : openssl si dispo, sinon /dev/urandom.
if command -v openssl >/dev/null 2>&1; then
export JWT__SecretKey="$(openssl rand -base64 48)"
else
export JWT__SecretKey="$(head -c 48 /dev/urandom | base64)"
fi
echo "⚠ Aucune clé JWT dans .env : secret de développement aléatoire généré (sessions invalidées à chaque relance)."
fi
fi
# URL de base du frontend pour les liens de réinitialisation (dev = ng serve sur 4200).
export Auth__FrontendBaseUrl="${Auth__FrontendBaseUrl:-http://localhost:4200}"
# SMTP non configuré en dev : le lien de réinitialisation est journalisé dans la
# sortie du backend (repli LoggingEmailService). Cherche « reset-password?token= »
# dans les logs pour tester la réinitialisation sans email réel.
echo "→ Démarrage du backend sur http://localhost:8080 (base : $RACINE/backend/SchotzIadKmTracker.Api/trajets.db)"
dotnet run --project "$RACINE/backend/SchotzIadKmTracker.Api" --urls http://localhost:8080 &
PID_BACK=$!
@@ -100,6 +132,10 @@ if attendre_frontend; then
echo ""
echo " Au premier chargement, fais Ctrl+Shift+R pour vider le cache navigateur."
echo " Vérif : dans la console DevTools, 'window.Zone' doit renvoyer un objet."
echo ""
echo " Auth : à la 1re utilisation, crée le compte via /register puis connecte-toi."
echo " Reset de mot de passe (dev) : le lien est journalisé dans la sortie backend"
echo " ci-dessus (cherche « reset-password?token= »), aucun email n'est envoyé."
echo "======================================================================"
echo ""
fi