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.Date,
trajet.AdresseDepart, trajet.AdresseDepart,
trajet.AdresseArrivee, trajet.AdresseArrivee,
trajet.Distance, // Distance EFFECTIVE (doublée si aller/retour) exposée au frontend.
trajet.DistanceEffective,
trajet.Commentaire, trajet.Commentaire,
trajet.Position, trajet.Position,
trajet.VehiculeId, trajet.VehiculeId,
trajet.PuissanceFiscale)); trajet.PuissanceFiscale,
trajet.AllerRetour));
} }
/// <summary> /// <summary>
@@ -227,12 +229,14 @@ public class TrajetsController : ControllerBase
trajet.Date, trajet.Date,
trajet.AdresseDepart, trajet.AdresseDepart,
trajet.AdresseArrivee, trajet.AdresseArrivee,
trajet.Distance ?? 0m, // Distance EFFECTIVE (doublée si aller/retour), cohérente avec la liste.
trajet.DistanceEffective ?? 0m,
depart?.Latitude, depart?.Latitude,
depart?.Longitude, depart?.Longitude,
arrivee?.Latitude, arrivee?.Latitude,
arrivee?.Longitude, arrivee?.Longitude,
geometrie)); geometrie,
trajet.AllerRetour));
} }
return Ok(resultats); 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) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("AllerRetour")
.HasColumnType("INTEGER");
b.Property<string>("Commentaire") b.Property<string>("Commentaire")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -10,7 +10,8 @@ public record CreateTrajetDto(
double? ArriveeLatitude, // null si adresse saisie manuellement sans BAN double? ArriveeLatitude, // null si adresse saisie manuellement sans BAN
double? ArriveeLongitude, double? ArriveeLongitude,
string? Commentaire, // optionnel, max 500 car. 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 // Mise à jour
@@ -23,7 +24,8 @@ public record UpdateTrajetDto(
double? ArriveeLatitude, double? ArriveeLatitude,
double? ArriveeLongitude, double? ArriveeLongitude,
string? Commentaire, 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 // Réponse trajet
@@ -32,11 +34,16 @@ public record TrajetDto(
DateOnly Date, DateOnly Date,
string AdresseDepart, string AdresseDepart,
string AdresseArrivee, 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, string? Commentaire,
decimal Position, // rang dans la journée decimal Position, // rang dans la journée
int VehiculeId, int VehiculeId,
int PuissanceFiscale int PuissanceFiscale,
bool AllerRetour // information aller/retour conservée par trajet
); );
// Trajet enrichi pour la carte // Trajet enrichi pour la carte
@@ -45,12 +52,14 @@ public record TrajetCarteDto(
DateOnly Date, DateOnly Date,
string AdresseDepart, string AdresseDepart,
string AdresseArrivee, string AdresseArrivee,
// Distance EFFECTIVE (doublée si AllerRetour), pour cohérence avec l'affichage de la liste.
decimal Distance, decimal Distance,
double? DepartLatitude, double? DepartLatitude,
double? DepartLongitude, double? DepartLongitude,
double? ArriveeLatitude, double? ArriveeLatitude,
double? ArriveeLongitude, 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 // Réordonnancement manuel des trajets d'une journée
@@ -25,6 +25,23 @@ public class Trajet
/// </summary> /// </summary>
public decimal? Distance { get; set; } 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> /// <summary>Commentaire optionnel (0–500 caractères).</summary>
[MaxLength(500)] [MaxLength(500)]
public string? Commentaire { get; set; } public string? Commentaire { get; set; }
@@ -94,9 +94,11 @@ public class MontantRegleService : IMontantRegleService
.Where(t => t.VehiculeId == vehiculeId .Where(t => t.VehiculeId == vehiculeId
&& t.Date.Year == annee && t.Date.Year == annee
&& t.Date.Month <= mois) && 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); .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). // Barème plein appliqué au total partiel (sans minoration).
var bareme = await _baremeService.CalculerMontantAsync( var bareme = await _baremeService.CalculerMontantAsync(
@@ -423,7 +423,11 @@ public class PdfService : IPdfService
foreach (var trajet in modele.Trajets) foreach (var trajet in modele.Trajets)
{ {
var motif = string.IsNullOrWhiteSpace(trajet.Commentaire) ? "—" : trajet.Commentaire!; 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; var km = trajet.Distance ?? 0m;
table.Cell().Element(Cellule).Text(trajet.Date.ToString("dd/MM/yyyy", FrCulture)); 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 var trajetsAnnee = await _context.Trajets
.AsNoTracking() .AsNoTracking()
.Where(t => t.VehiculeId == vehiculeId && t.Date.Year == annee) .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); .ToListAsync(cancellationToken);
var kmMoisMap = trajetsAnnee var kmMoisMap = trajetsAnnee
.GroupBy(t => t.Month) .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). // Total kilométrique annuel COMPLET (détermine la tranche — Exigence 14.5, Property 27).
var totalKmAnnuel = kmMoisMap.Values.Sum(); var totalKmAnnuel = kmMoisMap.Values.Sum();
@@ -98,7 +98,8 @@ public class ResumeService : IResumeService
private static ResumeMensuelDto ConstruireResumeMensuel(int annee, int mois, IReadOnlyList<Trajet> trajets) private static ResumeMensuelDto ConstruireResumeMensuel(int annee, int mois, IReadOnlyList<Trajet> trajets)
{ {
var trajetsDto = trajets.Select(ToDto).ToList(); 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); return new ResumeMensuelDto(annee, mois, trajetsDto, totalKm);
} }
@@ -130,9 +131,11 @@ public class ResumeService : IResumeService
t.Date, t.Date,
t.AdresseDepart, t.AdresseDepart,
t.AdresseArrivee, t.AdresseArrivee,
t.Distance, // Distance EFFECTIVE (doublée si aller/retour), alignée sur le TrajetDto commun.
t.DistanceEffective,
t.Commentaire, t.Commentaire,
t.Position, t.Position,
t.VehiculeId, t.VehiculeId,
t.PuissanceFiscale); t.PuissanceFiscale,
t.AllerRetour);
} }
@@ -29,24 +29,30 @@ public class TotauxService : ITotauxService
var trajetsAnnee = await _context.Trajets var trajetsAnnee = await _context.Trajets
.AsNoTracking() .AsNoTracking()
.Where(t => t.VehiculeId == vehiculeCible && t.Date.Year == date.Year) .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); .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 var totalJour = trajetsAnnee
.Where(t => t.Date == date) .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 var totalMois = trajetsAnnee
.Where(t => t.Date.Month == date.Month) .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, // 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). // exclusion des mois strictement postérieurs (Exigence 12.8, Property 17).
var totalAnnuelPartiel = trajetsAnnee var totalAnnuelPartiel = trajetsAnnee
.Where(t => t.Date.Month <= date.Month) .Where(t => t.Date.Month <= date.Month)
.Sum(t => t.Distance); .Sum(t => Effective(t.Distance, t.AllerRetour));
return new TotauxDto( return new TotauxDto(
Date: date, Date: date,
@@ -49,6 +49,9 @@ public class TrajetService : ITrajetService
AdresseDepart = dto.AdresseDepart, AdresseDepart = dto.AdresseDepart,
AdresseArrivee = dto.AdresseArrivee, AdresseArrivee = dto.AdresseArrivee,
Commentaire = dto.Commentaire, 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, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
}; };
@@ -184,6 +187,9 @@ public class TrajetService : ITrajetService
trajet.AdresseDepart = dto.AdresseDepart; trajet.AdresseDepart = dto.AdresseDepart;
trajet.AdresseArrivee = dto.AdresseArrivee; trajet.AdresseArrivee = dto.AdresseArrivee;
trajet.Commentaire = dto.Commentaire; 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; trajet.UpdatedAt = DateTime.UtcNow;
if (trajet.Date != nouvelleDate) if (trajet.Date != nouvelleDate)
@@ -438,9 +444,11 @@ public class TrajetService : ITrajetService
t.Date, t.Date,
t.AdresseDepart, t.AdresseDepart,
t.AdresseArrivee, t.AdresseArrivee,
t.Distance, // Distance EFFECTIVE (doublée si aller/retour) exposée au frontend.
t.DistanceEffective,
t.Commentaire, t.Commentaire,
t.Position, t.Position,
t.VehiculeId, 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 { .nav-bar {
flex: 0 0 auto;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@@ -6,8 +14,6 @@
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #1f2937; background: #1f2937;
border-bottom: 1px solid #111827; border-bottom: 1px solid #111827;
position: sticky;
top: 0;
z-index: 500; z-index: 500;
&__lien { &__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; arriveeLongitude?: number | null;
commentaire?: string | null; // optionnel, max 500 car. commentaire?: string | null; // optionnel, max 500 car.
vehiculeId?: number | null; // null -> véhicule courant à la date du trajet 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. */ /** Mise à jour d'un Trajet. */
@@ -28,6 +29,7 @@ export interface UpdateTrajet {
arriveeLongitude?: number | null; arriveeLongitude?: number | null;
commentaire?: string | null; commentaire?: string | null;
vehiculeId?: number | null; vehiculeId?: number | null;
allerRetour?: boolean; // true -> distance comptée doublée (aller/retour)
} }
/** Réponse Trajet. */ /** Réponse Trajet. */
@@ -36,11 +38,12 @@ export interface Trajet {
date: string; // ISO date (yyyy-MM-dd) renvoyée par l'API date: string; // ISO date (yyyy-MM-dd) renvoyée par l'API
adresseDepart: string; adresseDepart: string;
adresseArrivee: 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; commentaire: string | null;
position: number; // rang dans la journée position: number; // rang dans la journée
vehiculeId: number; vehiculeId: number;
puissanceFiscale: number; puissanceFiscale: number;
allerRetour: boolean; // information aller/retour conservée par trajet
} }
/** /**
@@ -59,6 +62,7 @@ export interface TrajetCarte {
arriveeLatitude?: number | null; arriveeLatitude?: number | null;
arriveeLongitude?: number | null; arriveeLongitude?: number | null;
geometrieJson: string | null; // GeoJSON LineString ou 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. */ /** Position d'un trajet lors d'un réordonnancement. */
@@ -1,6 +1,9 @@
<section class="consultation" aria-label="Consultation des trajets d'une journée"> <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 --> <div class="consultation__corps">
<header class="consultation__barre"> <!-- 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 <app-selecteur-temporel
[date]="date()" [date]="date()"
(dateSelectionnee)="onDateSelectionnee($event)" (dateSelectionnee)="onDateSelectionnee($event)"
@@ -12,7 +15,7 @@
class="consultation__nav-bouton" class="consultation__nav-bouton"
(click)="jourPrecedent()" (click)="jourPrecedent()"
> >
‹ Jour précédent ‹ Jour préc.
</button> </button>
<span class="consultation__date" aria-live="polite"> <span class="consultation__date" aria-live="polite">
@@ -25,10 +28,25 @@
(click)="jourSuivant()" (click)="jourSuivant()"
[disabled]="jourSuivantDesactive" [disabled]="jourSuivantDesactive"
> >
Jour suivant › Jour suiv. ›
</button> </button>
</div> </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">
<header class="consultation__barre-liste">
<label class="consultation__vehicule"> <label class="consultation__vehicule">
<span class="consultation__vehicule-libelle">Véhicule</span> <span class="consultation__vehicule-libelle">Véhicule</span>
<select <select
@@ -43,16 +61,11 @@
} }
</select> </select>
</label> </label>
</header>
<div class="consultation__corps">
<!-- Zone principale : bouton d'ajout + liste des trajets (scroll vertical) -->
<main class="consultation__principal">
<div class="consultation__actions">
<button type="button" class="consultation__ajouter" (click)="ouvrirCreation()"> <button type="button" class="consultation__ajouter" (click)="ouvrirCreation()">
+ Ajouter un trajet + Ajouter un trajet
</button> </button>
</div> </header>
@if (erreur()) { @if (erreur()) {
<p class="consultation__erreur" role="alert">{{ erreur() }}</p> <p class="consultation__erreur" role="alert">{{ erreur() }}</p>
@@ -76,19 +89,6 @@
></app-trajet-list> ></app-trajet-list>
</div> </div>
</main> </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> </div>
<!-- Formulaire en modale : la liste / carte / totaux restent visibles derrière. --> <!-- Formulaire en modale : la liste / carte / totaux restent visibles derrière. -->
@@ -1,21 +1,42 @@
:host { :host {
display: block; display: block;
height: 100%;
min-height: 0;
} }
.consultation { .consultation {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
min-height: 0;
gap: 1rem; gap: 1rem;
padding: 1.5rem; padding: 1.5rem;
max-width: 1400px; max-width: 1400px;
margin: 0 auto; 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; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-end; align-items: flex-end;
gap: 1rem 1.5rem; justify-content: space-between;
padding: 1rem; gap: 0.75rem 1rem;
padding: 0.75rem;
background: #f7f8fa; background: #f7f8fa;
border: 1px solid #e3e6ea; border: 1px solid #e3e6ea;
border-radius: 8px; border-radius: 8px;
@@ -24,7 +45,8 @@
&__navigation { &__navigation {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; justify-content: space-between;
gap: 0.5rem;
} }
&__nav-bouton { &__nav-bouton {
@@ -56,7 +78,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
margin-left: auto;
} }
&__vehicule-libelle { &__vehicule-libelle {
@@ -75,16 +96,20 @@
// --- Corps deux colonnes --- // --- Corps deux colonnes ---
&__corps { &__corps {
display: grid; 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; 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 { &__principal {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.75rem;
min-width: 0; min-width: 0;
min-height: 0; // permet à la liste enfant de scroller au lieu de pousser la hauteur
} }
&__formulaire { &__formulaire {
@@ -100,6 +125,7 @@
} }
&__ajouter { &__ajouter {
align-self: flex-end;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
@@ -107,6 +133,7 @@
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
white-space: nowrap;
&:hover { &:hover {
background: #135ba1; background: #135ba1;
@@ -123,9 +150,11 @@
margin: 0; 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 { &__liste {
max-height: 55vh; flex: 1 1 auto;
min-height: 0;
overflow-y: auto; overflow-y: auto;
border: 1px solid #e3e6ea; border: 1px solid #e3e6ea;
border-radius: 8px; border-radius: 8px;
@@ -139,12 +168,16 @@
} }
// Panneau latéral toujours visible (sticky) : totaux + carte (Exigence 12.9). // 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 { &__panneau {
position: sticky; // Colonne gauche FIGÉE : ne défile jamais. Alignée en haut.
top: 1rem; align-self: start;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
min-height: 0;
} }
} }
@@ -179,5 +212,7 @@
.consultation__panneau { .consultation__panneau {
position: static; 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 { 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 { Subscription, forkJoin, of } from 'rxjs';
import { catchError, finalize } from 'rxjs/operators'; import { catchError, finalize } from 'rxjs/operators';
@@ -65,6 +65,9 @@ export class ConsultationJourComponent implements OnInit, OnDestroy {
private readonly vehiculeService = inject(VehiculeService); private readonly vehiculeService = inject(VehiculeService);
private readonly cdr = inject(ChangeDetectorRef); 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. */ /** Date sélectionnée au format ISO (`yyyy-MM-dd`). `null` tant qu'aucune date n'est choisie. */
readonly date = signal<string | null>(null); readonly date = signal<string | null>(null);
@@ -232,6 +235,10 @@ export class ConsultationJourComponent implements OnInit, OnDestroy {
private rafraichirJournee(): void { private rafraichirJournee(): void {
this.jourSub?.unsubscribe(); 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(); const date = this.date();
if (!date) { if (!date) {
this.trajets.set([]); 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 * 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 * courants. Ne déclenche aucun appel tant que les deux entrées ne sont pas
* disponibles. * 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.totauxSub?.unsubscribe();
this.montantSub?.unsubscribe(); this.montantSub?.unsubscribe();
@@ -214,6 +214,14 @@
} }
</div> </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 --> <!-- Actions -->
<div class="actions"> <div class="actions">
<button type="submit" class="btn-primaire" [disabled]="soumissionEnCours()"> <button type="submit" class="btn-primaire" [disabled]="soumissionEnCours()">
@@ -212,3 +212,21 @@
background: #f3f5f8; 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)] [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), date: this.versIsoInput(this.trajet.date),
adresseDepart: this.trajet.adresseDepart, adresseDepart: this.trajet.adresseDepart,
adresseArrivee: this.trajet.adresseArrivee, 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 // 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 // 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, arriveeLatitude: coordArrivee?.latitude ?? null,
arriveeLongitude: coordArrivee?.longitude ?? null, arriveeLongitude: coordArrivee?.longitude ?? null,
commentaire: this.normaliserCommentaire(), 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, arriveeLatitude: coordArrivee?.latitude ?? null,
arriveeLongitude: coordArrivee?.longitude ?? null, arriveeLongitude: coordArrivee?.longitude ?? null,
commentaire: this.normaliserCommentaire(), 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: '', date: '',
adresseDepart: '', adresseDepart: '',
adresseArrivee: '', adresseArrivee: '',
commentaire: '' commentaire: '',
allerRetour: false
}); });
this.coordDepart.set(null); this.coordDepart.set(null);
this.coordArrivee.set(null); this.coordArrivee.set(null);
@@ -28,6 +28,13 @@
⠿ ⠿
</button> </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__infos">
<div class="trajet__ligne"> <div class="trajet__ligne">
<span class="trajet__date">{{ trajet.date | date: 'dd/MM/yyyy' }}</span> <span class="trajet__date">{{ trajet.date | date: 'dd/MM/yyyy' }}</span>
@@ -84,6 +84,29 @@
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
white-space: nowrap; 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 { &__actions {
+36
View File
@@ -29,6 +29,38 @@ if [[ -z "${ORS__ApiKey:-}" && -f "$RACINE/.env" ]]; then
fi fi
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)" 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 & dotnet run --project "$RACINE/backend/SchotzIadKmTracker.Api" --urls http://localhost:8080 &
PID_BACK=$! PID_BACK=$!
@@ -100,6 +132,10 @@ if attendre_frontend; then
echo "" echo ""
echo " Au premier chargement, fais Ctrl+Shift+R pour vider le cache navigateur." 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 " 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 "======================================================================"
echo "" echo ""
fi fi