SchotzIAD KM Tracker : suivi des trajets kilométriques (Angular + .NET 8)
Application complète de saisie et de suivi des trajets kilométriques avec calcul de distance routière (OpenRouteService), barème kilométrique, résumés et régularisation annuelle. Backend (ASP.NET Core .NET 8, EF Core, SQLite) : - Trajets (CRUD, réordonnancement, insertion), calcul de distance ORS avec fallback Haversine, géométrie d'itinéraire stockée par trajet, recalcul en masse. - Profil, véhicules (avec dénomination), carnet d'adresses nommées, cache d'adresses. - Barème kilométrique : tranches multiples par puissance fiscale, borne max exclusive avec 'illimité' (KmMax nullable), coefficient à 3 décimales, recopie du dernier barème connu. - Résumés mensuel/annuel, montants réglés, régularisation, export PDF. Frontend (Angular 22, zoneless + signals) : - Consultation par jour, navigation temporelle synchronisée, carte Leaflet, formulaire de trajet (adresses nommées, domicile, autocomplétion BAN). - Détection de changement zoneless (indépendante de zone.js). Hébergement : Docker Compose (nginx + backend), scripts run-dev.sh / run-docker.sh.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import { TrajetService } from '../../core/services/trajet.service';
|
||||
|
||||
/**
|
||||
* Sélecteur temporel : navigation successive année → mois → jour.
|
||||
*
|
||||
* - Sélecteur d'année : année courante + années ayant des données
|
||||
* (`TrajetService.getAnneesDisponibles`). L'année courante est toujours
|
||||
* présente afin que le sélecteur ne soit jamais vide. (Exigence 12.2)
|
||||
* - Sélecteur de mois : les douze mois affichés, surbrillance des mois
|
||||
* comportant des données (`TrajetService.getMoisDisponibles`). (Exigence 12.3)
|
||||
* - Sélecteur de jour : les jours du mois affichés, surbrillance des jours
|
||||
* comportant des données (`TrajetService.getJoursDisponibles`). (Exigence 12.4)
|
||||
* - Aucune date postérieure à aujourd'hui n'est sélectionnable. (Exigence 12.5)
|
||||
* - Émet `dateSelectionnee` (ISO `yyyy-MM-dd`) à chaque sélection utilisateur.
|
||||
* - Se synchronise avec la date affichée par le parent via `@Input date` : lors de
|
||||
* la navigation ± 1 jour, l'année / mois / jour affichés reflètent cette date.
|
||||
*
|
||||
* _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.14_
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-selecteur-temporel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './selecteur-temporel.component.html',
|
||||
styleUrl: './selecteur-temporel.component.scss'
|
||||
})
|
||||
export class SelecteurTemporelComponent implements OnInit, OnChanges {
|
||||
private readonly trajetService = inject(TrajetService);
|
||||
|
||||
/**
|
||||
* Date affichée par le parent (ISO `yyyy-MM-dd`). Quand elle change (navigation
|
||||
* ± 1 jour ou sélection), le sélecteur pré-sélectionne l'année, le mois et le
|
||||
* jour correspondants sans réémettre `dateSelectionnee`.
|
||||
*/
|
||||
@Input() date: string | null = null;
|
||||
|
||||
/** Émis à chaque changement de sélection complète (année + mois + jour). */
|
||||
@Output() readonly dateSelectionnee = new EventEmitter<string>();
|
||||
|
||||
/** Libellés des douze mois (index 0 = janvier). */
|
||||
readonly nomsMois = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
|
||||
];
|
||||
|
||||
/** Date du jour, tronquée à minuit, pour borner les sélections futures. */
|
||||
private readonly aujourdHui = this.debutDeJournee(new Date());
|
||||
|
||||
/** Années proposées (année courante + années ayant des données). */
|
||||
readonly annees = signal<number[]>([]);
|
||||
/** Les douze mois (1..12), toujours affichés. */
|
||||
readonly mois: number[] = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
/** Jours du mois sélectionné (1..N). */
|
||||
readonly jours = signal<number[]>([]);
|
||||
|
||||
/** Mois comportant au moins un trajet pour l'année sélectionnée. */
|
||||
private readonly moisAvecDonnees = signal<Set<number>>(new Set<number>());
|
||||
/** Jours comportant au moins un trajet pour le mois sélectionné. */
|
||||
private readonly joursAvecDonnees = signal<Set<number>>(new Set<number>());
|
||||
|
||||
readonly anneeSelectionnee = signal<number | null>(null);
|
||||
readonly moisSelectionne = signal<number | null>(null);
|
||||
readonly jourSelectionne = signal<number | null>(null);
|
||||
|
||||
/** État d'ouverture du conteneur repliable (fermé par défaut). */
|
||||
readonly ouvert = signal(false);
|
||||
|
||||
/** Bascule l'ouverture / fermeture du sélecteur repliable. */
|
||||
basculerOuverture(): void {
|
||||
this.ouvert.update((v) => !v);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Affichage garanti dès le premier rendu : au minimum l'année courante et,
|
||||
// si le parent a déjà fourni une date, la pré-sélection année/mois/jour.
|
||||
// Ainsi le sélecteur n'est jamais vide même si l'appel réseau tarde ou échoue.
|
||||
this.annees.set(this.avecAnneeCourante([]));
|
||||
this.synchroniserSurDate(this.date);
|
||||
|
||||
this.trajetService.getAnneesDisponibles().subscribe({
|
||||
next: (annees) => {
|
||||
this.annees.set(this.avecAnneeCourante(annees));
|
||||
// Réaligner l'affichage sur la date du parent une fois les années connues.
|
||||
this.synchroniserSurDate(this.date);
|
||||
},
|
||||
// En cas d'échec réseau, on conserve l'année courante déjà affichée.
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['date'] && !changes['date'].firstChange) {
|
||||
this.synchroniserSurDate(this.date);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligne la sélection (année, mois, jour) sur la date fournie par le parent,
|
||||
* sans réémettre d'évènement. Charge les mois / jours avec données associés.
|
||||
*/
|
||||
private synchroniserSurDate(dateIso: string | null): void {
|
||||
if (!dateIso) {
|
||||
return;
|
||||
}
|
||||
const [annee, mois, jour] = dateIso.slice(0, 10).split('-').map(Number);
|
||||
if (!annee || !mois || !jour) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Garantit que l'année affichée figure dans la liste des pastilles.
|
||||
if (!this.annees().includes(annee)) {
|
||||
this.annees.set(this.avecAnneeCourante([...this.annees(), annee]));
|
||||
}
|
||||
|
||||
this.anneeSelectionnee.set(annee);
|
||||
this.moisSelectionne.set(mois);
|
||||
this.jours.set(this.genererJours(annee, mois));
|
||||
this.jourSelectionne.set(jour);
|
||||
|
||||
this.trajetService.getMoisDisponibles(annee).subscribe((m) => {
|
||||
this.moisAvecDonnees.set(new Set<number>(m));
|
||||
});
|
||||
this.trajetService.getJoursDisponibles(annee, mois).subscribe((j) => {
|
||||
this.joursAvecDonnees.set(new Set<number>(j));
|
||||
});
|
||||
}
|
||||
|
||||
/** Sélectionne une année et charge les mois comportant des données. */
|
||||
selectionnerAnnee(annee: number): void {
|
||||
if (this.estAnneeFuture(annee)) {
|
||||
return;
|
||||
}
|
||||
this.anneeSelectionnee.set(annee);
|
||||
this.moisSelectionne.set(null);
|
||||
this.jourSelectionne.set(null);
|
||||
this.jours.set([]);
|
||||
this.joursAvecDonnees.set(new Set<number>());
|
||||
this.moisAvecDonnees.set(new Set<number>());
|
||||
|
||||
this.trajetService.getMoisDisponibles(annee).subscribe((mois) => {
|
||||
this.moisAvecDonnees.set(new Set<number>(mois));
|
||||
});
|
||||
}
|
||||
|
||||
/** Sélectionne un mois et charge les jours comportant des données. */
|
||||
selectionnerMois(mois: number): void {
|
||||
const annee = this.anneeSelectionnee();
|
||||
if (annee == null || this.estMoisFutur(annee, mois)) {
|
||||
return;
|
||||
}
|
||||
this.moisSelectionne.set(mois);
|
||||
this.jourSelectionne.set(null);
|
||||
this.jours.set(this.genererJours(annee, mois));
|
||||
this.joursAvecDonnees.set(new Set<number>());
|
||||
|
||||
this.trajetService.getJoursDisponibles(annee, mois).subscribe((jours) => {
|
||||
this.joursAvecDonnees.set(new Set<number>(jours));
|
||||
});
|
||||
}
|
||||
|
||||
/** Sélectionne un jour et émet la date ISO correspondante. */
|
||||
selectionnerJour(jour: number): void {
|
||||
const annee = this.anneeSelectionnee();
|
||||
const mois = this.moisSelectionne();
|
||||
if (
|
||||
annee == null ||
|
||||
mois == null ||
|
||||
this.estJourFutur(annee, mois, jour)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.jourSelectionne.set(jour);
|
||||
this.dateSelectionnee.emit(
|
||||
this.versIso(annee, mois, jour)
|
||||
);
|
||||
}
|
||||
|
||||
/** Un mois est en surbrillance s'il comporte au moins un trajet. */
|
||||
moisADonnees(mois: number): boolean {
|
||||
return this.moisAvecDonnees().has(mois);
|
||||
}
|
||||
|
||||
/** Un jour est en surbrillance s'il comporte au moins un trajet. */
|
||||
jourADonnees(jour: number): boolean {
|
||||
return this.joursAvecDonnees().has(jour);
|
||||
}
|
||||
|
||||
/** Une année est désactivée si elle est postérieure à l'année courante. */
|
||||
estAnneeFuture(annee: number): boolean {
|
||||
return annee > this.aujourdHui.getFullYear();
|
||||
}
|
||||
|
||||
/** Un mois est désactivé si son premier jour est postérieur à aujourd'hui. */
|
||||
estMoisFutur(annee: number, mois: number): boolean {
|
||||
return this.debutDeJournee(new Date(annee, mois - 1, 1)) > this.aujourdHui;
|
||||
}
|
||||
|
||||
/** Un jour est désactivé s'il est postérieur à la date courante. */
|
||||
estJourFutur(annee: number, mois: number, jour: number): boolean {
|
||||
return this.debutDeJournee(new Date(annee, mois - 1, jour)) > this.aujourdHui;
|
||||
}
|
||||
|
||||
/** Complète la liste avec l'année courante et trie en ordre décroissant, sans doublon. */
|
||||
private avecAnneeCourante(annees: number[]): number[] {
|
||||
const ensemble = new Set<number>(annees);
|
||||
ensemble.add(this.aujourdHui.getFullYear());
|
||||
return [...ensemble].sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/** Génère la liste 1..N des jours du mois donné. */
|
||||
private genererJours(annee: number, mois: number): number[] {
|
||||
const nbJours = new Date(annee, mois, 0).getDate();
|
||||
return Array.from({ length: nbJours }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
/** Formate une date en ISO `yyyy-MM-dd` sans dérive de fuseau horaire. */
|
||||
private versIso(annee: number, mois: number, jour: number): string {
|
||||
const mm = String(mois).padStart(2, '0');
|
||||
const dd = String(jour).padStart(2, '0');
|
||||
return `${annee}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/** Retourne une copie de la date à minuit (heure locale). */
|
||||
private debutDeJournee(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user