diff --git a/backend/SchotzIadKmTracker.Api/Controllers/TrajetsController.cs b/backend/SchotzIadKmTracker.Api/Controllers/TrajetsController.cs index 4f6390c..5873fdd 100644 --- a/backend/SchotzIadKmTracker.Api/Controllers/TrajetsController.cs +++ b/backend/SchotzIadKmTracker.Api/Controllers/TrajetsController.cs @@ -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)); } /// @@ -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); diff --git a/backend/SchotzIadKmTracker.Api/Data/Migrations/20260915073357_TrajetAllerRetour.Designer.cs b/backend/SchotzIadKmTracker.Api/Data/Migrations/20260915073357_TrajetAllerRetour.Designer.cs new file mode 100644 index 0000000..0b50863 --- /dev/null +++ b/backend/SchotzIadKmTracker.Api/Data/Migrations/20260915073357_TrajetAllerRetour.Designer.cs @@ -0,0 +1,393 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Adresse") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DerniereUtilisation") + .HasColumnType("TEXT"); + + b.Property("GeometrieJson") + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Adresse") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Denomination") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DenominationNormalisee") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("GeocodageReussi") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("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("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("MotDePasseHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("INTEGER"); + + b.Property("Mois") + .HasColumnType("INTEGER"); + + b.Property("Montant") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .HasColumnType("INTEGER"); + + b.Property("AdresseDomicile") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DomicileGeocode") + .HasColumnType("INTEGER"); + + b.Property("DomicileLatitude") + .HasColumnType("REAL"); + + b.Property("DomicileLongitude") + .HasColumnType("REAL"); + + b.Property("Matricule") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("Nom") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Prenom") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Expiration") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdresseArrivee") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AdresseDepart") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AllerRetour") + .HasColumnType("INTEGER"); + + b.Property("Commentaire") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Distance") + .HasColumnType("TEXT"); + + b.Property("GeometrieJson") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("TEXT"); + + b.Property("PuissanceFiscale") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("INTEGER"); + + b.Property("Base") + .HasColumnType("TEXT"); + + b.Property("Coefficient") + .HasColumnType("TEXT"); + + b.Property("KmMax") + .HasColumnType("TEXT"); + + b.Property("KmMin") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DateEffet") + .HasColumnType("TEXT"); + + b.Property("Denomination") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Plaque") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/backend/SchotzIadKmTracker.Api/Data/Migrations/20260915073357_TrajetAllerRetour.cs b/backend/SchotzIadKmTracker.Api/Data/Migrations/20260915073357_TrajetAllerRetour.cs new file mode 100644 index 0000000..fff7532 --- /dev/null +++ b/backend/SchotzIadKmTracker.Api/Data/Migrations/20260915073357_TrajetAllerRetour.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SchotzIadKmTracker.Api.Data.Migrations +{ + /// + public partial class TrajetAllerRetour : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllerRetour", + table: "Trajets", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AllerRetour", + table: "Trajets"); + } + } +} diff --git a/backend/SchotzIadKmTracker.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/SchotzIadKmTracker.Api/Data/Migrations/AppDbContextModelSnapshot.cs index 93cb421..5c54c5b 100644 --- a/backend/SchotzIadKmTracker.Api/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/SchotzIadKmTracker.Api/Data/Migrations/AppDbContextModelSnapshot.cs @@ -242,6 +242,9 @@ namespace SchotzIadKmTracker.Api.Data.Migrations .HasMaxLength(255) .HasColumnType("TEXT"); + b.Property("AllerRetour") + .HasColumnType("INTEGER"); + b.Property("Commentaire") .HasMaxLength(500) .HasColumnType("TEXT"); diff --git a/backend/SchotzIadKmTracker.Api/Models/DTOs/TrajetDtos.cs b/backend/SchotzIadKmTracker.Api/Models/DTOs/TrajetDtos.cs index d8cdd5b..8b8dee2 100644 --- a/backend/SchotzIadKmTracker.Api/Models/DTOs/TrajetDtos.cs +++ b/backend/SchotzIadKmTracker.Api/Models/DTOs/TrajetDtos.cs @@ -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 diff --git a/backend/SchotzIadKmTracker.Api/Models/Entities/Trajet.cs b/backend/SchotzIadKmTracker.Api/Models/Entities/Trajet.cs index d2d2f84..3b1eca5 100644 --- a/backend/SchotzIadKmTracker.Api/Models/Entities/Trajet.cs +++ b/backend/SchotzIadKmTracker.Api/Models/Entities/Trajet.cs @@ -25,6 +25,23 @@ public class Trajet /// public decimal? Distance { get; set; } + /// + /// Indique un trajet aller/retour. Lorsqu'il vaut true, la distance COMPTÉE est le + /// double de la distance simple (aller) stockée dans ; la ligne de + /// trajet reste unique. Valeur par défaut false : les trajets existants conservent + /// leur comportement (distance simple). + /// + public bool AllerRetour { get; set; } + + /// + /// Distance comptée (effective), doublée lorsque vaut true. + /// Propriété calculée NON MAPPÉE : elle n'est pas persistée mais dérivée de + /// et . null lorsque la distance simple + /// est inconnue (non calculable), afin de préserver le comportement existant dans les sommes. + /// + [System.ComponentModel.DataAnnotations.Schema.NotMapped] + public decimal? DistanceEffective => Distance.HasValue ? Distance.Value * (AllerRetour ? 2m : 1m) : (decimal?)null; + /// Commentaire optionnel (0–500 caractères). [MaxLength(500)] public string? Commentaire { get; set; } diff --git a/backend/SchotzIadKmTracker.Api/Services/MontantRegleService.cs b/backend/SchotzIadKmTracker.Api/Services/MontantRegleService.cs index af336ca..9fef75f 100644 --- a/backend/SchotzIadKmTracker.Api/Services/MontantRegleService.cs +++ b/backend/SchotzIadKmTracker.Api/Services/MontantRegleService.cs @@ -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( diff --git a/backend/SchotzIadKmTracker.Api/Services/PdfService.cs b/backend/SchotzIadKmTracker.Api/Services/PdfService.cs index 1ae659a..36371bb 100644 --- a/backend/SchotzIadKmTracker.Api/Services/PdfService.cs +++ b/backend/SchotzIadKmTracker.Api/Services/PdfService.cs @@ -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)); diff --git a/backend/SchotzIadKmTracker.Api/Services/RemboursementService.cs b/backend/SchotzIadKmTracker.Api/Services/RemboursementService.cs index e9529ef..e22635b 100644 --- a/backend/SchotzIadKmTracker.Api/Services/RemboursementService.cs +++ b/backend/SchotzIadKmTracker.Api/Services/RemboursementService.cs @@ -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(); diff --git a/backend/SchotzIadKmTracker.Api/Services/ResumeService.cs b/backend/SchotzIadKmTracker.Api/Services/ResumeService.cs index f8ab58c..f0a6d24 100644 --- a/backend/SchotzIadKmTracker.Api/Services/ResumeService.cs +++ b/backend/SchotzIadKmTracker.Api/Services/ResumeService.cs @@ -98,7 +98,8 @@ public class ResumeService : IResumeService private static ResumeMensuelDto ConstruireResumeMensuel(int annee, int mois, IReadOnlyList 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); } diff --git a/backend/SchotzIadKmTracker.Api/Services/TotauxService.cs b/backend/SchotzIadKmTracker.Api/Services/TotauxService.cs index ce0f29e..31dd4ff 100644 --- a/backend/SchotzIadKmTracker.Api/Services/TotauxService.cs +++ b/backend/SchotzIadKmTracker.Api/Services/TotauxService.cs @@ -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, diff --git a/backend/SchotzIadKmTracker.Api/Services/TrajetService.cs b/backend/SchotzIadKmTracker.Api/Services/TrajetService.cs index b16e0cd..f401067 100644 --- a/backend/SchotzIadKmTracker.Api/Services/TrajetService.cs +++ b/backend/SchotzIadKmTracker.Api/Services/TrajetService.cs @@ -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); } diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss index 2ef9ac3..23766c9 100644 --- a/frontend/src/app/app.component.scss +++ b/frontend/src/app/app.component.scss @@ -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; +} diff --git a/frontend/src/app/core/models/trajet.model.ts b/frontend/src/app/core/models/trajet.model.ts index 7d3a07b..da1513f 100644 --- a/frontend/src/app/core/models/trajet.model.ts +++ b/frontend/src/app/core/models/trajet.model.ts @@ -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. */ diff --git a/frontend/src/app/features/consultation-jour/consultation-jour.component.html b/frontend/src/app/features/consultation-jour/consultation-jour.component.html index b3783d6..863b9cd 100644 --- a/frontend/src/app/features/consultation-jour/consultation-jour.component.html +++ b/frontend/src/app/features/consultation-jour/consultation-jour.component.html @@ -1,58 +1,71 @@
- -
- - -
- - - - {{ dateFormatee }} - - - -
- - -
-
- + + + +
-
+
+ + -
+ @if (erreur()) { @@ -76,19 +89,6 @@ >
- - - diff --git a/frontend/src/app/features/consultation-jour/consultation-jour.component.scss b/frontend/src/app/features/consultation-jour/consultation-jour.component.scss index 38d8d3e..6305073 100644 --- a/frontend/src/app/features/consultation-jour/consultation-jour.component.scss +++ b/frontend/src/app/features/consultation-jour/consultation-jour.component.scss @@ -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; } } diff --git a/frontend/src/app/features/consultation-jour/consultation-jour.component.ts b/frontend/src/app/features/consultation-jour/consultation-jour.component.ts index dc5f1f0..79a2c70 100644 --- a/frontend/src/app/features/consultation-jour/consultation-jour.component.ts +++ b/frontend/src/app/features/consultation-jour/consultation-jour.component.ts @@ -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(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([]); diff --git a/frontend/src/app/features/panneau-totaux/panneau-totaux.component.ts b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.ts index 257243d..45ed440 100644 --- a/frontend/src/app/features/panneau-totaux/panneau-totaux.component.ts +++ b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.ts @@ -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(); diff --git a/frontend/src/app/features/trajet-form/trajet-form.component.html b/frontend/src/app/features/trajet-form/trajet-form.component.html index e038ce1..d1479a7 100644 --- a/frontend/src/app/features/trajet-form/trajet-form.component.html +++ b/frontend/src/app/features/trajet-form/trajet-form.component.html @@ -214,6 +214,14 @@ } + +
+ +
+
+ +
+ @if (trajet.allerRetour) { + A/R + } +
+
{{ trajet.date | date: 'dd/MM/yyyy' }} diff --git a/frontend/src/app/features/trajet-list/trajet-list.component.scss b/frontend/src/app/features/trajet-list/trajet-list.component.scss index 489bbb3..cce758c 100644 --- a/frontend/src/app/features/trajet-list/trajet-list.component.scss +++ b/frontend/src/app/features/trajet-list/trajet-list.component.scss @@ -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 { diff --git a/run-dev.sh b/run-dev.sh index e252df5..009c2fe 100755 --- a/run-dev.sh +++ b/run-dev.sh @@ -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