Refonte mise en page des exports PDF (en-tête, pied de page, tables)

En-tête (mensuel + annuel) :
- Titre « RAPPORT DE TRAJETS » en majuscules
- Logo agrandi (120 pt) à gauche, bloc titre centré sur l'espace restant,
  bloc d'infos placé dessous, trait séparateur sur toute la largeur

Pied de page (mensuel + annuel) :
- Mention légale (déclaration sur l'honneur, calcul indicatif, non
  opposable aux administrations) en 5 pt justifiée, au-dessus de la
  pagination

Rapport mensuel :
- Adresse d'arrivée à la ligne sous le départ, précédée de « → »
- Colonne dédiée « A/R » (marqueur « X ») avant les Kms ; largeurs
  ajustées (A/R minimale, Kms jusqu'à « 9 999 », adresses maximisées)

Rapport annuel :
- Espace après « Véhicule » ; barème applicable et total km sur deux
  colonnes
- Titre du tableau barème complété « — Kms parcourus × Coef + Base »
- Espace avant la régularisation, régularisation centrée

Steering : nouvelle règle workflow (valider l'approche avant de coder,
l'utilisateur lance le dev).

Specs requirements.md / design-requirements.md / design.md synchronisées
(Exigence 7 : critères + maquettes PDF + glossaire Mention_Legale).
This commit is contained in:
2026-10-10 10:33:23 +02:00
parent 6bdd67fd00
commit e9f34e59e8
5 changed files with 238 additions and 95 deletions
@@ -319,13 +319,7 @@ public class PdfService : IPdfService
page.Header().Element(c => ComposerEntete(c, modele, precision));
page.Content().PaddingTop(10).Element(c => ComposerTableTrajets(c, modele, precision));
page.Footer().AlignCenter().Text(txt =>
{
txt.Span("Page ");
txt.CurrentPageNumber();
txt.Span(" / ");
txt.TotalPages();
});
page.Footer().Element(ComposerPiedDePage);
});
});
@@ -351,24 +345,50 @@ public class PdfService : IPdfService
$"{modele.Vehicule.Plaque} — {modele.Vehicule.PuissanceFiscale} CV " +
$"(Coef. : {FormatDecimal(modele.Coefficient, 3)} / Base : {FormatDecimal(modele.Base, 0)})";
container.Row(ligne =>
container.Column(entete =>
{
// Logo carré en haut à gauche.
if (LogoCarre.Length > 0)
// Logo à gauche ; à droite, bloc titre (centré) puis bloc d'infos en dessous.
entete.Item().Row(bandeau =>
{
ligne.ConstantItem(45).Image(LogoCarre).FitWidth();
}
if (LogoCarre.Length > 0)
{
bandeau.ConstantItem(120).AlignTop().Image(LogoCarre).FitWidth();
}
ligne.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 10 : 0).Column(col =>
bandeau.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 12 : 0).Column(droite =>
{
// Bloc titre, centré sur l'espace restant après le logo.
droite.Item().Column(titre =>
{
titre.Spacing(2);
titre.Item().AlignCenter().Text("RAPPORT DE TRAJETS").FontSize(16).Bold();
titre.Item().AlignCenter().Text($"Feuille d'indemnité kilométrique {periode}").FontSize(11).SemiBold();
});
// Bloc d'infos, sous le bloc titre (espace pour les séparer).
ComposerInfosMensuel(droite.Item().PaddingTop(10), modele, identite, matricule, domicile, vehiculeLibelle, precision);
});
});
// Trait séparateur sur toute la largeur de la page.
entete.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
}
/// <summary>
/// Compose le bloc d'informations du rapport mensuel (identité, domicile, véhicule, totaux),
/// affiché à droite du logo sous le bloc titre.
/// </summary>
private static void ComposerInfosMensuel(
IContainer container, ModeleRapportMensuel modele, string identite, string matricule,
string domicile, string vehiculeLibelle, int precision)
{
container.Column(col =>
{
col.Spacing(2);
// Titre fixe imposé par l'Exigence 7.6, période intégrée (remplace la ligne « Période »).
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
col.Item().Text($"Feuille d'indemnité kilométrique {periode}").FontSize(11).SemiBold();
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().PaddingTop(4).Text(txt =>
col.Item().Text(txt =>
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
@@ -418,10 +438,7 @@ public class PdfService : IPdfService
});
});
});
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
});
}
/// <summary>
@@ -436,8 +453,9 @@ public class PdfService : IPdfService
{
columns.ConstantColumn(70); // Date
columns.RelativeColumn(2); // Motif (commentaire)
columns.RelativeColumn(3); // Adresse départ → arrivée
columns.ConstantColumn(55); // Kms
columns.RelativeColumn(4); // Adresse départ → arrivée (maximisée pour tenir sur 2 lignes)
columns.ConstantColumn(22); // A/R (minimale)
columns.ConstantColumn(44); // Kms (jusqu'à « 9 999 » avec séparateur de milliers)
});
// En-tête de table.
@@ -446,33 +464,39 @@ public class PdfService : IPdfService
header.Cell().Element(CelluleEntete).Text("Date");
header.Cell().Element(CelluleEntete).Text("Motifs");
header.Cell().Element(CelluleEntete).Text("Départ → Arrivée");
header.Cell().Element(CelluleEntete).AlignCenter().Text("A/R").FontSize(8);
header.Cell().Element(CelluleEntete).AlignRight().Text("Kms");
});
if (modele.Trajets.Count == 0)
{
table.Cell().ColumnSpan(4).Element(Cellule).Text("Aucun trajet pour ce mois.").Italic();
table.Cell().ColumnSpan(5).Element(Cellule).Text("Aucun trajet pour ce mois.").Italic();
}
else
{
foreach (var trajet in modele.Trajets)
{
var motif = string.IsNullOrWhiteSpace(trajet.Commentaire) ? "—" : trajet.Commentaire!;
// 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));
table.Cell().Element(Cellule).Text(motif);
table.Cell().Element(Cellule).Text(itineraire);
// Adresse de départ, puis adresse d'arrivée sur une nouvelle ligne, précédée de « → ».
// L'information aller/retour est désormais portée par la colonne A/R dédiée.
table.Cell().Element(Cellule).Text(txt =>
{
txt.Line(trajet.AdresseDepart);
txt.Span($"→ {trajet.AdresseArrivee}");
});
// Colonne A/R : « X » lorsque le trajet est un aller/retour, sinon vide
// (« X » plutôt qu'une coche Unicode, non rendue par la police par défaut Lato).
// La distance affichée est déjà la distance effective (doublée) portée par le TrajetDto.
table.Cell().Element(Cellule).AlignCenter().Text(trajet.AllerRetour ? "X" : string.Empty).SemiBold();
table.Cell().Element(Cellule).AlignRight().Text(FormatKm(km, precision));
}
// Ligne de total du mois.
table.Cell().ColumnSpan(3).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold();
table.Cell().ColumnSpan(4).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold();
table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois, precision)}").SemiBold();
}
});
@@ -511,13 +535,7 @@ public class PdfService : IPdfService
col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele, precision));
col.Item().Element(c => ComposerTotauxAnnuels(c, modele, precision));
});
page.Footer().AlignCenter().Text(txt =>
{
txt.Span("Page ");
txt.CurrentPageNumber();
txt.Span(" / ");
txt.TotalPages();
});
page.Footer().Element(ComposerPiedDePage);
});
});
@@ -531,7 +549,6 @@ public class PdfService : IPdfService
/// </summary>
private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele, int precision)
{
var reg = modele.Regularisation;
var identite = FormatIdentite(modele.Profil);
var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule)
? "—"
@@ -540,24 +557,52 @@ public class PdfService : IPdfService
? "—"
: modele.Profil.AdresseDomicile!;
container.Row(ligne =>
container.Column(entete =>
{
// Logo carré en haut à gauche.
if (LogoCarre.Length > 0)
// Logo à gauche ; à droite, bloc titre (centré) puis bloc d'infos en dessous.
entete.Item().Row(bandeau =>
{
ligne.ConstantItem(45).Image(LogoCarre).FitWidth();
}
if (LogoCarre.Length > 0)
{
bandeau.ConstantItem(120).AlignTop().Image(LogoCarre).FitWidth();
}
ligne.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 10 : 0).Column(col =>
bandeau.RelativeItem().PaddingLeft(LogoCarre.Length > 0 ? 12 : 0).Column(droite =>
{
// Bloc titre, centré sur l'espace restant après le logo.
droite.Item().Column(titre =>
{
titre.Spacing(2);
titre.Item().AlignCenter().Text("RAPPORT DE TRAJETS").FontSize(16).Bold();
titre.Item().AlignCenter().Text($"Synthèse {modele.Annee} d'indemnité kilométrique").FontSize(11).SemiBold();
});
// Bloc d'infos, sous le bloc titre (espace pour les séparer).
ComposerInfosAnnuel(droite.Item().PaddingTop(10), modele, identite, matricule, domicile, precision);
});
});
// Trait séparateur sur toute la largeur de la page.
entete.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
}
/// <summary>
/// Compose le bloc d'informations de la synthèse annuelle (identité, domicile, véhicule,
/// tranche applicable, total annuel), affiché à droite du logo sous le bloc titre.
/// </summary>
private static void ComposerInfosAnnuel(
IContainer container, ModeleRapportAnnuel modele, string identite, string matricule,
string domicile, int precision)
{
var reg = modele.Regularisation;
container.Column(col =>
{
col.Spacing(2);
// Titre fixe imposé par l'Exigence 7.6.
col.Item().Text("Rapport de trajets").FontSize(16).Bold();
col.Item().Text($"Synthèse {modele.Annee} d'indemnité kilométrique").FontSize(11).SemiBold();
// Identité (nom, prénom, matricule) — Exigence 7.8.
col.Item().PaddingTop(4).Text(txt =>
col.Item().Text(txt =>
{
txt.Span("Utilisateur : ").SemiBold();
txt.Span($"{identite} — Matricule : {matricule}");
@@ -577,22 +622,22 @@ public class PdfService : IPdfService
txt.Span("Véhicule : ").SemiBold();
txt.Span($"{reg.Plaque} — {reg.PuissanceFiscale} CV");
});
col.Item().Text(txt =>
// Barème applicable et total kilométrique sur deux colonnes (gain d'une ligne,
// même agencement que le bloc totaux du rapport mensuel) — Exigences 7.6/7.7/7.12.
col.Item().PaddingTop(6).Row(row =>
{
txt.Span("Barème applicable : ").SemiBold();
txt.Span(reg.TrancheLibelle);
row.RelativeItem().Text(txt =>
{
txt.Span("Barème applicable : ").SemiBold();
txt.Span(reg.TrancheLibelle);
});
row.RelativeItem().Text(txt =>
{
txt.Span("Total kilométrique de l'année : ").SemiBold();
txt.Span($"{FormatKm(reg.TotalKmAnnuel, precision)} km");
});
});
// Total kilométrique de la période (Exigence 7.6).
col.Item().Text(txt =>
{
txt.Span("Total kilométrique de l'année : ").SemiBold();
txt.Span($"{FormatKm(reg.TotalKmAnnuel, precision)} km");
});
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium);
});
});
}
/// <summary>
@@ -607,7 +652,7 @@ public class PdfService : IPdfService
col.Item().Text(txt =>
{
txt.Span("Barème applicable pour l'année ").SemiBold();
txt.Span($"({modele.Regularisation.PuissanceFiscale} CV)").SemiBold();
txt.Span($"({modele.Regularisation.PuissanceFiscale} CV) — Kms parcourus × Coef + Base").SemiBold();
});
if (modele.TranchesBareme.Count == 0)
@@ -725,7 +770,8 @@ public class PdfService : IPdfService
txt.Span("Formule réelle : ").SemiBold();
txt.Span(formule);
});
col.Item().Text(txt =>
// Espace entre la formule réelle et la régularisation, puis régularisation centrée.
col.Item().PaddingTop(8).AlignCenter().Text(txt =>
{
txt.Span("Régularisation (réel − payé) : ").SemiBold();
txt.Span($"{FormatMontant(reg.Regularisation)} €");
@@ -733,6 +779,41 @@ public class PdfService : IPdfService
});
}
/// <summary>
/// Pied de page commun aux deux rapports : mention indicative en petite taille, puis la
/// pagination en dessous.
/// </summary>
private static void ComposerPiedDePage(IContainer container)
{
container.Column(col =>
{
col.Spacing(2);
// Mention légale indicative, en très petit caractère pour tenir sur 2-3 lignes.
col.Item().Text(txt =>
{
txt.Justify();
txt.DefaultTextStyle(x => x.FontSize(5).FontColor(Colors.Grey.Darken1));
txt.Span(
"Les kilomètres restitués dans le présent document sont déclarés par l'utilisateur sur l'honneur " +
"et sous sa seule responsabilité. Le calcul de l'indemnité kilométrique est fourni à titre " +
"purement indicatif, sur la base du barème kilométrique officiel en vigueur. SchotzIAD ne saurait " +
"être tenue responsable d'une quelconque erreur ou omission dans les données saisies ni du montant " +
"résultant du calcul. Le présent document ne constitue pas un justificatif opposable " +
"aux administrations.");
});
col.Item().AlignCenter().Text(txt =>
{
txt.DefaultTextStyle(x => x.FontSize(8));
txt.Span("Page ");
txt.CurrentPageNumber();
txt.Span(" / ");
txt.TotalPages();
});
});
}
private static string FormatIdentite(ProfilDto profil)
{
var prenom = (profil.Prenom ?? string.Empty).Trim();