commit e3f946f7d476719737f0502d426c27cec7f14b71 Author: Nikolaz Date: Mon Sep 14 02:08:27 2026 +0200 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3649681 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Exemple de configuration d'environnement pour Docker Compose. +# Copiez ce fichier vers `.env` puis renseignez les valeurs réelles : +# cp .env.example .env +# +# Le fichier `.env` n'est PAS commité (voir .gitignore). Ne mettez jamais de secret +# réel dans `.env.example`. + +# Clé d'API OpenRouteService (ORS) utilisée par le backend pour le calcul +# d'itinéraires. Obtenez une clé sur https://openrouteservice.org/dev/#/signup +# Injectée dans le conteneur backend via ORS__ApiKey=${ORS_API_KEY}. +ORS_API_KEY=your-openrouteservice-api-key-here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6916aeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Variables d'environnement locales (contiennent des secrets, ex. ORS_API_KEY). +# Jamais commité — utiliser .env.example comme modèle. +.env + +# Conserver le modèle documenté dans le suivi de version. +!.env.example + +# --- Frontend (Angular / Node) --- +frontend/node_modules/ +frontend/dist/ +frontend/.angular/ +frontend/out-tsc/ +npm-debug.log* + +# --- Backend (.NET) --- +backend/**/bin/ +backend/**/obj/ + +# --- Bases de données SQLite (données locales, non versionnées) --- +*.db +*.db-shm +*.db-wal + +# --- Divers OS / éditeurs --- +.DS_Store +Thumbs.db diff --git a/.kiro/specs/trajet-km-tracker/.config.kiro b/.kiro/specs/trajet-km-tracker/.config.kiro new file mode 100644 index 0000000..d554fde --- /dev/null +++ b/.kiro/specs/trajet-km-tracker/.config.kiro @@ -0,0 +1 @@ +{"specId": "6a5d45ea-e81d-4f5f-b4d0-a205872cf497", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/trajet-km-tracker/design.md b/.kiro/specs/trajet-km-tracker/design.md new file mode 100644 index 0000000..ad4f25f --- /dev/null +++ b/.kiro/specs/trajet-km-tracker/design.md @@ -0,0 +1,2123 @@ +# Document de Conception Technique — SchotzIAD KM Tracker + +--- + +## Overview + +**SchotzIAD KM Tracker** est une application web mono-utilisateur permettant de saisir, calculer et consulter l'historique kilométrique de déplacements professionnels ou personnels. L'application calcule automatiquement les distances routières via l'API OpenRouteService (ORS), agrège les données par mois et par année, et permet l'export en PDF des résumés. + +### Périmètre fonctionnel + +| # | Fonctionnalité | Description | +|---|----------------|-------------| +| 1 | Saisie d'un trajet | Formulaire avec date (JJ/MM/AAAA), adresse départ, adresse arrivée, commentaire | +| 2 | Calcul automatique de distance | Via ORS (routier), fallback orthodromique | +| 3 | Résumé mensuel | Liste des trajets + total km du mois | +| 4 | Vue annuelle | 12 résumés mensuels + total annuel | +| 5 | Modification / suppression | Édition avec recalcul conditionnel de distance | +| 6 | Persistance et cohérence | Base relationnelle avec invariants d'agrégation et transactions | +| 7 | Export PDF | Fichiers `trajets_MM_AAAA.pdf` et `trajets_AAAA.pdf` | +| 8 | Visualisation carte | Tracés des itinéraires d'une journée sur carte Leaflet + OpenStreetMap | +| 9 | Cache adresses | Stockage des coordonnées BAN des adresses saisies, autocomplétion BAN + cache local, politique LRU (1 000 entrées) | +| 10 | Profil / adresse domicile | Saisie du domicile, bouton "Utiliser mon domicile" dans le formulaire | +| 11 | Commentaire par trajet | Champ optionnel texte libre (0–500 car.) associé à chaque Trajet | +| 12 | Insertion entre deux trajets | Formulaire pré-rempli avec adresse d'arrivée du précédent / adresse de départ du suivant | +| 13 | Navigation temporelle | Sélecteur année/mois/jour avec surbrillance des périodes ayant des données, pas de date future | +| 14 | Mise en page consultation | Zone principale (trajets du jour, scroll) + panneau latéral totaux + carte + navigation jour±1 | +| 15 | Profil enrichi & véhicules | Nom, prénom, matricule (8 car.), plaque, puissance fiscale ; historique des véhicules daté | +| 16 | Association véhicule par trajet | Chaque trajet référence un véhicule + puissance fiscale ; totaux filtrés par véhicule | +| 17 | Barème kilométrique annuel | Saisie par tranches (puissance fiscale, bornes km, coefficient, part fixe) ; montant prévisionnel minoré 80% | +| 18 | Montants réglés & régularisation | Saisie du montant réglé par mois ; rapport annuel avec montant réel, versé, dû et régularisation | +| 19 | Carnet d'adresses nommées | Menu CRUD d'Adresses_Nommées (Dénomination unique + adresse BAN) ; réutilisation par Dénomination dans la saisie et l'insertion de Trajets | +| 20 | Onboarding première utilisation | Présentation de la saisie du profil (identité + véhicule) si le Profil n'est pas configuré, avant tout accès à la saisie / consultation des trajets | + +### Contraintes techniques + +- **Mono-utilisateur** : pas d'authentification ni de gestion de comptes +- **Frontend** : Angular (TypeScript), compilé et servi en SPA +- **Backend** : ASP.NET Core Web API (C#) +- **Base de données** : SQLite (choix retenu pour simplicité mono-utilisateur, zero-configuration, fichier unique portable) +- **Service externe — adresses** : API BAN (Base Adresses Nationale) — `https://api-adresse.data.gouv.fr` — autocomplétion et géocodage des adresses françaises, sans clé d'authentification +- **Service externe — itinéraires** : OpenRouteService (ORS) — calcul de distance routière et géométrie d'itinéraire entre coordonnées géographiques connues +- **Génération PDF** : côté backend, bibliothèque QuestPDF +- **Hébergement** : l'application est packagée et hébergée via **Docker Compose** — un conteneur `frontend` (Angular servi par nginx) + un conteneur `backend` (ASP.NET Core API). Le fichier SQLite est persisté sur un volume nommé (ou bind mount) monté dans le conteneur backend, afin de survivre aux redémarrages et mises à jour des conteneurs + +--- + +## Architecture + +### Architecture globale + +```mermaid +graph TB + subgraph "Navigateur (SPA)" + FE[Angular App
TypeScript] + end + + subgraph "Backend (ASP.NET Core)" + API[API REST
Controllers] + SVC[Services métier] + ORS_SVC[ORSClient
HttpClient] + BAN_SVC[BANClient
HttpClient] + PDF_SVC[PdfService
QuestPDF] + DAL[Repository
Entity Framework Core] + end + + subgraph "Stockage" + DB[(SQLite
trajets.db)] + end + + subgraph "Services externes" + ORS[OpenRouteService
API] + BAN[BAN API
api-adresse.data.gouv.fr] + end + + FE -- "HTTP/JSON (REST)" --> API + API --> SVC + SVC --> ORS_SVC + SVC --> BAN_SVC + SVC --> PDF_SVC + SVC --> DAL + DAL --> DB + ORS_SVC -- "HTTPS" --> ORS + BAN_SVC -- "HTTPS" --> BAN[BAN API
api-adresse.data.gouv.fr] +``` + +### Flux de création d'un trajet + +```mermaid +sequenceDiagram + participant U as Utilisateur + participant FE as Angular Frontend + participant API as API Controller + participant SVC as TrajetService + participant ORS as ORSClient + participant DB as SQLite + + U->>FE: Saisit formulaire + soumet + FE->>FE: Validation locale (champs, date) + FE->>API: POST /api/trajets + API->>SVC: CreateTrajetAsync(dto) + SVC->>ORS: GeocodeThenRouteAsync(depart, arrivee) + ORS-->>SVC: distance (km) ou erreur + alt ORS timeout ou erreur géocodage + SVC-->>API: erreur descriptive + API-->>FE: 400 / 503 + message + FE-->>U: Message d'erreur + else Succès + SVC->>DB: INSERT Trajet (avec distance) + DB-->>SVC: id créé + SVC-->>API: TrajetCreatedDto + API-->>FE: 201 Created + trajet + FE-->>U: Message de succès, formulaire réinitialisé + end +``` + +### Décision d'architecture : SQLite + +> **Justification** : L'application est mono-utilisateur et n'a pas vocation à être multi-instances. SQLite élimine la complexité d'installation d'un serveur de base de données, offre des transactions ACID complètes via Entity Framework Core, et est parfaitement supporté par EF Core avec les migrations standard. Le fichier `trajets.db` est autoportant et facilement sauvegardable. +> +> **Emplacement en conteneur** : dans le déploiement Docker Compose, le fichier de base de données est stocké sous le chemin du volume monté `/data/trajets.db` (voir la section « Déploiement (Docker Compose) »). Le chemin est fourni au backend via la chaîne de connexion / variable d'environnement (`ConnectionStrings__Default=Data Source=/data/trajets.db`), ce qui garantit la persistance des données indépendamment du cycle de vie du conteneur. + +### Décision d'architecture : BAN pour le géocodage, ORS pour l'itinéraire + +> **Justification** : L'API BAN (Base Adresses Nationale) est le référentiel officiel des adresses françaises, maintenu par l'État. Elle est gratuite, sans clé d'authentification, et propose une API de recherche (`/search`) retournant directement les coordonnées géographiques avec une autocomplétion précise. En l'utilisant côté frontend dès la saisie, on garantit que les coordonnées sont connues avant même la soumission du formulaire, éliminant ainsi les erreurs de géocodage au moment de la création du trajet. ORS reste utilisé uniquement pour le calcul de l'itinéraire routier (distance + géométrie) entre deux coordonnées déjà résolues, ce qui réduit sa surface de défaillance à la seule étape de routage. + +--- + +## Components and Interfaces + +### Frontend Angular + +#### Structure des modules + +``` +src/ +├── app/ +│ ├── core/ +│ │ ├── services/ +│ │ │ ├── trajet.service.ts # HTTP + state management +│ │ │ ├── notification.service.ts # Toast / snackbar +│ │ │ ├── adresse-cache.service.ts # Autocomplétion adresses +│ │ │ ├── adresse-nommee.service.ts # CRUD carnet d'adresses nommées +│ │ │ └── profil.service.ts # CRUD profil +│ │ ├── models/ +│ │ │ ├── trajet.model.ts +│ │ │ ├── resume-mensuel.model.ts +│ │ │ └── resume-annuel.model.ts +│ │ └── interceptors/ +│ │ └── timeout.interceptor.ts # Timeout 10s + erreurs réseau +│ ├── features/ +│ │ ├── trajet-form/ +│ │ │ ├── trajet-form.component.ts # Saisie + édition +│ │ │ └── trajet-form.component.html +│ │ ├── resume-mensuel/ +│ │ │ ├── resume-mensuel.component.ts +│ │ │ └── resume-mensuel.component.html +│ │ ├── resume-annuel/ +│ │ │ ├── resume-annuel.component.ts +│ │ │ └── resume-annuel.component.html +│ │ ├── trajet-list/ +│ │ │ ├── trajet-list.component.ts +│ │ │ └── trajet-list.component.html +│ │ ├── carte-trajets/ +│ │ │ ├── carte-trajets.component.ts # Visualisation Leaflet +│ │ │ └── carte-trajets.component.html +│ │ ├── profil/ +│ │ │ ├── profil.component.ts # Adresse domicile +│ │ │ └── profil.component.html +│ │ ├── vehicules/ +│ │ │ ├── vehicules.component.ts # Gestion + historique véhicules +│ │ │ ├── vehicules.component.html +│ │ │ └── reaffectation-dialog.component.ts # Dialog de réaffectation +│ │ ├── navigation-temporelle/ +│ │ │ ├── selecteur-temporel.component.ts # Année / mois / jour +│ │ │ └── selecteur-temporel.component.html +│ │ ├── consultation-jour/ +│ │ │ ├── consultation-jour.component.ts # Layout principal +│ │ │ └── consultation-jour.component.html +│ │ ├── panneau-totaux/ +│ │ │ ├── panneau-totaux.component.ts # Totaux jour/mois/année partiel +│ │ │ └── panneau-totaux.component.html +│ │ ├── bareme/ +│ │ │ ├── bareme.component.ts # Saisie du barème par année/tranches +│ │ │ └── bareme.component.html +│ │ ├── montants-regles/ +│ │ │ ├── montants-regles.component.ts # Saisie montant réglé par mois +│ │ │ └── montants-regles.component.html +│ │ ├── carnet-adresses/ +│ │ │ ├── carnet-adresses.component.ts # CRUD des Adresses_Nommées (liste + formulaire) +│ │ │ └── carnet-adresses.component.html +│ │ ├── onboarding/ +│ │ │ ├── onboarding.component.ts # Saisie initiale du profil (identité + 1er véhicule) +│ │ │ └── onboarding.component.html +│ ├── core/ +│ │ └── guards/ +│ │ └── profil-configure.guard.ts # Redirige vers l'onboarding si le profil n'est pas configuré +│ ├── shared/ +│ │ ├── components/ +│ │ │ └── confirm-dialog/ +│ │ └── validators/ +│ │ └── date-jj-mm-aaaa.validator.ts +│ └── app-routing.module.ts +``` + +#### Composants principaux + +**`TrajetFormComponent`** +- Reactive Form avec les champs : `date` (string JJ/MM/AAAA), `adresseDepart` (1–255 car.), `adresseArrivee` (1–255 car.), `commentaire` (optionnel, 0–500 car.) +- Validations synchrones : présence, longueur, format date via `DateJjMmAAAAValidator`, longueur commentaire +- Mode création / édition : pré-remplissage en édition, recalcul distance uniquement si adresse modifiée, modification commentaire sans recalcul +- **Mode insertion** : accepte en entrée `{ adresseDepart?: string, adresseArrivee?: string }` pour pré-remplir les champs depuis le Trajet_Précédent / Trajet_Suivant ; les champs restent modifiables +- Boutons "Utiliser mon domicile" à côté de chaque champ adresse +- Timeout de 10 secondes via `TimeoutInterceptor` +- **Autocomplétion BAN** : appel à `https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5` dès 3 caractères saisis ; chaque suggestion affiche le libellé + code postal + commune ; à la sélection d'une suggestion, les coordonnées `(lat, lon)` sont stockées dans l'état local du composant et transmises avec le formulaire +- Si l'utilisateur tape une adresse sans passer par BAN (pas de sélection), les coordonnées sont absentes et un avertissement discret s'affiche + +**`ResumeMensuelComponent`** +- Sélecteurs mois (1–12) et année (2000–2100) +- Affiche la liste des trajets et le total mensuel (2 décimales) +- Bouton "Export PDF mensuel" + +**`ResumeAnnuelComponent`** +- Sélecteur année (2000–2100) +- Tableau 12 lignes (mois, total km) + ligne total annuel (2 décimales, max 999 999,99) +- Bouton "Export PDF annuel" + +**`TrajetListComponent`** +- Ligne par trajet triée par `Position` croissante au sein de chaque journée : date, adresse départ, adresse arrivée, distance, commentaire (icône + tooltip si présent) +- Actions par trajet : modifier (→ `TrajetFormComponent` pré-rempli en < 500 ms), supprimer (→ dialog de confirmation), **insérer après** (→ `TrajetFormComponent` en mode insertion avec `adresseDepart` = adresse d'arrivée du trajet courant, `positionApres` = Position du trajet courant) +- Si le trajet n'est pas le dernier de la journée : action **insérer avant le suivant** disponible, pré-remplissant les deux adresses et calculant la position d'insertion `(P_courant + P_suivant) / 2` +- **Glisser-déposer** (drag & drop) pour réordonner manuellement les trajets d'une journée → appel `PATCH /api/trajets/reordonner` +- Rafraîchissement du résumé mensuel en < 1 000 ms après modification/suppression/insertion + +**`CarteTrajetsComponent`** +- Intègre Leaflet.js via `leaflet` package Angular +- Sélecteur de date (JJ/MM/AAAA) +- Affiche les tracés GeoJSON retournés par le backend, un tracé par trajet avec couleur distincte +- Fallback ligne droite si géométrie indisponible (trait pointillé) +- Infobulles au clic/survol : adresse départ, adresse arrivée, distance +- Fond de carte : OpenStreetMap (tile layer `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`) + +**`ProfilComponent`** +- Champs : nom (1–100 car.), prénom (1–100 car.), matricule (max 8 car., limite de saisie stricte), adresse de domicile (1–255 car.) +- Bouton "Enregistrer" +- Message d'avertissement si le géocodage de l'adresse a échoué + +**`VehiculesComponent`** +- Liste de l'historique des véhicules (plaque, puissance fiscale, date d'effet), triée par date d'effet décroissante +- Formulaire d'ajout d'un véhicule : plaque, puissance fiscale, date d'effet (<= date du jour, sélecteur bloquant les dates futures) +- À l'enregistrement, si la réponse `VehiculeCreeDto.ReaffectationProposee` est true, ouvre `ReaffectationDialogComponent` + +**`ReaffectationDialogComponent`** +- Affiche le nombre de trajets concernés (date >= date d'effet, puissance fiscale différente) +- Deux actions : "Réaffecter au nouveau véhicule" (POST reaffectation accepter=true) / "Conserver l'existant" (accepter=false) + +**`SelecteurTemporelComponent`** +- Sélecteur d'année : liste limitée à l'année courante + années ayant des données (endpoint `/api/annees-disponibles`) +- Sélecteur de mois : 12 mois affichés, surbrillance des mois avec données (endpoint `/api/mois-disponibles/{annee}`) +- Sélecteur de jour : jours du mois affichés, surbrillance des jours avec données (endpoint `/api/jours-disponibles/{annee}/{mois}`) +- Désactivation de toute date > date courante (aucune sélection future) +- Émet un événement `dateSelectionnee: DateOnly` à chaque changement + +**`ConsultationJourComponent`** (composant conteneur / layout principal) +- Disposition en deux colonnes : zone principale (gauche/centre) + panneau latéral (droite) +- Zone principale : `TrajetListComponent` des trajets du jour sélectionné, avec scroll vertical (overflow-y auto) +- Panneau latéral toujours visible (sticky) : `PanneauTotauxComponent` en haut, `CarteTrajetsComponent` en dessous +- Boutons "Jour précédent" / "Jour suivant" ; "Jour suivant" désactivé si le jour suivant > date courante +- Sélecteur de Véhicule (par défaut : véhicule courant) ; filtre la zone des trajets, le Panneau_Totaux et la Carte sur le véhicule sélectionné +- Coordonne le rafraîchissement de la liste, des totaux et de la carte (< 1 000 ms) à chaque changement de date + +**`PanneauTotauxComponent`** +- Affiche trois totaux : total du jour sélectionné, total du mois sélectionné, Total_Annuel_Partiel (cumul de l'année jusqu'au mois sélectionné inclus) +- Reçoit en entrée la date sélectionnée et recalcule les totaux à chaque changement +- Tous les totaux affichés à 2 décimales +- Affiche également le Montant_Previsionnel minoré à 80% calculé sur le Total_Annuel_Partiel du véhicule sélectionné + +**`BaremeComponent`** +- Sélecteur d'année +- Tableau éditable des tranches : puissance fiscale, km min, km max, coefficient €/km, part fixe € +- Ajout / modification / suppression de tranches +- Validation du non-chevauchement des tranches pour une même puissance fiscale et année (message d'erreur si chevauchement) + +**`MontantsReglesComponent`** +- Saisie du Montant_Regle par mois pour l'année et le véhicule sélectionnés +- Affichage en regard : Montant_Previsionnel minoré 80% (indicatif) +- Montants en euros à 2 décimales + +**`CarnetAdressesComponent`** +- Liste des Adresses_Nommées (Dénomination + adresse + indication si coordonnées disponibles), triée par Dénomination +- Formulaire de création / modification : champ `denomination` (1–100 car.) et champ `adresse` avec autocomplétion BAN (1–255 car.) +- **Autocomplétion BAN** : appel à `https://api-adresse.data.gouv.fr/search/?q={saisie}&limit=5` dès 3 caractères saisis, avec debounce de 300 ms, maximum 5 suggestions ; à la sélection, le libellé normalisé remplit le champ et les coordonnées `(lat, lon)` sont conservées pour transmission +- Actions par ligne : modifier (→ formulaire pré-rempli), supprimer (→ dialog de confirmation) +- À chaque succès de création, modification ou suppression, affiche un message de confirmation via `NotificationService` +- Avertissement discret si le géocodage BAN d'une adresse a échoué (coordonnées absentes) + +**`TrajetFormComponent` — sélecteur de Carnet d'adresses** +- Pour le champ adresse de départ **et** le champ adresse d'arrivée, en mode création **comme** en mode insertion, le composant propose un choix entre : sélectionner une Adresse_Nommee pré-enregistrée du Carnet_Adresses (par sa Dénomination) via un menu déroulant alimenté par `AdresseNommeeService.getAdressesNommees()`, ou saisir une nouvelle adresse manuellement avec l'autocomplétion BAN +- À la sélection d'une Adresse_Nommee, le champ concerné est rempli avec le texte d'adresse de l'Adresse_Nommee et ses coordonnées `(lat, lon)` sont associées pour transmission au backend (y compris lorsque le champ était pré-rempli depuis le Trajet_Précédent / Trajet_Suivant en mode insertion, auquel cas le contenu pré-rempli est remplacé) + +**`OnboardingComponent`** (première utilisation) +- Présenté à l'utilisateur lorsque le Profil n'est pas encore configuré (voir `ProfilConfigureGuard`) +- Formulaire de mise en route en une étape regroupant : + - **Identité** : nom (1–100 car.), prénom (1–100 car.), matricule (max 8 car.) + - **Adresse de domicile** : optionnelle (1–255 car.), avec autocomplétion BAN + - **Premier véhicule** : plaque, puissance fiscale, date d'effet (<= date du jour) — au moins un véhicule requis pour finaliser +- Bouton "Commencer" désactivé tant que les champs requis (nom, prénom, un véhicule valide) ne sont pas renseignés +- À l'enregistrement réussi (`PUT /api/profil` + `POST /api/vehicules`), navigue vers la vue principale de consultation (`ConsultationJourComponent`) +- Réutilise `ProfilService.saveProfil()` et `VehiculeService.createVehicule()` + +**`ProfilConfigureGuard`** (garde de route Angular — `CanActivate` / `CanActivateFn`) +- Au chargement de l'application, appelle `ProfilService.getEtat()` (`GET /api/profil/etat`) +- Si `EstConfigure` est `false`, redirige vers la route d'onboarding (`/onboarding`) et bloque l'accès à la saisie / consultation des trajets tant que le profil n'est pas configuré +- Si `EstConfigure` est `true`, laisse passer la navigation vers la vue principale +- **Gestion d'erreur** : en cas d'échec de récupération de l'indicateur (backend injoignable / erreur réseau), le guard affiche un message d'erreur de connexion avec une action "Réessayer" plutôt que de rediriger arbitrairement, laissant l'utilisateur relancer la vérification +- Déclaré dans `app-routing.module.ts` sur les routes protégées (consultation, saisie, résumés, etc.) ; la route `/onboarding` reste accessible sans garde + +#### Services Angular + +**`TrajetService`** + +```typescript +interface TrajetService { + createTrajet(dto: CreateTrajetDto): Observable; + updateTrajet(id: number, dto: UpdateTrajetDto): Observable; + deleteTrajet(id: number): Observable; + getResumeMensuel(annee: number, mois: number): Observable; + getResumeAnnuel(annee: number): Observable; + exportPdfMensuel(annee: number, mois: number): Observable; + exportPdfAnnuel(annee: number): Observable; + getTrajetsJournee(date: string): Observable; + getAnneesDisponibles(): Observable; + getMoisDisponibles(annee: number): Observable; + getJoursDisponibles(annee: number, mois: number): Observable; + getTotauxJour(date: string, vehiculeId: number): Observable; +} + +interface VehiculeService { + getVehicules(): Observable; + getVehiculeCourant(): Observable; + createVehicule(dto: CreateVehiculeDto): Observable; + reaffecter(dto: ReaffectationDto): Observable; +} + +interface BANService { + searchAdresses(query: string): Observable; +} + +interface AdresseCacheService { + searchLocal(query: string): Observable; // recherche dans le cache local + saveAdresse(adresse: AdresseCacheDto): Observable; // mise en cache après sélection BAN +} + +interface AdresseNommeeService { + getAdressesNommees(): Observable; // liste du carnet + create(dto: SaveAdresseNommeeDto): Observable; // création + update(id: number, dto: SaveAdresseNommeeDto): Observable; // modification + delete(id: number): Observable; // suppression +} + +interface ProfilService { + getProfil(): Observable; + saveProfil(dto: SaveProfilDto): Observable; + getEtat(): Observable; // état de configuration (onboarding) +} + +interface BaremeService { + getBareme(annee: number): Observable; + saveTranche(dto: SaveTrancheBaremeDto): Observable; + updateTranche(id: number, dto: SaveTrancheBaremeDto): Observable; + deleteTranche(id: number): Observable; + getMontantPrevisionnel(annee: number, mois: number, vehiculeId: number): Observable; +} + +interface RemboursementService { + getMontantsRegles(annee: number, vehiculeId: number): Observable; + saveMontantRegle(dto: MontantRegleDto): Observable; + getRegularisation(annee: number, vehiculeId: number): Observable; +} +``` + +#### Modèle BAN + +```typescript +// Réponse BAN (Feature GeoJSON) +interface BANFeatureDto { + properties: { + label: string; // libellé complet normalisé + score: number; // score de pertinence 0–1 + postcode: string; + city: string; + context: string; + type: string; // 'housenumber' | 'street' | 'locality' | 'municipality' + x: number; // longitude + y: number; // latitude + }; + geometry: { + coordinates: [number, number]; // [longitude, latitude] + }; +} +``` + +> **Note** : BAN est la source primaire d'autocomplétion. Le cache local (`AdresseCacheService`) est un fallback/optimisation : il permet de retrouver instantanément des adresses déjà utilisées sans solliciter le réseau. + +#### Validateur de date personnalisé + +```typescript +// shared/validators/date-jj-mm-aaaa.validator.ts +export function dateJjMmAAAAValidator(): ValidatorFn { + // Vérifie le format et l'existence de la date (mois 1-12, jours cohérents avec le mois/année) +} +``` + +--- + +### Backend ASP.NET Core + +#### Structure du projet + +``` +TrajetKmTracker.Api/ +├── Controllers/ +│ ├── TrajetsController.cs +│ ├── ResumesMensuelsController.cs +│ ├── ResumesAnnuelsController.cs +│ ├── ExportsPdfController.cs +│ └── AdressesNommeesController.cs # CRUD carnet d'adresses +├── Services/ +│ ├── ITrajetService.cs / TrajetService.cs +│ ├── IDistanceCalculator.cs / DistanceCalculatorService.cs +│ ├── IORSClient.cs / ORSClient.cs # calcul itinéraire uniquement +│ ├── IBANClient.cs / BANClient.cs # géocodage adresses françaises +│ ├── IAdresseNommeeService.cs / AdresseNommeeService.cs # CRUD + normalisation + cache/BAN +│ └── IPdfService.cs / PdfService.cs +├── Repositories/ +│ ├── ITrajetRepository.cs / TrajetRepository.cs +│ └── IResumeRepository.cs / ResumeRepository.cs +├── Models/ +│ ├── Entities/ +│ │ ├── Trajet.cs +│ │ └── AdresseNommee.cs +│ └── DTOs/ +│ ├── CreateTrajetDto.cs +│ ├── UpdateTrajetDto.cs +│ ├── TrajetDto.cs +│ ├── ResumeMensuelDto.cs +│ ├── ResumeAnnuelDto.cs +│ ├── AdresseNommeeDto.cs +│ └── SaveAdresseNommeeDto.cs +├── Validators/ +│ └── SaveAdresseNommeeDtoValidator.cs # FluentValidation (dénomination, adresse) +├── Data/ +│ ├── AppDbContext.cs +│ └── Migrations/ +├── Configuration/ +│ ├── ORSOptions.cs +│ └── BANOptions.cs +└── Program.cs +``` + +--- + +## Data Models + +### Entité `Trajet` (base de données) + +```csharp +public class Trajet +{ + public int Id { get; set; } + + [Required] + public DateOnly Date { get; set; } + + [Required, MaxLength(255)] + public string AdresseDepart { get; set; } = string.Empty; + + [Required, MaxLength(255)] + public string AdresseArrivee { get; set; } = string.Empty; + + /// Distance en kilomètres, arrondie à 2 décimales. Toujours > 0. + [Required] + public decimal Distance { get; set; } + + /// Commentaire optionnel (0–500 caractères). + [MaxLength(500)] + public string? Commentaire { get; set; } + + /// + /// Rang du trajet dans la liste de la journée. Valeur décimale (ex. 1.0, 1.5, 2.0) + /// permettant l'insertion sans réordonnancement systématique. + /// Trié par ordre croissant pour l'affichage. + /// + [Required] + public decimal Position { get; set; } + + /// Référence au véhicule associé au trajet (véhicule courant à la date de création par défaut). + [Required] + public int VehiculeId { get; set; } + public Vehicule? Vehicule { get; set; } + + /// Puissance fiscale (CV) du véhicule au moment de l'association ; stockée pour l'historique. + [Required] + public int PuissanceFiscale { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +``` + +> **Note** : Bien que les exigences métier limitent les adresses à 255 caractères à l'entrée (validation API), la colonne de base de données est définie à 255 caractères maximum. La contrainte de 100 caractères mentionnée en Exigence 6.2 est interprétée comme la valeur après nettoyage/normalisation stockée ; la validation API accepte jusqu'à 255 caractères en saisie. + +### Schéma SQLite (via EF Core) + +```sql +CREATE TABLE Trajets ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Date TEXT NOT NULL, + AdresseDepart TEXT NOT NULL, + AdresseArrivee TEXT NOT NULL, + Distance REAL NOT NULL CHECK (Distance > 0), + Commentaire TEXT, + Position REAL NOT NULL DEFAULT 0, + VehiculeId INTEGER NOT NULL REFERENCES Vehicules(Id), + PuissanceFiscale INTEGER NOT NULL, + CreatedAt TEXT NOT NULL DEFAULT (datetime('now')), + UpdatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IX_Trajets_Date ON Trajets (Date); +CREATE INDEX IX_Trajets_Date_Position ON Trajets (Date, Position); +CREATE INDEX IX_Trajets_Vehicule ON Trajets (VehiculeId); -- filtrage par véhicule +``` + +> **Stratégie d'insertion sans réordonnancement** : la `Position` est un `REAL` (décimal) permettant d'insérer entre deux positions existantes P₁ et P₂ en assignant `(P₁ + P₂) / 2`. Un nouveau trajet ajouté en fin de journée reçoit `max(Position) + 1.0`. Lorsque la précision devient trop faible (différence < 0.001), le backend renumérote les positions de la journée concernée par pas de 1.0 en transaction. + +### Entité `AdresseCache` (base de données) + +```csharp +/// Cache des adresses géocodées via ORS. +public class AdresseCache +{ + public int Id { get; set; } + + [Required, MaxLength(255)] + public string Adresse { get; set; } = string.Empty; // texte normalisé + + public double Latitude { get; set; } // précision 6 décimales + public double Longitude { get; set; } // précision 6 décimales + + /// Géométrie GeoJSON de l'itinéraire (null si indisponible). + public string? GeometrieJson { get; set; } + + public DateTime DerniereUtilisation { get; set; } = DateTime.UtcNow; +} +``` + +### Entité `AdresseNommee` (base de données) + +```csharp +/// +/// Adresse enregistrée dans le Carnet_Adresses, identifiée par une Dénomination unique +/// et réutilisable par sa Dénomination lors de la saisie/insertion d'un Trajet. +/// +public class AdresseNommee +{ + public int Id { get; set; } + + /// Libellé libre et unique identifiant l'adresse (1 à 100 caractères). + [Required, MaxLength(100)] + public string Denomination { get; set; } = string.Empty; + + /// Texte d'adresse résolu via l'autocomplétion BAN (1 à 255 caractères). + [Required, MaxLength(255)] + public string Adresse { get; set; } = string.Empty; + + /// Latitude (précision 6 décimales) ; null si le géocodage a échoué. + public double? Latitude { get; set; } + + /// Longitude (précision 6 décimales) ; null si le géocodage a échoué. + public double? Longitude { get; set; } + + /// Vrai si les coordonnées ont été obtenues (cache ou BAN) ; faux sinon. + public bool GeocodageReussi { get; set; } = false; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +``` + +> **Stratégie de normalisation de la Dénomination** : l'unicité de la Dénomination est évaluée sur une forme normalisée = suppression des espaces de début et de fin (`trim`), passage en minuscules (insensible à la casse) et suppression des diacritiques/accents (insensible aux accents). Cette forme normalisée est calculée par le backend et matérialisée dans une colonne dédiée `DenominationNormalisee` portant un index unique `UX_AdresseNommee_Denomination`, ce qui délègue la garantie d'unicité au moteur SQLite tout en conservant la Dénomination d'origine (casse/accents) pour l'affichage. Le même schéma de normalisation est réutilisé pour comparer un texte d'adresse au Cache_Adresses (Exigences 15.7, 15.10). + +### Entité `Profil` (base de données) + +```csharp +/// Profil utilisateur (singleton — une seule ligne). +public class Profil +{ + public int Id { get; set; } = 1; // toujours 1 + + [MaxLength(100)] + public string? Nom { get; set; } + + [MaxLength(100)] + public string? Prenom { get; set; } + + /// Matricule (8 caractères maximum). + [MaxLength(8)] + public string? Matricule { get; set; } + + [MaxLength(255)] + public string? AdresseDomicile { get; set; } + + public double? DomicileLatitude { get; set; } + public double? DomicileLongitude { get; set; } + public bool DomicileGeocode { get; set; } = false; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// Véhicule de l'utilisateur avec date d'effet. Historique conservé. +public class Vehicule +{ + public int Id { get; set; } + + /// Plaque d'immatriculation (1–15 caractères). + [Required, MaxLength(15)] + public string Plaque { get; set; } = string.Empty; + + /// Puissance fiscale en chevaux fiscaux (entier positif). + [Required] + public int PuissanceFiscale { get; set; } + + /// Date à partir de laquelle ce véhicule devient le véhicule courant (<= date du jour). + [Required] + public DateOnly DateEffet { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public ICollection Trajets { get; set; } = new List(); +} + +/// +/// Tranche du barème kilométrique URSSAF pour une année et une puissance fiscale. +/// Trois tranches par (année, puissance fiscale) : ≤5000 km, 5001–20000 km, >20000 km. +/// +public class TrancheBareme +{ + public int Id { get; set; } + + [Required] + public int Annee { get; set; } + + [Required] + public int PuissanceFiscale { get; set; } + + /// Borne minimale de kilométrage annuel (incluse). + [Required] + public decimal KmMin { get; set; } + + /// Borne maximale de kilométrage annuel (incluse) ; valeur élevée pour la 3e tranche. + [Required] + public decimal KmMax { get; set; } + + /// Coefficient en euros par kilomètre. + [Required] + public decimal Coefficient { get; set; } + + /// Base fixe en euros ajoutée au montant (nulle pour les tranches 1 et 3). Formule : km × Coefficient + Base. + public decimal Base { get; set; } = 0m; +} + +/// Montant réglé (remboursement reçu) pour un mois, une année et un véhicule. +public class MontantRegle +{ + public int Id { get; set; } + + [Required] + public int Annee { get; set; } + + [Required] + public int Mois { get; set; } // 1–12 + + [Required] + public int VehiculeId { get; set; } + public Vehicule? Vehicule { get; set; } + + /// Montant réglé en euros (>= 0), deux décimales. + [Required] + public decimal Montant { get; set; } + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +``` + +### Schéma SQLite additionnel + +```sql +CREATE TABLE AdresseCache ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Adresse TEXT NOT NULL UNIQUE, + Latitude REAL NOT NULL, + Longitude REAL NOT NULL, + GeometrieJson TEXT, + DerniereUtilisation TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE AdressesNommees ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Denomination TEXT NOT NULL, -- 100 caractères maximum, casse/accents d'origine + DenominationNormalisee TEXT NOT NULL, -- trim + minuscules + sans accents (clé d'unicité) + Adresse TEXT NOT NULL, -- 255 caractères maximum + Latitude REAL, -- null si géocodage échoué + Longitude REAL, -- null si géocodage échoué + GeocodageReussi INTEGER NOT NULL DEFAULT 0, + CreatedAt TEXT NOT NULL DEFAULT (datetime('now')), + UpdatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX UX_AdresseNommee_Denomination ON AdressesNommees (DenominationNormalisee); + +CREATE TABLE Profil ( + Id INTEGER PRIMARY KEY DEFAULT 1, + Nom TEXT, + Prenom TEXT, + Matricule TEXT, -- 8 caractères maximum + AdresseDomicile TEXT, + DomicileLatitude REAL, + DomicileLongitude REAL, + DomicileGeocode INTEGER NOT NULL DEFAULT 0, + UpdatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX UX_Profil_Singleton ON Profil (Id); + +CREATE TABLE Vehicules ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Plaque TEXT NOT NULL, + PuissanceFiscale INTEGER NOT NULL CHECK (PuissanceFiscale > 0), + DateEffet TEXT NOT NULL, -- YYYY-MM-DD, <= date du jour + CreatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IX_Vehicules_DateEffet ON Vehicules (DateEffet); +``` + +> **Détermination du Véhicule_Courant** : `SELECT * FROM Vehicules WHERE DateEffet <= date('now') ORDER BY DateEffet DESC, Id DESC LIMIT 1`. À la création d'un Trajet, on résout le véhicule dont la DateEffet est la plus récente <= date du Trajet, et on copie sa PuissanceFiscale sur le Trajet (dénormalisation pour figer l'historique même si le véhicule est modifié plus tard). + +```sql +CREATE TABLE TranchesBareme ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Annee INTEGER NOT NULL, + PuissanceFiscale INTEGER NOT NULL, + KmMin REAL NOT NULL, -- 0, 5000.01, 20000.01 + KmMax REAL NOT NULL, -- 5000, 20000, valeur élevée + Coefficient REAL NOT NULL, + Base REAL NOT NULL DEFAULT 0, + CHECK (KmMin <= KmMax) +); +CREATE INDEX IX_TranchesBareme_Annee_PF ON TranchesBareme (Annee, PuissanceFiscale); + +CREATE TABLE MontantsRegles ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Annee INTEGER NOT NULL, + Mois INTEGER NOT NULL CHECK (Mois BETWEEN 1 AND 12), + VehiculeId INTEGER NOT NULL REFERENCES Vehicules(Id), + Montant REAL NOT NULL CHECK (Montant >= 0), + UpdatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX UX_MontantsRegles_Periode_Vehicule ON MontantsRegles (Annee, Mois, VehiculeId); +``` + +> **Calcul du barème (URSSAF)** : pour un total kilométrique annuel, une année et une puissance fiscale donnés, on sélectionne la tranche dont [KmMin, KmMax] contient le total annuel, puis `Montant_Reel = total_km_annuel × Coefficient + Base`. La tranche est déterminée par le **total annuel** et non par les kilomètres d'un mois isolé. Le **frais réel mensuel** d'un mois = `km_du_mois × Coefficient de la tranche annuelle` ; la somme des douze frais réels mensuels + la Base de la tranche = Montant_Reel. Le **Montant_Previsionnel** (80% du barème sur le total partiel) est purement indicatif et n'entre pas dans le calcul de la régularisation. + +### DTOs + +```csharp +// Création — les coordonnées sont transmises par le Frontend (issues de la sélection BAN) +public record CreateTrajetDto( + string Date, // JJ/MM/AAAA en entrée + string AdresseDepart, + double? DepartLatitude, // null si adresse saisie manuellement sans BAN + double? DepartLongitude, + string AdresseArrivee, + 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 +); + +// Mise à jour +public record UpdateTrajetDto( + string Date, + string AdresseDepart, + double? DepartLatitude, + double? DepartLongitude, + string AdresseArrivee, + double? ArriveeLatitude, + double? ArriveeLongitude, + string? Commentaire, + int? VehiculeId // optionnel ; null → véhicule courant à la date du trajet +); + +// Résultat de recherche BAN (proxied via backend si nécessaire) +public record BANAdresseDto( + string Label, + double Latitude, + double Longitude, + string Postcode, + string City +); + +// Réponse trajet +public record TrajetDto( + int Id, + DateOnly Date, + string AdresseDepart, + string AdresseArrivee, + decimal Distance, // km, 2 décimales + string? Commentaire, + decimal Position, // rang dans la journée + int VehiculeId, + int PuissanceFiscale +); + +// Réordonnancement manuel des trajets d'une journée +public record ReordonnerTrajetsDto( + IReadOnlyList Trajets +); + +public record TrajetPositionDto( + int Id, + decimal Position +); + +// Résumé mensuel +public record ResumeMensuelDto( + int Annee, + int Mois, + IReadOnlyList Trajets, + decimal TotalKm // somme distances, 2 décimales +); + +// Résumé annuel +public record ResumeAnnuelDto( + int Annee, + IReadOnlyList Mois, // toujours 12 éléments + decimal TotalKm +); +``` + +### DTOs — nouvelles fonctionnalités + +```csharp +// Trajet enrichi pour la carte +public record TrajetCarteDto( + int Id, + DateOnly Date, + string AdresseDepart, + string AdresseArrivee, + decimal Distance, + double? DepartLatitude, + double? DepartLongitude, + double? ArriveeLatitude, + double? ArriveeLongitude, + string? GeometrieJson // GeoJSON LineString ou null +); + +// Suggestion autocomplétion +public record AdresseCacheDto( + string Adresse, + double Latitude, + double Longitude +); + +// Carnet d'adresses — Adresse_Nommee retournée par le backend +public record AdresseNommeeDto( + int Id, + string Denomination, + string Adresse, + double? Latitude, // null si le géocodage a échoué + double? Longitude // null si le géocodage a échoué +); + +// Carnet d'adresses — création / mise à jour d'une Adresse_Nommee +public record SaveAdresseNommeeDto( + string Denomination, // 1 à 100 car., unique après normalisation + string Adresse, // 1 à 255 car. + double? Latitude, // fournies par le frontend si sélection BAN + double? Longitude +); + +// Profil +public record ProfilDto( + string? Nom, + string? Prenom, + string? Matricule, + string? AdresseDomicile, + bool DomicileGeocode +); + +public record SaveProfilDto( + string? Nom, + string? Prenom, + string? Matricule, // max 8 car. + string AdresseDomicile +); + +// État de configuration du profil (onboarding première utilisation) +public record EtatProfilDto( + bool EstConfigure // true ssi Profil.Nom et Profil.Prenom non vides ET au moins un Vehicule existe +); + +// Véhicules +public record VehiculeDto( + int Id, + string Plaque, + int PuissanceFiscale, + DateOnly DateEffet +); + +public record CreateVehiculeDto( + string Plaque, + int PuissanceFiscale, + string DateEffet // JJ/MM/AAAA, <= date du jour +); + +// Réponse à la création d'un véhicule : indique s'il faut proposer une réaffectation +public record VehiculeCreeDto( + VehiculeDto Vehicule, + bool ReaffectationProposee, // true si des trajets >= DateEffet ont une autre puissance fiscale + int NombreTrajetsConcernes +); + +// Requête de réaffectation +public record ReaffectationDto( + int VehiculeId, // nouveau véhicule + bool Accepter // true = réaffecter les trajets >= DateEffet ; false = ne rien changer +); + +// Totaux affichés dans le Panneau_Totaux +public record TotauxDto( + DateOnly Date, + int VehiculeId, // véhicule sur lequel portent les totaux + decimal TotalJour, + decimal TotalMois, + decimal TotalAnnuelPartiel +); + +// Barème +public record TrancheBaremeDto( + int Id, + int Annee, + int PuissanceFiscale, + decimal KmMin, + decimal KmMax, + decimal Coefficient, + decimal Base +); + +public record SaveTrancheBaremeDto( + int Annee, + int PuissanceFiscale, + decimal KmMin, + decimal KmMax, + decimal Coefficient, + decimal Base +); + +// Montant réglé +public record MontantRegleDto( + int Annee, + int Mois, + int VehiculeId, + decimal Montant +); + +// Indication de versement mensuel (informatif) affichée à la saisie +public record MontantPrevisionnelDto( + int Annee, + int Mois, + int VehiculeId, + decimal TotalAnnuelPartielKm, + decimal MontantBaremePlein, // barème plein sur le total partiel + decimal MontantPrevisionnel // = 80% (indicatif uniquement) +); + +// Ligne mensuelle de la synthèse annuelle (aligne sur la synthèse réelle) +public record LigneSyntheseMoisDto( + int Mois, + decimal KmMois, // kilomètres parcourus dans le mois + decimal FraisPaye, // Montant_Regle du mois (saisi) + decimal FraisReelMensuel // km_du_mois × coefficient de la tranche annuelle +); + +// Synthèse annuelle de régularisation pour un véhicule (page 1 du rapport) +public record RegularisationAnnuelleDto( + int Annee, + int VehiculeId, + string Plaque, + int PuissanceFiscale, + decimal CoefficientTranche, // coefficient de la tranche annuelle applicable + decimal BaseTranche, // base de la tranche annuelle applicable + string TrancheLibelle, // ex. "5 001 à 20 000 km — D2" + IReadOnlyList Mois, // 12 lignes + decimal TotalKmAnnuel, + decimal TotalPaye, // somme des Montant_Regle + decimal MontantReel, // barème plein : total_km × coef + base + decimal Regularisation // MontantReel − TotalPaye +); +``` + +--- + +## Endpoints API REST + +### Base URL : `/api` + +#### Trajets + +| Méthode | Route | Description | Corps | Réponse succès | +|---------|-------|-------------|-------|----------------| +| `POST` | `/api/trajets` | Créer un trajet | `CreateTrajetDto` | `201 Created` + `TrajetDto` | +| `GET` | `/api/trajets/{id}` | Récupérer un trajet | — | `200 OK` + `TrajetDto` | +| `PUT` | `/api/trajets/{id}` | Modifier un trajet | `UpdateTrajetDto` | `200 OK` + `TrajetDto` | +| `DELETE` | `/api/trajets/{id}` | Supprimer un trajet | — | `204 No Content` | +| `PATCH` | `/api/trajets/reordonner` | Mettre à jour l'ordre des trajets d'une journée | `ReordonnerTrajetsDto` | `204 No Content` | + +#### Résumés + +| Méthode | Route | Description | Réponse succès | +|---------|-------|-------------|----------------| +| `GET` | `/api/resumes/mensuel/{annee}/{mois}?vehiculeId={id}` | Résumé mensuel (filtre véhicule, courant si omis) | `200 OK` + `ResumeMensuelDto` | +| `GET` | `/api/resumes/annuel/{annee}?vehiculeId={id}` | Résumé annuel (filtre véhicule, courant si omis) | `200 OK` + `ResumeAnnuelDto` | + +#### Exports PDF + +| Méthode | Route | Description | Réponse succès | +|---------|-------|-------------|----------------| +| `GET` | `/api/exports/mensuel/{annee}/{mois}?vehiculeId={id}` | PDF mensuel (filtre véhicule, courant si omis) | `200 OK` + `application/pdf` | +| `GET` | `/api/exports/annuel/{annee}?vehiculeId={id}` | PDF annuel (filtre véhicule, courant si omis) | `200 OK` + `application/pdf` | + +#### Carte + +| Méthode | Route | Description | Réponse succès | +|---------|-------|-------------|----------------| +| `GET` | `/api/trajets/journee/{date}` | Trajets d'une journée avec géométries | `200 OK` + `TrajetCarteDto[]` | + +#### Adresses cache + +| Méthode | Route | Description | Réponse succès | +|---------|-------|-------------|----------------| +| `GET` | `/api/adresses/search?q={query}` | Recherche autocomplétion (min 3 car.) | `200 OK` + `AdresseCacheDto[]` | + +#### Carnet d'adresses + +| Méthode | Route | Description | Corps | Réponse succès | +|---------|-------|-------------|-------|----------------| +| `GET` | `/api/adresses-nommees` | Lister les Adresses_Nommées (triées par Dénomination) | — | `200 OK` + `AdresseNommeeDto[]` | +| `POST` | `/api/adresses-nommees` | Créer une Adresse_Nommee | `SaveAdresseNommeeDto` | `201 Created` + `AdresseNommeeDto` | +| `PUT` | `/api/adresses-nommees/{id}` | Modifier une Adresse_Nommee (re-géocodage si l'adresse change et n'est pas en cache) | `SaveAdresseNommeeDto` | `200 OK` + `AdresseNommeeDto` | +| `DELETE` | `/api/adresses-nommees/{id}` | Supprimer une Adresse_Nommee (sans impact sur les Trajets existants) | — | `204 No Content` | + +#### Profil + +| Méthode | Route | Description | Réponse succès | +|---------|-------|-------------|----------------| +| `GET` | `/api/profil` | Récupérer le profil | `200 OK` + `ProfilDto` | +| `PUT` | `/api/profil` | Sauvegarder le profil | `200 OK` + `ProfilDto` | +| `GET` | `/api/profil/etat` | État de configuration du profil (onboarding) : `EstConfigure = true` ssi le Profil a un Nom + Prénom non vides ET au moins un Véhicule existe | `200 OK` + `EtatProfilDto` | + +#### Véhicules + +| Méthode | Route | Description | Corps | Réponse succès | +|---------|-------|-------------|-------|----------------| +| `GET` | `/api/vehicules` | Historique des véhicules | — | `200 OK` + `VehiculeDto[]` | +| `GET` | `/api/vehicules/courant` | Véhicule courant | — | `200 OK` + `VehiculeDto` | +| `POST` | `/api/vehicules` | Enregistrer un véhicule | `CreateVehiculeDto` | `201 Created` + `VehiculeCreeDto` | +| `POST` | `/api/vehicules/reaffectation` | Accepter/refuser la réaffectation | `ReaffectationDto` | `204 No Content` | + +#### Navigation temporelle + +| Méthode | Route | Description | Réponse succès | +|---------|-------|-------------|----------------| +| `GET` | `/api/annees-disponibles` | Années ayant des données + année courante | `200 OK` + `number[]` | +| `GET` | `/api/mois-disponibles/{annee}` | Mois de l'année ayant des données | `200 OK` + `number[]` (1–12) | +| `GET` | `/api/jours-disponibles/{annee}/{mois}` | Jours du mois ayant des données | `200 OK` + `number[]` | +| `GET` | `/api/totaux/{date}?vehiculeId={id}` | Totaux jour/mois/année partiel du véhicule (courant si omis) | `200 OK` + `TotauxDto` | + +#### Barème et remboursements + +| Méthode | Route | Description | Corps | Réponse succès | +|---------|-------|-------------|-------|----------------| +| `GET` | `/api/bareme/{annee}` | Barème d'une année (toutes tranches) | — | `200 OK` + `TrancheBaremeDto[]` | +| `POST` | `/api/bareme` | Ajouter une tranche | `SaveTrancheBaremeDto` | `201 Created` + `TrancheBaremeDto` | +| `PUT` | `/api/bareme/{id}` | Modifier une tranche | `SaveTrancheBaremeDto` | `200 OK` + `TrancheBaremeDto` | +| `DELETE` | `/api/bareme/{id}` | Supprimer une tranche | — | `204 No Content` | +| `GET` | `/api/montant-previsionnel/{annee}/{mois}?vehiculeId={id}` | Montant prévisionnel minoré 80% | — | `200 OK` + `MontantPrevisionnelDto` | +| `GET` | `/api/montants-regles/{annee}?vehiculeId={id}` | Montants réglés de l'année | — | `200 OK` + `MontantRegleDto[]` | +| `PUT` | `/api/montants-regles` | Enregistrer/mettre à jour un montant réglé | `MontantRegleDto` | `200 OK` + `MontantRegleDto` | +| `GET` | `/api/regularisation/{annee}?vehiculeId={id}` | Synthèse annuelle de régularisation : `RegularisationAnnuelleDto` avec les 12 `LigneSyntheseMoisDto`, le coefficient et la base de la tranche annuelle, le total réel (barème plein) et la régularisation (réel − payé) | — | `200 OK` + `RegularisationAnnuelleDto` | + +#### Codes d'erreur standardisés + +| Code HTTP | Cas | +|-----------|-----| +| `400 Bad Request` | Validation échouée (champ manquant, date invalide, paramètre hors plage) | +| `404 Not Found` | Trajet inexistant | +| `503 Service Unavailable` | ORS indisponible ou timeout | +| `500 Internal Server Error` | Erreur inattendue | + +Toutes les erreurs retournent un objet `ProblemDetails` (RFC 7807) : + +```json +{ + "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", + "title": "Adresse de départ introuvable", + "status": 400, + "detail": "L'adresse 'rue inconnue 999' n'a pas pu être géocodée par ORS.", + "instance": "/api/trajets" +} +``` + +--- + +## Intégration BAN et ORS + +### Rôles respectifs + +| Service | Rôle | Déclenchement | +|---------|------|---------------| +| **BAN** (`api-adresse.data.gouv.fr`) | Autocomplétion + géocodage des adresses françaises | Frontend : dès 3 caractères saisis dans un champ adresse | +| **ORS** (`openrouteservice.org`) | Calcul de distance routière + géométrie d'itinéraire | Backend : uniquement si les coordonnées départ et arrivée sont connues | + +### Flux de saisie et calcul — BAN en frontend + +```mermaid +sequenceDiagram + participant U as Utilisateur + participant FE as Angular Frontend + participant BAN as API BAN + participant API as Backend API + participant ORS as ORS API + participant DB as SQLite + + U->>FE: Saisit 3+ caractères dans champ adresse + FE->>BAN: GET /search/?q={saisie}&limit=5 + BAN-->>FE: Liste de suggestions (label + coords) + FE-->>U: Affiche suggestions dropdown + U->>FE: Sélectionne une suggestion + FE->>FE: Stocke label + lat/lon en état local + + U->>FE: Soumet formulaire (adresses + coords) + FE->>API: POST /api/trajets (avec coordonnées) + + alt Coordonnées départ ET arrivée présentes + API->>ORS: POST /v2/directions/driving-car + ORS-->>API: distance (mètres) + géométrie GeoJSON + API->>DB: INSERT Trajet (distance routière) + else Coordonnées partiellement absentes + API->>BAN: GET /search/?q={adresse_manquante} + alt BAN géocode avec succès + API->>ORS: POST /v2/directions/driving-car + API->>DB: INSERT Trajet (distance calculée) + else BAN échoue aussi + API->>DB: INSERT Trajet (distance = null, avertissement) + end + end + API-->>FE: 201 Created + TrajetDto +``` + +### Appel ORS (itinéraire uniquement) + +ORS est appelé **uniquement** avec des coordonnées déjà résolues. Plus aucun appel de géocodage ORS. + +```mermaid +sequenceDiagram + participant SVC as DistanceCalculatorService + participant ORS as ORSClient + participant API as ORS API + + SVC->>ORS: CalculateRouteAsync(lat1, lon1, lat2, lon2) + ORS->>API: POST /v2/directions/driving-car + Note over ORS,API: Body: { coordinates: [[lon1,lat1],[lon2,lat2]] } + alt Route calculée + API-->>ORS: distance (mètres) + géométrie GeoJSON + ORS-->>SVC: RouteResult(distanceKm, geometrieJson) + else Timeout (>10s) ou erreur réseau + ORS-->>SVC: fallback Haversine déclenché + SVC->>SVC: HaversineKm(lat1,lon1,lat2,lon2) + SVC-->>SVC: distance orthodromique (pas de géométrie) + end +``` + +### Fallback Haversine (inchangé) + +```csharp +private static decimal HaversineKm(double lat1, double lon1, double lat2, double lon2) +{ + const double R = 6371.0; + var dLat = (lat2 - lat1) * Math.PI / 180; + var dLon = (lon2 - lon1) * Math.PI / 180; + var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(lat1 * Math.PI / 180) * Math.Cos(lat2 * Math.PI / 180) + * Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + return Math.Round((decimal)(R * c), 2); +} +``` + +### Configuration BAN et ORS + +```json +// appsettings.json +{ + "BAN": { + "BaseUrl": "https://api-adresse.data.gouv.fr", + "TimeoutSeconds": 5 + }, + "ORS": { + "BaseUrl": "https://api.openrouteservice.org", + "ApiKey": "", + "TimeoutSeconds": 10 + } +} +``` + +```csharp +// Configuration/BANOptions.cs +public class BANOptions +{ + public string BaseUrl { get; set; } = "https://api-adresse.data.gouv.fr"; + public int TimeoutSeconds { get; set; } = 5; +} + +// Configuration/ORSOptions.cs +public class ORSOptions +{ + public string BaseUrl { get; set; } = string.Empty; + public string ApiKey { get; set; } = string.Empty; + public int TimeoutSeconds { get; set; } = 10; +} +``` + +> **Note** : L'API BAN ne nécessite pas de clé d'authentification. La clé ORS est injectée via User Secrets en développement et variables d'environnement en production — jamais commitée dans le dépôt. + +--- + +## Génération PDF (QuestPDF) + +### Bibliothèque retenue : QuestPDF + +QuestPDF est une bibliothèque .NET open-source (licence MIT pour usage communautaire) offrant une API fluent pour la génération de PDF, sans dépendance à Acrobat/PDFium/LibreOffice. Elle est bien maintenue et adaptée à la génération de rapports tabulaires. + +### Structure des documents PDF + +#### PDF Mensuel (`trajets_MM_AAAA.pdf`) + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ FEUILLE D'INDEMNITÉ KILOMÉTRIQUE MENSUELLE │ +│ Cécile DOUCET │ +├────────────────────────────────────────────────────────────────────────────┤ +│ Véhicule : GOLF (ES-036-YF) — 6 CV (Coef. : 0,447 / Base : 0) │ +│ Période : JANVIER 2025 Total parcouru sur le mois : 931 Kms │ +│ Coef. : 0,447 Total parcouru sur l'année : 931 Kms │ +│ Base : 0 TOTAL IK du mois : 416,16 € │ +│ Adresse de départ : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │ +│ Réglé : 500,00 € │ +├────────────┬──────────────────────────┬──────────────────────────┬─────────┤ +│ Date │ Motifs (commentaire) │ Adresse départ → arrivée │ Kms │ +├────────────┼──────────────────────────┼──────────────────────────┼─────────┤ +│ 03/01/2025 │ Rdv chez Marie │ ... → Aureil │ 94 │ +│ ... │ ... │ ... │ ... │ +└────────────┴──────────────────────────┴──────────────────────────┴─────────┘ +``` + +> Le « TOTAL IK du mois » = kilomètres du mois × coefficient de la tranche annuelle applicable. Le « Réglé » = Montant_Regle saisi pour le mois. Le motif affiché est le commentaire du Trajet. + +#### PDF Annuel (`trajets_AAAA.pdf`) + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ SYNTHÈSE 2025 D'INDEMNITÉ KILOMÉTRIQUE │ +│ Nom : Cécile DOUCET Année : 2025 │ +│ Domicile : 29 route des Cascades, 23400 Saint-Pardoux-Morterolles │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ Barème applicable pour l'année (par puissance fiscale) : │ +│ PF │ ≤5000 (coef/base) │ 5001–20000 (coef/base) │ >20000 (coef/base) │ +│ 6 CV │ 0,665 / 0 │ 0,374 / 1457 │ 0,447 / 0 │ +│ Véhicule : GOLF (ES-036-YF) Total Kms : 12 241 Barème applicable : D2 │ +├────────────┬───────────────┬───────────────────┬────────────────────────────┤ +│ Mois │ Kms parcourus │ Frais payé (réglé)│ Frais réel │ +├────────────┼───────────────┼───────────────────┼────────────────────────────┤ +│ Janvier │ 931 │ 500,00 € │ 459,01 € │ +│ ... │ ... │ ... │ ... │ +│ Décembre │ 1 540 │ 500,00 € │ 759,26 € │ +├────────────┼───────────────┼───────────────────┼────────────────────────────┤ +│ TOTAL │ 12 241 │ 6 000,00 € │ 6 035,13 € │ +├────────────┴───────────────┴───────────────────┴────────────────────────────┤ +│ Formule réelle : 12241 km × 0,374 + 1457 = 6 035,13 € (tranche 5001–20000, 6 CV) │ +│ Régularisation (réel − payé) : 35,13 € │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +> Le « Frais réel » mensuel = kilomètres du mois × coefficient de la tranche annuelle. Le « Frais payé » = Montant_Regle saisi. La minoration 80% n'apparaît pas dans la synthèse : elle sert uniquement d'indication de versement en cours d'année. + +> **Rapports distincts par véhicule** : si la période demandée (mois ou année) contient des Trajets associés à plusieurs Véhicules, le backend génère un fichier PDF distinct par Véhicule. Chaque PDF ne contient que les Trajets et montants du Véhicule concerné. + +### En-tête obligatoire (Exigence 7.6) + +Tous les PDF incluent en en-tête : +1. Titre fixe : **"Rapport de trajets"** +2. Période couverte : `Mois AAAA` pour le mensuel, `AAAA` pour l'annuel +3. Total kilométrique de la période + +### Nommage des fichiers + +- Mensuel : `trajets_MM_AAAA.pdf` (ex. `trajets_01_2025.pdf`) +- Annuel : `trajets_AAAA.pdf` (ex. `trajets_2025.pdf`) + +Le header HTTP de réponse inclut : +``` +Content-Disposition: attachment; filename="trajets_01_2025.pdf" +Content-Type: application/pdf +``` + +### Timeout PDF (Exigence 7.5) + +La génération PDF est enveloppée dans un `CancellationToken` avec délai de 15 secondes. En cas de dépassement, le backend retourne `503 Service Unavailable`. + +--- + +## Déploiement (Docker Compose) + +### Architecture des conteneurs + +La solution est hébergée via **Docker Compose** avec deux services : + +- **`backend`** : API ASP.NET Core (.NET 8), exposant le port `8080` en interne. Le fichier SQLite est stocké sous le chemin du volume monté `/data/trajets.db`. +- **`frontend`** : application Angular compilée et servie par **nginx**. Nginx sert la SPA (fallback vers `index.html` pour le routage côté client) et agit comme reverse-proxy en relayant les requêtes `/api` vers le service `backend`. C'est le seul service exposé à l'hôte (port `8080` de l'hôte → nginx). + +```mermaid +graph LR + U[Navigateur] -- "http://hôte:8080" --> NGINX + subgraph "Réseau Docker Compose" + NGINX[frontend
nginx + SPA Angular] + API[backend
ASP.NET Core :8080] + NGINX -- "proxy /api → backend:8080" --> API + API -- "/data/trajets.db" --> VOL[(Volume nommé
trajets-data)] + end +``` + +> **Approche retenue** : reverse-proxy unique via nginx (frontend). Le backend n'est pas exposé directement à l'hôte ; tout le trafic transite par nginx, qui sert les fichiers statiques et relaie `/api` vers le backend sur le réseau interne Compose. + +### Backend `Dockerfile` (multi-stage) + +```dockerfile +# ---- Étape build (SDK) ---- +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY *.sln ./ +COPY TrajetKmTracker.Api/*.csproj TrajetKmTracker.Api/ +RUN dotnet restore TrajetKmTracker.Api/TrajetKmTracker.Api.csproj +COPY . . +RUN dotnet publish TrajetKmTracker.Api/TrajetKmTracker.Api.csproj -c Release -o /app/publish + +# ---- Étape runtime (ASP.NET) ---- +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . + +# Répertoire du volume pour la base SQLite persistée +RUN mkdir -p /data +VOLUME ["/data"] + +# La base SQLite est stockée sur le volume monté ; le chemin est fourni via la chaîne de connexion +ENV ConnectionStrings__Default="Data Source=/data/trajets.db" +ENV ASPNETCORE_URLS="http://+:8080" + +EXPOSE 8080 +ENTRYPOINT ["dotnet", "TrajetKmTracker.Api.dll"] +``` + +### Frontend `Dockerfile` (multi-stage) + +```dockerfile +# ---- Étape build (Node) ---- +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build -- --configuration production + +# ---- Étape serve (nginx) ---- +FROM nginx:1.27-alpine AS serve +# Configuration nginx (SPA + reverse-proxy /api) +COPY nginx.conf /etc/nginx/conf.d/default.conf +# Artefacts Angular compilés +COPY --from=build /app/dist/trajet-km-tracker /usr/share/nginx/html +EXPOSE 80 +``` + +### Configuration nginx (`nginx.conf`) + +```nginx +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA Angular : toute route inconnue retombe sur index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Reverse-proxy des appels API vers le service backend (réseau interne Compose) + location /api/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### `docker-compose.yml` + +```yaml +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + - ASPNETCORE_URLS=http://+:8080 + - ConnectionStrings__Default=Data Source=/data/trajets.db + # La clé ORS est fournie par l'environnement / .env — jamais intégrée à l'image + - ORS__ApiKey=${ORS_API_KEY} + volumes: + - trajets-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "8080:80" # hôte 8080 → nginx (frontend) + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +volumes: + trajets-data: # persistance SQLite au-delà du cycle de vie des conteneurs +``` + +> **Endpoint de santé** : le backend expose `GET /health` (via `AddHealthChecks()` / `MapHealthChecks("/health")`) utilisé par le `healthcheck` Compose et la condition `depends_on: service_healthy`. + +### Configuration et secrets + +- La **clé ORS** est fournie exclusivement via variable d'environnement (`ORS_API_KEY`), typiquement à partir d'un fichier `.env` **non commité** (ajouté au `.gitignore`) et lu automatiquement par Docker Compose. Elle n'est **jamais** intégrée dans une image Docker. +- L'**API BAN** ne nécessite aucune clé — aucune configuration de secret n'est requise pour le géocodage. +- Le **volume nommé `trajets-data`** (monté sur `/data`) garantit que le fichier `trajets.db` persiste à travers les redémarrages, recréations et mises à jour des conteneurs. + +### Migrations au démarrage + +Les **migrations EF Core sont appliquées automatiquement au démarrage du backend** (`await dbContext.Database.MigrateAsync()` dans `Program.cs` avant `app.Run()`). Ainsi, au premier lancement, la base `/data/trajets.db` est créée sur le volume monté ; aux lancements suivants, elle est mise à jour avec les migrations en attente sans intervention manuelle. + +--- + +## Error Handling + +### Stratégie globale + +Un middleware `GlobalExceptionHandlerMiddleware` (ou `IExceptionHandler` .NET 8) intercepte toutes les exceptions non gérées et retourne un `ProblemDetails` standardisé. + +```mermaid +graph LR + REQ[Requête entrante] + MW[GlobalExceptionHandler] + CTRL[Controller] + SVC[Service] + ERR[ProblemDetails Response] + + REQ --> MW --> CTRL --> SVC + SVC -- Exception --> MW + MW --> ERR +``` + +### Tableau des exceptions métier + +| Exception | HTTP | Message | +|-----------|------|---------| +| `TrajetNotFoundException` | 404 | "Le trajet {id} n'existe pas." | +| `BANIndisponibleException` | 200 (warning dans réponse) | "L'autocomplétion d'adresse est temporairement indisponible. Vous pouvez saisir l'adresse manuellement." | +| `DistanceNonCalculableException` | 201 (warning dans réponse) | "Le trajet a été enregistré mais la distance n'a pas pu être calculée faute de coordonnées géographiques." | +| `ORSTimeoutException` | 200 (fallback Haversine appliqué) | "La distance routière est indisponible. La distance orthodromique (vol d'oiseau) a été utilisée." | +| `DistanceCalculationException` | 503 | "Erreur lors du calcul de distance." | +| `PdfGenerationException` | 503 | "La génération du PDF a échoué. Réessayez." | +| `ValidationException` | 400 | Détails par champ (FluentValidation) | +| `AdresseDomicileGeocodageException` | 200 (warning) | "L'adresse de domicile a été sauvegardée mais n'a pas pu être vérifiée via ORS." | +| `MatriculeTropLongException` | 400 | "Le matricule ne peut pas dépasser 8 caractères." | +| `DateEffetFutureException` | 400 | "La date d'effet du véhicule ne peut pas être dans le futur." | +| `VehiculeNotFoundException` | 404 | "Le véhicule demandé n'existe pas." | +| `TranchesBaremeChevauchantesException` | 400 | "Les tranches du barème pour cette puissance fiscale et cette année se chevauchent." | +| `BaremeNonConfigureException` | 200 (montant zéro + avertissement) | "Aucun barème configuré pour cette puissance fiscale et cette année ; le montant de remboursement est nul." | +| `MontantRegleNegatifException` | 400 | "Le montant réglé ne peut pas être négatif." | +| `DenominationInvalideException` | 400 | "La dénomination est absente ou invalide (1 à 100 caractères)." | +| `DenominationDupliqueeException` | 400 | "Cette dénomination est déjà utilisée dans le carnet d'adresses." | +| `AdresseNommeeInvalideException` | 400 | "L'adresse est absente ou invalide (1 à 255 caractères)." | +| `AdresseNommeeNotFoundException` | 404 | "L'adresse nommée demandée n'existe pas." | + +> **Onboarding — échec de récupération de l'état** : aucune nouvelle exception backend n'est requise pour l'endpoint `GET /api/profil/etat`. Le cas d'échec de récupération de l'indicateur `EstConfigure` (backend injoignable / erreur réseau) est géré **côté client** par `ProfilConfigureGuard`, qui affiche un message d'erreur de connexion assorti d'une action "Réessayer" au lieu de rediriger arbitrairement. + +### Validation des requêtes entrantes (Backend) + +La validation est assurée par **FluentValidation** intégré à ASP.NET Core : + +```csharp +public class CreateTrajetDtoValidator : AbstractValidator +{ + public CreateTrajetDtoValidator() + { + RuleFor(x => x.Date) + .NotEmpty().WithMessage("La date est obligatoire.") + .Matches(@"^\d{2}/\d{2}/\d{4}$").WithMessage("Format de date invalide (JJ/MM/AAAA).") + .Must(BeAValidDate).WithMessage("La date n'est pas une date existante."); + + RuleFor(x => x.AdresseDepart) + .NotEmpty().WithMessage("L'adresse de départ est obligatoire.") + .MaximumLength(255).WithMessage("L'adresse de départ ne peut dépasser 255 caractères."); + + RuleFor(x => x.AdresseArrivee) + .NotEmpty().WithMessage("L'adresse d'arrivée est obligatoire.") + .MaximumLength(255).WithMessage("L'adresse d'arrivée ne peut dépasser 255 caractères."); + } +} + +public class SaveAdresseNommeeDtoValidator : AbstractValidator +{ + public SaveAdresseNommeeDtoValidator() + { + RuleFor(x => x.Denomination) + .NotEmpty().WithMessage("La dénomination est obligatoire.") + .MaximumLength(100).WithMessage("La dénomination ne peut dépasser 100 caractères."); + + RuleFor(x => x.Adresse) + .NotEmpty().WithMessage("L'adresse est obligatoire.") + .MaximumLength(255).WithMessage("L'adresse ne peut dépasser 255 caractères."); + } +} +``` + +> **Unicité de la Dénomination** : le contrôle d'unicité (Exigence 15.7) ne peut pas être exprimé par FluentValidation seul car il dépend de l'état de la base. `AdresseNommeeService` calcule la forme normalisée (trim + minuscules + suppression des accents) et vérifie l'absence de collision avant persistance ; l'index unique `UX_AdresseNommee_Denomination` garantit l'unicité au niveau du moteur SQLite en dernier recours. En cas de collision, une `DenominationDupliqueeException` est levée et convertie en HTTP 400. + +### Transactions (Exigence 6.7) + +Toutes les opérations d'écriture (création, modification, suppression) s'exécutent dans une transaction EF Core : + +```csharp +await using var transaction = await _context.Database.BeginTransactionAsync(); +try +{ + // opérations... + await _context.SaveChangesAsync(); + await transaction.CommitAsync(); +} +catch +{ + await transaction.RollbackAsync(); + throw; +} +``` + +--- + +## Correctness Properties + +*Une propriété est une caractéristique ou un comportement qui doit rester vrai pour toutes les exécutions valides d'un système — en essence, un énoncé formel sur ce que le système doit faire. Les propriétés servent de pont entre les spécifications lisibles par l'humain et les garanties de correction vérifiables par une machine.* + +### Property 1: Cohérence du total mensuel + +*Pour toute* liste de trajets enregistrés dans un mois calendaire donné (année, mois), le total kilométrique retourné par le backend dans le `ResumeMensuelDto` doit être égal à la somme des distances individuelles de ces trajets, avec une tolérance de 0,01 km. + +**Validates: Requirements 3.3, 6.5** + +--- + +### Property 2: Cohérence du total annuel + +*Pour tout* résumé annuel, le total kilométrique annuel doit être égal à la somme des totaux kilométriques des douze résumés mensuels de l'année, avec une tolérance de 0,01 km. + +**Validates: Requirements 4.3, 6.6** + +--- + +### Property 3: Précision de stockage des distances + +*Pour tout* trajet créé avec une distance calculée, la valeur stockée en base de données doit correspondre à la valeur retournée arrondie à exactement deux décimales — jamais tronquée, jamais supérieure à 99 999,99 km. + +**Validates: Requirements 2.2, 2.5, 6.2** + +--- + +### Property 4: Rejet systématique des données d'entrée invalides (backend) + +*Pour tout* `CreateTrajetDto` contenant au moins un champ invalide — que ce soit une date absente, un format de date non conforme à JJ/MM/AAAA, une date inexistante, une adresse absente, vide, ou dépassant 255 caractères — le backend doit retourner une erreur HTTP 400 sans créer aucune entrée en base de données. + +> **Réflexion de consolidation** : les critères 1.6 (date invalide) et 1.7 (adresses invalides) partagent la même garantie d'atomicité (rejet → pas de persistance) et sont testables par le même générateur FsCheck produisant des `CreateTrajetDto` avec des champs invalides. Ils sont donc consolidés en une seule propriété. + +**Validates: Requirements 1.5, 1.6, 1.7, 6.3** + +--- + +### Property 5: Invariant de suppression + +*Pour tout* trajet supprimé, ce trajet ne doit apparaître dans aucun résumé mensuel ni annuel consultés après la suppression — le total kilométrique doit refléter l'état après suppression. + +**Validates: Requirements 5.5, 6.4** + +--- + +### Property 6: Recalcul conditionnel de la distance + +*Pour toute* modification d'un trajet, si aucune adresse n'est modifiée, la distance stockée ne doit pas changer. Si au moins une adresse est modifiée, la nouvelle distance doit correspondre au recalcul ORS (ou fallback Haversine) avec les nouvelles adresses. + +**Validates: Requirements 5.2, 5.3** + +--- + +### Property 7: Mois toujours présents dans le résumé annuel + +*Pour toute* année valide (2000–2100), le `ResumeAnnuelDto` retourné doit contenir exactement 12 entrées mensuelles (une par mois de 1 à 12), chaque mois sans trajet ayant un total kilométrique de 0,00. + +**Validates: Requirements 4.2, 4.4** + +--- + +### Property 8: Rejet des paramètres de résumé hors plage (backend) + +*Pour toute* requête de résumé mensuel avec un mois hors de la plage [1, 12] ou une année hors de la plage [2000, 2100], le backend doit systématiquement retourner une erreur HTTP 400. + +**Validates: Requirements 3.6** + +--- + +### Property 9: Nommage des fichiers PDF exportés + +*Pour tout* export PDF généré — qu'il soit mensuel ou annuel — le nom de fichier inclus dans l'en-tête `Content-Disposition` doit correspondre exactement au format `trajets_MM_AAAA.pdf` (mensuel) ou `trajets_AAAA.pdf` (annuel), où MM et AAAA correspondent aux paramètres de la requête. + +**Validates: Requirements 7.4** + +--- + +### Property 10: Cohérence du cache adresses après géocodage + +*Pour toute* adresse résolue avec succès par ORS lors de la création ou modification d'un Trajet, cette adresse doit être présente dans le Cache_Adresses à l'issue de l'opération, avec des coordonnées non nulles. Si l'adresse était déjà présente, ses coordonnées ne doivent pas être modifiées, mais sa date de dernière utilisation doit être mise à jour. + +**Validates: Requirements 9.1, 9.2** + +--- + +### Property 11: Politique LRU du cache adresses + +*Pour tout* état du Cache_Adresses contenant exactement 1 001 entrées après une insertion, le Backend doit supprimer exactement 1 entrée — celle dont la date de dernière utilisation est la plus ancienne — de sorte que le Cache_Adresses ne contienne jamais plus de 1 000 entrées. + +**Validates: Requirements 9.5** + +--- + +### Property 12: Cohérence du profil domicile + +*Pour toute* sauvegarde réussie d'une Adresse_Domicile (que le géocodage ait réussi ou échoué), le Backend doit persister l'adresse textuelle et retourner un ProfilDto dont le champ `AdresseDomicile` correspond exactement au texte soumis. Le champ `DomicileGeocode` doit être `true` si et seulement si ORS a résolu les coordonnées. + +**Validates: Requirements 10.2, 10.7** + +--- + +### Property 13: Persistance optionnelle du commentaire + +*Pour tout* Trajet créé ou modifié avec un commentaire non nul et d'une longueur comprise entre 1 et 500 caractères, le Backend doit stocker ce commentaire et le retourner intact dans le `TrajetDto`. Pour tout Trajet créé sans commentaire (null ou absent), le `TrajetDto` retourné doit avoir `Commentaire` égal à `null`. + +**Validates: Requirements 1.11, 1.12** + +--- + +### Property 14: Cohérence de l'ordre des trajets d'une journée + +*Pour toute* journée contenant N trajets, la liste retournée par le Backend doit être triée par valeur de `Position` croissante. *Pour tout* trajet inséré entre un Trajet_Précédent de position P₁ et un Trajet_Suivant de position P₂ (P₁ < P₂), la `Position` du trajet inséré doit être strictement comprise entre P₁ et P₂, de sorte que `P₁ < Position_inséré < P₂`. + +**Validates: Requirements 11.7, 11.8** + +--- + +### Property 15: Unicité et cohérence des positions au sein d'une journée + +*Pour tout* ensemble de trajets d'une même journée, deux trajets distincts ne peuvent pas avoir la même valeur de `Position`. Après toute opération d'insertion ou de réordonnancement, l'ordre relatif des trajets non déplacés doit être préservé — aucun trajet non concerné par l'opération ne doit voir sa `Position` modifiée sauf lors d'une renumérotation explicite. + +**Validates: Requirements 11.7, 11.8, 11.9** + +--- + +### Property 16: Priorité BAN sur ORS pour la résolution des coordonnées + +*Pour tout* Trajet dont les coordonnées d'une adresse sont absentes au moment de la création, le Backend doit tenter de les résoudre via l'API BAN avant tout appel ORS. L'appel ORS ne doit jamais être effectué avec des coordonnées nulles — si la BAN ne peut pas résoudre les coordonnées, le Trajet est enregistré avec distance nulle sans appel ORS. + +**Validates: Requirements 2.4** + +--- + +### Property 17: Total annuel partiel borné au mois sélectionné + +*Pour toute* date sélectionnée (année A, mois M), le `TotalAnnuelPartiel` retourné doit être égal à la somme des distances de tous les Trajets de l'année A dont le mois est inférieur ou égal à M — aucun Trajet d'un mois strictement supérieur à M ne doit être inclus. Par conséquent, `TotalAnnuelPartiel >= TotalMois` toujours, et `TotalAnnuelPartiel <= total annuel complet`. + +**Validates: Requirements 12.8** + +--- + +### Property 18: Aucune sélection de date future + +*Pour toute* date proposée à la sélection dans le Sélecteur_Temporel, cette date doit être inférieure ou égale à la date courante. Le bouton "Jour suivant" doit être désactivé si et seulement si la journée suivante est strictement postérieure à la date courante. + +**Validates: Requirements 12.5, 12.13** + +--- + +### Property 19: Association du véhicule courant par défaut + +*Pour tout* Trajet créé sans VehiculeId explicite, le Backend doit l'associer au Véhicule dont la DateEffet est la plus récente parmi celles inférieures ou égales à la date du Trajet, et copier la PuissanceFiscale de ce véhicule sur le Trajet. + +**Validates: Requirements 10.13, 10.14** + +--- + +### Property 20: Réaffectation bornée à la date d'effet + +*Pour toute* réaffectation acceptée d'un nouveau Véhicule de DateEffet D, seuls les Trajets dont la date est supérieure ou égale à D doivent voir leur VehiculeId et leur PuissanceFiscale mis à jour ; aucun Trajet de date strictement inférieure à D ne doit être modifié. En cas de refus, aucun Trajet existant n'est modifié. + +**Validates: Requirements 10.16, 10.17** + +--- + +### Property 21: Totaux filtrés par véhicule + +*Pour tout* calcul de totaux (jour, mois, annuel partiel) pour un Véhicule V, seuls les Trajets dont le VehiculeId est égal à V doivent être additionnés ; aucun Trajet associé à un autre véhicule ne doit contribuer au total. + +**Validates: Requirements 12.19** + +--- + +### Property 22: Conservation de l'historique des véhicules + +*Pour tout* enregistrement d'un nouveau Véhicule, tous les Véhicules précédemment enregistrés doivent rester présents en base ; aucun véhicule existant n'est supprimé ni écrasé. + +**Validates: Requirements 10.12, 10.18** + +--- + +### Property 23: Indication de versement à 80% (informative) + +*Pour tout* Montant_Previsionnel affiché, sa valeur doit être exactement égale à 80% du Bareme_Kilometrique appliqué au Total_Annuel_Partiel, arrondie à deux décimales. Cette valeur est purement informative : elle ne doit jamais entrer dans le calcul du Montant_Reel ni de la Regularisation. + +**Validates: Requirements 13.5, 13.6** + +--- + +### Property 24: Sélection de tranche du barème + +*Pour tout* total kilométrique K, année A et puissance fiscale P donnés, le montant barème appliqué doit utiliser exactement la Tranche_Bareme dont l'intervalle [KmMin, KmMax] contient K ; le montant est (K × Coefficient) + Base de cette tranche. Comme les tranches ne se chevauchent pas, au plus une tranche est sélectionnable. + +**Validates: Requirements 13.3, 13.4** + +--- + +### Property 25: Cohérence de la régularisation annuelle (barème plein) + +*Pour tout* Véhicule et année, le Montant_Reel doit être égal à `total_km_annuel × coefficient + base` de la tranche dont l'intervalle contient le total kilométrique annuel (barème plein, 100%). La Regularisation doit être exactement égale à `Montant_Reel − somme des Montant_Regle de l'année`, arrondie à deux décimales. La somme des douze Frais_Reel_Mensuel (km_du_mois × coefficient de la tranche annuelle) plus la base de la tranche doit être égale au Montant_Reel, avec une tolérance de 0,01 €. + +**Validates: Requirements 13.3, 14.5, 14.6, 14.7** + +--- + +### Property 26: Unicité du montant réglé par période et véhicule + +*Pour tout* couple (année, mois, Véhicule), il ne peut exister qu'au plus un Montant_Regle ; un second enregistrement pour la même clé met à jour le montant existant sans créer de doublon. + +**Validates: Requirements 14.3** + +--- + +### Property 27: Sélection de tranche par le total annuel + +*Pour tout* total kilométrique annuel K, année A et puissance fiscale P, la tranche sélectionnée pour le calcul du Montant_Reel doit être celle dont [KmMin, KmMax] contient K. Le coefficient et la base utilisés pour tous les frais réels mensuels de l'année doivent provenir de cette même tranche annuelle, quel que soit le kilométrage de chaque mois pris isolément. + +**Validates: Requirements 13.3, 14.6** + +--- + +### Property 28: Unicité normalisée de la dénomination + +*Pour tout* couple d'Adresses_Nommées enregistrées dans le Carnet_Adresses, leurs Dénominations normalisées (trim + minuscules + sans accents) doivent être distinctes. *Pour toute* tentative de création ou de modification produisant une Dénomination dont la forme normalisée est déjà présente, le Backend doit rejeter l'opération avec une erreur HTTP 400 sans persister ni modifier aucune Adresse_Nommee. + +**Validates: Requirements 15.6, 15.7** + +--- + +### Property 29: Préservation des trajets à la suppression d'une adresse nommée + +*Pour tout* Trajet existant et *pour toute* Adresse_Nommee supprimée du Carnet_Adresses, le texte d'adresse et les coordonnées géographiques (départ et arrivée) stockés du Trajet doivent rester strictement inchangés après la suppression — la suppression d'une Adresse_Nommee ne modifie, ne supprime ni n'altère aucun Trajet. + +**Validates: Requirements 15.14** + +--- + +### Property 30: Réutilisation du cache et cohérence du géocodage des adresses nommées + +*Pour toute* Adresse_Nommee dont le texte d'adresse correspond, après normalisation (trim + minuscules + sans accents), à une entrée du Cache_Adresses, le Backend doit réutiliser les coordonnées du cache sans appeler l'API BAN. *Pour toute* Adresse_Nommee persistée avec des coordonnées valides absentes du Cache_Adresses, ce couple (libellé normalisé, latitude, longitude) doit être présent dans le Cache_Adresses à l'issue de l'opération. *Pour toute* Adresse_Nommee dont le géocodage BAN échoue ou dépasse 5 secondes, l'entité doit être persistée avec des coordonnées nulles et `GeocodageReussi = false`. + +**Validates: Requirements 15.9, 15.10, 15.11, 15.12** + +--- + +### Property 31: Détermination de l'état de configuration du profil + +*Pour tout* état de la base (Profil éventuel + ensemble de Véhicules), l'indicateur `EstConfigure` retourné par `GET /api/profil/etat` doit être `true` **si et seulement si** un Profil existe avec un `Nom` et un `Prenom` tous deux non vides (après trim) **ET** qu'au moins un Véhicule existe. Dans tous les autres cas — Profil absent, Nom vide, Prénom vide, ou aucun Véhicule — `EstConfigure` doit être `false`. + +**Validates: Requirements 16.1, 16.2** + +--- + +## Testing Strategy + +### Approche duale + +La stratégie combine deux niveaux de tests complémentaires : + +- **Tests unitaires (xUnit + FluentAssertions + Moq)** : comportements spécifiques, cas limites, gestion des erreurs +- **Tests basés sur les propriétés (FsCheck + xUnit)** : propriétés universelles vérifiées sur des centaines de cas générés aléatoirement +- **Tests d'intégration (xUnit + WebApplicationFactory)** : flux end-to-end sur une base SQLite en mémoire + +### Tests backend C# + +#### Outil PBT retenu : FsCheck (intégration xUnit via `FsCheck.Xunit`) + +FsCheck est le framework de property-based testing standard pour .NET, inspiré de QuickCheck. Il s'intègre nativement avec xUnit via l'attribut `[Property]`. + +Configuration minimale : 100 itérations (valeur par défaut FsCheck ≥ 100). + +#### Implémentation des propriétés + +Chaque propriété de correction est couverte par un test FsCheck unique. La clé ORS est mockée dans tous les tests PBT pour éviter des appels réseau réels. + +```csharp +// Tag : Feature: trajet-km-tracker, Property 1: Cohérence du total mensuel +[Property(MaxTest = 200)] +public Property TotalMensuelEgaleSommeDistances() +{ + return Prop.ForAll( + Arb.From(GenTrajetList()), + trajets => { + var total = _resumeService.CalculerTotalMensuel(trajets); + var somme = trajets.Sum(t => t.Distance); + return Math.Abs(total - somme) <= 0.01m; + }); +} + +// Tag : Feature: trajet-km-tracker, Property 2: Cohérence du total annuel +[Property(MaxTest = 200)] +public Property TotalAnnuelEgaleSommeMensuels() { ... } + +// Tag : Feature: trajet-km-tracker, Property 3: Précision de stockage des distances +[Property(MaxTest = 200)] +public Property DistanceArrondieDeuxDecimales() { ... } + +// Tag : Feature: trajet-km-tracker, Property 4: Rejet systématique des données d'entrée invalides +[Property(MaxTest = 500)] +public Property EntreeInvalideRetourne400SansPersistance() { ... } +// Génère des CreateTrajetDto avec dates invalides (mauvais format, date inexistante) +// ET/OU adresses vides/null/> 255 caractères → vérifie HTTP 400 + aucune entrée créée en base + +// Tag : Feature: trajet-km-tracker, Property 5: Invariant de suppression +[Property(MaxTest = 200)] +public Property TrajetSupprimePasInResume() { ... } + +// Tag : Feature: trajet-km-tracker, Property 6: Recalcul conditionnel de la distance +[Property(MaxTest = 200)] +public Property ModificationSansAdressePreserveDistance() { ... } + +// Tag : Feature: trajet-km-tracker, Property 7: Mois toujours présents dans le résumé annuel +[Property(MaxTest = 200)] +public Property ResumeAnnuelContient12Mois() { ... } + +// Tag : Feature: trajet-km-tracker, Property 8: Rejet des paramètres de résumé hors plage +[Property(MaxTest = 300)] +public Property ParamsHorsPlageRetourne400() { ... } +// Génère des mois hors [1,12] et des années hors [2000,2100] + +// Tag : Feature: trajet-km-tracker, Property 9: Nommage des fichiers PDF exportés +[Property(MaxTest = 200)] +public Property NomFichierPdfConforme() { ... } +// Génère des couples (mois 1-12, année 2000-2100) et des années valides, +// vérifie que Content-Disposition correspond au format attendu + +// Tag : Feature: trajet-km-tracker, Property 10: Cohérence du cache après géocodage +[Property(MaxTest = 200)] +public Property AdresseGecoodeePresenteDansCache() { ... } + +// Tag : Feature: trajet-km-tracker, Property 11: Politique LRU +[Property(MaxTest = 200)] +public Property CacheNExcedePas1000Entrees() { ... } + +// Tag : Feature: trajet-km-tracker, Property 12: Cohérence profil domicile +[Property(MaxTest = 200)] +public Property ProfilDomicileCoherent() { ... } + +// Property 13: Persistance optionnelle du commentaire +[Property(MaxTest = 300)] +public Property CommentaireStockeEtRetourneIntact() { ... } +// Génère des commentaires de longueur 0 à 500 et vérifie round-trip, +// génère aussi des commentaires > 500 et vérifie HTTP 400 + +// Property 14: Cohérence de l'ordre des trajets d'une journée +[Property(MaxTest = 200)] +public Property InsertionPositionStrictementEntreDeuxExistants() +{ + // Génère une liste de trajets d'une journée avec positions P1 < P2, + // insère un trajet entre les deux et vérifie P1 < Position_inséré < P2 +} + +// Property 15: Unicité et cohérence des positions +[Property(MaxTest = 200)] +public Property PositionsUniquesParJournee() +{ + // Vérifie qu'après N insertions, aucune position n'est dupliquée dans une journée +} + +// Property 16: Priorité BAN sur ORS +[Property(MaxTest = 200)] +public Property BANAppeleeAvantORSQuandCoordManquantes() { ... } +// Vérifie que ORS n'est jamais appelé avec des coordonnées null + +// Property 17: Total annuel partiel borné au mois +[Property(MaxTest = 200)] +public Property TotalAnnuelPartielBorneAuMois() { ... } +// Vérifie TotalAnnuelPartiel = somme des trajets mois <= M, et >= TotalMois + +// Property 18: Aucune date future sélectionnable +[Property(MaxTest = 200)] +public Property AucuneDateFutureProposee() { ... } + +// Property 19: Association véhicule courant par défaut +[Property(MaxTest = 200)] +public Property TrajetAssocieVehiculeCourant() { ... } + +// Property 20: Réaffectation bornée à la date d'effet +[Property(MaxTest = 200)] +public Property ReaffectationBorneeDateEffet() { ... } + +// Property 21: Totaux filtrés par véhicule +[Property(MaxTest = 200)] +public Property TotauxFiltresParVehicule() { ... } + +// Property 22: Conservation historique véhicules +[Property(MaxTest = 200)] +public Property HistoriqueVehiculesConserve() { ... } + +// Property 23: Indication 80% informative +[Property(MaxTest = 200)] +public Property Previsionnel80IndicatifSansImpactRegularisation() { ... } + +// Property 24: Sélection de tranche +[Property(MaxTest = 300)] +public Property TrancheSelectionneeContientKm() { ... } + +// Property 25: Régularisation barème plein +[Property(MaxTest = 200)] +public Property RegularisationBaremePlein() { ... } +// Vérifie Montant_Reel = km_annuel × coef + base, Regularisation = reel − total réglé, +// et somme des frais réels mensuels + base = Montant_Reel (tolérance 0,01) + +// Property 26: Unicité montant réglé par période/véhicule +[Property(MaxTest = 200)] +public Property MontantRegleUniqueParPeriode() { ... } + +// Property 27: Sélection de tranche par le total annuel +[Property(MaxTest = 300)] +public Property TrancheSelectionneeParTotalAnnuel() { ... } + +// Tag : Feature: trajet-km-tracker, Property 28: Unicité normalisée de la dénomination +[Property(MaxTest = 300)] +public Property DenominationUniqueApresNormalisation() { ... } +// Génère des dénominations variant en casse/accents/espaces de bord ; +// vérifie qu'une seconde dénomination normalisée identique est rejetée (HTTP 400, aucune persistance) + +// Tag : Feature: trajet-km-tracker, Property 29: Préservation des trajets à la suppression +[Property(MaxTest = 200)] +public Property SuppressionAdresseNommeePreserveTrajets() { ... } +// Génère des trajets + des adresses nommées, supprime une adresse nommée, +// vérifie que les textes d'adresse et coordonnées des trajets sont inchangés + +// Tag : Feature: trajet-km-tracker, Property 30: Réutilisation du cache et cohérence du géocodage +[Property(MaxTest = 200)] +public Property AdresseNommeeReutiliseCacheOuGeocode() { ... } +// Adresse en cache (après normalisation) → coords réutilisées, BAN non appelé (mock BAN vérifié) ; +// adresse valide absente du cache → couple présent en cache après opération ; +// BAN indisponible/timeout → coordonnées null + GeocodageReussi = false + +// Tag : Feature: trajet-km-tracker, Property 31: Détermination de l'état de configuration du profil +[Property(MaxTest = 300)] +public Property EtatProfilConfigureIffNomPrenomEtVehicule() { ... } +// Génère des états variés (Profil absent/présent, Nom/Prénom vides ou remplis, 0..N véhicules) ; +// vérifie EstConfigure == (Nom non vide && Prénom non vide && au moins un véhicule) +``` + +#### Tests unitaires spécifiques (xUnit) + +- `TrajetService`: création, modification avec/sans recalcul, suppression, transactions, rollback sur erreur ORS +- `TrajetService`: création avec commentaire, création sans commentaire (null stocké), modification commentaire seul (pas de recalcul distance), validation commentaire > 500 car. → HTTP 400 +- `BANClient`: résolution adresse réussie, adresse non trouvée (score < seuil), timeout BAN → avertissement discret +- `DistanceCalculatorService`: coordonnées connues → appel ORS ; coordonnées inconnues → appel BAN d'abord, puis ORS si BAN OK ; ORS timeout → fallback Haversine (plus d'erreur bloquante) +- `DistanceCalculatorService`: BAN indisponible → saisie manuelle autorisée, distance null stockée +- `ResumeService`: total mois vide = 0, résumé annuel avec mois manquants = 0 +- `ResumeService` / `TotauxService`: calcul TotalAnnuelPartiel (cumul jusqu'au mois inclus, exclusion des mois postérieurs), années disponibles (données + année courante), mois/jours disponibles +- `PdfService`: génération mensuelle, génération annuelle, nommage des fichiers, timeout +- `AdresseCacheService`: insertion nouvelle adresse, réutilisation adresse existante, éviction LRU à 1 001 entrées +- `ProfilService`: sauvegarde avec géocodage réussi, sauvegarde avec géocodage échoué (warning sans erreur), mise à jour +- `ProfilService`: validation matricule <= 8 car. +- `ProfilService.getEtat()`: `EstConfigure = true` (Nom + Prénom non vides ET au moins un véhicule), `EstConfigure = false` (Profil absent, Nom vide, Prénom vide, ou aucun véhicule) +- `VehiculeService`: enregistrement véhicule, détermination véhicule courant (DateEffet max <= aujourd'hui), rejet DateEffet future, conservation historique, détection trajets à réaffecter (date >= DateEffet & puissance différente) +- `VehiculeService`: réaffectation acceptée (trajets >= DateEffet mis à jour), réaffectation refusée (aucun trajet modifié) +- `TrajetService`: association véhicule courant par défaut à la création, PuissanceFiscale copiée sur le trajet +- `TotauxService`: totaux filtrés par véhicule (exclusion des trajets d'autres véhicules) +- Validators FluentValidation: tous les cas de validation (date, adresses) +- `BaremeService`: saisie des 3 tranches (≤5000, 5001–20000, >20000) par puissance fiscale, sélection de tranche selon total annuel, formule `km × coef + base`, barème non configuré → montant zéro +- `RemboursementService`: Montant_Reel = barème plein sur total annuel (tranche selon total annuel), Frais_Reel_Mensuel = km_mois × coef tranche annuelle, somme des frais réels + base = Montant_Reel, Montant_Previsionnel = 80% indicatif (jamais dans la régularisation), Regularisation = Montant_Reel − total réglé +- `MontantRegleService`: upsert par (année, mois, véhicule), rejet montant négatif +- `PdfService`: en-tête identité (nom, prénom, matricule, plaque, puissance fiscale), PDF distinct par véhicule si plusieurs véhicules sur la période, montants prévisionnel/réglé (mensuel), régularisation (annuel) +- `AdresseNommeeService`: création avec coordonnées (mise en cache si absente), création dénomination dupliquée après normalisation (casse/accents/espaces) → HTTP 400, dénomination vide/> 100 car. → HTTP 400, adresse vide/> 255 car. → HTTP 400 +- `AdresseNommeeService`: adresse correspondant au cache (après normalisation) → réutilisation coords sans appel BAN (mock BAN), modification du texte d'adresse absente du cache → re-géocodage BAN, BAN indisponible/timeout 5 s → persistance sans coordonnées + `GeocodageReussi = false` +- `AdresseNommeeService`: suppression d'une Adresse_Nommee → aucun Trajet modifié (texte d'adresse et coordonnées inchangés) +- `SaveAdresseNommeeDtoValidator`: cas de validation dénomination (vide, > 100) et adresse (vide, > 255) + +#### Tests d'intégration (WebApplicationFactory + SQLite en mémoire) + +- Flux complet POST `/api/trajets` → vérification en base +- POST `/api/trajets` avec et sans commentaire +- PUT `/api/trajets/{id}` modification commentaire seul (distance inchangée) +- POST `/api/trajets` avec coordonnées transmises (BAN) → calcul ORS → distance stockée +- POST `/api/trajets` sans coordonnées (saisie manuelle) → appel BAN backend → si OK : ORS ; si KO : distance null + 201 + warning +- POST `/api/trajets` avec coordonnées + ORS timeout → fallback Haversine → 201 avec distance orthodromique +- Insertion entre deux trajets : vérification position (Position_inséré strictement entre P₁ et P₂) +- Insertion entre P=1.0 et P=2.0 : vérification que Position_inséré = 1.5 +- Insertion répétée jusqu'au déclenchement de la renumérotation (précision < 0.001) : vérification que l'ordre relatif est préservé +- PATCH `/api/trajets/reordonner` : vérification du nouvel ordre retourné, préservation des positions non modifiées +- GET résumé mensuel après insertions (vérification tri par date puis Position croissante) +- GET résumé annuel, y compris mois sans trajet +- PUT modification avec recalcul (ORS mocké) +- DELETE + vérification disparition du résumé +- GET exports PDF (vérification Content-Type, Content-Disposition, taille > 0) +- GET `/api/trajets/journee/{date}` avec géométries +- GET `/api/adresses/search?q=...` +- PUT `/api/profil` avec et sans géocodage réussi +- GET `/api/profil/etat` → `EstConfigure = true` (profil complet + véhicule) ; `EstConfigure = false` (profil incomplet ou sans véhicule) +- GET `/api/annees-disponibles`, `/api/mois-disponibles/{annee}`, `/api/jours-disponibles/{annee}/{mois}` +- GET `/api/totaux/{date}` : vérification TotalAnnuelPartiel borné au mois sélectionné +- POST `/api/vehicules` → détermination réaffectation proposée si trajets >= DateEffet avec autre puissance +- POST `/api/vehicules/reaffectation` accepter=true → trajets >= DateEffet réaffectés ; accepter=false → inchangés +- GET `/api/vehicules` → historique complet conservé +- GET `/api/totaux/{date}?vehiculeId=` → totaux filtrés par véhicule +- POST `/api/trajets` sans VehiculeId → associé au véhicule courant +- POST `/api/bareme` chevauchant → 400 +- GET `/api/montant-previsionnel/{annee}/{mois}?vehiculeId=` → 80% du barème sur total partiel +- PUT `/api/montants-regles` (upsert) puis re-PUT même période → mise à jour sans doublon +- GET `/api/regularisation/{annee}?vehiculeId=` → dû − versé cohérent +- GET export PDF annuel avec 2 véhicules → 2 fichiers distincts +- POST `/api/adresses-nommees` avec coordonnées → 201 + entrée présente dans le Cache_Adresses +- POST `/api/adresses-nommees` avec dénomination déjà utilisée (variante casse/accents/espaces) → 400 sans persistance +- POST `/api/adresses-nommees` avec dénomination vide/> 100 car. ou adresse vide/> 255 car. → 400 +- POST `/api/adresses-nommees` avec adresse déjà en cache → réutilisation coords (BAN mocké non appelé) +- PUT `/api/adresses-nommees/{id}` modifiant le texte d'adresse hors cache → re-géocodage BAN (mocké) ; BAN timeout → 200/warning + coordonnées null +- GET `/api/adresses-nommees` → liste triée par Dénomination +- DELETE `/api/adresses-nommees/{id}` → 204 puis vérification que les Trajets existants conservent adresse et coordonnées +- POST `/api/trajets` avec adresse issue d'une Adresse_Nommee sélectionnée (coordonnées associées) → distance calculée normalement + +### Tests frontend Angular (Jest + Testing Library) + +- `TrajetFormComponent` : validation synchrone (champs vides, format date, longueur), soumission succès/erreur, mode édition pré-rempli, autocomplétion BAN à 3 caractères, sélection suggestion → coordonnées stockées, saisie manuelle sans BAN → avertissement affiché, BAN indisponible → mode dégradé, bouton "Utiliser mon domicile" actif/désactivé +- `TrajetFormComponent` : saisie commentaire optionnel, validation longueur 500 car., mode insertion avec adresses pré-remplies depuis Trajet_Précédent/Trajet_Suivant, modification des adresses pré-remplies +- `ResumeMensuelComponent` : affichage 2 décimales, message erreur si API en échec +- `ResumeAnnuelComponent` : 12 lignes affichées, total correct, max 999 999,99 +- `CarteTrajetsComponent` : affichage tracés, fallback ligne droite, infobulles +- `ProfilComponent` : sauvegarde, avertissement géocodage échoué, saisie nom/prénom/matricule (limite 8 car.), validation +- `OnboardingComponent` : bouton "Commencer" désactivé tant que nom/prénom/véhicule requis manquants, enregistrement réussi (profil + véhicule) → navigation vers la vue de consultation +- `ProfilConfigureGuard` : `EstConfigure = false` → redirection vers `/onboarding` (accès trajets/consultation bloqué) ; `EstConfigure = true` → navigation autorisée ; échec de récupération de l'état → message d'erreur de connexion + action "Réessayer" (pas de redirection arbitraire) +- `VehiculesComponent` : ajout véhicule, date effet future bloquée, historique trié par date effet décroissante +- `ReaffectationDialogComponent` : affichage nombre trajets concernés, accepter/refuser +- `TrajetListComponent` : affichage commentaire (icône + tooltip), bouton "insérer après", bouton "insérer avant le suivant", drag & drop réordonne la liste et appelle PATCH, liste triée par Position croissante après insertion +- `SelecteurTemporelComponent` : années limitées (courante + données), surbrillance mois/jours avec données, désactivation dates futures +- `ConsultationJourComponent` : layout deux colonnes, scroll zone trajets, navigation jour±1, bouton "jour suivant" désactivé si futur, rafraîchissement < 1 000 ms, sélecteur véhicule filtre trajets/totaux/carte +- `PanneauTotauxComponent` : affichage 3 totaux à 2 décimales, recalcul au changement de date, affichage montant prévisionnel minoré 80% +- `BaremeComponent` : saisie tranches, détection chevauchement +- `MontantsReglesComponent` : saisie montant réglé, rejet négatif, affichage prévisionnel indicatif +- `CarnetAdressesComponent` : liste triée par dénomination, création/modification (autocomplétion BAN à 3 car., debounce 300 ms, max 5 suggestions), confirmation à la suppression, message de confirmation sur succès CRUD, avertissement si géocodage échoué +- `TrajetFormComponent` : sélecteur Adresse_Nommee (par Dénomination) pour départ et arrivée, en mode création et insertion ; sélection → champ rempli + coordonnées associées ; remplacement du contenu pré-rempli en mode insertion +- `TrajetService` : mocking HttpClient, timeout interceptor +- Tests E2E (Playwright, optionnel) : flux création → consultation → export PDF + +### Couverture cible + +| Périmètre | Cible | +|-----------|-------| +| Backend services (unitaire + PBT) | ≥ 90 % | +| Controllers / intégration | ≥ 80 % | +| Frontend components | ≥ 75 % | diff --git a/.kiro/specs/trajet-km-tracker/requirements.md b/.kiro/specs/trajet-km-tracker/requirements.md new file mode 100644 index 0000000..640a1d5 --- /dev/null +++ b/.kiro/specs/trajet-km-tracker/requirements.md @@ -0,0 +1,380 @@ +# Requirements Document + +## Introduction + +L'application **SchotzIAD KM Tracker** est une application web **mono-utilisateur** (sans authentification ni gestion de comptes) permettant de saisir et de suivre ses trajets mensuels (jour, adresse de départ, adresse d'arrivée), de calculer automatiquement les kilomètres parcourus par mois, et de consulter une vue annuelle consolidée des kilomètres parcourus. + +L'application est composée d'un frontend Angular/TypeScript et d'un backend C#. La saisie des adresses s'appuie sur l'API de la **Base Adresses Nationale (BAN)** (disponible sur data.gouv.fr) pour l'autocomplétion et la résolution des coordonnées géographiques. Le calcul de distance routière utilise l'API **OpenRouteService (ORS)** uniquement lorsque les coordonnées des deux adresses sont connues. Elle cible des utilisateurs souhaitant garder un historique fiable de leurs déplacements, notamment à des fins professionnelles ou de remboursement de frais. + +--- + +## Glossary + +- **Application** : L'ensemble du système SchotzIAD KM Tracker (frontend Angular + backend C#). +- **Frontend** : L'interface utilisateur développée en Angular/TypeScript. +- **Backend** : Le serveur d'API développé en C#. +- **Utilisateur** : La personne qui utilise l'application. +- **Trajet** : Un déplacement défini par une date, une adresse de départ et une adresse d'arrivée. +- **Distance** : Le nombre de kilomètres calculés entre l'adresse de départ et l'adresse d'arrivée d'un Trajet. +- **Résumé_Mensuel** : L'agrégation des Trajets et des Distances pour un mois calendaire donné. +- **Résumé_Annuel** : L'agrégation des Résumés_Mensuels pour une année calendaire donnée. +- **Geocodeur** : Le service de la Base Adresses Nationale (BAN), accessible via l'API api.gouv.fr, utilisé pour convertir une adresse textuelle en coordonnées géographiques et proposer des suggestions d'adresses en temps réel lors de la saisie. +- **ORS** : OpenRouteService, l'API externe utilisée exclusivement pour le calcul d'itinéraire routier entre deux points géographiques dont les coordonnées sont déjà connues. +- **BAN** : Base Adresses Nationale, le référentiel officiel des adresses françaises accessible via l'API publique `https://api-adresse.data.gouv.fr`. Fournit l'autocomplétion d'adresses et les coordonnées géographiques (latitude, longitude) sans clé d'authentification. +- **Calculateur_Distance** : Le composant backend responsable du calcul de la Distance entre deux points géographiques. +- **API** : L'interface de programmation exposée par le Backend pour communiquer avec le Frontend. +- **Export_PDF** : La fonctionnalité permettant de générer un fichier PDF contenant les données d'un Résumé_Mensuel ou d'un Résumé_Annuel. +- **Carte** : La visualisation cartographique interactive affichant les itinéraires des Trajets d'une journée, rendue via Leaflet.js sur fond OpenStreetMap. +- **Cache_Adresses** : La table de base de données stockant les couples (adresse textuelle normalisée BAN, coordonnées géographiques) des adresses déjà saisies, afin d'éviter des appels répétés à l'API BAN pour des adresses déjà connues. +- **Carnet_Adresses** : Le répertoire des Adresses_Nommées gérées par l'utilisateur via un menu dédié, permettant de créer, lister, modifier et supprimer des adresses réutilisables identifiées par une Dénomination, afin de simplifier la saisie des Trajets. +- **Adresse_Nommee** : Une adresse enregistrée dans le Carnet_Adresses, caractérisée par une Dénomination unique (libellé, 1 à 100 caractères) et un texte d'adresse (1 à 255 caractères) résolu via l'autocomplétion BAN, avec ses coordonnées géographiques (latitude, longitude) stockées lorsqu'elles sont disponibles. +- **Dénomination** : Le libellé libre et unique (1 à 100 caractères) identifiant une Adresse_Nommee dans le Carnet_Adresses (ex. « Bureau », « Client Dupont », « Domicile »). +- **Profil** : Les paramètres personnels de l'utilisateur, notamment son adresse de domicile. +- **Adresse_Domicile** : L'adresse personnelle de l'utilisateur enregistrée dans son Profil, utilisable en un clic lors de la saisie d'un Trajet. +- **Profil_Configure** : L'état du Profil considéré comme configuré lorsqu'un Profil est enregistré avec un nom et un prénom non vides ET qu'au moins un Véhicule (plaque, Puissance_Fiscale, Date_Effet) est enregistré. À l'inverse, le Profil est dit « non configuré » lorsqu'aucun Profil n'est enregistré avec un nom et un prénom non vides, ou lorsqu'aucun Véhicule n'est enregistré. +- **Onboarding** : Le parcours d'initialisation présenté à la première utilisation de l'Application lorsque le Profil est non configuré, invitant l'utilisateur à renseigner son identité (nom, prénom, matricule), éventuellement son Adresse_Domicile, et au moins un Véhicule avant d'accéder aux fonctionnalités de saisie et de consultation des Trajets. +- **Vue_Consultation** : La vue principale de l'Application permettant la saisie et la consultation des Trajets (Sélecteur_Temporel, zone des Trajets, Panneau_Totaux, Carte). +- **Commentaire** : Un texte optionnel associé à un Trajet, permettant à l'utilisateur d'annoter un déplacement (0 à 500 caractères). +- **Trajet_Précédent** : Le Trajet immédiatement antérieur dans la liste chronologique d'une journée, dont l'adresse d'arrivée peut servir de départ lors d'une insertion. +- **Trajet_Suivant** : Le Trajet immédiatement postérieur dans la liste chronologique d'une journée, dont l'adresse de départ peut servir d'arrivée lors d'une insertion. +- **Position** : Un entier positif associé à chaque Trajet au sein d'une même journée, déterminant son rang dans la liste affichée. L'ordre d'affichage est toujours trié par Position croissante, indépendamment de l'heure de création du Trajet. +- **Sélecteur_Temporel** : Le composant de navigation permettant de choisir successivement une année, un mois puis une journée pour consulter les Trajets correspondants. +- **Panneau_Totaux** : La zone latérale, toujours visible, affichant les totaux kilométriques du jour, du mois sélectionné et de l'année sélectionnée (cumul jusqu'au mois sélectionné inclus). +- **Total_Annuel_Partiel** : Le total kilométrique de l'année sélectionnée cumulant uniquement les Trajets jusqu'au mois sélectionné inclus, sans inclure les mois postérieurs. +- **Véhicule** : Un véhicule de l'utilisateur, caractérisé par une plaque d'immatriculation et une puissance fiscale, avec une date d'effet à partir de laquelle il devient le véhicule courant. L'historique des Véhicules est conservé. +- **Puissance_Fiscale** : La puissance administrative du Véhicule exprimée en chevaux fiscaux (CV), utilisée notamment pour le calcul du barème kilométrique. +- **Véhicule_Courant** : Le Véhicule dont la date d'effet est la plus récente parmi celles antérieures ou égales à la date du jour ; véhicule associé par défaut aux nouveaux Trajets. +- **Date_Effet** : La date (inférieure ou égale à la date du jour) à partir de laquelle un Véhicule devient le Véhicule_Courant. +- **Réaffectation** : L'opération consistant à réassocier des Trajets existants à un Véhicule différent lors d'un changement de véhicule. +- **Bareme_Kilometrique** : Le barème de remboursement kilométrique officiel (type URSSAF) pour une année donnée. Pour chaque Puissance_Fiscale, il comprend trois tranches de kilométrage annuel : jusqu'à 5 000 km, de 5 001 à 20 000 km, et au-delà de 20 000 km. Chaque tranche est définie par un coefficient (€/km) et une base (montant fixe en €, nul pour les tranches 1 et 3). La formule d'une tranche est : `montant = km × coefficient + base`. +- **Tranche_Bareme** : Une ligne du Bareme_Kilometrique pour une (année, Puissance_Fiscale, tranche de kilométrage) donnée : (borne_min_km, borne_max_km, coefficient €/km, base €). +- **Montant_Previsionnel** : Une indication de versement mensuel affichée à titre informatif, calculée comme 80% du Bareme_Kilometrique appliqué au Total_Annuel_Partiel. Ce montant est purement indicatif et n'est jamais utilisé dans le calcul de la Regularisation ; seul le Montant_Regle saisi par l'utilisateur fait foi. +- **Montant_Regle** : Le montant effectivement versé pour un mois donné, saisi librement par l'utilisateur (variable d'un mois à l'autre, forfaitaire ou non). C'est ce montant qui fait foi pour le calcul de la Regularisation. +- **Montant_Reel** : Le montant de remboursement réellement dû pour l'année, calculé en appliquant le Bareme_Kilometrique plein (100%, sans minoration) au total kilométrique annuel complet du Véhicule. La tranche applicable est déterminée par le total kilométrique annuel (et non par les kilomètres d'un mois isolé). +- **Regularisation** : La différence entre le Montant_Reel annuel et le total des Montant_Regle versés sur l'année, indiquant le montant restant à verser (ou à récupérer si négatif). +- **Frais_Reel_Mensuel** : La quote-part mensuelle du Montant_Reel, calculée en appliquant à chaque mois le coefficient de la tranche annuelle applicable (déterminée par le total annuel) : `Frais_Reel_Mensuel = km_du_mois × coefficient_tranche_annuelle`. La somme des douze Frais_Reel_Mensuel plus la base éventuelle de la tranche annuelle est égale au Montant_Reel. +- **Coefficient_Base_Vehicule** : Le coefficient et la base de la tranche du Bareme_Kilometrique correspondant à la Puissance_Fiscale du Véhicule et au total kilométrique annuel, affichés dans l'en-tête des feuilles mensuelles (ex. « Coef. 0,447 / Base 0 »). + +--- + +## Requirements + +### Requirement 1: Saisie d'un trajet + +**User Story:** En tant qu'utilisateur, je veux saisir un trajet avec une date, une adresse de départ et une adresse d'arrivée, afin de conserver un historique de mes déplacements. + +#### Acceptance Criteria + +1. THE Frontend SHALL afficher un formulaire de saisie contenant exactement les champs suivants : date du trajet (au format JJ/MM/AAAA), adresse de départ avec autocomplétion BAN (texte libre, 1 à 255 caractères) et adresse d'arrivée avec autocomplétion BAN (texte libre, 1 à 255 caractères). +2. WHEN l'utilisateur soumet le formulaire de saisie avec tous les champs obligatoires valides, THE Frontend SHALL envoyer les données du Trajet au Backend via l'API dans un délai maximum de 3 secondes. +3. WHEN le Backend reçoit les données d'un Trajet valide, THE Backend SHALL persister le Trajet en base de données et retourner une confirmation de création au Frontend. +4. IF le formulaire de saisie est soumis avec un ou plusieurs champs obligatoires manquants ou vides, THEN THE Frontend SHALL afficher, sans soumettre la requête au Backend, un message d'erreur identifiant chaque champ manquant ou vide. +5. IF le formulaire de saisie est soumis avec une date dont le format ne correspond pas à JJ/MM/AAAA ou dont la valeur est une date inexistante, THEN THE Frontend SHALL afficher un message d'erreur indiquant que la date est invalide sans soumettre la requête au Backend. +6. IF le Backend reçoit un Trajet dont la date est absente ou dont le format ne correspond pas à JJ/MM/AAAA, THEN THE Backend SHALL retourner une erreur HTTP 400 avec un message indiquant que la date est absente ou invalide, sans persister le Trajet. +7. IF le Backend reçoit un Trajet dont l'adresse de départ ou l'adresse d'arrivée est absente, vide ou dépasse 255 caractères, THEN THE Backend SHALL retourner une erreur HTTP 400 avec un message identifiant le champ concerné, sans persister le Trajet. +8. WHEN le Backend retourne une confirmation de création, THE Frontend SHALL afficher un message de confirmation de succès et réinitialiser les champs du formulaire de saisie. +9. IF le Frontend ne reçoit pas de réponse du Backend dans un délai de 10 secondes après la soumission, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le serveur est indisponible et conserver les données saisies dans le formulaire. +10. THE Frontend SHALL afficher un champ optionnel "Commentaire" (texte libre, 0 à 500 caractères) dans le formulaire de saisie d'un Trajet. +11. WHEN le Backend persiste un Trajet, THE Backend SHALL stocker le commentaire associé au Trajet si fourni, ou null si absent. +12. IF le Backend reçoit un Trajet dont le commentaire dépasse 500 caractères, THEN THE Backend SHALL retourner une erreur HTTP 400 avec un message indiquant que le commentaire est trop long, sans persister le Trajet. +13. WHEN l'utilisateur saisit au moins 3 caractères dans le champ adresse de départ ou adresse d'arrivée, THE Frontend SHALL interroger l'API BAN (`https://api-adresse.data.gouv.fr/search/`) et afficher une liste de suggestions d'adresses françaises correspondant à la saisie dans un délai de 2 secondes. +14. WHEN l'utilisateur sélectionne une suggestion de l'API BAN, THE Frontend SHALL pré-remplir le champ adresse avec le libellé normalisé de l'adresse sélectionnée et stocker les coordonnées géographiques (latitude, longitude) associées pour transmission au Backend. +15. WHEN l'utilisateur soumet le formulaire avec une adresse sélectionnée via l'API BAN, THE Frontend SHALL transmettre au Backend les coordonnées géographiques (latitude, longitude) de chaque adresse en même temps que le texte de l'adresse. +16. IF l'API BAN est indisponible lors de la saisie, THEN THE Frontend SHALL permettre à l'utilisateur de saisir l'adresse manuellement en texte libre sans suggestion d'autocomplétion, en affichant un avertissement discret indiquant que l'autocomplétion est temporairement indisponible. +17. IF l'utilisateur soumet une adresse saisie manuellement sans passer par l'autocomplétion BAN (coordonnées non disponibles), THEN THE Frontend SHALL afficher un avertissement indiquant que la distance ne pourra pas être calculée automatiquement si les coordonnées sont absentes. +18. THE Frontend SHALL proposer, pour le champ adresse de départ et pour le champ adresse d'arrivée du formulaire de saisie, un choix entre sélectionner une Adresse_Nommee pré-enregistrée du Carnet_Adresses (par sa Dénomination) via un sélecteur et saisir une nouvelle adresse manuellement avec l'autocomplétion BAN. +19. WHEN l'utilisateur sélectionne une Adresse_Nommee (par sa Dénomination) dans le sélecteur associé au champ adresse de départ ou adresse d'arrivée, THE Frontend SHALL pré-remplir ce champ avec le texte d'adresse de l'Adresse_Nommee et associer ses coordonnées géographiques (latitude, longitude) pour transmission au Backend. + +--- + +### Requirement 2: Calcul automatique de la distance + +**User Story:** En tant qu'utilisateur, je veux que les kilomètres parcourus soient calculés automatiquement à partir des coordonnées des adresses saisies, afin de ne pas avoir à les calculer manuellement. + +#### Acceptance Criteria + +1. WHEN le Backend reçoit un Trajet valide dont les coordonnées de départ et d'arrivée sont toutes deux connues, THE Calculateur_Distance SHALL calculer la Distance via l'API ORS en utilisant ces coordonnées. +2. WHEN OpenRouteService (ORS) retourne avec succès la distance routière entre les deux points géographiques d'un Trajet, THE Calculateur_Distance SHALL utiliser cette valeur comme Distance du Trajet, exprimée en kilomètres avec une valeur comprise entre 0,01 km et 99 999,99 km. +3. IF OpenRouteService (ORS) ne parvient pas à calculer la distance routière entre les coordonnées de départ et d'arrivée, THEN THE Calculateur_Distance SHALL calculer la Distance en utilisant la formule orthodromique (Haversine) comme fallback, sans retourner d'erreur à l'utilisateur. +4. IF le Backend reçoit un Trajet dont les coordonnées de départ ou d'arrivée sont absentes (adresse saisie manuellement sans BAN), THEN THE Backend SHALL tenter le géocodage de l'adresse manquante via l'API BAN avant d'appeler ORS ; si le géocodage BAN échoue également, THE Backend SHALL stocker le Trajet avec une Distance nulle et retourner une réponse HTTP 201 avec un avertissement indiquant que la distance n'a pas pu être calculée. +5. WHEN la Distance d'un Trajet est calculée, THE Backend SHALL stocker la valeur de la Distance en kilomètres avec une précision de deux décimales. +6. IF le Calculateur_Distance ne reçoit pas de réponse d'OpenRouteService (ORS) dans un délai de 10 secondes, THE Backend SHALL appliquer le fallback Haversine et stocker la Distance calculée orthodromiquement, sans erreur bloquante pour l'utilisateur. + +--- + +### Requirement 3: Consultation du résumé mensuel + +**User Story:** En tant qu'utilisateur, je veux consulter le total des kilomètres parcourus pour un mois donné, afin de suivre mes déplacements mensuels. + +#### Acceptance Criteria + +1. WHEN l'utilisateur sélectionne un mois (valeur entre 1 et 12 inclus) et une année (valeur entre 2000 et 2100 inclus), THE Frontend SHALL afficher la liste des Trajets enregistrés pour ce mois ainsi que le Résumé_Mensuel correspondant. +2. WHEN le Backend reçoit une requête de Résumé_Mensuel pour un mois (entre 1 et 12 inclus) et une année (entre 2000 et 2100 inclus) donnés, THE Backend SHALL retourner la liste des Trajets de ce mois triés par date croissante puis par Position croissante au sein de chaque journée, avec leur Distance respective et le total kilométrique du mois. +3. WHEN le Backend reçoit une requête de Résumé_Mensuel, THE Backend SHALL calculer le total kilométrique du Résumé_Mensuel en additionnant les Distances, exprimées en kilomètres à deux décimales, de tous les Trajets du mois concerné. +4. IF aucun Trajet n'est enregistré pour le mois sélectionné, THEN THE Backend SHALL retourner un Résumé_Mensuel avec une liste vide et un total kilométrique de zéro. +5. WHEN le Frontend affiche le Résumé_Mensuel, THE Frontend SHALL afficher le total kilométrique mensuel avec une précision de deux décimales. +6. IF le Backend reçoit une requête de Résumé_Mensuel avec un mois hors de la plage 1–12 ou une année hors de la plage 2000–2100, THEN THE Backend SHALL retourner une erreur HTTP 400 avec un message descriptif. +7. IF le Backend retourne une erreur lors d'une requête de Résumé_Mensuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles. +8. WHEN le Backend calcule avec succès un Résumé_Mensuel, THE Backend SHALL retourner une réponse HTTP 200 avec le Résumé_Mensuel correspondant. + +--- + +### Requirement 4: Consultation de la vue annuelle + +**User Story:** En tant qu'utilisateur, je veux consulter le total des kilomètres parcourus pour une année entière, afin d'avoir une vision globale de mes déplacements annuels. + +#### Acceptance Criteria + +1. WHEN l'utilisateur sélectionne une année comprise entre 2000 et 2100 inclus, THE Frontend SHALL afficher le Résumé_Annuel présentant le total kilométrique de chaque mois et le total annuel. +2. WHEN le Backend reçoit une requête de Résumé_Annuel pour une année donnée, THE Backend SHALL retourner les douze Résumés_Mensuels de cette année, chacun avec son total kilométrique. +3. WHEN le Backend reçoit une requête de Résumé_Annuel pour une année donnée, THE Backend SHALL calculer le total kilométrique annuel en additionnant les totaux kilométriques des douze Résumés_Mensuels de l'année concernée. +4. IF aucun Trajet n'est enregistré pour un mois d'une année, THEN THE Backend SHALL inclure ce mois dans le Résumé_Annuel avec un total kilométrique de zéro. +5. WHEN le Frontend affiche le Résumé_Annuel, THE Frontend SHALL afficher chaque total kilométrique mensuel et le total annuel avec une précision de deux décimales et une valeur maximale affichable de 999 999,99 km. +6. IF le Backend retourne une erreur lors d'une requête de Résumé_Annuel, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le chargement des données a échoué et ne pas afficher de données kilométriques partielles. +7. THE Backend SHALL calculer le total kilométrique annuel exclusivement en additionnant les totaux kilométriques des douze Résumés_Mensuels de l'année concernée ; aucun kilométrage non rattaché à un mois ne peut être inclus dans le total annuel. + +--- + +### Requirement 5: Modification et suppression d'un trajet + +**User Story:** En tant qu'utilisateur, je veux pouvoir modifier ou supprimer un trajet existant, afin de corriger des erreurs de saisie. + +#### Acceptance Criteria + +1. WHEN l'utilisateur demande la modification d'un Trajet existant, THE Frontend SHALL afficher le formulaire de saisie pré-rempli avec les données actuelles du Trajet dans un délai de 500 ms. +2. WHEN l'utilisateur soumet la modification d'un Trajet avec au moins une adresse modifiée, THE Backend SHALL recalculer la Distance et mettre à jour les données du Trajet avec la nouvelle Distance. +3. WHEN l'utilisateur soumet la modification d'un Trajet sans modifier les adresses, THE Backend SHALL mettre à jour les données du Trajet sans recalculer la Distance. +4. WHEN l'utilisateur demande la suppression d'un Trajet, THE Frontend SHALL afficher une demande de confirmation avant d'envoyer la requête de suppression au Backend. +5. WHEN l'utilisateur confirme la suppression d'un Trajet, THE Backend SHALL supprimer le Trajet de la base de données. +6. IF le Backend reçoit une demande de modification ou de suppression pour un Trajet inexistant, THEN THE Backend SHALL retourner une réponse indiquant que le Trajet demandé n'existe pas et SHALL bloquer toute mise à jour du Résumé_Mensuel associée à cette opération. +7. WHEN un Trajet est modifié ou supprimé, THE Frontend SHALL mettre à jour l'affichage du Résumé_Mensuel dans un délai de 1000 ms sans nécessiter un rechargement complet de la page. +8. IF le recalcul de la Distance échoue lors d'une modification, THEN THE Backend SHALL annuler la modification et retourner une erreur descriptive, en préservant les données existantes du Trajet sans modification. +9. WHEN l'utilisateur soumet la modification d'un Trajet avec un commentaire modifié, THE Backend SHALL mettre à jour le commentaire du Trajet sans déclencher de recalcul de distance. +10. IF un Trajet possède une Distance nulle suite à l'absence de coordonnées, THEN THE Backend SHALL accepter la modification du Trajet et tenter de recalculer la Distance si les nouvelles adresses soumises disposent de coordonnées via le Cache_Adresses ou l'API BAN. + +--- + +### Requirement 6: Persistance et cohérence des données + +**User Story:** En tant qu'utilisateur, je veux que mes données de trajets soient persistées de manière fiable, afin de les retrouver à chaque connexion. + +#### Acceptance Criteria + +1. THE Backend SHALL persister l'ensemble des Trajets dans une base de données relationnelle. +2. WHEN le Backend persiste un Trajet, THE Backend SHALL stocker la date du Trajet, l'adresse de départ (100 caractères maximum), l'adresse d'arrivée (100 caractères maximum) et la Distance calculée (valeur numérique positive exprimée en kilomètres, arrondie à deux décimales). +3. WHEN le Backend persiste un Trajet, IF l'un des champs obligatoires (date, adresse de départ, adresse d'arrivée, Distance) est absent ou invalide, THEN THE Backend SHALL rejeter la persistance et retourner un message d'erreur indiquant le champ manquant ou invalide sans créer d'entrée partielle. +4. WHEN un Trajet est supprimé, THE Backend SHALL mettre à jour le Résumé_Mensuel et le Résumé_Annuel correspondants en garantissant la cohérence des données en priorité ; le délai de 5 secondes est un objectif de performance et non une contrainte stricte lorsque la charge du système l'empêche. +5. THE Backend SHALL garantir que le total d'un Résumé_Mensuel est égal à la somme des Distances de tous les Trajets non supprimés du mois calendaire correspondant (année et mois identiques à la date du Trajet), avec une tolérance de 0,01 km. +6. THE Backend SHALL garantir que le total d'un Résumé_Annuel est égal à la somme des totaux des Résumés_Mensuels des douze mois de l'année calendaire correspondante, avec une tolérance de 0,01 km. +7. IF une opération d'écriture (création ou suppression de Trajet) échoue partiellement, THEN THE Backend SHALL annuler l'intégralité de l'opération et conserver l'état précédent des données sans modification partielle. +8. IF une opération d'écriture (création ou suppression de Trajet) réussit intégralement, THEN THE Backend SHALL ne pas effectuer de rollback de cette opération, quel que soit l'état du système après son exécution. + +--- + +### Requirement 7: Export PDF des résumés + +**User Story:** En tant qu'utilisateur, je veux pouvoir exporter en PDF le résumé mensuel ou annuel de mes trajets, afin de conserver une trace imprimable pour mes notes de frais. + +#### Acceptance Criteria + +1. WHEN l'utilisateur demande l'export PDF d'un Résumé_Mensuel, THE Backend SHALL générer un fichier PDF contenant la liste de tous les Trajets du mois (date, adresse de départ, adresse d'arrivée, distance en km) ainsi que le total kilométrique mensuel. +2. WHEN l'utilisateur demande l'export PDF d'un Résumé_Annuel, THE Backend SHALL générer un fichier PDF contenant le tableau des douze mois (mois, total kilométrique) ainsi que le total kilométrique annuel. +3. WHEN le Backend génère un Export_PDF avec succès, THE Frontend SHALL déclencher le téléchargement automatique du fichier PDF dans le navigateur de l'utilisateur. IF le mécanisme de téléchargement échoue après une génération réussie, THEN THE Frontend SHALL afficher un message d'erreur indiquant que le téléchargement a échoué. +4. THE Backend SHALL nommer le fichier PDF exporté selon le format suivant : `trajets_MM_AAAA.pdf` pour un export mensuel et `trajets_AAAA.pdf` pour un export annuel. +5. IF le Backend ne parvient pas à générer le fichier PDF dans un délai de 15 secondes, THEN THE Backend SHALL retourner une erreur et THE Frontend SHALL afficher un message indiquant que la génération du PDF a échoué. +6. WHEN le Backend génère un Export_PDF, THE Backend SHALL inclure dans le PDF : le titre "Rapport de trajets", la période couverte (mois/année ou année), et le total kilométrique de la période en en-tête du document. +7. WHEN l'utilisateur demande un export PDF (mensuel ou annuel), THE Backend SHALL n'inclure que les Trajets associés au Véhicule sélectionné et SHALL indiquer dans l'en-tête du PDF la plaque d'immatriculation et la Puissance_Fiscale du Véhicule concerné. +8. WHEN le Backend génère un Export_PDF (mensuel ou annuel), THE Backend SHALL inclure dans l'en-tête du document : le nom, le prénom, le matricule de l'utilisateur, ainsi que la plaque d'immatriculation et la Puissance_Fiscale du Véhicule concerné. +9. IF des Trajets de plusieurs Véhicules existent pour la période (mois ou année) demandée, THEN THE Backend SHALL générer un rapport PDF distinct par Véhicule. +10. WHEN le Backend génère un Export_PDF mensuel, THE Backend SHALL faire figurer dans l'en-tête : le Véhicule (plaque, puissance fiscale, coefficient et base de la tranche applicable), la période, l'adresse de départ du Profil, le total kilométrique du mois, le total kilométrique annuel cumulé, le TOTAL IK du mois (kilomètres du mois × coefficient de la tranche annuelle) et le Montant_Regle du mois. +11. WHEN le Backend génère un Export_PDF mensuel, THE Backend SHALL faire figurer la liste des Trajets du mois avec, pour chaque Trajet : la date, le motif (commentaire), l'adresse de départ, l'adresse d'arrivée et la distance en kilomètres. +12. WHEN le Backend génère un Export_PDF annuel (synthèse), THE Backend SHALL faire figurer : le nom et le prénom de l'utilisateur, l'année, l'adresse de domicile, le Bareme_Kilometrique applicable pour l'année, le Véhicule et sa puissance fiscale, la tranche applicable ; puis pour chacun des douze mois : les kilomètres parcourus, le Montant_Regle (frais payé) et le Frais_Reel_Mensuel (frais réel) ; et enfin les totaux : total kilométrique annuel, total réglé, Montant_Reel, et Regularisation (Montant_Reel − total réglé). + +--- + +### Requirement 8: Visualisation des trajets sur une carte + +**User Story:** En tant qu'utilisateur, je veux visualiser sur une carte les trajets effectués lors d'une journée donnée, afin d'avoir une représentation géographique de mes déplacements. + +#### Acceptance Criteria + +1. WHEN l'utilisateur sélectionne une date, THE Frontend SHALL afficher une carte interactive centrée sur la zone géographique couvrant l'ensemble des Trajets de cette journée, ou centrée sur une position par défaut si aucun Trajet n'est disponible. +2. WHEN la carte est affichée pour une journée donnée, THE Frontend SHALL représenter chaque Trajet de la journée par un tracé d'itinéraire routier (polyline) sur la carte, avec un marqueur au point de départ et un marqueur au point d'arrivée. +3. WHEN l'utilisateur survole ou clique sur un tracé de Trajet sur la carte, THE Frontend SHALL afficher une infobulle indiquant l'adresse de départ, l'adresse d'arrivée et la distance du Trajet. +4. IF aucun Trajet n'est enregistré pour la date sélectionnée, THEN THE Frontend SHALL afficher la carte interactive vide avec un message indiquant qu'aucun trajet n'est disponible pour cette journée. +5. IF la géométrie d'itinéraire d'un Trajet n'est pas disponible (coordonnées manquantes ou ORS indisponible), THEN THE Frontend SHALL afficher ce Trajet comme une ligne droite entre le point de départ et le point d'arrivée, avec une indication visuelle que l'itinéraire est approximatif. +6. THE Frontend SHALL permettre à l'utilisateur de zoomer et de se déplacer sur la carte de manière interactive. +7. WHEN le Backend reçoit une requête de trajets pour une journée donnée avec visualisation, THE Backend SHALL retourner pour chaque Trajet les coordonnées géographiques de départ et d'arrivée ainsi que la géométrie de l'itinéraire (liste de points GeoJSON) si disponible dans le Cache_Adresses. +8. IF le service de carte (Leaflet.js ou les tuiles OpenStreetMap) est indisponible ou inaccessible, THEN THE Frontend SHALL afficher un message d'erreur indiquant que la carte ne peut pas être chargée et proposer à l'utilisateur de réessayer. + +--- + +### Requirement 9: Cache et réutilisation des adresses géocodées + +**User Story:** En tant qu'utilisateur, je veux que les adresses que j'ai déjà saisies soient mémorisées et réutilisées automatiquement, afin d'éviter des appels répétés à l'API BAN et de gagner du temps lors de la saisie de nouveaux trajets. + +#### Acceptance Criteria + +1. WHEN l'utilisateur sélectionne une adresse via l'autocomplétion BAN lors de la création ou modification d'un Trajet, THE Backend SHALL stocker dans le Cache_Adresses le libellé normalisé BAN de l'adresse, sa latitude et sa longitude si cette adresse n'y est pas déjà présente. +2. WHEN le Backend reçoit un Trajet dont l'adresse de départ ou d'arrivée correspond exactement à une entrée du Cache_Adresses, THE Backend SHALL utiliser les coordonnées du Cache_Adresses sans appeler l'API BAN. +3. WHEN l'utilisateur saisit au moins 3 caractères dans un champ adresse, THE Frontend SHALL d'abord interroger le Cache_Adresses local via le Backend pour afficher les suggestions d'adresses déjà connues, puis compléter avec les résultats de l'API BAN si le Cache_Adresses retourne moins de 5 suggestions. +4. WHEN l'utilisateur sélectionne une suggestion du Cache_Adresses ou de l'API BAN, THE Frontend SHALL pré-remplir le champ adresse avec le libellé complet et stocker les coordonnées associées pour transmission au Backend. +5. IF le Cache_Adresses contient plus de 1 000 entrées, THEN THE Backend SHALL conserver les 1 000 entrées les plus récemment utilisées et supprimer les plus anciennes (politique LRU). +6. THE Cache_Adresses SHALL stocker pour chaque adresse : le libellé normalisé BAN (255 caractères maximum), la latitude (précision de 6 décimales), la longitude (précision de 6 décimales) et la date de dernière utilisation. + +--- + +### Requirement 10: Profil utilisateur, adresse de domicile et véhicules + +**User Story:** En tant qu'utilisateur, je veux gérer mon profil (identité, matricule, adresse de domicile et mes véhicules avec leur puissance fiscale), afin d'associer automatiquement chaque trajet au bon véhicule et de suivre mes kilomètres par véhicule. + +#### Acceptance Criteria + +1. THE Frontend SHALL afficher une page de profil permettant à l'utilisateur de saisir et de sauvegarder : son nom (1 à 100 caractères), son prénom (1 à 100 caractères), un matricule (1 à 8 caractères maximum), et une Adresse_Domicile (texte libre, 1 à 255 caractères). +2. IF le matricule saisi dépasse 8 caractères, THEN THE Frontend SHALL empêcher la saisie au-delà de 8 caractères et THE Backend SHALL rejeter tout matricule de plus de 8 caractères avec une erreur HTTP 400. +3. WHEN l'utilisateur sauvegarde une Adresse_Domicile, THE Backend SHALL géocoder l'adresse via l'API BAN pour obtenir ses coordonnées et les stocker dans le Profil ainsi que dans le Cache_Adresses ; si le géocodage BAN échoue, THE Backend SHALL persister l'Adresse_Domicile textuelle sans coordonnées. +4. THE Frontend SHALL afficher un bouton "Utiliser mon domicile" dans le formulaire de saisie d'un Trajet, à côté du champ adresse de départ et à côté du champ adresse d'arrivée. +5. WHEN l'utilisateur clique sur le bouton "Utiliser mon domicile" à côté d'un champ adresse, THE Frontend SHALL pré-remplir ce champ avec l'Adresse_Domicile enregistrée dans le Profil. +6. IF aucune Adresse_Domicile n'est enregistrée dans le Profil, THEN THE Frontend SHALL désactiver le bouton "Utiliser mon domicile" et afficher une infobulle indiquant que l'adresse de domicile n'est pas encore configurée. +7. WHEN l'utilisateur modifie l'Adresse_Domicile dans le Profil et sauvegarde, THE Backend SHALL re-géocoder la nouvelle adresse via l'API BAN pour mettre à jour les coordonnées dans le Profil et dans le Cache_Adresses. +8. IF le géocodage BAN de l'Adresse_Domicile échoue, THEN THE Backend SHALL sauvegarder l'Adresse_Domicile textuelle sans coordonnées et THE Frontend SHALL afficher un avertissement indiquant que l'adresse n'a pas pu être vérifiée. +9. THE Frontend SHALL permettre à l'utilisateur de saisir un Véhicule caractérisé par une plaque d'immatriculation (1 à 15 caractères) et une Puissance_Fiscale (entier positif, exprimé en chevaux fiscaux). +10. WHEN l'utilisateur enregistre un nouveau Véhicule, THE Frontend SHALL exiger la saisie d'une Date_Effet inférieure ou égale à la date du jour. +11. IF l'utilisateur saisit une Date_Effet postérieure à la date du jour, THEN THE Frontend SHALL rejeter la saisie et afficher un message indiquant que la date d'effet ne peut pas être dans le futur. +12. WHEN le Backend enregistre un nouveau Véhicule, THE Backend SHALL conserver l'historique complet des Véhicules précédents sans les supprimer. +13. THE Backend SHALL déterminer le Véhicule_Courant comme étant le Véhicule dont la Date_Effet est la plus récente parmi celles inférieures ou égales à la date du jour. +14. WHEN l'utilisateur crée un nouveau Trajet, THE Backend SHALL associer par défaut ce Trajet à la Puissance_Fiscale du Véhicule_Courant à la date du Trajet et stocker cette Puissance_Fiscale sur le Trajet. +15. WHEN l'utilisateur enregistre un nouveau Véhicule avec une Date_Effet donnée, IF il existe des Trajets déjà saisis dont la date est supérieure ou égale à cette Date_Effet et qui sont associés à une Puissance_Fiscale différente de celle du nouveau Véhicule, THEN THE Frontend SHALL proposer à l'utilisateur la Réaffectation de ces Trajets à la Puissance_Fiscale du nouveau Véhicule. +16. WHEN l'utilisateur accepte la Réaffectation, THE Backend SHALL mettre à jour la Puissance_Fiscale (et l'association au Véhicule) de tous les Trajets concernés (date >= Date_Effet) avec celle du nouveau Véhicule. +17. WHEN l'utilisateur refuse la Réaffectation, THE Backend SHALL laisser inchangés les Trajets déjà saisis et associer uniquement les prochains Trajets au nouveau Véhicule. +18. THE Backend SHALL exposer la liste de l'historique des Véhicules avec leur plaque, leur Puissance_Fiscale et leur Date_Effet. + +--- + +### Requirement 11: Insertion d'un trajet entre deux saisies existantes + +**User Story:** En tant qu'utilisateur, je veux insérer un nouveau trajet entre deux trajets existants d'une même journée, afin de compléter une journée de déplacements en conservant la continuité des adresses. + +#### Acceptance Criteria + +1. WHEN l'utilisateur demande l'insertion d'un Trajet entre deux Trajets existants d'une même journée (Trajet_Précédent et Trajet_Suivant), THE Frontend SHALL afficher le formulaire de saisie avec le champ adresse de départ pré-rempli avec l'adresse d'arrivée du Trajet_Précédent et le champ adresse d'arrivée pré-rempli avec l'adresse de départ du Trajet_Suivant. +2. WHEN l'utilisateur choisit d'insérer un Trajet après un Trajet_Précédent sans Trajet_Suivant connu, THE Frontend SHALL afficher le formulaire de saisie avec le champ adresse de départ pré-rempli avec l'adresse d'arrivée du Trajet_Précédent et le champ adresse d'arrivée vide. +3. WHEN l'utilisateur choisit d'insérer un Trajet avant un Trajet_Suivant sans Trajet_Précédent connu, THE Frontend SHALL afficher le formulaire de saisie avec le champ adresse d'arrivée pré-rempli avec l'adresse de départ du Trajet_Suivant et le champ adresse de départ vide. +4. THE Frontend SHALL permettre à l'utilisateur de modifier les adresses de départ et d'arrivée pré-remplies avant de soumettre le formulaire d'insertion. +5. WHEN l'utilisateur soumet le formulaire d'insertion avec tous les champs obligatoires valides, THE Backend SHALL créer le nouveau Trajet avec les mêmes règles de validation et de calcul de distance que la création standard (Exigence 1 et 2). +6. WHEN un Trajet est inséré avec succès, THE Frontend SHALL rafraîchir l'affichage de la liste des Trajets de la journée sans rechargement complet de la page, en positionnant le nouveau Trajet à sa place chronologique dans la liste. +7. THE Backend SHALL maintenir pour chaque Trajet un champ Position (entier positif) représentant son rang dans la liste des Trajets d'une même journée, indépendamment de l'heure de création du Trajet. +8. WHEN l'utilisateur insère un Trajet entre un Trajet_Précédent de position P et un Trajet_Suivant de position P+1 ou plus, THE Backend SHALL attribuer au Trajet inséré une Position comprise entre P et P+1 (valeur fractionnaire ou réordonnancement) de sorte que la liste des Trajets de la journée soit triée par Position croissante et que le Trajet inséré apparaisse bien entre les deux trajets existants. +9. WHEN l'utilisateur réordonne manuellement les Trajets d'une journée (glisser-déposer), THE Backend SHALL mettre à jour les valeurs de Position des Trajets concernés de manière à refléter le nouvel ordre sans modifier les autres données du Trajet. +10. THE Frontend SHALL proposer, dans le formulaire d'insertion, pour le champ adresse de départ et pour le champ adresse d'arrivée (y compris lorsqu'ils sont pré-remplis à partir du Trajet_Précédent ou du Trajet_Suivant), un choix entre sélectionner une Adresse_Nommee pré-enregistrée du Carnet_Adresses (par sa Dénomination) et saisir une nouvelle adresse manuellement avec l'autocomplétion BAN. +11. WHEN l'utilisateur sélectionne une Adresse_Nommee (par sa Dénomination) dans le formulaire d'insertion pour le champ adresse de départ ou adresse d'arrivée, THE Frontend SHALL remplacer le contenu pré-rempli de ce champ par le texte d'adresse de l'Adresse_Nommee et associer ses coordonnées géographiques (latitude, longitude) pour transmission au Backend. + +--- + +### Requirement 12: Navigation temporelle et mise en page + +**User Story:** En tant qu'utilisateur, je veux naviguer facilement entre les années, mois et journées ayant des données, et voir en permanence les totaux et la carte, afin de consulter et saisir mes trajets de façon fluide. + +#### Acceptance Criteria + +1. THE Frontend SHALL afficher un Sélecteur_Temporel permettant de choisir successivement une année, puis un mois, puis une journée. +2. THE Frontend SHALL proposer dans le sélecteur d'année uniquement l'année courante et les années pour lesquelles au moins un Trajet est enregistré. +3. WHEN l'utilisateur sélectionne une année, THE Frontend SHALL afficher les douze mois de cette année et mettre en surbrillance les mois comportant au moins un Trajet. +4. WHEN l'utilisateur sélectionne un mois, THE Frontend SHALL afficher les jours de ce mois et mettre en surbrillance les jours comportant au moins un Trajet. +5. THE Frontend SHALL empêcher la sélection de toute date postérieure à la date courante (aucune date future sélectionnable). +6. THE Frontend SHALL afficher la zone des Trajets de la journée sélectionnée comme zone principale, avec défilement vertical (scroll) lorsque le nombre de Trajets dépasse la hauteur visible. +7. THE Frontend SHALL afficher un Panneau_Totaux latéral toujours visible contenant : le total kilométrique du jour sélectionné, le total kilométrique du mois sélectionné, et le Total_Annuel_Partiel de l'année sélectionnée. +8. THE Panneau_Totaux SHALL calculer le Total_Annuel_Partiel en cumulant uniquement les Trajets de l'année sélectionnée jusqu'au mois sélectionné inclus, sans inclure les mois postérieurs au mois sélectionné. +9. THE Frontend SHALL afficher, sous le Panneau_Totaux, la Carte des Trajets de la journée sélectionnée. +10. THE Frontend SHALL afficher un bouton "Jour précédent" et un bouton "Jour suivant" permettant de naviguer d'une journée à l'autre. +11. WHEN l'utilisateur clique sur "Jour précédent", THE Frontend SHALL sélectionner la journée calendaire immédiatement antérieure et rafraîchir la zone des Trajets, le Panneau_Totaux et la Carte. +12. WHEN l'utilisateur clique sur "Jour suivant", THE Frontend SHALL sélectionner la journée calendaire immédiatement postérieure et rafraîchir la zone des Trajets, le Panneau_Totaux et la Carte. +13. IF la journée suivante est postérieure à la date courante, THEN THE Frontend SHALL désactiver le bouton "Jour suivant". +14. WHEN l'utilisateur change de jour, de mois ou d'année via le Sélecteur_Temporel ou les boutons de navigation, THE Frontend SHALL mettre à jour la zone des Trajets, le Panneau_Totaux et la Carte dans un délai de 1000 ms sans rechargement complet de la page. +15. WHEN le Backend reçoit une requête des années disponibles, THE Backend SHALL retourner la liste des années comportant au moins un Trajet, ainsi que l'année courante si elle n'y figure pas déjà. +16. WHEN le Backend reçoit une requête des jours comportant des Trajets pour un mois et une année donnés, THE Backend SHALL retourner la liste des jours de ce mois pour lesquels au moins un Trajet est enregistré. +17. THE Frontend SHALL afficher un sélecteur de Véhicule permettant de choisir le Véhicule dont les données sont affichées ; par défaut, le Véhicule_Courant est sélectionné. +18. WHEN l'utilisateur sélectionne un Véhicule dans le sélecteur de Véhicule, THE Frontend SHALL filtrer la zone des Trajets, le Panneau_Totaux et la Carte pour n'afficher que les Trajets associés à ce Véhicule. +19. THE Panneau_Totaux SHALL calculer le total du jour, le total du mois et le Total_Annuel_Partiel uniquement à partir des Trajets associés au Véhicule sélectionné. +20. WHEN l'utilisateur change de Véhicule sélectionné, THE Frontend SHALL mettre à jour la zone des Trajets, le Panneau_Totaux et la Carte dans un délai de 1000 ms sans rechargement complet de la page. + +--- + +### Requirement 13: Barème kilométrique et indication de versement + +**User Story:** En tant qu'utilisateur, je veux saisir le barème kilométrique officiel par année et par puissance fiscale, et voir une indication de versement mensuel, afin d'estimer mes remboursements en cours d'année. + +#### Acceptance Criteria + +1. THE Frontend SHALL afficher un menu de saisie du Bareme_Kilometrique par année, permettant de saisir, pour chaque Puissance_Fiscale, les trois tranches de kilométrage : jusqu'à 5 000 km, de 5 001 à 20 000 km, et au-delà de 20 000 km, chacune avec son coefficient (€/km) et sa base (€). +2. WHEN l'utilisateur enregistre une Tranche_Bareme, THE Backend SHALL persister la tranche associée à l'année, à la Puissance_Fiscale et à la borne de kilométrage concernées. +3. WHEN le Backend applique le Bareme_Kilometrique au total kilométrique annuel d'un Véhicule pour une année donnée, THE Backend SHALL sélectionner la tranche dont l'intervalle de kilométrage contient le total annuel, puis calculer le montant selon la formule `montant = total_km_annuel × coefficient + base`. +4. IF aucune Tranche_Bareme n'existe pour la Puissance_Fiscale et l'année d'un calcul, THEN THE Backend SHALL retourner le montant zéro et signaler que le barème n'est pas configuré pour cette combinaison. +5. WHEN l'utilisateur consulte les données d'un mois, THE Frontend SHALL afficher à titre indicatif le Montant_Previsionnel, calculé comme 80% du Bareme_Kilometrique appliqué au Total_Annuel_Partiel du Véhicule sélectionné. +6. THE Frontend SHALL indiquer explicitement que le Montant_Previsionnel (80%) est une simple indication de versement et que le montant réellement versé est celui saisi par l'utilisateur (Montant_Regle). +7. THE Frontend SHALL afficher tous les montants en euros avec une précision de deux décimales. +8. THE Frontend SHALL afficher, dans l'en-tête de chaque feuille mensuelle, le Coefficient_Base_Vehicule (coefficient et base de la tranche applicable selon la puissance fiscale du Véhicule et le total kilométrique annuel). + +--- + +### Requirement 14: Montant réglé mensuel et régularisation annuelle + +**User Story:** En tant qu'utilisateur, je veux saisir le montant réellement versé chaque mois et voir la régularisation annuelle, afin de connaître le solde restant à verser en fin d'année. + +#### Acceptance Criteria + +1. THE Frontend SHALL permettre à l'utilisateur de saisir un Montant_Regle (valeur numérique positive en euros, deux décimales) pour un mois et une année donnés, associé à un Véhicule ; ce montant est saisi librement et peut différer d'un mois à l'autre. +2. WHEN l'utilisateur enregistre un Montant_Regle, THE Backend SHALL persister ce montant associé au mois, à l'année et au Véhicule concernés. +3. IF un Montant_Regle existe déjà pour le même mois, la même année et le même Véhicule, THEN THE Backend SHALL mettre à jour le montant existant plutôt que d'en créer un nouveau. +4. IF l'utilisateur saisit un Montant_Regle négatif, THEN THE Backend SHALL rejeter la saisie avec une erreur HTTP 400. +5. WHEN le Backend calcule le Montant_Reel annuel d'un Véhicule, THE Backend SHALL appliquer le Bareme_Kilometrique plein (100%) au total kilométrique annuel du Véhicule, en utilisant la tranche déterminée par ce total annuel. +6. WHEN le Backend calcule le Frais_Reel_Mensuel de chaque mois, THE Backend SHALL multiplier les kilomètres du mois par le coefficient de la tranche annuelle applicable, de sorte que la somme des douze Frais_Reel_Mensuel plus la base de la tranche annuelle soit égale au Montant_Reel annuel. +7. WHEN le Backend calcule la Regularisation d'un Véhicule pour une année, THE Backend SHALL soustraire le total des Montant_Regle de l'année du Montant_Reel annuel : `Regularisation = Montant_Reel − Total_Regle`. +8. THE Frontend SHALL afficher le Montant_Regle, le Frais_Reel_Mensuel, le Montant_Reel annuel, le total réglé et la Regularisation avec une précision de deux décimales. + +--- + +### Requirement 15: Carnet d'adresses nommées + +**User Story:** En tant qu'utilisateur, je veux gérer un carnet d'adresses nommées (créer, lister, modifier, supprimer) et pouvoir réutiliser ces adresses par leur dénomination lors de la saisie d'un trajet, afin de simplifier et d'accélérer la saisie de mes déplacements. + +#### Acceptance Criteria + +1. THE Frontend SHALL afficher un menu dédié au Carnet_Adresses permettant à l'utilisateur de créer, lister, modifier et supprimer des Adresses_Nommées. +2. THE Frontend SHALL afficher, pour la création ou la modification d'une Adresse_Nommee, un champ Dénomination (texte libre, 1 à 100 caractères) et un champ adresse avec autocomplétion BAN (texte libre, 1 à 255 caractères). +3. WHEN l'utilisateur saisit au moins 3 caractères dans le champ adresse d'une Adresse_Nommee, THE Frontend SHALL interroger l'API BAN après un délai d'inactivité de 300 millisecondes et afficher au maximum 5 suggestions. +4. WHEN l'utilisateur sélectionne une suggestion de l'API BAN lors de la saisie du champ adresse d'une Adresse_Nommee, THE Frontend SHALL pré-remplir le champ avec le libellé normalisé et associer les coordonnées géographiques (latitude, longitude) pour transmission au Backend. +5. WHEN le Backend reçoit une Adresse_Nommee valide, THE Backend SHALL persister en base de données la Dénomination, le texte d'adresse et, lorsqu'elles sont disponibles, les coordonnées géographiques (latitude, longitude). +6. IF le Backend reçoit une Adresse_Nommee dont la Dénomination est absente, vide ou dépasse 100 caractères, THEN THE Backend SHALL retourner une erreur HTTP 400 avec un message indiquant que la Dénomination est absente ou invalide, sans persister l'Adresse_Nommee. +7. IF le Backend reçoit une Adresse_Nommee dont la Dénomination est identique, après normalisation (suppression des espaces de début et de fin, comparaison insensible à la casse et aux accents), à celle d'une Adresse_Nommee déjà enregistrée, THEN THE Backend SHALL retourner une erreur HTTP 400 avec un message indiquant que la Dénomination est déjà utilisée, sans persister l'Adresse_Nommee. +8. IF le Backend reçoit une Adresse_Nommee dont le texte d'adresse est absent, vide ou dépasse 255 caractères, THEN THE Backend SHALL retourner une erreur HTTP 400 avec un message indiquant que l'adresse est absente ou invalide, sans persister l'Adresse_Nommee. +9. WHEN le Backend persiste une Adresse_Nommee dont les coordonnées géographiques sont disponibles, THE Backend SHALL enregistrer le couple (libellé normalisé, latitude, longitude) dans le Cache_Adresses s'il n'y est pas déjà présent, afin que cette adresse soit réutilisable et mise en cache. +10. WHEN le Backend reçoit une Adresse_Nommee dont le texte d'adresse correspond, après normalisation (suppression des espaces de début et de fin, comparaison insensible à la casse et aux accents), à une entrée du Cache_Adresses, THE Backend SHALL réutiliser les coordonnées du Cache_Adresses sans appeler l'API BAN. +11. WHEN l'utilisateur modifie le texte d'adresse d'une Adresse_Nommee existante et enregistre, et que ce texte ne correspond à aucune entrée du Cache_Adresses après normalisation, THE Backend SHALL re-géocoder la nouvelle adresse via l'API BAN dans un délai maximal de 5 secondes afin de rafraîchir les coordonnées géographiques (latitude, longitude) de l'Adresse_Nommee. +12. IF l'API BAN est indisponible ou ne répond pas dans le délai de 5 secondes lors du géocodage ou du re-géocodage d'une Adresse_Nommee, THEN THE Backend SHALL persister l'Adresse_Nommee sans coordonnées géographiques et retourner une réponse indiquant que les coordonnées n'ont pas pu être obtenues. +13. WHEN l'utilisateur demande la suppression d'une Adresse_Nommee, THE Frontend SHALL afficher une demande de confirmation avant d'envoyer la requête de suppression au Backend. +14. WHEN l'utilisateur confirme la suppression d'une Adresse_Nommee, THE Backend SHALL supprimer l'Adresse_Nommee du Carnet_Adresses en conservant inchangés les Trajets déjà enregistrés, qui gardent leur texte d'adresse et leurs coordonnées géographiques stockés. +15. WHEN le Backend a créé, modifié ou supprimé une Adresse_Nommee avec succès, THE Frontend SHALL afficher un message de confirmation indiquant que l'opération a réussi. +16. WHEN le Frontend affiche le formulaire de saisie ou d'insertion d'un Trajet, THE Frontend SHALL proposer la liste des Adresses_Nommées du Carnet_Adresses par leur Dénomination pour le champ adresse de départ et pour le champ adresse d'arrivée. + +--- + +### Requirement 16: Initialisation du profil à la première utilisation + +**User Story:** En tant qu'utilisateur, je veux que l'application me présente la saisie de mon profil dès la première connexion, afin de configurer mon identité et mon véhicule avant de commencer à saisir mes trajets. + +#### Acceptance Criteria + +1. THE Backend SHALL considérer le Profil comme non configuré tant qu'aucun Profil n'est enregistré avec un nom et un prénom non vides, ou tant qu'aucun Véhicule (plaque, Puissance_Fiscale, Date_Effet) n'est enregistré. +2. THE Backend SHALL exposer un indicateur de configuration du Profil (`estConfigure`) valant vrai lorsque le Profil est un Profil_Configure et faux dans le cas contraire. +3. WHEN l'utilisateur ouvre l'Application, WHILE le Profil est non configuré, THE Frontend SHALL présenter le parcours d'Onboarding (formulaire de saisie du Profil) au lieu de la Vue_Consultation. +4. WHILE le Profil est non configuré, THE Frontend SHALL bloquer l'accès à la saisie et à la consultation des Trajets et maintenir l'affichage du parcours d'Onboarding. +5. THE Frontend SHALL exiger, dans le parcours d'Onboarding, la saisie du nom (1 à 100 caractères), du prénom (1 à 100 caractères) et du matricule (1 à 8 caractères) avant d'autoriser la validation du Profil. +6. THE Frontend SHALL exiger, dans le parcours d'Onboarding, la saisie d'au moins un Véhicule comportant une plaque d'immatriculation (1 à 15 caractères), une Puissance_Fiscale (entier positif) et une Date_Effet inférieure ou égale à la date du jour avant d'autoriser la validation du Profil. +7. THE Frontend SHALL présenter, dans le parcours d'Onboarding, un champ Adresse_Domicile optionnel (texte libre, 1 à 255 caractères) que l'utilisateur peut renseigner immédiatement ou compléter ultérieurement via la page de profil. +8. IF l'utilisateur tente de valider le parcours d'Onboarding alors qu'un champ d'identité obligatoire (nom, prénom, matricule) est manquant ou invalide, ou qu'aucun Véhicule valide n'est saisi, THEN THE Frontend SHALL afficher un message d'erreur identifiant chaque élément manquant ou invalide sans soumettre les données au Backend. +9. WHEN l'utilisateur valide le parcours d'Onboarding avec un Profil et au moins un Véhicule valides, THE Backend SHALL persister le Profil et le Véhicule et retourner un indicateur `estConfigure` valant vrai. +10. WHEN le Backend confirme l'enregistrement d'un Profil_Configure, THE Frontend SHALL marquer le Profil comme configuré et naviguer vers la Vue_Consultation dans un délai de 1000 ms. +11. IF le Profil est déjà un Profil_Configure lors de l'ouverture de l'Application, THEN THE Frontend SHALL afficher directement la Vue_Consultation sans présenter le parcours d'Onboarding. +12. IF la persistance du Profil ou du Véhicule échoue au niveau du Backend lors de la soumission du parcours d'Onboarding, THEN THE Frontend SHALL afficher un message d'erreur indiquant l'échec de l'enregistrement, ne pas marquer le Profil comme configuré et conserver les données saisies par l'utilisateur. +13. IF la récupération de l'indicateur `estConfigure` échoue lors de l'ouverture de l'Application, THEN THE Frontend SHALL afficher un message d'erreur indiquant un problème de connexion au serveur et proposer de réessayer, sans présenter la Vue_Consultation. diff --git a/.kiro/specs/trajet-km-tracker/tasks.md b/.kiro/specs/trajet-km-tracker/tasks.md new file mode 100644 index 0000000..1d45f11 --- /dev/null +++ b/.kiro/specs/trajet-km-tracker/tasks.md @@ -0,0 +1,432 @@ +# Implementation Plan: SchotzIAD KM Tracker + +## Overview + +Ce plan décompose la conception en tâches de codage incrémentales. Le backend (ASP.NET Core / C# / EF Core / SQLite) est construit d'abord — entités, calculs métier, intégrations BAN/ORS, PDF — puis le frontend Angular consomme l'API. Chaque tâche s'appuie sur les précédentes et se termine par l'intégration, sans code orphelin. Les sous-tâches de test (unitaire, propriété, intégration) sont marquées optionnelles par `*` et référencent explicitement les propriétés de correction et exigences validées. + +Langage backend : **C# (ASP.NET Core, .NET 8)**. Langage frontend : **TypeScript (Angular)**. Tests : **xUnit + FsCheck + FluentAssertions + Moq** (backend), **Jest + Angular Testing Library** (frontend). + +## Tasks + +- [x] 1. Initialiser la solution et le socle backend + - Créer la solution .NET et le projet `TrajetKmTracker.Api` (ASP.NET Core Web API, .NET 8) + - Créer le projet de tests `TrajetKmTracker.Tests` (xUnit) avec les dépendances FsCheck, FluentAssertions, Moq, Microsoft.AspNetCore.Mvc.Testing + - Ajouter les packages : EF Core + provider SQLite, FluentValidation.AspNetCore, QuestPDF + - Configurer `Program.cs` : services de base, Swagger, middleware `ProblemDetails`, CORS pour le frontend Angular + - _Requirements: 6.1_ + +- [x] 2. Modèle de données et contexte EF Core + - [x] 2.1 Définir les entités du domaine + - Créer `Trajet` (Date, AdresseDepart, AdresseArrivee, Distance, Commentaire, Position REAL, VehiculeId, PuissanceFiscale dénormalisée, CreatedAt/UpdatedAt) + - Créer `Vehicule` (Plaque, PuissanceFiscale, DateEffet), `Profil` (singleton Id=1, Nom/Prenom/Matricule/AdresseDomicile + coordonnées + DomicileGeocode) + - Créer `AdresseCache` (Adresse unique, Latitude/Longitude, GeometrieJson, DerniereUtilisation), `TrancheBareme` (Annee, PuissanceFiscale, KmMin, KmMax, Coefficient, Base), `MontantRegle` (Annee, Mois, VehiculeId, Montant) + - Créer `AdresseNommee` (Denomination unique 100 car., DenominationNormalisee, Adresse 255 car., Latitude?, Longitude?, GeocodageReussi, CreatedAt/UpdatedAt) + - _Requirements: 6.1, 6.2, 9.6, 10.1, 10.9, 13.1, 14.1, 15.4, 15.5_ + + - [x] 2.2 Configurer `AppDbContext` et les migrations + - Déclarer les `DbSet`, contraintes (CHECK Distance > 0, Montant >= 0, PuissanceFiscale > 0), index (Date, Date+Position, VehiculeId, DateEffet, Annee+PuissanceFiscale) et index uniques (Profil singleton, MontantsRegles par période/véhicule, AdresseCache par adresse) + - Ajouter la table `AdressesNommees` avec l'index unique `UX_AdresseNommee_Denomination` sur `DenominationNormalisee` + - Générer la migration initiale et l'appliquer à `trajets.db` + - _Requirements: 6.1, 6.2, 9.6, 14.3, 15.6_ + + - [ ]* 2.3 Écrire les tests d'intégration du schéma + - Vérifier la création de la base, les contraintes CHECK et les index uniques (rejet des doublons de période/véhicule) + - _Requirements: 6.1, 14.3_ + +- [x] 3. DTOs, options de configuration et validateurs + - [x] 3.1 Créer les DTOs et enregistrements + - `CreateTrajetDto`, `UpdateTrajetDto`, `TrajetDto`, `TrajetCarteDto`, `ReordonnerTrajetsDto`, `TrajetPositionDto` + - `ResumeMensuelDto`, `ResumeAnnuelDto`, `TotauxDto`, `AdresseCacheDto`, `BANAdresseDto` + - `ProfilDto`, `SaveProfilDto`, `VehiculeDto`, `CreateVehiculeDto`, `VehiculeCreeDto`, `ReaffectationDto` + - `TrancheBaremeDto`, `SaveTrancheBaremeDto`, `MontantRegleDto`, `MontantPrevisionnelDto`, `LigneSyntheseMoisDto`, `RegularisationAnnuelleDto` + - `AdresseNommeeDto`, `SaveAdresseNommeeDto` + - `EtatProfilDto` (EstConfigure) + - Créer `BANOptions` et `ORSOptions`, les lier depuis `appsettings.json` + - _Requirements: 1.1, 1.10, 2.1, 15.4, 15.5_ + + - [x] 3.2 Implémenter les validateurs FluentValidation + - `CreateTrajetDtoValidator` / `UpdateTrajetDtoValidator` : date obligatoire au format JJ/MM/AAAA et existante, adresses 1–255 car., commentaire ≤ 500 car. + - `SaveProfilDtoValidator` (matricule ≤ 8 car.), `CreateVehiculeDtoValidator` (DateEffet ≤ aujourd'hui), `MontantRegleDto` (montant ≥ 0) + - `SaveAdresseNommeeDtoValidator` : dénomination 1–100 car. (absente/vide/>100 → 15.6), adresse 1–255 car. (absente/vide/>255 → 15.8) ; l'unicité normalisée (doublon → 15.7) est vérifiée côté service + - _Requirements: 1.4, 1.5, 1.6, 1.7, 1.12, 6.3, 10.2, 10.11, 14.4, 15.6, 15.7, 15.8_ + + - [ ]* 3.3 Écrire les tests unitaires des validateurs + - **Property 4 : Rejet systématique des données d'entrée invalides (backend)** + - **Validates: Requirements 1.5, 1.6, 1.7, 6.3** + +- [x] 4. Intégrations externes : BAN et ORS + - [x] 4.1 Implémenter `IBANClient` / `BANClient` + - Appel `GET /search/?q={query}&limit=5` avec timeout configurable ; mapper vers `BANAdresseDto` ; lever `BANIndisponibleException` en cas d'échec/timeout + - _Requirements: 1.13, 2.4, 9.1_ + + - [x] 4.2 Implémenter `IORSClient` / `ORSClient` + - Appel `POST /v2/directions/driving-car` avec coordonnées connues, extraction distance (km) + géométrie GeoJSON, timeout 10 s ; ne jamais appeler ORS avec des coordonnées nulles + - _Requirements: 2.1, 2.2, 8.7_ + + - [x] 4.3 Implémenter `IDistanceCalculator` / `DistanceCalculatorService` + - Coordonnées connues → ORS ; coordonnées manquantes → géocodage BAN puis ORS ; échec ORS/timeout → fallback Haversine ; échec BAN total → distance nulle + avertissement + - Implémenter `HaversineKm`, arrondi 2 décimales, borne 0,01–99 999,99 km + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.6_ + + - [ ]* 4.4 Écrire le test de propriété de priorité BAN sur ORS + - **Property 16 : Priorité BAN sur ORS pour la résolution des coordonnées** + - **Validates: Requirements 2.4** + + - [ ]* 4.5 Écrire les tests unitaires BAN/ORS/Distance + - BAN succès/non trouvé/timeout, ORS succès/timeout → Haversine, coordonnées manquantes → BAN puis ORS + - _Requirements: 2.1, 2.2, 2.3, 2.6_ + +- [x] 5. Cache des adresses géocodées + - [x] 5.1 Implémenter `AdresseCacheService` et le repository associé + - Recherche locale (min 3 car.), upsert d'une adresse (coordonnées inchangées si existante, mise à jour de `DerniereUtilisation`), éviction LRU au-delà de 1 000 entrées + - _Requirements: 9.1, 9.2, 9.3, 9.5, 9.6_ + + - [ ]* 5.2 Écrire le test de propriété de cohérence du cache + - **Property 10 : Cohérence du cache adresses après géocodage** + - **Validates: Requirements 9.1, 9.2** + + - [ ]* 5.3 Écrire le test de propriété LRU + - **Property 11 : Politique LRU du cache adresses** + - **Validates: Requirements 9.5** + +- [x] 6. Gestion des véhicules et du profil + - [x] 6.1 Implémenter `VehiculeService` et le repository véhicules + - CRUD historique, détermination du Véhicule_Courant (DateEffet max ≤ aujourd'hui), rejet DateEffet future, conservation de l'historique + - Détection des trajets à réaffecter (date ≥ DateEffet et PuissanceFiscale différente) → `VehiculeCreeDto.ReaffectationProposee` + - Réaffectation acceptée (mise à jour VehiculeId + PuissanceFiscale des trajets ≥ DateEffet) / refusée (aucun changement), en transaction + - _Requirements: 10.9, 10.10, 10.11, 10.12, 10.13, 10.15, 10.16, 10.17, 10.18_ + + - [ ]* 6.2 Écrire le test de propriété d'association du véhicule courant + - **Property 19 : Association du véhicule courant par défaut** + - **Validates: Requirements 10.13, 10.14** + + - [ ]* 6.3 Écrire le test de propriété de réaffectation bornée + - **Property 20 : Réaffectation bornée à la date d'effet** + - **Validates: Requirements 10.16, 10.17** + + - [ ]* 6.4 Écrire le test de propriété de conservation de l'historique + - **Property 22 : Conservation de l'historique des véhicules** + - **Validates: Requirements 10.12, 10.18** + + - [x] 6.5 Implémenter `ProfilService` et le controller `ProfilController` + - GET/PUT profil singleton, géocodage BAN de l'Adresse_Domicile (DomicileGeocode true/false), mise en cache, validation matricule ≤ 8 car. + - Exposer `GET /api/vehicules`, `GET /api/vehicules/courant`, `POST /api/vehicules`, `POST /api/vehicules/reaffectation` + - Exposer `GET /api/profil/etat` retournant `EtatProfilDto(EstConfigure)` (true ssi Profil Nom+Prénom non vides ET au moins un Véhicule) + - _Requirements: 10.1, 10.2, 10.3, 10.7, 10.8, 10.18, 16.1, 16.2_ + + - [ ]* 6.6 Écrire le test de propriété de cohérence du profil domicile + - **Property 12 : Cohérence du profil domicile** + - **Validates: Requirements 10.2, 10.7** + + - [ ]* 6.7 Écrire le test de propriété de détermination de l'état de configuration du profil + - **Property 31 : Détermination de l'état de configuration du profil** + - **Validates: Requirements 16.1, 16.2** + +- [x] 7. Checkpoint — S'assurer que tous les tests passent + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Service et API des trajets (CRUD + positions) + - [x] 8.1 Implémenter `TrajetRepository` et la gestion des positions + - Résolution du véhicule à la date du trajet et copie de la PuissanceFiscale (dénormalisation) + - Attribution de Position : fin de journée `max(Position)+1.0`, insertion `(P1+P2)/2`, renumérotation par pas de 1.0 si précision < 0.001 (transaction) + - _Requirements: 10.14, 11.7, 11.8_ + + - [x] 8.2 Implémenter `TrajetService` (création) + - Validation, association véhicule courant si VehiculeId absent, calcul de distance via `DistanceCalculatorService`, mise en cache des adresses résolues, persistance transactionnelle + - Gérer distance nulle + avertissement quand aucune coordonnée n'est résoluble + - _Requirements: 1.2, 1.3, 1.11, 2.4, 2.5, 6.3, 6.7, 6.8, 10.14_ + + - [ ]* 8.3 Écrire le test de propriété de précision des distances + - **Property 3 : Précision de stockage des distances** + - **Validates: Requirements 2.2, 2.5, 6.2** + + - [ ]* 8.4 Écrire le test de propriété de persistance du commentaire + - **Property 13 : Persistance optionnelle du commentaire** + - **Validates: Requirements 1.11, 1.12** + + - [x] 8.5 Implémenter `TrajetService` (modification, suppression, réordonnancement) + - Modification : recalcul distance uniquement si adresse modifiée, commentaire seul sans recalcul, rollback si recalcul échoue, 404 si inexistant + - Suppression transactionnelle ; `PATCH /api/trajets/reordonner` met à jour les Position sans altérer les autres champs + - _Requirements: 5.2, 5.3, 5.5, 5.6, 5.8, 5.9, 5.10, 6.7, 6.8, 11.9_ + + - [ ]* 8.6 Écrire le test de propriété de recalcul conditionnel + - **Property 6 : Recalcul conditionnel de la distance** + - **Validates: Requirements 5.2, 5.3** + + - [ ]* 8.7 Écrire les tests de propriété sur les positions + - **Property 14 : Cohérence de l'ordre des trajets d'une journée** + - **Property 15 : Unicité et cohérence des positions au sein d'une journée** + - **Validates: Requirements 11.7, 11.8, 11.9** + + - [x] 8.8 Implémenter `TrajetsController` et brancher le controller adresses + - `POST/GET/PUT/DELETE /api/trajets`, `PATCH /api/trajets/reordonner`, `GET /api/trajets/journee/{date}` (avec géométries), `GET /api/adresses/search` + - _Requirements: 1.3, 5.5, 5.6, 8.7, 9.3_ + + - [ ]* 8.9 Écrire le test de propriété d'invariant de suppression + - **Property 5 : Invariant de suppression** + - **Validates: Requirements 5.5, 6.4** + +- [x] 9. Résumés, totaux et navigation temporelle + - [x] 9.1 Implémenter `ResumeService` (mensuel et annuel) + - Résumé mensuel filtré par véhicule, trié par date puis Position, total 2 décimales ; validation mois [1,12] et année [2000,2100] + - Résumé annuel : toujours 12 mois (0 si vide), total = somme des 12 mois + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.6, 3.8, 4.2, 4.3, 4.4, 4.7, 6.5, 6.6_ + + - [ ]* 9.2 Écrire les tests de propriété des totaux et résumés + - **Property 1 : Cohérence du total mensuel** — **Validates: Requirements 3.3, 6.5** + - **Property 2 : Cohérence du total annuel** — **Validates: Requirements 4.3, 6.6** + - **Property 7 : Mois toujours présents dans le résumé annuel** — **Validates: Requirements 4.2, 4.4** + - **Property 8 : Rejet des paramètres de résumé hors plage** — **Validates: Requirements 3.6** + + - [x] 9.3 Implémenter `TotauxService` et la navigation temporelle + - Totaux jour/mois/Total_Annuel_Partiel filtrés par véhicule (cumul jusqu'au mois inclus, exclusion des mois postérieurs) + - Années disponibles (données + année courante), mois disponibles, jours disponibles + - _Requirements: 12.7, 12.8, 12.15, 12.16, 12.19_ + + - [ ]* 9.4 Écrire les tests de propriété des totaux filtrés et partiels + - **Property 17 : Total annuel partiel borné au mois sélectionné** — **Validates: Requirements 12.8** + - **Property 21 : Totaux filtrés par véhicule** — **Validates: Requirements 12.19** + + - [x] 9.5 Implémenter `ResumesController` et les controllers de navigation + - `GET /api/resumes/mensuel/{annee}/{mois}`, `GET /api/resumes/annuel/{annee}` + - `GET /api/annees-disponibles`, `/api/mois-disponibles/{annee}`, `/api/jours-disponibles/{annee}/{mois}`, `/api/totaux/{date}` + - _Requirements: 3.8, 4.1, 12.15, 12.16_ + +- [x] 10. Barème kilométrique et régularisation + - [x] 10.1 Implémenter `BaremeService` et le controller barème + - CRUD des tranches, validation du non-chevauchement par (année, puissance fiscale) + - Sélection de tranche contenant le total annuel, formule `km × Coefficient + Base`, barème non configuré → montant zéro + avertissement + - `GET /api/bareme/{annee}`, `POST/PUT/DELETE /api/bareme` + - _Requirements: 13.1, 13.2, 13.3, 13.4_ + + - [ ]* 10.2 Écrire les tests de propriété de sélection de tranche + - **Property 24 : Sélection de tranche du barème** — **Validates: Requirements 13.3, 13.4** + - **Property 27 : Sélection de tranche par le total annuel** — **Validates: Requirements 13.3, 14.6** + + - [x] 10.3 Implémenter `MontantRegleService` (upsert et montant prévisionnel) + - Upsert par (année, mois, véhicule), rejet montant négatif + - Montant_Previsionnel = 80% du barème sur le Total_Annuel_Partiel (indicatif, hors régularisation) + - _Requirements: 13.5, 13.6, 14.1, 14.2, 14.3, 14.4_ + + - [ ]* 10.4 Écrire les tests de propriété montants réglés / prévisionnel + - **Property 23 : Indication de versement à 80% (informative)** — **Validates: Requirements 13.5, 13.6** + - **Property 26 : Unicité du montant réglé par période et véhicule** — **Validates: Requirements 14.3** + + - [x] 10.5 Implémenter `RemboursementService` (régularisation annuelle) + - Montant_Reel = barème plein 100% sur total annuel, Frais_Reel_Mensuel = km_mois × coef tranche annuelle, Regularisation = Montant_Reel − total réglé + - Construire `RegularisationAnnuelleDto` (12 lignes + totaux) + - `GET /api/montant-previsionnel/...`, `GET/PUT /api/montants-regles`, `GET /api/regularisation/{annee}` + - _Requirements: 14.5, 14.6, 14.7, 14.8_ + + - [ ]* 10.6 Écrire le test de propriété de régularisation + - **Property 25 : Cohérence de la régularisation annuelle (barème plein)** + - **Validates: Requirements 13.3, 14.5, 14.6, 14.7** + +- [x] 11. Export PDF (QuestPDF) + - [x] 11.1 Implémenter `PdfService` — rapport mensuel + - En-tête : identité (nom, prénom, matricule), véhicule (plaque, puissance fiscale, coef/base tranche), période, adresse départ profil, totaux mois/année, TOTAL IK du mois, Montant_Regle + - Liste des trajets (date, motif/commentaire, départ → arrivée, km) ; timeout 15 s + - _Requirements: 7.1, 7.4, 7.6, 7.8, 7.10, 7.11_ + + - [x] 11.2 Implémenter `PdfService` — synthèse annuelle et rapports par véhicule + - Page synthèse (identité, année, domicile, barème de l'année, véhicule, tranche) + 12 lignes (km, réglé, frais réel) + totaux (km annuel, réglé, réel, régularisation) + - Génération d'un PDF distinct par véhicule si plusieurs véhicules sur la période + - _Requirements: 7.2, 7.4, 7.6, 7.7, 7.8, 7.9, 7.12_ + + - [x] 11.3 Implémenter `ExportsPdfController` + - `GET /api/exports/mensuel/{annee}/{mois}` et `GET /api/exports/annuel/{annee}` ; en-tête Content-Disposition `trajets_MM_AAAA.pdf` / `trajets_AAAA.pdf`, timeout → 503 + - _Requirements: 7.3, 7.4, 7.5_ + + - [ ]* 11.4 Écrire le test de propriété de nommage PDF + - **Property 9 : Nommage des fichiers PDF exportés** + - **Validates: Requirements 7.4** + + - [ ]* 11.5 Écrire les tests unitaires PDF + - En-tête identité/véhicule, PDF distinct par véhicule, montants mensuels, régularisation annuelle + - _Requirements: 7.6, 7.8, 7.9, 7.12_ + +- [x] 12. Checkpoint backend — S'assurer que tous les tests passent + - Ensure all tests pass, ask the user if questions arise. + +- [ ]* 13. Tests d'intégration backend end-to-end + - [ ]* 13.1 Tests d'intégration trajets et distance (WebApplicationFactory + SQLite en mémoire) + - POST avec/sans coordonnées (BAN puis ORS, fallback Haversine, distance nulle + 201), insertion entre deux trajets, PATCH réordonner, PUT/DELETE + - _Requirements: 1.3, 2.4, 2.6, 5.5, 11.8_ + + - [ ]* 13.2 Tests d'intégration résumés, totaux, véhicules, barème et exports + - Résumés mensuel/annuel, totaux partiels, réaffectation accepter/refuser, barème chevauchant → 400, upsert montant réglé, régularisation, export PDF 2 véhicules → 2 fichiers + - _Requirements: 3.8, 4.1, 7.9, 10.16, 12.8, 13.2, 14.3, 14.7_ + +- [x] 14. Socle frontend Angular et services + - [x] 14.1 Initialiser l'application Angular et le socle core + - Créer l'app Angular, `app-routing.module.ts`, modèles TypeScript, `notification.service.ts`, `TimeoutInterceptor` (10 s), `DateJjMmAAAAValidator` + - _Requirements: 1.9, 1.5_ + + - [x] 14.2 Implémenter les services HTTP Angular + - `TrajetService`, `VehiculeService`, `BANService` (autocomplétion), `AdresseCacheService`, `ProfilService`, `BaremeService`, `RemboursementService` + - `ProfilService.getEtat()` : appelle `GET /api/profil/etat` et retourne `EtatProfilDto` + - _Requirements: 1.2, 9.3, 9.4, 16.2_ + + - [ ]* 14.3 Écrire les tests unitaires des services + - Mock HttpClient, comportement du TimeoutInterceptor + - _Requirements: 1.9_ + +- [x] 15. Saisie et édition des trajets (frontend) + - [x] 15.1 Implémenter `TrajetFormComponent` + - Reactive Form (date, adresses 1–255, commentaire 0–500), validations synchrones, mode création/édition/insertion, autocomplétion BAN dès 3 car. + stockage coordonnées, boutons « Utiliser mon domicile », avertissement saisie manuelle sans coordonnées + - _Requirements: 1.1, 1.4, 1.5, 1.8, 1.10, 1.13, 1.14, 1.15, 1.16, 1.17, 5.1, 10.4, 10.5, 10.6, 11.1, 11.2, 11.3, 11.4_ + + - [x] 15.2 Implémenter `TrajetListComponent` + - Liste triée par Position, actions modifier/supprimer (avec `ConfirmDialog`)/insérer après/insérer avant le suivant, drag & drop → `PATCH reordonner`, rafraîchissement < 1 000 ms, icône + tooltip commentaire + - _Requirements: 5.1, 5.4, 5.7, 11.6, 11.9_ + + - [ ]* 15.3 Écrire les tests des composants de saisie + - Validation, autocomplétion BAN, mode insertion pré-rempli, drag & drop, tri par Position + - _Requirements: 1.4, 1.5, 1.13, 11.1, 11.6_ + +- [x] 16. Consultation, navigation temporelle et carte (frontend) + - [x] 16.1 Implémenter `SelecteurTemporelComponent` + - Sélecteurs année/mois/jour avec surbrillance des périodes ayant des données, désactivation des dates futures + - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5_ + + - [x] 16.2 Implémenter `PanneauTotauxComponent` + - Totaux jour/mois/Total_Annuel_Partiel à 2 décimales, Montant_Previsionnel 80% indicatif, recalcul au changement + - _Requirements: 12.7, 12.8, 13.5, 13.6, 13.7, 13.8_ + + - [x] 16.3 Implémenter `CarteTrajetsComponent` + - Leaflet + tuiles OpenStreetMap, tracés GeoJSON par trajet, fallback ligne droite, infobulles, zoom/déplacement, message si vide ou carte indisponible + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.8_ + + - [x] 16.4 Implémenter `ConsultationJourComponent` (layout principal) + - Deux colonnes (liste scrollable + panneau sticky totaux/carte), boutons jour ±1 (« suivant » désactivé si futur), sélecteur de véhicule filtrant liste/totaux/carte, rafraîchissement < 1 000 ms + - _Requirements: 12.6, 12.9, 12.10, 12.11, 12.12, 12.13, 12.14, 12.17, 12.18, 12.20_ + + - [ ]* 16.5 Écrire les tests des composants de consultation + - Surbrillance/dates futures, 3 totaux + prévisionnel, tracés carte + fallback, layout et filtrage par véhicule + - _Requirements: 8.2, 8.5, 12.5, 12.8, 12.18_ + +- [x] 17. Profil, véhicules, barème et montants réglés (frontend) + - [x] 17.1 Implémenter `ProfilComponent`, `VehiculesComponent` et `ReaffectationDialogComponent` + - Profil (nom/prénom/matricule 8 car./domicile) + avertissement géocodage ; historique véhicules trié, ajout avec DateEffet ≤ aujourd'hui, dialog de réaffectation (nombre de trajets, accepter/refuser) + - _Requirements: 10.1, 10.2, 10.8, 10.9, 10.10, 10.11, 10.15, 10.16, 10.17_ + + - [x] 17.2 Implémenter `BaremeComponent` et `MontantsReglesComponent` + - Saisie des 3 tranches par puissance fiscale/année avec détection de chevauchement ; saisie Montant_Regle par mois + prévisionnel indicatif, rejet négatif, montants 2 décimales + - _Requirements: 13.1, 13.7, 14.1, 14.4, 14.8_ + + - [x] 17.3 Implémenter `ResumeMensuelComponent` et `ResumeAnnuelComponent` + - Résumé mensuel (liste + total 2 décimales, bouton export), résumé annuel (12 lignes + total ≤ 999 999,99, bouton export), déclenchement téléchargement PDF + gestion d'erreur + - _Requirements: 3.1, 3.5, 3.7, 4.1, 4.5, 4.6, 7.3_ + + - [ ]* 17.4 Écrire les tests des composants profil/véhicules/barème/montants/résumés + - Matricule 8 car., date effet future bloquée, dialog réaffectation, chevauchement barème, montant négatif rejeté, affichage 2 décimales et max annuel + - _Requirements: 4.5, 10.2, 10.11, 13.1, 14.4_ + +- [x] 18. Checkpoint final — S'assurer que tous les tests passent + - Ensure all tests pass, ask the user if questions arise. + +- [x] 19. Carnet d'adresses nommées (backend) + - [x] 19.1 Implémenter `AdresseNommeeService` et `AdressesNommeesController` + - CRUD des Adresses_Nommées ; normalisation (trim + minuscules + suppression des accents) pour l'unicité de la Dénomination et le matching du Cache_Adresses + - Réutilisation du Cache_Adresses si l'adresse normalisée y est présente (sans appel BAN) ; sinon géocodage/re-géocodage via BAN (timeout 5 s) + - Si BAN indisponible/timeout : persistance sans coordonnées + `GeocodageReussi = false` et réponse indiquant l'absence de coordonnées ; mise en cache des coordonnées obtenues + - Suppression sans impact sur les Trajets existants (conservation de leur adresse et coordonnées) + - Endpoints `GET/POST/PUT/DELETE /api/adresses-nommees` + - _Requirements: 15.1, 15.4, 15.5, 15.6, 15.7, 15.8, 15.9, 15.10, 15.11, 15.12, 15.14_ + + - [ ]* 19.2 Écrire le test de propriété d'unicité de la dénomination + - **Property 28 : Unicité normalisée de la dénomination** + - **Validates: Requirements 15.6, 15.7** + + - [ ]* 19.3 Écrire le test de propriété de préservation des trajets à la suppression + - **Property 29 : Préservation des trajets à la suppression d'une adresse nommée** + - **Validates: Requirements 15.14** + + - [ ]* 19.4 Écrire le test de propriété de réutilisation du cache et cohérence du géocodage + - **Property 30 : Réutilisation du cache et cohérence du géocodage des adresses nommées** + - **Validates: Requirements 15.9, 15.10, 15.11, 15.12** + +- [x] 20. Carnet d'adresses nommées (frontend) + - [x] 20.1 Implémenter `AdresseNommeeService` (Angular) et `CarnetAdressesComponent` + - `AdresseNommeeService` : `getAdressesNommees`, `create`, `update`, `delete` (HTTP) + - `CarnetAdressesComponent` : liste triée par Dénomination, formulaire de création/modification avec autocomplétion BAN (min 3 car., debounce 300 ms, max 5 suggestions), confirmation de suppression, message de succès CRUD, avertissement si le géocodage a échoué + - _Requirements: 15.1, 15.2, 15.3, 15.4, 15.11, 15.13, 15.15_ + + - [x] 20.2 Étendre `TrajetFormComponent` avec le sélecteur d'Adresse_Nommee + - Pour les champs adresse de départ et adresse d'arrivée, ajouter un sélecteur d'Adresse_Nommee (par Dénomination) en mode création ET insertion + - À la sélection, remplir le champ + associer les coordonnées (remplace le contenu pré-rempli en mode insertion) + - _Requirements: 1.18, 1.19, 11.10, 11.11, 15.16_ + + - [ ]* 20.3 Écrire les tests des composants carnet et du sélecteur dans le formulaire + - Composant Carnet_Adresses (liste, formulaire, confirmation, message), sélecteur d'Adresse_Nommee dans `TrajetFormComponent` + - _Requirements: 15.2, 15.13, 1.19_ + +- [x] 21. Onboarding profil à la première utilisation (frontend) + - [x] 21.1 Implémenter `OnboardingComponent` + - Formulaire une étape : identité (nom, prénom, matricule), Adresse_Domicile optionnelle avec autocomplétion BAN, au moins un Véhicule (plaque, puissance fiscale, date d'effet ≤ aujourd'hui) + - Bouton « Commencer » désactivé tant que les champs requis manquent + - À l'enregistrement réussi du profil + véhicule → navigation vers la vue de consultation + - Gestion d'erreur de persistance : message d'erreur + conservation des données saisies + - _Requirements: 16.3, 16.5, 16.6, 16.7, 16.8, 16.9, 16.10, 16.12_ + + - [x] 21.2 Implémenter le guard `ProfilConfigureGuard` (CanActivate) + - Appelle `ProfilService.getEtat()` au chargement ; redirige vers `/onboarding` si `EstConfigure = false` et bloque l'accès aux routes trajets/consultation ; laisse passer si `true` + - Gère l'échec de récupération de l'état par un message de connexion + action « Réessayer » + - Déclarer le guard sur les routes protégées dans `app-routing` + - _Requirements: 16.3, 16.4, 16.11, 16.13_ + + - [ ]* 21.3 Écrire les tests du parcours d'onboarding et du guard + - Bouton « Commencer » désactivé, navigation après enregistrement, redirection du guard, cas `EstConfigure` true/false, erreur + retry + - _Requirements: 16.3, 16.8, 16.10, 16.11, 16.13_ + +- [x] 22. Conteneurisation et déploiement Docker Compose + - [x] 22.1 Écrire le `Dockerfile` backend multi-stage + - Étape build (SDK) → étape runtime (aspnet), exposition du port 8080 + - Base SQLite sous le volume `/data/trajets.db` via `ConnectionStrings__Default` + - Application automatique des migrations EF Core au démarrage (`Database.MigrateAsync()` dans `Program.cs`) + - Endpoint de santé `GET /health` (`AddHealthChecks` / `MapHealthChecks`) + - _Requirements: 6.1_ + + - [x] 22.2 Écrire le `Dockerfile` frontend multi-stage et la configuration `nginx.conf` + - Étape build (node) → étape serve (nginx) + - `nginx.conf` : fallback SPA vers `index.html` + reverse-proxy `/api` vers `backend:8080` + + - [x] 22.3 Écrire le `docker-compose.yml` + - Services backend + frontend, volume nommé `trajets-data:/data` pour la persistance SQLite + - Clé ORS via variable d'environnement `${ORS_API_KEY}` depuis un fichier `.env` non commité + - Mapping hôte 8080 → nginx, `depends_on: service_healthy`, `restart: unless-stopped`, healthcheck backend + - Ajouter `.env` au `.gitignore` + +## Notes + +- Les tâches marquées `*` sont optionnelles (tests unitaires, tests de propriété, tests d'intégration) et peuvent être ignorées pour un MVP plus rapide ; les tâches d'implémentation cœur ne le sont jamais. +- Chaque tâche référence des exigences précises pour la traçabilité ; les sous-tâches de test de propriété référencent explicitement la propriété de correction validée (numéro + exigences). +- Les 31 propriétés de correction sont couvertes par des tests FsCheck (backend) répartis au plus près de leur implémentation pour détecter les erreurs tôt. +- La clé ORS n'est jamais commitée : User Secrets en développement, variables d'environnement en production. +- Les checkpoints garantissent une validation incrémentale du backend puis du frontend. + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1"] }, + { "id": 1, "tasks": ["2.1"] }, + { "id": 2, "tasks": ["2.2", "3.1"] }, + { "id": 3, "tasks": ["2.3", "3.2", "4.1", "4.2"] }, + { "id": 4, "tasks": ["3.3", "4.3", "5.1", "6.1", "6.5"] }, + { "id": 5, "tasks": ["4.4", "4.5", "5.2", "5.3", "6.2", "6.3", "6.4", "6.6", "6.7", "8.1"] }, + { "id": 6, "tasks": ["8.2", "10.1", "19.1"] }, + { "id": 7, "tasks": ["8.3", "8.4", "8.5", "10.2", "10.3", "19.2", "19.3", "19.4"] }, + { "id": 8, "tasks": ["8.6", "8.7", "8.8", "10.4", "10.5"] }, + { "id": 9, "tasks": ["8.9", "9.1", "9.3", "10.6", "11.1", "11.2"] }, + { "id": 10, "tasks": ["9.2", "9.4", "9.5", "11.3"] }, + { "id": 11, "tasks": ["11.4", "11.5", "13.1", "13.2"] }, + { "id": 12, "tasks": ["14.1", "22.1"] }, + { "id": 13, "tasks": ["14.2"] }, + { "id": 14, "tasks": ["14.3", "15.1", "16.1", "16.3", "17.1", "20.1", "21.1"] }, + { "id": 15, "tasks": ["15.2", "16.2", "16.4", "17.2", "17.3", "20.2", "21.2", "22.2"] }, + { "id": 16, "tasks": ["15.3", "16.5", "17.4", "20.3", "21.3", "22.3"] } + ] +} +``` diff --git a/.kiro/specs/trajet-km-tracker/tasks.meta.json b/.kiro/specs/trajet-km-tracker/tasks.meta.json new file mode 100644 index 0000000..15a3702 --- /dev/null +++ b/.kiro/specs/trajet-km-tracker/tasks.meta.json @@ -0,0 +1,906 @@ +{ + "pbtResults": {}, + "executionHistory": { + "1. Initialiser la solution et le socle backend": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287232165 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287246543 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287998132 + } + ], + "2.1 Définir les entités du domaine": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287232559 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288005627 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288114112 + } + ], + "2.2 Configurer `AppDbContext` et les migrations": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287232721 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288119065 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288331297 + } + ], + "3.1 Créer les DTOs et enregistrements": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287232879 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288119884 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288332271 + } + ], + "3.2 Implémenter les validateurs FluentValidation": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287233063 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288338632 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288511176 + } + ], + "4.1 Implémenter `IBANClient` / `BANClient`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287233177 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288339629 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288512549 + } + ], + "4.2 Implémenter `IORSClient` / `ORSClient`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287233302 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288340752 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288513521 + } + ], + "4.3 Implémenter `IDistanceCalculator` / `DistanceCalculatorService`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287233400 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288541576 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288751981 + } + ], + "5.1 Implémenter `AdresseCacheService` et le repository associé": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287233556 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288542488 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288752928 + } + ], + "6.1 Implémenter `VehiculeService` et le repository véhicules": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287233727 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288543893 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288754864 + } + ], + "6.5 Implémenter `ProfilService` et le controller `ProfilController`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287233834 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288544845 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288922998 + } + ], + "7. Checkpoint — S'assurer que tous les tests passent": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287233946 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288928483 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288949593 + } + ], + "8.1 Implémenter `TrajetRepository` et la gestion des positions": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287234050 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288963408 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789289192036 + } + ], + "8.2 Implémenter `TrajetService` (création)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287234243 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789289199983 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789289530614 + } + ], + "8.5 Implémenter `TrajetService` (modification, suppression, réordonnancement)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287234350 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789289537094 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290182288 + } + ], + "8.8 Implémenter `TrajetsController` et brancher le controller adresses": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287234531 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290188266 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290381932 + } + ], + "9.1 Implémenter `ResumeService` (mensuel et annuel)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287234746 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290189218 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290380584 + } + ], + "9.3 Implémenter `TotauxService` et la navigation temporelle": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287234874 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290190414 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290382822 + } + ], + "9.5 Implémenter `ResumesController` et les controllers de navigation": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287234983 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290389151 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290611560 + } + ], + "10.1 Implémenter `BaremeService` et le controller barème": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287235093 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288964381 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789289189697 + } + ], + "10.3 Implémenter `MontantRegleService` (upsert et montant prévisionnel)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287235202 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789289200933 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789289531613 + } + ], + "10.5 Implémenter `RemboursementService` (régularisation annuelle)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287235303 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789289538235 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290181307 + } + ], + "11.1 Implémenter `PdfService` — rapport mensuel": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287235417 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290389978 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290610124 + } + ], + "11.2 Implémenter `PdfService` — synthèse annuelle et rapports par véhicule": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287235641 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290612582 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290840695 + } + ], + "11.3 Implémenter `ExportsPdfController`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287235735 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290841795 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290955479 + } + ], + "12. Checkpoint backend — S'assurer que tous les tests passent": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287235836 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290960674 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290981616 + } + ], + "14.1 Initialiser l'application Angular et le socle core": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287235947 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290991997 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789292473439 + } + ], + "14.2 Implémenter les services HTTP Angular": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287236050 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789292479753 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789292716818 + } + ], + "15.1 Implémenter `TrajetFormComponent`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287236150 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789292723557 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789293936331 + } + ], + "15.2 Implémenter `TrajetListComponent`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287236250 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294145233 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294848699 + } + ], + "16.1 Implémenter `SelecteurTemporelComponent`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287236348 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789292725073 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789293937718 + } + ], + "16.2 Implémenter `PanneauTotauxComponent`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287236452 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294146289 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294849892 + } + ], + "16.3 Implémenter `CarteTrajetsComponent`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287236569 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789292725912 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789293939158 + } + ], + "16.4 Implémenter `ConsultationJourComponent` (layout principal)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287236668 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294856111 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789295779877 + } + ], + "17.1 Implémenter `ProfilComponent`, `VehiculesComponent` et `ReaffectationDialogComponent`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287236781 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789292727318 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789293940464 + } + ], + "17.2 Implémenter `BaremeComponent` et `MontantsReglesComponent`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287236901 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294147594 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294847717 + } + ], + "17.3 Implémenter `ResumeMensuelComponent` et `ResumeAnnuelComponent`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287237023 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294149107 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294845080 + } + ], + "18. Checkpoint final — S'assurer que tous les tests passent": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287237181 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789295909781 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789295952171 + } + ], + "19.1 Implémenter `AdresseNommeeService` et `AdressesNommeesController`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287237302 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288965614 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789289191066 + } + ], + "20.1 Implémenter `AdresseNommeeService` (Angular) et `CarnetAdressesComponent`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287237403 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789292728418 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789293941575 + } + ], + "20.2 Étendre `TrajetFormComponent` avec le sélecteur d'Adresse_Nommee": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287237505 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294150157 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294846331 + } + ], + "21.1 Implémenter `OnboardingComponent`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287237623 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789293946937 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294136345 + } + ], + "21.2 Implémenter le guard `ProfilConfigureGuard` (CanActivate)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287237734 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294857367 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789295904278 + } + ], + "22.1 Écrire le `Dockerfile` backend multi-stage": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287237834 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290993096 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789292474299 + } + ], + "22.2 Écrire le `Dockerfile` frontend multi-stage et la configuration `nginx.conf`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287237949 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789293948068 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294137486 + } + ], + "22.3 Écrire le `docker-compose.yml`": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789287238070 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294858181 + }, + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789295780772 + } + ], + "2. Modèle de données et contexte EF Core": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288331545 + } + ], + "3. DTOs, options de configuration et validateurs": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288511390 + } + ], + "4. Intégrations externes : BAN et ORS": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288752247 + } + ], + "5. Cache des adresses géocodées": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288753060 + } + ], + "6. Gestion des véhicules et du profil": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789288923258 + } + ], + "19. Carnet d'adresses nommées (backend)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789289191199 + } + ], + "10. Barème kilométrique et régularisation": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290181589 + } + ], + "8. Service et API des trajets (CRUD + positions)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290382197 + } + ], + "9. Résumés, totaux et navigation temporelle": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290611752 + } + ], + "11. Export PDF (QuestPDF)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789290955735 + } + ], + "14. Socle frontend Angular et services": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789292717019 + } + ], + "20. Carnet d'adresses nommées (frontend)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294846578 + } + ], + "17. Profil, véhicules, barème et montants réglés (frontend)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294847882 + } + ], + "15. Saisie et édition des trajets (frontend)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789294848965 + } + ], + "16. Consultation, navigation temporelle et carte (frontend)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789295780126 + } + ], + "22. Conteneurisation et déploiement Docker Compose": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789295781029 + } + ], + "21. Onboarding profil à la première utilisation (frontend)": [ + { + "executionId": "ee6b2d0f-d644-4cba-8c61-09b790014d2d", + "chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8", + "timestamp": 1789295904483 + } + ] + } +} \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..fb5b48c --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,16 @@ +# Build output +bin/ +obj/ + +# SQLite database files +*.db +*.db-shm +*.db-wal + +# User-specific files +*.user +*.suo + +# Secrets +.env +secrets.json diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a1cec61 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,35 @@ +# ---- Étape build (SDK) ---- +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY *.sln ./ +COPY TrajetKmTracker.Api/*.csproj TrajetKmTracker.Api/ +RUN dotnet restore TrajetKmTracker.Api/TrajetKmTracker.Api.csproj +COPY . . +RUN dotnet publish TrajetKmTracker.Api/TrajetKmTracker.Api.csproj -c Release -o /app/publish + +# ---- Étape runtime (ASP.NET) ---- +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app + +# curl est requis pour le HEALTHCHECK Docker (l'image aspnet ne l'inclut pas par défaut). +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/publish . + +# Répertoire du volume pour la base SQLite persistée +RUN mkdir -p /data +VOLUME ["/data"] + +# La base SQLite est stockée sur le volume monté ; le chemin est fourni via la chaîne de connexion +ENV ConnectionStrings__Default="Data Source=/data/trajets.db" +ENV ASPNETCORE_URLS="http://+:8080" + +EXPOSE 8080 + +# Sonde de santé intégrée à l'image (utilisée aussi par Docker Compose). +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=20s \ + CMD curl -f http://localhost:8080/health || exit 1 + +ENTRYPOINT ["dotnet", "TrajetKmTracker.Api.dll"] diff --git a/backend/TrajetKmTracker.Api/Configuration/BANOptions.cs b/backend/TrajetKmTracker.Api/Configuration/BANOptions.cs new file mode 100644 index 0000000..eaf6c3b --- /dev/null +++ b/backend/TrajetKmTracker.Api/Configuration/BANOptions.cs @@ -0,0 +1,10 @@ +namespace TrajetKmTracker.Api.Configuration; + +/// Options de configuration de l'API BAN (Base Adresses Nationale). +public class BANOptions +{ + public const string SectionName = "BAN"; + + public string BaseUrl { get; set; } = "https://api-adresse.data.gouv.fr"; + public int TimeoutSeconds { get; set; } = 5; +} diff --git a/backend/TrajetKmTracker.Api/Configuration/ORSOptions.cs b/backend/TrajetKmTracker.Api/Configuration/ORSOptions.cs new file mode 100644 index 0000000..75a0530 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Configuration/ORSOptions.cs @@ -0,0 +1,11 @@ +namespace TrajetKmTracker.Api.Configuration; + +/// Options de configuration de l'API OpenRouteService (ORS). +public class ORSOptions +{ + public const string SectionName = "ORS"; + + public string BaseUrl { get; set; } = string.Empty; + public string ApiKey { get; set; } = string.Empty; + public int TimeoutSeconds { get; set; } = 10; +} diff --git a/backend/TrajetKmTracker.Api/Controllers/AdressesController.cs b/backend/TrajetKmTracker.Api/Controllers/AdressesController.cs new file mode 100644 index 0000000..bd0dba6 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Controllers/AdressesController.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Mvc; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services; + +namespace TrajetKmTracker.Api.Controllers; + +/// +/// Endpoint d'autocomplétion locale des adresses déjà géocodées (Cache_Adresses). +/// La recherche exige au moins 3 caractères ; en deçà, une liste vide est retournée +/// (Exigence 9.3). +/// +[ApiController] +[Route("api/adresses")] +public class AdressesController : ControllerBase +{ + private readonly IAdresseCacheService _adresseCacheService; + + public AdressesController(IAdresseCacheService adresseCacheService) + { + _adresseCacheService = adresseCacheService; + } + + /// + /// Recherche dans le Cache_Adresses les suggestions correspondant au fragment + /// (min. 3 caractères), ordonnées de la plus récemment utilisée à la plus ancienne. + /// + [HttpGet("search")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> Search( + [FromQuery] string? q, + CancellationToken cancellationToken) + { + var suggestions = await _adresseCacheService.RechercherLocalAsync( + q ?? string.Empty, + cancellationToken: cancellationToken); + + return Ok(suggestions); + } +} diff --git a/backend/TrajetKmTracker.Api/Controllers/AdressesNommeesController.cs b/backend/TrajetKmTracker.Api/Controllers/AdressesNommeesController.cs new file mode 100644 index 0000000..bfef8e7 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Controllers/AdressesNommeesController.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Mvc; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Controllers; + +/// +/// Endpoints de gestion du Carnet_Adresses : liste, création, modification et suppression +/// des Adresses_Nommées (Exigence 15). +/// +[ApiController] +[Route("api/adresses-nommees")] +public class AdressesNommeesController : ControllerBase +{ + private readonly IAdresseNommeeService _service; + + public AdressesNommeesController(IAdresseNommeeService service) + { + _service = service; + } + + /// Liste toutes les Adresses_Nommées, triées par Dénomination (15.1). + [HttpGet] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetAll(CancellationToken cancellationToken) + { + var adresses = await _service.GetAllAsync(cancellationToken); + return Ok(adresses); + } + + /// + /// Crée une Adresse_Nommee. Rejette une Dénomination invalide/dupliquée ou une adresse + /// invalide (400). + /// + [HttpPost] + [ProducesResponseType(typeof(AdresseNommeeDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Create( + [FromBody] SaveAdresseNommeeDto dto, + CancellationToken cancellationToken) + { + try + { + var cree = await _service.CreateAsync(dto, cancellationToken); + return CreatedAtAction(nameof(GetAll), null, cree); + } + catch (DenominationInvalideException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + catch (DenominationDupliqueeException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + catch (AdresseNommeeInvalideException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + } + + /// + /// Modifie une Adresse_Nommee. Rejette une Dénomination invalide/dupliquée ou une adresse + /// invalide (400) et une Adresse_Nommee introuvable (404). + /// + [HttpPut("{id:int}")] + [ProducesResponseType(typeof(AdresseNommeeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Update( + int id, + [FromBody] SaveAdresseNommeeDto dto, + CancellationToken cancellationToken) + { + try + { + var maj = await _service.UpdateAsync(id, dto, cancellationToken); + return Ok(maj); + } + catch (AdresseNommeeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + catch (DenominationInvalideException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + catch (DenominationDupliqueeException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + catch (AdresseNommeeInvalideException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + } + + /// + /// Supprime une Adresse_Nommee. Les Trajets existants restent inchangés (15.14). + /// + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + try + { + await _service.DeleteAsync(id, cancellationToken); + return NoContent(); + } + catch (AdresseNommeeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Controllers/BaremeController.cs b/backend/TrajetKmTracker.Api/Controllers/BaremeController.cs new file mode 100644 index 0000000..56ec557 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Controllers/BaremeController.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Mvc; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Controllers; + +/// +/// Endpoints de gestion du barème kilométrique : consultation par année et CRUD des tranches. +/// La validation du non-chevauchement des tranches ([KmMin, KmMax]) par (année, puissance fiscale) +/// est appliquée à l'ajout et à la modification (Exigences 13.1–13.2). +/// +[ApiController] +[Route("api/bareme")] +public class BaremeController : ControllerBase +{ + private readonly IBaremeService _baremeService; + + public BaremeController(IBaremeService baremeService) + { + _baremeService = baremeService; + } + + /// Retourne toutes les tranches du barème pour l'année indiquée. + [HttpGet("{annee:int}")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetBareme( + int annee, CancellationToken cancellationToken) + { + var tranches = await _baremeService.GetBaremeAsync(annee, cancellationToken); + return Ok(tranches); + } + + /// + /// Ajoute une tranche du barème. Rejette (400) si l'intervalle chevauche une tranche existante + /// pour la même (année, puissance fiscale). + /// + [HttpPost] + [ProducesResponseType(typeof(TrancheBaremeDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> AddTranche( + [FromBody] SaveTrancheBaremeDto dto, CancellationToken cancellationToken) + { + try + { + var tranche = await _baremeService.AddTrancheAsync(dto, cancellationToken); + return CreatedAtAction(nameof(GetBareme), new { annee = tranche.Annee }, tranche); + } + catch (TranchesBaremeChevauchantesException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + } + + /// + /// Met à jour une tranche existante. Rejette (400) en cas de chevauchement, 404 si la tranche est absente. + /// + [HttpPut("{id:int}")] + [ProducesResponseType(typeof(TrancheBaremeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateTranche( + int id, [FromBody] SaveTrancheBaremeDto dto, CancellationToken cancellationToken) + { + try + { + var tranche = await _baremeService.UpdateTrancheAsync(id, dto, cancellationToken); + return Ok(tranche); + } + catch (TrancheBaremeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + catch (TranchesBaremeChevauchantesException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + } + + /// Années pour lesquelles un barème (au moins une tranche) est défini, triées croissantes. + [HttpGet("annees-disponibles")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetAnneesDisponibles(CancellationToken cancellationToken) + { + var annees = await _baremeService.GetAnneesAvecBaremeAsync(cancellationToken); + return Ok(annees); + } + + /// + /// Copie le dernier barème connu (année la plus récente antérieure ayant un barème) vers + /// l'année cible. Remplace les tranches existantes de l'année cible (annule et remplace). + /// Retourne le nombre de tranches copiées (0 si aucun barème antérieur n'existe). + /// + [HttpPost("copier/{anneeCible:int}")] + [ProducesResponseType(typeof(CopieBaremeResultDto), StatusCodes.Status200OK)] + public async Task> CopierDernierBareme( + int anneeCible, CancellationToken cancellationToken) + { + var nombre = await _baremeService.CopierDernierBaremeAsync(anneeCible, cancellationToken); + return Ok(new CopieBaremeResultDto(nombre)); + } + + /// Supprime une tranche du barème. Retourne 404 si la tranche est absente. + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteTranche(int id, CancellationToken cancellationToken) + { + try + { + await _baremeService.DeleteTrancheAsync(id, cancellationToken); + return NoContent(); + } + catch (TrancheBaremeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Controllers/ExportsPdfController.cs b/backend/TrajetKmTracker.Api/Controllers/ExportsPdfController.cs new file mode 100644 index 0000000..9b10ae2 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Controllers/ExportsPdfController.cs @@ -0,0 +1,126 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using TrajetKmTracker.Api.Services; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Controllers; + +/// +/// Endpoints d'Export_PDF : rapport mensuel et synthèse annuelle (Exigences 7.3, 7.4, 7.5). +/// Chaque réponse renvoie un unique fichier application/pdf avec l'en-tête +/// Content-Disposition: attachment et le nom de fichier suggéré par le service +/// (trajets_MM_AAAA.pdf pour le mensuel, trajets_AAAA.pdf pour l'annuel). +/// +/// Le filtre par véhicule est optionnel : si vehiculeId est omis, le Véhicule_Courant +/// est utilisé par défaut. +/// +/// +/// Note (Exigence 7.9) : une réponse HTTP unique ne peut transporter qu'un seul fichier. +/// Ces endpoints produisent donc le rapport du véhicule sélectionné (ou courant). La génération +/// d'un rapport distinct par véhicule lorsqu'une période comporte plusieurs véhicules est assurée +/// par les variantes de service GenererRapports…ParVehiculeAsync, réservées à un usage +/// multi-fichiers ultérieur (p. ex. archive). Ici, un appel = un fichier filtré par véhicule. +/// +/// +/// +/// Erreurs : un dépassement du délai de génération ou un échec de rendu lève une +/// mappée vers 503 Service Unavailable ; un véhicule +/// introuvable lève une mappée vers 404 Not Found +/// (réponses ProblemDetails). +/// +/// +[ApiController] +[Route("api/exports")] +public class ExportsPdfController : ControllerBase +{ + private readonly IPdfService _pdfService; + private readonly IVehiculeService _vehiculeService; + + public ExportsPdfController(IPdfService pdfService, IVehiculeService vehiculeService) + { + _pdfService = pdfService; + _vehiculeService = vehiculeService; + } + + /// + /// Génère et télécharge le rapport PDF mensuel d'un (année, mois) pour le véhicule indiqué + /// (Véhicule_Courant si est omis). Retourne les octets du PDF + /// avec Content-Type: application/pdf et Content-Disposition: attachment; + /// filename="trajets_MM_AAAA.pdf" (Exigences 7.3, 7.4). + /// + [HttpGet("mensuel/{annee:int}/{mois:int}")] + [Produces(MediaTypeNames.Application.Pdf)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task ExporterMensuel( + int annee, int mois, [FromQuery] int? vehiculeId, CancellationToken cancellationToken) + { + try + { + var vehicule = await ResoudreVehiculeIdAsync(vehiculeId, cancellationToken); + var pdf = await _pdfService.GenererRapportMensuelAsync(annee, mois, vehicule, cancellationToken); + return FichierPdf(pdf); + } + catch (VehiculeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + catch (PdfGenerationException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status503ServiceUnavailable); + } + } + + /// + /// Génère et télécharge la synthèse PDF annuelle d'une année pour le véhicule indiqué + /// (Véhicule_Courant si est omis). Retourne les octets du PDF + /// avec Content-Type: application/pdf et Content-Disposition: attachment; + /// filename="trajets_AAAA.pdf" (Exigences 7.3, 7.4). + /// + [HttpGet("annuel/{annee:int}")] + [Produces(MediaTypeNames.Application.Pdf)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task ExporterAnnuel( + int annee, [FromQuery] int? vehiculeId, CancellationToken cancellationToken) + { + try + { + var vehicule = await ResoudreVehiculeIdAsync(vehiculeId, cancellationToken); + var pdf = await _pdfService.GenererRapportAnnuelAsync(annee, vehicule, cancellationToken); + return FichierPdf(pdf); + } + catch (VehiculeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + catch (PdfGenerationException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status503ServiceUnavailable); + } + } + + /// + /// Résout l'identifiant du véhicule à utiliser : celui fourni s'il est présent, sinon + /// le Véhicule_Courant (Date_Effet la plus récente <= aujourd'hui). + /// + private async Task ResoudreVehiculeIdAsync(int? vehiculeId, CancellationToken cancellationToken) + { + if (vehiculeId.HasValue) + { + return vehiculeId.Value; + } + + var courant = await _vehiculeService.GetVehiculeCourantAsync(cancellationToken); + return courant.Id; + } + + /// + /// Renvoie le PDF en pièce jointe téléchargeable, en s'appuyant sur le nom de fichier + /// suggéré par le (Content-Disposition: attachment). + /// + private FileContentResult FichierPdf(PdfResult pdf) + => File(pdf.Contenu, MediaTypeNames.Application.Pdf, pdf.NomFichier); +} diff --git a/backend/TrajetKmTracker.Api/Controllers/NavigationController.cs b/backend/TrajetKmTracker.Api/Controllers/NavigationController.cs new file mode 100644 index 0000000..e83807c --- /dev/null +++ b/backend/TrajetKmTracker.Api/Controllers/NavigationController.cs @@ -0,0 +1,86 @@ +using System.Globalization; +using Microsoft.AspNetCore.Mvc; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services; + +namespace TrajetKmTracker.Api.Controllers; + +/// +/// Endpoints de navigation temporelle et de totaux du Panneau_Totaux : +/// années/mois/jours disponibles (périodes ayant des données) et totaux +/// jour/mois/année partiel pour une date donnée (Exigences 12.15, 12.16). +/// +[ApiController] +[Route("api")] +public class NavigationController : ControllerBase +{ + private readonly ITotauxService _totauxService; + + public NavigationController(ITotauxService totauxService) + { + _totauxService = totauxService; + } + + /// + /// Retourne les années comportant au moins un Trajet, complétées par l'année + /// courante, triées croissantes (Exigence 12.15). + /// + [HttpGet("annees-disponibles")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetAnneesDisponibles( + CancellationToken cancellationToken) + { + var annees = await _totauxService.GetAnneesDisponiblesAsync(cancellationToken); + return Ok(annees); + } + + /// + /// Retourne les mois (1..12) de l'année comportant au moins un Trajet, + /// triés croissants (Exigence 12.16). + /// + [HttpGet("mois-disponibles/{annee:int}")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetMoisDisponibles( + int annee, CancellationToken cancellationToken) + { + var mois = await _totauxService.GetMoisDisponiblesAsync(annee, cancellationToken); + return Ok(mois); + } + + /// + /// Retourne les jours du mois de l'année comportant au moins un Trajet, + /// triés croissants (Exigence 12.16). + /// + [HttpGet("jours-disponibles/{annee:int}/{mois:int}")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetJoursDisponibles( + int annee, int mois, CancellationToken cancellationToken) + { + var jours = await _totauxService.GetJoursDisponiblesAsync(annee, mois, cancellationToken); + return Ok(jours); + } + + /// + /// Retourne les totaux jour/mois/année partiel du Panneau_Totaux pour la date + /// indiquée et le véhicule (Véhicule_Courant si omis). + /// La date est attendue au format ISO yyyy-MM-dd ; un format invalide est + /// rejeté avec un code HTTP 400. + /// + [HttpGet("totaux/{date}")] + [ProducesResponseType(typeof(TotauxDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> GetTotaux( + string date, [FromQuery] int? vehiculeId, CancellationToken cancellationToken) + { + if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var jour)) + { + return Problem( + detail: $"La date '{date}' est invalide. Format attendu : yyyy-MM-dd.", + statusCode: StatusCodes.Status400BadRequest); + } + + var totaux = await _totauxService.GetTotauxAsync(jour, vehiculeId, cancellationToken); + return Ok(totaux); + } +} diff --git a/backend/TrajetKmTracker.Api/Controllers/ProfilController.cs b/backend/TrajetKmTracker.Api/Controllers/ProfilController.cs new file mode 100644 index 0000000..bac985d --- /dev/null +++ b/backend/TrajetKmTracker.Api/Controllers/ProfilController.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services; + +namespace TrajetKmTracker.Api.Controllers; + +/// +/// Endpoints de gestion du profil utilisateur (singleton) et de son état de configuration. +/// +[ApiController] +[Route("api/profil")] +public class ProfilController : ControllerBase +{ + private readonly IProfilService _profilService; + + public ProfilController(IProfilService profilService) + { + _profilService = profilService; + } + + /// Récupère le profil (créé à la volée s'il est absent). + [HttpGet] + [ProducesResponseType(typeof(ProfilDto), StatusCodes.Status200OK)] + public async Task> GetProfil(CancellationToken cancellationToken) + { + var profil = await _profilService.GetProfilAsync(cancellationToken); + return Ok(profil); + } + + /// + /// Sauvegarde le profil. La validation du matricule (≤ 8 car.) est assurée par FluentValidation. + /// Le géocodage de l'Adresse_Domicile est effectué au mieux : un échec BAN n'empêche pas la sauvegarde. + /// + [HttpPut] + [ProducesResponseType(typeof(ProfilDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> SaveProfil( + [FromBody] SaveProfilDto dto, + CancellationToken cancellationToken) + { + var profil = await _profilService.SaveProfilAsync(dto, cancellationToken); + return Ok(profil); + } + + /// État de configuration du profil (onboarding). + [HttpGet("etat")] + [ProducesResponseType(typeof(EtatProfilDto), StatusCodes.Status200OK)] + public async Task> GetEtat(CancellationToken cancellationToken) + { + var etat = await _profilService.GetEtatAsync(cancellationToken); + return Ok(etat); + } +} diff --git a/backend/TrajetKmTracker.Api/Controllers/RemboursementController.cs b/backend/TrajetKmTracker.Api/Controllers/RemboursementController.cs new file mode 100644 index 0000000..3f4e256 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Controllers/RemboursementController.cs @@ -0,0 +1,103 @@ +using Microsoft.AspNetCore.Mvc; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Controllers; + +/// +/// Endpoints « Barème et remboursements » : montant prévisionnel (80% indicatif), +/// montants réglés (saisie/consultation) et régularisation annuelle (barème plein). +/// +[ApiController] +[Route("api")] +public class RemboursementController : ControllerBase +{ + private readonly IMontantRegleService _montantRegleService; + private readonly IRemboursementService _remboursementService; + + public RemboursementController( + IMontantRegleService montantRegleService, + IRemboursementService remboursementService) + { + _montantRegleService = montantRegleService; + _remboursementService = remboursementService; + } + + /// + /// Retourne le Montant_Previsionnel indicatif (80% du barème plein sur le total annuel partiel) + /// pour un (année, mois, véhicule). Purement informatif — hors régularisation (Exigences 13.5/13.6). + /// + [HttpGet("montant-previsionnel/{annee:int}/{mois:int}")] + [ProducesResponseType(typeof(MontantPrevisionnelDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetMontantPrevisionnel( + int annee, int mois, [FromQuery] int vehiculeId, CancellationToken cancellationToken) + { + try + { + var dto = await _montantRegleService.GetMontantPrevisionnelAsync( + annee, mois, vehiculeId, cancellationToken); + return Ok(dto); + } + catch (VehiculeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + } + + /// + /// Retourne les Montant_Regle saisis pour une (année, véhicule), triés par mois croissant. + /// + [HttpGet("montants-regles/{annee:int}")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetMontantsRegles( + int annee, [FromQuery] int vehiculeId, CancellationToken cancellationToken) + { + var montants = await _montantRegleService.GetMontantsReglesAsync(annee, vehiculeId, cancellationToken); + return Ok(montants); + } + + /// + /// Enregistre ou met à jour (upsert) un Montant_Regle par (année, mois, véhicule). + /// Rejette un montant négatif avec un code HTTP 400 (Exigence 14.4). + /// + [HttpPut("montants-regles")] + [ProducesResponseType(typeof(MontantRegleDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> UpsertMontantRegle( + [FromBody] MontantRegleDto dto, CancellationToken cancellationToken) + { + try + { + var result = await _montantRegleService.UpsertMontantRegleAsync(dto, cancellationToken); + return Ok(result); + } + catch (MontantRegleNegatifException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + } + + /// + /// Retourne la synthèse annuelle de régularisation (barème plein) pour une (année, véhicule) : + /// tranche annuelle, 12 lignes mensuelles et totaux (km annuel, réglé, Montant_Reel, Regularisation). + /// + [HttpGet("regularisation/{annee:int}")] + [ProducesResponseType(typeof(RegularisationAnnuelleDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetRegularisation( + int annee, [FromQuery] int vehiculeId, CancellationToken cancellationToken) + { + try + { + var dto = await _remboursementService.GetRegularisationAnnuelleAsync( + annee, vehiculeId, cancellationToken); + return Ok(dto); + } + catch (VehiculeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Controllers/ResumesController.cs b/backend/TrajetKmTracker.Api/Controllers/ResumesController.cs new file mode 100644 index 0000000..83a76d8 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Controllers/ResumesController.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Mvc; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Controllers; + +/// +/// Endpoints des résumés kilométriques : Résumé_Mensuel et Résumé_Annuel. +/// Le filtre par véhicule est optionnel (Véhicule_Courant utilisé si omis). +/// Les paramètres hors bornes (mois ∉ [1, 12], année ∉ [2000, 2100]) sont +/// rejetés avec un code HTTP 400 (Exigences 3.6, 3.8, 4.1). +/// +[ApiController] +[Route("api/resumes")] +public class ResumesController : ControllerBase +{ + private readonly IResumeService _resumeService; + + public ResumesController(IResumeService resumeService) + { + _resumeService = resumeService; + } + + /// + /// Retourne le Résumé_Mensuel d'un (année, mois) pour le véhicule indiqué + /// (Véhicule_Courant si est omis). Rejette (400) + /// les paramètres hors bornes (Exigences 3.6, 3.8). + /// + [HttpGet("mensuel/{annee:int}/{mois:int}")] + [ProducesResponseType(typeof(ResumeMensuelDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> GetResumeMensuel( + int annee, int mois, [FromQuery] int? vehiculeId, CancellationToken cancellationToken) + { + try + { + var resume = await _resumeService.GetResumeMensuelAsync(annee, mois, vehiculeId, cancellationToken); + return Ok(resume); + } + catch (ResumeParametreInvalideException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + } + + /// + /// Retourne le Résumé_Annuel d'une année (12 résumés mensuels) pour le véhicule + /// indiqué (Véhicule_Courant si est omis). Rejette (400) + /// une année hors bornes (Exigences 3.6, 4.1). + /// + [HttpGet("annuel/{annee:int}")] + [ProducesResponseType(typeof(ResumeAnnuelDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> GetResumeAnnuel( + int annee, [FromQuery] int? vehiculeId, CancellationToken cancellationToken) + { + try + { + var resume = await _resumeService.GetResumeAnnuelAsync(annee, vehiculeId, cancellationToken); + return Ok(resume); + } + catch (ResumeParametreInvalideException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Controllers/TrajetsController.cs b/backend/TrajetKmTracker.Api/Controllers/TrajetsController.cs new file mode 100644 index 0000000..13a272b --- /dev/null +++ b/backend/TrajetKmTracker.Api/Controllers/TrajetsController.cs @@ -0,0 +1,240 @@ +using System.Globalization; +using Microsoft.AspNetCore.Mvc; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Repositories; +using TrajetKmTracker.Api.Services; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Controllers; + +/// +/// Endpoints CRUD des Trajets, réordonnancement manuel et récupération des trajets d'une +/// journée enrichis des coordonnées et géométries issues du Cache_Adresses (Exigences 1.3, +/// 5.5, 5.6, 8.7, 9.3). +/// +[ApiController] +[Route("api/trajets")] +public class TrajetsController : ControllerBase +{ + /// + /// En-tête de réponse portant l'avertissement lorsqu'un Trajet a été enregistré sans + /// distance calculable (Exigence 2.4). Le corps reste le attendu. + /// + private const string WarningHeader = "X-Trajet-Warning"; + + private readonly ITrajetService _trajetService; + private readonly ITrajetRepository _trajetRepository; + private readonly IAdresseCacheService _adresseCacheService; + + public TrajetsController( + ITrajetService trajetService, + ITrajetRepository trajetRepository, + IAdresseCacheService adresseCacheService) + { + _trajetService = trajetService; + _trajetRepository = trajetRepository; + _adresseCacheService = adresseCacheService; + } + + /// + /// Crée un Trajet (Exigence 1.3). Retourne 201 + . Si la distance n'a + /// pas pu être calculée faute de coordonnées, la réponse reste 201 et un avertissement est + /// ajouté dans l'en-tête X-Trajet-Warning (Exigence 2.4). + /// + [HttpPost] + [ProducesResponseType(typeof(TrajetDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Create( + [FromBody] CreateTrajetDto dto, + CancellationToken cancellationToken) + { + try + { + var resultat = await _trajetService.CreateAsync(dto, cancellationToken); + + if (resultat.Avertissement && !string.IsNullOrWhiteSpace(resultat.Message)) + { + Response.Headers[WarningHeader] = resultat.Message; + } + + return CreatedAtAction( + nameof(GetById), + new { id = resultat.Trajet.Id }, + resultat.Trajet); + } + catch (VehiculeNotFoundException ex) + { + // Aucun véhicule applicable à la date et aucun VehiculeId fourni. + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + } + + /// + /// Recalcule la distance de TOUS les trajets via OpenRouteService (BAN + ORS), indépendamment + /// d'un changement d'adresse. Sert à corriger des distances calculées en mode dégradé (ex. clé + /// ORS invalide → distances à vol d'oiseau) une fois ORS de nouveau opérationnel. + /// Retourne 200 + le nombre de trajets recalculés. + /// + [HttpPost("recalculer-distances")] + [ProducesResponseType(typeof(RecalculDistancesResultDto), StatusCodes.Status200OK)] + public async Task> RecalculerDistances( + CancellationToken cancellationToken) + { + var nombre = await _trajetService.RecalculerToutesLesDistancesAsync(cancellationToken); + return Ok(new RecalculDistancesResultDto(nombre)); + } + + /// Récupère un Trajet par identifiant. 404 s'il est introuvable. + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(TrajetDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int id, CancellationToken cancellationToken) + { + var trajet = await _trajetRepository.GetByIdAsync(id, cancellationToken); + if (trajet is null) + { + return Problem( + detail: $"Le trajet {id} n'existe pas.", + statusCode: StatusCodes.Status404NotFound); + } + + return Ok(new TrajetDto( + trajet.Id, + trajet.Date, + trajet.AdresseDepart, + trajet.AdresseArrivee, + trajet.Distance, + trajet.Commentaire, + trajet.Position, + trajet.VehiculeId, + trajet.PuissanceFiscale)); + } + + /// + /// Modifie un Trajet (Exigences 5.5, 5.6). Retourne 200 + . Un + /// avertissement de distance non calculable est porté par l'en-tête X-Trajet-Warning. + /// 404 si le Trajet est introuvable ; 503 si le recalcul de distance échoue (Exigence 5.8). + /// + [HttpPut("{id:int}")] + [ProducesResponseType(typeof(TrajetDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> Update( + int id, + [FromBody] UpdateTrajetDto dto, + CancellationToken cancellationToken) + { + try + { + var resultat = await _trajetService.UpdateAsync(id, dto, cancellationToken); + + if (resultat.Avertissement && !string.IsNullOrWhiteSpace(resultat.Message)) + { + Response.Headers[WarningHeader] = resultat.Message; + } + + return Ok(resultat.Trajet); + } + catch (TrajetNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + catch (VehiculeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + catch (DistanceRecalculException ex) + { + // Échec du recalcul sur des coordonnées connues : modification annulée (rollback), + // données existantes préservées (Exigence 5.8). Mappé sur 503 conformément à la table + // d'erreurs de la conception (DistanceCalculationException → 503). + return Problem(detail: ex.Message, statusCode: StatusCodes.Status503ServiceUnavailable); + } + } + + /// Supprime un Trajet (Exigence 5.5). 204 si succès, 404 s'il est introuvable (5.6). + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + try + { + await _trajetService.DeleteAsync(id, cancellationToken); + return NoContent(); + } + catch (TrajetNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + } + + /// + /// Réordonne manuellement les trajets d'une journée : met à jour uniquement leurs positions + /// sans altérer les autres champs (Exigence 11.9). 204 en cas de succès. + /// + [HttpPatch("reordonner")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Reordonner( + [FromBody] ReordonnerTrajetsDto dto, + CancellationToken cancellationToken) + { + await _trajetService.ReordonnerAsync(dto, cancellationToken); + return NoContent(); + } + + /// + /// Retourne les trajets d'une journée enrichis des coordonnées et géométries connues via le + /// Cache_Adresses (Exigences 8.7, 9.3). Le paramètre est attendu au + /// format ISO yyyy-MM-dd pour la sûreté du routage. Les coordonnées/géométries absentes + /// du cache restent à null. + /// + [HttpGet("journee/{date}")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetJournee( + string date, + CancellationToken cancellationToken) + { + if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var jour)) + { + return Problem( + detail: $"La date '{date}' est invalide. Format attendu : yyyy-MM-dd.", + statusCode: StatusCodes.Status400BadRequest); + } + + var trajets = await _trajetRepository.GetByDateAsync(jour, cancellationToken); + + var resultats = new List(trajets.Count); + foreach (var trajet in trajets) + { + var depart = await _adresseCacheService.ObtenirParAdresseAsync( + trajet.AdresseDepart, cancellationToken); + var arrivee = await _adresseCacheService.ObtenirParAdresseAsync( + trajet.AdresseArrivee, cancellationToken); + + // Géométrie de l'itinéraire : portée par le Trajet lui-même (paire départ→arrivée), + // seule source correcte. Fallback sur le cache d'adresses pour d'éventuels trajets + // hérités dont la géométrie n'a pas encore été recalculée (rétrocompatibilité). + var geometrie = trajet.GeometrieJson ?? depart?.GeometrieJson ?? arrivee?.GeometrieJson; + + resultats.Add(new TrajetCarteDto( + trajet.Id, + trajet.Date, + trajet.AdresseDepart, + trajet.AdresseArrivee, + trajet.Distance ?? 0m, + depart?.Latitude, + depart?.Longitude, + arrivee?.Latitude, + arrivee?.Longitude, + geometrie)); + } + + return Ok(resultats); + } +} diff --git a/backend/TrajetKmTracker.Api/Controllers/VehiculesController.cs b/backend/TrajetKmTracker.Api/Controllers/VehiculesController.cs new file mode 100644 index 0000000..7d07668 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Controllers/VehiculesController.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Mvc; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Controllers; + +/// +/// Endpoints de gestion des véhicules : historique, véhicule courant, création et réaffectation. +/// +[ApiController] +[Route("api/vehicules")] +public class VehiculesController : ControllerBase +{ + private readonly IVehiculeService _vehiculeService; + + public VehiculesController(IVehiculeService vehiculeService) + { + _vehiculeService = vehiculeService; + } + + /// Historique complet des véhicules (trié par date d'effet décroissante). + [HttpGet] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetHistorique(CancellationToken cancellationToken) + { + var vehicules = await _vehiculeService.GetHistoriqueAsync(cancellationToken); + return Ok(vehicules); + } + + /// Véhicule courant (date d'effet la plus récente ≤ aujourd'hui). + [HttpGet("courant")] + [ProducesResponseType(typeof(VehiculeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetCourant(CancellationToken cancellationToken) + { + try + { + var vehicule = await _vehiculeService.GetVehiculeCourantAsync(cancellationToken); + return Ok(vehicule); + } + catch (VehiculeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + } + + /// + /// Enregistre un véhicule. Retourne indiquant si une réaffectation + /// des trajets doit être proposée. Rejette une date d'effet future (400). + /// + [HttpPost] + [ProducesResponseType(typeof(VehiculeCreeDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Create( + [FromBody] CreateVehiculeDto dto, + CancellationToken cancellationToken) + { + try + { + var resultat = await _vehiculeService.CreateAsync(dto, cancellationToken); + return CreatedAtAction(nameof(GetHistorique), null, resultat); + } + catch (DateEffetFutureException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + } + + /// Accepte ou refuse la réaffectation des trajets au véhicule indiqué. + [HttpPost("reaffectation")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Reaffecter( + [FromBody] ReaffectationDto dto, + CancellationToken cancellationToken) + { + try + { + await _vehiculeService.ReaffecterAsync(dto, cancellationToken); + return NoContent(); + } + catch (VehiculeNotFoundException ex) + { + return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/AppDbContext.cs b/backend/TrajetKmTracker.Api/Data/AppDbContext.cs new file mode 100644 index 0000000..1bb51ea --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/AppDbContext.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Models.Entities; + +namespace TrajetKmTracker.Api.Data; + +/// +/// Contexte EF Core de l'application. Déclare les , les contraintes CHECK, +/// les index et les index uniques conformément à la section « Schéma SQLite » de la conception. +/// +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Trajets => Set(); + public DbSet Vehicules => Set(); + public DbSet Profil => Set(); + public DbSet AdresseCache => Set(); + public DbSet AdressesNommees => Set(); + public DbSet TranchesBareme => Set(); + public DbSet MontantsRegles => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // --- Trajets --- + modelBuilder.Entity(entity => + { + entity.ToTable("Trajets", t => + { + // Distance nulle autorisée (Exigence 2.4 : distance non calculable) ; sinon strictement positive. + t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0"); + t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0"); + }); + + entity.HasIndex(t => t.Date, "IX_Trajets_Date"); + entity.HasIndex(t => new { t.Date, t.Position }, "IX_Trajets_Date_Position"); + entity.HasIndex(t => t.VehiculeId, "IX_Trajets_Vehicule"); + + entity.HasOne(t => t.Vehicule) + .WithMany(v => v.Trajets) + .HasForeignKey(t => t.VehiculeId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // --- Vehicules --- + modelBuilder.Entity(entity => + { + entity.ToTable("Vehicules", t => + t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0")); + + entity.HasIndex(v => v.DateEffet, "IX_Vehicules_DateEffet"); + }); + + // --- Profil (singleton Id = 1) --- + modelBuilder.Entity(entity => + { + entity.ToTable("Profil"); + entity.HasKey(p => p.Id); + entity.Property(p => p.Id).ValueGeneratedNever(); + entity.HasIndex(p => p.Id, "UX_Profil_Singleton").IsUnique(); + }); + + // --- AdresseCache (Adresse unique) --- + modelBuilder.Entity(entity => + { + entity.ToTable("AdresseCache"); + entity.HasIndex(a => a.Adresse).IsUnique(); + }); + + // --- AdressesNommees (unicité sur DenominationNormalisee) --- + modelBuilder.Entity(entity => + { + entity.ToTable("AdressesNommees"); + entity.HasIndex(a => a.DenominationNormalisee, "UX_AdresseNommee_Denomination").IsUnique(); + }); + + // --- TranchesBareme --- + modelBuilder.Entity(entity => + { + // Convention [KmMin, KmMax[ : borne max EXCLUSIVE, ou NULL = illimité. + // La contrainte tolère KmMax NULL et impose sinon KmMin < KmMax. + // Convention [KmMin, KmMax[ : borne max EXCLUSIVE, ou NULL = illimité. + // Les décimaux sont stockés en TEXT par SQLite : on CAST en REAL pour comparer + // numériquement (sinon "5000" > "20000" en comparaison lexicographique de chaînes). + entity.ToTable("TranchesBareme", t => + t.HasCheckConstraint( + "CK_TranchesBareme_Km", + "\"KmMax\" IS NULL OR CAST(\"KmMin\" AS REAL) < CAST(\"KmMax\" AS REAL)")); + + entity.HasIndex(t => new { t.Annee, t.PuissanceFiscale }, "IX_TranchesBareme_Annee_PF"); + }); + + // --- MontantsRegles --- + modelBuilder.Entity(entity => + { + entity.ToTable("MontantsRegles", t => + { + t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0"); + t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12"); + }); + + entity.HasIndex(m => new { m.Annee, m.Mois, m.VehiculeId }, "UX_MontantsRegles_Periode_Vehicule") + .IsUnique(); + + entity.HasOne(m => m.Vehicule) + .WithMany() + .HasForeignKey(m => m.VehiculeId) + .OnDelete(DeleteBehavior.Restrict); + }); + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913083053_InitialCreate.Designer.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913083053_InitialCreate.Designer.cs new file mode 100644 index 0000000..71880b3 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913083053_InitialCreate.Designer.cs @@ -0,0 +1,325 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TrajetKmTracker.Api.Data; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260913083053_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("Commentaire") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Distance") + .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\" > 0"); + + t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0"); + }); + }); + + modelBuilder.Entity("TrajetKmTracker.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", "\"KmMin\" <= \"KmMax\""); + }); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DateEffet") + .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("TrajetKmTracker.Api.Models.Entities.MontantRegle", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany() + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany("Trajets") + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Navigation("Trajets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913083053_InitialCreate.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913083053_InitialCreate.cs new file mode 100644 index 0000000..c5ed4cb --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913083053_InitialCreate.cs @@ -0,0 +1,241 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AdresseCache", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Adresse = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Latitude = table.Column(type: "REAL", nullable: false), + Longitude = table.Column(type: "REAL", nullable: false), + GeometrieJson = table.Column(type: "TEXT", nullable: true), + DerniereUtilisation = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AdresseCache", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AdressesNommees", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Denomination = table.Column(type: "TEXT", maxLength: 100, nullable: false), + DenominationNormalisee = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Adresse = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Latitude = table.Column(type: "REAL", nullable: true), + Longitude = table.Column(type: "REAL", nullable: true), + GeocodageReussi = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AdressesNommees", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Profil", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + Nom = table.Column(type: "TEXT", maxLength: 100, nullable: true), + Prenom = table.Column(type: "TEXT", maxLength: 100, nullable: true), + Matricule = table.Column(type: "TEXT", maxLength: 8, nullable: true), + AdresseDomicile = table.Column(type: "TEXT", maxLength: 255, nullable: true), + DomicileLatitude = table.Column(type: "REAL", nullable: true), + DomicileLongitude = table.Column(type: "REAL", nullable: true), + DomicileGeocode = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Profil", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TranchesBareme", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Annee = table.Column(type: "INTEGER", nullable: false), + PuissanceFiscale = table.Column(type: "INTEGER", nullable: false), + KmMin = table.Column(type: "TEXT", nullable: false), + KmMax = table.Column(type: "TEXT", nullable: false), + Coefficient = table.Column(type: "TEXT", nullable: false), + Base = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TranchesBareme", x => x.Id); + table.CheckConstraint("CK_TranchesBareme_Km", "\"KmMin\" <= \"KmMax\""); + }); + + migrationBuilder.CreateTable( + name: "Vehicules", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Plaque = table.Column(type: "TEXT", maxLength: 15, nullable: false), + PuissanceFiscale = table.Column(type: "INTEGER", nullable: false), + DateEffet = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Vehicules", x => x.Id); + table.CheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0"); + }); + + migrationBuilder.CreateTable( + name: "MontantsRegles", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Annee = table.Column(type: "INTEGER", nullable: false), + Mois = table.Column(type: "INTEGER", nullable: false), + VehiculeId = table.Column(type: "INTEGER", nullable: false), + Montant = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MontantsRegles", x => x.Id); + table.CheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12"); + table.CheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0"); + table.ForeignKey( + name: "FK_MontantsRegles_Vehicules_VehiculeId", + column: x => x.VehiculeId, + principalTable: "Vehicules", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Trajets", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Date = table.Column(type: "TEXT", nullable: false), + AdresseDepart = table.Column(type: "TEXT", maxLength: 255, nullable: false), + AdresseArrivee = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Distance = table.Column(type: "TEXT", nullable: false), + Commentaire = table.Column(type: "TEXT", maxLength: 500, nullable: true), + Position = table.Column(type: "TEXT", nullable: false), + VehiculeId = table.Column(type: "INTEGER", nullable: false), + PuissanceFiscale = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Trajets", x => x.Id); + table.CheckConstraint("CK_Trajets_Distance", "\"Distance\" > 0"); + table.CheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0"); + table.ForeignKey( + name: "FK_Trajets_Vehicules_VehiculeId", + column: x => x.VehiculeId, + principalTable: "Vehicules", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AdresseCache_Adresse", + table: "AdresseCache", + column: "Adresse", + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_AdresseNommee_Denomination", + table: "AdressesNommees", + column: "DenominationNormalisee", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MontantsRegles_VehiculeId", + table: "MontantsRegles", + column: "VehiculeId"); + + migrationBuilder.CreateIndex( + name: "UX_MontantsRegles_Periode_Vehicule", + table: "MontantsRegles", + columns: new[] { "Annee", "Mois", "VehiculeId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_Profil_Singleton", + table: "Profil", + column: "Id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Trajets_Date", + table: "Trajets", + column: "Date"); + + migrationBuilder.CreateIndex( + name: "IX_Trajets_Date_Position", + table: "Trajets", + columns: new[] { "Date", "Position" }); + + migrationBuilder.CreateIndex( + name: "IX_Trajets_Vehicule", + table: "Trajets", + column: "VehiculeId"); + + migrationBuilder.CreateIndex( + name: "IX_TranchesBareme_Annee_PF", + table: "TranchesBareme", + columns: new[] { "Annee", "PuissanceFiscale" }); + + migrationBuilder.CreateIndex( + name: "IX_Vehicules_DateEffet", + table: "Vehicules", + column: "DateEffet"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AdresseCache"); + + migrationBuilder.DropTable( + name: "AdressesNommees"); + + migrationBuilder.DropTable( + name: "MontantsRegles"); + + migrationBuilder.DropTable( + name: "Profil"); + + migrationBuilder.DropTable( + name: "Trajets"); + + migrationBuilder.DropTable( + name: "TranchesBareme"); + + migrationBuilder.DropTable( + name: "Vehicules"); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913085051_TrajetDistanceNullable.Designer.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913085051_TrajetDistanceNullable.Designer.cs new file mode 100644 index 0000000..4849153 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913085051_TrajetDistanceNullable.Designer.cs @@ -0,0 +1,325 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TrajetKmTracker.Api.Data; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260913085051_TrajetDistanceNullable")] + partial class TrajetDistanceNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("Commentaire") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Distance") + .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("TrajetKmTracker.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", "\"KmMin\" <= \"KmMax\""); + }); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DateEffet") + .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("TrajetKmTracker.Api.Models.Entities.MontantRegle", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany() + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany("Trajets") + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Navigation("Trajets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913085051_TrajetDistanceNullable.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913085051_TrajetDistanceNullable.cs new file mode 100644 index 0000000..4fa39db --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913085051_TrajetDistanceNullable.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + /// + public partial class TrajetDistanceNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_Trajets_Distance", + table: "Trajets"); + + migrationBuilder.AlterColumn( + name: "Distance", + table: "Trajets", + type: "TEXT", + nullable: true, + oldClrType: typeof(decimal), + oldType: "TEXT"); + + migrationBuilder.AddCheckConstraint( + name: "CK_Trajets_Distance", + table: "Trajets", + sql: "\"Distance\" IS NULL OR \"Distance\" > 0"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_Trajets_Distance", + table: "Trajets"); + + migrationBuilder.AlterColumn( + name: "Distance", + table: "Trajets", + type: "TEXT", + nullable: false, + defaultValue: 0m, + oldClrType: typeof(decimal), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddCheckConstraint( + name: "CK_Trajets_Distance", + table: "Trajets", + sql: "\"Distance\" > 0"); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913224032_TrajetGeometrieJson.Designer.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913224032_TrajetGeometrieJson.Designer.cs new file mode 100644 index 0000000..3e842d4 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913224032_TrajetGeometrieJson.Designer.cs @@ -0,0 +1,328 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TrajetKmTracker.Api.Data; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260913224032_TrajetGeometrieJson")] + partial class TrajetGeometrieJson + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("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("TrajetKmTracker.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", "\"KmMin\" <= \"KmMax\""); + }); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DateEffet") + .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("TrajetKmTracker.Api.Models.Entities.MontantRegle", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany() + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany("Trajets") + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Navigation("Trajets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913224032_TrajetGeometrieJson.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913224032_TrajetGeometrieJson.cs new file mode 100644 index 0000000..6d24fdf --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913224032_TrajetGeometrieJson.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + /// + public partial class TrajetGeometrieJson : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GeometrieJson", + table: "Trajets", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GeometrieJson", + table: "Trajets"); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913231424_VehiculeDenomination.Designer.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913231424_VehiculeDenomination.Designer.cs new file mode 100644 index 0000000..edd81fc --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913231424_VehiculeDenomination.Designer.cs @@ -0,0 +1,333 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TrajetKmTracker.Api.Data; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260913231424_VehiculeDenomination")] + partial class VehiculeDenomination + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("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("TrajetKmTracker.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", "\"KmMin\" <= \"KmMax\""); + }); + }); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.Api.Models.Entities.MontantRegle", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany() + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany("Trajets") + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Navigation("Trajets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913231424_VehiculeDenomination.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913231424_VehiculeDenomination.cs new file mode 100644 index 0000000..e68b7b4 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913231424_VehiculeDenomination.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + /// + public partial class VehiculeDenomination : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Denomination", + table: "Vehicules", + type: "TEXT", + maxLength: 100, + nullable: false, + defaultValue: ""); + + // Véhicules déjà existants : dénomination par défaut = leur plaque, pour éviter + // une dénomination vide (le champ est désormais obligatoire côté saisie). + migrationBuilder.Sql( + "UPDATE \"Vehicules\" SET \"Denomination\" = \"Plaque\" " + + "WHERE \"Denomination\" IS NULL OR \"Denomination\" = '';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Denomination", + table: "Vehicules"); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913233718_TrancheBaremeKmMaxNullable.Designer.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913233718_TrancheBaremeKmMaxNullable.Designer.cs new file mode 100644 index 0000000..3a3ed45 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913233718_TrancheBaremeKmMaxNullable.Designer.cs @@ -0,0 +1,333 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TrajetKmTracker.Api.Data; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260913233718_TrancheBaremeKmMaxNullable")] + partial class TrancheBaremeKmMaxNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("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("TrajetKmTracker.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", "\"KmMin\" <= \"KmMax\""); + }); + }); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.Api.Models.Entities.MontantRegle", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany() + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany("Trajets") + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Navigation("Trajets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913233718_TrancheBaremeKmMaxNullable.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913233718_TrancheBaremeKmMaxNullable.cs new file mode 100644 index 0000000..9d2da05 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913233718_TrancheBaremeKmMaxNullable.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + /// + public partial class TrancheBaremeKmMaxNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "KmMax", + table: "TranchesBareme", + type: "TEXT", + nullable: true, + oldClrType: typeof(decimal), + oldType: "TEXT"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "KmMax", + table: "TranchesBareme", + type: "TEXT", + nullable: false, + defaultValue: 0m, + oldClrType: typeof(decimal), + oldType: "TEXT", + oldNullable: true); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913234022_TrancheBaremeKmCheckExclusif.Designer.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913234022_TrancheBaremeKmCheckExclusif.Designer.cs new file mode 100644 index 0000000..5cb8850 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913234022_TrancheBaremeKmCheckExclusif.Designer.cs @@ -0,0 +1,333 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TrajetKmTracker.Api.Data; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260913234022_TrancheBaremeKmCheckExclusif")] + partial class TrancheBaremeKmCheckExclusif + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("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("TrajetKmTracker.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 \"KmMin\" < \"KmMax\""); + }); + }); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.Api.Models.Entities.MontantRegle", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany() + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany("Trajets") + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Navigation("Trajets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913234022_TrancheBaremeKmCheckExclusif.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913234022_TrancheBaremeKmCheckExclusif.cs new file mode 100644 index 0000000..c69b117 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913234022_TrancheBaremeKmCheckExclusif.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + /// + public partial class TrancheBaremeKmCheckExclusif : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_TranchesBareme_Km", + table: "TranchesBareme"); + + migrationBuilder.AddCheckConstraint( + name: "CK_TranchesBareme_Km", + table: "TranchesBareme", + sql: "\"KmMax\" IS NULL OR \"KmMin\" < \"KmMax\""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_TranchesBareme_Km", + table: "TranchesBareme"); + + migrationBuilder.AddCheckConstraint( + name: "CK_TranchesBareme_Km", + table: "TranchesBareme", + sql: "\"KmMin\" <= \"KmMax\""); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913234153_TrancheBaremeKmCheckNumerique.Designer.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913234153_TrancheBaremeKmCheckNumerique.Designer.cs new file mode 100644 index 0000000..aa3dd81 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913234153_TrancheBaremeKmCheckNumerique.Designer.cs @@ -0,0 +1,333 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TrajetKmTracker.Api.Data; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260913234153_TrancheBaremeKmCheckNumerique")] + partial class TrancheBaremeKmCheckNumerique + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.Api.Models.Entities.MontantRegle", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany() + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany("Trajets") + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Navigation("Trajets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/20260913234153_TrancheBaremeKmCheckNumerique.cs b/backend/TrajetKmTracker.Api/Data/Migrations/20260913234153_TrancheBaremeKmCheckNumerique.cs new file mode 100644 index 0000000..5864475 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/20260913234153_TrancheBaremeKmCheckNumerique.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + /// + public partial class TrancheBaremeKmCheckNumerique : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_TranchesBareme_Km", + table: "TranchesBareme"); + + migrationBuilder.AddCheckConstraint( + name: "CK_TranchesBareme_Km", + table: "TranchesBareme", + sql: "\"KmMax\" IS NULL OR CAST(\"KmMin\" AS REAL) < CAST(\"KmMax\" AS REAL)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_TranchesBareme_Km", + table: "TranchesBareme"); + + migrationBuilder.AddCheckConstraint( + name: "CK_TranchesBareme_Km", + table: "TranchesBareme", + sql: "\"KmMax\" IS NULL OR \"KmMin\" < \"KmMax\""); + } + } +} diff --git a/backend/TrajetKmTracker.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/TrajetKmTracker.Api/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..04a9a73 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,330 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TrajetKmTracker.Api.Data; + +#nullable disable + +namespace TrajetKmTracker.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.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("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("TrajetKmTracker.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("TrajetKmTracker.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("TrajetKmTracker.Api.Models.Entities.MontantRegle", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany() + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Trajet", b => + { + b.HasOne("TrajetKmTracker.Api.Models.Entities.Vehicule", "Vehicule") + .WithMany("Trajets") + .HasForeignKey("VehiculeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vehicule"); + }); + + modelBuilder.Entity("TrajetKmTracker.Api.Models.Entities.Vehicule", b => + { + b.Navigation("Trajets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/TrajetKmTracker.Api/Models/DTOs/AdresseDtos.cs b/backend/TrajetKmTracker.Api/Models/DTOs/AdresseDtos.cs new file mode 100644 index 0000000..4239a1d --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/DTOs/AdresseDtos.cs @@ -0,0 +1,42 @@ +namespace TrajetKmTracker.Api.Models.DTOs; + +// Résultat de recherche BAN (proxied via backend si nécessaire) +public record BANAdresseDto( + string Label, + double Latitude, + double Longitude, + string Postcode, + string City +); + +// Suggestion autocomplétion +public record AdresseCacheDto( + string Adresse, + double Latitude, + double Longitude +); + +// Entrée complète du Cache_Adresses (lookup exact par la création de Trajet) +public record AdresseCacheEntreeDto( + string Adresse, + double Latitude, + double Longitude, + string? GeometrieJson +); + +// Carnet d'adresses — Adresse_Nommee retournée par le backend +public record AdresseNommeeDto( + int Id, + string Denomination, + string Adresse, + double? Latitude, // null si le géocodage a échoué + double? Longitude // null si le géocodage a échoué +); + +// Carnet d'adresses — création / mise à jour d'une Adresse_Nommee +public record SaveAdresseNommeeDto( + string Denomination, // 1 à 100 car., unique après normalisation + string Adresse, // 1 à 255 car. + double? Latitude, // fournies par le frontend si sélection BAN + double? Longitude +); diff --git a/backend/TrajetKmTracker.Api/Models/DTOs/BaremeDtos.cs b/backend/TrajetKmTracker.Api/Models/DTOs/BaremeDtos.cs new file mode 100644 index 0000000..f495059 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/DTOs/BaremeDtos.cs @@ -0,0 +1,63 @@ +namespace TrajetKmTracker.Api.Models.DTOs; + +// Barème +public record TrancheBaremeDto( + int Id, + int Annee, + int PuissanceFiscale, + decimal KmMin, + decimal? KmMax, // null = illimité (borne max exclusive : [KmMin, KmMax[) + decimal Coefficient, + decimal Base +); + +public record SaveTrancheBaremeDto( + int Annee, + int PuissanceFiscale, + decimal KmMin, + decimal? KmMax, // null = illimité (borne max exclusive : [KmMin, KmMax[) + decimal Coefficient, + decimal Base +); + +// Montant réglé +public record MontantRegleDto( + int Annee, + int Mois, + int VehiculeId, + decimal Montant +); + +// Indication de versement mensuel (informatif) affichée à la saisie +public record MontantPrevisionnelDto( + int Annee, + int Mois, + int VehiculeId, + decimal TotalAnnuelPartielKm, + decimal MontantBaremePlein, // barème plein sur le total partiel + decimal MontantPrevisionnel // = 80% (indicatif uniquement) +); + +// Ligne mensuelle de la synthèse annuelle (aligne sur la synthèse réelle) +public record LigneSyntheseMoisDto( + int Mois, + decimal KmMois, // kilomètres parcourus dans le mois + decimal FraisPaye, // Montant_Regle du mois (saisi) + decimal FraisReelMensuel // km_du_mois × coefficient de la tranche annuelle +); + +// Synthèse annuelle de régularisation pour un véhicule (page 1 du rapport) +public record RegularisationAnnuelleDto( + int Annee, + int VehiculeId, + string Plaque, + int PuissanceFiscale, + decimal CoefficientTranche, // coefficient de la tranche annuelle applicable + decimal BaseTranche, // base de la tranche annuelle applicable + string TrancheLibelle, // ex. "5 001 à 20 000 km — D2" + IReadOnlyList Mois, // 12 lignes + decimal TotalKmAnnuel, + decimal TotalPaye, // somme des Montant_Regle + decimal MontantReel, // barème plein : total_km × coef + base + decimal Regularisation // MontantReel − TotalPaye +); diff --git a/backend/TrajetKmTracker.Api/Models/DTOs/CopieBaremeResultDto.cs b/backend/TrajetKmTracker.Api/Models/DTOs/CopieBaremeResultDto.cs new file mode 100644 index 0000000..d342456 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/DTOs/CopieBaremeResultDto.cs @@ -0,0 +1,5 @@ +namespace TrajetKmTracker.Api.Models.DTOs; + +/// Résultat de la copie du dernier barème vers une année cible. +/// Nombre de tranches copiées (0 si aucun barème antérieur). +public record CopieBaremeResultDto(int TranchesCopiees); diff --git a/backend/TrajetKmTracker.Api/Models/DTOs/ProfilDtos.cs b/backend/TrajetKmTracker.Api/Models/DTOs/ProfilDtos.cs new file mode 100644 index 0000000..b80ebfa --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/DTOs/ProfilDtos.cs @@ -0,0 +1,22 @@ +namespace TrajetKmTracker.Api.Models.DTOs; + +// Profil +public record ProfilDto( + string? Nom, + string? Prenom, + string? Matricule, + string? AdresseDomicile, + bool DomicileGeocode +); + +public record SaveProfilDto( + string? Nom, + string? Prenom, + string? Matricule, // max 8 car. + string AdresseDomicile +); + +// État de configuration du profil (onboarding première utilisation) +public record EtatProfilDto( + bool EstConfigure // true ssi Profil.Nom et Profil.Prenom non vides ET au moins un Vehicule existe +); diff --git a/backend/TrajetKmTracker.Api/Models/DTOs/RecalculDistancesResultDto.cs b/backend/TrajetKmTracker.Api/Models/DTOs/RecalculDistancesResultDto.cs new file mode 100644 index 0000000..f3a1ce6 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/DTOs/RecalculDistancesResultDto.cs @@ -0,0 +1,5 @@ +namespace TrajetKmTracker.Api.Models.DTOs; + +/// Résultat du recalcul en masse des distances des trajets. +/// Nombre de trajets dont la distance a été recalculée. +public record RecalculDistancesResultDto(int TrajetsRecalcules); diff --git a/backend/TrajetKmTracker.Api/Models/DTOs/ResumeDtos.cs b/backend/TrajetKmTracker.Api/Models/DTOs/ResumeDtos.cs new file mode 100644 index 0000000..2f5c731 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/DTOs/ResumeDtos.cs @@ -0,0 +1,25 @@ +namespace TrajetKmTracker.Api.Models.DTOs; + +// Résumé mensuel +public record ResumeMensuelDto( + int Annee, + int Mois, + IReadOnlyList Trajets, + decimal TotalKm // somme distances, 2 décimales +); + +// Résumé annuel +public record ResumeAnnuelDto( + int Annee, + IReadOnlyList Mois, // toujours 12 éléments + decimal TotalKm +); + +// Totaux affichés dans le Panneau_Totaux +public record TotauxDto( + DateOnly Date, + int VehiculeId, // véhicule sur lequel portent les totaux + decimal TotalJour, + decimal TotalMois, + decimal TotalAnnuelPartiel +); diff --git a/backend/TrajetKmTracker.Api/Models/DTOs/TrajetDtos.cs b/backend/TrajetKmTracker.Api/Models/DTOs/TrajetDtos.cs new file mode 100644 index 0000000..54f2981 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/DTOs/TrajetDtos.cs @@ -0,0 +1,64 @@ +namespace TrajetKmTracker.Api.Models.DTOs; + +// Création — les coordonnées sont transmises par le Frontend (issues de la sélection BAN) +public record CreateTrajetDto( + string Date, // JJ/MM/AAAA en entrée + string AdresseDepart, + double? DepartLatitude, // null si adresse saisie manuellement sans BAN + double? DepartLongitude, + string AdresseArrivee, + 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 +); + +// Mise à jour +public record UpdateTrajetDto( + string Date, + string AdresseDepart, + double? DepartLatitude, + double? DepartLongitude, + string AdresseArrivee, + double? ArriveeLatitude, + double? ArriveeLongitude, + string? Commentaire, + int? VehiculeId // optionnel ; null → véhicule courant à la date du trajet +); + +// Réponse trajet +public record TrajetDto( + int Id, + 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) + string? Commentaire, + decimal Position, // rang dans la journée + int VehiculeId, + int PuissanceFiscale +); + +// Trajet enrichi pour la carte +public record TrajetCarteDto( + int Id, + DateOnly Date, + string AdresseDepart, + string AdresseArrivee, + decimal Distance, + double? DepartLatitude, + double? DepartLongitude, + double? ArriveeLatitude, + double? ArriveeLongitude, + string? GeometrieJson // GeoJSON LineString ou null +); + +// Réordonnancement manuel des trajets d'une journée +public record ReordonnerTrajetsDto( + IReadOnlyList Trajets +); + +public record TrajetPositionDto( + int Id, + decimal Position +); diff --git a/backend/TrajetKmTracker.Api/Models/DTOs/VehiculeDtos.cs b/backend/TrajetKmTracker.Api/Models/DTOs/VehiculeDtos.cs new file mode 100644 index 0000000..df210e8 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/DTOs/VehiculeDtos.cs @@ -0,0 +1,30 @@ +namespace TrajetKmTracker.Api.Models.DTOs; + +// Véhicules +public record VehiculeDto( + int Id, + string Denomination, + string Plaque, + int PuissanceFiscale, + DateOnly DateEffet +); + +public record CreateVehiculeDto( + string Denomination, + string Plaque, + int PuissanceFiscale, + string DateEffet // JJ/MM/AAAA, <= date du jour +); + +// Réponse à la création d'un véhicule : indique s'il faut proposer une réaffectation +public record VehiculeCreeDto( + VehiculeDto Vehicule, + bool ReaffectationProposee, // true si des trajets >= DateEffet ont une autre puissance fiscale + int NombreTrajetsConcernes +); + +// Requête de réaffectation +public record ReaffectationDto( + int VehiculeId, // nouveau véhicule + bool Accepter // true = réaffecter les trajets >= DateEffet ; false = ne rien changer +); diff --git a/backend/TrajetKmTracker.Api/Models/Entities/AdresseCache.cs b/backend/TrajetKmTracker.Api/Models/Entities/AdresseCache.cs new file mode 100644 index 0000000..6e97979 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/Entities/AdresseCache.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrajetKmTracker.Api.Models.Entities; + +/// Cache des adresses géocodées via ORS. +public class AdresseCache +{ + public int Id { get; set; } + + [Required, MaxLength(255)] + public string Adresse { get; set; } = string.Empty; // texte normalisé + + public double Latitude { get; set; } // précision 6 décimales + public double Longitude { get; set; } // précision 6 décimales + + /// Géométrie GeoJSON de l'itinéraire (null si indisponible). + public string? GeometrieJson { get; set; } + + public DateTime DerniereUtilisation { get; set; } = DateTime.UtcNow; +} diff --git a/backend/TrajetKmTracker.Api/Models/Entities/AdresseNommee.cs b/backend/TrajetKmTracker.Api/Models/Entities/AdresseNommee.cs new file mode 100644 index 0000000..783f542 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/Entities/AdresseNommee.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrajetKmTracker.Api.Models.Entities; + +/// +/// Adresse enregistrée dans le Carnet_Adresses, identifiée par une Dénomination unique +/// et réutilisable par sa Dénomination lors de la saisie/insertion d'un Trajet. +/// +public class AdresseNommee +{ + public int Id { get; set; } + + /// Libellé libre et unique identifiant l'adresse (1 à 100 caractères). + [Required, MaxLength(100)] + public string Denomination { get; set; } = string.Empty; + + /// + /// Forme normalisée de la Dénomination (trim + minuscules + sans accents) servant de clé + /// d'unicité via l'index unique UX_AdresseNommee_Denomination. Calculée par le backend. + /// + [Required, MaxLength(100)] + public string DenominationNormalisee { get; set; } = string.Empty; + + /// Texte d'adresse résolu via l'autocomplétion BAN (1 à 255 caractères). + [Required, MaxLength(255)] + public string Adresse { get; set; } = string.Empty; + + /// Latitude (précision 6 décimales) ; null si le géocodage a échoué. + public double? Latitude { get; set; } + + /// Longitude (précision 6 décimales) ; null si le géocodage a échoué. + public double? Longitude { get; set; } + + /// Vrai si les coordonnées ont été obtenues (cache ou BAN) ; faux sinon. + public bool GeocodageReussi { get; set; } = false; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/TrajetKmTracker.Api/Models/Entities/MontantRegle.cs b/backend/TrajetKmTracker.Api/Models/Entities/MontantRegle.cs new file mode 100644 index 0000000..3cd3ee1 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/Entities/MontantRegle.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrajetKmTracker.Api.Models.Entities; + +/// Montant réglé (remboursement reçu) pour un mois, une année et un véhicule. +public class MontantRegle +{ + public int Id { get; set; } + + [Required] + public int Annee { get; set; } + + [Required] + public int Mois { get; set; } // 1–12 + + [Required] + public int VehiculeId { get; set; } + public Vehicule? Vehicule { get; set; } + + /// Montant réglé en euros (>= 0), deux décimales. + [Required] + public decimal Montant { get; set; } + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/TrajetKmTracker.Api/Models/Entities/Profil.cs b/backend/TrajetKmTracker.Api/Models/Entities/Profil.cs new file mode 100644 index 0000000..52e959e --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/Entities/Profil.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrajetKmTracker.Api.Models.Entities; + +/// Profil utilisateur (singleton — une seule ligne). +public class Profil +{ + public int Id { get; set; } = 1; // toujours 1 + + [MaxLength(100)] + public string? Nom { get; set; } + + [MaxLength(100)] + public string? Prenom { get; set; } + + /// Matricule (8 caractères maximum). + [MaxLength(8)] + public string? Matricule { get; set; } + + [MaxLength(255)] + public string? AdresseDomicile { get; set; } + + public double? DomicileLatitude { get; set; } + public double? DomicileLongitude { get; set; } + public bool DomicileGeocode { get; set; } = false; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/TrajetKmTracker.Api/Models/Entities/Trajet.cs b/backend/TrajetKmTracker.Api/Models/Entities/Trajet.cs new file mode 100644 index 0000000..baaad10 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/Entities/Trajet.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrajetKmTracker.Api.Models.Entities; + +/// Trajet kilométrique saisi par l'utilisateur. +public class Trajet +{ + public int Id { get; set; } + + [Required] + public DateOnly Date { get; set; } + + [Required, MaxLength(255)] + public string AdresseDepart { get; set; } = string.Empty; + + [Required, MaxLength(255)] + public string AdresseArrivee { get; set; } = string.Empty; + + /// + /// Distance en kilomètres, arrondie à 2 décimales, strictement positive lorsqu'elle est + /// connue. null lorsque la distance n'a pas pu être calculée (aucune coordonnée + /// résoluble via le Cache_Adresses / la BAN) : le Trajet est alors persisté avec une + /// Distance nulle et un avertissement (Exigence 2.4). La modification ultérieure peut + /// recalculer la Distance si de nouvelles coordonnées deviennent disponibles (Exigence 5.10). + /// + public decimal? Distance { get; set; } + + /// Commentaire optionnel (0–500 caractères). + [MaxLength(500)] + public string? Commentaire { get; set; } + + /// + /// Géométrie GeoJSON (LineString) de l'itinéraire routier départ→arrivée, telle que + /// renvoyée par OpenRouteService, pour le tracé exact sur la carte. null lorsque + /// l'itinéraire routier n'a pas pu être obtenu (fallback vol d'oiseau) : la carte trace + /// alors une ligne droite. La géométrie dépend de la PAIRE départ→arrivée, c'est pourquoi + /// elle est portée par le Trajet et non par le Cache_Adresses (indexé par adresse seule). + /// + public string? GeometrieJson { get; set; } + + /// + /// Rang du trajet dans la liste de la journée. Valeur décimale (ex. 1.0, 1.5, 2.0) + /// permettant l'insertion sans réordonnancement systématique. + /// Trié par ordre croissant pour l'affichage. + /// + [Required] + public decimal Position { get; set; } + + /// Référence au véhicule associé au trajet (véhicule courant à la date de création par défaut). + [Required] + public int VehiculeId { get; set; } + public Vehicule? Vehicule { get; set; } + + /// Puissance fiscale (CV) du véhicule au moment de l'association ; stockée pour l'historique. + [Required] + public int PuissanceFiscale { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/TrajetKmTracker.Api/Models/Entities/TrancheBareme.cs b/backend/TrajetKmTracker.Api/Models/Entities/TrancheBareme.cs new file mode 100644 index 0000000..71f72df --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/Entities/TrancheBareme.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrajetKmTracker.Api.Models.Entities; + +/// +/// Tranche du barème kilométrique URSSAF pour une année et une puissance fiscale. +/// Trois tranches par (année, puissance fiscale) : ≤5000 km, 5001–20000 km, >20000 km. +/// +public class TrancheBareme +{ + public int Id { get; set; } + + [Required] + public int Annee { get; set; } + + [Required] + public int PuissanceFiscale { get; set; } + + /// Borne minimale de kilométrage annuel (incluse). + [Required] + public decimal KmMin { get; set; } + + /// + /// Borne maximale de kilométrage annuel, EXCLUSIVE : la tranche couvre [KmMin, KmMax[. + /// null signifie « illimité » (dernière tranche, sans borne supérieure). + /// + public decimal? KmMax { get; set; } + + /// Coefficient en euros par kilomètre. + [Required] + public decimal Coefficient { get; set; } + + /// Base fixe en euros ajoutée au montant (nulle pour les tranches 1 et 3). Formule : km × Coefficient + Base. + public decimal Base { get; set; } = 0m; +} diff --git a/backend/TrajetKmTracker.Api/Models/Entities/Vehicule.cs b/backend/TrajetKmTracker.Api/Models/Entities/Vehicule.cs new file mode 100644 index 0000000..8eaee2f --- /dev/null +++ b/backend/TrajetKmTracker.Api/Models/Entities/Vehicule.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrajetKmTracker.Api.Models.Entities; + +/// Véhicule de l'utilisateur avec date d'effet. Historique conservé. +public class Vehicule +{ + public int Id { get; set; } + + /// Dénomination libre du véhicule (ex. « Voiture personnelle »), obligatoire (1–100 caractères). + [Required, MaxLength(100)] + public string Denomination { get; set; } = string.Empty; + + /// Plaque d'immatriculation (1–15 caractères). + [Required, MaxLength(15)] + public string Plaque { get; set; } = string.Empty; + + /// Puissance fiscale en chevaux fiscaux (entier positif). + [Required] + public int PuissanceFiscale { get; set; } + + /// Date à partir de laquelle ce véhicule devient le véhicule courant (<= date du jour). + [Required] + public DateOnly DateEffet { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public ICollection Trajets { get; set; } = new List(); +} diff --git a/backend/TrajetKmTracker.Api/Program.cs b/backend/TrajetKmTracker.Api/Program.cs new file mode 100644 index 0000000..7396fd0 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Program.cs @@ -0,0 +1,174 @@ +using FluentValidation; +using FluentValidation.AspNetCore; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Validators; + +using TrajetKmTracker.Api.Configuration; +using TrajetKmTracker.Api.Repositories; +using TrajetKmTracker.Api.Services; + +// Licence QuestPDF (usage communautaire, gratuit) — requise avant toute génération de PDF. +QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community; + +var builder = WebApplication.CreateBuilder(args); + +// --- Options de configuration liées depuis appsettings.json --- +builder.Services.Configure( + builder.Configuration.GetSection(BANOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(ORSOptions.SectionName)); + +// --- Client typé BAN (Base Adresses Nationale) --- +// BaseAddress et Timeout proviennent de BANOptions (appsettings.json). +var banOptions = builder.Configuration + .GetSection(BANOptions.SectionName) + .Get() ?? new BANOptions(); + +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(banOptions.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(banOptions.TimeoutSeconds); +}); + +// --- Client typé ORS (OpenRouteService) --- +// BaseAddress, Timeout et en-tête de clé d'API proviennent de ORSOptions (appsettings.json). +var orsOptions = builder.Configuration + .GetSection(ORSOptions.SectionName) + .Get() ?? new ORSOptions(); + +builder.Services.AddHttpClient(client => +{ + if (!string.IsNullOrWhiteSpace(orsOptions.BaseUrl)) + { + // Le slash final garantit la résolution correcte des chemins relatifs. + var baseUrl = orsOptions.BaseUrl.EndsWith('/') + ? orsOptions.BaseUrl + : orsOptions.BaseUrl + "/"; + client.BaseAddress = new Uri(baseUrl); + } + + client.Timeout = TimeSpan.FromSeconds(orsOptions.TimeoutSeconds); + + if (!string.IsNullOrWhiteSpace(orsOptions.ApiKey)) + { + // ORS attend la clé brute dans l'en-tête Authorization (sans schéma « Bearer »). + // Les clés ORS (JWT) contiennent des caractères refusés par la validation stricte + // de DefaultRequestHeaders.Add ; on utilise TryAddWithoutValidation pour l'insérer + // telle quelle et éviter une FormatException à la création du client. + client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", orsOptions.ApiKey); + } +}); + +// --- Calculateur de distance (orchestration BAN + ORS + fallback Haversine) --- +builder.Services.AddScoped(); + +// --- Cache des adresses géocodées --- +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// --- Services de base --- +builder.Services.AddControllers(); + +// --- Health checks (endpoint GET /health, utilisé par Docker Compose) --- +builder.Services.AddHealthChecks(); + +// --- Validation des requêtes (FluentValidation) --- +// Enregistre tous les validateurs de l'assembly et active la validation automatique côté MVC. +builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddFluentValidationAutoValidation(); + +// --- Base de données (EF Core + SQLite) --- +// La chaîne de connexion provient de la configuration ("ConnectionStrings:Default"). +var connectionString = builder.Configuration.GetConnectionString("Default") + ?? "Data Source=trajets.db"; +builder.Services.AddDbContext(options => + options.UseSqlite(connectionString)); + +// --- Repositories & services métier --- +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Exploration des endpoints + Swagger / OpenAPI +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo + { + Title = "SchotzIAD KM Tracker API", + Version = "v1", + Description = "API de saisie, calcul et consultation des trajets kilométriques." + }); +}); + +// ProblemDetails : réponses d'erreur normalisées (RFC 7807) +builder.Services.AddProblemDetails(); + +// CORS pour le frontend Angular. +// Les origines autorisées sont configurables via appsettings ("Cors:AllowedOrigins"). +const string CorsPolicyName = "AngularFrontend"; +var allowedOrigins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get() ?? new[] { "http://localhost:4200" }; + +builder.Services.AddCors(options => +{ + options.AddPolicy(CorsPolicyName, policy => + { + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +var app = builder.Build(); + +// --- Pipeline HTTP --- + +// Gestion centralisée des exceptions -> ProblemDetails +app.UseExceptionHandler(); +app.UseStatusCodePages(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseCors(CorsPolicyName); + +app.UseAuthorization(); + +app.MapControllers(); + +// Endpoint de santé — utilisé par le healthcheck Docker Compose (depends_on: service_healthy). +app.MapHealthChecks("/health"); + +// --- Application automatique des migrations EF Core au démarrage --- +// Au premier lancement, la base (ex. /data/trajets.db sur le volume monté) est créée ; +// aux lancements suivants, les migrations en attente sont appliquées sans intervention manuelle. +using (var scope = app.Services.CreateScope()) +{ + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); +} + +app.Run(); + +// Exposé pour les tests d'intégration (WebApplicationFactory). +public partial class Program { } diff --git a/backend/TrajetKmTracker.Api/Properties/launchSettings.json b/backend/TrajetKmTracker.Api/Properties/launchSettings.json new file mode 100644 index 0000000..0166b4d --- /dev/null +++ b/backend/TrajetKmTracker.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:8200", + "sslPort": 44379 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5237", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7085;http://localhost:5237", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/TrajetKmTracker.Api/Repositories/AdresseCacheRepository.cs b/backend/TrajetKmTracker.Api/Repositories/AdresseCacheRepository.cs new file mode 100644 index 0000000..16ffc7e --- /dev/null +++ b/backend/TrajetKmTracker.Api/Repositories/AdresseCacheRepository.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.Entities; + +namespace TrajetKmTracker.Api.Repositories; + +/// +/// Implémentation EF Core de au-dessus d'. +/// +public class AdresseCacheRepository : IAdresseCacheRepository +{ + private readonly AppDbContext _db; + + public AdresseCacheRepository(AppDbContext db) + { + _db = db; + } + + public async Task> RechercherAsync( + string fragmentNormalise, + int limite, + CancellationToken cancellationToken = default) + { + // La colonne Adresse stocke déjà la forme normalisée : la comparaison se fait donc + // directement sur celle-ci. EF.Functions.Like assure une comparaison indépendante + // de la casse côté SQLite. + var motif = $"%{fragmentNormalise}%"; + + return await _db.AdresseCache + .Where(a => EF.Functions.Like(a.Adresse, motif)) + .OrderByDescending(a => a.DerniereUtilisation) + .Take(limite) + .ToListAsync(cancellationToken); + } + + public async Task ObtenirParAdresseNormaliseeAsync( + string adresseNormalisee, + CancellationToken cancellationToken = default) + { + return await _db.AdresseCache + .FirstOrDefaultAsync(a => a.Adresse == adresseNormalisee, cancellationToken); + } + + public async Task AjouterAsync(AdresseCache entree, CancellationToken cancellationToken = default) + { + await _db.AdresseCache.AddAsync(entree, cancellationToken); + } + + public Task CompterAsync(CancellationToken cancellationToken = default) + { + return _db.AdresseCache.CountAsync(cancellationToken); + } + + public async Task> ObtenirPlusAnciennesAsync( + int nombre, + CancellationToken cancellationToken = default) + { + if (nombre <= 0) + { + return Array.Empty(); + } + + return await _db.AdresseCache + .OrderBy(a => a.DerniereUtilisation) + .Take(nombre) + .ToListAsync(cancellationToken); + } + + public void Supprimer(IEnumerable entrees) + { + _db.AdresseCache.RemoveRange(entrees); + } + + public Task SauvegarderAsync(CancellationToken cancellationToken = default) + { + return _db.SaveChangesAsync(cancellationToken); + } +} diff --git a/backend/TrajetKmTracker.Api/Repositories/IAdresseCacheRepository.cs b/backend/TrajetKmTracker.Api/Repositories/IAdresseCacheRepository.cs new file mode 100644 index 0000000..30cfc68 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Repositories/IAdresseCacheRepository.cs @@ -0,0 +1,45 @@ +using TrajetKmTracker.Api.Models.Entities; + +namespace TrajetKmTracker.Api.Repositories; + +/// +/// Accès aux données du Cache_Adresses (table AdresseCache). +/// Toutes les correspondances d'adresse s'effectuent sur le texte normalisé +/// (voir ). +/// +public interface IAdresseCacheRepository +{ + /// + /// Retourne les entrées dont l'adresse normalisée contient le fragment normalisé fourni, + /// triées de la plus récemment utilisée à la plus ancienne, limitées à . + /// + Task> RechercherAsync( + string fragmentNormalise, + int limite, + CancellationToken cancellationToken = default); + + /// Retourne l'entrée correspondant exactement à l'adresse normalisée, ou null. + Task ObtenirParAdresseNormaliseeAsync( + string adresseNormalisee, + CancellationToken cancellationToken = default); + + /// Ajoute une nouvelle entrée au cache. + Task AjouterAsync(AdresseCache entree, CancellationToken cancellationToken = default); + + /// Compte le nombre total d'entrées du cache. + Task CompterAsync(CancellationToken cancellationToken = default); + + /// + /// Retourne les entrées les plus anciennes (par DerniereUtilisation croissante), + /// limitées à . Utilisé pour l'éviction LRU. + /// + Task> ObtenirPlusAnciennesAsync( + int nombre, + CancellationToken cancellationToken = default); + + /// Supprime les entrées fournies du cache. + void Supprimer(IEnumerable entrees); + + /// Persiste les modifications en attente. + Task SauvegarderAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Repositories/ITrajetRepository.cs b/backend/TrajetKmTracker.Api/Repositories/ITrajetRepository.cs new file mode 100644 index 0000000..801fedb --- /dev/null +++ b/backend/TrajetKmTracker.Api/Repositories/ITrajetRepository.cs @@ -0,0 +1,87 @@ +using TrajetKmTracker.Api.Models.Entities; + +namespace TrajetKmTracker.Api.Repositories; + +/// +/// Accès aux données des trajets et gestion des positions au sein d'une journée. +/// +/// La est une valeur décimale (REAL) permettant l'insertion +/// sans réordonnancement systématique : ajout en fin de journée → max(Position) + 1.0, +/// insertion entre deux positions P₁ et P₂ → (P₁ + P₂) / 2. Lorsque l'écart devient +/// trop faible (< ), la journée est renumérotée par pas de +/// 1.0 en transaction en préservant l'ordre relatif (Property 15, Exigences 11.7/11.8). +/// +/// +/// Ce repository fournit uniquement les primitives d'accès et de calcul de position ; +/// la logique métier (validation, calcul de distance, mise en cache) relève de TrajetService. +/// +/// +public interface ITrajetRepository +{ + /// + /// Seuil de précision en deçà duquel une renumérotation de la journée est déclenchée. + /// Conforme à la conception : écart < 0.001. + /// + const decimal SeuilRenumerotation = 0.001m; + + /// Retourne le trajet par identifiant, ou null s'il n'existe pas. + Task GetByIdAsync(int id, CancellationToken cancellationToken = default); + + /// Retourne les trajets d'une journée triés par croissante. + Task> GetByDateAsync(DateOnly date, CancellationToken cancellationToken = default); + + /// + /// Résout le véhicule applicable à la date du trajet et copie sa PuissanceFiscale + /// sur le trajet (dénormalisation, Exigence 10.14). Ne modifie ni VehiculeId ni + /// PuissanceFiscale si aucun véhicule n'est applicable à cette date. + /// Coordonné avec . + /// + Task AppliquerVehiculeADateAsync(Trajet trajet, CancellationToken cancellationToken = default); + + /// + /// Calcule la position d'un trajet ajouté en fin de journée : max(Position) + 1.0 + /// pour la date, ou 1.0 si la journée est vide. + /// + Task CalculerPositionFinDeJourneeAsync(DateOnly date, CancellationToken cancellationToken = default); + + /// + /// Calcule la position d'un trajet inséré entre les positions + /// (P₁) et (P₂) : (P₁ + P₂) / 2. + /// + /// Si P₁ n'est pas strictement inférieure à P₂. + decimal CalculerPositionEntre(decimal positionPrecedente, decimal positionSuivante); + + /// + /// Indique si l'écart entre deux positions adjacentes est devenu trop faible + /// (< ) pour insérer une nouvelle valeur intermédiaire. + /// + bool NecessiteRenumerotation(decimal positionPrecedente, decimal positionSuivante); + + /// Ajoute un trajet (sans persister). L'appelant gère la transaction et l'appel à . + Task AddAsync(Trajet trajet, CancellationToken cancellationToken = default); + + /// Marque un trajet existant comme modifié (sans persister). + void Update(Trajet trajet); + + /// Supprime un trajet (sans persister). + void Delete(Trajet trajet); + + /// Persiste les changements en attente. + Task SauvegarderAsync(CancellationToken cancellationToken = default); + + /// + /// Applique un réordonnancement manuel : met à jour uniquement la + /// des trajets fournis (par identifiant) sans altérer leurs autres champs, en transaction. + /// Les identifiants inconnus sont ignorés. + /// + Task ReordonnerAsync( + IReadOnlyList<(int Id, decimal Position)> positions, + CancellationToken cancellationToken = default); + + /// + /// Renumérote les trajets d'une journée par pas de 1.0 (1.0, 2.0, 3.0, …) en préservant + /// l'ordre relatif courant (tri par Position puis Id), en transaction. Retourne la liste + /// renumérotée triée par Position croissante (Property 15). + /// + Task> RenumeroterJourneeAsync(DateOnly date, CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Repositories/IVehiculeRepository.cs b/backend/TrajetKmTracker.Api/Repositories/IVehiculeRepository.cs new file mode 100644 index 0000000..714b76b --- /dev/null +++ b/backend/TrajetKmTracker.Api/Repositories/IVehiculeRepository.cs @@ -0,0 +1,35 @@ +using TrajetKmTracker.Api.Models.Entities; + +namespace TrajetKmTracker.Api.Repositories; + +/// +/// Accès aux données des véhicules et aux trajets impactés par une réaffectation. +/// L'historique des véhicules est conservé (aucune suppression) — Exigence 10.12 / Property 22. +/// +public interface IVehiculeRepository +{ + /// Retourne l'historique complet des véhicules, trié par Date_Effet décroissante puis Id décroissant. + Task> GetHistoriqueAsync(CancellationToken cancellationToken = default); + + /// Retourne le véhicule par identifiant, ou null s'il n'existe pas. + Task GetByIdAsync(int id, CancellationToken cancellationToken = default); + + /// + /// Résout le véhicule courant à une date donnée : Date_Effet la plus récente parmi + /// celles <= (ORDER BY DateEffet DESC, Id DESC LIMIT 1). + /// Retourne null si aucun véhicule n'a de Date_Effet <= . + /// + Task GetVehiculeCourantAsync(DateOnly date, CancellationToken cancellationToken = default); + + /// Ajoute un nouveau véhicule (sans persister). L'appelant gère la transaction. + Task AddAsync(Vehicule vehicule, CancellationToken cancellationToken = default); + + /// + /// Retourne les trajets dont la Date est >= et dont la + /// PuissanceFiscale diffère de (candidats à la réaffectation). + /// + Task> GetTrajetsAReaffecterAsync( + DateOnly dateEffet, + int puissanceFiscale, + CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Repositories/TrajetRepository.cs b/backend/TrajetKmTracker.Api/Repositories/TrajetRepository.cs new file mode 100644 index 0000000..7cf7e78 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Repositories/TrajetRepository.cs @@ -0,0 +1,193 @@ +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.Entities; +using TrajetKmTracker.Api.Services; + +namespace TrajetKmTracker.Api.Repositories; + +/// +public class TrajetRepository : ITrajetRepository +{ + private readonly AppDbContext _context; + private readonly IVehiculeService _vehiculeService; + + public TrajetRepository(AppDbContext context, IVehiculeService vehiculeService) + { + _context = context; + _vehiculeService = vehiculeService; + } + + /// + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Trajets + .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + } + + /// + public async Task> GetByDateAsync(DateOnly date, CancellationToken cancellationToken = default) + { + // SQLite ne sait pas trier (ORDER BY) sur une colonne decimal : on ramène les + // trajets du jour puis on trie côté client (volume par journée très réduit). + var trajets = await _context.Trajets + .Where(t => t.Date == date) + .ToListAsync(cancellationToken); + + return trajets + .OrderBy(t => t.Position) + .ThenBy(t => t.Id) + .ToList(); + } + + /// + public async Task AppliquerVehiculeADateAsync(Trajet trajet, CancellationToken cancellationToken = default) + { + // Résolution du véhicule applicable à la date du trajet (Date_Effet la plus récente <= Date). + var vehicule = await _vehiculeService.GetVehiculeADateAsync(trajet.Date, cancellationToken); + if (vehicule is null) + { + // Aucun véhicule applicable à cette date : on laisse la logique métier (TrajetService) décider. + return; + } + + // Dénormalisation : copie de la puissance fiscale du véhicule au moment de l'association (Exigence 10.14). + trajet.VehiculeId = vehicule.Id; + trajet.PuissanceFiscale = vehicule.PuissanceFiscale; + } + + /// + public async Task CalculerPositionFinDeJourneeAsync(DateOnly date, CancellationToken cancellationToken = default) + { + // SQLite ne sait pas appliquer MAX sur une colonne decimal : on ramène les positions + // du jour et on calcule le maximum côté client (volume par journée très réduit). + var positions = await _context.Trajets + .Where(t => t.Date == date) + .Select(t => t.Position) + .ToListAsync(cancellationToken); + + if (positions.Count == 0) + { + return 1.0m; + } + + return positions.Max() + 1.0m; + } + + /// + public decimal CalculerPositionEntre(decimal positionPrecedente, decimal positionSuivante) + { + if (positionPrecedente >= positionSuivante) + { + throw new ArgumentException( + $"La position précédente ({positionPrecedente}) doit être strictement inférieure à la position suivante ({positionSuivante}).", + nameof(positionPrecedente)); + } + + return (positionPrecedente + positionSuivante) / 2.0m; + } + + /// + public bool NecessiteRenumerotation(decimal positionPrecedente, decimal positionSuivante) + { + return positionSuivante - positionPrecedente < ITrajetRepository.SeuilRenumerotation; + } + + /// + public Task AddAsync(Trajet trajet, CancellationToken cancellationToken = default) + { + _context.Trajets.Add(trajet); + return Task.CompletedTask; + } + + /// + public void Update(Trajet trajet) + { + _context.Trajets.Update(trajet); + } + + /// + public void Delete(Trajet trajet) + { + _context.Trajets.Remove(trajet); + } + + /// + public Task SauvegarderAsync(CancellationToken cancellationToken = default) + { + return _context.SaveChangesAsync(cancellationToken); + } + + /// + public async Task ReordonnerAsync( + IReadOnlyList<(int Id, decimal Position)> positions, + CancellationToken cancellationToken = default) + { + if (positions.Count == 0) + { + return; + } + + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + var ids = positions.Select(p => p.Id).ToList(); + var trajets = await _context.Trajets + .Where(t => ids.Contains(t.Id)) + .ToListAsync(cancellationToken); + + var positionParId = positions.ToDictionary(p => p.Id, p => p.Position); + + foreach (var trajet in trajets) + { + if (positionParId.TryGetValue(trajet.Id, out var nouvellePosition)) + { + // Seule la Position est modifiée ; aucun autre champ n'est altéré (Exigence 11.9). + trajet.Position = nouvellePosition; + trajet.UpdatedAt = DateTime.UtcNow; + } + } + + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + /// + public async Task> RenumeroterJourneeAsync(DateOnly date, CancellationToken cancellationToken = default) + { + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + // Préservation de l'ordre relatif courant (Position croissante, Id en départage) — Property 15. + var trajets = (await _context.Trajets + .Where(t => t.Date == date) + .ToListAsync(cancellationToken)) + .OrderBy(t => t.Position) + .ThenBy(t => t.Id) + .ToList(); + + decimal position = 1.0m; + foreach (var trajet in trajets) + { + trajet.Position = position; + trajet.UpdatedAt = DateTime.UtcNow; + position += 1.0m; + } + + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + return trajets; + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } +} diff --git a/backend/TrajetKmTracker.Api/Repositories/VehiculeRepository.cs b/backend/TrajetKmTracker.Api/Repositories/VehiculeRepository.cs new file mode 100644 index 0000000..5d0f024 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Repositories/VehiculeRepository.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.Entities; + +namespace TrajetKmTracker.Api.Repositories; + +/// +public class VehiculeRepository : IVehiculeRepository +{ + private readonly AppDbContext _context; + + public VehiculeRepository(AppDbContext context) + { + _context = context; + } + + /// + public async Task> GetHistoriqueAsync(CancellationToken cancellationToken = default) + { + return await _context.Vehicules + .OrderByDescending(v => v.DateEffet) + .ThenByDescending(v => v.Id) + .ToListAsync(cancellationToken); + } + + /// + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Vehicules + .FirstOrDefaultAsync(v => v.Id == id, cancellationToken); + } + + /// + public async Task GetVehiculeCourantAsync(DateOnly date, CancellationToken cancellationToken = default) + { + // Design SQL : SELECT * FROM Vehicules WHERE DateEffet <= date ORDER BY DateEffet DESC, Id DESC LIMIT 1 + return await _context.Vehicules + .Where(v => v.DateEffet <= date) + .OrderByDescending(v => v.DateEffet) + .ThenByDescending(v => v.Id) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddAsync(Vehicule vehicule, CancellationToken cancellationToken = default) + { + _context.Vehicules.Add(vehicule); + return Task.CompletedTask; + } + + /// + public async Task> GetTrajetsAReaffecterAsync( + DateOnly dateEffet, + int puissanceFiscale, + CancellationToken cancellationToken = default) + { + return await _context.Trajets + .Where(t => t.Date >= dateEffet && t.PuissanceFiscale != puissanceFiscale) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/TrajetKmTracker.Api/Services/AdresseCacheService.cs b/backend/TrajetKmTracker.Api/Services/AdresseCacheService.cs new file mode 100644 index 0000000..35d7b9c --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/AdresseCacheService.cs @@ -0,0 +1,184 @@ +using System.Globalization; +using System.Text; +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Models.Entities; +using TrajetKmTracker.Api.Repositories; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Implémentation du Cache_Adresses. Les adresses sont stockées et comparées sous forme +/// normalisée (trim + minuscules + suppression des accents) afin d'assurer un matching robuste. +/// Les écritures (upsert + éviction LRU) sont exécutées en transaction. +/// +public class AdresseCacheService : IAdresseCacheService +{ + /// Longueur minimale d'une requête de recherche locale (Requirement 9.3). + private const int LongueurMinimaleRecherche = 3; + + /// Taille maximale du cache avant éviction LRU (Requirement 9.5). + private const int TailleMaximaleCache = 1_000; + + private readonly IAdresseCacheRepository _repository; + private readonly AppDbContext _db; + + public AdresseCacheService(IAdresseCacheRepository repository, AppDbContext db) + { + _repository = repository; + _db = db; + } + + public async Task> RechercherLocalAsync( + string query, + int limite = 5, + CancellationToken cancellationToken = default) + { + var fragment = Normaliser(query); + if (fragment.Length < LongueurMinimaleRecherche) + { + return Array.Empty(); + } + + if (limite <= 0) + { + limite = 5; + } + + var entrees = await _repository.RechercherAsync(fragment, limite, cancellationToken); + + return entrees + .Select(e => new AdresseCacheDto(e.Adresse, e.Latitude, e.Longitude)) + .ToList(); + } + + public async Task ObtenirParAdresseAsync( + string adresse, + CancellationToken cancellationToken = default) + { + var normalisee = Normaliser(adresse); + if (normalisee.Length == 0) + { + return null; + } + + var entree = await _repository.ObtenirParAdresseNormaliseeAsync(normalisee, cancellationToken); + if (entree is null) + { + return null; + } + + // Toute utilisation d'une adresse en cache rafraîchit sa date de dernière utilisation. + // Si une transaction est déjà ouverte par l'appelant (ex. création d'un Trajet), + // on y participe sans en ouvrir une nouvelle : SQLite interdit les transactions imbriquées. + entree.DerniereUtilisation = DateTime.UtcNow; + if (_db.Database.CurrentTransaction is null) + { + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + await _repository.SauvegarderAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + else + { + await _repository.SauvegarderAsync(cancellationToken); + } + + return new AdresseCacheEntreeDto( + entree.Adresse, + entree.Latitude, + entree.Longitude, + entree.GeometrieJson); + } + + public async Task UpsertAdresseAsync( + string adresse, + double latitude, + double longitude, + string? geometrieJson = null, + CancellationToken cancellationToken = default) + { + var normalisee = Normaliser(adresse); + if (normalisee.Length == 0) + { + return; + } + + // Si une transaction est déjà ouverte par l'appelant (ex. TrajetService.CreateAsync), + // on y participe sans en ouvrir une nouvelle (SQLite interdit les transactions imbriquées). + var transactionAmbiante = _db.Database.CurrentTransaction is not null; + var transaction = transactionAmbiante + ? null + : await _db.Database.BeginTransactionAsync(cancellationToken); + + var existante = await _repository.ObtenirParAdresseNormaliseeAsync(normalisee, cancellationToken); + if (existante is not null) + { + // Adresse déjà présente : coordonnées inchangées, seule la date d'utilisation est + // rafraîchie (Requirements 9.1, 9.6). + existante.DerniereUtilisation = DateTime.UtcNow; + } + else + { + // Nouvelle adresse : insertion avec les coordonnées fournies. + var nouvelle = new AdresseCache + { + Adresse = normalisee, + Latitude = latitude, + Longitude = longitude, + GeometrieJson = geometrieJson, + DerniereUtilisation = DateTime.UtcNow + }; + await _repository.AjouterAsync(nouvelle, cancellationToken); + } + + await _repository.SauvegarderAsync(cancellationToken); + + // Éviction LRU : conserver au plus les 1 000 entrées les plus récemment utilisées + // et supprimer les plus anciennes (Requirement 9.5). + var total = await _repository.CompterAsync(cancellationToken); + if (total > TailleMaximaleCache) + { + var aSupprimer = await _repository.ObtenirPlusAnciennesAsync( + total - TailleMaximaleCache, cancellationToken); + + _repository.Supprimer(aSupprimer); + await _repository.SauvegarderAsync(cancellationToken); + } + + // On ne valide que la transaction que l'on a nous-mêmes ouverte ; sinon c'est + // l'appelant qui validera la transaction ambiante. + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + await transaction.DisposeAsync(); + } + } + + /// + /// Normalise un texte d'adresse : suppression des espaces de bord, passage en minuscules + /// et suppression des accents (diacritiques). Cohérent avec la normalisation des Adresses_Nommées. + /// + private static string Normaliser(string? valeur) + { + if (string.IsNullOrWhiteSpace(valeur)) + { + return string.Empty; + } + + var trimmee = valeur.Trim().ToLowerInvariant(); + + var decomposee = trimmee.Normalize(NormalizationForm.FormD); + var builder = new StringBuilder(decomposee.Length); + foreach (var caractere in decomposee) + { + var categorie = CharUnicodeInfo.GetUnicodeCategory(caractere); + if (categorie != UnicodeCategory.NonSpacingMark) + { + builder.Append(caractere); + } + } + + return builder.ToString().Normalize(NormalizationForm.FormC); + } +} diff --git a/backend/TrajetKmTracker.Api/Services/AdresseNommeeService.cs b/backend/TrajetKmTracker.Api/Services/AdresseNommeeService.cs new file mode 100644 index 0000000..24d7904 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/AdresseNommeeService.cs @@ -0,0 +1,309 @@ +using System.Globalization; +using System.Text; +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Models.Entities; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Implémentation du Carnet_Adresses (Exigence 15). Gère le CRUD des Adresses_Nommées avec : +/// +/// normalisation de la Dénomination (trim + minuscules + suppression des accents) pour +/// garantir l'unicité (15.6/15.7) ; +/// réutilisation du Cache_Adresses si l'adresse normalisée y est présente (15.10), +/// sinon géocodage/re-géocodage via l'API BAN avec un délai de 5 s (15.11) ; +/// persistance sans coordonnées + GeocodageReussi = false si BAN est +/// indisponible ou dépasse le délai (15.12) ; +/// mise en cache des coordonnées obtenues (15.9). +/// +/// +public class AdresseNommeeService : IAdresseNommeeService +{ + private const int DenominationMaxLength = 100; + private const int AdresseMaxLength = 255; + + private readonly AppDbContext _db; + private readonly IBANClient _banClient; + private readonly IAdresseCacheService _cache; + + public AdresseNommeeService(AppDbContext db, IBANClient banClient, IAdresseCacheService cache) + { + _db = db; + _banClient = banClient; + _cache = cache; + } + + /// + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + var adresses = await _db.AdressesNommees + .AsNoTracking() + .OrderBy(a => a.Denomination) + .ToListAsync(cancellationToken); + + return adresses.Select(ToDto).ToList(); + } + + /// + public async Task CreateAsync( + SaveAdresseNommeeDto dto, + CancellationToken cancellationToken = default) + { + ValiderDenomination(dto.Denomination); + ValiderAdresse(dto.Adresse); + + var denominationNormalisee = Normaliser(dto.Denomination); + + // Contrôle d'unicité avant persistance (15.7 / Property 28) ; l'index unique + // UX_AdresseNommee_Denomination reste le dernier rempart au niveau SQL. + var collision = await _db.AdressesNommees + .AnyAsync(a => a.DenominationNormalisee == denominationNormalisee, cancellationToken); + if (collision) + { + throw new DenominationDupliqueeException( + $"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée."); + } + + var (latitude, longitude, geocodageReussi) = + await ResoudreCoordonneesAsync(dto.Adresse, dto.Latitude, dto.Longitude, cancellationToken); + + var maintenant = DateTime.UtcNow; + var entite = new AdresseNommee + { + Denomination = dto.Denomination.Trim(), + DenominationNormalisee = denominationNormalisee, + Adresse = dto.Adresse.Trim(), + Latitude = latitude, + Longitude = longitude, + GeocodageReussi = geocodageReussi, + CreatedAt = maintenant, + UpdatedAt = maintenant + }; + + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + try + { + _db.AdressesNommees.Add(entite); + await _db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch (DbUpdateException ex) + { + await transaction.RollbackAsync(cancellationToken); + // Dernier rempart : violation de l'index unique (course concurrente). + throw new DenominationDupliqueeException( + $"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.", ex); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + // Mise en cache des coordonnées obtenues si absentes du Cache_Adresses (15.9). + if (geocodageReussi && latitude.HasValue && longitude.HasValue) + { + await _cache.UpsertAdresseAsync(entite.Adresse, latitude.Value, longitude.Value, + cancellationToken: cancellationToken); + } + + return ToDto(entite); + } + + /// + public async Task UpdateAsync( + int id, + SaveAdresseNommeeDto dto, + CancellationToken cancellationToken = default) + { + ValiderDenomination(dto.Denomination); + ValiderAdresse(dto.Adresse); + + var entite = await _db.AdressesNommees.FirstOrDefaultAsync(a => a.Id == id, cancellationToken) + ?? throw new AdresseNommeeNotFoundException( + $"L'adresse nommée d'identifiant {id} est introuvable."); + + var denominationNormalisee = Normaliser(dto.Denomination); + + // Unicité : une autre Adresse_Nommee ne doit pas porter la même Dénomination normalisée (15.7). + var collision = await _db.AdressesNommees + .AnyAsync(a => a.Id != id && a.DenominationNormalisee == denominationNormalisee, cancellationToken); + if (collision) + { + throw new DenominationDupliqueeException( + $"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée."); + } + + var adresseTrim = dto.Adresse.Trim(); + var adresseAChange = !string.Equals( + Normaliser(entite.Adresse), Normaliser(adresseTrim), StringComparison.Ordinal); + + double? latitude = entite.Latitude; + double? longitude = entite.Longitude; + var geocodageReussi = entite.GeocodageReussi; + + // Re-géocodage uniquement si le texte d'adresse a changé (15.11). + if (adresseAChange) + { + (latitude, longitude, geocodageReussi) = + await ResoudreCoordonneesAsync(adresseTrim, dto.Latitude, dto.Longitude, cancellationToken); + } + + entite.Denomination = dto.Denomination.Trim(); + entite.DenominationNormalisee = denominationNormalisee; + entite.Adresse = adresseTrim; + entite.Latitude = latitude; + entite.Longitude = longitude; + entite.GeocodageReussi = geocodageReussi; + entite.UpdatedAt = DateTime.UtcNow; + + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + try + { + await _db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch (DbUpdateException ex) + { + await transaction.RollbackAsync(cancellationToken); + throw new DenominationDupliqueeException( + $"La dénomination « {dto.Denomination.Trim()} » est déjà utilisée.", ex); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + if (geocodageReussi && latitude.HasValue && longitude.HasValue) + { + await _cache.UpsertAdresseAsync(entite.Adresse, latitude.Value, longitude.Value, + cancellationToken: cancellationToken); + } + + return ToDto(entite); + } + + /// + public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + var entite = await _db.AdressesNommees.FirstOrDefaultAsync(a => a.Id == id, cancellationToken) + ?? throw new AdresseNommeeNotFoundException( + $"L'adresse nommée d'identifiant {id} est introuvable."); + + // Suppression du seul enregistrement du Carnet_Adresses : les Trajets ne référencent que + // le texte d'adresse et les coordonnées copiés, donc rien d'autre n'est modifié (15.14). + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + try + { + _db.AdressesNommees.Remove(entite); + await _db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + /// + /// Résout les coordonnées d'un texte d'adresse : réutilisation du Cache_Adresses si l'adresse + /// normalisée y figure (15.10), sinon coordonnées fournies par le frontend (sélection BAN), + /// sinon géocodage via l'API BAN (15.11). En cas d'indisponibilité/timeout de la BAN, aucune + /// coordonnée n'est retournée et GeocodageReussi vaut false (15.12). + /// + private async Task<(double? Latitude, double? Longitude, bool GeocodageReussi)> ResoudreCoordonneesAsync( + string adresse, + double? latitudeFournie, + double? longitudeFournie, + CancellationToken cancellationToken) + { + // 1) Réutilisation du Cache_Adresses sans appel BAN (15.10). + var enCache = await _cache.ObtenirParAdresseAsync(adresse, cancellationToken); + if (enCache is not null) + { + return (enCache.Latitude, enCache.Longitude, true); + } + + // 2) Coordonnées déjà résolues côté frontend via une sélection BAN : on les conserve + // directement, ce qui évite un appel BAN redondant (15.5). + if (latitudeFournie.HasValue && longitudeFournie.HasValue) + { + return (latitudeFournie, longitudeFournie, true); + } + + // 3) Géocodage via l'API BAN (timeout 5 s configuré sur le HttpClient) (15.11). + try + { + var resultats = await _banClient.RechercherAsync(adresse, cancellationToken); + var premier = resultats.FirstOrDefault(); + if (premier is not null) + { + return (premier.Latitude, premier.Longitude, true); + } + + // Aucune correspondance BAN : persistance sans coordonnées. + return (null, null, false); + } + catch (BANIndisponibleException) + { + // BAN indisponible/timeout : persistance sans coordonnées (15.12). + return (null, null, false); + } + } + + private static void ValiderDenomination(string? denomination) + { + var trimmee = denomination?.Trim() ?? string.Empty; + if (trimmee.Length == 0 || trimmee.Length > DenominationMaxLength) + { + throw new DenominationInvalideException( + "La dénomination est absente ou invalide (1 à 100 caractères requis)."); + } + } + + private static void ValiderAdresse(string? adresse) + { + var trimmee = adresse?.Trim() ?? string.Empty; + if (trimmee.Length == 0 || trimmee.Length > AdresseMaxLength) + { + throw new AdresseNommeeInvalideException( + "L'adresse est absente ou invalide (1 à 255 caractères requis)."); + } + } + + private static AdresseNommeeDto ToDto(AdresseNommee a) => + new(a.Id, a.Denomination, a.Adresse, a.Latitude, a.Longitude); + + /// + /// Normalise un texte : suppression des espaces de bord, passage en minuscules et suppression + /// des accents (diacritiques). Cohérent avec afin d'assurer + /// un matching identique entre Carnet_Adresses et Cache_Adresses. + /// + private static string Normaliser(string? valeur) + { + if (string.IsNullOrWhiteSpace(valeur)) + { + return string.Empty; + } + + var trimmee = valeur.Trim().ToLowerInvariant(); + + var decomposee = trimmee.Normalize(NormalizationForm.FormD); + var builder = new StringBuilder(decomposee.Length); + foreach (var caractere in decomposee) + { + var categorie = CharUnicodeInfo.GetUnicodeCategory(caractere); + if (categorie != UnicodeCategory.NonSpacingMark) + { + builder.Append(caractere); + } + } + + return builder.ToString().Normalize(NormalizationForm.FormC); + } +} diff --git a/backend/TrajetKmTracker.Api/Services/BANClient.cs b/backend/TrajetKmTracker.Api/Services/BANClient.cs new file mode 100644 index 0000000..6d7a358 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/BANClient.cs @@ -0,0 +1,133 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Client typé de l'API BAN. Appelle GET /search/?q={query}&limit=5, +/// parse la réponse GeoJSON (FeatureCollection) et mappe les features en +/// . Lève +/// en cas d'échec ou de timeout. +/// +public class BANClient : IBANClient +{ + private const int MaxResultats = 5; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient _httpClient; + + public BANClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> RechercherAsync( + string query, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + { + return Array.Empty(); + } + + var requestUri = $"/search/?q={Uri.EscapeDataString(query)}&limit={MaxResultats}"; + + BANFeatureCollection? collection; + try + { + using var response = await _httpClient.GetAsync(requestUri, cancellationToken); + response.EnsureSuccessStatusCode(); + + collection = await response.Content.ReadFromJsonAsync( + JsonOptions, cancellationToken); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Le timeout configuré sur le HttpClient déclenche une annulation + // non demandée par l'appelant. + throw new BANIndisponibleException( + "L'appel à l'API BAN a dépassé le délai imparti (timeout)."); + } + catch (HttpRequestException ex) + { + throw new BANIndisponibleException( + "L'API BAN est indisponible ou a renvoyé une réponse en erreur.", ex); + } + catch (JsonException ex) + { + throw new BANIndisponibleException( + "La réponse de l'API BAN n'a pas pu être interprétée.", ex); + } + + if (collection?.Features is null || collection.Features.Count == 0) + { + return Array.Empty(); + } + + var resultats = new List(collection.Features.Count); + foreach (var feature in collection.Features) + { + var coordinates = feature.Geometry?.Coordinates; + if (coordinates is null || coordinates.Count < 2) + { + continue; + } + + // GeoJSON : coordinates = [longitude, latitude]. + var longitude = coordinates[0]; + var latitude = coordinates[1]; + + resultats.Add(new BANAdresseDto( + Label: feature.Properties?.Label ?? string.Empty, + Latitude: latitude, + Longitude: longitude, + Postcode: feature.Properties?.Postcode ?? string.Empty, + City: feature.Properties?.City ?? string.Empty)); + } + + return resultats; + } + + // --- Modèles internes de désérialisation de la réponse GeoJSON BAN --- + + private sealed class BANFeatureCollection + { + [JsonPropertyName("features")] + public List? Features { get; set; } + } + + private sealed class BANFeature + { + [JsonPropertyName("properties")] + public BANProperties? Properties { get; set; } + + [JsonPropertyName("geometry")] + public BANGeometry? Geometry { get; set; } + } + + private sealed class BANProperties + { + [JsonPropertyName("label")] + public string? Label { get; set; } + + [JsonPropertyName("postcode")] + public string? Postcode { get; set; } + + [JsonPropertyName("city")] + public string? City { get; set; } + } + + private sealed class BANGeometry + { + // [longitude, latitude] + [JsonPropertyName("coordinates")] + public List? Coordinates { get; set; } + } +} diff --git a/backend/TrajetKmTracker.Api/Services/BaremeCalculResult.cs b/backend/TrajetKmTracker.Api/Services/BaremeCalculResult.cs new file mode 100644 index 0000000..297a084 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/BaremeCalculResult.cs @@ -0,0 +1,19 @@ +namespace TrajetKmTracker.Api.Services; + +/// +/// Résultat du calcul du montant barème pour un (année, puissance fiscale, total km) donné. +/// Lorsque aucune tranche n'est configurée, vaut false, +/// le est nul et un décrit la situation +/// (Exigence 13.4) — le calcul n'est jamais interrompu par une exception bloquante. +/// +/// Montant calculé : total_km × Coefficient + Base de la tranche, ou 0 si non configuré. +/// Vrai si une tranche a été trouvée et appliquée. +/// Coefficient €/km de la tranche appliquée (0 si non configuré). +/// Base fixe € de la tranche appliquée (0 si non configuré). +/// Message d'avertissement lorsque le barème n'est pas configuré, sinon null. +public record BaremeCalculResult( + decimal Montant, + bool BaremeConfigure, + decimal Coefficient, + decimal Base, + string? Avertissement); diff --git a/backend/TrajetKmTracker.Api/Services/BaremeService.cs b/backend/TrajetKmTracker.Api/Services/BaremeService.cs new file mode 100644 index 0000000..79d4f5b --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/BaremeService.cs @@ -0,0 +1,248 @@ +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Models.Entities; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +public class BaremeService : IBaremeService +{ + private readonly AppDbContext _context; + + public BaremeService(AppDbContext context) + { + _context = context; + } + + /// + public async Task> GetBaremeAsync( + int annee, CancellationToken cancellationToken = default) + { + // KmMin est un decimal : SQLite ne peut pas l'utiliser dans un ORDER BY, on trie côté client. + var tranches = (await _context.TranchesBareme + .AsNoTracking() + .Where(t => t.Annee == annee) + .ToListAsync(cancellationToken)) + .OrderBy(t => t.PuissanceFiscale) + .ThenBy(t => t.KmMin) + .ToList(); + + return tranches.Select(ToDto).ToList(); + } + + /// + public async Task AddTrancheAsync( + SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default) + { + await EnsureNoOverlapAsync(dto.Annee, dto.PuissanceFiscale, dto.KmMin, dto.KmMax, excludeId: null, cancellationToken); + + var tranche = new TrancheBareme + { + Annee = dto.Annee, + PuissanceFiscale = dto.PuissanceFiscale, + KmMin = dto.KmMin, + KmMax = dto.KmMax, + Coefficient = dto.Coefficient, + Base = dto.Base, + }; + + _context.TranchesBareme.Add(tranche); + await _context.SaveChangesAsync(cancellationToken); + + return ToDto(tranche); + } + + /// + public async Task UpdateTrancheAsync( + int id, SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default) + { + var tranche = await _context.TranchesBareme + .FirstOrDefaultAsync(t => t.Id == id, cancellationToken) + ?? throw new TrancheBaremeNotFoundException($"La tranche de barème d'identifiant {id} est introuvable."); + + await EnsureNoOverlapAsync(dto.Annee, dto.PuissanceFiscale, dto.KmMin, dto.KmMax, excludeId: id, cancellationToken); + + tranche.Annee = dto.Annee; + tranche.PuissanceFiscale = dto.PuissanceFiscale; + tranche.KmMin = dto.KmMin; + tranche.KmMax = dto.KmMax; + tranche.Coefficient = dto.Coefficient; + tranche.Base = dto.Base; + + await _context.SaveChangesAsync(cancellationToken); + + return ToDto(tranche); + } + + /// + public async Task DeleteTrancheAsync(int id, CancellationToken cancellationToken = default) + { + var tranche = await _context.TranchesBareme + .FirstOrDefaultAsync(t => t.Id == id, cancellationToken) + ?? throw new TrancheBaremeNotFoundException($"La tranche de barème d'identifiant {id} est introuvable."); + + _context.TranchesBareme.Remove(tranche); + await _context.SaveChangesAsync(cancellationToken); + } + + /// + public async Task CalculerMontantAsync( + int annee, int puissanceFiscale, decimal totalKm, CancellationToken cancellationToken = default) + { + var tranches = await _context.TranchesBareme + .AsNoTracking() + .Where(t => t.Annee == annee && t.PuissanceFiscale == puissanceFiscale) + .ToListAsync(cancellationToken); + + // Aucune tranche configurée : montant zéro + signal (Exigence 13.4), sans erreur bloquante. + if (tranches.Count == 0) + { + return new BaremeCalculResult( + Montant: 0m, + BaremeConfigure: false, + Coefficient: 0m, + Base: 0m, + Avertissement: $"Aucun barème configuré pour la puissance fiscale {puissanceFiscale} CV et l'année {annee} ; le montant de remboursement est nul."); + } + + // Sélection de la tranche dont [KmMin, KmMax[ contient le total annuel (Property 24/27). + // Borne max EXCLUSIVE ; KmMax == null = illimité. Les tranches ne se chevauchant pas, + // au plus une est sélectionnable. + var tranche = tranches.FirstOrDefault(t => + totalKm >= t.KmMin && (t.KmMax == null || totalKm < t.KmMax.Value)); + + if (tranche is null) + { + // Des tranches existent mais aucune ne couvre ce total : traité comme non couvert (montant nul). + return new BaremeCalculResult( + Montant: 0m, + BaremeConfigure: false, + Coefficient: 0m, + Base: 0m, + Avertissement: $"Aucune tranche du barème ne couvre un total de {totalKm} km pour la puissance fiscale {puissanceFiscale} CV et l'année {annee} ; le montant de remboursement est nul."); + } + + var montant = totalKm * tranche.Coefficient + tranche.Base; + + return new BaremeCalculResult( + Montant: montant, + BaremeConfigure: true, + Coefficient: tranche.Coefficient, + Base: tranche.Base, + Avertissement: null); + } + + /// + public async Task CopierDernierBaremeAsync(int anneeCible, CancellationToken cancellationToken = default) + { + // Année source = année la plus récente STRICTEMENT antérieure à l'année cible qui a un barème. + var anneesAvecBareme = (await _context.TranchesBareme + .AsNoTracking() + .Select(t => t.Annee) + .Distinct() + .ToListAsync(cancellationToken)) + .Where(a => a < anneeCible) + .OrderByDescending(a => a) + .ToList(); + + if (anneesAvecBareme.Count == 0) + { + return 0; // Aucun barème antérieur : rien à recopier. + } + + var anneeSource = anneesAvecBareme.First(); + + var source = await _context.TranchesBareme + .AsNoTracking() + .Where(t => t.Annee == anneeSource) + .ToListAsync(cancellationToken); + + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + // Annule et remplace : on supprime d'abord les tranches existantes de l'année cible. + var existantes = await _context.TranchesBareme + .Where(t => t.Annee == anneeCible) + .ToListAsync(cancellationToken); + if (existantes.Count > 0) + { + _context.TranchesBareme.RemoveRange(existantes); + await _context.SaveChangesAsync(cancellationToken); + } + + // Copie des tranches de l'année source vers l'année cible. + var copies = source.Select(t => new TrancheBareme + { + Annee = anneeCible, + PuissanceFiscale = t.PuissanceFiscale, + KmMin = t.KmMin, + KmMax = t.KmMax, + Coefficient = t.Coefficient, + Base = t.Base, + }).ToList(); + + _context.TranchesBareme.AddRange(copies); + await _context.SaveChangesAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + return copies.Count; + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + /// + /// Vérifie qu'aucune tranche existante pour la même (année, puissance fiscale) ne chevauche + /// l'intervalle [, ]. La tranche d'identifiant + /// (en cours de mise à jour) est exclue de la comparaison. + /// Deux intervalles [a1,a2] et [b1,b2] se chevauchent ssi a1 <= b2 ET b1 <= a2. + /// + private async Task EnsureNoOverlapAsync( + int annee, int puissanceFiscale, decimal kmMin, decimal? kmMax, int? excludeId, CancellationToken cancellationToken) + { + var existantes = await _context.TranchesBareme + .AsNoTracking() + .Where(t => t.Annee == annee + && t.PuissanceFiscale == puissanceFiscale + && (excludeId == null || t.Id != excludeId.Value)) + .ToListAsync(cancellationToken); + + // Convention [KmMin, KmMax[ (borne max EXCLUSIVE). KmMax == null = illimité (+infini). + // Deux intervalles [a1,a2[ et [b1,b2[ se chevauchent ssi a1 < b2 ET b1 < a2, + // en traitant une borne max nulle comme +infini. + var chevauche = existantes.Any(t => + { + var aMaxInfini = kmMax == null; + var bMaxInfini = t.KmMax == null; + var condA = bMaxInfini || kmMin < t.KmMax!.Value; // a1 < b2 + var condB = aMaxInfini || t.KmMin < kmMax!.Value; // b1 < a2 + return condA && condB; + }); + + if (chevauche) + { + throw new TranchesBaremeChevauchantesException( + "Les tranches du barème pour cette puissance fiscale et cette année se chevauchent."); + } + } + + /// + public async Task> GetAnneesAvecBaremeAsync(CancellationToken cancellationToken = default) + { + var annees = await _context.TranchesBareme + .AsNoTracking() + .Select(t => t.Annee) + .Distinct() + .ToListAsync(cancellationToken); + annees.Sort(); + return annees; + } + + private static TrancheBaremeDto ToDto(TrancheBareme t) => + new(t.Id, t.Annee, t.PuissanceFiscale, t.KmMin, t.KmMax, t.Coefficient, t.Base); +} diff --git a/backend/TrajetKmTracker.Api/Services/DistanceCalculationResult.cs b/backend/TrajetKmTracker.Api/Services/DistanceCalculationResult.cs new file mode 100644 index 0000000..134e8d2 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/DistanceCalculationResult.cs @@ -0,0 +1,35 @@ +namespace TrajetKmTracker.Api.Services; + +/// +/// Résultat d'un calcul de distance orchestré par . +/// Porte les coordonnées résolues des adresses de départ et d'arrivée, la distance +/// éventuellement calculée (routière via ORS ou orthodromique via Haversine), la +/// géométrie GeoJSON de l'itinéraire lorsqu'elle est disponible, et un éventuel +/// avertissement lorsque la distance n'a pas pu être calculée. +/// +/// Latitude résolue du point de départ (null si non résoluble). +/// Longitude résolue du point de départ (null si non résoluble). +/// Latitude résolue du point d'arrivée (null si non résoluble). +/// Longitude résolue du point d'arrivée (null si non résoluble). +/// +/// Distance en kilomètres, arrondie à 2 décimales et bornée entre 0,01 et 99 999,99 km ; +/// null lorsque la distance n'a pas pu être calculée (aucune coordonnée résoluble). +/// +/// +/// Géométrie GeoJSON de l'itinéraire routier (fournie par ORS) ; null en cas de +/// fallback Haversine ou d'absence de calcul. +/// +/// +/// Vrai lorsque la distance n'a pas pu être calculée faute de coordonnées résolubles ; +/// dans ce cas l'appelant stocke une distance nulle avec un avertissement. +/// +/// Message d'avertissement associé, ou null. +public record DistanceCalculationResult( + double? DepartLatitude, + double? DepartLongitude, + double? ArriveeLatitude, + double? ArriveeLongitude, + decimal? DistanceKm, + string? GeometrieJson, + bool Avertissement, + string? Message); diff --git a/backend/TrajetKmTracker.Api/Services/DistanceCalculatorService.cs b/backend/TrajetKmTracker.Api/Services/DistanceCalculatorService.cs new file mode 100644 index 0000000..096fb81 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/DistanceCalculatorService.cs @@ -0,0 +1,193 @@ +using Microsoft.Extensions.Logging; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Implémentation de . +/// +/// Résout d'abord les coordonnées manquantes via BAN (priorité BAN sur ORS, +/// Property 16), puis calcule la distance routière via ORS. En cas de timeout ou +/// d'échec ORS, applique le fallback orthodromique (Haversine) sans erreur. Si les +/// coordonnées ne peuvent pas être résolues (BAN sans résultat / indisponible), retourne +/// une distance nulle assortie d'un avertissement, sans lever d'exception bloquante. +/// +/// _Requirements: 2.1, 2.2, 2.3, 2.4, 2.6_ +/// +public class DistanceCalculatorService : IDistanceCalculator +{ + /// Distance minimale stockable (km). + private const decimal DistanceMinKm = 0.01m; + + /// Distance maximale stockable (km). + private const decimal DistanceMaxKm = 99_999.99m; + + private readonly IBANClient _banClient; + private readonly IORSClient _orsClient; + private readonly ILogger _logger; + + public DistanceCalculatorService( + IBANClient banClient, + IORSClient orsClient, + ILogger logger) + { + _banClient = banClient; + _orsClient = orsClient; + _logger = logger; + } + + /// + public async Task CalculerAsync( + string adresseDepart, + string adresseArrivee, + double? departLatitude, + double? departLongitude, + double? arriveeLatitude, + double? arriveeLongitude, + CancellationToken cancellationToken = default) + { + // Coordonnées de départ connues seulement si latitude ET longitude sont présentes. + double? depLat = departLatitude; + double? depLon = departLongitude; + double? arrLat = arriveeLatitude; + double? arrLon = arriveeLongitude; + + // Résolution BAN AVANT tout appel ORS pour les coordonnées manquantes (Property 16). + if (!EstCoordonneeConnue(depLat, depLon)) + { + var (lat, lon) = await GeocoderAsync(adresseDepart, cancellationToken); + depLat = lat; + depLon = lon; + } + + if (!EstCoordonneeConnue(arrLat, arrLon)) + { + var (lat, lon) = await GeocoderAsync(adresseArrivee, cancellationToken); + arrLat = lat; + arrLon = lon; + } + + // Si une coordonnée reste absente après géocodage BAN → distance nulle + avertissement. + if (!EstCoordonneeConnue(depLat, depLon) || !EstCoordonneeConnue(arrLat, arrLon)) + { + return new DistanceCalculationResult( + DepartLatitude: depLat, + DepartLongitude: depLon, + ArriveeLatitude: arrLat, + ArriveeLongitude: arrLon, + DistanceKm: null, + GeometrieJson: null, + Avertissement: true, + Message: "Le trajet a été enregistré mais la distance n'a pas pu être " + + "calculée faute de coordonnées géographiques."); + } + + // À ce stade, les quatre coordonnées sont non-null : jamais d'appel ORS avec des coordonnées nulles. + var lat1 = depLat!.Value; + var lon1 = depLon!.Value; + var lat2 = arrLat!.Value; + var lon2 = arrLon!.Value; + + decimal distanceKm; + string? geometrieJson = null; + var avertissement = false; + string? message = null; + + try + { + var route = await _orsClient.CalculateRouteAsync(lat1, lon1, lat2, lon2, cancellationToken); + distanceKm = route.DistanceKm; + geometrieJson = route.GeometrieJson; + } + catch (ORSTimeoutException ex) + { + _logger.LogWarning(ex, + "Timeout ORS : application du fallback Haversine pour le calcul de distance."); + distanceKm = HaversineKm(lat1, lon1, lat2, lon2); + } + catch (ORSException ex) + { + _logger.LogWarning(ex, + "Échec ORS : application du fallback Haversine pour le calcul de distance."); + distanceKm = HaversineKm(lat1, lon1, lat2, lon2); + } + + // Arrondi 2 décimales + bornage [0,01 ; 99 999,99] km. + distanceKm = BornerDistance(Math.Round(distanceKm, 2, MidpointRounding.AwayFromZero)); + + return new DistanceCalculationResult( + DepartLatitude: lat1, + DepartLongitude: lon1, + ArriveeLatitude: lat2, + ArriveeLongitude: lon2, + DistanceKm: distanceKm, + GeometrieJson: geometrieJson, + Avertissement: avertissement, + Message: message); + } + + /// + /// Géocode une adresse via BAN et retourne les coordonnées du meilleur (premier) résultat. + /// Retourne (null, null) si aucune adresse n'est trouvée ou si BAN est indisponible, + /// sans propager d'exception (échec BAN non bloquant). + /// + private async Task<(double? Latitude, double? Longitude)> GeocoderAsync( + string adresse, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(adresse)) + { + return (null, null); + } + + try + { + var resultats = await _banClient.RechercherAsync(adresse, cancellationToken); + if (resultats.Count == 0) + { + return (null, null); + } + + // Meilleur / premier résultat (la BAN retourne les résultats par pertinence décroissante). + var meilleur = resultats[0]; + return (meilleur.Latitude, meilleur.Longitude); + } + catch (BANIndisponibleException ex) + { + _logger.LogWarning(ex, + "BAN indisponible lors du géocodage de l'adresse manquante ; distance non calculable."); + return (null, null); + } + } + + /// Vrai si latitude ET longitude sont toutes deux renseignées. + private static bool EstCoordonneeConnue(double? latitude, double? longitude) + => latitude.HasValue && longitude.HasValue; + + /// Borne une distance dans l'intervalle stockable [0,01 ; 99 999,99] km. + private static decimal BornerDistance(decimal distanceKm) + { + if (distanceKm < DistanceMinKm) + { + return DistanceMinKm; + } + + return distanceKm > DistanceMaxKm ? DistanceMaxKm : distanceKm; + } + + /// + /// Calcule la distance orthodromique (formule de Haversine) entre deux points, en + /// kilomètres, arrondie à 2 décimales. R = 6371 km (rayon moyen de la Terre). + /// + private static decimal HaversineKm(double lat1, double lon1, double lat2, double lon2) + { + const double R = 6371.0; + var dLat = (lat2 - lat1) * Math.PI / 180; + var dLon = (lon2 - lon1) * Math.PI / 180; + var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(lat1 * Math.PI / 180) * Math.Cos(lat2 * Math.PI / 180) + * Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + return Math.Round((decimal)(R * c), 2); + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/AdresseNommeeInvalideException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/AdresseNommeeInvalideException.cs new file mode 100644 index 0000000..f325e71 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/AdresseNommeeInvalideException.cs @@ -0,0 +1,18 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsque le texte d'adresse d'une Adresse_Nommee est absent, vide ou dépasse +/// 255 caractères (Exigence 15.8). Convertie en réponse HTTP 400 (Bad Request). +/// +public class AdresseNommeeInvalideException : Exception +{ + public AdresseNommeeInvalideException(string message) + : base(message) + { + } + + public AdresseNommeeInvalideException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/AdresseNommeeNotFoundException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/AdresseNommeeNotFoundException.cs new file mode 100644 index 0000000..12163fc --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/AdresseNommeeNotFoundException.cs @@ -0,0 +1,18 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'aucune Adresse_Nommee ne correspond à l'identifiant demandé +/// (modification, suppression). Convertie en réponse HTTP 404 (Not Found). +/// +public class AdresseNommeeNotFoundException : Exception +{ + public AdresseNommeeNotFoundException(string message) + : base(message) + { + } + + public AdresseNommeeNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/BANIndisponibleException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/BANIndisponibleException.cs new file mode 100644 index 0000000..6322bd1 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/BANIndisponibleException.cs @@ -0,0 +1,18 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsque l'API BAN (Base Adresses Nationale) est indisponible : +/// échec réseau, réponse HTTP non réussie ou dépassement du délai (timeout). +/// +public class BANIndisponibleException : Exception +{ + public BANIndisponibleException(string message) + : base(message) + { + } + + public BANIndisponibleException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/BaremeNonConfigureException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/BaremeNonConfigureException.cs new file mode 100644 index 0000000..6d0262b --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/BaremeNonConfigureException.cs @@ -0,0 +1,20 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Signale l'absence de barème configuré pour une (puissance fiscale, année) lors d'un calcul de montant. +/// Ce n'est pas une erreur bloquante : le calcul retourne un montant zéro accompagné d'un avertissement +/// (Exigence 13.4). Le service de calcul n'a donc pas à lever cette exception pour interrompre le calcul ; +/// elle est fournie pour les cas où un appelant souhaite explicitement signaler la non-configuration. +/// +public class BaremeNonConfigureException : Exception +{ + public BaremeNonConfigureException(string message) + : base(message) + { + } + + public BaremeNonConfigureException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/DateEffetFutureException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/DateEffetFutureException.cs new file mode 100644 index 0000000..dae9c49 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/DateEffetFutureException.cs @@ -0,0 +1,18 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'une Date_Effet fournie pour un véhicule est postérieure à la date du jour. +/// Convertie en réponse HTTP 400 (Bad Request) — Exigence 10.11. +/// +public class DateEffetFutureException : Exception +{ + public DateEffetFutureException(string message) + : base(message) + { + } + + public DateEffetFutureException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/DenominationDupliqueeException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/DenominationDupliqueeException.cs new file mode 100644 index 0000000..99f1527 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/DenominationDupliqueeException.cs @@ -0,0 +1,19 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'une Dénomination d'Adresse_Nommee, après normalisation (trim + minuscules + +/// suppression des accents), correspond à une Adresse_Nommee déjà enregistrée (Exigence 15.7). +/// Convertie en réponse HTTP 400 (Bad Request). +/// +public class DenominationDupliqueeException : Exception +{ + public DenominationDupliqueeException(string message) + : base(message) + { + } + + public DenominationDupliqueeException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/DenominationInvalideException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/DenominationInvalideException.cs new file mode 100644 index 0000000..d519ff3 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/DenominationInvalideException.cs @@ -0,0 +1,18 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'une Dénomination d'Adresse_Nommee est absente, vide ou dépasse 100 caractères +/// (Exigence 15.6). Convertie en réponse HTTP 400 (Bad Request). +/// +public class DenominationInvalideException : Exception +{ + public DenominationInvalideException(string message) + : base(message) + { + } + + public DenominationInvalideException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/DistanceRecalculException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/DistanceRecalculException.cs new file mode 100644 index 0000000..fe305af --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/DistanceRecalculException.cs @@ -0,0 +1,20 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'un recalcul de la Distance échoue lors de la modification d'un Trajet. +/// Le service annule (rollback) la modification et conserve les données existantes du Trajet +/// sans modification, puis retourne cette erreur descriptive (Exigence 5.8). +/// Convertie en réponse HTTP 502 (Bad Gateway) / erreur descriptive par le contrôleur. +/// +public class DistanceRecalculException : Exception +{ + public DistanceRecalculException(string message) + : base(message) + { + } + + public DistanceRecalculException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/MontantRegleNegatifException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/MontantRegleNegatifException.cs new file mode 100644 index 0000000..7d75915 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/MontantRegleNegatifException.cs @@ -0,0 +1,18 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'un Montant_Regle soumis est négatif. Convertie en réponse HTTP 400 +/// (Bad Request) — Exigence 14.4 : « Le montant réglé ne peut pas être négatif. » +/// +public class MontantRegleNegatifException : Exception +{ + public MontantRegleNegatifException(string message) + : base(message) + { + } + + public MontantRegleNegatifException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/PdfGenerationException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/PdfGenerationException.cs new file mode 100644 index 0000000..00aa7ca --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/PdfGenerationException.cs @@ -0,0 +1,19 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsque la génération d'un Export_PDF échoue : erreur de rendu QuestPDF +/// ou dépassement du délai de 15 secondes (Exigence 7.5). Le controller la mappe +/// vers une réponse 503 Service Unavailable. +/// +public class PdfGenerationException : Exception +{ + public PdfGenerationException(string message) + : base(message) + { + } + + public PdfGenerationException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/ResumeParametreInvalideException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/ResumeParametreInvalideException.cs new file mode 100644 index 0000000..42c2eb5 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/ResumeParametreInvalideException.cs @@ -0,0 +1,19 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'un paramètre de résumé (mois ou année) est hors des plages autorisées : +/// mois ∈ [1, 12] et année ∈ [2000, 2100]. Convertie en réponse HTTP 400 (Bad Request) +/// par le controller — Exigence 3.6. +/// +public class ResumeParametreInvalideException : Exception +{ + public ResumeParametreInvalideException(string message) + : base(message) + { + } + + public ResumeParametreInvalideException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/TrajetNotFoundException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/TrajetNotFoundException.cs new file mode 100644 index 0000000..15183bf --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/TrajetNotFoundException.cs @@ -0,0 +1,24 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'une demande de modification ou de suppression cible un Trajet inexistant. +/// Convertie en réponse HTTP 404 (Not Found) ; l'opération est bloquée et aucun Résumé_Mensuel +/// n'est mis à jour (Exigences 5.6). +/// +public class TrajetNotFoundException : Exception +{ + public TrajetNotFoundException(int id) + : base($"Le trajet {id} n'existe pas.") + { + } + + public TrajetNotFoundException(string message) + : base(message) + { + } + + public TrajetNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/TrancheBaremeNotFoundException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/TrancheBaremeNotFoundException.cs new file mode 100644 index 0000000..f06b81f --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/TrancheBaremeNotFoundException.cs @@ -0,0 +1,18 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'aucune tranche de barème ne correspond à l'identifiant demandé +/// (mise à jour ou suppression). Convertie en réponse HTTP 404 (Not Found). +/// +public class TrancheBaremeNotFoundException : Exception +{ + public TrancheBaremeNotFoundException(string message) + : base(message) + { + } + + public TrancheBaremeNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/TranchesBaremeChevauchantesException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/TranchesBaremeChevauchantesException.cs new file mode 100644 index 0000000..842fd99 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/TranchesBaremeChevauchantesException.cs @@ -0,0 +1,19 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'une opération sur le barème produirait des tranches dont les intervalles +/// [KmMin, KmMax] se chevauchent pour une même (année, puissance fiscale). +/// Convertie en réponse HTTP 400 (Bad Request) — Exigence 13.2. +/// +public class TranchesBaremeChevauchantesException : Exception +{ + public TranchesBaremeChevauchantesException(string message) + : base(message) + { + } + + public TranchesBaremeChevauchantesException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/Exceptions/VehiculeNotFoundException.cs b/backend/TrajetKmTracker.Api/Services/Exceptions/VehiculeNotFoundException.cs new file mode 100644 index 0000000..1d49bbe --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/Exceptions/VehiculeNotFoundException.cs @@ -0,0 +1,19 @@ +namespace TrajetKmTracker.Api.Services.Exceptions; + +/// +/// Levée lorsqu'aucun véhicule ne correspond à l'identifiant demandé +/// (résolution du véhicule courant, réaffectation, etc.). +/// Convertie en réponse HTTP 404 (Not Found). +/// +public class VehiculeNotFoundException : Exception +{ + public VehiculeNotFoundException(string message) + : base(message) + { + } + + public VehiculeNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/IAdresseCacheService.cs b/backend/TrajetKmTracker.Api/Services/IAdresseCacheService.cs new file mode 100644 index 0000000..0a3c041 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IAdresseCacheService.cs @@ -0,0 +1,48 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Gestion du Cache_Adresses : recherche locale des adresses déjà connues, mise en cache +/// (upsert) des adresses géocodées et politique d'éviction LRU. +/// +public interface IAdresseCacheService +{ + /// + /// Recherche locale des adresses en cache correspondant au fragment fourni. + /// Retourne une liste vide si le fragment fait moins de 3 caractères (Requirement 9.3). + /// Les suggestions sont ordonnées de la plus récemment utilisée à la plus ancienne. + /// + /// Fragment de recherche (min. 3 caractères). + /// Nombre maximal de suggestions retournées (par défaut 5). + Task> RechercherLocalAsync( + string query, + int limite = 5, + CancellationToken cancellationToken = default); + + /// + /// Recherche l'entrée du cache correspondant exactement à l'adresse fournie (après normalisation). + /// Utilisé lors de la création d'un Trajet pour éviter un appel BAN (Requirement 9.2). + /// Met à jour la date de dernière utilisation de l'entrée trouvée. + /// + /// Les coordonnées et la géométrie si l'adresse est en cache, sinon null. + Task ObtenirParAdresseAsync( + string adresse, + CancellationToken cancellationToken = default); + + /// + /// Insère l'adresse dans le cache si elle est absente, sinon met uniquement à jour sa date de + /// dernière utilisation sans modifier ses coordonnées (Requirements 9.1, 9.6). Applique ensuite + /// la politique d'éviction LRU (Requirement 9.5). L'ensemble s'exécute en transaction. + /// + /// Libellé de l'adresse (sera normalisé pour le matching). + /// Latitude à utiliser si l'adresse est nouvelle. + /// Longitude à utiliser si l'adresse est nouvelle. + /// Géométrie GeoJSON optionnelle si l'adresse est nouvelle. + Task UpsertAdresseAsync( + string adresse, + double latitude, + double longitude, + string? geometrieJson = null, + CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IAdresseNommeeService.cs b/backend/TrajetKmTracker.Api/Services/IAdresseNommeeService.cs new file mode 100644 index 0000000..db41489 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IAdresseNommeeService.cs @@ -0,0 +1,38 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Gestion du Carnet_Adresses : CRUD des Adresses_Nommées avec normalisation de la Dénomination +/// (unicité) et résolution des coordonnées via le Cache_Adresses ou l'API BAN (Exigence 15). +/// +public interface IAdresseNommeeService +{ + /// + /// Liste toutes les Adresses_Nommées du Carnet_Adresses, triées par Dénomination (Exigence 15.1). + /// + Task> GetAllAsync(CancellationToken cancellationToken = default); + + /// + /// Crée une Adresse_Nommee : vérifie l'unicité de la Dénomination normalisée (15.6/15.7), + /// réutilise le Cache_Adresses si l'adresse y est déjà présente (15.10) ou géocode via BAN (15.11), + /// persiste avec ou sans coordonnées (15.5/15.12) et met en cache les coordonnées obtenues (15.9). + /// + /// Dénomination normalisée déjà utilisée (400). + Task CreateAsync(SaveAdresseNommeeDto dto, CancellationToken cancellationToken = default); + + /// + /// Modifie une Adresse_Nommee existante. Si le texte d'adresse a changé et n'est pas en cache, + /// re-géocode via BAN (15.11). Applique le contrôle d'unicité de la Dénomination (15.7). + /// + /// Adresse_Nommee introuvable (404). + /// Dénomination normalisée déjà utilisée (400). + Task UpdateAsync(int id, SaveAdresseNommeeDto dto, CancellationToken cancellationToken = default); + + /// + /// Supprime une Adresse_Nommee du Carnet_Adresses. Les Trajets existants ne sont pas modifiés + /// (ils conservent leur texte d'adresse et leurs coordonnées) (Exigence 15.14). + /// + /// Adresse_Nommee introuvable (404). + Task DeleteAsync(int id, CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IBANClient.cs b/backend/TrajetKmTracker.Api/Services/IBANClient.cs new file mode 100644 index 0000000..8b26ab4 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IBANClient.cs @@ -0,0 +1,22 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Client de l'API BAN (Base Adresses Nationale) pour l'autocomplétion +/// et le géocodage des adresses françaises. +/// +public interface IBANClient +{ + /// + /// Recherche des adresses correspondant à via l'endpoint + /// /search/ de la BAN (limité à 5 résultats). + /// + /// Texte de recherche (adresse partielle ou complète). + /// Jeton d'annulation. + /// La liste des adresses trouvées, mappées en . + /// + /// Levée en cas d'échec réseau, de réponse non réussie ou de dépassement du délai. + /// + Task> RechercherAsync(string query, CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IBaremeService.cs b/backend/TrajetKmTracker.Api/Services/IBaremeService.cs new file mode 100644 index 0000000..70d134a --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IBaremeService.cs @@ -0,0 +1,52 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Logique métier du barème kilométrique : CRUD des tranches, validation du non-chevauchement +/// par (année, puissance fiscale), sélection de tranche et calcul de montant. +/// +public interface IBaremeService +{ + /// Retourne toutes les tranches d'une année (toutes puissances fiscales confondues). + Task> GetBaremeAsync(int annee, CancellationToken cancellationToken = default); + + /// + /// Ajoute une tranche. Rejette l'ajout s'il crée un chevauchement d'intervalle [KmMin, KmMax] + /// pour la même (année, puissance fiscale) via . + /// + Task AddTrancheAsync(SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default); + + /// + /// Met à jour une tranche existante. Rejette la mise à jour si elle crée un chevauchement + /// (hors la tranche elle-même) via . + /// Lève si la tranche n'existe pas. + /// + Task UpdateTrancheAsync(int id, SaveTrancheBaremeDto dto, CancellationToken cancellationToken = default); + + /// + /// Supprime une tranche. Lève si elle n'existe pas. + /// + Task DeleteTrancheAsync(int id, CancellationToken cancellationToken = default); + + /// + /// Sélectionne la tranche dont [KmMin, KmMax] contient pour la + /// (année, puissance fiscale) donnée et calcule le montant `totalKm × Coefficient + Base` + /// (Property 24/27). Si aucune tranche n'existe pour cette combinaison, retourne un montant nul + /// et un signal « barème non configuré » sans lever d'erreur bloquante (Exigence 13.4). + /// + Task CalculerMontantAsync( + int annee, int puissanceFiscale, decimal totalKm, CancellationToken cancellationToken = default); + + /// + /// Copie toutes les tranches de l'année la plus récente (strictement antérieure à + /// ) qui possède un barème, vers . + /// Les tranches existantes de l'année cible sont d'abord supprimées (annule et remplace), + /// en transaction. Retourne le nombre de tranches copiées. Retourne 0 si aucun barème + /// antérieur n'existe (rien n'est modifié). + /// + Task CopierDernierBaremeAsync(int anneeCible, CancellationToken cancellationToken = default); + + /// Retourne les années (triées croissantes) pour lesquelles au moins une tranche de barème existe. + Task> GetAnneesAvecBaremeAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IDistanceCalculator.cs b/backend/TrajetKmTracker.Api/Services/IDistanceCalculator.cs new file mode 100644 index 0000000..71d0248 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IDistanceCalculator.cs @@ -0,0 +1,41 @@ +namespace TrajetKmTracker.Api.Services; + +/// +/// Orchestre le calcul de la distance d'un trajet à partir des adresses de départ et +/// d'arrivée et de leurs coordonnées éventuelles. +/// +/// Logique : +/// +/// Si les deux coordonnées sont connues → appel ORS ; en cas de timeout/échec ORS +/// → fallback orthodromique (Haversine), sans erreur pour l'utilisateur. +/// Si une coordonnée est absente → géocodage BAN de l'adresse manquante +/// (meilleur résultat) AVANT tout appel ORS ; ORS n'est jamais appelé avec des +/// coordonnées nulles (Property 16). +/// Si BAN ne peut pas résoudre (aucun résultat ou indisponible) → distance nulle +/// + avertissement, sans erreur bloquante, pour que l'appelant stocke la distance null. +/// +/// +public interface IDistanceCalculator +{ + /// + /// Calcule la distance entre et + /// , en résolvant au besoin les coordonnées manquantes + /// via BAN, puis en appelant ORS (avec fallback Haversine). + /// + /// Texte de l'adresse de départ. + /// Texte de l'adresse d'arrivée. + /// Latitude de départ connue, ou null. + /// Longitude de départ connue, ou null. + /// Latitude d'arrivée connue, ou null. + /// Longitude d'arrivée connue, ou null. + /// Jeton d'annulation. + /// Un (jamais d'exception bloquante). + Task CalculerAsync( + string adresseDepart, + string adresseArrivee, + double? departLatitude, + double? departLongitude, + double? arriveeLatitude, + double? arriveeLongitude, + CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IMontantRegleService.cs b/backend/TrajetKmTracker.Api/Services/IMontantRegleService.cs new file mode 100644 index 0000000..2c11a4f --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IMontantRegleService.cs @@ -0,0 +1,36 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Logique métier des Montant_Regle (montants effectivement versés, saisis par l'utilisateur) +/// et du Montant_Previsionnel (indication de versement à 80% du barème, purement informative). +/// +public interface IMontantRegleService +{ + /// + /// Enregistre ou met à jour (upsert) le Montant_Regle pour un couple (année, mois, véhicule). + /// S'il existe déjà une ligne pour la même (Annee, Mois, VehiculeId), elle est mise à jour ; + /// sinon une nouvelle ligne est insérée (Property 26, Exigences 14.1/14.2/14.3). + /// Rejette un montant négatif via (Exigence 14.4). + /// + Task UpsertMontantRegleAsync( + MontantRegleDto dto, CancellationToken cancellationToken = default); + + /// + /// Retourne les Montant_Regle saisis pour une (année, véhicule), triés par mois croissant. + /// Destiné au controller et au service de régularisation. + /// + Task> GetMontantsReglesAsync( + int annee, int vehiculeId, CancellationToken cancellationToken = default); + + /// + /// Calcule le Montant_Previsionnel indicatif pour un (année, mois, véhicule) : + /// 80% du Bareme_Kilometrique plein appliqué au Total_Annuel_Partiel du véhicule + /// (somme des distances de l'année pour ce véhicule jusqu'au mois indiqué inclus), + /// arrondi à deux décimales (Property 23, Exigences 13.5/13.6). + /// Cette valeur est purement informative et n'entre jamais dans la régularisation. + /// + Task GetMontantPrevisionnelAsync( + int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IORSClient.cs b/backend/TrajetKmTracker.Api/Services/IORSClient.cs new file mode 100644 index 0000000..40be77e --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IORSClient.cs @@ -0,0 +1,34 @@ +namespace TrajetKmTracker.Api.Services; + +/// +/// Client de l'API OpenRouteService (ORS) — calcul d'itinéraire routier uniquement. +/// ORS n'est jamais appelé pour du géocodage ni avec des coordonnées nulles : +/// les coordonnées de départ et d'arrivée doivent être connues (non-null). +/// +public interface IORSClient +{ + /// + /// Calcule l'itinéraire routier entre deux points géographiques connus via + /// POST /v2/directions/driving-car et retourne la distance (km) et la + /// géométrie GeoJSON de l'itinéraire. + /// + /// Latitude du point de départ. + /// Longitude du point de départ. + /// Latitude du point d'arrivée. + /// Longitude du point d'arrivée. + /// Jeton d'annulation. + /// Un (distance en km + géométrie GeoJSON). + /// + /// Levée si l'appel dépasse le délai imparti (voir ORSOptions.TimeoutSeconds), + /// afin que l'appelant applique le fallback orthodromique (Haversine). + /// + /// + /// Levée en cas d'échec général (erreur réseau, réponse invalide, corps illisible). + /// + Task CalculateRouteAsync( + double lat1, + double lon1, + double lat2, + double lon2, + CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IPdfService.cs b/backend/TrajetKmTracker.Api/Services/IPdfService.cs new file mode 100644 index 0000000..ce04d18 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IPdfService.cs @@ -0,0 +1,65 @@ +namespace TrajetKmTracker.Api.Services; + +/// +/// Génération des Export_PDF (QuestPDF). Compose les services métier existants +/// (résumé, profil, véhicule, barème, montants réglés) pour produire les documents. +/// La génération est enveloppée dans un délai de 15 secondes ; en cas de dépassement +/// ou d'erreur de rendu, une est levée +/// (Exigence 7.5) — le controller la mappe vers 503 Service Unavailable. +/// +public interface IPdfService +{ + /// + /// Génère le rapport PDF mensuel d'un (année, mois) pour le véhicule indiqué (Exigences 7.1, + /// 7.4, 7.6, 7.8, 7.10, 7.11). L'en-tête contient l'identité de l'utilisateur, le véhicule + /// (plaque, puissance fiscale, coefficient et base de la tranche annuelle applicable), la + /// période, l'adresse de départ du profil, le total kilométrique du mois, le total kilométrique + /// annuel cumulé, le TOTAL IK du mois (km du mois × coefficient de la tranche annuelle) et le + /// Montant_Regle du mois. Le corps liste les trajets (date, motif/commentaire, départ → arrivée, km). + /// Le nom de fichier suggéré est trajets_MM_AAAA.pdf. + /// + /// + /// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5). + /// + Task GenererRapportMensuelAsync( + int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default); + + /// + /// Génère la synthèse annuelle de régularisation d'une pour le véhicule + /// indiqué (Exigences 7.2, 7.4, 7.6, 7.7, 7.8, 7.12). La page de synthèse contient le nom et le + /// prénom de l'utilisateur, l'année, l'adresse de domicile, le barème applicable de l'année (les + /// trois tranches coefficient/base par puissance fiscale du véhicule), le véhicule et sa puissance + /// fiscale, la tranche annuelle applicable ; puis pour chacun des douze mois : les kilomètres + /// parcourus, le Montant_Regle (frais payé) et le Frais_Reel_Mensuel (frais réel). Les totaux + /// (km annuel, total réglé, Montant_Reel, Regularisation) et la « formule réelle » sont indiqués. + /// Le nom de fichier suggéré est trajets_AAAA.pdf. + /// + /// + /// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5). + /// + Task GenererRapportAnnuelAsync( + int annee, int vehiculeId, CancellationToken cancellationToken = default); + + /// + /// Détermine les véhicules ayant des Trajets pour l' et génère un rapport + /// annuel PDF distinct par véhicule (Exigence 7.9). Retourne un PDF par véhicule ayant des données, + /// trié par identifiant de véhicule. Retourne une liste vide si aucun trajet n'existe pour l'année. + /// + /// + /// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5). + /// + Task> GenererRapportsAnnuelsParVehiculeAsync( + int annee, CancellationToken cancellationToken = default); + + /// + /// Détermine les véhicules ayant des Trajets pour le couple (, + /// ) et génère un rapport mensuel PDF distinct par véhicule (Exigence 7.9). + /// Retourne un PDF par véhicule ayant des données, trié par identifiant de véhicule. Retourne une + /// liste vide si aucun trajet n'existe pour la période. + /// + /// + /// Levée en cas de dépassement du délai de 15 s ou d'échec de rendu (Exigence 7.5). + /// + Task> GenererRapportsMensuelsParVehiculeAsync( + int annee, int mois, CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IProfilService.cs b/backend/TrajetKmTracker.Api/Services/IProfilService.cs new file mode 100644 index 0000000..663e2a3 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IProfilService.cs @@ -0,0 +1,31 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Logique métier du profil utilisateur (singleton Id = 1) : lecture, sauvegarde avec +/// géocodage BAN de l'Adresse_Domicile et détermination de l'état de configuration (onboarding). +/// +public interface IProfilService +{ + /// + /// Retourne le profil (singleton Id = 1). Crée l'enregistrement à la volée s'il est absent + /// afin de garantir qu'un profil vide soit toujours disponible à la lecture. + /// + Task GetProfilAsync(CancellationToken cancellationToken = default); + + /// + /// Sauvegarde le profil. Géocode l'Adresse_Domicile via la BAN : en cas de succès, stocke les + /// coordonnées et positionne DomicileGeocode = true (avec mise en cache de l'adresse) ; + /// en cas d'échec BAN, persiste l'adresse textuelle sans coordonnées et DomicileGeocode = false + /// sans lever d'exception (Requirements 10.2, 10.7, 10.8). + /// + Task SaveProfilAsync(SaveProfilDto dto, CancellationToken cancellationToken = default); + + /// + /// Détermine l'état de configuration du profil (onboarding) : + /// EstConfigure = true si et seulement si un Profil existe avec un Nom ET un Prénom non vides + /// (après trim) ET qu'au moins un Véhicule existe (Property 31, Requirements 16.1, 16.2). + /// + Task GetEtatAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IRemboursementService.cs b/backend/TrajetKmTracker.Api/Services/IRemboursementService.cs new file mode 100644 index 0000000..d0fb74a --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IRemboursementService.cs @@ -0,0 +1,27 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Logique métier de la régularisation annuelle (barème plein, 100%). +/// +/// Contrairement au Montant_Previsionnel (80% indicatif, hors régularisation), la régularisation +/// applique le Bareme_Kilometrique plein au total kilométrique annuel COMPLET du véhicule : +/// +/// Montant_Reel = total_km_annuel × Coefficient + Base de la tranche déterminée par ce +/// total annuel (Exigence 14.5, Property 25/27). +/// Frais_Reel_Mensuel[m] = km_du_mois[m] × Coefficient de la tranche annuelle, de sorte que +/// la somme des douze Frais_Reel_Mensuel + la Base de la tranche = Montant_Reel (Exigence 14.6). +/// Regularisation = Montant_Reel − total des Montant_Regle de l'année (Exigence 14.7). +/// +/// +public interface IRemboursementService +{ + /// + /// Construit la synthèse annuelle de régularisation pour un couple (année, véhicule) : + /// identité de la tranche annuelle applicable, 12 lignes mensuelles + /// () et totaux (km annuel, réglé, Montant_Reel, Regularisation). + /// + Task GetRegularisationAnnuelleAsync( + int annee, int vehiculeId, CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IResumeService.cs b/backend/TrajetKmTracker.Api/Services/IResumeService.cs new file mode 100644 index 0000000..ad6e0fb --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IResumeService.cs @@ -0,0 +1,34 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Logique métier des résumés kilométriques mensuels et annuels. +/// Les distances nulles (Trajet non calculé, Exigence 2.4) sont traitées comme 0 dans les totaux. +/// +public interface IResumeService +{ + /// + /// Construit le Résumé_Mensuel d'un mois : liste des Trajets triés par date croissante puis + /// par Position croissante, chacun avec sa Distance, et total kilométrique (somme des distances, + /// null → 0) arrondi à 2 décimales (Property 1 — Exigences 3.2, 3.3, 6.5). + /// Filtre par véhicule lorsque est fourni ; sinon inclut tous les véhicules. + /// + /// + /// Levée si ∉ [1, 12] ou ∉ [2000, 2100] (Exigence 3.6). + /// + Task GetResumeMensuelAsync( + int annee, int mois, int? vehiculeId = null, CancellationToken cancellationToken = default); + + /// + /// Construit le Résumé_Annuel d'une année : toujours exactement 12 Résumés_Mensuels + /// (mois 1..12, les mois vides ayant un total de 0,00), et total annuel égal à la somme + /// des 12 totaux mensuels (Property 2, Property 7 — Exigences 4.2, 4.3, 4.4, 4.7, 6.6). + /// Filtre par véhicule lorsque est fourni ; sinon inclut tous les véhicules. + /// + /// + /// Levée si ∉ [2000, 2100] (Exigence 3.6). + /// + Task GetResumeAnnuelAsync( + int annee, int? vehiculeId = null, CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/ITotauxService.cs b/backend/TrajetKmTracker.Api/Services/ITotauxService.cs new file mode 100644 index 0000000..d547f5e --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/ITotauxService.cs @@ -0,0 +1,44 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Calcul des totaux du Panneau_Totaux (jour, mois, Total_Annuel_Partiel) filtrés par véhicule +/// et fourniture des données de navigation temporelle (années, mois et jours disponibles). +/// +public interface ITotauxService +{ + /// + /// Calcule, pour la sélectionnée et le véhicule indiqué, les totaux + /// kilométriques affichés dans le Panneau_Totaux (Exigences 12.7, 12.8, 12.19) : + /// + /// TotalJour : somme des distances des Trajets de la journée. + /// TotalMois : somme des distances des Trajets du mois de la date. + /// TotalAnnuelPartiel : cumul de l'année jusqu'au mois de la date inclus, + /// à l'exclusion des mois strictement postérieurs (Property 17). + /// + /// Les distances nulles sont comptées comme 0 et tous les totaux sont arrondis à 2 décimales. + /// Seuls les Trajets du véhicule sont additionnés (Property 21). + /// Si vaut null, le Véhicule_Courant est utilisé + /// (via ). + /// + Task GetTotauxAsync(DateOnly date, int? vehiculeId = null, CancellationToken cancellationToken = default); + + /// + /// Retourne la liste des années comportant au moins un Trajet, à laquelle est ajoutée + /// l'année courante si elle n'y figure pas déjà (Exigence 12.15). Liste triée croissante. + /// + Task> GetAnneesDisponiblesAsync(CancellationToken cancellationToken = default); + + /// + /// Retourne la liste des mois (1..12) de l'année comportant au moins + /// un Trajet (Exigence 12.16). Liste triée croissante. + /// + Task> GetMoisDisponiblesAsync(int annee, CancellationToken cancellationToken = default); + + /// + /// Retourne la liste des jours du mois de l'année + /// comportant au moins un Trajet (Exigence 12.16). Liste triée croissante. + /// + Task> GetJoursDisponiblesAsync(int annee, int mois, CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/ITrajetService.cs b/backend/TrajetKmTracker.Api/Services/ITrajetService.cs new file mode 100644 index 0000000..735c977 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/ITrajetService.cs @@ -0,0 +1,87 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Logique métier de gestion des Trajets. Cette interface couvre pour l'instant la +/// création (tâche 8.2) ; la modification, la suppression et le réordonnancement +/// (tâche 8.5) l'étendront ultérieurement. +/// +public interface ITrajetService +{ + /// + /// Crée un Trajet à partir de : + /// + /// parse la date au format JJ/MM/AAAA (la validation de forme est faite par le validateur MVC) ; + /// associe le véhicule : VehiculeId fourni sinon le véhicule courant à la date + /// du trajet, en copiant sa PuissanceFiscale (dénormalisation, Exigence 10.14) ; + /// calcule la distance via (BAN puis ORS, fallback Haversine) ; + /// met en cache les adresses résolues via ; + /// attribue la position en fin de journée (max(Position)+1.0) ; + /// persiste le Trajet en transaction. Lorsque la distance n'a pas pu être calculée, + /// le Trajet est tout de même persisté avec une Distance nulle et un avertissement (Exigence 2.4). + /// + /// + /// Données de création transmises par le contrôleur (déjà validées côté MVC). + /// Jeton d'annulation. + /// Le (trajet créé + éventuel avertissement). + /// + /// Si aucun véhicule n'est applicable à la date du trajet et qu'aucun VehiculeId n'est fourni. + /// + Task CreateAsync(CreateTrajetDto dto, CancellationToken cancellationToken = default); + + /// + /// Modifie un Trajet existant à partir de : + /// + /// lève (404) si le Trajet est introuvable + /// (Exigence 5.6) ; + /// recalcule la Distance uniquement si au moins une adresse a changé (comparaison sur + /// le texte d'adresse normalisé) ou si le Trajet avait une Distance nulle et que les nouvelles + /// adresses deviennent résolubles (Exigences 5.2, 5.9, 5.10) ; sinon les autres champs (commentaire, + /// véhicule, date) sont mis à jour sans recalcul de distance (Exigence 5.3) ; + /// en cas d'échec du recalcul (coordonnées connues mais calcul impossible), annule la + /// modification (rollback) et lève en préservant + /// les données existantes du Trajet (Exigence 5.8) ; + /// met en cache les adresses résolues (Exigences 6.7, 6.8) ; + /// persiste la modification en transaction. + /// + /// + /// Identifiant du Trajet à modifier. + /// Données de modification (déjà validées côté MVC). + /// Jeton d'annulation. + /// Le (trajet mis à jour + éventuel avertissement). + /// Si le Trajet n'existe pas. + /// Si le recalcul de la distance échoue. + /// + /// Si le VehiculeId fourni est introuvable, ou qu'aucun véhicule n'est applicable à la date. + /// + Task UpdateAsync(int id, UpdateTrajetDto dto, CancellationToken cancellationToken = default); + + /// + /// Supprime un Trajet existant de manière transactionnelle (Exigence 5.5). + /// Lève (404) si le Trajet est introuvable (Exigence 5.6). + /// + /// Identifiant du Trajet à supprimer. + /// Jeton d'annulation. + /// Si le Trajet n'existe pas. + Task DeleteAsync(int id, CancellationToken cancellationToken = default); + + /// + /// Applique un réordonnancement manuel des trajets d'une journée : met à jour uniquement la + /// Position des trajets fournis via , + /// sans altérer leurs autres champs (Exigence 11.9). Les identifiants inconnus sont ignorés. + /// + /// Nouvelles positions des trajets à réordonner. + /// Jeton d'annulation. + Task ReordonnerAsync(ReordonnerTrajetsDto dto, CancellationToken cancellationToken = default); + + /// + /// Recalcule la Distance (et la géométrie d'itinéraire mise en cache) de TOUS les trajets + /// via (BAN + ORS, fallback Haversine), indépendamment de + /// tout changement d'adresse. Utile pour « réparer » des distances calculées en mode dégradé + /// (ex. clé ORS invalide → distances à vol d'oiseau) une fois ORS de nouveau opérationnel. + /// Chaque trajet est traité de façon indépendante : un échec sur l'un n'interrompt pas les autres. + /// + /// Le nombre de trajets dont la distance a effectivement été recalculée. + Task RecalculerToutesLesDistancesAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/IVehiculeService.cs b/backend/TrajetKmTracker.Api/Services/IVehiculeService.cs new file mode 100644 index 0000000..e4d2177 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/IVehiculeService.cs @@ -0,0 +1,41 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Logique métier de gestion des véhicules : historique, véhicule courant, +/// création (avec détection de réaffectation) et réaffectation des trajets. +/// +public interface IVehiculeService +{ + /// Retourne l'historique complet des véhicules (plaque, puissance fiscale, date d'effet), trié par Date_Effet décroissante. + Task> GetHistoriqueAsync(CancellationToken cancellationToken = default); + + /// + /// Retourne le Véhicule_Courant : Date_Effet la plus récente parmi celles <= aujourd'hui. + /// Lève si aucun véhicule n'est applicable. + /// + Task GetVehiculeCourantAsync(CancellationToken cancellationToken = default); + + /// + /// Résout le véhicule applicable à une date donnée (pour l'association d'un trajet) : + /// Date_Effet la plus récente <= . + /// Retourne null si aucun véhicule n'est applicable à cette date. + /// + Task GetVehiculeADateAsync(DateOnly date, CancellationToken cancellationToken = default); + + /// + /// Crée un véhicule, conserve l'historique et détecte les trajets à réaffecter + /// (date >= Date_Effet et puissance fiscale différente). + /// Lève si la Date_Effet est postérieure à aujourd'hui. + /// + Task CreateAsync(CreateVehiculeDto dto, CancellationToken cancellationToken = default); + + /// + /// Applique (ou non) la réaffectation des trajets au véhicule indiqué. + /// Si est vrai, met à jour VehiculeId + PuissanceFiscale + /// de tous les trajets dont la Date >= Date_Effet du véhicule ; sinon ne change rien. + /// Lève si le véhicule n'existe pas. + /// + Task ReaffecterAsync(ReaffectationDto dto, CancellationToken cancellationToken = default); +} diff --git a/backend/TrajetKmTracker.Api/Services/MontantRegleService.cs b/backend/TrajetKmTracker.Api/Services/MontantRegleService.cs new file mode 100644 index 0000000..f1a7fe6 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/MontantRegleService.cs @@ -0,0 +1,121 @@ +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Models.Entities; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +public class MontantRegleService : IMontantRegleService +{ + private const decimal TauxPrevisionnel = 0.80m; + + private readonly AppDbContext _context; + private readonly IBaremeService _baremeService; + + public MontantRegleService(AppDbContext context, IBaremeService baremeService) + { + _context = context; + _baremeService = baremeService; + } + + /// + public async Task UpsertMontantRegleAsync( + MontantRegleDto dto, CancellationToken cancellationToken = default) + { + // Défense en profondeur : le validateur FluentValidation impose déjà Montant >= 0, + // mais le service refuse aussi les montants négatifs (Exigence 14.4). + if (dto.Montant < 0m) + { + throw new MontantRegleNegatifException("Le montant réglé ne peut pas être négatif."); + } + + // Upsert par la clé métier (Annee, Mois, VehiculeId) — index unique + // UX_MontantsRegles_Periode_Vehicule garantit l'unicité (Property 26, Exigence 14.3). + var existant = await _context.MontantsRegles + .FirstOrDefaultAsync( + m => m.Annee == dto.Annee && m.Mois == dto.Mois && m.VehiculeId == dto.VehiculeId, + cancellationToken); + + if (existant is null) + { + var montantRegle = new MontantRegle + { + Annee = dto.Annee, + Mois = dto.Mois, + VehiculeId = dto.VehiculeId, + Montant = dto.Montant, + UpdatedAt = DateTime.UtcNow, + }; + + _context.MontantsRegles.Add(montantRegle); + await _context.SaveChangesAsync(cancellationToken); + + return ToDto(montantRegle); + } + + existant.Montant = dto.Montant; + existant.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(cancellationToken); + + return ToDto(existant); + } + + /// + public async Task> GetMontantsReglesAsync( + int annee, int vehiculeId, CancellationToken cancellationToken = default) + { + var montants = await _context.MontantsRegles + .AsNoTracking() + .Where(m => m.Annee == annee && m.VehiculeId == vehiculeId) + .OrderBy(m => m.Mois) + .ToListAsync(cancellationToken); + + return montants.Select(ToDto).ToList(); + } + + /// + public async Task GetMontantPrevisionnelAsync( + int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default) + { + // Puissance fiscale du véhicule concerné : détermine la tranche du barème. + var vehicule = await _context.Vehicules + .AsNoTracking() + .FirstOrDefaultAsync(v => v.Id == vehiculeId, cancellationToken) + ?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable."); + + // Total_Annuel_Partiel : somme des distances de l'année pour ce véhicule, + // jusqu'au mois sélectionné inclus (les mois postérieurs sont exclus). + // SQLite ne sait pas appliquer SUM sur une colonne decimal : on ramène les distances + // concernées et on somme côté client (null traité comme 0). + var distancesPartielles = await _context.Trajets + .AsNoTracking() + .Where(t => t.VehiculeId == vehiculeId + && t.Date.Year == annee + && t.Date.Month <= mois) + .Select(t => t.Distance) + .ToListAsync(cancellationToken); + var totalAnnuelPartiel = distancesPartielles.Sum(d => d ?? 0m); + + // Barème plein appliqué au total partiel (sans minoration). + var bareme = await _baremeService.CalculerMontantAsync( + annee, vehicule.PuissanceFiscale, totalAnnuelPartiel, cancellationToken); + + var montantBaremePlein = Math.Round(bareme.Montant, 2, MidpointRounding.AwayFromZero); + + // Montant_Previsionnel = 80% du barème plein, arrondi à 2 décimales (indicatif — Exigences 13.5/13.6). + var montantPrevisionnel = Math.Round(bareme.Montant * TauxPrevisionnel, 2, MidpointRounding.AwayFromZero); + + return new MontantPrevisionnelDto( + Annee: annee, + Mois: mois, + VehiculeId: vehiculeId, + TotalAnnuelPartielKm: Math.Round(totalAnnuelPartiel, 2, MidpointRounding.AwayFromZero), + MontantBaremePlein: montantBaremePlein, + MontantPrevisionnel: montantPrevisionnel); + } + + private static MontantRegleDto ToDto(MontantRegle m) => + new(m.Annee, m.Mois, m.VehiculeId, m.Montant); +} diff --git a/backend/TrajetKmTracker.Api/Services/ORSClient.cs b/backend/TrajetKmTracker.Api/Services/ORSClient.cs new file mode 100644 index 0000000..c8274d5 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/ORSClient.cs @@ -0,0 +1,133 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Client typé (HttpClient) de l'API OpenRouteService (ORS) pour le calcul +/// d'itinéraire routier. La BaseAddress, le Timeout et l'en-tête +/// de clé d'API sont configurés lors de l'enregistrement du client typé dans +/// Program.cs à partir de ORSOptions. +/// +public class ORSClient : IORSClient +{ + // Point d'accès GeoJSON : retourne une FeatureCollection avec la géométrie + // de l'itinéraire au format GeoJSON et le résumé (distance en mètres). + private const string DirectionsGeoJsonPath = "v2/directions/driving-car/geojson"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient _httpClient; + + public ORSClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + /// + public async Task CalculateRouteAsync( + double lat1, + double lon1, + double lat2, + double lon2, + CancellationToken cancellationToken = default) + { + // ORS attend les coordonnées au format [longitude, latitude]. + var body = new + { + coordinates = new[] + { + new[] { lon1, lat1 }, + new[] { lon2, lat2 } + } + }; + + HttpResponseMessage response; + try + { + response = await _httpClient.PostAsJsonAsync(DirectionsGeoJsonPath, body, cancellationToken); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + // Le HttpClient annule via son Timeout : OperationCanceledException est levée + // sans que le jeton de l'appelant soit annulé -> il s'agit d'un dépassement de délai. + throw new ORSTimeoutException( + "L'appel à OpenRouteService a dépassé le délai imparti.", ex); + } + catch (TaskCanceledException ex) + { + throw new ORSTimeoutException( + "L'appel à OpenRouteService a dépassé le délai imparti.", ex); + } + catch (HttpRequestException ex) + { + throw new ORSException( + "Échec de l'appel réseau à OpenRouteService.", ex); + } + + if (!response.IsSuccessStatusCode) + { + throw new ORSException( + $"OpenRouteService a retourné un statut non conforme : {(int)response.StatusCode}."); + } + + try + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + return ParseRouteResult(document.RootElement); + } + catch (JsonException ex) + { + throw new ORSException( + "La réponse d'OpenRouteService n'a pas pu être analysée.", ex); + } + } + + /// + /// Extrait la distance (mètres -> km) et la géométrie GeoJSON de la réponse ORS. + /// La réponse GeoJSON est une FeatureCollection dont la première feature contient + /// properties.summary.distance (mètres) et geometry (GeoJSON). + /// + private static RouteResult ParseRouteResult(JsonElement root) + { + if (!root.TryGetProperty("features", out var features) + || features.ValueKind != JsonValueKind.Array + || features.GetArrayLength() == 0) + { + throw new ORSException( + "La réponse d'OpenRouteService ne contient aucun itinéraire."); + } + + var feature = features[0]; + + double distanceMeters; + if (feature.TryGetProperty("properties", out var properties) + && properties.TryGetProperty("summary", out var summary) + && summary.TryGetProperty("distance", out var distanceElement) + && distanceElement.ValueKind == JsonValueKind.Number) + { + distanceMeters = distanceElement.GetDouble(); + } + else + { + throw new ORSException( + "La distance est absente de la réponse d'OpenRouteService."); + } + + // Conversion mètres -> km, arrondi à 2 décimales. + var distanceKm = Math.Round((decimal)(distanceMeters / 1000.0), 2, MidpointRounding.AwayFromZero); + + string? geometrieJson = null; + if (feature.TryGetProperty("geometry", out var geometry) + && geometry.ValueKind == JsonValueKind.Object) + { + geometrieJson = geometry.GetRawText(); + } + + return new RouteResult(distanceKm, geometrieJson); + } +} diff --git a/backend/TrajetKmTracker.Api/Services/ORSException.cs b/backend/TrajetKmTracker.Api/Services/ORSException.cs new file mode 100644 index 0000000..9c185f4 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/ORSException.cs @@ -0,0 +1,26 @@ +namespace TrajetKmTracker.Api.Services; + +/// +/// Levée en cas d'échec général d'un appel à OpenRouteService (ORS) qui n'est pas +/// un dépassement de délai : erreur réseau, réponse HTTP non conforme, corps de +/// réponse illisible, etc. Distincte de afin que +/// l'appelant puisse différencier un timeout (fallback Haversine) d'une défaillance +/// générale du service de calcul d'itinéraire. +/// +public class ORSException : Exception +{ + public ORSException() + : base("Erreur lors de l'appel à OpenRouteService.") + { + } + + public ORSException(string message) + : base(message) + { + } + + public ORSException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/ORSTimeoutException.cs b/backend/TrajetKmTracker.Api/Services/ORSTimeoutException.cs new file mode 100644 index 0000000..3ba0f76 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/ORSTimeoutException.cs @@ -0,0 +1,26 @@ +namespace TrajetKmTracker.Api.Services; + +/// +/// Levée lorsqu'un appel à OpenRouteService (ORS) dépasse le délai imparti +/// (voir ). +/// Le service appelant (DistanceCalculatorService) intercepte cette exception +/// pour appliquer le fallback orthodromique (Haversine) sans erreur bloquante +/// pour l'utilisateur. Voir Requirement 2.6. +/// +public class ORSTimeoutException : Exception +{ + public ORSTimeoutException() + : base("L'appel à OpenRouteService a dépassé le délai imparti.") + { + } + + public ORSTimeoutException(string message) + : base(message) + { + } + + public ORSTimeoutException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/backend/TrajetKmTracker.Api/Services/PdfResult.cs b/backend/TrajetKmTracker.Api/Services/PdfResult.cs new file mode 100644 index 0000000..5908034 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/PdfResult.cs @@ -0,0 +1,12 @@ +namespace TrajetKmTracker.Api.Services; + +/// +/// Résultat de la génération d'un Export_PDF : les octets du document et le nom de fichier +/// suggéré (format trajets_MM_AAAA.pdf pour le mensuel, trajets_AAAA.pdf pour l'annuel). +/// Le câblage de l'en-tête HTTP Content-Disposition relève du controller (tâche 11.3). +/// +/// Octets du fichier PDF (application/pdf). +/// Nom de fichier suggéré, ex. trajets_01_2025.pdf. +public record PdfResult( + byte[] Contenu, + string NomFichier); diff --git a/backend/TrajetKmTracker.Api/Services/PdfService.cs b/backend/TrajetKmTracker.Api/Services/PdfService.cs new file mode 100644 index 0000000..f5f84a1 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/PdfService.cs @@ -0,0 +1,745 @@ +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Génération des Export_PDF via QuestPDF. Ce service compose les services métier existants +/// (résumé, profil, véhicule, barème, montants réglés) et n'accède pas directement à la base. +/// Le rapport mensuel est produit par ; la synthèse annuelle +/// par . Les variantes « par véhicule » génèrent un PDF distinct +/// pour chaque véhicule ayant des trajets sur la période (Exigence 7.9). +/// +public class PdfService : IPdfService +{ + /// Délai maximal de génération d'un PDF (Exigence 7.5). + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); + + private static readonly CultureInfo FrCulture = CultureInfo.GetCultureInfo("fr-FR"); + + private readonly IResumeService _resumeService; + private readonly IProfilService _profilService; + private readonly IVehiculeService _vehiculeService; + private readonly IBaremeService _baremeService; + private readonly IMontantRegleService _montantRegleService; + private readonly IRemboursementService _remboursementService; + private readonly AppDbContext _context; + + public PdfService( + IResumeService resumeService, + IProfilService profilService, + IVehiculeService vehiculeService, + IBaremeService baremeService, + IMontantRegleService montantRegleService, + IRemboursementService remboursementService, + AppDbContext context) + { + _resumeService = resumeService; + _profilService = profilService; + _vehiculeService = vehiculeService; + _baremeService = baremeService; + _montantRegleService = montantRegleService; + _remboursementService = remboursementService; + _context = context; + } + + /// + public async Task GenererRapportMensuelAsync( + int annee, int mois, int vehiculeId, CancellationToken cancellationToken = default) + { + // Enveloppe la génération dans un délai de 15 s (Exigence 7.5) — le dépassement ou toute + // erreur de rendu se traduit par une PdfGenerationException que le controller mappe en 503. + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(Timeout); + var token = timeoutCts.Token; + + return await ExecuterAvecTimeoutAsync(cancellationToken, async token => + { + var modele = await AssemblerModeleMensuelAsync(annee, mois, vehiculeId, token); + var octets = RendreMensuel(modele, token); + + var nomFichier = $"trajets_{mois:D2}_{annee:D4}.pdf"; + return new PdfResult(octets, nomFichier); + }); + } + + /// + public async Task GenererRapportAnnuelAsync( + int annee, int vehiculeId, CancellationToken cancellationToken = default) + { + return await ExecuterAvecTimeoutAsync(cancellationToken, async token => + { + var modele = await AssemblerModeleAnnuelAsync(annee, vehiculeId, token); + var octets = RendreAnnuel(modele, token); + + var nomFichier = $"trajets_{annee:D4}.pdf"; + return new PdfResult(octets, nomFichier); + }); + } + + /// + public async Task> GenererRapportsAnnuelsParVehiculeAsync( + int annee, CancellationToken cancellationToken = default) + { + var vehiculeIds = await VehiculesAvecTrajetsAnneeAsync(annee, cancellationToken); + + var resultats = new List(vehiculeIds.Count); + foreach (var vehiculeId in vehiculeIds) + { + resultats.Add(await GenererRapportAnnuelAsync(annee, vehiculeId, cancellationToken)); + } + + return resultats; + } + + /// + public async Task> GenererRapportsMensuelsParVehiculeAsync( + int annee, int mois, CancellationToken cancellationToken = default) + { + var vehiculeIds = await VehiculesAvecTrajetsMoisAsync(annee, mois, cancellationToken); + + var resultats = new List(vehiculeIds.Count); + foreach (var vehiculeId in vehiculeIds) + { + resultats.Add(await GenererRapportMensuelAsync(annee, mois, vehiculeId, cancellationToken)); + } + + return resultats; + } + + /// + /// Enveloppe une opération de génération PDF dans un délai de 15 s (Exigence 7.5). Tout + /// dépassement du délai propre au PDF (et non une annulation demandée par l'appelant) ou toute + /// erreur de rendu est traduit en — que le controller mappe + /// vers 503 Service Unavailable. + /// + private static async Task ExecuterAvecTimeoutAsync( + CancellationToken cancellationToken, Func> operation) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(Timeout); + var token = timeoutCts.Token; + + try + { + return await operation(token); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + // Dépassement du délai propre à la génération PDF (et non annulation par l'appelant). + throw new PdfGenerationException( + "La génération du PDF a échoué : délai de 15 secondes dépassé."); + } + catch (PdfGenerationException) + { + throw; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Annulation demandée par l'appelant : propagée telle quelle. + throw; + } + catch (Exception ex) + { + throw new PdfGenerationException( + "La génération du PDF a échoué.", ex); + } + } + + /// + /// Retourne, triés par identifiant, les véhicules ayant au moins un Trajet sur l'année. + /// + private async Task> VehiculesAvecTrajetsAnneeAsync( + int annee, CancellationToken cancellationToken) + { + return await _context.Trajets + .AsNoTracking() + .Where(t => t.Date.Year == annee) + .Select(t => t.VehiculeId) + .Distinct() + .OrderBy(id => id) + .ToListAsync(cancellationToken); + } + + /// + /// Retourne, triés par identifiant, les véhicules ayant au moins un Trajet sur le mois donné. + /// + private async Task> VehiculesAvecTrajetsMoisAsync( + int annee, int mois, CancellationToken cancellationToken) + { + return await _context.Trajets + .AsNoTracking() + .Where(t => t.Date.Year == annee && t.Date.Month == mois) + .Select(t => t.VehiculeId) + .Distinct() + .OrderBy(id => id) + .ToListAsync(cancellationToken); + } + + /// + /// Agrège, via les services métier existants, toutes les données nécessaires au rapport mensuel. + /// + private async Task AssemblerModeleMensuelAsync( + int annee, int mois, int vehiculeId, CancellationToken cancellationToken) + { + // Identité et adresse de départ (profil). + var profil = await _profilService.GetProfilAsync(cancellationToken); + + // Véhicule concerné (plaque, puissance fiscale). + var vehicule = await ResoudreVehiculeAsync(vehiculeId, cancellationToken); + + // Résumé mensuel filtré sur le véhicule (trajets triés + total km du mois). + var resumeMensuel = await _resumeService.GetResumeMensuelAsync(annee, mois, vehiculeId, cancellationToken); + + // Total kilométrique annuel cumulé (tous mois de l'année, même véhicule). + var resumeAnnuel = await _resumeService.GetResumeAnnuelAsync(annee, vehiculeId, cancellationToken); + var totalKmAnnuel = resumeAnnuel.TotalKm; + + // Coefficient et base de la tranche ANNUELLE applicable (déterminée par le total annuel). + var bareme = await _baremeService.CalculerMontantAsync( + annee, vehicule.PuissanceFiscale, totalKmAnnuel, cancellationToken); + + // TOTAL IK du mois = km du mois × coefficient de la tranche annuelle applicable. + var totalIkMois = Math.Round(resumeMensuel.TotalKm * bareme.Coefficient, 2, MidpointRounding.AwayFromZero); + + // Montant_Regle saisi pour ce (année, mois, véhicule) — 0 si absent. + var montantsRegles = await _montantRegleService.GetMontantsReglesAsync(annee, vehiculeId, cancellationToken); + var montantRegle = montantsRegles + .Where(m => m.Mois == mois) + .Select(m => m.Montant) + .FirstOrDefault(); + + return new ModeleRapportMensuel( + Annee: annee, + Mois: mois, + Profil: profil, + Vehicule: vehicule, + Trajets: resumeMensuel.Trajets, + TotalKmMois: resumeMensuel.TotalKm, + TotalKmAnnuel: totalKmAnnuel, + Coefficient: bareme.Coefficient, + Base: bareme.Base, + TotalIkMois: totalIkMois, + MontantRegle: montantRegle); + } + + /// + /// Agrège, via les services métier existants, les données nécessaires à la synthèse annuelle : + /// profil (identité + domicile), régularisation annuelle (12 lignes + totaux + tranche) et les + /// trois tranches du barème de l'année pour la puissance fiscale du véhicule. + /// + private async Task AssemblerModeleAnnuelAsync( + int annee, int vehiculeId, CancellationToken cancellationToken) + { + // Identité et adresse de domicile (profil). + var profil = await _profilService.GetProfilAsync(cancellationToken); + + // Synthèse annuelle de régularisation (barème plein, 12 lignes, totaux, tranche applicable). + var regularisation = await _remboursementService.GetRegularisationAnnuelleAsync( + annee, vehiculeId, cancellationToken); + + // Barème de l'année filtré sur la puissance fiscale du véhicule (les trois tranches). + var toutesTranches = await _baremeService.GetBaremeAsync(annee, cancellationToken); + var tranchesVehicule = toutesTranches + .Where(t => t.PuissanceFiscale == regularisation.PuissanceFiscale) + .OrderBy(t => t.KmMin) + .ToList(); + + return new ModeleRapportAnnuel( + Annee: annee, + Profil: profil, + Regularisation: regularisation, + TranchesBareme: tranchesVehicule); + } + + /// + /// Résout le véhicule ciblé par son identifiant à partir de l'historique. + /// Lève si aucun véhicule ne correspond. + /// + private async Task ResoudreVehiculeAsync(int vehiculeId, CancellationToken cancellationToken) + { + var historique = await _vehiculeService.GetHistoriqueAsync(cancellationToken); + var vehicule = historique.FirstOrDefault(v => v.Id == vehiculeId); + + return vehicule + ?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable."); + } + + /// + /// Rend le document mensuel en octets PDF selon la maquette « PDF Mensuel » du design. + /// + private static byte[] RendreMensuel(ModeleRapportMensuel modele, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var document = Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(30); + page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black)); + + page.Header().Element(c => ComposerEntete(c, modele)); + page.Content().PaddingTop(10).Element(c => ComposerTableTrajets(c, modele)); + page.Footer().AlignCenter().Text(txt => + { + txt.Span("Page "); + txt.CurrentPageNumber(); + txt.Span(" / "); + txt.TotalPages(); + }); + }); + }); + + cancellationToken.ThrowIfCancellationRequested(); + return document.GeneratePdf(); + } + + /// + /// Compose l'en-tête obligatoire (Exigences 7.6, 7.8, 7.10) : titre fixe, identité, véhicule, + /// période, adresse de départ, totaux mois/année, TOTAL IK et Montant_Regle. + /// + private static void ComposerEntete(IContainer container, ModeleRapportMensuel modele) + { + var periode = $"{NomMois(modele.Mois).ToUpper(FrCulture)} {modele.Annee}"; + var identite = FormatIdentite(modele.Profil); + var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule) + ? "—" + : modele.Profil.Matricule!; + var adresseDepart = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile) + ? "—" + : modele.Profil.AdresseDomicile!; + var vehiculeLibelle = + $"{modele.Vehicule.Plaque} — {modele.Vehicule.PuissanceFiscale} CV " + + $"(Coef. : {FormatDecimal(modele.Coefficient, 3)} / Base : {FormatDecimal(modele.Base, 0)})"; + + 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("Feuille d'indemnité kilométrique mensuelle").FontSize(11).SemiBold(); + + // Identité (nom, prénom, matricule) — Exigence 7.8. + col.Item().PaddingTop(4).Text(txt => + { + txt.Span("Utilisateur : ").SemiBold(); + txt.Span($"{identite} — Matricule : {matricule}"); + }); + + // Véhicule (plaque, puissance fiscale, coefficient/base de la tranche annuelle) — Exigences 7.8/7.10. + col.Item().Text(txt => + { + txt.Span("Véhicule : ").SemiBold(); + txt.Span(vehiculeLibelle); + }); + + // Période et adresse de départ du profil — Exigences 7.6/7.10. + col.Item().Text(txt => + { + txt.Span("Période : ").SemiBold(); + txt.Span(periode); + }); + col.Item().Text(txt => + { + txt.Span("Adresse de départ : ").SemiBold(); + txt.Span(adresseDepart); + }); + + // Totaux : mois, année cumulée, TOTAL IK du mois, Montant_Regle — Exigences 7.6/7.10. + col.Item().PaddingTop(6).Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().Text(txt => + { + txt.Span("Total parcouru sur le mois : ").SemiBold(); + txt.Span($"{FormatKm(modele.TotalKmMois)} km"); + }); + c.Item().Text(txt => + { + txt.Span("Total parcouru sur l'année : ").SemiBold(); + txt.Span($"{FormatKm(modele.TotalKmAnnuel)} km"); + }); + }); + row.RelativeItem().Column(c => + { + c.Item().Text(txt => + { + txt.Span("TOTAL IK du mois : ").SemiBold(); + txt.Span($"{FormatMontant(modele.TotalIkMois)} €"); + }); + c.Item().Text(txt => + { + txt.Span("Réglé : ").SemiBold(); + txt.Span($"{FormatMontant(modele.MontantRegle)} €"); + }); + }); + }); + + col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium); + }); + } + + /// + /// Compose la table des trajets du mois (Exigence 7.11) : date, motif (commentaire), + /// adresse de départ → arrivée, kilomètres. + /// + private static void ComposerTableTrajets(IContainer container, ModeleRapportMensuel modele) + { + container.Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(70); // Date + columns.RelativeColumn(2); // Motif (commentaire) + columns.RelativeColumn(3); // Adresse départ → arrivée + columns.ConstantColumn(55); // Kms + }); + + // En-tête de table. + table.Header(header => + { + 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).AlignRight().Text("Kms"); + }); + + if (modele.Trajets.Count == 0) + { + table.Cell().ColumnSpan(4).Element(Cellule).Text("Aucun trajet pour ce mois.").Italic(); + } + else + { + foreach (var trajet in modele.Trajets) + { + var motif = string.IsNullOrWhiteSpace(trajet.Commentaire) ? "—" : trajet.Commentaire!; + var itineraire = $"{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); + table.Cell().Element(Cellule).AlignRight().Text(FormatKm(km)); + } + + // Ligne de total du mois. + table.Cell().ColumnSpan(3).Element(CelluleTotal).AlignRight().Text("Total du mois :").SemiBold(); + table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(modele.TotalKmMois)}").SemiBold(); + } + }); + + static IContainer CelluleEntete(IContainer c) => + c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium) + .PaddingVertical(4).PaddingHorizontal(3); + + static IContainer Cellule(IContainer c) => + c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3); + + static IContainer CelluleTotal(IContainer c) => + c.BorderTop(1).BorderColor(Colors.Grey.Medium).PaddingVertical(4).PaddingHorizontal(3); + } + + /// + /// Rend le document de synthèse annuelle en octets PDF selon la maquette « PDF Annuel » du design. + /// + private static byte[] RendreAnnuel(ModeleRapportAnnuel modele, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var document = Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(30); + page.DefaultTextStyle(x => x.FontSize(9).FontColor(Colors.Black)); + + page.Header().Element(c => ComposerEnteteAnnuel(c, modele)); + page.Content().PaddingTop(10).Column(col => + { + col.Spacing(10); + col.Item().Element(c => ComposerTableBaremeAnnuel(c, modele)); + col.Item().Element(c => ComposerTableSyntheseAnnuelle(c, modele)); + col.Item().Element(c => ComposerTotauxAnnuels(c, modele)); + }); + page.Footer().AlignCenter().Text(txt => + { + txt.Span("Page "); + txt.CurrentPageNumber(); + txt.Span(" / "); + txt.TotalPages(); + }); + }); + }); + + cancellationToken.ThrowIfCancellationRequested(); + return document.GeneratePdf(); + } + + /// + /// Compose l'en-tête de la synthèse annuelle (Exigences 7.6, 7.7, 7.8, 7.12) : titre fixe, + /// identité, année, domicile, véhicule (plaque, puissance fiscale) et tranche annuelle applicable. + /// + private static void ComposerEnteteAnnuel(IContainer container, ModeleRapportAnnuel modele) + { + var reg = modele.Regularisation; + var identite = FormatIdentite(modele.Profil); + var matricule = string.IsNullOrWhiteSpace(modele.Profil.Matricule) + ? "—" + : modele.Profil.Matricule!; + var domicile = string.IsNullOrWhiteSpace(modele.Profil.AdresseDomicile) + ? "—" + : modele.Profil.AdresseDomicile!; + + 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 => + { + txt.Span("Utilisateur : ").SemiBold(); + txt.Span($"{identite} — Matricule : {matricule}"); + }); + + // Année (période couverte) — Exigence 7.6. + col.Item().Text(txt => + { + txt.Span("Année : ").SemiBold(); + txt.Span(modele.Annee.ToString(CultureInfo.InvariantCulture)); + }); + + // Adresse de domicile — Exigence 7.12. + col.Item().Text(txt => + { + txt.Span("Domicile : ").SemiBold(); + txt.Span(domicile); + }); + + // Véhicule (plaque, puissance fiscale) et tranche annuelle applicable — Exigences 7.7/7.8/7.12. + col.Item().Text(txt => + { + txt.Span("Véhicule : ").SemiBold(); + txt.Span($"{reg.Plaque} — {reg.PuissanceFiscale} CV"); + }); + col.Item().Text(txt => + { + txt.Span("Barème applicable : ").SemiBold(); + txt.Span(reg.TrancheLibelle); + }); + + // 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)} km"); + }); + + col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Medium); + }); + } + + /// + /// Compose la table du barème applicable de l'année pour la puissance fiscale du véhicule + /// (les trois tranches coefficient/base) — Exigence 7.12. + /// + private static void ComposerTableBaremeAnnuel(IContainer container, ModeleRapportAnnuel modele) + { + container.Column(col => + { + col.Spacing(3); + col.Item().Text(txt => + { + txt.Span("Barème applicable pour l'année ").SemiBold(); + txt.Span($"({modele.Regularisation.PuissanceFiscale} CV)").SemiBold(); + }); + + if (modele.TranchesBareme.Count == 0) + { + col.Item().Text("Barème non configuré pour cette année et cette puissance fiscale.").Italic(); + return; + } + + col.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(3); // Tranche (bornes km) + columns.RelativeColumn(2); // Coefficient + columns.RelativeColumn(2); // Base + }); + + table.Header(header => + { + header.Cell().Element(CelluleEnteteBareme).Text("Tranche (km)"); + header.Cell().Element(CelluleEnteteBareme).AlignRight().Text("Coefficient"); + header.Cell().Element(CelluleEnteteBareme).AlignRight().Text("Base (€)"); + }); + + foreach (var tranche in modele.TranchesBareme) + { + var bornes = tranche.KmMax.HasValue + ? $"{FormatDecimal(tranche.KmMin, 0)} à {FormatDecimal(tranche.KmMax.Value, 0)}" + : $"{FormatDecimal(tranche.KmMin, 0)} et plus"; + table.Cell().Element(CelluleBareme).Text(bornes); + table.Cell().Element(CelluleBareme).AlignRight().Text(FormatDecimal(tranche.Coefficient, 3)); + table.Cell().Element(CelluleBareme).AlignRight().Text(FormatDecimal(tranche.Base, 0)); + } + }); + }); + + static IContainer CelluleEnteteBareme(IContainer c) => + c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium) + .PaddingVertical(4).PaddingHorizontal(3); + + static IContainer CelluleBareme(IContainer c) => + c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3); + } + + /// + /// Compose la table des douze mois (kilomètres parcourus, frais payé, frais réel) — Exigence 7.12. + /// Les données proviennent de . + /// + private static void ComposerTableSyntheseAnnuelle(IContainer container, ModeleRapportAnnuel modele) + { + var reg = modele.Regularisation; + + container.Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(2); // Mois + columns.RelativeColumn(2); // Kms parcourus + columns.RelativeColumn(2); // Frais payé (réglé) + columns.RelativeColumn(2); // Frais réel + }); + + table.Header(header => + { + header.Cell().Element(CelluleEntete).Text("Mois"); + header.Cell().Element(CelluleEntete).AlignRight().Text("Kms parcourus"); + header.Cell().Element(CelluleEntete).AlignRight().Text("Frais payé"); + header.Cell().Element(CelluleEntete).AlignRight().Text("Frais réel"); + }); + + foreach (var ligne in reg.Mois) + { + table.Cell().Element(Cellule).Text(NomMois(ligne.Mois)); + table.Cell().Element(Cellule).AlignRight().Text($"{FormatKm(ligne.KmMois)}"); + table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisPaye)} €"); + table.Cell().Element(Cellule).AlignRight().Text($"{FormatMontant(ligne.FraisReelMensuel)} €"); + } + + // Ligne de total annuel. + table.Cell().Element(CelluleTotal).Text("TOTAL").SemiBold(); + table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatKm(reg.TotalKmAnnuel)}").SemiBold(); + table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.TotalPaye)} €").SemiBold(); + table.Cell().Element(CelluleTotal).AlignRight().Text($"{FormatMontant(reg.MontantReel)} €").SemiBold(); + }); + + static IContainer CelluleEntete(IContainer c) => + c.Background(Colors.Grey.Lighten2).BorderBottom(1).BorderColor(Colors.Grey.Medium) + .PaddingVertical(4).PaddingHorizontal(3); + + static IContainer Cellule(IContainer c) => + c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(3).PaddingHorizontal(3); + + static IContainer CelluleTotal(IContainer c) => + c.BorderTop(1).BorderColor(Colors.Grey.Medium).PaddingVertical(4).PaddingHorizontal(3); + } + + /// + /// Compose le bloc « formule réelle » et la régularisation (réel − payé) — Exigence 7.12. + /// + private static void ComposerTotauxAnnuels(IContainer container, ModeleRapportAnnuel modele) + { + var reg = modele.Regularisation; + + // Formule réelle : total_km × coefficient + base = Montant_Reel (tranche annuelle). + var formule = + $"{FormatKm(reg.TotalKmAnnuel)} km × {FormatDecimal(reg.CoefficientTranche, 3)} " + + $"+ {FormatDecimal(reg.BaseTranche, 0)} = {FormatMontant(reg.MontantReel)} € " + + $"(tranche {reg.TrancheLibelle}, {reg.PuissanceFiscale} CV)"; + + container.Column(col => + { + col.Spacing(3); + col.Item().Text(txt => + { + txt.Span("Formule réelle : ").SemiBold(); + txt.Span(formule); + }); + col.Item().Text(txt => + { + txt.Span("Régularisation (réel − payé) : ").SemiBold(); + txt.Span($"{FormatMontant(reg.Regularisation)} €"); + }); + }); + } + + private static string FormatIdentite(ProfilDto profil) + { + var prenom = (profil.Prenom ?? string.Empty).Trim(); + var nom = (profil.Nom ?? string.Empty).Trim(); + var identite = string.Join(" ", new[] { prenom, nom }.Where(s => s.Length > 0)); + return identite.Length > 0 ? identite : "—"; + } + + /// Kilomètres : séparateur de milliers, 2 décimales. + private static string FormatKm(decimal km) => + Math.Round(km, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture); + + /// Montant : séparateur de milliers, 2 décimales. + private static string FormatMontant(decimal montant) => + Math.Round(montant, 2, MidpointRounding.AwayFromZero).ToString("N2", FrCulture); + + private static string FormatDecimal(decimal valeur, int decimales) => + Math.Round(valeur, decimales, MidpointRounding.AwayFromZero) + .ToString("N" + decimales, FrCulture); + + private static string NomMois(int mois) + { + if (mois < 1 || mois > 12) + { + return mois.ToString(CultureInfo.InvariantCulture); + } + + var nom = FrCulture.DateTimeFormat.GetMonthName(mois); + return nom.Length > 0 ? char.ToUpper(nom[0], FrCulture) + nom[1..] : nom; + } + + /// Modèle de données agrégé pour le rendu du rapport mensuel. + private sealed record ModeleRapportMensuel( + int Annee, + int Mois, + ProfilDto Profil, + VehiculeDto Vehicule, + IReadOnlyList Trajets, + decimal TotalKmMois, + decimal TotalKmAnnuel, + decimal Coefficient, + decimal Base, + decimal TotalIkMois, + decimal MontantRegle); + + /// Modèle de données agrégé pour le rendu de la synthèse annuelle. + private sealed record ModeleRapportAnnuel( + int Annee, + ProfilDto Profil, + RegularisationAnnuelleDto Regularisation, + IReadOnlyList TranchesBareme); +} diff --git a/backend/TrajetKmTracker.Api/Services/ProfilService.cs b/backend/TrajetKmTracker.Api/Services/ProfilService.cs new file mode 100644 index 0000000..957aa15 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/ProfilService.cs @@ -0,0 +1,149 @@ +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Models.Entities; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +public class ProfilService : IProfilService +{ + // Le profil est un singleton : une seule ligne dont l'identifiant est toujours 1. + private const int ProfilId = 1; + + private readonly AppDbContext _context; + private readonly IBANClient _banClient; + private readonly IAdresseCacheService _adresseCacheService; + private readonly ILogger _logger; + + public ProfilService( + AppDbContext context, + IBANClient banClient, + IAdresseCacheService adresseCacheService, + ILogger logger) + { + _context = context; + _banClient = banClient; + _adresseCacheService = adresseCacheService; + _logger = logger; + } + + /// + public async Task GetProfilAsync(CancellationToken cancellationToken = default) + { + var profil = await GetOrCreateProfilAsync(cancellationToken); + return ToDto(profil); + } + + /// + public async Task SaveProfilAsync(SaveProfilDto dto, CancellationToken cancellationToken = default) + { + var profil = await GetOrCreateProfilAsync(cancellationToken); + + profil.Nom = dto.Nom; + profil.Prenom = dto.Prenom; + profil.Matricule = dto.Matricule; + profil.AdresseDomicile = dto.AdresseDomicile; + profil.UpdatedAt = DateTime.UtcNow; + + // Géocodage BAN de l'Adresse_Domicile. En cas de succès on stocke les coordonnées et + // DomicileGeocode = true ; en cas d'échec on persiste l'adresse textuelle sans coordonnées + // et DomicileGeocode = false (aucune exception propagée) — Requirements 10.7, 10.8. + await GeocoderDomicileAsync(profil, dto.AdresseDomicile, cancellationToken); + + await _context.SaveChangesAsync(cancellationToken); + + return ToDto(profil); + } + + /// + public async Task GetEtatAsync(CancellationToken cancellationToken = default) + { + // On ne crée pas le profil ici : l'état doit refléter la base telle quelle (Property 31). + var profil = await _context.Profil + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == ProfilId, cancellationToken); + + var nomPrenomRenseignes = profil is not null + && !string.IsNullOrWhiteSpace(profil.Nom) + && !string.IsNullOrWhiteSpace(profil.Prenom); + + var auMoinsUnVehicule = await _context.Vehicules.AnyAsync(cancellationToken); + + return new EtatProfilDto(nomPrenomRenseignes && auMoinsUnVehicule); + } + + /// + /// Tente de résoudre les coordonnées de l'Adresse_Domicile via la BAN. Met à jour le profil + /// (coordonnées + DomicileGeocode) et alimente le Cache_Adresses en cas de succès. Ne lève jamais : + /// tout échec BAN est traité comme un géocodage non résolu. + /// + private async Task GeocoderDomicileAsync(Profil profil, string? adresseDomicile, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(adresseDomicile)) + { + // Pas d'adresse à géocoder : on repart d'un état non géocodé. + profil.DomicileLatitude = null; + profil.DomicileLongitude = null; + profil.DomicileGeocode = false; + return; + } + + try + { + var resultats = await _banClient.RechercherAsync(adresseDomicile, cancellationToken); + var meilleur = resultats.Count > 0 ? resultats[0] : null; + + if (meilleur is null) + { + // BAN a répondu mais aucune adresse ne correspond : adresse conservée sans coordonnées. + profil.DomicileLatitude = null; + profil.DomicileLongitude = null; + profil.DomicileGeocode = false; + return; + } + + profil.DomicileLatitude = meilleur.Latitude; + profil.DomicileLongitude = meilleur.Longitude; + profil.DomicileGeocode = true; + + // Mise en cache de l'adresse géocodée pour réutilisation ultérieure (Requirement 9.1). + await _adresseCacheService.UpsertAdresseAsync( + adresseDomicile, + meilleur.Latitude, + meilleur.Longitude, + geometrieJson: null, + cancellationToken); + } + catch (BANIndisponibleException ex) + { + // BAN indisponible : l'adresse est conservée sans coordonnées, sans échec de la sauvegarde. + _logger.LogWarning(ex, + "Géocodage BAN de l'adresse de domicile indisponible ; l'adresse est conservée sans coordonnées."); + profil.DomicileLatitude = null; + profil.DomicileLongitude = null; + profil.DomicileGeocode = false; + } + } + + /// + /// Récupère le profil singleton (Id = 1) ou le crée à la volée s'il est absent. + /// + private async Task GetOrCreateProfilAsync(CancellationToken cancellationToken) + { + var profil = await _context.Profil.FirstOrDefaultAsync(p => p.Id == ProfilId, cancellationToken); + if (profil is not null) + { + return profil; + } + + profil = new Profil { Id = ProfilId }; + await _context.Profil.AddAsync(profil, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return profil; + } + + private static ProfilDto ToDto(Profil p) => + new(p.Nom, p.Prenom, p.Matricule, p.AdresseDomicile, p.DomicileGeocode); +} diff --git a/backend/TrajetKmTracker.Api/Services/RemboursementService.cs b/backend/TrajetKmTracker.Api/Services/RemboursementService.cs new file mode 100644 index 0000000..c1b2aa1 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/RemboursementService.cs @@ -0,0 +1,141 @@ +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +public class RemboursementService : IRemboursementService +{ + private readonly AppDbContext _context; + private readonly IBaremeService _baremeService; + private readonly IMontantRegleService _montantRegleService; + + public RemboursementService( + AppDbContext context, + IBaremeService baremeService, + IMontantRegleService montantRegleService) + { + _context = context; + _baremeService = baremeService; + _montantRegleService = montantRegleService; + } + + /// + public async Task GetRegularisationAnnuelleAsync( + int annee, int vehiculeId, CancellationToken cancellationToken = default) + { + var vehicule = await _context.Vehicules + .AsNoTracking() + .FirstOrDefaultAsync(v => v.Id == vehiculeId, cancellationToken) + ?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {vehiculeId} est introuvable."); + + // Kilomètres du véhicule par mois pour l'année (Distance nullable → 0 quand null). + // Le GROUP BY + SUM sur decimal n'est pas traduisible par SQLite : on ramène les + // trajets de l'année puis on agrège côté client. + var trajetsAnnee = await _context.Trajets + .AsNoTracking() + .Where(t => t.VehiculeId == vehiculeId && t.Date.Year == annee) + .Select(t => new { t.Date.Month, t.Distance }) + .ToListAsync(cancellationToken); + + var kmMoisMap = trajetsAnnee + .GroupBy(t => t.Month) + .ToDictionary(g => g.Key, g => g.Sum(t => t.Distance ?? 0m)); + + // Total kilométrique annuel COMPLET (détermine la tranche — Exigence 14.5, Property 27). + var totalKmAnnuel = kmMoisMap.Values.Sum(); + + // Barème plein (100%) appliqué au total annuel : Montant_Reel = total_km × coef + base. + var bareme = await _baremeService.CalculerMontantAsync( + annee, vehicule.PuissanceFiscale, totalKmAnnuel, cancellationToken); + + var coefficient = bareme.Coefficient; + var baseTranche = bareme.Base; + var montantReel = Math.Round(bareme.Montant, 2, MidpointRounding.AwayFromZero); + + // Libellé de la tranche annuelle applicable (ex. « 5 001 à 20 000 km »). + var trancheLibelle = await BuildTrancheLibelleAsync( + annee, vehicule.PuissanceFiscale, totalKmAnnuel, bareme.BaremeConfigure, cancellationToken); + + // Montant_Regle par mois pour ce véhicule/année. + var montantsRegles = await _montantRegleService.GetMontantsReglesAsync(annee, vehiculeId, cancellationToken); + var reglesMap = montantsRegles.ToDictionary(m => m.Mois, m => m.Montant); + + // 12 lignes mensuelles : + // FraisReelMensuel = km_du_mois × coefficient de la tranche annuelle (Exigence 14.6), + // FraisPaye = Montant_Regle saisi du mois (0 si absent). + var lignes = new List(12); + for (var mois = 1; mois <= 12; mois++) + { + var kmMois = kmMoisMap.TryGetValue(mois, out var km) ? km : 0m; + var fraisPaye = reglesMap.TryGetValue(mois, out var paye) ? paye : 0m; + var fraisReelMensuel = Math.Round(kmMois * coefficient, 2, MidpointRounding.AwayFromZero); + + lignes.Add(new LigneSyntheseMoisDto( + Mois: mois, + KmMois: Math.Round(kmMois, 2, MidpointRounding.AwayFromZero), + FraisPaye: fraisPaye, + FraisReelMensuel: fraisReelMensuel)); + } + + var totalPaye = Math.Round(montantsRegles.Sum(m => m.Montant), 2, MidpointRounding.AwayFromZero); + + // Regularisation = Montant_Reel − total des Montant_Regle (Exigence 14.7). + var regularisation = Math.Round(montantReel - totalPaye, 2, MidpointRounding.AwayFromZero); + + return new RegularisationAnnuelleDto( + Annee: annee, + VehiculeId: vehiculeId, + Plaque: vehicule.Plaque, + PuissanceFiscale: vehicule.PuissanceFiscale, + CoefficientTranche: coefficient, + BaseTranche: baseTranche, + TrancheLibelle: trancheLibelle, + Mois: lignes, + TotalKmAnnuel: Math.Round(totalKmAnnuel, 2, MidpointRounding.AwayFromZero), + TotalPaye: totalPaye, + MontantReel: montantReel, + Regularisation: regularisation); + } + + /// + /// Construit un libellé lisible de la tranche annuelle dont l'intervalle contient + /// . Retourne un libellé « non configuré » si aucune tranche + /// ne s'applique (barème absent ou total hors des intervalles définis). + /// + private async Task BuildTrancheLibelleAsync( + int annee, int puissanceFiscale, decimal totalKmAnnuel, bool baremeConfigure, CancellationToken cancellationToken) + { + if (!baremeConfigure) + { + return "Barème non configuré"; + } + + var tranches = await _context.TranchesBareme + .AsNoTracking() + .Where(t => t.Annee == annee && t.PuissanceFiscale == puissanceFiscale) + .ToListAsync(cancellationToken); + + var tranche = tranches + .FirstOrDefault(t => totalKmAnnuel >= t.KmMin && (t.KmMax == null || totalKmAnnuel < t.KmMax.Value)); + + if (tranche is null) + { + return "Barème non configuré"; + } + + return tranche.KmMax.HasValue + ? $"{FormatKm(tranche.KmMin)} à {FormatKm(tranche.KmMax.Value)} km" + : $"{FormatKm(tranche.KmMin)} km et plus"; + } + + private static string FormatKm(decimal km) + { + // Espace comme séparateur de milliers, sans décimales (bornes de tranche entières). + var fr = CultureInfo.GetCultureInfo("fr-FR"); + return Math.Round(km, 0, MidpointRounding.AwayFromZero).ToString("N0", fr); + } +} diff --git a/backend/TrajetKmTracker.Api/Services/ResumeService.cs b/backend/TrajetKmTracker.Api/Services/ResumeService.cs new file mode 100644 index 0000000..c9920e1 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/ResumeService.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Models.Entities; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +public class ResumeService : IResumeService +{ + private const int MoisMin = 1; + private const int MoisMax = 12; + private const int AnneeMin = 2000; + private const int AnneeMax = 2100; + + private readonly AppDbContext _context; + + public ResumeService(AppDbContext context) + { + _context = context; + } + + /// + public async Task GetResumeMensuelAsync( + int annee, int mois, int? vehiculeId = null, CancellationToken cancellationToken = default) + { + ValiderAnnee(annee); + ValiderMois(mois); + + var trajets = await ChargerTrajetsDuMoisAsync(annee, mois, vehiculeId, cancellationToken); + + return ConstruireResumeMensuel(annee, mois, trajets); + } + + /// + public async Task GetResumeAnnuelAsync( + int annee, int? vehiculeId = null, CancellationToken cancellationToken = default) + { + ValiderAnnee(annee); + + // Charge une seule fois tous les trajets de l'année, puis répartit par mois. + var query = _context.Trajets + .AsNoTracking() + .Where(t => t.Date.Year == annee); + + if (vehiculeId.HasValue) + { + query = query.Where(t => t.VehiculeId == vehiculeId.Value); + } + + var trajetsAnnee = await query.ToListAsync(cancellationToken); + + var resumesMensuels = new List(MoisMax); + for (var mois = MoisMin; mois <= MoisMax; mois++) + { + var trajetsDuMois = trajetsAnnee + .Where(t => t.Date.Month == mois) + .ToList(); + + resumesMensuels.Add(ConstruireResumeMensuel(annee, mois, trajetsDuMois)); + } + + // Total annuel = somme des 12 totaux mensuels (Exigences 4.3, 4.7, 6.6). + var totalAnnuel = Arrondir(resumesMensuels.Sum(r => r.TotalKm)); + + return new ResumeAnnuelDto(annee, resumesMensuels, totalAnnuel); + } + + /// + /// Charge les Trajets d'un mois donné, filtrés éventuellement par véhicule, triés par + /// date croissante puis par Position croissante (Exigence 3.2). + /// + private async Task> ChargerTrajetsDuMoisAsync( + int annee, int mois, int? vehiculeId, CancellationToken cancellationToken) + { + var query = _context.Trajets + .AsNoTracking() + .Where(t => t.Date.Year == annee && t.Date.Month == mois); + + if (vehiculeId.HasValue) + { + query = query.Where(t => t.VehiculeId == vehiculeId.Value); + } + + // Tri par Position (decimal) non traduisible par SQLite : ordonnancement côté client. + var trajets = await query.ToListAsync(cancellationToken); + return trajets + .OrderBy(t => t.Date) + .ThenBy(t => t.Position) + .ToList(); + } + + /// + /// Construit un à partir d'une liste de Trajets déjà triée. + /// Le total est la somme des distances (null → 0) arrondie à 2 décimales (Property 1). + /// + 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)); + + return new ResumeMensuelDto(annee, mois, trajetsDto, totalKm); + } + + private void ValiderMois(int mois) + { + if (mois < MoisMin || mois > MoisMax) + { + throw new ResumeParametreInvalideException( + $"Le mois doit être compris entre {MoisMin} et {MoisMax} inclus ; valeur reçue : {mois}."); + } + } + + private void ValiderAnnee(int annee) + { + if (annee < AnneeMin || annee > AnneeMax) + { + throw new ResumeParametreInvalideException( + $"L'année doit être comprise entre {AnneeMin} et {AnneeMax} inclus ; valeur reçue : {annee}."); + } + } + + private static decimal Arrondir(decimal valeur) => + Math.Round(valeur, 2, MidpointRounding.AwayFromZero); + + private static TrajetDto ToDto(Trajet t) => + new( + t.Id, + t.Date, + t.AdresseDepart, + t.AdresseArrivee, + t.Distance, + t.Commentaire, + t.Position, + t.VehiculeId, + t.PuissanceFiscale); +} diff --git a/backend/TrajetKmTracker.Api/Services/RouteResult.cs b/backend/TrajetKmTracker.Api/Services/RouteResult.cs new file mode 100644 index 0000000..681a207 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/RouteResult.cs @@ -0,0 +1,13 @@ +namespace TrajetKmTracker.Api.Services; + +/// +/// Résultat d'un calcul d'itinéraire routier via OpenRouteService (ORS). +/// +/// +/// Distance routière en kilomètres (ORS retourne des mètres, convertis ici en km). +/// +/// +/// Géométrie de l'itinéraire au format GeoJSON (chaîne JSON), ou null +/// si ORS ne l'a pas fournie. +/// +public record RouteResult(decimal DistanceKm, string? GeometrieJson); diff --git a/backend/TrajetKmTracker.Api/Services/TotauxService.cs b/backend/TrajetKmTracker.Api/Services/TotauxService.cs new file mode 100644 index 0000000..ec61bf3 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/TotauxService.cs @@ -0,0 +1,110 @@ +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +public class TotauxService : ITotauxService +{ + private readonly AppDbContext _context; + private readonly IVehiculeService _vehiculeService; + + public TotauxService(AppDbContext context, IVehiculeService vehiculeService) + { + _context = context; + _vehiculeService = vehiculeService; + } + + /// + public async Task GetTotauxAsync( + DateOnly date, int? vehiculeId = null, CancellationToken cancellationToken = default) + { + // Véhicule cible : celui fourni, sinon le Véhicule_Courant (Exigence 12.19). + var vehiculeCible = vehiculeId + ?? (await _vehiculeService.GetVehiculeCourantAsync(cancellationToken)).Id; + + // Trajets du véhicule pour l'année sélectionnée (Property 21 : filtrage par véhicule). + // Distance nullable → 0 lors des sommes (traitée côté client après projection). + 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 }) + .ToListAsync(cancellationToken); + + // TotalJour : somme des distances de la journée sélectionnée (Exigence 12.7). + var totalJour = trajetsAnnee + .Where(t => t.Date == date) + .Sum(t => t.Distance); + + // TotalMois : somme des distances 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); + + // 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); + + return new TotauxDto( + Date: date, + VehiculeId: vehiculeCible, + TotalJour: Math.Round(totalJour, 2, MidpointRounding.AwayFromZero), + TotalMois: Math.Round(totalMois, 2, MidpointRounding.AwayFromZero), + TotalAnnuelPartiel: Math.Round(totalAnnuelPartiel, 2, MidpointRounding.AwayFromZero)); + } + + /// + public async Task> GetAnneesDisponiblesAsync(CancellationToken cancellationToken = default) + { + // Années comportant au moins un Trajet (Exigence 12.15). + var annees = await _context.Trajets + .AsNoTracking() + .Select(t => t.Date.Year) + .Distinct() + .ToListAsync(cancellationToken); + + // Ajout de l'année courante si absente. + var anneeCourante = DateOnly.FromDateTime(DateTime.Today).Year; + if (!annees.Contains(anneeCourante)) + { + annees.Add(anneeCourante); + } + + annees.Sort(); + return annees; + } + + /// + public async Task> GetMoisDisponiblesAsync(int annee, CancellationToken cancellationToken = default) + { + // Mois de l'année comportant au moins un Trajet (Exigence 12.16). + var mois = await _context.Trajets + .AsNoTracking() + .Where(t => t.Date.Year == annee) + .Select(t => t.Date.Month) + .Distinct() + .ToListAsync(cancellationToken); + + mois.Sort(); + return mois; + } + + /// + public async Task> GetJoursDisponiblesAsync( + int annee, int mois, CancellationToken cancellationToken = default) + { + // Jours du mois comportant au moins un Trajet (Exigence 12.16). + var jours = await _context.Trajets + .AsNoTracking() + .Where(t => t.Date.Year == annee && t.Date.Month == mois) + .Select(t => t.Date.Day) + .Distinct() + .ToListAsync(cancellationToken); + + jours.Sort(); + return jours; + } +} diff --git a/backend/TrajetKmTracker.Api/Services/TrajetCreationResult.cs b/backend/TrajetKmTracker.Api/Services/TrajetCreationResult.cs new file mode 100644 index 0000000..e7f748a --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/TrajetCreationResult.cs @@ -0,0 +1,20 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Résultat de la création d'un Trajet par . +/// Porte le persisté et un éventuel avertissement lorsque la +/// distance n'a pas pu être calculée (aucune coordonnée résoluble). Dans ce cas, le +/// Trajet est tout de même persisté avec une Distance nulle et le contrôleur retourne +/// une réponse HTTP 201 accompagnée du message d'avertissement (Exigence 2.4). +/// +/// Le trajet créé, tel qu'il sera exposé au client. +/// +/// Vrai lorsque la distance n'a pas pu être calculée (Distance nulle persistée). +/// +/// Message d'avertissement associé, ou null. +public record TrajetCreationResult( + TrajetDto Trajet, + bool Avertissement, + string? Message); diff --git a/backend/TrajetKmTracker.Api/Services/TrajetService.cs b/backend/TrajetKmTracker.Api/Services/TrajetService.cs new file mode 100644 index 0000000..f054745 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/TrajetService.cs @@ -0,0 +1,446 @@ +using System.Globalization; +using System.Text; +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Models.Entities; +using TrajetKmTracker.Api.Repositories; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +public class TrajetService : ITrajetService +{ + private readonly AppDbContext _context; + private readonly ITrajetRepository _repository; + private readonly IVehiculeService _vehiculeService; + private readonly IDistanceCalculator _distanceCalculator; + private readonly IAdresseCacheService _adresseCacheService; + + public TrajetService( + AppDbContext context, + ITrajetRepository repository, + IVehiculeService vehiculeService, + IDistanceCalculator distanceCalculator, + IAdresseCacheService adresseCacheService) + { + _context = context; + _repository = repository; + _vehiculeService = vehiculeService; + _distanceCalculator = distanceCalculator; + _adresseCacheService = adresseCacheService; + } + + /// + public async Task CreateAsync( + CreateTrajetDto dto, CancellationToken cancellationToken = default) + { + // La forme de la date (JJ/MM/AAAA) et la validité des champs sont garanties par le + // validateur FluentValidation exécuté côté MVC avant d'atteindre le service. + var date = ParseDate(dto.Date); + + // --- Association du véhicule (Exigences 1.2, 10.14) --- + // VehiculeId explicite : on l'utilise et on copie sa PuissanceFiscale. + // Sinon : véhicule courant à la date du trajet (via le repository). + var trajet = new Trajet + { + Date = date, + AdresseDepart = dto.AdresseDepart, + AdresseArrivee = dto.AdresseArrivee, + Commentaire = dto.Commentaire, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + await AssocierVehiculeAsync(trajet, dto.VehiculeId, date, cancellationToken); + + // --- Calcul de la distance (BAN puis ORS, fallback Haversine) (Exigences 2.4, 2.5) --- + var distanceResult = await _distanceCalculator.CalculerAsync( + dto.AdresseDepart, + dto.AdresseArrivee, + dto.DepartLatitude, + dto.DepartLongitude, + dto.ArriveeLatitude, + dto.ArriveeLongitude, + cancellationToken); + + // Distance nulle possible (aucune coordonnée résoluble) : on persiste tout de même + // le Trajet avec Distance = null + avertissement (Exigence 2.4). + trajet.Distance = distanceResult.DistanceKm; + // Géométrie de l'itinéraire routier (paire départ→arrivée) pour le tracé carte. + trajet.GeometrieJson = distanceResult.GeometrieJson; + + // --- Persistance transactionnelle --- + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + // Position en fin de journée : max(Position) + 1.0 (ou 1.0 si journée vide). + trajet.Position = await _repository.CalculerPositionFinDeJourneeAsync(date, cancellationToken); + + await _repository.AddAsync(trajet, cancellationToken); + await _repository.SauvegarderAsync(cancellationToken); + + // Mise en cache des adresses résolues (coordonnées connues) — après persistance + // du trajet, dans la même transaction (Exigences 6.7, 6.8, 9.1). + await MettreEnCacheAdressesResoluesAsync( + dto.AdresseDepart, dto.AdresseArrivee, distanceResult, cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + return new TrajetCreationResult( + Trajet: ToDto(trajet), + Avertissement: distanceResult.Avertissement, + Message: distanceResult.Avertissement ? distanceResult.Message : null); + } + + /// + public async Task UpdateAsync( + int id, UpdateTrajetDto dto, CancellationToken cancellationToken = default) + { + // 404 si le trajet n'existe pas : l'opération est bloquée, aucun résumé n'est mis à jour + // (Exigence 5.6). + var trajet = await _repository.GetByIdAsync(id, cancellationToken) + ?? throw new TrajetNotFoundException(id); + + var nouvelleDate = ParseDate(dto.Date); + + // Détermine s'il faut recalculer la distance : + // - au moins une adresse a changé (comparaison sur le texte normalisé) (Exigence 5.2), OU + // - le trajet avait une Distance nulle et de nouvelles coordonnées sont fournies : on tente + // un recalcul (Exigence 5.10). + // Sinon (adresses inchangées) : on met à jour les autres champs SANS recalcul (Exigence 5.3/5.9). + var adresseModifiee = + !AdressesEquivalentes(trajet.AdresseDepart, dto.AdresseDepart) || + !AdressesEquivalentes(trajet.AdresseArrivee, dto.AdresseArrivee); + + var tenterRecalculDistanceNulle = + trajet.Distance is null && + (dto.DepartLatitude.HasValue || dto.ArriveeLatitude.HasValue || + !string.IsNullOrWhiteSpace(dto.AdresseDepart) || !string.IsNullOrWhiteSpace(dto.AdresseArrivee)); + + var doitRecalculer = adresseModifiee || tenterRecalculDistanceNulle; + + // Sauvegarde des valeurs existantes pour préservation en cas d'échec (Exigence 5.8). + var distanceExistante = trajet.Distance; + + DistanceCalculationResult? distanceResult = null; + bool avertissement = false; + string? messageAvertissement = null; + + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + if (doitRecalculer) + { + try + { + distanceResult = await _distanceCalculator.CalculerAsync( + dto.AdresseDepart, + dto.AdresseArrivee, + dto.DepartLatitude, + dto.DepartLongitude, + dto.ArriveeLatitude, + dto.ArriveeLongitude, + cancellationToken); + } + catch (Exception ex) + { + // Le recalcul a échoué : annuler la modification et préserver les données + // existantes du Trajet (Exigence 5.8). + await transaction.RollbackAsync(cancellationToken); + throw new DistanceRecalculException( + "Le recalcul de la distance a échoué ; la modification a été annulée et les données du trajet sont conservées.", + ex); + } + + if (adresseModifiee) + { + // Adresse(s) modifiée(s) : la nouvelle distance remplace l'ancienne (peut être null + // si aucune coordonnée n'est résoluble → avertissement, Exigence 2.4). + trajet.Distance = distanceResult.DistanceKm; + // La géométrie routière suit la nouvelle paire d'adresses (null si vol d'oiseau). + trajet.GeometrieJson = distanceResult.GeometrieJson; + } + else if (distanceResult.DistanceKm.HasValue) + { + // Distance était nulle et de nouvelles coordonnées ont permis un calcul (Exigence 5.10). + trajet.Distance = distanceResult.DistanceKm; + trajet.GeometrieJson = distanceResult.GeometrieJson; + } + + avertissement = distanceResult.Avertissement; + messageAvertissement = distanceResult.Avertissement ? distanceResult.Message : null; + } + + // Mise à jour des champs (commentaire, adresses, date, véhicule) — sans recalcul si + // les adresses n'ont pas changé (Exigence 5.3/5.9). + trajet.AdresseDepart = dto.AdresseDepart; + trajet.AdresseArrivee = dto.AdresseArrivee; + trajet.Commentaire = dto.Commentaire; + trajet.UpdatedAt = DateTime.UtcNow; + + if (trajet.Date != nouvelleDate) + { + trajet.Date = nouvelleDate; + } + + await AssocierVehiculeAsync(trajet, dto.VehiculeId, nouvelleDate, cancellationToken); + + _repository.Update(trajet); + await _repository.SauvegarderAsync(cancellationToken); + + // Mise en cache des adresses résolues (coordonnées connues) (Exigences 6.7, 6.8). + if (distanceResult is not null) + { + await MettreEnCacheAdressesResoluesAsync( + dto.AdresseDepart, dto.AdresseArrivee, distanceResult, cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + } + catch (DistanceRecalculException) + { + // Déjà rollbacké ci-dessus ; préserver les données existantes en mémoire. + trajet.Distance = distanceExistante; + throw; + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + return new TrajetUpdateResult( + Trajet: ToDto(trajet), + Avertissement: avertissement, + Message: messageAvertissement); + } + + /// + public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + var trajet = await _repository.GetByIdAsync(id, cancellationToken) + ?? throw new TrajetNotFoundException(id); + + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + _repository.Delete(trajet); + await _repository.SauvegarderAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + /// + public async Task ReordonnerAsync( + ReordonnerTrajetsDto dto, CancellationToken cancellationToken = default) + { + // Le repository met à jour uniquement la Position des trajets fournis, en transaction, + // sans altérer les autres champs (Exigence 11.9). Les identifiants inconnus sont ignorés. + var positions = (dto.Trajets ?? Array.Empty()) + .Select(t => (t.Id, t.Position)) + .ToList(); + + if (positions.Count == 0) + { + return; + } + + await _repository.ReordonnerAsync(positions, cancellationToken); + } + + /// + public async Task RecalculerToutesLesDistancesAsync(CancellationToken cancellationToken = default) + { + // On ne (re)sollicite ORS que là où c'est utile : uniquement les trajets SANS géométrie + // routière (GeometrieJson NULL). Un trajet ayant déjà une géométrie signifie que le calcul + // routier a réussi précédemment ; on ne le recalcule pas, ce qui limite les appels ORS. + var trajets = await _context.Trajets + .Where(t => t.GeometrieJson == null) + .ToListAsync(cancellationToken); + + var recalcules = 0; + foreach (var trajet in trajets) + { + DistanceCalculationResult result; + try + { + // Recalcul systématique via BAN + ORS (fallback Haversine géré en interne). + // On ne fournit pas de coordonnées : le calculateur les résout depuis les adresses. + result = await _distanceCalculator.CalculerAsync( + trajet.AdresseDepart, + trajet.AdresseArrivee, + departLatitude: null, + departLongitude: null, + arriveeLatitude: null, + arriveeLongitude: null, + cancellationToken); + } + catch + { + // Un échec sur un trajet ne doit pas interrompre le lot : on le laisse tel quel. + continue; + } + + // On ne remplace la distance que si un calcul a abouti (sinon on conserve l'existant). + if (result.DistanceKm.HasValue) + { + trajet.Distance = result.DistanceKm; + // Géométrie routière recalculée (null si ORS a échoué → tracé ligne droite). + trajet.GeometrieJson = result.GeometrieJson; + trajet.UpdatedAt = DateTime.UtcNow; + _repository.Update(trajet); + await _repository.SauvegarderAsync(cancellationToken); + + // Met à jour le cache d'adresses (coordonnées + géométrie d'itinéraire). + await MettreEnCacheAdressesResoluesAsync( + trajet.AdresseDepart, trajet.AdresseArrivee, result, cancellationToken); + + recalcules++; + } + } + + return recalcules; + } + + /// + /// Compare deux textes d'adresse après normalisation (trim + minuscules + suppression des accents), + /// de façon cohérente avec le Cache_Adresses. Deux adresses équivalentes ne déclenchent pas de recalcul. + /// + private static bool AdressesEquivalentes(string? a, string? b) => + string.Equals(Normaliser(a), Normaliser(b), StringComparison.Ordinal); + + /// + /// Normalise un texte d'adresse : suppression des espaces de bord, passage en minuscules et + /// suppression des accents (diacritiques). Cohérent avec . + /// + private static string Normaliser(string? valeur) + { + if (string.IsNullOrWhiteSpace(valeur)) + { + return string.Empty; + } + + var trimmee = valeur.Trim().ToLowerInvariant(); + + var decomposee = trimmee.Normalize(NormalizationForm.FormD); + var builder = new StringBuilder(decomposee.Length); + foreach (var caractere in decomposee) + { + var categorie = CharUnicodeInfo.GetUnicodeCategory(caractere); + if (categorie != UnicodeCategory.NonSpacingMark) + { + builder.Append(caractere); + } + } + + return builder.ToString().Normalize(NormalizationForm.FormC); + } + + /// + /// Associe le véhicule au trajet : si est fourni, on l'utilise + /// et on copie sa puissance fiscale ; sinon on applique le véhicule courant à la date du trajet + /// (dénormalisation de la PuissanceFiscale, Exigence 10.14). + /// + private async Task AssocierVehiculeAsync( + Trajet trajet, int? vehiculeId, DateOnly date, CancellationToken cancellationToken) + { + if (vehiculeId.HasValue) + { + var historique = await _vehiculeService.GetHistoriqueAsync(cancellationToken); + var vehicule = historique.FirstOrDefault(v => v.Id == vehiculeId.Value) + ?? throw new VehiculeNotFoundException( + $"Le véhicule d'identifiant {vehiculeId.Value} est introuvable."); + + trajet.VehiculeId = vehicule.Id; + trajet.PuissanceFiscale = vehicule.PuissanceFiscale; + return; + } + + // Véhicule courant à la date du trajet : le repository résout et copie la puissance fiscale. + await _repository.AppliquerVehiculeADateAsync(trajet, cancellationToken); + + // Aucun véhicule applicable à cette date : VehiculeId reste 0 (non initialisé). + if (trajet.VehiculeId == 0) + { + throw new VehiculeNotFoundException( + $"Aucun véhicule n'est applicable à la date {date:dd/MM/yyyy} ; renseignez un véhicule avant de saisir ce trajet."); + } + } + + /// + /// Met en cache les adresses de départ et d'arrivée dont les coordonnées ont été résolues + /// (via BAN/ORS). Les adresses non résolues ne sont pas mises en cache. + /// La géométrie GeoJSON de l'itinéraire, lorsqu'elle existe, est associée à l'adresse d'arrivée. + /// + private async Task MettreEnCacheAdressesResoluesAsync( + string adresseDepart, + string adresseArrivee, + DistanceCalculationResult result, + CancellationToken cancellationToken) + { + if (result.DepartLatitude.HasValue && result.DepartLongitude.HasValue) + { + await _adresseCacheService.UpsertAdresseAsync( + adresseDepart, + result.DepartLatitude.Value, + result.DepartLongitude.Value, + geometrieJson: null, + cancellationToken); + } + + if (result.ArriveeLatitude.HasValue && result.ArriveeLongitude.HasValue) + { + await _adresseCacheService.UpsertAdresseAsync( + adresseArrivee, + result.ArriveeLatitude.Value, + result.ArriveeLongitude.Value, + result.GeometrieJson, + cancellationToken); + } + } + + /// + /// Parse une date au format JJ/MM/AAAA. La validité est déjà garantie par le validateur MVC ; + /// ce parse défensif lève si la contrainte n'a pas été appliquée. + /// + private static DateOnly ParseDate(string date) + { + if (!DateOnly.TryParseExact( + date, + "dd/MM/yyyy", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var parsed)) + { + throw new FormatException( + $"La date « {date} » n'est pas au format attendu JJ/MM/AAAA."); + } + + return parsed; + } + + private static TrajetDto ToDto(Trajet t) => + new( + t.Id, + t.Date, + t.AdresseDepart, + t.AdresseArrivee, + t.Distance, + t.Commentaire, + t.Position, + t.VehiculeId, + t.PuissanceFiscale); +} diff --git a/backend/TrajetKmTracker.Api/Services/TrajetUpdateResult.cs b/backend/TrajetKmTracker.Api/Services/TrajetUpdateResult.cs new file mode 100644 index 0000000..3baf0b2 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/TrajetUpdateResult.cs @@ -0,0 +1,22 @@ +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Services; + +/// +/// Résultat de la modification d'un Trajet par . +/// Porte le mis à jour et un éventuel avertissement lorsque la +/// distance n'a pas pu être (re)calculée faute de coordonnées résolubles. Dans ce cas, le +/// Trajet est tout de même mis à jour avec une Distance nulle et un message d'avertissement +/// (Exigence 2.4). En revanche, un échec du calcul lui-même (indisponibilité ORS/BAN sur des +/// coordonnées connues) provoque un rollback et la levée d'une +/// (Exigence 5.8). +/// +/// Le trajet mis à jour, tel qu'il sera exposé au client. +/// +/// Vrai lorsque la distance n'a pas pu être calculée (Distance nulle persistée). +/// +/// Message d'avertissement associé, ou null. +public record TrajetUpdateResult( + TrajetDto Trajet, + bool Avertissement, + string? Message); diff --git a/backend/TrajetKmTracker.Api/Services/VehiculeService.cs b/backend/TrajetKmTracker.Api/Services/VehiculeService.cs new file mode 100644 index 0000000..924783a --- /dev/null +++ b/backend/TrajetKmTracker.Api/Services/VehiculeService.cs @@ -0,0 +1,143 @@ +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using TrajetKmTracker.Api.Data; +using TrajetKmTracker.Api.Models.DTOs; +using TrajetKmTracker.Api.Models.Entities; +using TrajetKmTracker.Api.Repositories; +using TrajetKmTracker.Api.Services.Exceptions; + +namespace TrajetKmTracker.Api.Services; + +/// +public class VehiculeService : IVehiculeService +{ + private const string DateFormat = "dd/MM/yyyy"; + + private readonly IVehiculeRepository _repository; + private readonly AppDbContext _context; + + public VehiculeService(IVehiculeRepository repository, AppDbContext context) + { + _repository = repository; + _context = context; + } + + /// + public async Task> GetHistoriqueAsync(CancellationToken cancellationToken = default) + { + var vehicules = await _repository.GetHistoriqueAsync(cancellationToken); + return vehicules.Select(ToDto).ToList(); + } + + /// + public async Task GetVehiculeCourantAsync(CancellationToken cancellationToken = default) + { + var aujourdHui = DateOnly.FromDateTime(DateTime.Today); + var vehicule = await _repository.GetVehiculeCourantAsync(aujourdHui, cancellationToken) + ?? throw new VehiculeNotFoundException( + "Aucun véhicule courant : aucun véhicule n'a de date d'effet inférieure ou égale à aujourd'hui."); + + return ToDto(vehicule); + } + + /// + public async Task GetVehiculeADateAsync(DateOnly date, CancellationToken cancellationToken = default) + { + var vehicule = await _repository.GetVehiculeCourantAsync(date, cancellationToken); + return vehicule is null ? null : ToDto(vehicule); + } + + /// + public async Task CreateAsync(CreateVehiculeDto dto, CancellationToken cancellationToken = default) + { + var dateEffet = ParseDateEffet(dto.DateEffet); + + // Rejet d'une date d'effet dans le futur (Exigence 10.11). + if (dateEffet > DateOnly.FromDateTime(DateTime.Today)) + { + throw new DateEffetFutureException("La date d'effet ne peut pas être postérieure à aujourd'hui."); + } + + var vehicule = new Vehicule + { + Denomination = dto.Denomination, + Plaque = dto.Plaque, + PuissanceFiscale = dto.PuissanceFiscale, + DateEffet = dateEffet, + }; + + // Conservation de l'historique : on ajoute sans supprimer/modifier les véhicules existants (Property 22). + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + await _repository.AddAsync(vehicule, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + // Détection des trajets à réaffecter : date >= DateEffet et puissance fiscale différente. + var trajetsConcernes = await _repository.GetTrajetsAReaffecterAsync( + dateEffet, vehicule.PuissanceFiscale, cancellationToken); + + return new VehiculeCreeDto( + ToDto(vehicule), + ReaffectationProposee: trajetsConcernes.Count > 0, + NombreTrajetsConcernes: trajetsConcernes.Count); + } + + /// + public async Task ReaffecterAsync(ReaffectationDto dto, CancellationToken cancellationToken = default) + { + var vehicule = await _repository.GetByIdAsync(dto.VehiculeId, cancellationToken) + ?? throw new VehiculeNotFoundException($"Le véhicule d'identifiant {dto.VehiculeId} est introuvable."); + + // Réaffectation refusée : aucun changement (Exigence 10.17). + if (!dto.Accepter) + { + return; + } + + // Réaffectation acceptée : mise à jour bornée à la date d'effet (Exigence 10.16 / Property 20). + var trajets = await _repository.GetTrajetsAReaffecterAsync( + vehicule.DateEffet, vehicule.PuissanceFiscale, cancellationToken); + + if (trajets.Count == 0) + { + return; + } + + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + var maintenant = DateTime.UtcNow; + foreach (var trajet in trajets) + { + trajet.VehiculeId = vehicule.Id; + trajet.PuissanceFiscale = vehicule.PuissanceFiscale; + trajet.UpdatedAt = maintenant; + } + + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + private static DateOnly ParseDateEffet(string value) + { + // La validation FluentValidation garantit normalement le format ; on reste défensif ici. + return DateOnly.ParseExact(value, DateFormat, CultureInfo.InvariantCulture); + } + + private static VehiculeDto ToDto(Vehicule v) => + new(v.Id, v.Denomination, v.Plaque, v.PuissanceFiscale, v.DateEffet); +} diff --git a/backend/TrajetKmTracker.Api/TrajetKmTracker.Api.csproj b/backend/TrajetKmTracker.Api/TrajetKmTracker.Api.csproj new file mode 100644 index 0000000..f84859a --- /dev/null +++ b/backend/TrajetKmTracker.Api/TrajetKmTracker.Api.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + 3f3b2959-ca5d-4cde-8a1c-62d2aca4ebb2 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/backend/TrajetKmTracker.Api/TrajetKmTracker.Api.http b/backend/TrajetKmTracker.Api/TrajetKmTracker.Api.http new file mode 100644 index 0000000..e7aeed2 --- /dev/null +++ b/backend/TrajetKmTracker.Api/TrajetKmTracker.Api.http @@ -0,0 +1,6 @@ +@TrajetKmTracker.Api_HostAddress = http://localhost:5237 + +GET {{TrajetKmTracker.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/backend/TrajetKmTracker.Api/Validators/CreateTrajetDtoValidator.cs b/backend/TrajetKmTracker.Api/Validators/CreateTrajetDtoValidator.cs new file mode 100644 index 0000000..26540fb --- /dev/null +++ b/backend/TrajetKmTracker.Api/Validators/CreateTrajetDtoValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Validators; + +// Validation de la création d'un trajet (Requirements 1.4, 1.5, 1.6, 1.7, 1.12, 6.3). +public class CreateTrajetDtoValidator : AbstractValidator +{ + public CreateTrajetDtoValidator() + { + RuleFor(x => x.Date) + .NotEmpty().WithMessage("La date est obligatoire.") + .Matches(DateValidation.FormatJjMmAAAA).WithMessage("Format de date invalide (JJ/MM/AAAA).") + .Must(DateValidation.BeAValidDate).WithMessage("La date n'est pas une date existante."); + + RuleFor(x => x.AdresseDepart) + .NotEmpty().WithMessage("L'adresse de départ est obligatoire.") + .MaximumLength(255).WithMessage("L'adresse de départ ne peut dépasser 255 caractères."); + + RuleFor(x => x.AdresseArrivee) + .NotEmpty().WithMessage("L'adresse d'arrivée est obligatoire.") + .MaximumLength(255).WithMessage("L'adresse d'arrivée ne peut dépasser 255 caractères."); + + RuleFor(x => x.Commentaire) + .MaximumLength(500).WithMessage("Le commentaire ne peut dépasser 500 caractères."); + } +} diff --git a/backend/TrajetKmTracker.Api/Validators/CreateVehiculeDtoValidator.cs b/backend/TrajetKmTracker.Api/Validators/CreateVehiculeDtoValidator.cs new file mode 100644 index 0000000..230d9d2 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Validators/CreateVehiculeDtoValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Validators; + +// Validation de la création d'un véhicule (Requirements 10.11, 6.3). +public class CreateVehiculeDtoValidator : AbstractValidator +{ + public CreateVehiculeDtoValidator() + { + RuleFor(x => x.Denomination) + .NotEmpty().WithMessage("La dénomination est obligatoire.") + .MaximumLength(100).WithMessage("La dénomination ne peut dépasser 100 caractères."); + + RuleFor(x => x.Plaque) + .NotEmpty().WithMessage("La plaque est obligatoire.") + .MaximumLength(15).WithMessage("La plaque ne peut dépasser 15 caractères."); + + RuleFor(x => x.PuissanceFiscale) + .GreaterThan(0).WithMessage("La puissance fiscale doit être strictement positive."); + + RuleFor(x => x.DateEffet) + .NotEmpty().WithMessage("La date d'effet est obligatoire.") + .Matches(DateValidation.FormatJjMmAAAA).WithMessage("Format de date invalide (JJ/MM/AAAA).") + .Must(DateValidation.BeAValidDateNotInFuture) + .WithMessage("La date d'effet doit être une date existante et ne peut être postérieure à aujourd'hui."); + } +} diff --git a/backend/TrajetKmTracker.Api/Validators/DateValidation.cs b/backend/TrajetKmTracker.Api/Validators/DateValidation.cs new file mode 100644 index 0000000..f655e95 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Validators/DateValidation.cs @@ -0,0 +1,41 @@ +using System.Globalization; + +namespace TrajetKmTracker.Api.Validators; + +// Fonctions de validation de dates partagées par les validateurs FluentValidation. +internal static class DateValidation +{ + public const string FormatJjMmAAAA = @"^\d{2}/\d{2}/\d{4}$"; + + // Vérifie que la chaîne au format JJ/MM/AAAA correspond à une date réellement existante. + public static bool BeAValidDate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return DateOnly.TryParseExact( + value, + "dd/MM/yyyy", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _); + } + + // Vérifie que la date (JJ/MM/AAAA) est existante et n'est pas dans le futur. + public static bool BeAValidDateNotInFuture(string? value) + { + if (!DateOnly.TryParseExact( + value, + "dd/MM/yyyy", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var date)) + { + return false; + } + + return date <= DateOnly.FromDateTime(DateTime.Today); + } +} diff --git a/backend/TrajetKmTracker.Api/Validators/MontantRegleDtoValidator.cs b/backend/TrajetKmTracker.Api/Validators/MontantRegleDtoValidator.cs new file mode 100644 index 0000000..f0db900 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Validators/MontantRegleDtoValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Validators; + +// Validation d'un montant réglé (Requirements 14.4). +public class MontantRegleDtoValidator : AbstractValidator +{ + public MontantRegleDtoValidator() + { + RuleFor(x => x.Montant) + .GreaterThanOrEqualTo(0).WithMessage("Le montant ne peut être négatif."); + } +} diff --git a/backend/TrajetKmTracker.Api/Validators/SaveAdresseNommeeDtoValidator.cs b/backend/TrajetKmTracker.Api/Validators/SaveAdresseNommeeDtoValidator.cs new file mode 100644 index 0000000..f2a101d --- /dev/null +++ b/backend/TrajetKmTracker.Api/Validators/SaveAdresseNommeeDtoValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Validators; + +// Validation de la création / modification d'une Adresse_Nommee (Requirements 15.6, 15.8). +public class SaveAdresseNommeeDtoValidator : AbstractValidator +{ + public SaveAdresseNommeeDtoValidator() + { + RuleFor(x => x.Denomination) + .NotEmpty().WithMessage("La dénomination est obligatoire.") + .MaximumLength(100).WithMessage("La dénomination ne peut dépasser 100 caractères."); + + RuleFor(x => x.Adresse) + .NotEmpty().WithMessage("L'adresse est obligatoire.") + .MaximumLength(255).WithMessage("L'adresse ne peut dépasser 255 caractères."); + } +} diff --git a/backend/TrajetKmTracker.Api/Validators/SaveProfilDtoValidator.cs b/backend/TrajetKmTracker.Api/Validators/SaveProfilDtoValidator.cs new file mode 100644 index 0000000..9174215 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Validators/SaveProfilDtoValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Validators; + +// Validation de l'enregistrement du profil (Requirements 10.2, 10.11). +public class SaveProfilDtoValidator : AbstractValidator +{ + public SaveProfilDtoValidator() + { + // Champs facultatifs : les contraintes ne s'appliquent que si une valeur est fournie. + RuleFor(x => x.Matricule) + .MaximumLength(8).WithMessage("Le matricule ne peut dépasser 8 caractères."); + + RuleFor(x => x.Nom) + .MaximumLength(100).WithMessage("Le nom ne peut dépasser 100 caractères."); + + RuleFor(x => x.Prenom) + .MaximumLength(100).WithMessage("Le prénom ne peut dépasser 100 caractères."); + } +} diff --git a/backend/TrajetKmTracker.Api/Validators/UpdateTrajetDtoValidator.cs b/backend/TrajetKmTracker.Api/Validators/UpdateTrajetDtoValidator.cs new file mode 100644 index 0000000..29bde13 --- /dev/null +++ b/backend/TrajetKmTracker.Api/Validators/UpdateTrajetDtoValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using TrajetKmTracker.Api.Models.DTOs; + +namespace TrajetKmTracker.Api.Validators; + +// Validation de la mise à jour d'un trajet (Requirements 1.4, 1.5, 1.6, 1.7, 1.12, 6.3). +public class UpdateTrajetDtoValidator : AbstractValidator +{ + public UpdateTrajetDtoValidator() + { + RuleFor(x => x.Date) + .NotEmpty().WithMessage("La date est obligatoire.") + .Matches(DateValidation.FormatJjMmAAAA).WithMessage("Format de date invalide (JJ/MM/AAAA).") + .Must(DateValidation.BeAValidDate).WithMessage("La date n'est pas une date existante."); + + RuleFor(x => x.AdresseDepart) + .NotEmpty().WithMessage("L'adresse de départ est obligatoire.") + .MaximumLength(255).WithMessage("L'adresse de départ ne peut dépasser 255 caractères."); + + RuleFor(x => x.AdresseArrivee) + .NotEmpty().WithMessage("L'adresse d'arrivée est obligatoire.") + .MaximumLength(255).WithMessage("L'adresse d'arrivée ne peut dépasser 255 caractères."); + + RuleFor(x => x.Commentaire) + .MaximumLength(500).WithMessage("Le commentaire ne peut dépasser 500 caractères."); + } +} diff --git a/backend/TrajetKmTracker.Api/appsettings.Development.json b/backend/TrajetKmTracker.Api/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/backend/TrajetKmTracker.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/backend/TrajetKmTracker.Api/appsettings.json b/backend/TrajetKmTracker.Api/appsettings.json new file mode 100644 index 0000000..8370371 --- /dev/null +++ b/backend/TrajetKmTracker.Api/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Default": "Data Source=trajets.db" + }, + "Cors": { + "AllowedOrigins": [ "http://localhost:4200" ] + }, + "BAN": { + "BaseUrl": "https://api-adresse.data.gouv.fr", + "TimeoutSeconds": 5 + }, + "ORS": { + "BaseUrl": "https://api.openrouteservice.org", + "ApiKey": "", + "TimeoutSeconds": 10 + } +} diff --git a/backend/TrajetKmTracker.Tests/SocleApiTests.cs b/backend/TrajetKmTracker.Tests/SocleApiTests.cs new file mode 100644 index 0000000..955b294 --- /dev/null +++ b/backend/TrajetKmTracker.Tests/SocleApiTests.cs @@ -0,0 +1,31 @@ +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace TrajetKmTracker.Tests; + +/// +/// Test de fumée du socle : vérifie que l'hôte API démarre et sert Swagger +/// en environnement Development (valide services de base, Swagger, pipeline). +/// +public class SocleApiTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public SocleApiTests(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(builder => + builder.UseEnvironment("Development")); + } + + [Fact] + public async Task Le_document_Swagger_est_disponible_en_developpement() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/swagger/v1/swagger.json"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/backend/TrajetKmTracker.Tests/TrajetKmTracker.Tests.csproj b/backend/TrajetKmTracker.Tests/TrajetKmTracker.Tests.csproj new file mode 100644 index 0000000..26c71cc --- /dev/null +++ b/backend/TrajetKmTracker.Tests/TrajetKmTracker.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/TrajetKmTracker.sln b/backend/TrajetKmTracker.sln new file mode 100644 index 0000000..dea4088 --- /dev/null +++ b/backend/TrajetKmTracker.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrajetKmTracker.Api", "TrajetKmTracker.Api\TrajetKmTracker.Api.csproj", "{A7699322-243B-4F74-9386-EE1C7E3DE320}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrajetKmTracker.Tests", "TrajetKmTracker.Tests\TrajetKmTracker.Tests.csproj", "{73123654-F8E7-4337-9620-5EAE79FD89E8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A7699322-243B-4F74-9386-EE1C7E3DE320}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7699322-243B-4F74-9386-EE1C7E3DE320}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7699322-243B-4F74-9386-EE1C7E3DE320}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7699322-243B-4F74-9386-EE1C7E3DE320}.Release|Any CPU.Build.0 = Release|Any CPU + {73123654-F8E7-4337-9620-5EAE79FD89E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73123654-F8E7-4337-9620-5EAE79FD89E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73123654-F8E7-4337-9620-5EAE79FD89E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73123654-F8E7-4337-9620-5EAE79FD89E8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b17d087 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + - ASPNETCORE_URLS=http://+:8080 + - ConnectionStrings__Default=Data Source=/data/trajets.db + # La clé ORS est fournie par l'environnement / .env — jamais intégrée à l'image. + - ORS__ApiKey=${ORS_API_KEY:-} + volumes: + - trajets-data:/data + restart: unless-stopped + # Healthcheck sur GET /health. curl est installé dans l'image backend (voir Dockerfile). + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "8080:80" # hôte 8080 -> nginx (frontend) + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +volumes: + trajets-data: # persistance SQLite au-delà du cycle de vie des conteneurs diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..de3c536 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,21 @@ +# Dépendances et artefacts de build (réinstallés/reconstruits dans l'image) +node_modules +dist +.angular + +# Outils / IDE +.vscode +.idea +*.log + +# Contrôle de version +.git +.gitignore + +# Fichiers Docker (inutiles au contexte de build) +Dockerfile +.dockerignore + +# Divers +.DS_Store +Thumbs.db diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..59d9a3a --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..cc7b141 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,42 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..77b3745 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 + "recommendations": ["angular.ng-template"] +} diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json new file mode 100644 index 0000000..925af83 --- /dev/null +++ b/frontend/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "ng serve", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: start", + "url": "http://localhost:4200/" + }, + { + "name": "ng test", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: test", + "url": "http://localhost:9876/debug.html" + } + ] +} diff --git a/frontend/.vscode/tasks.json b/frontend/.vscode/tasks.json new file mode 100644 index 0000000..a298b5b --- /dev/null +++ b/frontend/.vscode/tasks.json @@ -0,0 +1,42 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } + }, + { + "type": "npm", + "script": "test", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } + } + ] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..75e01d4 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,24 @@ +# ---- Étape build (Node) ---- +FROM node:22-alpine AS build +WORKDIR /app + +# Installer les dépendances (couche cache indépendante du code source) +COPY package*.json ./ +RUN npm ci + +# Copier le code source et compiler en configuration production +COPY . . +RUN npm run build -- --configuration production + +# ---- Étape serve (nginx) ---- +FROM nginx:1.27-alpine AS serve + +# Configuration nginx (SPA Angular + reverse-proxy /api) +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Artefacts Angular compilés. +# Le builder @angular-devkit/build-angular:application (Angular 22) émet les +# fichiers statiques dans dist//browser (et non directement dans dist/). +COPY --from=build /app/dist/trajet-km-tracker/browser /usr/share/nginx/html + +EXPOSE 80 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..56791f4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,27 @@ +# TrajetKmTracker + +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.3.17. + +## Development server + +Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. + +## Code scaffolding + +Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. + +## Build + +Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. + +## Running unit tests + +Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). + +## Running end-to-end tests + +Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 0000000..ee0bd4c --- /dev/null +++ b/frontend/angular.json @@ -0,0 +1,111 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm", + "analytics": false + }, + "newProjectRoot": "projects", + "projects": { + "trajet-km-tracker": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/trajet-km-tracker", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "node_modules/leaflet/dist/leaflet.css", + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "trajet-km-tracker:build:production" + }, + "development": { + "buildTarget": "trajet-km-tracker:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "trajet-km-tracker:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "node_modules/leaflet/dist/leaflet.css", + "src/styles.scss" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..b64b4ea --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA Angular : toute route inconnue retombe sur index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Reverse-proxy des appels API vers le service backend (réseau interne Compose) + location /api/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..26c4a5d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,15189 @@ +{ + "name": "trajet-km-tracker", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trajet-km-tracker", + "version": "0.0.0", + "dependencies": { + "@angular/animations": "^22.1.6", + "@angular/cdk": "^22.1.6", + "@angular/common": "^22.1.6", + "@angular/compiler": "^22.1.6", + "@angular/core": "^22.1.6", + "@angular/forms": "^22.1.6", + "@angular/platform-browser": "^22.1.6", + "@angular/platform-browser-dynamic": "^22.1.6", + "@angular/router": "^22.1.6", + "@types/leaflet": "^1.9.22", + "leaflet": "^1.9.4", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^22.1.8", + "@angular/cli": "^22.1.8", + "@angular/compiler-cli": "^22.1.6", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.1.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~6.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.2201.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2201.8.tgz", + "integrity": "sha512-EUQo8RDS1my2Bo5FRS+gBYgz1/klfIp9XESfMpTBO04nBkjF6DkPCeOxeAf1CYjWy9nCXcWn968eSdEhV5jXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.8", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/build-angular": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-22.1.8.tgz", + "integrity": "sha512-UH75aJJ5lBpnu3QtC+WygE0Z5dESDLibNQ0jbcot4L7jnT/fLPWbffWp2WHvIjaUybVj/sh441N9rCiy0YMN3Q==", + "deprecated": "Angular's Webpack support is deprecated. Use the esbuild and Vite-based \"@angular/build\" package instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2201.8", + "@angular-devkit/build-webpack": "0.2201.8", + "@angular-devkit/core": "22.1.8", + "@angular/build": "22.1.8", + "@babel/core": "8.0.1", + "@babel/generator": "8.0.0", + "@babel/helper-annotate-as-pure": "8.0.0", + "@babel/helper-split-export-declaration": "7.24.7", + "@babel/plugin-transform-async-generator-functions": "8.0.1", + "@babel/plugin-transform-async-to-generator": "8.0.1", + "@babel/plugin-transform-runtime": "8.0.1", + "@babel/preset-env": "8.0.2", + "@babel/runtime": "8.0.0", + "@discoveryjs/json-ext": "1.1.0", + "@ngtools/webpack": "22.1.8", + "ansi-colors": "4.1.3", + "autoprefixer": "10.5.4", + "babel-loader": "10.1.1", + "browserslist": "^4.26.0", + "copy-webpack-plugin": "14.0.0", + "css-loader": "7.1.4", + "esbuild-wasm": "0.28.2", + "http-proxy-middleware": "4.2.0", + "istanbul-lib-instrument": "6.0.3", + "jsonc-parser": "3.3.1", + "karma-source-map-support": "1.4.0", + "less": "4.9.0", + "less-loader": "13.0.0", + "license-webpack-plugin": "4.0.2", + "loader-utils": "3.3.1", + "mini-css-extract-plugin": "2.10.2", + "open": "11.0.0", + "ora": "9.4.1", + "picomatch": "4.0.5", + "piscina": "5.2.0", + "postcss": "8.5.25", + "postcss-loader": "8.2.1", + "resolve-url-loader": "5.0.0", + "rxjs": "7.8.2", + "sass": "1.101.0", + "sass-loader": "17.0.0", + "semver": "7.8.5", + "source-map-loader": "5.0.0", + "source-map-support": "0.5.21", + "terser": "5.49.0", + "tinyglobby": "0.2.17", + "tslib": "2.8.1", + "webpack": "5.109.2", + "webpack-dev-middleware": "8.0.3", + "webpack-dev-server": "5.2.6", + "webpack-merge": "6.0.1", + "webpack-subresource-integrity": "5.1.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "esbuild": "0.28.2" + }, + "peerDependencies": { + "@angular/compiler-cli": "^22.0.0", + "@angular/core": "^22.0.0", + "@angular/localize": "^22.0.0", + "@angular/platform-browser": "^22.0.0", + "@angular/platform-server": "^22.0.0", + "@angular/service-worker": "^22.0.0", + "@angular/ssr": "^22.1.8", + "browser-sync": "^3.0.2", + "karma": "^6.3.0", + "ng-packagr": "^22.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "typescript": ">=6.0 <6.1" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "browser-sync": { + "optional": true + }, + "karma": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-webpack": { + "version": "0.2201.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.2201.8.tgz", + "integrity": "sha512-T9Fl7Qcy40UFZaiGAY9ieJC3cgTTupS/eAncKTeuSdqCHpZn+IMhU+Nyl7C2yE4VZe3KCk8CGGKS3g4TmyH/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2201.8", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "webpack": "^5.30.0", + "webpack-dev-server": "^5.0.2" + } + }, + "node_modules/@angular-devkit/core": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.1.8.tgz", + "integrity": "sha512-34lsgg2FwMVBX7nR/ZqzRUcdvdYPvSB8XAEr2GudXAyZwLI9ehzfQlagAWR/gEb8p4uCFZW0r7uU0toxydq4Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.5", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.1.8.tgz", + "integrity": "sha512-Pv3cPa/44kvEwYcqwx4Ns5dhIDmcFNSHhbpheqiEG1Z/u4e2t4zHKDtE3eHZB+8o+IcC7xJj+d+AqGR44RoZDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.8", + "jsonc-parser": "3.3.1", + "magic-string": "1.0.0", + "ora": "9.4.1", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/animations": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-22.1.6.tgz", + "integrity": "sha512-o3tad5WSlcq80aFvZGQ1VrNtY2fieJWbYn1h2AxJXE1Mj0h3atvw4zgQ+qx9ZRCYsZvsluW88YxA1Zk0bCuZvQ==", + "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/core": "22.1.6" + } + }, + "node_modules/@angular/build": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-22.1.8.tgz", + "integrity": "sha512-tw+Evk0EITb8p8dTu743ONoFztCEiyDkdRbtZsh7C0vBS7Q9ElsOcFeg2Z9OAAwrmtZMIBuW3jrdPRQp7OCSdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2201.8", + "@babel/core": "8.0.1", + "@babel/helper-annotate-as-pure": "8.0.0", + "@babel/helper-split-export-declaration": "7.24.7", + "@inquirer/confirm": "6.1.1", + "@vitejs/plugin-basic-ssl": "2.3.0", + "beasties": "0.4.3", + "browserslist": "^4.26.0", + "esbuild": "0.28.2", + "https-proxy-agent": "9.1.0", + "jsonc-parser": "3.3.1", + "listr2": "11.0.0", + "magic-string": "1.0.0", + "mrmime": "2.0.1", + "oxc-parser": "0.142.0", + "parse5-html-rewriting-stream": "8.0.1", + "picomatch": "4.0.5", + "piscina": "5.2.0", + "rolldown": "1.2.0", + "sass": "1.101.0", + "semver": "7.8.5", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.17", + "vite": "8.1.5", + "watchpack": "2.5.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.5.6" + }, + "peerDependencies": { + "@angular/compiler": "^22.0.0", + "@angular/compiler-cli": "^22.0.0", + "@angular/core": "^22.0.0", + "@angular/localize": "^22.0.0", + "@angular/platform-browser": "^22.0.0", + "@angular/platform-server": "^22.0.0", + "@angular/service-worker": "^22.0.0", + "@angular/ssr": "^22.1.8", + "istanbul-lib-instrument": "^6.0.0", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^22.0.0", + "postcss": "^8.4.0", + "rollup": "^4.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=6.0 <6.1", + "vitest": "^4.0.8" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "istanbul-lib-instrument": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "rollup": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@angular/cdk": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-22.1.6.tgz", + "integrity": "sha512-4eHSs/sIIp1mesNKFDkDTYDhfB/3/Bpfjzv/PbybsKVd642FTa3IAbjWf3uqHS/V7mpucF3Q5gSpirZHDgxwrg==", + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^22.0.0 || ^23.0.0", + "@angular/core": "^22.0.0 || ^23.0.0", + "@angular/forms": "^22.0.0 || ^23.0.0", + "@angular/platform-browser": "^22.0.0 || ^23.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/cli": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-22.1.8.tgz", + "integrity": "sha512-MFN/FydqDndai8vF12hDxG5RlBt1HUUheeJgJNmuHYIne4bOAfOT+xuMpIK8+gLSRwCemdqR3OMM38pnsFJ/nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2201.8", + "@angular-devkit/core": "22.1.8", + "@angular-devkit/schematics": "22.1.8", + "@inquirer/prompts": "8.5.2", + "@listr2/prompt-adapter-inquirer": "4.2.5", + "@modelcontextprotocol/sdk": "1.30.0", + "@schematics/angular": "22.1.8", + "jsonc-parser": "3.3.1", + "listr2": "11.0.0", + "npm-package-arg": "14.0.0", + "parse5-html-rewriting-stream": "8.0.1", + "semver": "7.8.5", + "yargs": "18.1.0", + "zod": "4.4.3" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/common": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.6.tgz", + "integrity": "sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/core": "22.1.6", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.6.tgz", + "integrity": "sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@angular/compiler-cli": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.6.tgz", + "integrity": "sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "8.0.1", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^5.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^18.0.0" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.1.6", + "typescript": ">=6.0 <6.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular/core": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz", + "integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.1.6", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "node_modules/@angular/forms": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.6.tgz", + "integrity": "sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0", + "zod": "^4.0.10" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.1.6", + "@angular/core": "22.1.6", + "@angular/platform-browser": "22.1.6", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.6.tgz", + "integrity": "sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/animations": "22.1.6", + "@angular/common": "22.1.6", + "@angular/core": "22.1.6" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/platform-browser-dynamic": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-22.1.6.tgz", + "integrity": "sha512-ixLGQoRkF1ctIN01opG8XaT84TR2/RngMvA0DKsbCjoz0+ECCq3RtsrXUB5Qdny7AXzPvsJchxQ+ZubtOX9MYQ==", + "deprecated": "@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.1.6", + "@angular/compiler": "22.1.6", + "@angular/core": "22.1.6", + "@angular/platform-browser": "22.1.6" + } + }, + "node_modules/@angular/router": { + "version": "22.1.6", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.6.tgz", + "integrity": "sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.1.6", + "@angular/core": "22.1.6", + "@angular/platform-browser": "22.1.6", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.5.tgz", + "integrity": "sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.1.tgz", + "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helpers": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^2.1.1", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz", + "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.5.tgz", + "integrity": "sha512-Qk8ahMGooH5mz6uuhoDvfZGkUf/Mf3RTBucVVl4MKx4LKMTv872TeW8O92h15iVtlN8wAROBIpI1aV6x1z0LCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^8.0.5", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "lru-cache": "^11.0.0", + "verkit": "^0.3.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-8.0.5.tgz", + "integrity": "sha512-ckUE7tmolBbW4QV02lD874sWSPy8da2A09i/h0qS+IWolR+WiNhpq7C/rHZm00uOIkUb0Cp6+0Cy1ku/wj5uQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-member-expression-to-functions": "^8.0.5", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/helper-replace-supers": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/traverse": "^8.0.5", + "verkit": "^0.3.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-8.0.5.tgz", + "integrity": "sha512-TWnIgw21pAv/FbBKB8ks26DMxQ+A3aBdi1HUBp+QhqSE8QdOqJgSK/FEvdMYPTZcHBBJPZ5pN6DDKmC5ZYPJbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "regexpu-core": "^6.3.1", + "verkit": "^0.3.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-1.0.0.tgz", + "integrity": "sha512-9jzVaTeZyXRDKTgUnNzcPQMO8y0ga3o+Z4fKjNet9Fcx7slgKa83qRbz0EwROSd6qO6CoEe/HQszqSPKb5lhkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.0", + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-8.0.5.tgz", + "integrity": "sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.5", + "@babel/types": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-8.0.0.tgz", + "integrity": "sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-8.0.5.tgz", + "integrity": "sha512-lUsSqMD0l5cJKl+3vlTV/hPDA/IvNkcHWVI3zFud2Vur+C9bCKdpqbpkN9eABembedbdWwF0qow6nbdIpNlpcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4", + "@babel/traverse": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-8.0.0.tgz", + "integrity": "sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-8.0.1.tgz", + "integrity": "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-8.0.1.tgz", + "integrity": "sha512-baAKuLEMmu6BCSY3tuiU7qglM1qOZt6F1SrFScA241oNqksxkxfEZEKztlGRmoVns9AQ5UgArH7RsUEjxWnzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-wrap-function": "^8.0.0", + "@babel/traverse": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-8.0.1.tgz", + "integrity": "sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^8.0.0", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/traverse": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-8.0.0.tgz", + "integrity": "sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-8.0.0.tgz", + "integrity": "sha512-Qpm8+wi5xfDkBfollanwriCcKniFfBmMmaKB01GVM6VGzKXo1fdxosZp04qEr5HM+LKhwr3hG1yRy8+ORsficA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.5.tgz", + "integrity": "sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.5.tgz", + "integrity": "sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-8.0.1.tgz", + "integrity": "sha512-Ytgjjne4RnG3Oig7ik+NfY4ebRY30BPptVkkyu1f72eINJXRM3/bkU++tIc5aPvyLmo4KH20avq0xJ2o+9aEnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-8.0.1.tgz", + "integrity": "sha512-X7pAMBhuKluA7UfwZNvKN0XVVu/AGeo84Z75eJl85rcb8J2aBzLK92btahM1X5h0oi0QIrbe0qIMA/0+4Buk7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-8.0.1.tgz", + "integrity": "sha512-DJviKTxYfH0hFwnMiW4dnPyMGzS3Hrr4zUfXl1zwQ0QiGlGlNYklLoPSYEQr8S7nau0/K7NdQjTh0qbYuyFjCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-8.0.5.tgz", + "integrity": "sha512-e5eeoBASO+893sCNdFE/vQ9STIxkSRtIcQwBmLdfDOWn1nnXDY+/ngqFC5zTSUTRl0yWR3inkPRlOQD4MMHeFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-8.0.1.tgz", + "integrity": "sha512-x8bi0LFVD2xkULjfNn+hCMg16yAFHAM9fS/ThSFeYBi+0MP9K6qcY2BZb4urUwC7PYtEy5wPe6TKjOEjXrCGFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/plugin-transform-optional-chaining": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-8.0.1.tgz", + "integrity": "sha512-P8+RN2n7ts2s1vnE+lXdHYf+dhnmcGSen/kWzBsVluT9Sey5AqmcRXYWlHqgQxaNlKTD5YMa1tf5z4d1v8W88w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-8.0.1.tgz", + "integrity": "sha512-o/gr7kRlq3PKLLuYth4udOsrC7geBerti+QtwPeyxMOsEQO1d8kDHqk9r2PtMx2y9i8FG7tzyTerfv1yMLSMsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-8.0.1.tgz", + "integrity": "sha512-kqnSMF1YHBzuiQrl68675i5Ma1oljvo+SJsNEZFZVBu5BUrVIZm9KId3ui2PdtLK2sv2zM8sJnjPDfgLxQlEqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-remap-async-to-generator": "^8.0.1", + "@babel/traverse": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-8.0.1.tgz", + "integrity": "sha512-e1jmmEU4p2Lx64sA1+EF8e8/RxPuegzbXcEbmFp5alDyLE+f2ViUpZ77bRWMXzihTwgVVmn/TOpqDbAuS5g1Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-remap-async-to-generator": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-8.0.1.tgz", + "integrity": "sha512-0V97/gcf7LIgPieEiK1YT0eXa18XJFSLOTZjzEZhA9SJIqZhD/IwGUrCitBzXSmnGCP7hchwC6svHtJ/Eidcpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-8.0.5.tgz", + "integrity": "sha512-czkN3g76Ns2wMe1C0IuceKUCqCe/yJ1by0qz4h4znq74K/Ks7K2Fx6Dplq9ecS65Z3rJHxT9a/kDSF2LbEov8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-8.0.1.tgz", + "integrity": "sha512-tORnYiVhIHnKj90TgbSZXrO24f9oEpA6MgFxpIDSKKlHv7AzBIRhkMlYevanueLNYaQXqZWarfCgXM4bWTfNiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-8.0.5.tgz", + "integrity": "sha512-hyqluKOSy6PVOoHDEb4xsjFbtOSy+FFNzHNCrAHS5T+LxS8BtZHBCIVYFAcWzhBf/wRYsYu2uEpUvRmHYeOf8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^8.0.5", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-8.0.5.tgz", + "integrity": "sha512-GKFgovQQzZfEDFxclYFDOiw/Pszxa1epcs7RasL7yyeqi6rxL61Vbqt0uSDPc2M7XXCmNZnQZaK19333v6rabA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-compilation-targets": "^8.0.5", + "@babel/helper-globals": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-replace-supers": "^8.0.1", + "@babel/traverse": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-8.0.1.tgz", + "integrity": "sha512-i4l3OGLO8DUDcwdnyraOvILbhqdUf4QgfzhVxSOSzRy49XKXrY7pwaSg9gDSKmhZfNPrEMciBSJSciQh/CjB1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-8.0.5.tgz", + "integrity": "sha512-evjxc5fGvpXG2WMGSalpX8IJZVumLIDgk5r5eRhvXRDqFX/GPWq1BeRfJalPCcwJfrR5A/wvegUhx9W5Nr7/mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-8.0.1.tgz", + "integrity": "sha512-czOUoSaZljJ92yu+bYlXqb/UBN8K9daNCob/B6/7nthSvfGP6YhCnfqD64XWfyb2dN4ypxALNplApoJrsMd4fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-8.0.1.tgz", + "integrity": "sha512-kNnVLkxFUEcTtCyB5PFVQ5Xoy88Bk1lU/ZgDu97CW8eNhRH2Wsiy8Sq5l5dFnwtIUYjzsXHU77jUy1W5AtGSIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-8.0.1.tgz", + "integrity": "sha512-Tv43P47o6fuHgBL7HLHQg3WKXohW9CEUGjLtnCDW27yJLK0zKUdTTqREbZbycNHA83hewMjde5tF6ekrHu9bAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-8.0.1.tgz", + "integrity": "sha512-AS9GlgKc43tJNRu7yOvLaTko4qmdOb+8M69uNS8i421WLO20eVez7LdG5khKdi8E0LIQpYzzzdGIrdXWnO753g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-8.0.5.tgz", + "integrity": "sha512-Vr20jk/ZxRGu30o0jYvcKl2Pf0tiyr8H9q2ZGxYD6yjOpv9DdP6//I85q0cSOdADZhsu/ZZoApjGVCfl4xnVJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-transform-destructuring": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-8.0.1.tgz", + "integrity": "sha512-DsZvUUklUmDQ7d2vp+VjqgUWD51mGxhZZ1FPdPP9Hcj0vsgGUKX+zEBGp/vzB1O5PZUxWT/Euq5fu39M9dm9wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-8.0.1.tgz", + "integrity": "sha512-bFzznm46bvWGaTYKle3iolbBJ+oPBfUjwCPesxlFE3SQ7DaY9EHf/8Y5ZzrodKJi8JDdcAyaVWaDUSVyhULh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-8.0.1.tgz", + "integrity": "sha512-rpeXtgELjpIBQH/+YmyFlD9timPEVCyqY+TNednzoeoTYvXSBEeUvYnYE+BK8rB8m6hHiNK7aL9QWKhGifEJCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-8.0.1.tgz", + "integrity": "sha512-H1L/JfPf3CqmubuaiZaquXKQ8MRs4YWSsgRllkTviM8TafcCNnlvc4/fJZ3rXP8HmFM+/Bg+TlsPehUI9BtDFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-8.0.1.tgz", + "integrity": "sha512-Mowp8X0J6p7ZehLU82B5e65te2uuSeDHyxrEROwEAS2VKXNXssfw5ZMqhY7k9iXTsOv1Xs/49G3lDCj9Vvw8qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-8.0.1.tgz", + "integrity": "sha512-ai7kfPRcfyUV1EszXoF1PvL3IuJoCuH08WSEPoRcJTWfZZ55VL/rcfvbVY16QLA3jjbzzSneQSoCtD3L6OyUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-8.0.1.tgz", + "integrity": "sha512-Emvtr5zkEGyCNAmt+qKD5EUh8G0RbxV9EZWrDdX0LuVy5tBq1B3fOIslvVF9aCJmpnwS/AvAT53b9LxAZyXlng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-8.0.1.tgz", + "integrity": "sha512-3Axi9abnyGsm/hh6DsKPZ1Cr9fTtKqS7w0Ig5g12mU269YclpH8pV3xMln2vPLexXgUp6S6L+I06d9/YOLfRKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-8.0.1.tgz", + "integrity": "sha512-FDdhET8y1YFDNRuoynqSf23WTzbBBpbIB2oRrlFX7YYm9uWtFvJDSD1r/epBSjfPkOjeaaLgRW9xNnt3JGx46A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-8.0.1.tgz", + "integrity": "sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-8.0.1.tgz", + "integrity": "sha512-0NEHanXmnFEnfT2dLKTXnu7m8GXFsnxRgteBC2aH21hYMBwAgxu5dcTdi/Eg+ToI1HbZe0CHwz4XRLgRNQhYoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-identifier": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-8.0.1.tgz", + "integrity": "sha512-XKTa2J2MdkmbVEeChq9f7Or0VYcsF0NyVBgytRyeN9F+J+ETAB2SHhfkG4toz/ssuU0i+h/QgJ6ddo5YakSQcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-8.0.1.tgz", + "integrity": "sha512-zCHu+Jr2gTdJE48lN9SV/kXueCW2M79mKtKJc/ttfzzr/jvgdQdCd17RADMqFRQc/25MLxdtjTmlD0HSAMOlIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-8.0.1.tgz", + "integrity": "sha512-QSQxVg1x4PuOuhWUs4Y9u+x9Y+ER8z6G3tC+bDLBzvoOrNLJrEBQLRnwrTP8e5klihAw6Z+e9X5RjdAKcAGapA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-8.0.1.tgz", + "integrity": "sha512-AgCJAmQLF7+PtsK79wJqr4xJ2StHCXlz7JL5CVFP4HejJx25Tk6yl1ZrXvi0cKh3VGDVnfVxefxnrpsBirgpyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-8.0.1.tgz", + "integrity": "sha512-it2DmUyLIA1GQUXlFDEnI+/G89mTgxndnAiZYpW8xYR6LboblfirMqiWJeTna5uypQJg7viTT4D1iEURRtFcfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-8.0.1.tgz", + "integrity": "sha512-VmxkDu6bBdbxRzqn6E93hYucug4OVa6svSO19W//vVzNUGAmQzk3QRyHyyEtfcjSLR3NWfRsWwVM9zExLmd+2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-transform-destructuring": "^8.0.1", + "@babel/plugin-transform-parameters": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-8.0.1.tgz", + "integrity": "sha512-fDkPXRTRKGm25bAq01q82UM4ypPqdVXCwphUUm4t1dL01fGIG0v8KRvT+4BjhMAtRxtPuI34t5Vs7yjRgs3ZgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-replace-supers": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-8.0.1.tgz", + "integrity": "sha512-b2OQ74uGliyATcasTjxGy2O/86UI/n+EN4juB4EMfEwTi9j9uq70PuP0L8fW77vfRY66gO/YoTo/WbIdQ/Si1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-8.0.5.tgz", + "integrity": "sha512-w8ouIDKdmUc2oyvBx4S/WtiQM7bMww5LGjB2A4L1+sDWZGw37XxzqOqfgHnPm+2ldINmAqrf2ekzpeJ/sS9poQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-8.0.5.tgz", + "integrity": "sha512-oXsstDkzuE+gOqBz2+e1VFqDew0KlGgd9GT4MGptmcIq3uDbxcowHRSaqMCvp3nbiZws4wTm8Phuk9ZJO3NtTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-8.0.1.tgz", + "integrity": "sha512-TrFCGcXaVDh6S5IRhmLSRTY9H80VTCMQWnZtzBRg4RWg3KCLmdmsmj4M15kZAPZfoPkWL/SJb4em3Py/vOiX8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-8.0.5.tgz", + "integrity": "sha512-OilYoElZMdD/My3fJTh0jLOPy8Y9Jfpcb9nPXG0Bpy4oRbZeeVX39P1cXKsPwnXTBZg7qEKk8TQ0lzpJvdK3yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-create-class-features-plugin": "^8.0.5", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-8.0.1.tgz", + "integrity": "sha512-Z/qx4cxUtYR1nt7XWRutObPxDks98fEYsjWbVeKEqZH6y3AGknmgzCqmHf2FHWZCl1DfoPeuJY+3hZ+35D+2tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-8.0.5.tgz", + "integrity": "sha512-eSjLoICd0l8SkUAyV7hnVrsDycTVK7DRKXi3Pr1oqAVe37vdd4sesp8t6aZ/Wa23rV2ZQbnYJWDn7pOIf9Z/Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-8.0.1.tgz", + "integrity": "sha512-02ITRDBesPdTYU0oShAzERwEPzozOUQSXlz3qrt8JGuhalBJQv9z5NjgHJPC9sS3Fsam8gDtfAEpBnqZwUIdjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-8.0.1.tgz", + "integrity": "sha512-+aykZi7ZP3U84veqfJXm3HhPZGddWFi64g7jr0ni6tb1zel+1ey+SL+IRKPoZXFyFqvYEsoqrmx4PyEJRlHl/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-8.0.1.tgz", + "integrity": "sha512-MPDpKBrxn+thQay3eJmUiSeHswiT7MkINb48hHkX6OzodB149PKq1kred+lpMebrDzHA+G1ekCQnlYSkyEqAOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-8.0.1.tgz", + "integrity": "sha512-JddANd9yPVH8dYgVoNkqAH5BftnsDxFpG51Zas7sc6F3poz5QWcejHNGO8a/57IX5ByjGSzEmYk9Z7ZMa5MWaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-8.0.5.tgz", + "integrity": "sha512-thqaGFTViCmJxUPJ3IivfaGHz8eodOL7UbsZ2gMOybSi8uQFXFEoabnnILMl4ZCok0vVrKD4HKq9MJWSj5qqxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-8.0.1.tgz", + "integrity": "sha512-IsVP6WrZZQdaG2zLmeKwWiI+ua2NB5L1+f77C2/8z2NCDz7uxlIA/lnwocYOJk9PXcOC2sZgRls3LN4XpNduzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-8.0.1.tgz", + "integrity": "sha512-JXvtj5+BJA9Qv3prDzW2z2DkGTJNmG0BObTdUD03STiu1Jr4fNQkQy3hYZgPL46a2RjcuhwBMYf49BOuJ98gnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-8.0.1.tgz", + "integrity": "sha512-+wJoxgxP2gtey0UMUOMhzMMji2XHO/Uu6MXUh/r5Yhc2jngKzK/wFxY2WNe4UCaRcMvCb4gcnB8wIgFXJsocXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-8.0.1.tgz", + "integrity": "sha512-TAXJepIJ6vZphytTwcf+LuXi2M2ZWI43VCqNw+1ZZLPP/38Z1A8j4Mahvg8kqDgMOSM/cakk+hedTJCiw3jQuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-8.0.1.tgz", + "integrity": "sha512-zjBN9tSMSuomNDfurL69Gf7+v4D2t5uI1mSZaYJDo88SKpbduhCXqtxH7Tx66iCF6caWYwnBzSM0tnCozmQq5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-8.0.1.tgz", + "integrity": "sha512-v0oO83cvT5lwbcIVRShpx4vaHD8AvM9IBowsQuTeP+kGmhh3recJQs33Bl6dlo3/2g9amlznLbFGn4VJbPCJqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-8.0.1.tgz", + "integrity": "sha512-MlQeyS0K7gh0XNeLBMS/3Z07HjDOKhA7xm2L18GyxOXyiFHI9E+ZuQ4mFYmcLjluXsE/Wf6dABIqZvKpKw0Z3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-8.0.2.tgz", + "integrity": "sha512-CUGLn9hNBCF/eXnwdFAWERbniCcXCRvnKwLV9fegeUEIqv7YlU2MepsWMMM54GcILx5XYMnRh+JAL+K5G+mK6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^8.0.0", + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-option": "^8.0.0", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^8.0.1", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^8.0.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^8.0.1", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^8.0.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^8.0.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^8.0.1", + "@babel/plugin-transform-arrow-functions": "^8.0.1", + "@babel/plugin-transform-async-generator-functions": "^8.0.1", + "@babel/plugin-transform-async-to-generator": "^8.0.1", + "@babel/plugin-transform-block-scoped-functions": "^8.0.1", + "@babel/plugin-transform-block-scoping": "^8.0.1", + "@babel/plugin-transform-class-properties": "^8.0.1", + "@babel/plugin-transform-class-static-block": "^8.0.1", + "@babel/plugin-transform-classes": "^8.0.1", + "@babel/plugin-transform-computed-properties": "^8.0.1", + "@babel/plugin-transform-destructuring": "^8.0.1", + "@babel/plugin-transform-dotall-regex": "^8.0.1", + "@babel/plugin-transform-duplicate-keys": "^8.0.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^8.0.1", + "@babel/plugin-transform-dynamic-import": "^8.0.1", + "@babel/plugin-transform-explicit-resource-management": "^8.0.1", + "@babel/plugin-transform-exponentiation-operator": "^8.0.1", + "@babel/plugin-transform-export-namespace-from": "^8.0.1", + "@babel/plugin-transform-for-of": "^8.0.1", + "@babel/plugin-transform-function-name": "^8.0.1", + "@babel/plugin-transform-json-strings": "^8.0.1", + "@babel/plugin-transform-literals": "^8.0.1", + "@babel/plugin-transform-logical-assignment-operators": "^8.0.1", + "@babel/plugin-transform-member-expression-literals": "^8.0.1", + "@babel/plugin-transform-modules-amd": "^8.0.1", + "@babel/plugin-transform-modules-commonjs": "^8.0.1", + "@babel/plugin-transform-modules-systemjs": "^8.0.1", + "@babel/plugin-transform-modules-umd": "^8.0.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^8.0.1", + "@babel/plugin-transform-new-target": "^8.0.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^8.0.1", + "@babel/plugin-transform-numeric-separator": "^8.0.1", + "@babel/plugin-transform-object-rest-spread": "^8.0.1", + "@babel/plugin-transform-object-super": "^8.0.1", + "@babel/plugin-transform-optional-catch-binding": "^8.0.1", + "@babel/plugin-transform-optional-chaining": "^8.0.1", + "@babel/plugin-transform-parameters": "^8.0.1", + "@babel/plugin-transform-private-methods": "^8.0.1", + "@babel/plugin-transform-private-property-in-object": "^8.0.1", + "@babel/plugin-transform-property-literals": "^8.0.1", + "@babel/plugin-transform-regenerator": "^8.0.2", + "@babel/plugin-transform-regexp-modifiers": "^8.0.1", + "@babel/plugin-transform-reserved-words": "^8.0.1", + "@babel/plugin-transform-shorthand-properties": "^8.0.1", + "@babel/plugin-transform-spread": "^8.0.1", + "@babel/plugin-transform-sticky-regex": "^8.0.1", + "@babel/plugin-transform-template-literals": "^8.0.1", + "@babel/plugin-transform-typeof-symbol": "^8.0.1", + "@babel/plugin-transform-unicode-escapes": "^8.0.1", + "@babel/plugin-transform-unicode-property-regex": "^8.0.1", + "@babel/plugin-transform-unicode-regex": "^8.0.1", + "@babel/plugin-transform-unicode-sets-regex": "^8.0.1", + "@babel/preset-modules": "^0.2.0", + "babel-plugin-polyfill-corejs3": "^1.0.0", + "core-js-compat": "^3.48.0", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.2.0.tgz", + "integrity": "sha512-yz0RBN2fx4fjCeFcTWsWgL7PxSRltvTa0Qg14HkWCU3qS8MO7ZSJlBVbGceynd5C9NsJwwUHNQD3dc6tYO+jqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-transform-dotall-regex": "^8.0.1", + "@babel/plugin-transform-unicode-property-regex": "^8.0.1", + "@babel/types": "^8.0.0", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.5.tgz", + "integrity": "sha512-XFfnuvapSc/vJOcUO7kwORSvpBIvraofKEZ2dhT0PjiF21BRCD7YbAFC8UEeDJNeLoQz82/gVqzgX5hCzkCbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.5", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.5", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.5", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/generator": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.5.tgz", + "integrity": "sha512-f/TuhuMAxJqhwxEGNsJrswuG9VHmh0oNFoQoo6TbpgtFAz9wYZXcTAcWZMHfp7ljesr0RG04bp3Aos9GI59L7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.5", + "@babel/types": "^8.0.5", + "@jridgewell/gen-mapping": "0.4.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.31", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@jridgewell/gen-mapping": { + "version": "0.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.4.0-beta.0.tgz", + "integrity": "sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@babel/types": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.5.tgz", + "integrity": "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@harperfast/extended-iterable": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", + "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.5.tgz", + "integrity": "sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/checkbox/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.3.tgz", + "integrity": "sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/external-editor": "^3.0.5", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.5.tgz", + "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", + "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.6.tgz", + "integrity": "sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.3.tgz", + "integrity": "sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.2.tgz", + "integrity": "sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.5.tgz", + "integrity": "sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.3.tgz", + "integrity": "sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.5.tgz", + "integrity": "sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", + "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.77.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.77.0.tgz", + "integrity": "sha512-Y8nAB7e6gfxBC+eQKG2R7NcontJo/k4J1bb1hz8451Ivd1KQca196EQlv0R7oTJixyvfYgq/SUh54XaXp0ZB/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.77.0", + "@jsonjoy.com/fs-node-utils": "4.77.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.77.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.77.0.tgz", + "integrity": "sha512-layta9p+iX4ybe9vmCfS5CTQMdE/eW+/C3ts6Rk4sIiuOTaK304dQYCz8iXXAIw/qev9qvT0d3xiboE+EGHTHQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.77.0", + "@jsonjoy.com/fs-node-builtins": "4.77.0", + "@jsonjoy.com/fs-node-utils": "4.77.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.77.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.77.0.tgz", + "integrity": "sha512-HVf6AIlKS7zzEGW0KM3D6W+7I2wFmFv6gnqVYy47j420Rm7+aHqKKlkI/94Lt4zfU61iSxEEVHUbf71kZ2uiWg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.77.0", + "@jsonjoy.com/fs-node-builtins": "4.77.0", + "@jsonjoy.com/fs-node-utils": "4.77.0", + "@jsonjoy.com/fs-print": "4.77.0", + "@jsonjoy.com/fs-snapshot": "4.77.0", + "glob-to-regex.js": "^1.3.1", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.77.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.77.0.tgz", + "integrity": "sha512-3gX7Ms/wLCVb8gtFTFQtv1wh6ODfFArA1GEJk+74eiJcs3RfVe6rF3L6UrBVL/xf07qhdm8ykWsfvDj3bBkroA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.77.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.77.0.tgz", + "integrity": "sha512-4KQrymJELJyp/QUq1f5VoK7sy9pzTL/SN+AYg8S+ISqUjgkomI4X4pnES07Q59EY3CiHNWelte4KFTJreucOOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.77.0", + "@jsonjoy.com/fs-node-builtins": "4.77.0", + "@jsonjoy.com/fs-node-utils": "4.77.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.77.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.77.0.tgz", + "integrity": "sha512-ZenZouChwBpO1WblCa0K4bMWdCdHOuQYBgKCArQl5ol8tBKOi8faz/H40iDXjl8+vcmRO5jo4kFr1EFE4nrFaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.77.0", + "glob-to-regex.js": "^1.3.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.77.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.77.0.tgz", + "integrity": "sha512-bpctLICANt8JdePLrHu3QsPMDxYNU9B5KAHKd2OBKPVkdj/M9z4/AlvGdPojhED7ur7j2cfjha5pxuLbNON9DA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.77.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.77.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.77.0.tgz", + "integrity": "sha512-PcoEoQS85KV6Xqh/NJMoXA1V01LcdCSgstV5zxnijeJIJ3+heu1Ed2Xst82GkH43czsHOaC9cKhBI24MsVejlA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.77.0", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz", + "integrity": "sha512-pYGy9dTdTwXdasPgyohkr0HoQ4FrkAzFnsUZl/gcnadDArbpZ8e+fgr+F9WBdNEl2y00mb9bCM4WgmoBkZJ27A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 9", + "listr2": "11.0.0" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.6.tgz", + "integrity": "sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.6.tgz", + "integrity": "sha512-foa+pwitysO8k+xhs7psBFfTKnVgR69NlZRRTHaFVDqphh7AdGpLeyRzKw/ofatr/sN6TiHRRW6mmop0ZrrppQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.6.tgz", + "integrity": "sha512-QR4YRyR5h5Z8eGXrNQjiyo2NNDfqi3tCc9dQG5Is1blCt+qWw1ZoBWhlWAr5d+jshkifMIJjVHzHGKbkKzF8Tw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.6.tgz", + "integrity": "sha512-HmiyFFdJa38s1heCMSooSPaBSFTHJ3C+ERPp28xAPlDX1YiALJVOgbry065nXd8Y7KISWjnw05zpG1RX8IfftA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.6.tgz", + "integrity": "sha512-ADzCuCF2cTNiX9kDScqcz1fjnAkxPpQNneV3KFTdV3wWtVlI2sTGzySoMTgDpinkMMFj1NTJlxA6XR8fwc4hlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-arm64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.6.tgz", + "integrity": "sha512-J7A9aEQsQiv0TYtBGL7NDIPp2lOS8nnl+zm4sWZm1xlsTTaQ4PgD096Adzdrk27rw3UxCkDXdCUa4ax41oztBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.6.tgz", + "integrity": "sha512-1g7G0knRX2iV/voDu54yxrGqw5Dk0w2oIYb7dgJq8IkOi+m7wbD8Q3QpPFjh0C01G58S88dqGn03len6UPCXsg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@ngtools/webpack": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-22.1.8.tgz", + "integrity": "sha512-U8VA7iaxDjSWaUOmYmcK1JjrQczoGQeq4QbBwFHo1ET3ArshIDtaiXkvuIwUDHBL4N2yGHAgEZZee8ChJvz77w==", + "deprecated": "Angular's Webpack support is deprecated. Use the esbuild and Vite-based \"@angular/build\" package instead.", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^22.0.0", + "typescript": ">=6.0 <6.1", + "webpack": "^5.54.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz", + "integrity": "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.142.0.tgz", + "integrity": "sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.142.0.tgz", + "integrity": "sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.142.0.tgz", + "integrity": "sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.142.0.tgz", + "integrity": "sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.142.0.tgz", + "integrity": "sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.142.0.tgz", + "integrity": "sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.142.0.tgz", + "integrity": "sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.142.0.tgz", + "integrity": "sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.142.0.tgz", + "integrity": "sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.142.0.tgz", + "integrity": "sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.142.0.tgz", + "integrity": "sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.142.0.tgz", + "integrity": "sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.142.0.tgz", + "integrity": "sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.142.0.tgz", + "integrity": "sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.142.0.tgz", + "integrity": "sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.142.0.tgz", + "integrity": "sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.142.0.tgz", + "integrity": "sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.142.0.tgz", + "integrity": "sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.142.0.tgz", + "integrity": "sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", + "integrity": "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.0.tgz", + "integrity": "sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.0.tgz", + "integrity": "sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.0.tgz", + "integrity": "sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.0.tgz", + "integrity": "sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.0.tgz", + "integrity": "sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.0.tgz", + "integrity": "sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@schematics/angular": { + "version": "22.1.8", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.1.8.tgz", + "integrity": "sha512-V37T9uHOQVHyxxOqwcJ9xjSIW/mW9UuSfjOc7WJE4V8+3zj0abDJLHuoxDZKe0icYGapgOcvHyYjtNOjSeSivw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.8", + "@angular-devkit/schematics": "22.1.8", + "jsonc-parser": "3.3.1", + "typescript": "6.0.3" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/gensync": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/jasmine": { + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.15.tgz", + "integrity": "sha512-ZAC8KjmV2MJxbNTrwXFN+HKeajpXQZp6KpPiR6Aa4XvaEnjP6qh23lL/Rqb7AYzlp3h/rcwDrQ7Gg7q28cQTQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/less": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/less/-/less-3.0.8.tgz", + "integrity": "sha512-Gjm4+H9noDJgu5EdT3rUw5MhPBag46fiOy27BefvWkNL8mlZnKnCaVVVTLKj6RYXed9b62CPKnPav9govyQDzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/adjust-sourcemap-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/babel-loader": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.1.1.tgz", + "integrity": "sha512-JwKSzk2kjIe7mgPK+/lyZ2QAaJcpahNAdM+hgR2HI8D0OJVkdj8Rl6J3kaLYki9pwF7P2iWnD8qVv80Lq1ABtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": "^18.20.0 || ^20.10.0 || >=22.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0 || ^8.0.0-beta.1", + "@rspack/core": "^1.0.0 || ^2.0.0-0", + "webpack": ">=5.61.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-1.0.0.tgz", + "integrity": "sha512-yIkslVjbmml2Xjb6XhFW7lISXHsqk6cesxTdDsXoMom4Lnb99DbD3OQbSOoM5Z+ASh8YXYaLAsRQrU2Jeh3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^1.0.0", + "core-js-compat": "^3.48.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/beasties": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.3.tgz", + "integrity": "sha512-fIIeLOcbAB/K1kb1HBVJoiq1alHL4RCYBSo5e7HzrNkkgMggXR1Vqt/Z9JWnkfe/qdCo66Ux3QRwZioAIBdWRA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "css-select": "^6.0.0", + "css-what": "^7.0.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^10.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bonjour-service": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz", + "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/copy-webpack-plugin": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", + "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^7.0.3", + "tinyglobby": "^0.2.12" + }, + "engines": { + "node": ">= 20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/empathic": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.10", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.10.tgz", + "integrity": "sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", + "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "punycode": "^1.4.1", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/esbuild-wasm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.28.2.tgz", + "integrity": "sha512-GccVwhv3mmOUVQHCQm2Ox/rby8n/EqUwvZxE6Pjfikrq/lWw9g9WX/u9EykWnpot3Ko6j426DgQdea2xWKIAQA==", + "dev": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.3.1.tgz", + "integrity": "sha512-zhWhMRsgnOym+1txhUmYJGD6eUsbUdrZv55gCYKuJX8t2p3+M8My23PD09bEIsreGUs5pnSa/idmcfp2/IlqGg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hosted-git-info": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-10.1.1.tgz", + "integrity": "sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.2.0.tgz", + "integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "httpxy": "^0.5.4", + "is-glob": "^4.0.3", + "is-plain-obj": "^4.1.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^22.15.0 || ^24.0.0 || >=26.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/httpxy": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.5.tgz", + "integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-instrument/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-instrument/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jasmine-core": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.1.2.tgz", + "integrity": "sha512-2oIUMGn00FdUiqz6epiiJr7xcFyNYj3rDcfmnzfkBnHyBQ3cBQUs4mmyGsOb7TTLb9kxk7dBcmEmqhDKkBoDyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/karma": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", + "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@colors/colors": "1.5.0", + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.4.1", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.5", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.7.2", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + }, + "bin": { + "karma": "bin/karma" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/karma-chrome-launcher": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", + "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "which": "^1.2.1" + } + }, + "node_modules/karma-chrome-launcher/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/karma-coverage": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", + "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.1", + "istanbul-reports": "^3.0.5", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/karma-coverage/node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/karma-coverage/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma-coverage/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma-coverage/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/karma-coverage/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/karma-jasmine": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", + "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jasmine-core": "^4.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "karma": "^6.0.0" + } + }, + "node_modules/karma-jasmine-html-reporter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", + "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "jasmine-core": "^4.0.0 || ^5.0.0", + "karma": "^6.0.0", + "karma-jasmine": "^5.0.0" + } + }, + "node_modules/karma-jasmine/node_modules/jasmine-core": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", + "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma-source-map-support": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", + "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map-support": "^0.5.5" + } + }, + "node_modules/karma/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/karma/node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/karma/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/karma/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/karma/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/karma/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/karma/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/karma/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/karma/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/karma/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/karma/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/karma/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/karma/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/karma/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/karma/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/karma/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/karma/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/karma/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launch-editor": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.4" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/less": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/less/-/less-4.9.0.tgz", + "integrity": "sha512-umRhrCH7fCi8Uj2RcwKjJdvUORTjeWqkdKx0LbcZvjIwsAVsnIAGcxHaqowPeBFBjQuWOeC/bve0AlpFzF/+SQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "copy-anything": "^3.0.5", + "parse-node-version": "^1.0.1" + }, + "bin": { + "lessc": "bin/lessc" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "make-dir": "^5.1.0", + "mime": "^1.4.1", + "needle": "^3.1.0", + "probe-image-size": "^7.2.3", + "source-map": "~0.6.0" + } + }, + "node_modules/less-loader": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-13.0.0.tgz", + "integrity": "sha512-TIa8d6znKH634Mg+7OU3jevZT6KeOhh0amW+YeMPD0GM9buUn5Y7HvtyCR5pUDdLaFfqLA8AX5PTSIHMNSexEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/less": "^3.0.8" + }, + "engines": { + "node": ">= 22.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "less": "^3.5.0 || ^4.0.0", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/less/node_modules/make-dir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-5.1.0.tgz", + "integrity": "sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/less/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/less/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/license-webpack-plugin": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", + "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", + "dev": true, + "license": "ISC", + "dependencies": { + "webpack-sources": "^3.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-sources": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-11.0.0.tgz", + "integrity": "sha512-8K88S0aSrcSXdJfiZtEy5BQMnR+TyjrCGLcgAvQs6ta0NEnIm0RJ72/Pv67Jvg07cfBhDbuN74V81lSSVYEFEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^6.1.1", + "log-update": "^8.0.0", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/lmdb": { + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.6.tgz", + "integrity": "sha512-j3uE8ReKNyUWDjhfEFSJqE/1DLtfTR5Z8yFzVHvBjAk37wNg7HdScjcv8ttPHRvrdgPQMPWxFFI0SsdBzI5lBw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@harperfast/extended-iterable": "^1.0.3", + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.5.6", + "@lmdb/lmdb-darwin-x64": "3.5.6", + "@lmdb/lmdb-linux-arm": "3.5.6", + "@lmdb/lmdb-linux-arm64": "3.5.6", + "@lmdb/lmdb-linux-x64": "3.5.6", + "@lmdb/lmdb-win32-arm64": "3.5.6", + "@lmdb/lmdb-win32-x64": "3.5.6" + } + }, + "node_modules/loader-utils": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", + "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-8.0.0.tgz", + "integrity": "sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.3.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0", + "strip-ansi": "^7.2.0", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz", + "integrity": "sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/memfs": { + "version": "4.77.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.77.0.tgz", + "integrity": "sha512-6fzXx9E2AzDHZRZZz9EVpJCVqqobjp2tMkHxdT14HfKiygSfO0mIhyaMo7iiL239sHFrF6iCdGXKuv8s6kfM9A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.77.0", + "@jsonjoy.com/fs-fsa": "4.77.0", + "@jsonjoy.com/fs-node": "4.77.0", + "@jsonjoy.com/fs-node-builtins": "4.77.0", + "@jsonjoy.com/fs-node-to-fsa": "4.77.0", + "@jsonjoy.com/fs-node-utils": "4.77.0", + "@jsonjoy.com/fs-print": "4.77.0", + "@jsonjoy.com/fs-snapshot": "4.77.0", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.3.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.2.tgz", + "integrity": "sha512-AOSS0IdEB95ayVkxn5oGzNQwqAi2J0Jb/kKm43t7H73s8+f5873g0yuj0PNvK4dO75mu5DHg4nlgp4k6Kga8eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.10.1.tgz", + "integrity": "sha512-+dsZEyTcy1lkq41lxdiOqvb26gXbYGgwY+30w37f9PqUVkuEBejBLDotsHOTjuga9xJyGLW2DqzKDUyJ4W/PMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.3", + "terser": "^5.51.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@napi-rs/image": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "imagemin": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "sharp": { + "optional": true + }, + "svgo": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/needle": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", + "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-package-arg": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-14.0.0.tgz", + "integrity": "sha512-69XQh3k+dtGa1p+7RaR57IuG3rCko96xr/nUfN4yDYBXbTYICiWcOpsFKLN2GtGE9cyIljE+f1exnaYt9MvM+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^10.1.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^8.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ordered-binary": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/oxc-parser": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz", + "integrity": "sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.142.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.142.0", + "@oxc-parser/binding-android-arm64": "0.142.0", + "@oxc-parser/binding-darwin-arm64": "0.142.0", + "@oxc-parser/binding-darwin-x64": "0.142.0", + "@oxc-parser/binding-freebsd-x64": "0.142.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.142.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.142.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.142.0", + "@oxc-parser/binding-linux-arm64-musl": "0.142.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.142.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.142.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.142.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.142.0", + "@oxc-parser/binding-linux-x64-gnu": "0.142.0", + "@oxc-parser/binding-linux-x64-musl": "0.142.0", + "@oxc-parser/binding-openharmony-arm64": "0.142.0", + "@oxc-parser/binding-wasm32-wasi": "0.142.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.142.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.142.0", + "@oxc-parser/binding-win32-x64-msvc": "0.142.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/parse-json/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/parse-json/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.1.tgz", + "integrity": "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0", + "parse5": "^8.0.0", + "parse5-sax-parser": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-sax-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", + "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/piscina": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.2.0.tgz", + "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.x" + }, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-loader": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.2.1.tgz", + "integrity": "sha512-k98jtRzthjj3f76MYTs9JTpRqV1RaaMhEU0Lpw9OTmQZQdppg4B30VZ74BojuBHt3F4KyubHJoXCMUeM8Bqeow==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^9.0.0", + "jiti": "^2.5.1", + "semver": "^7.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", + "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/probe-image-size": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.4.0.tgz", + "integrity": "sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, + "node_modules/probe-image-size/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/probe-image-size/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/probe-image-size/node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/proc-log": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", + "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.9" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regex-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", + "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-url-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", + "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.14", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/resolve-url-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rolldown": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.0.tgz", + "integrity": "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.140.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.0", + "@rolldown/binding-darwin-arm64": "1.2.0", + "@rolldown/binding-darwin-x64": "1.2.0", + "@rolldown/binding-freebsd-x64": "1.2.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", + "@rolldown/binding-linux-arm64-gnu": "1.2.0", + "@rolldown/binding-linux-arm64-musl": "1.2.0", + "@rolldown/binding-linux-ppc64-gnu": "1.2.0", + "@rolldown/binding-linux-s390x-gnu": "1.2.0", + "@rolldown/binding-linux-x64-gnu": "1.2.0", + "@rolldown/binding-linux-x64-musl": "1.2.0", + "@rolldown/binding-openharmony-arm64": "1.2.0", + "@rolldown/binding-wasm32-wasi": "1.2.0", + "@rolldown/binding-win32-arm64-msvc": "1.2.0", + "@rolldown/binding-win32-x64-msvc": "1.2.0" + } + }, + "node_modules/rolldown/node_modules/@oxc-project/types": { + "version": "0.140.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.140.0.tgz", + "integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-17.0.0.tgz", + "integrity": "sha512-0Ybm8ohBQ9LcrycVrFQp/KQBNX5a3Wda9/smS0mE/xLffzEnwvV8nykOzrbiSWNzTE3IB/jiXx8O4QmDPG2+Gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 22.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/schema-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serialize-javascript": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", + "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-index/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-index/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-loader/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/thingies": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", + "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-assert": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", + "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-name": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-8.0.0.tgz", + "integrity": "sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verkit": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.3.2.tgz", + "integrity": "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/webpack": { + "version": "5.109.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", + "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.24.4", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-8.0.3.tgz", + "integrity": "sha512-zWrde9VZDiRaFuWsjHO40wm9LxxtXEk8DdzFXdU7eU5ZpiANnZZDBbZgN3guxbEoKqUHd9YupBmynyioz42nkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "memfs": "^4.56.10", + "mime-types": "^3.0.2", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.3.3" + }, + "engines": { + "node": ">= 20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.101.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", + "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.25", + "@types/express-serve-static-core": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.8.1", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.22.1", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.14.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^5.5.0", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/webpack-dev-server/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-dev-server/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/webpack-dev-server/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-dev-server/node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/express/node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/webpack-dev-server/node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webpack-dev-server/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webpack-dev-server/node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-dev-server/node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/webpack-dev-server/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-dev-server/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/webpack-dev-server/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webpack-dev-server/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/webpack-dev-server/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/webpack-dev-server/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/webpack-dev-server/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/type-is/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.6.tgz", + "integrity": "sha512-yBWCMvIfUmuhAE8vdqUKzH0vg9kuWN0KeG4vBnqRplUFHRU7lMQjkiJWxVQzvo2BTewqhPhDlMB41rAt2jVA9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.43.1", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-subresource-integrity": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz", + "integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "typed-assert": "^1.0.8" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "html-webpack-plugin": ">= 5.0.0-beta.1 < 6", + "webpack": "^5.12.0" + }, + "peerDependenciesMeta": { + "html-webpack-plugin": { + "optional": true + } + } + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", + "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zone.js": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", + "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..671270a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "trajet-km-tracker", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@angular/animations": "^22.1.6", + "@angular/cdk": "^22.1.6", + "@angular/common": "^22.1.6", + "@angular/compiler": "^22.1.6", + "@angular/core": "^22.1.6", + "@angular/forms": "^22.1.6", + "@angular/platform-browser": "^22.1.6", + "@angular/platform-browser-dynamic": "^22.1.6", + "@angular/router": "^22.1.6", + "@types/leaflet": "^1.9.22", + "leaflet": "^1.9.4", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^22.1.8", + "@angular/cli": "^22.1.8", + "@angular/compiler-cli": "^22.1.6", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.1.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~6.0.0" + } +} diff --git a/frontend/package.json.bak b/frontend/package.json.bak new file mode 100644 index 0000000..5cccf53 --- /dev/null +++ b/frontend/package.json.bak @@ -0,0 +1,41 @@ +{ + "name": "trajet-km-tracker", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@angular/animations": "^22.1.6", + "@angular/cdk": "^22.1.6", + "@angular/common": "^22.1.6", + "@angular/compiler": "^22.1.6", + "@angular/core": "^22.1.6", + "@angular/forms": "^22.1.6", + "@angular/platform-browser": "^22.1.6", + "@angular/platform-browser-dynamic": "^22.1.6", + "@angular/router": "^22.1.6", + "@types/leaflet": "^1.9.22", + "leaflet": "^1.9.4", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.14.3" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^22.1.8", + "@angular/cli": "^22.1.8", + "@angular/compiler-cli": "^22.1.6", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.1.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.4.2" + } +} diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html new file mode 100644 index 0000000..389eaab --- /dev/null +++ b/frontend/src/app/app.component.html @@ -0,0 +1,31 @@ + + + +
+ +
diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss new file mode 100644 index 0000000..2ef9ac3 --- /dev/null +++ b/frontend/src/app/app.component.scss @@ -0,0 +1,32 @@ +.nav-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 1rem; + background: #1f2937; + border-bottom: 1px solid #111827; + position: sticky; + top: 0; + z-index: 500; + + &__lien { + padding: 0.5rem 0.9rem; + border-radius: 6px; + color: #cbd5e1; + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + white-space: nowrap; + + &:hover { + background: #374151; + color: #fff; + } + + &--actif { + background: #2b6cb0; + color: #fff; + } + } +} diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts new file mode 100644 index 0000000..93eab9e --- /dev/null +++ b/frontend/src/app/app.component.spec.ts @@ -0,0 +1,33 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { AppComponent } from './app.component'; +import { routes } from './app.routes'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + providers: [provideRouter(routes)] + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have the 'trajet-km-tracker' title`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('trajet-km-tracker'); + }); + + it('should render a router outlet', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('router-outlet')).not.toBeNull(); + }); +}); diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts new file mode 100644 index 0000000..e598b18 --- /dev/null +++ b/frontend/src/app/app.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet, RouterLink, RouterLinkActive], + templateUrl: './app.component.html', + styleUrl: './app.component.scss' +}) +export class AppComponent { + title = 'trajet-km-tracker'; +} diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts new file mode 100644 index 0000000..e3f9087 --- /dev/null +++ b/frontend/src/app/app.config.ts @@ -0,0 +1,19 @@ +import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; + +import { routes } from './app.routes'; +import { timeoutInterceptor } from './core/interceptors/timeout.interceptor'; + +export const appConfig: ApplicationConfig = { + providers: [ + // Détection de changement SANS zone.js (zoneless). L'état affiché est exposé via + // des signals Angular, qui déclenchent le rendu automatiquement. Décision prise + // car le dev-server (Vite) chargeait zone.js comme module pré-bundlé séparé, ce + // qui empêchait la détection de changement de se déclencher sur les réponses HTTP. + provideZonelessChangeDetection(), + provideRouter(routes), + // TimeoutInterceptor : timeout 10s + gestion des erreurs réseau (Exigence 1.9). + provideHttpClient(withInterceptors([timeoutInterceptor])) + ] +}; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..05c18da --- /dev/null +++ b/frontend/src/app/app.routes.ts @@ -0,0 +1,46 @@ +import { Routes } from '@angular/router'; + +import { profilConfigureGuard } from './core/guards/profil-configure.guard'; +import { BaremeComponent } from './features/bareme/bareme.component'; +import { CarnetAdressesComponent } from './features/carnet-adresses/carnet-adresses.component'; +import { ConsultationJourComponent } from './features/consultation-jour/consultation-jour.component'; +import { MontantsReglesComponent } from './features/montants-regles/montants-regles.component'; +import { OnboardingComponent } from './features/onboarding/onboarding.component'; +import { ProfilComponent } from './features/profil/profil.component'; +import { ResumeAnnuelComponent } from './features/resume-annuel/resume-annuel.component'; +import { ResumeMensuelComponent } from './features/resume-mensuel/resume-mensuel.component'; +import { VehiculesComponent } from './features/vehicules/vehicules.component'; + +/** + * Routes de l'application (équivalent standalone de `app-routing.module.ts`). + * + * Placeholders pour le socle : + * - `/consultation` : vue principale de consultation des trajets (tâche 16.4). + * - `/onboarding` : parcours de première utilisation (tâche 21.1), accessible sans garde. + * + * La garde `profilConfigureGuard` (tâche 21.2) protège l'accès à la saisie / + * consultation des trajets et aux résumés tant que le profil n'est pas configuré. + * + * Décision de routage : + * - Routes protégées par la garde (bloquées si `estConfigure = false`, redirection + * vers `/onboarding`) : `consultation`, `carnet-adresses`, `resume-mensuel`, + * `resume-annuel`, `bareme`, `montants-regles`. + * - Routes laissées accessibles SANS garde pour permettre à l'utilisateur de terminer + * sa configuration : `onboarding` (toujours atteignable), `profil` et `vehicules`. + * Appliquer la garde à `/profil` / `/vehicules` risquerait de piéger un utilisateur + * partiellement configuré ; la garde ne vise que la saisie/consultation des trajets + * et les rapports (voir design « ProfilConfigureGuard »). + */ +export const routes: Routes = [ + { path: '', redirectTo: 'consultation', pathMatch: 'full' }, + { path: 'consultation', component: ConsultationJourComponent, canActivate: [profilConfigureGuard] }, + { path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [profilConfigureGuard] }, + { path: 'profil', component: ProfilComponent }, + { path: 'vehicules', component: VehiculesComponent }, + { path: 'resume-mensuel', component: ResumeMensuelComponent, canActivate: [profilConfigureGuard] }, + { path: 'resume-annuel', component: ResumeAnnuelComponent, canActivate: [profilConfigureGuard] }, + { path: 'bareme', component: BaremeComponent, canActivate: [profilConfigureGuard] }, + { path: 'montants-regles', component: MontantsReglesComponent, canActivate: [profilConfigureGuard] }, + { path: 'onboarding', component: OnboardingComponent }, + { path: '**', redirectTo: 'consultation' } +]; diff --git a/frontend/src/app/core/guards/profil-configure.guard.ts b/frontend/src/app/core/guards/profil-configure.guard.ts new file mode 100644 index 0000000..ad3d4b7 --- /dev/null +++ b/frontend/src/app/core/guards/profil-configure.guard.ts @@ -0,0 +1,43 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router, UrlTree } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +import { NotificationService } from '../services/notification.service'; +import { ProfilService } from '../services/profil.service'; + +/** + * Garde de route `CanActivateFn` protégeant l'accès à la saisie / consultation des trajets + * tant que le profil n'est pas configuré (onboarding première utilisation). + * + * Comportement (Exigences 16.3, 16.4, 16.11, 16.13) : + * - Appelle `ProfilService.getEtat()` (`GET /api/profil/etat`) à l'activation d'une route protégée. + * - `estConfigure === true` → navigation autorisée (retourne `true`). (Exigence 16.11) + * - `estConfigure === false` → redirection vers `/onboarding` (retourne un `UrlTree`) et + * blocage de l'accès aux trajets / consultation tant que le profil n'est pas configuré. + * (Exigences 16.3, 16.4) + * - En cas d'échec de récupération de l'indicateur (backend injoignable / erreur réseau), + * la garde n'affiche PAS la vue principale : elle notifie une erreur de connexion via + * `NotificationService` (avec une invitation à réessayer) et bloque la navigation en + * retournant `false`, plutôt que de rediriger arbitrairement vers la vue principale. + * L'utilisateur peut relancer la vérification en réessayant la navigation. (Exigence 16.13) + */ +export const profilConfigureGuard: CanActivateFn = (): Observable => { + const profilService = inject(ProfilService); + const router = inject(Router); + const notifications = inject(NotificationService); + + return profilService.getEtat().pipe( + map((etat) => + etat.estConfigure ? true : router.createUrlTree(['/onboarding']) + ), + catchError(() => { + // Backend injoignable / erreur réseau : ne pas afficher la vue principale. + // Notifier l'erreur de connexion avec une invitation à réessayer, puis bloquer. + notifications.error( + 'Impossible de vérifier la configuration du profil (connexion au serveur indisponible). Veuillez réessayer.' + ); + return of(false); + }) + ); +}; diff --git a/frontend/src/app/core/interceptors/timeout.interceptor.ts b/frontend/src/app/core/interceptors/timeout.interceptor.ts new file mode 100644 index 0000000..5c03975 --- /dev/null +++ b/frontend/src/app/core/interceptors/timeout.interceptor.ts @@ -0,0 +1,61 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { TimeoutError, throwError, timeout } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; +import { NotificationService } from '../services/notification.service'; + +/** Message affiché lorsque le serveur ne répond pas dans les temps (Exigence 1.9). */ +export const SERVEUR_INDISPONIBLE_MESSAGE = + 'Le serveur est indisponible. Veuillez réessayer. Vos données saisies ont été conservées.'; + +/** + * Erreur applicative levée lorsque le backend est injoignable ou trop lent. + * Les composants peuvent la détecter (`error instanceof ServeurIndisponibleError`) + * pour conserver les données du formulaire (Exigence 1.9). + */ +export class ServeurIndisponibleError extends Error { + constructor( + message: string = SERVEUR_INDISPONIBLE_MESSAGE, + readonly origine?: unknown + ) { + super(message); + this.name = 'ServeurIndisponibleError'; + } +} + +/** + * Intercepteur HTTP fonctionnel : + * - applique un timeout de 10 secondes (Exigence 1.9) à chaque requête vers l'API backend ; + * - transforme un dépassement de délai ou une erreur réseau (statut 0) en + * `ServeurIndisponibleError` et affiche une notification d'indisponibilité. + * + * Les appels externes (ex. autocomplétion BAN) ne sont pas soumis au timeout API + * afin de ne pas déclencher le message d'indisponibilité du backend pour un service tiers. + */ +export const timeoutInterceptor: HttpInterceptorFn = (req, next) => { + const notifications = inject(NotificationService); + + // On ne borne que les appels vers notre API backend. + const cibleApi = req.url.startsWith(environment.apiUrl) || req.url.includes('/api/'); + if (!cibleApi) { + return next(req); + } + + return next(req).pipe( + timeout(environment.httpTimeoutMs), + catchError((error: unknown) => { + // Timeout (10s dépassées) ou erreur réseau (statut 0 = pas de réponse serveur). + const estTimeout = error instanceof TimeoutError; + const estReseau = error instanceof HttpErrorResponse && error.status === 0; + + if (estTimeout || estReseau) { + notifications.error(SERVEUR_INDISPONIBLE_MESSAGE); + return throwError(() => new ServeurIndisponibleError(SERVEUR_INDISPONIBLE_MESSAGE, error)); + } + + return throwError(() => error); + }) + ); +}; diff --git a/frontend/src/app/core/models/adresse.model.ts b/frontend/src/app/core/models/adresse.model.ts new file mode 100644 index 0000000..ff66ba1 --- /dev/null +++ b/frontend/src/app/core/models/adresse.model.ts @@ -0,0 +1,59 @@ +// Modèles Adresses — miroir de AdresseDtos.cs + réponse BAN. + +/** Résultat de recherche BAN proxifié via le backend — miroir de BANAdresseDto. */ +export interface BANAdresse { + label: string; + latitude: number; + longitude: number; + postcode: string; + city: string; +} + +/** Suggestion d'autocomplétion issue du cache local — miroir de AdresseCacheDto. */ +export interface AdresseCache { + adresse: string; + latitude: number; + longitude: number; +} + +/** Adresse nommée du carnet d'adresses — miroir de AdresseNommeeDto. */ +export interface AdresseNommee { + id: number; + denomination: string; + adresse: string; + latitude: number | null; // null si le géocodage a échoué + longitude: number | null; // null si le géocodage a échoué +} + +/** Création / mise à jour d'une adresse nommée — miroir de SaveAdresseNommeeDto. */ +export interface SaveAdresseNommee { + denomination: string; // 1 à 100 car., unique après normalisation + adresse: string; // 1 à 255 car. + latitude?: number | null; // fournies par le frontend si sélection BAN + longitude?: number | null; +} + +/** + * Réponse brute de l'API Base Adresse Nationale (Feature GeoJSON). + * Source primaire d'autocomplétion appelée directement par le frontend. + */ +export interface BANFeature { + properties: { + label: string; // libellé complet normalisé + score: number; // score de pertinence 0–1 + postcode: string; + city: string; + context: string; + type: string; // 'housenumber' | 'street' | 'locality' | 'municipality' + x: number; // longitude + y: number; // latitude + }; + geometry: { + coordinates: [number, number]; // [longitude, latitude] + }; +} + +/** Enveloppe de la réponse BAN (FeatureCollection GeoJSON). */ +export interface BANFeatureCollection { + features: BANFeature[]; +} diff --git a/frontend/src/app/core/models/bareme.model.ts b/frontend/src/app/core/models/bareme.model.ts new file mode 100644 index 0000000..9fbb8c4 --- /dev/null +++ b/frontend/src/app/core/models/bareme.model.ts @@ -0,0 +1,64 @@ +// Modèles Barème & montants — miroir de BaremeDtos.cs. + +/** Tranche de barème kilométrique — miroir de TrancheBaremeDto. */ +export interface TrancheBareme { + id: number; + annee: number; + puissanceFiscale: number; + kmMin: number; + kmMax: number | null; // null = illimité (borne max exclusive : [kmMin, kmMax[) + coefficient: number; + base: number; +} + +/** Création / mise à jour d'une tranche — miroir de SaveTrancheBaremeDto. */ +export interface SaveTrancheBareme { + annee: number; + puissanceFiscale: number; + kmMin: number; + kmMax: number | null; // null = illimité + coefficient: number; + base: number; +} + +/** Montant réglé pour une période et un véhicule — miroir de MontantRegleDto. */ +export interface MontantRegle { + annee: number; + mois: number; + vehiculeId: number; + montant: number; +} + +/** Indication de versement mensuel prévisionnel — miroir de MontantPrevisionnelDto. */ +export interface MontantPrevisionnel { + annee: number; + mois: number; + vehiculeId: number; + totalAnnuelPartielKm: number; + montantBaremePlein: number; // barème plein sur le total partiel + montantPrevisionnel: number; // = 80% (indicatif uniquement) +} + +/** Ligne mensuelle de la synthèse annuelle — miroir de LigneSyntheseMoisDto. */ +export interface LigneSyntheseMois { + mois: number; + kmMois: number; + fraisPaye: number; // Montant_Regle du mois (saisi) + fraisReelMensuel: number; // km_du_mois × coefficient de la tranche annuelle +} + +/** Synthèse annuelle de régularisation — miroir de RegularisationAnnuelleDto. */ +export interface RegularisationAnnuelle { + annee: number; + vehiculeId: number; + plaque: string; + puissanceFiscale: number; + coefficientTranche: number; + baseTranche: number; + trancheLibelle: string; + mois: LigneSyntheseMois[]; // 12 lignes + totalKmAnnuel: number; + totalPaye: number; // somme des Montant_Regle + montantReel: number; // barème plein : total_km × coef + base + regularisation: number; // montantReel − totalPaye +} diff --git a/frontend/src/app/core/models/index.ts b/frontend/src/app/core/models/index.ts new file mode 100644 index 0000000..bc529dc --- /dev/null +++ b/frontend/src/app/core/models/index.ts @@ -0,0 +1,9 @@ +// Barrel d'export des modèles du domaine (miroir des DTOs backend). +export * from './trajet.model'; +export * from './resume-mensuel.model'; +export * from './resume-annuel.model'; +export * from './totaux.model'; +export * from './vehicule.model'; +export * from './profil.model'; +export * from './bareme.model'; +export * from './adresse.model'; diff --git a/frontend/src/app/core/models/profil.model.ts b/frontend/src/app/core/models/profil.model.ts new file mode 100644 index 0000000..8d174b5 --- /dev/null +++ b/frontend/src/app/core/models/profil.model.ts @@ -0,0 +1,27 @@ +// Modèles Profil — miroir de ProfilDtos.cs. + +/** Profil — miroir de ProfilDto. */ +export interface Profil { + nom: string | null; + prenom: string | null; + matricule: string | null; + adresseDomicile: string | null; + domicileGeocode: boolean; +} + +/** Création / mise à jour du profil — miroir de SaveProfilDto. */ +export interface SaveProfil { + nom?: string | null; + prenom?: string | null; + matricule?: string | null; // max 8 car. + adresseDomicile: string; +} + +/** + * État de configuration du profil (onboarding première utilisation). + * Miroir de EtatProfilDto. + * estConfigure = true ssi Profil.Nom et Profil.Prenom non vides ET au moins un Vehicule existe. + */ +export interface EtatProfil { + estConfigure: boolean; +} diff --git a/frontend/src/app/core/models/resume-annuel.model.ts b/frontend/src/app/core/models/resume-annuel.model.ts new file mode 100644 index 0000000..7d7d037 --- /dev/null +++ b/frontend/src/app/core/models/resume-annuel.model.ts @@ -0,0 +1,8 @@ +import { ResumeMensuel } from './resume-mensuel.model'; + +/** Résumé annuel — miroir de ResumeAnnuelDto. */ +export interface ResumeAnnuel { + annee: number; + mois: ResumeMensuel[]; // toujours 12 éléments + totalKm: number; +} diff --git a/frontend/src/app/core/models/resume-mensuel.model.ts b/frontend/src/app/core/models/resume-mensuel.model.ts new file mode 100644 index 0000000..cb9da9e --- /dev/null +++ b/frontend/src/app/core/models/resume-mensuel.model.ts @@ -0,0 +1,9 @@ +import { Trajet } from './trajet.model'; + +/** Résumé mensuel — miroir de ResumeMensuelDto. */ +export interface ResumeMensuel { + annee: number; + mois: number; + trajets: Trajet[]; + totalKm: number; // somme des distances, 2 décimales +} diff --git a/frontend/src/app/core/models/totaux.model.ts b/frontend/src/app/core/models/totaux.model.ts new file mode 100644 index 0000000..97e8479 --- /dev/null +++ b/frontend/src/app/core/models/totaux.model.ts @@ -0,0 +1,8 @@ +/** Totaux affichés dans le Panneau_Totaux — miroir de TotauxDto. */ +export interface Totaux { + date: string; // ISO date + vehiculeId: number; // véhicule sur lequel portent les totaux + totalJour: number; + totalMois: number; + totalAnnuelPartiel: number; // cumul de l'année jusqu'au mois inclus +} diff --git a/frontend/src/app/core/models/trajet.model.ts b/frontend/src/app/core/models/trajet.model.ts new file mode 100644 index 0000000..7d3a07b --- /dev/null +++ b/frontend/src/app/core/models/trajet.model.ts @@ -0,0 +1,73 @@ +// Modèles Trajet — miroir des DTOs backend (TrajetDtos.cs). + +/** + * Création d'un Trajet. + * Les coordonnées sont transmises par le Frontend (issues de la sélection BAN) ; + * elles sont nulles si l'adresse a été saisie manuellement sans autocomplétion. + */ +export interface CreateTrajet { + date: string; // JJ/MM/AAAA en entrée + adresseDepart: string; + departLatitude?: number | null; + departLongitude?: number | null; + adresseArrivee: string; + arriveeLatitude?: number | null; + arriveeLongitude?: number | null; + commentaire?: string | null; // optionnel, max 500 car. + vehiculeId?: number | null; // null -> véhicule courant à la date du trajet +} + +/** Mise à jour d'un Trajet. */ +export interface UpdateTrajet { + date: string; + adresseDepart: string; + departLatitude?: number | null; + departLongitude?: number | null; + adresseArrivee: string; + arriveeLatitude?: number | null; + arriveeLongitude?: number | null; + commentaire?: string | null; + vehiculeId?: number | null; +} + +/** Réponse Trajet. */ +export interface Trajet { + id: number; + 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) + commentaire: string | null; + position: number; // rang dans la journée + vehiculeId: number; + puissanceFiscale: number; +} + +/** + * Trajet enrichi pour l'affichage carte (géométries GeoJSON). + * Note : ce DTO ne porte PAS de `commentaire` (le commentaire n'est affiché que + * dans la liste des trajets, via l'interface `Trajet`, jamais dans l'infobulle carte). + */ +export interface TrajetCarte { + id: number; + date: string; + adresseDepart: string; + adresseArrivee: string; + distance: number; + departLatitude?: number | null; + departLongitude?: number | null; + arriveeLatitude?: number | null; + arriveeLongitude?: number | null; + geometrieJson: string | null; // GeoJSON LineString ou null +} + +/** Position d'un trajet lors d'un réordonnancement. */ +export interface TrajetPosition { + id: number; + position: number; +} + +/** Réordonnancement manuel des trajets d'une journée. */ +export interface ReordonnerTrajets { + trajets: TrajetPosition[]; +} diff --git a/frontend/src/app/core/models/vehicule.model.ts b/frontend/src/app/core/models/vehicule.model.ts new file mode 100644 index 0000000..325a904 --- /dev/null +++ b/frontend/src/app/core/models/vehicule.model.ts @@ -0,0 +1,34 @@ +// Modèles Véhicule — miroir de VehiculeDtos.cs. + +/** Véhicule — miroir de VehiculeDto. */ +export interface Vehicule { + id: number; + denomination: string; + plaque: string; + puissanceFiscale: number; + dateEffet: string; // ISO date +} + +/** Création d'un véhicule — miroir de CreateVehiculeDto. */ +export interface CreateVehicule { + denomination: string; + plaque: string; + puissanceFiscale: number; + dateEffet: string; // JJ/MM/AAAA, <= date du jour +} + +/** + * Réponse à la création d'un véhicule. + * Indique s'il faut proposer une réaffectation des trajets existants. + */ +export interface VehiculeCree { + vehicule: Vehicule; + reaffectationProposee: boolean; // true si des trajets >= DateEffet ont une autre puissance fiscale + nombreTrajetsConcernes: number; +} + +/** Requête de réaffectation — miroir de ReaffectationDto. */ +export interface Reaffectation { + vehiculeId: number; // nouveau véhicule + accepter: boolean; // true = réaffecter les trajets >= DateEffet ; false = ne rien changer +} diff --git a/frontend/src/app/core/services/adresse-cache.service.ts b/frontend/src/app/core/services/adresse-cache.service.ts new file mode 100644 index 0000000..165ccbb --- /dev/null +++ b/frontend/src/app/core/services/adresse-cache.service.ts @@ -0,0 +1,37 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, of } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { AdresseCache } from '../models'; + +/** Nombre minimal de caractères avant d'interroger le cache local. */ +export const CACHE_MIN_CARACTERES = 3; + +/** + * Service de recherche dans le cache local d'adresses. + * + * Le cache local est un fallback / optimisation : il retrouve instantanément + * des adresses déjà utilisées sans solliciter la BAN. La mise en cache est + * effectuée côté backend lors de la création d'un trajet ; aucun `saveAdresse` + * client n'est donc nécessaire. + */ +@Injectable({ providedIn: 'root' }) +export class AdresseCacheService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/adresses`; + + /** + * Recherche dans le cache local (GET /api/adresses/search?q=). + * Une requête de moins de 3 caractères renvoie une liste vide sans appel réseau. + */ + searchLocal(query: string): Observable { + const terme = (query ?? '').trim(); + if (terme.length < CACHE_MIN_CARACTERES) { + return of([]); + } + + const params = new HttpParams().set('q', terme); + return this.http.get(`${this.baseUrl}/search`, { params }); + } +} diff --git a/frontend/src/app/core/services/adresse-nommee.service.ts b/frontend/src/app/core/services/adresse-nommee.service.ts new file mode 100644 index 0000000..b8fa2c5 --- /dev/null +++ b/frontend/src/app/core/services/adresse-nommee.service.ts @@ -0,0 +1,36 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { AdresseNommee, SaveAdresseNommee } from '../models'; + +/** + * Service HTTP du carnet d'adresses (Adresses_Nommées). + * Supporte la gestion du carnet d'adresses (CRUD). + */ +@Injectable({ providedIn: 'root' }) +export class AdresseNommeeService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/adresses-nommees`; + + /** Liste des adresses nommées, triées par dénomination (GET /api/adresses-nommees). */ + getAdressesNommees(): Observable { + return this.http.get(this.baseUrl); + } + + /** Crée une adresse nommée (POST /api/adresses-nommees). */ + create(dto: SaveAdresseNommee): Observable { + return this.http.post(this.baseUrl, dto); + } + + /** Modifie une adresse nommée (PUT /api/adresses-nommees/{id}). */ + update(id: number, dto: SaveAdresseNommee): Observable { + return this.http.put(`${this.baseUrl}/${id}`, dto); + } + + /** Supprime une adresse nommée (DELETE /api/adresses-nommees/{id}). */ + delete(id: number): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } +} diff --git a/frontend/src/app/core/services/ban.service.ts b/frontend/src/app/core/services/ban.service.ts new file mode 100644 index 0000000..b6d9858 --- /dev/null +++ b/frontend/src/app/core/services/ban.service.ts @@ -0,0 +1,51 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; +import { BANFeature, BANFeatureCollection } from '../models'; + +/** Nombre minimal de caractères avant d'interroger la BAN (Exigence 1.13). */ +export const BAN_MIN_CARACTERES = 3; + +/** Nombre maximal de suggestions demandées à la BAN. */ +export const BAN_LIMITE_SUGGESTIONS = 5; + +/** + * Service d'autocomplétion via l'API Base Adresse Nationale (BAN). + * + * Source primaire d'autocomplétion : l'appel est fait directement au service + * externe (`environment.banUrl`), pas au backend. Le `timeoutInterceptor` ne + * borne pas ces appels (ils ne ciblent pas l'API backend), afin de ne pas + * déclencher le message d'indisponibilité du backend pour un service tiers. + * + * La logique de debounce et le déclenchement dès 3 caractères sont à la charge + * du composant appelant ; ce service applique néanmoins un garde-fou en retournant + * une liste vide pour toute requête de moins de 3 caractères. + */ +@Injectable({ providedIn: 'root' }) +export class BANService { + private readonly http = inject(HttpClient); + private readonly baseUrl = environment.banUrl; + + /** + * Recherche d'adresses françaises (GET {banUrl}/search/?q=...&limit=5). + * Retourne au plus 5 suggestions. Une requête de moins de 3 caractères + * renvoie une liste vide sans appel réseau. + */ + searchAdresses(query: string): Observable { + const terme = (query ?? '').trim(); + if (terme.length < BAN_MIN_CARACTERES) { + return of([]); + } + + const params = new HttpParams() + .set('q', terme) + .set('limit', BAN_LIMITE_SUGGESTIONS); + + return this.http + .get(`${this.baseUrl}/search/`, { params }) + .pipe(map((collection) => collection?.features ?? [])); + } +} diff --git a/frontend/src/app/core/services/bareme.service.ts b/frontend/src/app/core/services/bareme.service.ts new file mode 100644 index 0000000..22f789e --- /dev/null +++ b/frontend/src/app/core/services/bareme.service.ts @@ -0,0 +1,67 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { MontantPrevisionnel, SaveTrancheBareme, TrancheBareme } from '../models'; + +/** + * Service HTTP du barème kilométrique (tranches) et du montant prévisionnel. + */ +@Injectable({ providedIn: 'root' }) +export class BaremeService { + private readonly http = inject(HttpClient); + private readonly baseUrl = environment.apiUrl; + + /** Toutes les tranches du barème d'une année (GET /api/bareme/{annee}). */ + getBareme(annee: number): Observable { + return this.http.get(`${this.baseUrl}/bareme/${annee}`); + } + + /** Ajoute une tranche (POST /api/bareme). */ + saveTranche(dto: SaveTrancheBareme): Observable { + return this.http.post(`${this.baseUrl}/bareme`, dto); + } + + /** Modifie une tranche existante (PUT /api/bareme/{id}). */ + updateTranche(id: number, dto: SaveTrancheBareme): Observable { + return this.http.put(`${this.baseUrl}/bareme/${id}`, dto); + } + + /** Supprime une tranche (DELETE /api/bareme/{id}). */ + deleteTranche(id: number): Observable { + return this.http.delete(`${this.baseUrl}/bareme/${id}`); + } + + /** Années pour lesquelles un barème existe (GET /api/bareme/annees-disponibles). */ + getAnneesAvecBareme(): Observable { + return this.http.get(`${this.baseUrl}/bareme/annees-disponibles`); + } + + /** + * Copie le dernier barème connu vers l'année cible (POST /api/bareme/copier/{annee}). + * Remplace les tranches existantes de l'année cible. Retourne le nombre de tranches copiées. + */ + copierDernierBareme(anneeCible: number): Observable<{ tranchesCopiees: number }> { + return this.http.post<{ tranchesCopiees: number }>( + `${this.baseUrl}/bareme/copier/${anneeCible}`, + {} + ); + } + + /** + * Montant prévisionnel minoré (80%) pour une période et un véhicule + * (GET /api/montant-previsionnel/{annee}/{mois}?vehiculeId={id}). + */ + getMontantPrevisionnel( + annee: number, + mois: number, + vehiculeId: number + ): Observable { + const params = new HttpParams().set('vehiculeId', vehiculeId); + return this.http.get( + `${this.baseUrl}/montant-previsionnel/${annee}/${mois}`, + { params } + ); + } +} diff --git a/frontend/src/app/core/services/index.ts b/frontend/src/app/core/services/index.ts new file mode 100644 index 0000000..a15ddf2 --- /dev/null +++ b/frontend/src/app/core/services/index.ts @@ -0,0 +1,10 @@ +// Barrel d'export des services HTTP du domaine (core). +export * from './notification.service'; +export * from './trajet.service'; +export * from './vehicule.service'; +export * from './ban.service'; +export * from './adresse-cache.service'; +export * from './adresse-nommee.service'; +export * from './profil.service'; +export * from './bareme.service'; +export * from './remboursement.service'; diff --git a/frontend/src/app/core/services/notification.service.ts b/frontend/src/app/core/services/notification.service.ts new file mode 100644 index 0000000..96d0d42 --- /dev/null +++ b/frontend/src/app/core/services/notification.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; + +/** Niveaux de notification supportés. */ +export type NotificationType = 'success' | 'error' | 'warning' | 'info'; + +/** Notification émise vers le composant d'affichage (toast/snackbar). */ +export interface Notification { + type: NotificationType; + message: string; + /** Durée d'affichage en ms (0 = persistant jusqu'à fermeture manuelle). */ + durationMs: number; +} + +/** + * Abstraction toast / snackbar. + * + * Le service ne dépend d'aucune librairie UI : il expose un flux `notifications$` + * qu'un composant d'affichage (à implémenter dans les tâches UI) consomme pour + * présenter les messages. Cela permet de découpler la logique métier du rendu. + */ +@Injectable({ providedIn: 'root' }) +export class NotificationService { + private readonly subject = new Subject(); + + /** Flux des notifications à afficher. */ + readonly notifications$: Observable = this.subject.asObservable(); + + /** Message de succès (confirmation d'action). */ + success(message: string, durationMs = 4000): void { + this.emit('success', message, durationMs); + } + + /** Message d'erreur (échec d'action, indisponibilité serveur, etc.). */ + error(message: string, durationMs = 6000): void { + this.emit('error', message, durationMs); + } + + /** Avertissement (ex. géocodage échoué, distance non calculée). */ + warning(message: string, durationMs = 5000): void { + this.emit('warning', message, durationMs); + } + + /** Information neutre. */ + info(message: string, durationMs = 4000): void { + this.emit('info', message, durationMs); + } + + private emit(type: NotificationType, message: string, durationMs: number): void { + this.subject.next({ type, message, durationMs }); + } +} diff --git a/frontend/src/app/core/services/profil.service.ts b/frontend/src/app/core/services/profil.service.ts new file mode 100644 index 0000000..e2f2cdc --- /dev/null +++ b/frontend/src/app/core/services/profil.service.ts @@ -0,0 +1,33 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { EtatProfil, Profil, SaveProfil } from '../models'; + +/** + * Service HTTP du profil utilisateur et de l'état de configuration (onboarding). + */ +@Injectable({ providedIn: 'root' }) +export class ProfilService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/profil`; + + /** Récupère le profil (GET /api/profil). */ + getProfil(): Observable { + return this.http.get(this.baseUrl); + } + + /** Sauvegarde le profil (PUT /api/profil). */ + saveProfil(dto: SaveProfil): Observable { + return this.http.put(this.baseUrl, dto); + } + + /** + * État de configuration du profil pour l'onboarding (GET /api/profil/etat). + * `estConfigure = true` ssi Nom + Prénom non vides ET au moins un véhicule (Exigence 16.2). + */ + getEtat(): Observable { + return this.http.get(`${this.baseUrl}/etat`); + } +} diff --git a/frontend/src/app/core/services/remboursement.service.ts b/frontend/src/app/core/services/remboursement.service.ts new file mode 100644 index 0000000..f457f5c --- /dev/null +++ b/frontend/src/app/core/services/remboursement.service.ts @@ -0,0 +1,43 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { MontantRegle, RegularisationAnnuelle } from '../models'; + +/** + * Service HTTP des montants réglés et de la régularisation annuelle. + */ +@Injectable({ providedIn: 'root' }) +export class RemboursementService { + private readonly http = inject(HttpClient); + private readonly baseUrl = environment.apiUrl; + + /** + * Montants réglés d'une année pour un véhicule + * (GET /api/montants-regles/{annee}?vehiculeId={id}). + */ + getMontantsRegles(annee: number, vehiculeId: number): Observable { + const params = new HttpParams().set('vehiculeId', vehiculeId); + return this.http.get(`${this.baseUrl}/montants-regles/${annee}`, { params }); + } + + /** + * Enregistre ou met à jour un montant réglé (PUT /api/montants-regles). + * Upsert par (année, mois, véhicule). + */ + saveMontantRegle(dto: MontantRegle): Observable { + return this.http.put(`${this.baseUrl}/montants-regles`, dto); + } + + /** + * Synthèse annuelle de régularisation d'un véhicule + * (GET /api/regularisation/{annee}?vehiculeId={id}). + */ + getRegularisation(annee: number, vehiculeId: number): Observable { + const params = new HttpParams().set('vehiculeId', vehiculeId); + return this.http.get(`${this.baseUrl}/regularisation/${annee}`, { + params + }); + } +} diff --git a/frontend/src/app/core/services/trajet.service.ts b/frontend/src/app/core/services/trajet.service.ts new file mode 100644 index 0000000..5099620 --- /dev/null +++ b/frontend/src/app/core/services/trajet.service.ts @@ -0,0 +1,137 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { + CreateTrajet, + ReordonnerTrajets, + ResumeAnnuel, + ResumeMensuel, + Totaux, + Trajet, + TrajetCarte, + UpdateTrajet +} from '../models'; + +/** + * Service HTTP des trajets, résumés, exports PDF et navigation temporelle. + * + * Toutes les routes sont relatives à `environment.apiUrl` (`/api` en prod, + * `http://localhost:8080/api` en dev). Les appels sont soumis au + * `timeoutInterceptor` (10 s, Exigence 1.9). + */ +@Injectable({ providedIn: 'root' }) +export class TrajetService { + private readonly http = inject(HttpClient); + private readonly baseUrl = environment.apiUrl; + + /** Crée un trajet (POST /api/trajets). */ + createTrajet(dto: CreateTrajet): Observable { + return this.http.post(`${this.baseUrl}/trajets`, dto); + } + + /** Modifie un trajet (PUT /api/trajets/{id}). */ + updateTrajet(id: number, dto: UpdateTrajet): Observable { + return this.http.put(`${this.baseUrl}/trajets/${id}`, dto); + } + + /** Supprime un trajet (DELETE /api/trajets/{id}). */ + deleteTrajet(id: number): Observable { + return this.http.delete(`${this.baseUrl}/trajets/${id}`); + } + + /** Réordonne les trajets d'une journée (PATCH /api/trajets/reordonner). */ + reordonner(dto: ReordonnerTrajets): Observable { + return this.http.patch(`${this.baseUrl}/trajets/reordonner`, dto); + } + + /** + * Recalcule la distance de tous les trajets via ORS + * (POST /api/trajets/recalculer-distances). Retourne le nombre de trajets recalculés. + */ + recalculerDistances(): Observable<{ trajetsRecalcules: number }> { + return this.http.post<{ trajetsRecalcules: number }>( + `${this.baseUrl}/trajets/recalculer-distances`, + {} + ); + } + + /** + * Résumé mensuel (GET /api/resumes/mensuel/{annee}/{mois}). + * Filtre optionnel par véhicule ; véhicule courant si omis. + */ + getResumeMensuel(annee: number, mois: number, vehiculeId?: number): Observable { + return this.http.get(`${this.baseUrl}/resumes/mensuel/${annee}/${mois}`, { + params: this.vehiculeParams(vehiculeId) + }); + } + + /** + * Résumé annuel (GET /api/resumes/annuel/{annee}). + * Filtre optionnel par véhicule ; véhicule courant si omis. + */ + getResumeAnnuel(annee: number, vehiculeId?: number): Observable { + return this.http.get(`${this.baseUrl}/resumes/annuel/${annee}`, { + params: this.vehiculeParams(vehiculeId) + }); + } + + /** Export PDF mensuel (GET /api/exports/mensuel/{annee}/{mois}). */ + exportPdfMensuel(annee: number, mois: number, vehiculeId?: number): Observable { + return this.http.get(`${this.baseUrl}/exports/mensuel/${annee}/${mois}`, { + params: this.vehiculeParams(vehiculeId), + responseType: 'blob' + }); + } + + /** Export PDF annuel (GET /api/exports/annuel/{annee}). */ + exportPdfAnnuel(annee: number, vehiculeId?: number): Observable { + return this.http.get(`${this.baseUrl}/exports/annuel/${annee}`, { + params: this.vehiculeParams(vehiculeId), + responseType: 'blob' + }); + } + + /** + * Trajets d'une journée avec géométries pour la carte + * (GET /api/trajets/journee/{date}). La date est au format ISO (yyyy-MM-dd). + */ + getTrajetsJournee(date: string): Observable { + return this.http.get(`${this.baseUrl}/trajets/journee/${date}`); + } + + /** Années disponibles pour la navigation (GET /api/annees-disponibles). */ + getAnneesDisponibles(): Observable { + return this.http.get(`${this.baseUrl}/annees-disponibles`); + } + + /** Mois disponibles d'une année (GET /api/mois-disponibles/{annee}). */ + getMoisDisponibles(annee: number): Observable { + return this.http.get(`${this.baseUrl}/mois-disponibles/${annee}`); + } + + /** Jours disponibles d'un mois (GET /api/jours-disponibles/{annee}/{mois}). */ + getJoursDisponibles(annee: number, mois: number): Observable { + return this.http.get(`${this.baseUrl}/jours-disponibles/${annee}/${mois}`); + } + + /** + * Totaux jour / mois / année partiel d'un véhicule à une date donnée + * (GET /api/totaux/{date}?vehiculeId={id}). La date est au format ISO. + */ + getTotauxJour(date: string, vehiculeId: number): Observable { + return this.http.get(`${this.baseUrl}/totaux/${date}`, { + params: new HttpParams().set('vehiculeId', vehiculeId) + }); + } + + /** Construit les paramètres de requête avec un `vehiculeId` optionnel. */ + private vehiculeParams(vehiculeId?: number): HttpParams { + let params = new HttpParams(); + if (vehiculeId != null) { + params = params.set('vehiculeId', vehiculeId); + } + return params; + } +} diff --git a/frontend/src/app/core/services/vehicule.service.ts b/frontend/src/app/core/services/vehicule.service.ts new file mode 100644 index 0000000..5a36bda --- /dev/null +++ b/frontend/src/app/core/services/vehicule.service.ts @@ -0,0 +1,39 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { CreateVehicule, Reaffectation, Vehicule, VehiculeCree } from '../models'; + +/** + * Service HTTP des véhicules (historique, véhicule courant, création, + * réaffectation des trajets). + */ +@Injectable({ providedIn: 'root' }) +export class VehiculeService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/vehicules`; + + /** Historique des véhicules (GET /api/vehicules). */ + getVehicules(): Observable { + return this.http.get(this.baseUrl); + } + + /** Véhicule courant (GET /api/vehicules/courant). */ + getVehiculeCourant(): Observable { + return this.http.get(`${this.baseUrl}/courant`); + } + + /** + * Enregistre un véhicule (POST /api/vehicules). + * La réponse indique si une réaffectation des trajets doit être proposée. + */ + createVehicule(dto: CreateVehicule): Observable { + return this.http.post(this.baseUrl, dto); + } + + /** Accepte ou refuse la réaffectation des trajets (POST /api/vehicules/reaffectation). */ + reaffecter(dto: Reaffectation): Observable { + return this.http.post(`${this.baseUrl}/reaffectation`, dto); + } +} diff --git a/frontend/src/app/features/bareme/bareme.component.html b/frontend/src/app/features/bareme/bareme.component.html new file mode 100644 index 0000000..fc3d139 --- /dev/null +++ b/frontend/src/app/features/bareme/bareme.component.html @@ -0,0 +1,118 @@ +
+

Barème kilométrique

+ +
+
+ + +
+ +
+ +
+

Tranches {{ annee() }}

+ +
+ + + + + + + + + + + + + + + + + + + + + + +
Puissance fiscale (CV)Km minKm maxCoefficient (€/km)Base (€)Actions
{{ t.puissanceFiscale }}{{ t.kmMin | number: '1.0-0' }}{{ t.kmMax === null ? 'illimité' : (t.kmMax | number: '1.0-0') }}{{ t.coefficient | number: '1.3-3' }}{{ t.base | number: '1.2-2' }} + + +
+ + +

Aucune tranche définie pour cette année.

+
+ +

{{ trancheEnEdition() !== null ? 'Modifier la tranche' : 'Ajouter une tranche' }}

+ +
+
+
+ + + + Puissance fiscale requise (≥ 1). + +
+ +
+ + +
+ +
+ + + Laisser vide pour une tranche sans borne supérieure (illimité). +
+
+ +
+
+ + +
+ +
+ + +
+
+ +

{{ erreurChevauchement() }}

+ +
+ + +
+
+
diff --git a/frontend/src/app/features/bareme/bareme.component.scss b/frontend/src/app/features/bareme/bareme.component.scss new file mode 100644 index 0000000..b266631 --- /dev/null +++ b/frontend/src/app/features/bareme/bareme.component.scss @@ -0,0 +1,122 @@ +.bareme { + padding: 1.5rem; + max-width: 960px; +} + +h1 { + font-size: 1.4rem; + margin-bottom: 1rem; +} + +h2 { + font-size: 1.1rem; + margin: 1.5rem 0 0.75rem; +} + +.annee-form { + display: flex; + align-items: flex-end; + gap: 1rem; +} + +.ligne { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.champ { + display: flex; + flex-direction: column; + margin-bottom: 1rem; +} + +.champ label { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.champ input { + padding: 0.5rem; + border: 1px solid #bbb; + border-radius: 6px; + font-size: 0.95rem; +} + +.erreur { + color: #c62828; + margin-top: 0.25rem; +} + +.actions { + margin-top: 0.5rem; + display: flex; + gap: 0.75rem; +} + +.btn-principal { + padding: 0.55rem 1.25rem; + background: #1565c0; + color: #fff; + border: 1px solid #1565c0; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; +} + +.btn-principal:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-secondaire { + padding: 0.55rem 1.25rem; + background: #fff; + color: #1565c0; + border: 1px solid #1565c0; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; +} + +.btn-lien { + background: none; + border: none; + color: #1565c0; + cursor: pointer; + padding: 0 0.5rem; + font-size: 0.9rem; +} + +.btn-lien.danger { + color: #c62828; +} + +.vide { + color: #777; +} + +.tranches { + width: 100%; + border-collapse: collapse; +} + +.tranches th, +.tranches td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #e0e0e0; +} + +.tranches th { + font-weight: 600; + background: #f5f5f5; +} + +.tranches tr.en-edition { + background: #e3f2fd; +} + +.actions-cellule { + white-space: nowrap; +} diff --git a/frontend/src/app/features/bareme/bareme.component.ts b/frontend/src/app/features/bareme/bareme.component.ts new file mode 100644 index 0000000..1cfd22b --- /dev/null +++ b/frontend/src/app/features/bareme/bareme.component.ts @@ -0,0 +1,291 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { BaremeService } from '../../core/services/bareme.service'; +import { NotificationService } from '../../core/services/notification.service'; +import { SaveTrancheBareme, TrancheBareme } from '../../core/models'; + +/** + * Saisie et édition du barème kilométrique (Exigences 13.1, 13.7). + * + * - Sélecteur d'année. + * - Tableau éditable des tranches par puissance fiscale : km min, km max, + * coefficient (€/km), base (€). + * - Ajout / modification / suppression des tranches via `BaremeService`. + * - Détection côté client du chevauchement des tranches pour une même + * (année, puissance fiscale) ; un message d'erreur est affiché et + * l'enregistrement est bloqué (le backend applique aussi la contrainte). + * - Tous les montants sont affichés avec deux décimales (pipe `number`). + */ +@Component({ + selector: 'app-bareme', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './bareme.component.html', + styleUrl: './bareme.component.scss' +}) +export class BaremeComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly baremeService = inject(BaremeService); + private readonly notification = inject(NotificationService); + + /** Formulaire de sélection d'année. */ + anneeForm!: FormGroup; + + /** Formulaire d'ajout / édition d'une tranche. */ + trancheForm!: FormGroup; + + /** Tranches du barème de l'année sélectionnée. */ + readonly tranches = signal([]); + + /** Année actuellement affichée. */ + readonly annee = signal(new Date().getFullYear()); + + /** id de la tranche en cours d'édition, ou null si mode ajout. */ + readonly trancheEnEdition = signal(null); + + /** Message de chevauchement détecté côté client (null si aucun). */ + readonly erreurChevauchement = signal(null); + + /** true pendant un appel réseau. */ + readonly chargement = signal(false); + + /** Années pour lesquelles un barème existe (pour piloter la recopie). */ + readonly anneesAvecBareme = signal([]); + + /** true pendant la recopie du dernier barème. */ + readonly recopieEnCours = signal(false); + + /** + * Année source de la recopie : année la plus récente STRICTEMENT antérieure à l'année + * affichée qui possède un barème. `null` si aucune (bouton désactivé). + */ + readonly anneeSourceRecopie = computed(() => { + const cible = this.annee(); + const anterieures = this.anneesAvecBareme().filter((a) => a < cible); + return anterieures.length ? Math.max(...anterieures) : null; + }); + + /** Le bouton de recopie est actif s'il existe un barème antérieur à recopier. */ + readonly recopiePossible = computed(() => this.anneeSourceRecopie() !== null); + + ngOnInit(): void { + this.anneeForm = this.fb.group({ + annee: [this.annee(), [Validators.required, Validators.min(2000), Validators.max(2100)]] + }); + + this.trancheForm = this.fb.group({ + puissanceFiscale: [null, [Validators.required, Validators.min(1)]], + kmMin: [0, [Validators.required, Validators.min(0)]], + kmMax: [null, [Validators.min(0)]], // vide = illimité + coefficient: [null, [Validators.required, Validators.min(0)]], + base: [0, [Validators.required, Validators.min(0)]] + }); + + this.charger(); + } + + /** Charge les tranches du barème pour l'année sélectionnée. */ + charger(): void { + const annee = Number(this.anneeForm.get('annee')?.value); + if (!annee) { + return; + } + this.annee.set(annee); + this.rafraichirAnneesAvecBareme(); + this.chargement.set(true); + this.baremeService.getBareme(annee).subscribe({ + next: (liste) => { + this.chargement.set(false); + this.tranches.set([...liste].sort( + (a, b) => a.puissanceFiscale - b.puissanceFiscale || a.kmMin - b.kmMin + )); + }, + error: () => { + this.chargement.set(false); + this.notification.error('Impossible de charger le barème.'); + } + }); + } + + /** Passe le formulaire en mode édition d'une tranche existante. */ + editer(tranche: TrancheBareme): void { + this.trancheEnEdition.set(tranche.id); + this.erreurChevauchement.set(null); + this.trancheForm.setValue({ + puissanceFiscale: tranche.puissanceFiscale, + kmMin: tranche.kmMin, + kmMax: tranche.kmMax, + coefficient: tranche.coefficient, + base: tranche.base + }); + } + + /** Annule l'édition en cours et réinitialise le formulaire. */ + annulerEdition(): void { + this.trancheEnEdition.set(null); + this.erreurChevauchement.set(null); + this.trancheForm.reset({ + puissanceFiscale: null, + kmMin: 0, + kmMax: null, + coefficient: null, + base: 0 + }); + } + + /** + * Enregistre (ajout ou mise à jour) une tranche après validation locale. + * Bloque l'enregistrement si un chevauchement est détecté (Exigence 13.1). + */ + enregistrer(): void { + if (this.trancheForm.invalid || this.chargement()) { + this.trancheForm.markAllAsTouched(); + return; + } + + const valeur = this.trancheForm.getRawValue(); + const kmMin = Number(valeur.kmMin); + // km max vide / non renseigné = tranche ILLIMITÉE (borne supérieure ouverte). + const kmMaxBrut = valeur.kmMax; + const kmMax = + kmMaxBrut === null || kmMaxBrut === undefined || kmMaxBrut === '' + ? null + : Number(kmMaxBrut); + + if (kmMax !== null && kmMax <= kmMin) { + this.erreurChevauchement.set('La borne km max doit être supérieure à la borne km min (ou vide pour « illimité »).'); + return; + } + + const dto: SaveTrancheBareme = { + annee: this.annee(), + puissanceFiscale: Number(valeur.puissanceFiscale), + kmMin, + kmMax, + coefficient: Number(valeur.coefficient), + base: Number(valeur.base) + }; + + if (this.detecteChevauchement(dto)) { + this.erreurChevauchement.set( + 'Cette tranche chevauche une tranche existante pour la même puissance fiscale.' + ); + return; + } + this.erreurChevauchement.set(null); + + const trancheEnEdition = this.trancheEnEdition(); + this.chargement.set(true); + const requete$ = + trancheEnEdition !== null + ? this.baremeService.updateTranche(trancheEnEdition, dto) + : this.baremeService.saveTranche(dto); + + requete$.subscribe({ + next: () => { + this.chargement.set(false); + this.notification.success( + trancheEnEdition !== null ? 'Tranche modifiée.' : 'Tranche ajoutée.' + ); + this.annulerEdition(); + this.charger(); + }, + error: () => { + this.chargement.set(false); + this.notification.error("Échec de l'enregistrement de la tranche."); + } + }); + } + + /** Supprime une tranche existante. */ + supprimer(tranche: TrancheBareme): void { + if (this.chargement()) { + return; + } + this.chargement.set(true); + this.baremeService.deleteTranche(tranche.id).subscribe({ + next: () => { + this.chargement.set(false); + this.notification.success('Tranche supprimée.'); + if (this.trancheEnEdition() === tranche.id) { + this.annulerEdition(); + } + this.charger(); + }, + error: () => { + this.chargement.set(false); + this.notification.error('Échec de la suppression de la tranche.'); + } + }); + } + + /** Recharge la liste des années disposant d'un barème (pour l'état du bouton de recopie). */ + private rafraichirAnneesAvecBareme(): void { + this.baremeService.getAnneesAvecBareme().subscribe({ + next: (annees) => this.anneesAvecBareme.set(annees), + error: () => this.anneesAvecBareme.set([]) + }); + } + + /** + * Recopie le dernier barème connu (année antérieure la plus récente) vers l'année affichée. + * Si l'année affichée contient déjà des tranches, demande confirmation (annule et remplace). + */ + recopierDernierBareme(): void { + const source = this.anneeSourceRecopie(); + if (source === null || this.recopieEnCours()) { + return; + } + + // Confirmation si l'année cible a déjà des tranches (écrasement). + if (this.tranches().length > 0) { + const ok = window.confirm( + `Le barème ${this.annee()} contient déjà des tranches. ` + + `Recopier le barème ${source} va les remplacer (annule et remplace). Continuer ?` + ); + if (!ok) { + return; + } + } + + this.recopieEnCours.set(true); + this.baremeService.copierDernierBareme(this.annee()).subscribe({ + next: (resultat) => { + this.recopieEnCours.set(false); + this.notification.success( + `Barème ${source} recopié vers ${this.annee()} (${resultat.tranchesCopiees} tranche(s)).` + ); + this.annulerEdition(); + this.charger(); + }, + error: () => { + this.recopieEnCours.set(false); + this.notification.error('La recopie du barème a échoué.'); + } + }); + } + + /** + * Détecte un chevauchement de la tranche candidate avec les tranches + * existantes de la même (année, puissance fiscale), en excluant la tranche + * en cours d'édition. Deux intervalles [aMin, aMax] et [bMin, bMax] se + * chevauchent si aMin <= bMax ET bMin <= aMax. + */ + private detecteChevauchement(candidate: SaveTrancheBareme): boolean { + return this.tranches().some((t) => { + if (t.id === this.trancheEnEdition()) { + return false; + } + if (t.puissanceFiscale !== candidate.puissanceFiscale) { + return false; + } + // Convention [kmMin, kmMax[ (borne max EXCLUSIVE) ; kmMax null = +infini. + // [a1,a2[ et [b1,b2[ se chevauchent ssi a1 < b2 ET b1 < a2. + const condA = t.kmMax === null || candidate.kmMin < t.kmMax; // a1 < b2 + const condB = candidate.kmMax === null || t.kmMin < candidate.kmMax; // b1 < a2 + return condA && condB; + }); + } +} diff --git a/frontend/src/app/features/carnet-adresses/carnet-adresses.component.html b/frontend/src/app/features/carnet-adresses/carnet-adresses.component.html new file mode 100644 index 0000000..fdde493 --- /dev/null +++ b/frontend/src/app/features/carnet-adresses/carnet-adresses.component.html @@ -0,0 +1,98 @@ +
+

Carnet d'adresses

+ + +
+

{{ enEdition ? 'Modifier une adresse' : 'Ajouter une adresse' }}

+ +
+ + +

+ La dénomination est obligatoire (1 à 100 caractères). +

+
+ +
+ + +

+ L'adresse est obligatoire (1 à 255 caractères). +

+ +
    +
  • + {{ s.properties.label }} +
  • +
+
+ +
+ + +
+
+ + +
+

Adresses enregistrées

+ +

Chargement…

+

+ Aucune adresse enregistrée pour le moment. +

+ +
    +
  • +
    + {{ adresse.denomination }} + {{ adresse.adresse }} + + ⚠ Coordonnées indisponibles + +
    +
    + + +
    +
  • +
+
+
diff --git a/frontend/src/app/features/carnet-adresses/carnet-adresses.component.scss b/frontend/src/app/features/carnet-adresses/carnet-adresses.component.scss new file mode 100644 index 0000000..4f67548 --- /dev/null +++ b/frontend/src/app/features/carnet-adresses/carnet-adresses.component.scss @@ -0,0 +1,163 @@ +.carnet { + padding: 1.5rem; + max-width: 720px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 2rem; + + h1 { + margin: 0; + font-size: 1.5rem; + } + + h2 { + margin: 0 0 0.75rem; + font-size: 1.1rem; + } +} + +.carnet__form { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + border: 1px solid #dcdcdc; + border-radius: 8px; + background: #fafafa; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + + label { + font-weight: 600; + font-size: 0.9rem; + } + + input { + padding: 0.5rem 0.65rem; + border: 1px solid #c4c4c4; + border-radius: 6px; + font-size: 0.95rem; + } +} + +.field--autocomplete { + position: relative; +} + +.field__error { + margin: 0; + color: #c0392b; + font-size: 0.8rem; +} + +.suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 10; + margin: 0.25rem 0 0; + padding: 0; + list-style: none; + border: 1px solid #c4c4c4; + border-radius: 6px; + background: #fff; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08); + max-height: 220px; + overflow-y: auto; +} + +.suggestions__item { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.9rem; + + &:hover { + background: #eef3fb; + } +} + +.carnet__actions { + display: flex; + gap: 0.5rem; +} + +button { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + background: #2f6fd8; + color: #fff; + font-size: 0.9rem; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.btn--secondary { + background: #e0e0e0; + color: #333; +} + +.btn--danger { + background: #c0392b; +} + +.carnet__liste ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.carnet__vide { + color: #666; + font-style: italic; +} + +.ligne { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.75rem; + border: 1px solid #e4e4e4; + border-radius: 6px; +} + +.ligne__infos { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.ligne__denomination { + font-weight: 600; +} + +.ligne__adresse { + color: #555; + font-size: 0.9rem; +} + +.ligne__warning { + color: #b8860b; + font-size: 0.8rem; +} + +.ligne__actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} diff --git a/frontend/src/app/features/carnet-adresses/carnet-adresses.component.ts b/frontend/src/app/features/carnet-adresses/carnet-adresses.component.ts new file mode 100644 index 0000000..ee61936 --- /dev/null +++ b/frontend/src/app/features/carnet-adresses/carnet-adresses.component.ts @@ -0,0 +1,257 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Subject, Subscription } from 'rxjs'; +import { + catchError, + debounceTime, + distinctUntilChanged, + filter, + switchMap, +} from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { + BAN_LIMITE_SUGGESTIONS, + BAN_MIN_CARACTERES, + BANService, +} from '../../core/services/ban.service'; +import { AdresseNommeeService } from '../../core/services/adresse-nommee.service'; +import { NotificationService } from '../../core/services/notification.service'; +import { AdresseNommee, BANFeature, SaveAdresseNommee } from '../../core/models'; + +/** Délai d'inactivité avant interrogation de la BAN (Exigence 15.3). */ +const BAN_DEBOUNCE_MS = 300; + +/** + * Carnet d'adresses nommées (Exigence 15). + * + * Liste triée par dénomination, formulaire de création / modification avec + * autocomplétion BAN (≥ 3 car., debounce 300 ms, max 5 suggestions), + * suppression avec confirmation, messages de confirmation via NotificationService. + */ +@Component({ + selector: 'app-carnet-adresses', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './carnet-adresses.component.html', + styleUrls: ['./carnet-adresses.component.scss'], +}) +export class CarnetAdressesComponent implements OnInit, OnDestroy { + private readonly fb = inject(FormBuilder); + private readonly service = inject(AdresseNommeeService); + private readonly banService = inject(BANService); + private readonly notifications = inject(NotificationService); + + /** Liste des adresses nommées, triée par dénomination. */ + readonly adresses = signal([]); + + /** Chargement de la liste en cours. */ + readonly chargement = signal(false); + + /** Enregistrement (create/update) en cours. */ + readonly enregistrement = signal(false); + + /** Identifiant de l'adresse en cours d'édition (null = création). */ + readonly editionId = signal(null); + + /** Suggestions BAN courantes pour le champ adresse. */ + readonly suggestions = signal([]); + + /** Coordonnées associées à l'adresse saisie (issues d'une sélection BAN). */ + private latitude: number | null = null; + private longitude: number | null = null; + + readonly form: FormGroup = this.fb.group({ + denomination: ['', [Validators.required, Validators.maxLength(100)]], + adresse: ['', [Validators.required, Validators.maxLength(255)]], + }); + + private readonly adresseInput$ = new Subject(); + private readonly subscriptions = new Subscription(); + + ngOnInit(): void { + this.chargerAdresses(); + this.configurerAutocompletion(); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + /** Recharge la liste des adresses nommées (triée par dénomination). */ + chargerAdresses(): void { + this.chargement.set(true); + this.subscriptions.add( + this.service + .getAdressesNommees() + .pipe( + catchError(() => { + this.notifications.error( + "Impossible de charger le carnet d'adresses.", + ); + return of([] as AdresseNommee[]); + }), + ) + .subscribe((adresses) => { + this.adresses.set( + [...adresses].sort((a, b) => + a.denomination.localeCompare(b.denomination, 'fr', { + sensitivity: 'base', + }), + ), + ); + this.chargement.set(false); + }), + ); + } + + /** Indique si l'adresse nommée a des coordonnées géocodées. */ + aCoordonnees(adresse: AdresseNommee): boolean { + return adresse.latitude != null && adresse.longitude != null; + } + + /** true en mode édition, false en mode création. */ + get enEdition(): boolean { + return this.editionId() != null; + } + + /** Passe le formulaire en mode édition, pré-rempli avec l'adresse choisie. */ + editer(adresse: AdresseNommee): void { + this.editionId.set(adresse.id); + this.latitude = adresse.latitude; + this.longitude = adresse.longitude; + this.suggestions.set([]); + this.form.setValue({ + denomination: adresse.denomination, + adresse: adresse.adresse, + }); + } + + /** Réinitialise le formulaire en mode création. */ + annuler(): void { + this.editionId.set(null); + this.latitude = null; + this.longitude = null; + this.suggestions.set([]); + this.form.reset({ denomination: '', adresse: '' }); + } + + /** Réagit à la saisie du champ adresse pour alimenter l'autocomplétion BAN. */ + onAdresseInput(valeur: string): void { + // Toute saisie manuelle invalide les coordonnées d'une sélection précédente. + this.latitude = null; + this.longitude = null; + this.adresseInput$.next(valeur ?? ''); + } + + /** Applique une suggestion BAN : remplit le champ et conserve les coordonnées. */ + choisirSuggestion(feature: BANFeature): void { + const [lon, lat] = feature.geometry.coordinates; + this.longitude = lon; + this.latitude = lat; + this.form.get('adresse')?.setValue(feature.properties.label); + this.suggestions.set([]); + } + + /** Crée ou met à jour l'adresse nommée. */ + enregistrer(): void { + if (this.form.invalid || this.enregistrement()) { + this.form.markAllAsTouched(); + return; + } + + const dto: SaveAdresseNommee = { + denomination: (this.form.value.denomination ?? '').trim(), + adresse: (this.form.value.adresse ?? '').trim(), + latitude: this.latitude, + longitude: this.longitude, + }; + + this.enregistrement.set(true); + const editionId = this.editionId(); + const requete$ = + editionId != null + ? this.service.update(editionId, dto) + : this.service.create(dto); + + const enCreation = editionId == null; + + this.subscriptions.add( + requete$.subscribe({ + next: (adresse) => { + this.enregistrement.set(false); + this.notifications.success( + enCreation + ? `Adresse « ${adresse.denomination} » créée.` + : `Adresse « ${adresse.denomination} » modifiée.`, + ); + if (!this.aCoordonnees(adresse)) { + this.notifications.warning( + `Les coordonnées de « ${adresse.denomination} » n'ont pas pu être obtenues (géocodage indisponible).`, + ); + } + this.annuler(); + this.chargerAdresses(); + }, + error: () => { + this.enregistrement.set(false); + this.notifications.error( + enCreation + ? "La création de l'adresse a échoué." + : "La modification de l'adresse a échoué.", + ); + }, + }), + ); + } + + /** Supprime une adresse nommée après confirmation. */ + supprimer(adresse: AdresseNommee): void { + const confirme = window.confirm( + `Supprimer l'adresse « ${adresse.denomination} » du carnet ?`, + ); + if (!confirme) { + return; + } + + this.subscriptions.add( + this.service.delete(adresse.id).subscribe({ + next: () => { + this.notifications.success( + `Adresse « ${adresse.denomination} » supprimée.`, + ); + if (this.editionId() === adresse.id) { + this.annuler(); + } + this.chargerAdresses(); + }, + error: () => { + this.notifications.error("La suppression de l'adresse a échoué."); + }, + }), + ); + } + + private configurerAutocompletion(): void { + this.subscriptions.add( + this.adresseInput$ + .pipe( + debounceTime(BAN_DEBOUNCE_MS), + distinctUntilChanged(), + filter((terme) => (terme ?? '').trim().length >= BAN_MIN_CARACTERES), + switchMap((terme) => + this.banService.searchAdresses(terme).pipe(catchError(() => of([]))), + ), + ) + .subscribe((features) => { + this.suggestions.set(features.slice(0, BAN_LIMITE_SUGGESTIONS)); + }), + ); + } +} diff --git a/frontend/src/app/features/carte-trajets/carte-trajets.component.html b/frontend/src/app/features/carte-trajets/carte-trajets.component.html new file mode 100644 index 0000000..ffdcb35 --- /dev/null +++ b/frontend/src/app/features/carte-trajets/carte-trajets.component.html @@ -0,0 +1,20 @@ +
+ +
+ + +
+

Aucun trajet disponible pour cette journée.

+
+ + +
+

Chargement de la carte…

+
+ + +
+

{{ messageErreur() || 'La carte ne peut pas être chargée.' }}

+ +
+
diff --git a/frontend/src/app/features/carte-trajets/carte-trajets.component.scss b/frontend/src/app/features/carte-trajets/carte-trajets.component.scss new file mode 100644 index 0000000..29a95a7 --- /dev/null +++ b/frontend/src/app/features/carte-trajets/carte-trajets.component.scss @@ -0,0 +1,73 @@ +:host { + display: block; + width: 100%; + height: 460px; +} + +.carte-trajets { + position: relative; + width: 100%; + height: 460px; + min-height: 460px; + + &__map { + width: 100%; + height: 100%; + min-height: 460px; + z-index: 0; + + &--cache { + visibility: hidden; + } + } + + &__overlay { + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + padding: 6px 12px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + font-size: 0.85rem; + color: #333; + pointer-events: none; + + p { + margin: 0; + } + } + + &__erreur { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px; + text-align: center; + background: #f5f5f5; + border: 1px solid #e0e0e0; + } + + &__erreur-message { + margin: 0; + color: #b71c1c; + } + + &__retry { + padding: 6px 16px; + border: none; + border-radius: 4px; + background: #1976d2; + color: #fff; + cursor: pointer; + + &:hover { + background: #1565c0; + } + } +} diff --git a/frontend/src/app/features/carte-trajets/carte-trajets.component.ts b/frontend/src/app/features/carte-trajets/carte-trajets.component.ts new file mode 100644 index 0000000..3b4499a --- /dev/null +++ b/frontend/src/app/features/carte-trajets/carte-trajets.component.ts @@ -0,0 +1,450 @@ +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, + inject, + signal +} from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { TrajetCarte } from '../../core/models'; +import { TrajetService } from '../../core/services'; + +/** + * Palette de couleurs distinctes attribuée aux tracés successifs d'une journée. + * On cycle sur la palette si le nombre de trajets la dépasse. + */ +const COULEURS_TRACES = [ + '#1976d2', // bleu + '#e53935', // rouge + '#43a047', // vert + '#fb8c00', // orange + '#8e24aa', // violet + '#00897b', // teal + '#c0ca33', // lime + '#6d4c41' // brun +]; + +/** Centre par défaut (France métropolitaine) et zoom lorsqu'aucun trajet n'est disponible. */ +const CENTRE_DEFAUT: [number, number] = [46.6, 2.4]; +const ZOOM_DEFAUT = 6; + +/** État de rendu de la carte, pour piloter les messages affichés dans le template. */ +type EtatCarte = 'chargement' | 'ok' | 'vide' | 'erreur'; + +/** + * `CarteTrajetsComponent` — visualisation Leaflet des trajets d'une journée. + * + * Intègre Leaflet avec les tuiles OpenStreetMap et affiche, pour la date fournie, + * un tracé par trajet : + * - tracé GeoJSON (LineString) issu du Cache_Adresses quand la géométrie existe + * (Exigence 8.2) ; + * - à défaut, une ligne droite pointillée entre départ et arrivée signalant un + * itinéraire approximatif (Exigence 8.5). + * + * Un marqueur de départ et un marqueur d'arrivée sont posés pour chaque trajet, et + * une infobulle (survol/clic) présente l'adresse de départ, l'adresse d'arrivée et + * la distance (Exigence 8.3). La carte s'ajuste aux trajets du jour, ou se centre + * sur une position par défaut si la journée est vide (Exigences 8.1, 8.4). Le zoom + * et le déplacement restent interactifs (Exigence 8.6). En cas d'échec de Leaflet + * ou des tuiles OSM, un message d'erreur avec bouton « Réessayer » est proposé + * (Exigence 8.8). + * + * On peut fournir soit une `date` (le composant charge alors les trajets via + * `TrajetService.getTrajetsJournee`), soit directement une liste de `trajets`. + */ +@Component({ + selector: 'app-carte-trajets', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './carte-trajets.component.html', + styleUrl: './carte-trajets.component.scss' +}) +export class CarteTrajetsComponent implements AfterViewInit, OnChanges, OnDestroy { + private readonly trajetService = inject(TrajetService); + private readonly cdr = inject(ChangeDetectorRef); + + /** Date de la journée à visualiser, au format ISO (yyyy-MM-dd). */ + @Input() date: string | null = null; + + /** + * Trajets à afficher directement. Si fourni, prime sur le chargement par `date`. + * Permet au conteneur (ConsultationJourComponent) de partager les trajets déjà + * chargés et filtrés par véhicule. + */ + @Input() trajets: TrajetCarte[] | null = null; + + /** + * Id du trajet actuellement survolé dans la liste (ou `null`). Quand il change, + * le tracé correspondant est mis en évidence (weight/opacité renforcés) sans + * re-rendre toute la carte ; les autres tracés sont légèrement estompés. + */ + @Input() trajetSurvoleId: number | null = null; + + @ViewChild('mapContainer') private mapContainer?: ElementRef; + + /** État courant piloté dans le template. */ + readonly etat = signal('chargement'); + readonly messageErreur = signal(''); + + /** Référence Leaflet, chargée dynamiquement pour isoler les échecs d'import. */ + private L: typeof import('leaflet') | null = null; + private map: import('leaflet').Map | null = null; + private layerGroup: import('leaflet').LayerGroup | null = null; + private tileLayer: import('leaflet').TileLayer | null = null; + + private vueInitialisee = false; + private trajetsCourants: TrajetCarte[] = []; + private chargementSub?: Subscription; + private tuilesEnErreur = false; + + /** + * Référence id de trajet → polyline rendue, conservée entre deux rendus pour + * ajuster les styles au survol sans re-rendre toute la carte. + */ + private readonly polylignesParTrajet = new Map(); + + ngAfterViewInit(): void { + this.vueInitialisee = true; + void this.initialiserCarte(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!this.vueInitialisee || !this.map) { + return; + } + if (changes['trajets'] || changes['date']) { + this.chargerDonnees(); + } + // Survol : n'ajuste que les styles des polylignes existantes (pas de re-rendu). + if (changes['trajetSurvoleId'] && !changes['trajets'] && !changes['date']) { + this.appliquerSurbrillanceSurvol(); + } + } + + ngOnDestroy(): void { + this.chargementSub?.unsubscribe(); + this.detruireCarte(); + } + + /** Recharge la carte après un échec (bouton « Réessayer », Exigence 8.8). */ + reessayer(): void { + this.etat.set('chargement'); + this.messageErreur.set(''); + this.tuilesEnErreur = false; + void this.initialiserCarte(); + } + + /** + * Charge dynamiquement Leaflet puis initialise la carte et la couche de tuiles. + * Toute défaillance (import, création de carte, tuiles) bascule en état d'erreur. + */ + private async initialiserCarte(): Promise { + if (!this.mapContainer) { + this.basculerErreur('Conteneur de carte introuvable.'); + return; + } + + this.detruireCarte(); + + try { + if (!this.L) { + this.L = await import('leaflet'); + } + } catch { + this.basculerErreur('La librairie de carte n\'a pas pu être chargée.'); + return; + } + + const L = this.L; + try { + this.map = L.map(this.mapContainer.nativeElement, { + center: CENTRE_DEFAUT, + zoom: ZOOM_DEFAUT, + zoomControl: true + }); + + this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© contributeurs OpenStreetMap' + }); + + // Une erreur de chargement des tuiles (réseau/OSM indisponible) est signalée. + this.tileLayer.on('tileerror', () => { + if (!this.tuilesEnErreur) { + this.tuilesEnErreur = true; + this.basculerErreur('Les tuiles de la carte (OpenStreetMap) sont indisponibles.'); + } + }); + + this.tileLayer.addTo(this.map); + this.layerGroup = L.layerGroup().addTo(this.map); + } catch { + this.basculerErreur('La carte n\'a pas pu être initialisée.'); + return; + } + + this.chargerDonnees(); + } + + /** Résout la source des trajets (Input direct ou chargement par date). */ + private chargerDonnees(): void { + this.chargementSub?.unsubscribe(); + + if (this.trajets != null) { + this.trajetsCourants = this.trajets; + this.rendreTrajets(); + return; + } + + if (!this.date) { + this.trajetsCourants = []; + this.rendreTrajets(); + return; + } + + this.etat.set('chargement'); + this.cdr.markForCheck(); + + this.chargementSub = this.trajetService.getTrajetsJournee(this.date).subscribe({ + next: (trajets) => { + this.trajetsCourants = trajets ?? []; + this.rendreTrajets(); + }, + error: () => { + this.basculerErreur('Impossible de charger les trajets de la journée.'); + } + }); + } + + /** + * Dessine les tracés, marqueurs et infobulles, puis ajuste le cadrage. + * Journée vide → carte interactive centrée par défaut + message (Exigences 8.1, 8.4). + */ + private rendreTrajets(): void { + if (!this.L || !this.map || !this.layerGroup) { + return; + } + if (this.tuilesEnErreur) { + return; + } + + const L = this.L; + this.layerGroup.clearLayers(); + this.polylignesParTrajet.clear(); + + if (this.trajetsCourants.length === 0) { + this.map.setView(CENTRE_DEFAUT, ZOOM_DEFAUT); + this.etat.set('vide'); + this.cdr.markForCheck(); + this.rafraichirTaille(); + return; + } + + const bornes = L.latLngBounds([]); + + this.trajetsCourants.forEach((trajet, index) => { + const couleur = COULEURS_TRACES[index % COULEURS_TRACES.length]; + const points = this.pointsGeometrie(trajet); + const infobulle = this.contenuInfobulle(trajet); + + if (points && points.length >= 2) { + // Tracé d'itinéraire routier réel (GeoJSON), Exigence 8.2. + const ligne = L.polyline(points, { color: couleur, weight: 4, opacity: 0.85 }); + ligne.bindPopup(infobulle); + ligne.bindTooltip(infobulle, { sticky: true }); + ligne.addTo(this.layerGroup!); + this.polylignesParTrajet.set(trajet.id, ligne); + points.forEach((p) => bornes.extend(p)); + this.ajouterMarqueurs(L, points[0], points[points.length - 1], trajet, infobulle, couleur, bornes); + } else { + // Fallback : ligne droite pointillée entre départ et arrivée, Exigence 8.5. + const depart = this.pointDepart(trajet); + const arrivee = this.pointArrivee(trajet); + if (depart && arrivee) { + const ligne = L.polyline([depart, arrivee], { + color: couleur, + weight: 3, + opacity: 0.7, + dashArray: '8, 8' + }); + const contenu = `${infobulle}
Itinéraire approximatif
`; + ligne.bindPopup(contenu); + ligne.bindTooltip(contenu, { sticky: true }); + ligne.addTo(this.layerGroup!); + this.polylignesParTrajet.set(trajet.id, ligne); + bornes.extend(depart); + bornes.extend(arrivee); + this.ajouterMarqueurs(L, depart, arrivee, trajet, contenu, couleur, bornes); + } + // Si aucune coordonnée n'est disponible, le trajet ne peut pas être tracé. + } + }); + + if (bornes.isValid()) { + this.map.fitBounds(bornes, { padding: [30, 30], maxZoom: 15 }); + } else { + // Trajets présents mais sans aucune coordonnée exploitable. + this.map.setView(CENTRE_DEFAUT, ZOOM_DEFAUT); + } + + this.etat.set('ok'); + this.cdr.markForCheck(); + this.rafraichirTaille(); + // Réapplique la surbrillance de survol éventuelle sur les nouvelles polylignes. + this.appliquerSurbrillanceSurvol(); + } + + /** + * Met en évidence la polyline du trajet survolé (weight/opacité renforcés) et + * estompe légèrement les autres. Sans survol, restaure les styles nominaux. + * N'agit que sur les styles des polylignes existantes (pas de re-rendu carte). + */ + private appliquerSurbrillanceSurvol(): void { + const survole = this.trajetSurvoleId; + this.polylignesParTrajet.forEach((ligne, id) => { + if (survole == null) { + ligne.setStyle({ weight: 4, opacity: 0.85 }); + } else if (id === survole) { + ligne.setStyle({ weight: 7, opacity: 1 }); + ligne.bringToFront(); + } else { + ligne.setStyle({ weight: 3, opacity: 0.35 }); + } + }); + } + + /** + * Pose un marqueur simple (petite pastille de la couleur du trajet, sans texte) au + * départ et à l'arrivée. On ne numérote plus les points : le survol d'un trajet (liste + * ou carte) suffit à l'identifier (surbrillance + infobulle), et cela évite que le + * marqueur de départ (souvent confondu avec l'arrivée précédente) n'en masque un autre. + */ + private ajouterMarqueurs( + L: typeof import('leaflet'), + depart: [number, number], + arrivee: [number, number], + trajet: TrajetCarte, + infobulle: string, + couleur: string, + bornes: import('leaflet').LatLngBounds + ): void { + const icone = this.creerIcone(L, couleur); + + L.marker(depart, { icon: icone, title: `Départ : ${trajet.adresseDepart}` }) + .bindPopup(infobulle) + .addTo(this.layerGroup!); + L.marker(arrivee, { icon: icone, title: `Arrivée : ${trajet.adresseArrivee}` }) + .bindPopup(infobulle) + .addTo(this.layerGroup!); + + bornes.extend(depart); + bornes.extend(arrivee); + } + + /** + * Crée une petite pastille « divIcon » de la couleur du trajet (sans texte), pour + * éviter le problème de chemin d'accès aux images de marqueur PNG au bundling. + */ + private creerIcone( + L: typeof import('leaflet'), + couleur: string + ): import('leaflet').DivIcon { + return L.divIcon({ + className: 'carte-marqueur', + html: ``, + iconSize: [14, 14], + iconAnchor: [7, 7], + popupAnchor: [0, -8] + }); + } + + /** + * Extrait les points [lat, lng] depuis la géométrie GeoJSON (LineString) du trajet. + * GeoJSON encode les positions en [lng, lat], inversées ici pour Leaflet. + */ + private pointsGeometrie(trajet: TrajetCarte): [number, number][] | null { + if (!trajet.geometrieJson) { + return null; + } + try { + const geo = JSON.parse(trajet.geometrieJson); + const coords = geo?.type === 'LineString' ? geo.coordinates : geo?.geometry?.coordinates; + if (!Array.isArray(coords)) { + return null; + } + const points = coords + .filter((c: unknown) => Array.isArray(c) && c.length >= 2) + .map((c: [number, number]) => [c[1], c[0]] as [number, number]); + return points.length >= 2 ? points : null; + } catch { + return null; + } + } + + private pointDepart(trajet: TrajetCarte): [number, number] | null { + return trajet.departLatitude != null && trajet.departLongitude != null + ? [trajet.departLatitude, trajet.departLongitude] + : null; + } + + private pointArrivee(trajet: TrajetCarte): [number, number] | null { + return trajet.arriveeLatitude != null && trajet.arriveeLongitude != null + ? [trajet.arriveeLatitude, trajet.arriveeLongitude] + : null; + } + + /** Contenu HTML de l'infobulle : départ, arrivée, distance (Exigence 8.3). */ + private contenuInfobulle(trajet: TrajetCarte): string { + const distance = + trajet.distance != null ? `${trajet.distance.toFixed(2)} km` : 'distance non calculée'; + return ` +
+
Départ : ${this.echapper(trajet.adresseDepart)}
+
Arrivée : ${this.echapper(trajet.adresseArrivee)}
+
Distance : ${distance}
+
`; + } + + /** Échappe le texte utilisateur inséré dans le HTML de l'infobulle. */ + private echapper(valeur: string): string { + return (valeur ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + /** Force Leaflet à recalculer la taille du conteneur (utile en layout dynamique). */ + private rafraichirTaille(): void { + if (!this.map) { + return; + } + setTimeout(() => this.map?.invalidateSize(), 0); + } + + private basculerErreur(message: string): void { + this.messageErreur.set(message); + this.etat.set('erreur'); + this.detruireCarte(); + this.cdr.markForCheck(); + } + + private detruireCarte(): void { + this.layerGroup = null; + this.tileLayer = null; + if (this.map) { + this.map.remove(); + this.map = null; + } + } +} diff --git a/frontend/src/app/features/consultation-jour/consultation-jour.component.html b/frontend/src/app/features/consultation-jour/consultation-jour.component.html new file mode 100644 index 0000000..b3783d6 --- /dev/null +++ b/frontend/src/app/features/consultation-jour/consultation-jour.component.html @@ -0,0 +1,137 @@ +
+ +
+ + +
+ + + + {{ dateFormatee }} + + + +
+ + +
+ +
+ +
+
+ +
+ + @if (erreur()) { + + } + + @if (chargement()) { +

Chargement des trajets…

+ } + +
+ @if (!chargement() && trajets().length === 0) { +

Aucun trajet pour cette journée.

+ } + +
+
+ + + +
+ + + @if (formulaire(); as f) { + + } +
diff --git a/frontend/src/app/features/consultation-jour/consultation-jour.component.scss b/frontend/src/app/features/consultation-jour/consultation-jour.component.scss new file mode 100644 index 0000000..38d8d3e --- /dev/null +++ b/frontend/src/app/features/consultation-jour/consultation-jour.component.scss @@ -0,0 +1,183 @@ +:host { + display: block; +} + +.consultation { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + max-width: 1400px; + margin: 0 auto; + + &__barre { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 1rem 1.5rem; + padding: 1rem; + background: #f7f8fa; + border: 1px solid #e3e6ea; + border-radius: 8px; + } + + &__navigation { + display: flex; + align-items: center; + gap: 0.75rem; + } + + &__nav-bouton { + padding: 0.4rem 0.9rem; + border: 1px solid #c7ccd3; + border-radius: 6px; + background: #fff; + cursor: pointer; + font-size: 0.9rem; + + &:hover:not(:disabled) { + background: #eef1f5; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + &__date { + min-width: 6.5rem; + text-align: center; + font-variant-numeric: tabular-nums; + font-weight: 600; + } + + &__vehicule { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-left: auto; + } + + &__vehicule-libelle { + font-size: 0.8rem; + color: #5a616b; + } + + &__vehicule-select { + padding: 0.4rem 0.6rem; + border: 1px solid #c7ccd3; + border-radius: 6px; + background: #fff; + min-width: 12rem; + } + + // --- Corps deux colonnes --- + &__corps { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(380px, 520px); + gap: 1.5rem; + align-items: start; + } + + &__principal { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; + } + + &__formulaire { + padding: 1rem; + border: 1px solid #e3e6ea; + border-radius: 8px; + background: #fff; + } + + &__actions { + display: flex; + justify-content: flex-end; + } + + &__ajouter { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + background: #1976d2; + color: #fff; + cursor: pointer; + font-size: 0.9rem; + + &:hover { + background: #135ba1; + } + } + + &__erreur { + color: #b3261e; + margin: 0; + } + + &__chargement { + color: #5a616b; + margin: 0; + } + + // Zone des trajets : scroll vertical quand la liste est haute (Exigence 12.6). + &__liste { + max-height: 55vh; + overflow-y: auto; + border: 1px solid #e3e6ea; + border-radius: 8px; + background: #fff; + } + + &__vide { + padding: 1rem; + margin: 0; + color: #5a616b; + } + + // Panneau latéral toujours visible (sticky) : totaux + carte (Exigence 12.9). + &__panneau { + position: sticky; + top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + } +} + +// --- Modale du formulaire de trajet (réutilise le motif d'overlay du ConfirmDialog) --- +.modale-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: flex-start; + justify-content: center; + background: rgba(15, 23, 42, 0.45); + z-index: 1000; + padding: 2rem 1rem; + overflow-y: auto; +} + +.modale-panneau { + background: #fff; + border-radius: 10px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25); + max-width: 640px; + width: 100%; + padding: 1.5rem; + margin: auto; +} + +// Repli en une colonne sur petits écrans : le panneau redevient statique. +@media (max-width: 900px) { + .consultation__corps { + grid-template-columns: 1fr; + } + + .consultation__panneau { + position: static; + } +} diff --git a/frontend/src/app/features/consultation-jour/consultation-jour.component.ts b/frontend/src/app/features/consultation-jour/consultation-jour.component.ts new file mode 100644 index 0000000..dc5f1f0 --- /dev/null +++ b/frontend/src/app/features/consultation-jour/consultation-jour.component.ts @@ -0,0 +1,349 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; +import { Subscription, forkJoin, of } from 'rxjs'; +import { catchError, finalize } from 'rxjs/operators'; + +import { ResumeMensuel, Trajet, TrajetCarte, Vehicule } from '../../core/models'; +import { TrajetService } from '../../core/services/trajet.service'; +import { VehiculeService } from '../../core/services/vehicule.service'; +import { CarteTrajetsComponent } from '../carte-trajets/carte-trajets.component'; +import { PanneauTotauxComponent } from '../panneau-totaux/panneau-totaux.component'; +import { SelecteurTemporelComponent } from '../navigation-temporelle/selecteur-temporel.component'; +import { TrajetFormComponent, TrajetInsertionPrefill } from '../trajet-form/trajet-form.component'; +import { + TrajetInsertionContext, + TrajetListComponent +} from '../trajet-list/trajet-list.component'; + +/** Mode courant du formulaire embarqué, ou `null` si le formulaire est masqué. */ +type FormulaireMode = + | { type: 'create' } + | { type: 'edit'; trajet: Trajet } + | { type: 'insert'; prefill: TrajetInsertionPrefill }; + +/** + * `ConsultationJourComponent` — layout principal de consultation d'une journée. + * + * Disposition en deux colonnes (Exigences 12.6, 12.9) : + * - Zone principale (gauche/centre) : `SelecteurTemporelComponent`, le + * `TrajetFormComponent` (création / édition / insertion) et la + * `TrajetListComponent` des trajets du jour, avec scroll vertical lorsque la + * liste est haute. + * - Panneau latéral sticky (droite) : `PanneauTotauxComponent` en haut, + * `CarteTrajetsComponent` en dessous. + * + * Sélecteur de Véhicule (Exigences 12.17, 12.18) : par défaut le Véhicule_Courant ; + * changer de véhicule filtre la liste, les totaux et la carte. + * + * Navigation jour ±1 (Exigences 12.10–12.13) : « Jour précédent » / « Jour + * suivant » ; « Jour suivant » est désactivé si le jour suivant est postérieur à + * la date courante. + * + * À chaque changement de date (sélecteur ou ±1 jour) ou de véhicule, la liste, les + * totaux et la carte sont rafraîchis sans rechargement complet de la page, dans un + * délai < 1000 ms (Exigences 12.14, 12.20). Les totaux et la carte sont pilotés par + * `@Input` ; seule la liste (et donc la carte partagée) est rechargée ici. + * + * _Requirements: 12.6, 12.9, 12.10, 12.11, 12.12, 12.13, 12.14, 12.17, 12.18, 12.20_ + */ +@Component({ + selector: 'app-consultation-jour', + standalone: true, + imports: [ + CommonModule, + SelecteurTemporelComponent, + TrajetListComponent, + TrajetFormComponent, + PanneauTotauxComponent, + CarteTrajetsComponent + ], + templateUrl: './consultation-jour.component.html', + styleUrl: './consultation-jour.component.scss' +}) +export class ConsultationJourComponent implements OnInit, OnDestroy { + private readonly trajetService = inject(TrajetService); + private readonly vehiculeService = inject(VehiculeService); + private readonly cdr = inject(ChangeDetectorRef); + + /** Date sélectionnée au format ISO (`yyyy-MM-dd`). `null` tant qu'aucune date n'est choisie. */ + readonly date = signal(null); + + /** Véhicules disponibles pour le sélecteur. */ + readonly vehicules = signal([]); + + /** Véhicule sélectionné ; par défaut le Véhicule_Courant (Exigence 12.17). */ + readonly vehiculeId = signal(null); + + /** Trajets du jour (filtrés par véhicule) affichés dans la liste. */ + readonly trajets = signal([]); + + /** Trajets du jour avec géométries (filtrés par véhicule) transmis à la carte. */ + readonly trajetsCarte = signal([]); + + /** Formulaire embarqué (création / édition / insertion) ou `null` s'il est masqué. */ + readonly formulaire = signal(null); + + /** Id du trajet survolé dans la liste, propagé à la carte pour surbrillance. */ + readonly trajetSurvoleId = signal(null); + + /** Chargement de la journée en cours. */ + readonly chargement = signal(false); + + /** Message d'erreur de chargement de la journée. */ + readonly erreur = signal(null); + + private jourSub?: Subscription; + private vehiculesSub?: Subscription; + + ngOnInit(): void { + // Journée courante par défaut. + this.date.set(this.isoAujourdHui()); + this.chargerVehicules(); + } + + ngOnDestroy(): void { + this.jourSub?.unsubscribe(); + this.vehiculesSub?.unsubscribe(); + } + + // --- Sélection date / véhicule --- + + /** Réagit à la sélection d'une date via le Sélecteur_Temporel (Exigence 12.14). */ + onDateSelectionnee(dateIso: string): void { + this.date.set(dateIso); + this.formulaire.set(null); + this.rafraichirJournee(); + } + + /** Réagit au changement de véhicule dans le sélecteur (Exigences 12.18, 12.20). */ + onVehiculeChange(valeur: string): void { + const id = Number(valeur); + this.vehiculeId.set(Number.isNaN(id) ? null : id); + this.formulaire.set(null); + this.rafraichirJournee(); + } + + // --- Navigation jour ±1 (Exigences 12.10–12.13) --- + + /** Sélectionne la journée précédente et rafraîchit l'affichage (Exigence 12.11). */ + jourPrecedent(): void { + this.date.set(this.decalerJour(this.dateCourante(), -1)); + this.formulaire.set(null); + this.rafraichirJournee(); + } + + /** Sélectionne la journée suivante et rafraîchit l'affichage (Exigence 12.12). */ + jourSuivant(): void { + if (this.jourSuivantDesactive) { + return; + } + this.date.set(this.decalerJour(this.dateCourante(), 1)); + this.formulaire.set(null); + this.rafraichirJournee(); + } + + /** « Jour suivant » désactivé si le jour suivant est postérieur à aujourd'hui (Exigence 12.13). */ + get jourSuivantDesactive(): boolean { + const suivant = this.decalerJour(this.dateCourante(), 1); + return suivant > this.isoAujourdHui(); + } + + /** Date courante affichée au format JJ/MM/AAAA (lisible), ou `—` si absente. */ + get dateFormatee(): string { + const date = this.date(); + if (!date) { + return '—'; + } + const [annee, mois, jour] = date.slice(0, 10).split('-'); + return `${jour}/${mois}/${annee}`; + } + + // --- Formulaire (création / édition / insertion) --- + + /** Ouvre le formulaire en mode création. */ + ouvrirCreation(): void { + this.formulaire.set({ type: 'create' }); + } + + /** Ouvre le formulaire en mode édition (Output `edit` de la liste). */ + onEdit(trajet: Trajet): void { + this.formulaire.set({ type: 'edit', trajet }); + } + + /** Ouvre le formulaire en mode insertion (Output `insertAfter` / `insertBetween`). */ + onInsert(contexte: TrajetInsertionContext): void { + this.formulaire.set({ type: 'insert', prefill: contexte.prefill }); + } + + /** Ferme le formulaire (annulation utilisateur). */ + fermerFormulaire(): void { + this.formulaire.set(null); + } + + /** + * Clic sur l'overlay de la modale : ferme le formulaire comme une annulation. + * On ne ferme que si le clic vise réellement l'overlay (et non le panneau). + */ + onOverlayClick(event: MouseEvent): void { + if (event.target === event.currentTarget) { + this.fermerFormulaire(); + } + } + + /** Un trajet a été créé ou modifié : ferme le formulaire et recharge la journée. */ + onFormEnregistre(): void { + this.formulaire.set(null); + this.rafraichirJournee(); + } + + /** La liste a changé (suppression, réordonnancement) : recharge la journée (Exigence 12.14). */ + onListeChangee(): void { + this.rafraichirJournee(); + } + + /** Survol d'une ligne de trajet : met en évidence le tracé correspondant sur la carte. */ + onSurvolTrajet(id: number | null): void { + this.trajetSurvoleId.set(id); + } + + // --- Chargement des données --- + + /** Charge les véhicules et sélectionne le véhicule courant par défaut (Exigence 12.17). */ + private chargerVehicules(): void { + this.vehiculesSub = forkJoin({ + vehicules: this.vehiculeService.getVehicules().pipe(catchError(() => of([] as Vehicule[]))), + courant: this.vehiculeService.getVehiculeCourant().pipe(catchError(() => of(null))) + }).subscribe(({ vehicules, courant }) => { + this.vehicules.set(vehicules); + this.vehiculeId.set(courant?.id ?? vehicules[0]?.id ?? null); + this.rafraichirJournee(); + }); + } + + /** + * Recharge la liste et la carte de la journée pour le véhicule sélectionné, sans + * rechargement complet de la page (Exigences 12.14, 12.20). Le Panneau_Totaux se + * met à jour de lui-même via ses `@Input` `date` / `vehiculeId`. + * + * La liste est dérivée du résumé mensuel filtré par jour et véhicule (`Trajet[]`), + * et la carte des trajets de la journée avec géométries (`TrajetCarte[]`), filtrés + * par le même véhicule. + */ + private rafraichirJournee(): void { + this.jourSub?.unsubscribe(); + + const date = this.date(); + if (!date) { + this.trajets.set([]); + this.trajetsCarte.set([]); + return; + } + + const vehiculeId = this.vehiculeId() ?? undefined; + const annee = this.anneeDe(date); + const mois = this.moisDe(date); + + this.chargement.set(true); + this.erreur.set(null); + + // Chaque observable du forkJoin porte son propre `catchError` afin que le + // forkJoin complète TOUJOURS (il n'émet qu'une fois tous les flux complétés). + // Ainsi un échec réseau sur le résumé ou la carte ne fige plus l'UI sur + // « Chargement des trajets… ». On mémorise l'erreur du résumé pour l'afficher. + let resumeEnErreur = false; + this.jourSub = forkJoin({ + resume: this.trajetService.getResumeMensuel(annee, mois, vehiculeId).pipe( + catchError(() => { + resumeEnErreur = true; + return of({ annee, mois, trajets: [], totalKm: 0 } as ResumeMensuel); + }) + ), + carte: this.trajetService + .getTrajetsJournee(date) + .pipe(catchError(() => of([] as TrajetCarte[]))) + }) + .pipe( + finalize(() => { + this.chargement.set(false); + // Déclenche la détection de changement même si `finalize` s'exécute + // en dehors du cycle Angular (ex. complétion synchrone / erreur). + this.cdr.markForCheck(); + }) + ) + .subscribe({ + next: ({ resume, carte }) => { + // Liste : trajets du mois restreints au jour sélectionné. + this.trajets.set((resume.trajets ?? []).filter((t) => this.memeJour(t.date, date))); + // Carte : trajets du jour filtrés par le véhicule sélectionné. + this.trajetsCarte.set( + this.vehiculeId() == null + ? carte + : this.filtrerCarteParVehicule(carte) + ); + this.erreur.set(resumeEnErreur + ? 'Impossible de charger les trajets de la journée.' + : null); + // Force la mise à jour immédiate de la liste, des totaux et de la carte + // après une navigation Jour ± 1, sans attendre une autre interaction. + this.cdr.markForCheck(); + }, + error: () => { + // Ne devrait pas survenir (catchError sur chaque source), mais on reste + // robuste : on affiche l'erreur et on rafraîchit la vue. + this.trajets.set([]); + this.trajetsCarte.set([]); + this.erreur.set('Impossible de charger les trajets de la journée.'); + this.cdr.markForCheck(); + } + }); + } + + /** + * Restreint les trajets carte au véhicule sélectionné en s'appuyant sur les ids + * de la liste déjà filtrée par véhicule (le DTO carte ne porte pas le vehiculeId). + */ + private filtrerCarteParVehicule(carte: TrajetCarte[]): TrajetCarte[] { + const idsAutorises = new Set(this.trajets().map((t) => t.id)); + return carte.filter((c) => idsAutorises.has(c.id)); + } + + // --- Utilitaires de date --- + + /** Date courante de travail (fallback sur aujourd'hui si aucune sélection). */ + private dateCourante(): string { + return this.date() ?? this.isoAujourdHui(); + } + + /** Vrai si deux dates ISO désignent le même jour (tolère un suffixe horaire). */ + private memeJour(a: string, b: string): boolean { + return (a ?? '').slice(0, 10) === (b ?? '').slice(0, 10); + } + + /** Décale une date ISO `yyyy-MM-dd` de `delta` jours, sans dérive de fuseau. */ + private decalerJour(dateIso: string, delta: number): string { + const [annee, mois, jour] = dateIso.slice(0, 10).split('-').map(Number); + const d = new Date(annee, mois - 1, jour); + d.setDate(d.getDate() + delta); + return this.versIso(d); + } + + /** Date du jour au format ISO local `yyyy-MM-dd`. */ + private isoAujourdHui(): string { + return this.versIso(new Date()); + } + + /** Formate une Date en ISO local `yyyy-MM-dd`. */ + private versIso(d: Date): string { + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${mm}-${dd}`; + } + + private anneeDe(dateIso: string): number { + return Number(dateIso.slice(0, 4)); + } + + private moisDe(dateIso: string): number { + return Number(dateIso.slice(5, 7)); + } +} diff --git a/frontend/src/app/features/montants-regles/montants-regles.component.html b/frontend/src/app/features/montants-regles/montants-regles.component.html new file mode 100644 index 0000000..c9c44e9 --- /dev/null +++ b/frontend/src/app/features/montants-regles/montants-regles.component.html @@ -0,0 +1,75 @@ +
+

Montants réglés mensuels

+ +
+
+ + +
+ +
+ + +
+ + +
+ +

+ Le montant prévisionnel (80% du barème) est une simple indication. Seul le montant réglé + saisi fait foi. +

+ + + + + + + + + + + + + + + + + + +
MoisMontant réglé (€)Prévisionnel 80% (€)Action
{{ ligne.libelle }} + + + + {{ ligne.previsionnel | number: '1.2-2' }} + + — + + +
+ + +

Sélectionnez une année et un véhicule pour saisir les montants.

+
+
diff --git a/frontend/src/app/features/montants-regles/montants-regles.component.scss b/frontend/src/app/features/montants-regles/montants-regles.component.scss new file mode 100644 index 0000000..a26d771 --- /dev/null +++ b/frontend/src/app/features/montants-regles/montants-regles.component.scss @@ -0,0 +1,103 @@ +.montants-regles { + padding: 1.5rem; + max-width: 720px; +} + +h1 { + font-size: 1.4rem; + margin-bottom: 1rem; +} + +.selection-form { + display: flex; + align-items: flex-end; + gap: 1rem; + flex-wrap: wrap; +} + +.champ { + display: flex; + flex-direction: column; + margin-bottom: 1rem; +} + +.champ label { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.champ input, +.champ select { + padding: 0.5rem; + border: 1px solid #bbb; + border-radius: 6px; + font-size: 0.95rem; +} + +.indication { + color: #555; + font-style: italic; + margin: 0.5rem 0 1rem; +} + +.btn-principal { + padding: 0.55rem 1.25rem; + background: #1565c0; + color: #fff; + border: 1px solid #1565c0; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; +} + +.btn-principal:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-lien { + background: none; + border: none; + color: #1565c0; + cursor: pointer; + padding: 0 0.5rem; + font-size: 0.9rem; +} + +.btn-lien:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.vide { + color: #777; +} + +.mois { + width: 100%; + border-collapse: collapse; +} + +.mois th, +.mois td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #e0e0e0; +} + +.mois th { + font-weight: 600; + background: #f5f5f5; +} + +.mois input { + width: 8rem; + padding: 0.4rem; + border: 1px solid #bbb; + border-radius: 6px; + font-size: 0.95rem; +} + +.previsionnel { + color: #555; +} diff --git a/frontend/src/app/features/montants-regles/montants-regles.component.ts b/frontend/src/app/features/montants-regles/montants-regles.component.ts new file mode 100644 index 0000000..3aff6de --- /dev/null +++ b/frontend/src/app/features/montants-regles/montants-regles.component.ts @@ -0,0 +1,215 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject, signal } from '@angular/core'; +import { + FormBuilder, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators +} from '@angular/forms'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { BaremeService } from '../../core/services/bareme.service'; +import { NotificationService } from '../../core/services/notification.service'; +import { RemboursementService } from '../../core/services/remboursement.service'; +import { VehiculeService } from '../../core/services/vehicule.service'; +import { MontantRegle, Vehicule } from '../../core/models'; + +/** Libellés des mois affichés dans le tableau. */ +const NOMS_MOIS = [ + 'Janvier', + 'Février', + 'Mars', + 'Avril', + 'Mai', + 'Juin', + 'Juillet', + 'Août', + 'Septembre', + 'Octobre', + 'Novembre', + 'Décembre' +]; + +/** Ligne mensuelle de saisie (montant réglé + prévisionnel indicatif). */ +interface LigneMois { + mois: number; + libelle: string; + montant: number | null; + previsionnel: number | null; +} + +/** + * Saisie du Montant_Regle mensuel et affichage du Montant_Previsionnel + * indicatif (Exigences 14.1, 14.4, 14.8). + * + * - Sélection d'une année et d'un véhicule. + * - 12 lignes (une par mois) : saisie du Montant_Regle, affichage en regard + * du Montant_Previsionnel (80% du barème sur le Total_Annuel_Partiel), + * fourni par `BaremeService.getMontantPrevisionnel`. + * - Rejet client des montants négatifs (Exigence 14.4). + * - Tous les montants affichés avec deux décimales (Exigence 14.8). + * - Enregistrement via `RemboursementService.saveMontantRegle` (upsert). + */ +@Component({ + selector: 'app-montants-regles', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, FormsModule], + templateUrl: './montants-regles.component.html', + styleUrl: './montants-regles.component.scss' +}) +export class MontantsReglesComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly baremeService = inject(BaremeService); + private readonly remboursementService = inject(RemboursementService); + private readonly vehiculeService = inject(VehiculeService); + private readonly notification = inject(NotificationService); + + /** Formulaire de sélection (année + véhicule). */ + selectionForm!: FormGroup; + + /** Liste des véhicules disponibles. */ + readonly vehicules = signal([]); + + /** 12 lignes mensuelles. */ + readonly lignes = signal([]); + + /** Année affichée. */ + readonly annee = signal(new Date().getFullYear()); + + /** Véhicule affiché. */ + readonly vehiculeId = signal(null); + + /** true pendant un appel réseau. */ + readonly chargement = signal(false); + + ngOnInit(): void { + this.selectionForm = this.fb.group({ + annee: [this.annee(), [Validators.required, Validators.min(2000), Validators.max(2100)]], + vehiculeId: [null, [Validators.required]] + }); + + this.initialiserLignes(); + + this.vehiculeService.getVehicules().subscribe({ + next: (liste) => { + const vehicules = [...liste].sort((a, b) => b.dateEffet.localeCompare(a.dateEffet)); + this.vehicules.set(vehicules); + if (vehicules.length) { + this.selectionForm.patchValue({ vehiculeId: vehicules[0].id }); + this.charger(); + } + }, + error: () => { + this.notification.error('Impossible de charger les véhicules.'); + } + }); + } + + /** Réinitialise les 12 lignes mensuelles (montant et prévisionnel vides). */ + private initialiserLignes(): void { + this.lignes.set(NOMS_MOIS.map((libelle, index) => ({ + mois: index + 1, + libelle, + montant: null, + previsionnel: null + }))); + } + + /** Charge les montants réglés et les montants prévisionnels de la sélection. */ + charger(): void { + if (this.selectionForm.invalid) { + this.selectionForm.markAllAsTouched(); + return; + } + + const valeur = this.selectionForm.getRawValue(); + const annee = Number(valeur.annee); + const vehiculeId = Number(valeur.vehiculeId); + this.annee.set(annee); + this.vehiculeId.set(vehiculeId); + + this.initialiserLignes(); + this.chargement.set(true); + + const montants$ = this.remboursementService + .getMontantsRegles(annee, vehiculeId) + .pipe(catchError(() => of([] as MontantRegle[]))); + + const previsionnels$ = this.lignes().map((ligne) => + this.baremeService + .getMontantPrevisionnel(annee, ligne.mois, vehiculeId) + .pipe(catchError(() => of(null))) + ); + + forkJoin({ montants: montants$, previsionnels: forkJoin(previsionnels$) }).subscribe({ + next: ({ montants, previsionnels }) => { + this.chargement.set(false); + + // Reconstruit un nouveau tableau pour notifier le signal (nouvelle référence). + const lignes = this.lignes().map((l) => ({ ...l })); + for (const m of montants) { + const ligne = lignes.find((l) => l.mois === m.mois); + if (ligne) { + ligne.montant = m.montant; + } + } + previsionnels.forEach((p, index) => { + lignes[index].previsionnel = p ? p.montantPrevisionnel : null; + }); + this.lignes.set(lignes); + }, + error: () => { + this.chargement.set(false); + this.notification.error('Impossible de charger les montants.'); + } + }); + } + + /** + * Enregistre le Montant_Regle d'un mois (upsert). + * Rejette côté client tout montant absent ou négatif (Exigence 14.4). + */ + enregistrer(ligne: LigneMois): void { + const vehiculeId = this.vehiculeId(); + if (vehiculeId === null || this.chargement()) { + return; + } + + const montant = ligne.montant; + if (montant === null || montant === undefined || Number.isNaN(Number(montant))) { + this.notification.error('Veuillez saisir un montant.'); + return; + } + if (Number(montant) < 0) { + this.notification.error('Le montant réglé ne peut pas être négatif.'); + return; + } + + const dto: MontantRegle = { + annee: this.annee(), + mois: ligne.mois, + vehiculeId, + montant: Number(montant) + }; + + this.chargement.set(true); + this.remboursementService.saveMontantRegle(dto).subscribe({ + next: (enregistre) => { + this.chargement.set(false); + // Reconstruit le tableau pour notifier le signal de la valeur normalisée. + this.lignes.set( + this.lignes().map((l) => + l.mois === ligne.mois ? { ...l, montant: enregistre.montant } : l + ) + ); + this.notification.success(`Montant de ${ligne.libelle} enregistré.`); + }, + error: () => { + this.chargement.set(false); + this.notification.error("Échec de l'enregistrement du montant."); + } + }); + } +} diff --git a/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.html b/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.html new file mode 100644 index 0000000..3cdfcf6 --- /dev/null +++ b/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.html @@ -0,0 +1,74 @@ +
+ + + +
+ +
+ Année +
+ +
+
+ + +
+ Mois +
+ +
+
+ + +
+ Jour +
+ +
+
+
+
diff --git a/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.scss b/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.scss new file mode 100644 index 0000000..7533ad0 --- /dev/null +++ b/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.scss @@ -0,0 +1,106 @@ +.selecteur-temporel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +// En-tête du conteneur repliable (collapse). +.selecteur-entete { + display: inline-flex; + align-items: center; + gap: 0.5rem; + align-self: flex-start; + border: 1px solid #cbd5e1; + background: #fff; + color: #1f2937; + border-radius: 8px; + padding: 0.5rem 0.9rem; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; + + &:hover { + border-color: #2563eb; + background: #eff6ff; + } + + &__chevron { + font-size: 0.85rem; + color: #64748b; + } +} + +// Contenu déplié : les trois groupes de pastilles. +.selecteur-contenu { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.selecteur-groupe { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.selecteur-titre { + font-size: 0.85rem; + font-weight: 600; + color: #555; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.selecteur-boutons { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.selecteur-jours { + display: grid; + grid-template-columns: repeat(7, minmax(2.2rem, 1fr)); +} + +.pastille { + border: 1px solid #cbd5e1; + background: #fff; + color: #1f2937; + border-radius: 6px; + padding: 0.4rem 0.7rem; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; + + &:hover:not(:disabled) { + border-color: #2563eb; + background: #eff6ff; + } + + &:disabled { + color: #b0b7c3; + background: #f4f5f7; + border-color: #e2e5ea; + cursor: not-allowed; + } + + // Surbrillance des périodes comportant des données (12.3, 12.4). + &.avec-donnees:not(:disabled) { + border-color: #16a34a; + background: #ecfdf5; + font-weight: 600; + } + + &.selectionne { + border-color: #2563eb; + background: #2563eb; + color: #fff; + font-weight: 600; + } +} + +.pastille-jour { + text-align: center; + padding: 0.4rem 0; +} diff --git a/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.ts b/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.ts new file mode 100644 index 0000000..a28b930 --- /dev/null +++ b/frontend/src/app/features/navigation-temporelle/selecteur-temporel.component.ts @@ -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(); + + /** 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([]); + /** 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([]); + + /** Mois comportant au moins un trajet pour l'année sélectionnée. */ + private readonly moisAvecDonnees = signal>(new Set()); + /** Jours comportant au moins un trajet pour le mois sélectionné. */ + private readonly joursAvecDonnees = signal>(new Set()); + + readonly anneeSelectionnee = signal(null); + readonly moisSelectionne = signal(null); + readonly jourSelectionne = signal(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(m)); + }); + this.trajetService.getJoursDisponibles(annee, mois).subscribe((j) => { + this.joursAvecDonnees.set(new Set(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()); + this.moisAvecDonnees.set(new Set()); + + this.trajetService.getMoisDisponibles(annee).subscribe((mois) => { + this.moisAvecDonnees.set(new Set(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()); + + this.trajetService.getJoursDisponibles(annee, mois).subscribe((jours) => { + this.joursAvecDonnees.set(new Set(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(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()); + } +} diff --git a/frontend/src/app/features/onboarding/onboarding.component.html b/frontend/src/app/features/onboarding/onboarding.component.html new file mode 100644 index 0000000..0238df4 --- /dev/null +++ b/frontend/src/app/features/onboarding/onboarding.component.html @@ -0,0 +1,113 @@ +
+
+

Bienvenue

+

+ Renseignez votre identité et votre premier véhicule pour commencer à saisir vos trajets. +

+
+ +
+
+ Identité + +
+ + + @if (form.get('nom')?.touched && form.get('nom')?.invalid) { + Le nom est obligatoire (1 à 100 caractères). + } +
+ +
+ + + @if (form.get('prenom')?.touched && form.get('prenom')?.invalid) { + Le prénom est obligatoire (1 à 100 caractères). + } +
+ +
+ + + @if (form.get('matricule')?.touched && form.get('matricule')?.invalid) { + Le matricule est obligatoire (1 à 8 caractères). + } +
+
+ +
+ Adresse de domicile (optionnelle) + +
+ + + @if (form.get('adresseDomicile')?.touched && form.get('adresseDomicile')?.errors?.['maxlength']) { + L'adresse ne peut pas dépasser 255 caractères. + } + @if (suggestions().length > 0) { +
    + @for (feature of suggestions(); track feature.properties.label) { +
  • + +
  • + } +
+ } +
+
+ +
+ Premier véhicule + +
+ + + @if (form.get('denomination')?.touched && form.get('denomination')?.invalid) { + La dénomination est obligatoire (1 à 100 caractères). + } +
+ +
+ + + @if (form.get('plaque')?.touched && form.get('plaque')?.invalid) { + La plaque est obligatoire (1 à 15 caractères). + } +
+ +
+ + + @if (form.get('puissanceFiscale')?.touched && form.get('puissanceFiscale')?.invalid) { + La puissance fiscale doit être un entier positif. + } +
+ +
+ + + @if (form.get('dateEffet')?.touched && form.get('dateEffet')?.errors?.['required']) { + La date d'effet est obligatoire. + } + @if (form.get('dateEffet')?.touched && form.get('dateEffet')?.errors?.['dateFuture']) { + La date d'effet ne peut pas être postérieure à aujourd'hui. + } +
+
+ +
+ +
+
+
diff --git a/frontend/src/app/features/onboarding/onboarding.component.scss b/frontend/src/app/features/onboarding/onboarding.component.scss new file mode 100644 index 0000000..576bfdd --- /dev/null +++ b/frontend/src/app/features/onboarding/onboarding.component.scss @@ -0,0 +1,115 @@ +.onboarding { + padding: 1.5rem; + max-width: 640px; + margin: 0 auto; +} + +.onboarding__intro { + margin-bottom: 1.5rem; +} + +.onboarding__intro h1 { + font-size: 1.6rem; + margin-bottom: 0.5rem; +} + +.onboarding__intro p { + color: #555; + margin: 0; +} + +.bloc { + border: 1px solid #ddd; + border-radius: 8px; + padding: 1rem 1.25rem 0.5rem; + margin-bottom: 1.25rem; +} + +.bloc legend { + font-weight: 600; + padding: 0 0.5rem; +} + +.optionnel { + font-weight: 400; + color: #777; + font-size: 0.85rem; +} + +.champ { + display: flex; + flex-direction: column; + margin-bottom: 1rem; +} + +.champ label { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.champ input { + padding: 0.5rem; + border: 1px solid #bbb; + border-radius: 6px; + font-size: 0.95rem; +} + +.erreur { + color: #c62828; + margin-top: 0.25rem; +} + +.champ-adresse { + position: relative; +} + +.suggestions { + list-style: none; + margin: 0.25rem 0 0; + padding: 0; + border: 1px solid #ccc; + border-radius: 6px; + background: #fff; + max-height: 220px; + overflow-y: auto; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 20; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.suggestions li button { + display: block; + width: 100%; + text-align: left; + padding: 0.5rem; + border: none; + background: transparent; + cursor: pointer; + font-size: 0.9rem; +} + +.suggestions li button:hover { + background: #f0f4ff; +} + +.actions { + margin-top: 1rem; +} + +.btn-principal { + padding: 0.55rem 1.25rem; + background: #1565c0; + color: #fff; + border: 1px solid #1565c0; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; +} + +.btn-principal:disabled { + opacity: 0.55; + cursor: not-allowed; +} diff --git a/frontend/src/app/features/onboarding/onboarding.component.ts b/frontend/src/app/features/onboarding/onboarding.component.ts new file mode 100644 index 0000000..963f43b --- /dev/null +++ b/frontend/src/app/features/onboarding/onboarding.component.ts @@ -0,0 +1,187 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; + +import { BANService, BAN_MIN_CARACTERES } from '../../core/services/ban.service'; +import { NotificationService } from '../../core/services/notification.service'; +import { ProfilService } from '../../core/services/profil.service'; +import { VehiculeService } from '../../core/services/vehicule.service'; +import { BANFeature, CreateVehicule, SaveProfil } from '../../core/models'; + +/** + * Parcours d'onboarding présenté à la première utilisation lorsque le Profil + * n'est pas encore configuré (Exigence 16.3). + * + * Formulaire en une étape regroupant : + * - Identité : nom (1–100 car.), prénom (1–100 car.), matricule (1–8 car.) — requis (Exigence 16.5) + * - Adresse de domicile : optionnelle (1–255 car.) avec autocomplétion BAN (Exigence 16.7) + * - Premier véhicule : plaque (1–15 car.), puissance fiscale (entier positif), + * date d'effet <= aujourd'hui — au moins un véhicule requis (Exigence 16.6) + * + * Le bouton « Commencer » reste désactivé tant que les champs requis (identité + + * véhicule valide) ne sont pas renseignés (Exigences 16.5, 16.6, 16.8). + * + * À la validation : `ProfilService.saveProfil` puis `VehiculeService.createVehicule` + * (l'ordre importe : profil d'abord, véhicule ensuite). En cas de succès des deux + * persistances, navigation vers `/consultation` (Exigences 16.9, 16.10). En cas + * d'échec de persistance, un message d'erreur est affiché, aucune navigation n'est + * effectuée et les données saisies sont conservées (Exigence 16.12). + */ +@Component({ + selector: 'app-onboarding', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './onboarding.component.html', + styleUrl: './onboarding.component.scss' +}) +export class OnboardingComponent implements OnInit, OnDestroy { + private readonly fb = inject(FormBuilder); + private readonly profilService = inject(ProfilService); + private readonly vehiculeService = inject(VehiculeService); + private readonly banService = inject(BANService); + private readonly notification = inject(NotificationService); + private readonly router = inject(Router); + + /** Longueur maximale du matricule (Exigence 16.5). */ + readonly matriculeMaxLength = 8; + + /** Date du jour au format ISO (yyyy-MM-dd) pour borner le sélecteur de date. */ + readonly aujourdhuiIso = new Date().toISOString().slice(0, 10); + + form!: FormGroup; + + /** Suggestions d'adresses issues de la BAN pour l'autocomplétion du domicile. */ + readonly suggestions = signal([]); + + /** true pendant l'appel de persistance (désactive le bouton). */ + readonly enregistrement = signal(false); + + private readonly domicileInput$ = new Subject(); + private readonly destroy$ = new Subject(); + + ngOnInit(): void { + this.form = this.fb.group({ + // Identité (Exigence 16.5) — tous requis. + nom: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]], + prenom: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]], + matricule: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(8)]], + // Adresse de domicile optionnelle (Exigence 16.7). + adresseDomicile: ['', [Validators.maxLength(255)]], + // Premier véhicule (Exigence 16.6) — tous requis. + denomination: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]], + plaque: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(15)]], + puissanceFiscale: [null, [Validators.required, Validators.min(1)]], + dateEffet: ['', [Validators.required, this.dateNonFutureValidator()]] + }); + + // Autocomplétion BAN (déclenchée dès 3 caractères, debounce 300 ms). + this.domicileInput$ + .pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap((terme) => this.banService.searchAdresses(terme)), + takeUntil(this.destroy$) + ) + .subscribe((features) => { + this.suggestions.set(features); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Validateur bloquant les dates postérieures à aujourd'hui (Exigence 16.6). + * La valeur du contrôle est au format ISO (yyyy-MM-dd) issu de l'input date. + */ + private dateNonFutureValidator() { + return (control: { value: unknown }) => { + const valeur = control.value as string; + if (!valeur) { + return null; + } + return valeur > this.aujourdhuiIso ? { dateFuture: true } : null; + }; + } + + /** Alimente le flux d'autocomplétion à chaque frappe dans le champ adresse. */ + onDomicileInput(valeur: string): void { + if ((valeur ?? '').trim().length >= BAN_MIN_CARACTERES) { + this.domicileInput$.next(valeur); + } else { + this.suggestions.set([]); + } + } + + /** Sélection d'une suggestion BAN : remplit l'adresse et ferme la liste. */ + choisirSuggestion(feature: BANFeature): void { + this.form.get('adresseDomicile')?.setValue(feature.properties.label); + this.suggestions.set([]); + } + + /** + * Valide le parcours d'onboarding : enregistre le profil puis le véhicule, + * et navigue vers la vue de consultation en cas de succès des deux appels. + */ + commencer(): void { + if (this.form.invalid || this.enregistrement()) { + // Affiche les erreurs sur chaque champ manquant/invalide sans soumettre (Exigence 16.8). + this.form.markAllAsTouched(); + return; + } + + const valeur = this.form.getRawValue(); + const profilDto: SaveProfil = { + nom: (valeur.nom ?? '').trim() || null, + prenom: (valeur.prenom ?? '').trim() || null, + matricule: (valeur.matricule ?? '').trim() || null, + adresseDomicile: (valeur.adresseDomicile ?? '').trim() + }; + const vehiculeDto: CreateVehicule = { + denomination: (valeur.denomination ?? '').trim(), + plaque: (valeur.plaque ?? '').trim(), + puissanceFiscale: Number(valeur.puissanceFiscale), + dateEffet: this.isoVersJjMmAAAA(valeur.dateEffet) + }; + + this.enregistrement.set(true); + + // Ordre imposé : profil d'abord (PUT /api/profil), puis véhicule (POST /api/vehicules). + this.profilService.saveProfil(profilDto).subscribe({ + next: () => { + this.vehiculeService.createVehicule(vehiculeDto).subscribe({ + next: () => { + this.enregistrement.set(false); + // Succès des deux persistances → navigation vers la vue de consultation (Exigences 16.9, 16.10). + this.notification.success('Configuration terminée.'); + this.router.navigate(['/consultation']); + }, + error: () => this.gererEchecPersistance() + }); + }, + error: () => this.gererEchecPersistance() + }); + } + + /** + * Gestion d'un échec de persistance (Exigence 16.12) : message d'erreur, + * pas de navigation, conservation des données saisies. + */ + private gererEchecPersistance(): void { + this.enregistrement.set(false); + this.notification.error( + "Échec de l'enregistrement. Vos informations ont été conservées, veuillez réessayer." + ); + } + + /** Conversion ISO (yyyy-MM-dd) → JJ/MM/AAAA attendu par le backend. */ + private isoVersJjMmAAAA(iso: string): string { + const [annee, mois, jour] = iso.split('-'); + return `${jour}/${mois}/${annee}`; + } +} diff --git a/frontend/src/app/features/panneau-totaux/panneau-totaux.component.html b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.html new file mode 100644 index 0000000..b281312 --- /dev/null +++ b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.html @@ -0,0 +1,44 @@ +
+

Totaux

+ + @if (erreur()) { + + } + + @if (chargement() && !totaux()) { +

Chargement des totaux…

+ } + +
+
+
Total du jour
+
{{ (totaux()?.totalJour ?? 0) | number: '1.2-2' }} km
+
+
+
Total du mois
+
{{ (totaux()?.totalMois ?? 0) | number: '1.2-2' }} km
+
+
+
Total annuel partiel
+
{{ (totaux()?.totalAnnuelPartiel ?? 0) | number: '1.2-2' }} km
+
+
+ + @if (montantPrevisionnel(); as montantPrevisionnel) { +
+
+ + Indication de versement (80%) + + + {{ montantPrevisionnel.montantPrevisionnel | number: '1.2-2' }} € + +
+

+ Simple indication de versement, calculée à 80% du barème sur le total + annuel partiel. Le montant réellement versé est le montant réglé que + vous saisissez. +

+
+ } +
diff --git a/frontend/src/app/features/panneau-totaux/panneau-totaux.component.scss b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.scss new file mode 100644 index 0000000..7215d71 --- /dev/null +++ b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.scss @@ -0,0 +1,83 @@ +:host { + display: block; + width: 100%; +} + +.panneau-totaux { + padding: 16px; + border: 1px solid #e0e0e0; + border-radius: 6px; + background: #fff; + + &__titre { + margin: 0 0 12px; + font-size: 1.05rem; + color: #333; + } + + &__erreur { + margin: 0 0 12px; + color: #b71c1c; + font-size: 0.9rem; + } + + &__chargement { + margin: 0 0 12px; + color: #666; + font-size: 0.9rem; + } + + &__liste { + margin: 0; + } + + &__ligne { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid #f0f0f0; + + dt, + span:first-child { + color: #555; + } + + dd, + span:last-child { + margin: 0; + font-variant-numeric: tabular-nums; + color: #222; + } + + &--fort { + font-weight: 600; + + dt, + dd, + span { + color: #111; + } + } + } + + &__previsionnel { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e0e0e0; + + &-montant { + font-variant-numeric: tabular-nums; + color: #1565c0; + } + + &-note { + margin: 8px 0 0; + font-size: 0.8rem; + color: #777; + font-style: italic; + line-height: 1.35; + } + } +} diff --git a/frontend/src/app/features/panneau-totaux/panneau-totaux.component.ts b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.ts new file mode 100644 index 0000000..257243d --- /dev/null +++ b/frontend/src/app/features/panneau-totaux/panneau-totaux.component.ts @@ -0,0 +1,149 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + inject, + signal +} from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { MontantPrevisionnel, Totaux } from '../../core/models'; +import { BaremeService } from '../../core/services/bareme.service'; +import { TrajetService } from '../../core/services/trajet.service'; + +/** + * `PanneauTotauxComponent` — panneau latéral des totaux kilométriques et de + * l'indication de versement. + * + * Reçoit en entrée une `date` (ISO `yyyy-MM-dd`) et un `vehiculeId`, puis affiche : + * - le total du jour, le total du mois et le Total_Annuel_Partiel (cumul de + * l'année jusqu'au mois sélectionné inclus), tous à deux décimales + * (Exigences 12.7, 12.8) ; + * - le Montant_Previsionnel minoré à 80%, calculé sur le Total_Annuel_Partiel du + * véhicule sélectionné, présenté explicitement comme une simple indication de + * versement — le montant réellement versé étant le Montant_Regle saisi par + * l'utilisateur (Exigences 13.5, 13.6). Les montants sont exprimés en euros à + * deux décimales (Exigence 13.7). + * + * Les totaux et le montant prévisionnel sont recalculés à chaque changement de + * `date` ou de `vehiculeId` (recalcul au changement, sans rechargement complet). + * + * _Requirements: 12.7, 12.8, 13.5, 13.6, 13.7, 13.8_ + */ +@Component({ + selector: 'app-panneau-totaux', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './panneau-totaux.component.html', + styleUrl: './panneau-totaux.component.scss' +}) +export class PanneauTotauxComponent implements OnChanges, OnDestroy { + private readonly trajetService = inject(TrajetService); + private readonly baremeService = inject(BaremeService); + private readonly cdr = inject(ChangeDetectorRef); + + /** Date sélectionnée au format ISO (`yyyy-MM-dd`). */ + @Input() date: string | null = null; + + /** Véhicule sur lequel portent les totaux et le montant prévisionnel. */ + @Input() vehiculeId: number | null = null; + + /** Totaux jour / mois / année partiel chargés pour la date et le véhicule. */ + readonly totaux = signal(null); + + /** Indication de versement minorée (80%) sur le Total_Annuel_Partiel. */ + readonly montantPrevisionnel = signal(null); + + /** Chargement en cours (totaux et/ou montant prévisionnel). */ + readonly chargement = signal(false); + + /** Message d'erreur affiché si le chargement échoue. */ + readonly erreur = signal(null); + + private totauxSub?: Subscription; + private montantSub?: Subscription; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['date'] || changes['vehiculeId']) { + this.rafraichir(); + } + } + + ngOnDestroy(): void { + this.totauxSub?.unsubscribe(); + this.montantSub?.unsubscribe(); + } + + /** + * 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. + */ + private rafraichir(): void { + this.totauxSub?.unsubscribe(); + this.montantSub?.unsubscribe(); + + if (!this.date || this.vehiculeId == null) { + this.totaux.set(null); + this.montantPrevisionnel.set(null); + this.chargement.set(false); + this.erreur.set(null); + this.cdr.markForCheck(); + return; + } + + const date = this.date; + const vehiculeId = this.vehiculeId; + const annee = this.anneeDe(date); + const mois = this.moisDe(date); + + this.chargement.set(true); + this.erreur.set(null); + this.cdr.markForCheck(); + + this.totauxSub = this.trajetService.getTotauxJour(date, vehiculeId).subscribe({ + next: (totaux) => { + this.totaux.set(totaux); + this.chargement.set(false); + this.cdr.markForCheck(); + }, + error: () => { + this.totaux.set(null); + this.chargement.set(false); + this.erreur.set('Impossible de charger les totaux.'); + this.cdr.markForCheck(); + } + }); + + // Le Montant_Previsionnel est purement indicatif : son échec n'empêche pas + // l'affichage des totaux, on masque simplement l'indication. + this.montantSub = this.baremeService + .getMontantPrevisionnel(annee, mois, vehiculeId) + .subscribe({ + next: (montant) => { + this.montantPrevisionnel.set(montant); + this.cdr.markForCheck(); + }, + error: () => { + this.montantPrevisionnel.set(null); + this.cdr.markForCheck(); + } + }); + } + + /** Année (nombre) extraite d'une date ISO `yyyy-MM-dd`. */ + private anneeDe(dateIso: string): number { + return Number(dateIso.slice(0, 4)); + } + + /** Mois (1..12) extrait d'une date ISO `yyyy-MM-dd`. */ + private moisDe(dateIso: string): number { + return Number(dateIso.slice(5, 7)); + } +} diff --git a/frontend/src/app/features/profil/profil.component.html b/frontend/src/app/features/profil/profil.component.html new file mode 100644 index 0000000..1815f33 --- /dev/null +++ b/frontend/src/app/features/profil/profil.component.html @@ -0,0 +1,86 @@ +
+

Profil

+ +
+
+ + + @if (form.get('nom')?.touched && form.get('nom')?.invalid) { + Le nom est obligatoire (1 à 100 caractères). + } +
+ +
+ + + @if (form.get('prenom')?.touched && form.get('prenom')?.invalid) { + Le prénom est obligatoire (1 à 100 caractères). + } +
+ +
+ + + 8 caractères maximum. +
+ +
+ + + @if (form.get('adresseDomicile')?.touched && form.get('adresseDomicile')?.errors?.['maxlength']) { + L'adresse ne peut pas dépasser 255 caractères. + } + @if (suggestions().length > 0) { +
    + @for (feature of suggestions(); track feature.properties.label) { +
  • + +
  • + } +
+ } +
+ +
+ +
+
+ + +
+

Maintenance

+

+ Recalcule la distance par la route des trajets qui n'ont pas encore + d'itinéraire routier (calculés à vol d'oiseau). Les trajets déjà corrects ne sont pas + resollicités, ce qui limite les appels à OpenRouteService. +

+ +
+
diff --git a/frontend/src/app/features/profil/profil.component.scss b/frontend/src/app/features/profil/profil.component.scss new file mode 100644 index 0000000..de01dbb --- /dev/null +++ b/frontend/src/app/features/profil/profil.component.scss @@ -0,0 +1,92 @@ +.profil { + padding: 1.5rem; + max-width: 640px; +} + +h1 { + font-size: 1.4rem; + margin-bottom: 1rem; +} + +.champ { + display: flex; + flex-direction: column; + margin-bottom: 1rem; +} + +.champ label { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.champ input { + padding: 0.5rem; + border: 1px solid #bbb; + border-radius: 6px; + font-size: 0.95rem; +} + +.aide { + color: #777; + margin-top: 0.25rem; +} + +.erreur { + color: #c62828; + margin-top: 0.25rem; +} + +.champ-adresse { + position: relative; +} + +.suggestions { + list-style: none; + margin: 0.25rem 0 0; + padding: 0; + border: 1px solid #ccc; + border-radius: 6px; + background: #fff; + max-height: 220px; + overflow-y: auto; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 20; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.suggestions li button { + display: block; + width: 100%; + text-align: left; + padding: 0.5rem; + border: none; + background: transparent; + cursor: pointer; + font-size: 0.9rem; +} + +.suggestions li button:hover { + background: #f0f4ff; +} + +.actions { + margin-top: 1rem; +} + +.btn-principal { + padding: 0.55rem 1.25rem; + background: #1565c0; + color: #fff; + border: 1px solid #1565c0; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; +} + +.btn-principal:disabled { + opacity: 0.55; + cursor: not-allowed; +} diff --git a/frontend/src/app/features/profil/profil.component.ts b/frontend/src/app/features/profil/profil.component.ts new file mode 100644 index 0000000..3e8691b --- /dev/null +++ b/frontend/src/app/features/profil/profil.component.ts @@ -0,0 +1,188 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; + +import { BANService, BAN_MIN_CARACTERES } from '../../core/services/ban.service'; +import { NotificationService } from '../../core/services/notification.service'; +import { ProfilService } from '../../core/services/profil.service'; +import { TrajetService } from '../../core/services/trajet.service'; +import { BANFeature, Profil, SaveProfil } from '../../core/models'; + +/** + * Page de profil utilisateur (Exigences 10.1, 10.2, 10.8). + * + * Formulaire réactif : + * - nom (1–100 car.), prénom (1–100 car.) : obligatoires + * - matricule (max 8 car., limite de saisie stricte via `maxlength`) + * - adresse de domicile (1–255 car., optionnelle) avec autocomplétion BAN + * + * "Enregistrer" appelle `ProfilService.saveProfil`. Un avertissement est affiché + * si le géocodage de l'adresse a échoué (profil.domicileGeocode = false alors + * qu'une adresse est renseignée). + */ +@Component({ + selector: 'app-profil', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './profil.component.html', + styleUrl: './profil.component.scss' +}) +export class ProfilComponent implements OnInit, OnDestroy { + private readonly fb = inject(FormBuilder); + private readonly profilService = inject(ProfilService); + private readonly banService = inject(BANService); + private readonly notification = inject(NotificationService); + private readonly trajetService = inject(TrajetService); + + /** Longueur maximale du matricule (limite de saisie stricte, Exigence 10.2). */ + readonly matriculeMaxLength = 8; + + form!: FormGroup; + + /** Suggestions d'adresses issues de la BAN pour l'autocomplétion. */ + readonly suggestions = signal([]); + + /** true pendant l'appel de sauvegarde (désactive le bouton). */ + readonly enregistrement = signal(false); + + /** true pendant le recalcul des distances (désactive le bouton dédié). */ + readonly recalculEnCours = signal(false); + + private readonly domicileInput$ = new Subject(); + private readonly destroy$ = new Subject(); + + ngOnInit(): void { + this.form = this.fb.group({ + nom: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]], + prenom: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]], + matricule: ['', [Validators.maxLength(8)]], + adresseDomicile: ['', [Validators.maxLength(255)]] + }); + + // Autocomplétion BAN (déclenchée dès 3 caractères, debounce 300 ms). + this.domicileInput$ + .pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap((terme) => this.banService.searchAdresses(terme)), + takeUntil(this.destroy$) + ) + .subscribe((features) => { + this.suggestions.set(features); + }); + + this.chargerProfil(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** Charge le profil existant et alimente le formulaire. */ + private chargerProfil(): void { + this.profilService.getProfil().subscribe({ + next: (profil) => { + this.form.patchValue({ + nom: profil.nom ?? '', + prenom: profil.prenom ?? '', + matricule: profil.matricule ?? '', + adresseDomicile: profil.adresseDomicile ?? '' + }); + this.verifierGeocodage(profil); + }, + error: () => { + this.notification.error('Impossible de charger le profil.'); + } + }); + } + + /** Alimente le flux d'autocomplétion à chaque frappe dans le champ adresse. */ + onDomicileInput(valeur: string): void { + if ((valeur ?? '').trim().length >= BAN_MIN_CARACTERES) { + this.domicileInput$.next(valeur); + } else { + this.suggestions.set([]); + } + } + + /** Sélection d'une suggestion BAN : remplit l'adresse et ferme la liste. */ + choisirSuggestion(feature: BANFeature): void { + this.form.get('adresseDomicile')?.setValue(feature.properties.label); + this.suggestions.set([]); + } + + /** Enregistre le profil via `ProfilService.saveProfil`. */ + enregistrer(): void { + if (this.form.invalid || this.enregistrement()) { + this.form.markAllAsTouched(); + return; + } + + const valeur = this.form.getRawValue(); + const dto: SaveProfil = { + nom: valeur.nom?.trim() || null, + prenom: valeur.prenom?.trim() || null, + matricule: valeur.matricule?.trim() || null, + adresseDomicile: (valeur.adresseDomicile ?? '').trim() + }; + + this.enregistrement.set(true); + this.profilService.saveProfil(dto).subscribe({ + next: (profil) => { + this.enregistrement.set(false); + this.notification.success('Profil enregistré.'); + this.verifierGeocodage(profil); + }, + error: () => { + this.enregistrement.set(false); + this.notification.error("Échec de l'enregistrement du profil."); + } + }); + } + + /** + * Recalcule la distance routière de tous les trajets via ORS (action de maintenance). + * Utile après correction de la configuration ORS pour remplacer d'anciennes distances + * calculées à vol d'oiseau par la distance par la route. + */ + recalculerDistances(): void { + if (this.recalculEnCours()) { + return; + } + this.recalculEnCours.set(true); + this.trajetService.recalculerDistances().subscribe({ + next: (resultat) => { + this.recalculEnCours.set(false); + if (resultat.trajetsRecalcules === 0) { + this.notification.success( + 'Toutes les distances sont déjà à jour (itinéraire routier calculé). Aucun appel ORS nécessaire.' + ); + } else { + this.notification.success( + `Distances recalculées pour ${resultat.trajetsRecalcules} trajet(s).` + ); + } + }, + error: () => { + this.recalculEnCours.set(false); + this.notification.error('Le recalcul des distances a échoué.'); + } + }); + } + + /** + * Affiche un avertissement si une adresse de domicile est renseignée mais + * n'a pas pu être géocodée (Exigence 10.8). + */ + private verifierGeocodage(profil: Profil): void { + const adresse = (profil.adresseDomicile ?? '').trim(); + if (adresse.length > 0 && !profil.domicileGeocode) { + this.notification.warning( + "L'adresse de domicile n'a pas pu être vérifiée (géocodage indisponible)." + ); + } + } +} diff --git a/frontend/src/app/features/resume-annuel/resume-annuel.component.html b/frontend/src/app/features/resume-annuel/resume-annuel.component.html new file mode 100644 index 0000000..43927b5 --- /dev/null +++ b/frontend/src/app/features/resume-annuel/resume-annuel.component.html @@ -0,0 +1,58 @@ +
+

Résumé annuel

+ +
+
+ + +
+ +
+ + +
+
+ + @if (chargement()) { +

Chargement du résumé…

+ } @else if (resumeCharge()) { + + + + + + + + + @for (ligne of lignes(); track ligne.mois) { + + + + + } + + + + + + + +
MoisTotal (km)
{{ ligne.libelle }}{{ ligne.totalKm | number: '1.2-2' }}
Total annuel{{ totalAnnuel() | number: '1.2-2' }} km
+ } +
diff --git a/frontend/src/app/features/resume-annuel/resume-annuel.component.scss b/frontend/src/app/features/resume-annuel/resume-annuel.component.scss new file mode 100644 index 0000000..d30ed70 --- /dev/null +++ b/frontend/src/app/features/resume-annuel/resume-annuel.component.scss @@ -0,0 +1,99 @@ +.resume-annuel { + padding: 1.5rem; + max-width: 640px; +} + +h1 { + font-size: 1.4rem; + margin-bottom: 1rem; +} + +.selecteurs { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.champ { + display: flex; + flex-direction: column; +} + +.champ label { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.champ input { + padding: 0.5rem; + border: 1px solid #bbb; + border-radius: 6px; + font-size: 0.95rem; +} + +.actions { + display: flex; + gap: 0.75rem; +} + +.btn-principal, +.btn-secondaire { + padding: 0.55rem 1.25rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; +} + +.btn-principal { + background: #1565c0; + color: #fff; + border: 1px solid #1565c0; +} + +.btn-secondaire { + background: #fff; + color: #1565c0; + border: 1px solid #1565c0; +} + +.btn-principal:disabled, +.btn-secondaire:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.mois { + width: 100%; + border-collapse: collapse; +} + +.mois th, +.mois td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #e0e0e0; +} + +.mois th { + font-weight: 600; + background: #f5f5f5; +} + +.mois .num { + text-align: right; +} + +.mois tfoot td { + font-weight: 600; + border-top: 2px solid #bbb; +} + +.mois .total { + color: #1565c0; +} + +.info { + color: #555; +} diff --git a/frontend/src/app/features/resume-annuel/resume-annuel.component.ts b/frontend/src/app/features/resume-annuel/resume-annuel.component.ts new file mode 100644 index 0000000..f67f1fb --- /dev/null +++ b/frontend/src/app/features/resume-annuel/resume-annuel.component.ts @@ -0,0 +1,167 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { NotificationService } from '../../core/services/notification.service'; +import { TrajetService } from '../../core/services/trajet.service'; +import { ResumeAnnuel } from '../../core/models'; + +/** Libellés des mois affichés dans le tableau annuel (index 0 = janvier). */ +const NOMS_MOIS = [ + 'Janvier', + 'Février', + 'Mars', + 'Avril', + 'Mai', + 'Juin', + 'Juillet', + 'Août', + 'Septembre', + 'Octobre', + 'Novembre', + 'Décembre' +]; + +/** Ligne du tableau annuel : un mois et son total kilométrique. */ +export interface LigneAnnuelle { + mois: number; + libelle: string; + totalKm: number; +} + +/** + * Résumé annuel (Exigences 4.1, 4.5, 4.6, 7.3). + * + * - Sélecteur année (2000–2100). + * - Tableau de 12 lignes (mois, total km) + ligne du total annuel à 2 décimales, + * plafonné à 999 999,99 (Exigence 4.5). + * - Bouton « Export PDF annuel » : Blob → téléchargement navigateur, repli + * `trajets_AAAA.pdf` ; échec de téléchargement → message d'erreur (Exigences 4.6, 7.3). + * - En cas d'erreur API, aucune donnée partielle n'est affichée. + */ +@Component({ + selector: 'app-resume-annuel', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './resume-annuel.component.html', + styleUrl: './resume-annuel.component.scss' +}) +export class ResumeAnnuelComponent implements OnInit { + private readonly trajetService = inject(TrajetService); + private readonly notification = inject(NotificationService); + + /** Bornes de saisie de l'année (cohérentes avec la validation backend). */ + readonly anneeMin = 2000; + readonly anneeMax = 2100; + + /** Plafond d'affichage du total annuel (Exigence 4.5). */ + readonly totalMax = 999999.99; + + /** Année sélectionnée, initialisée à l'année en cours. */ + annee: number = new Date().getFullYear(); + + /** 12 lignes (mois → total km) dérivées du résumé chargé. */ + readonly lignes = signal([]); + + /** Total annuel plafonné à 999 999,99. */ + readonly totalAnnuel = signal(0); + + /** true tant qu'aucun chargement réussi (masque le tableau). */ + readonly resumeCharge = signal(false); + + /** true pendant le chargement du résumé. */ + readonly chargement = signal(false); + + /** true pendant la génération/téléchargement du PDF (désactive le bouton). */ + readonly export = signal(false); + + ngOnInit(): void { + this.charger(); + } + + /** Charge le résumé annuel pour l'année sélectionnée. */ + charger(): void { + if (!this.anneeValide()) { + this.notification.error('Veuillez saisir une année valide (2000–2100).'); + return; + } + + this.chargement.set(true); + this.trajetService.getResumeAnnuel(this.annee).subscribe({ + next: (resume) => { + this.appliquerResume(resume); + this.chargement.set(false); + this.resumeCharge.set(true); + }, + error: () => { + // Ne pas afficher de données partielles en cas d'erreur. + this.lignes.set([]); + this.totalAnnuel.set(0); + this.resumeCharge.set(false); + this.chargement.set(false); + this.notification.error('Impossible de charger le résumé annuel.'); + } + }); + } + + /** Déclenche l'export PDF annuel et le téléchargement navigateur (Exigences 4.6, 7.3). */ + exporterPdf(): void { + if (!this.anneeValide() || this.export()) { + return; + } + + this.export.set(true); + this.trajetService.exportPdfAnnuel(this.annee).subscribe({ + next: (blob) => { + this.export.set(false); + this.declencherTelechargement(blob, `trajets_${this.annee}.pdf`); + }, + error: () => { + this.export.set(false); + this.notification.error("Échec du téléchargement du PDF annuel."); + } + }); + } + + /** + * Construit les 12 lignes à partir du résumé (toujours 12 mois côté backend, + * mais on garantit l'ordre et la présence des 12 mois) et plafonne le total. + */ + private appliquerResume(resume: ResumeAnnuel): void { + const parMois = new Map(); + for (const m of resume.mois ?? []) { + parMois.set(m.mois, m.totalKm ?? 0); + } + + this.lignes.set(NOMS_MOIS.map((libelle, index) => { + const mois = index + 1; + return { mois, libelle, totalKm: parMois.get(mois) ?? 0 }; + })); + + this.totalAnnuel.set(Math.min(resume.totalKm ?? 0, this.totalMax)); + } + + /** + * Crée une URL d'objet à partir du Blob et déclenche un téléchargement via + * une ancre temporaire, puis libère l'URL. + */ + private declencherTelechargement(blob: Blob, nomFichier: string): void { + const url = URL.createObjectURL(blob); + const ancre = document.createElement('a'); + ancre.href = url; + ancre.download = nomFichier; + document.body.appendChild(ancre); + ancre.click(); + document.body.removeChild(ancre); + URL.revokeObjectURL(url); + } + + /** Vérifie que l'année sélectionnée est dans les bornes attendues. */ + private anneeValide(): boolean { + return ( + Number.isInteger(this.annee) && + this.annee >= this.anneeMin && + this.annee <= this.anneeMax + ); + } +} diff --git a/frontend/src/app/features/resume-mensuel/resume-mensuel.component.html b/frontend/src/app/features/resume-mensuel/resume-mensuel.component.html new file mode 100644 index 0000000..78fec1e --- /dev/null +++ b/frontend/src/app/features/resume-mensuel/resume-mensuel.component.html @@ -0,0 +1,77 @@ +
+

Résumé mensuel

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + @if (chargement()) { +

Chargement du résumé…

+ } @else if (resume(); as resume) { + + + + + + + + + + + @for (trajet of resume.trajets; track trajet.id) { + + + + + + + } @empty { + + + + } + + + + + + + +
DateDépartArrivéeDistance (km)
{{ trajet.date | date: 'dd/MM/yyyy' }}{{ trajet.adresseDepart }}{{ trajet.adresseArrivee }} + {{ trajet.distance != null ? (trajet.distance | number: '1.2-2') : '—' }} +
Aucun trajet pour ce mois.
Total mensuel{{ resume.totalKm | number: '1.2-2' }} km
+ } +
diff --git a/frontend/src/app/features/resume-mensuel/resume-mensuel.component.scss b/frontend/src/app/features/resume-mensuel/resume-mensuel.component.scss new file mode 100644 index 0000000..31e3eb7 --- /dev/null +++ b/frontend/src/app/features/resume-mensuel/resume-mensuel.component.scss @@ -0,0 +1,105 @@ +.resume-mensuel { + padding: 1.5rem; + max-width: 900px; +} + +h1 { + font-size: 1.4rem; + margin-bottom: 1rem; +} + +.selecteurs { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.champ { + display: flex; + flex-direction: column; +} + +.champ label { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.champ select, +.champ input { + padding: 0.5rem; + border: 1px solid #bbb; + border-radius: 6px; + font-size: 0.95rem; +} + +.actions { + display: flex; + gap: 0.75rem; +} + +.btn-principal, +.btn-secondaire { + padding: 0.55rem 1.25rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; +} + +.btn-principal { + background: #1565c0; + color: #fff; + border: 1px solid #1565c0; +} + +.btn-secondaire { + background: #fff; + color: #1565c0; + border: 1px solid #1565c0; +} + +.btn-principal:disabled, +.btn-secondaire:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.trajets { + width: 100%; + border-collapse: collapse; +} + +.trajets th, +.trajets td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #e0e0e0; +} + +.trajets th { + font-weight: 600; + background: #f5f5f5; +} + +.trajets .num { + text-align: right; +} + +.trajets tfoot td { + font-weight: 600; + border-top: 2px solid #bbb; +} + +.trajets .total { + color: #1565c0; +} + +.vide { + color: #777; + text-align: center; +} + +.info { + color: #555; +} diff --git a/frontend/src/app/features/resume-mensuel/resume-mensuel.component.ts b/frontend/src/app/features/resume-mensuel/resume-mensuel.component.ts new file mode 100644 index 0000000..c858be9 --- /dev/null +++ b/frontend/src/app/features/resume-mensuel/resume-mensuel.component.ts @@ -0,0 +1,131 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { NotificationService } from '../../core/services/notification.service'; +import { TrajetService } from '../../core/services/trajet.service'; +import { ResumeMensuel } from '../../core/models'; + +/** + * Résumé mensuel (Exigences 3.1, 3.5, 3.7, 7.3). + * + * - Sélecteurs mois (1–12) et année (2000–2100). + * - Affiche la liste des trajets du mois et le total mensuel à 2 décimales. + * - En cas d'erreur API, affiche un message et n'affiche aucune donnée partielle + * (le résumé est remis à null, Exigence 3.7). + * - Bouton « Export PDF mensuel » : récupère le Blob via `TrajetService`, puis + * déclenche le téléchargement navigateur (URL d'objet + ancre). Le nom du + * fichier provient de l'en-tête `Content-Disposition` s'il est présent, sinon + * d'un repli `trajets_MM_AAAA.pdf`. Un échec de téléchargement affiche un + * message d'erreur (Exigence 7.3). + */ +@Component({ + selector: 'app-resume-mensuel', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './resume-mensuel.component.html', + styleUrl: './resume-mensuel.component.scss' +}) +export class ResumeMensuelComponent implements OnInit { + private readonly trajetService = inject(TrajetService); + private readonly notification = inject(NotificationService); + + /** Bornes de saisie de l'année (cohérentes avec la validation backend). */ + readonly anneeMin = 2000; + readonly anneeMax = 2100; + + /** Liste des mois pour le sélecteur (1–12). */ + readonly moisDisponibles = Array.from({ length: 12 }, (_, i) => i + 1); + + /** Sélection courante, initialisée au mois/année en cours. */ + annee: number = new Date().getFullYear(); + mois: number = new Date().getMonth() + 1; + + /** Résumé chargé (null tant qu'aucun chargement réussi ou après une erreur). */ + readonly resume = signal(null); + + /** true pendant le chargement du résumé. */ + readonly chargement = signal(false); + + /** true pendant la génération/téléchargement du PDF (désactive le bouton). */ + readonly export = signal(false); + + ngOnInit(): void { + this.charger(); + } + + /** Charge le résumé mensuel pour la sélection courante. */ + charger(): void { + if (!this.selectionValide()) { + this.notification.error('Veuillez saisir un mois (1–12) et une année valides (2000–2100).'); + return; + } + + this.chargement.set(true); + this.trajetService.getResumeMensuel(this.annee, this.mois).subscribe({ + next: (resume) => { + this.resume.set(resume); + this.chargement.set(false); + }, + error: () => { + // Ne pas afficher de données partielles en cas d'erreur (Exigence 3.7). + this.resume.set(null); + this.chargement.set(false); + this.notification.error('Impossible de charger le résumé mensuel.'); + } + }); + } + + /** Déclenche l'export PDF mensuel et le téléchargement navigateur (Exigence 7.3). */ + exporterPdf(): void { + if (!this.selectionValide() || this.export()) { + return; + } + + this.export.set(true); + this.trajetService.exportPdfMensuel(this.annee, this.mois).subscribe({ + next: (blob) => { + this.export.set(false); + const nom = this.nomFichierParDefaut(); + this.declencherTelechargement(blob, nom); + }, + error: () => { + this.export.set(false); + this.notification.error("Échec du téléchargement du PDF mensuel."); + } + }); + } + + /** Nom de fichier de repli au format `trajets_MM_AAAA.pdf`. */ + private nomFichierParDefaut(): string { + const mm = String(this.mois).padStart(2, '0'); + return `trajets_${mm}_${this.annee}.pdf`; + } + + /** + * Crée une URL d'objet à partir du Blob et déclenche un téléchargement via + * une ancre temporaire, puis libère l'URL. + */ + private declencherTelechargement(blob: Blob, nomFichier: string): void { + const url = URL.createObjectURL(blob); + const ancre = document.createElement('a'); + ancre.href = url; + ancre.download = nomFichier; + document.body.appendChild(ancre); + ancre.click(); + document.body.removeChild(ancre); + URL.revokeObjectURL(url); + } + + /** Vérifie que la sélection mois/année est dans les bornes attendues. */ + private selectionValide(): boolean { + return ( + Number.isInteger(this.mois) && + this.mois >= 1 && + this.mois <= 12 && + Number.isInteger(this.annee) && + this.annee >= this.anneeMin && + this.annee <= this.anneeMax + ); + } +} diff --git a/frontend/src/app/features/trajet-form/trajet-form.component.html b/frontend/src/app/features/trajet-form/trajet-form.component.html new file mode 100644 index 0000000..e038ce1 --- /dev/null +++ b/frontend/src/app/features/trajet-form/trajet-form.component.html @@ -0,0 +1,226 @@ +
+ + @if (banIndisponible()) { +

+ L'autocomplétion d'adresses est temporairement indisponible. Vous pouvez saisir + l'adresse manuellement. +

+ } + + +
+ + + @if (dateCtrl.touched && dateCtrl.errors) { + + } +
+ + +
+ + + +
+ + +
+ +
+ + +
+ + @if (suggestionsDepart().length > 0) { +
    + @for (feature of suggestionsDepart(); track feature.properties.label) { +
  • + +
  • + } +
+ } + + @if (departCtrl.touched && departCtrl.errors) { + + } +
+ + +
+ + + +
+ + +
+ +
+ + +
+ + @if (suggestionsArrivee().length > 0) { +
    + @for (feature of suggestionsArrivee(); track feature.properties.label) { +
  • + +
  • + } +
+ } + + @if (arriveeCtrl.touched && arriveeCtrl.errors) { + + } +
+ + + @if (avertissementCoordonneesManquantes) { +

+ Une adresse a été saisie sans être sélectionnée dans les suggestions : la distance + pourrait ne pas être calculée automatiquement. +

+ } + + +
+ + + + {{ (commentaireCtrl.value?.length ?? 0) }} / {{ commentaireMax }} + + @if (commentaireCtrl.touched && commentaireCtrl.errors?.['maxlength']) { + + } +
+ + +
+ + +
+
diff --git a/frontend/src/app/features/trajet-form/trajet-form.component.scss b/frontend/src/app/features/trajet-form/trajet-form.component.scss new file mode 100644 index 0000000..9b47b1c --- /dev/null +++ b/frontend/src/app/features/trajet-form/trajet-form.component.scss @@ -0,0 +1,214 @@ +.trajet-form { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 640px; +} + +.champ { + display: flex; + flex-direction: column; + gap: 0.35rem; + + label { + font-weight: 600; + font-size: 0.9rem; + } + + input, + textarea { + padding: 0.5rem 0.65rem; + border: 1px solid #c4c9d1; + border-radius: 6px; + font: inherit; + + &[aria-invalid='true'] { + border-color: #d13438; + } + + &:focus { + outline: 2px solid #2b6cb0; + outline-offset: 1px; + } + } +} + +.champ--adresse { + position: relative; +} + +.ligne-adresse { + display: flex; + gap: 0.5rem; + align-items: stretch; + + input { + flex: 1 1 auto; + } +} + +.btn-domicile { + flex: 0 0 auto; + white-space: nowrap; + padding: 0 0.75rem; + border: 1px solid #2b6cb0; + border-radius: 6px; + background: #fff; + color: #2b6cb0; + font-size: 0.85rem; + cursor: pointer; + + &:hover:not(:disabled) { + background: #ebf3fb; + } + + &:disabled { + border-color: #c4c9d1; + color: #9aa1ab; + cursor: not-allowed; + } +} + +.ligne-carnet { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 0.35rem; +} + +.label-carnet { + font-size: 0.8rem; + font-weight: 500; + color: #6b7280; +} + +.select-carnet { + padding: 0.4rem 0.55rem; + border: 1px solid #c4c9d1; + border-radius: 6px; + background: #fff; + font: inherit; + cursor: pointer; + + &:focus { + outline: 2px solid #2b6cb0; + outline-offset: 1px; + } + + &:disabled { + background: #f3f5f8; + color: #9aa1ab; + cursor: not-allowed; + } +} + +.suggestions { + list-style: none; + margin: 0.25rem 0 0; + padding: 0; + border: 1px solid #c4c9d1; + border-radius: 6px; + background: #fff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + max-height: 220px; + overflow-y: auto; + z-index: 10; + + li + li { + border-top: 1px solid #eef0f3; + } + + button { + display: flex; + flex-direction: column; + gap: 0.1rem; + width: 100%; + padding: 0.5rem 0.65rem; + border: none; + background: transparent; + text-align: left; + cursor: pointer; + + &:hover, + &:focus { + background: #ebf3fb; + outline: none; + } + } +} + +.suggestion-label { + font-weight: 500; +} + +.suggestion-detail { + font-size: 0.8rem; + color: #6b7280; +} + +.compteur { + align-self: flex-end; + font-size: 0.75rem; + color: #6b7280; +} + +.erreur { + margin: 0; + font-size: 0.82rem; + color: #d13438; +} + +.alerte { + margin: 0; + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-size: 0.85rem; + + &--info { + background: #eef2f7; + color: #33475b; + } + + &--warning { + background: #fff4e5; + color: #8a5a00; + border: 1px solid #f0c36d; + } +} + +.actions { + display: flex; + gap: 0.75rem; + + button { + padding: 0.55rem 1.1rem; + border-radius: 6px; + font: inherit; + cursor: pointer; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } +} + +.btn-primaire { + border: none; + background: #2b6cb0; + color: #fff; + + &:hover:not(:disabled) { + background: #245a94; + } +} + +.btn-secondaire { + border: 1px solid #c4c9d1; + background: #fff; + color: #33475b; + + &:hover:not(:disabled) { + background: #f3f5f8; + } +} diff --git a/frontend/src/app/features/trajet-form/trajet-form.component.ts b/frontend/src/app/features/trajet-form/trajet-form.component.ts new file mode 100644 index 0000000..e700f0d --- /dev/null +++ b/frontend/src/app/features/trajet-form/trajet-form.component.ts @@ -0,0 +1,698 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + inject, + signal +} from '@angular/core'; +import { + AbstractControl, + FormBuilder, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn, + Validators +} from '@angular/forms'; +import { Subject, of } from 'rxjs'; +import { + catchError, + debounceTime, + distinctUntilChanged, + filter, + map, + switchMap, + takeUntil +} from 'rxjs/operators'; + +import { + AdresseNommee, + BANFeature, + CreateTrajet, + Profil, + Trajet, + UpdateTrajet +} from '../../core/models'; +import { AdresseNommeeService } from '../../core/services/adresse-nommee.service'; +import { BAN_MIN_CARACTERES, BANService } from '../../core/services/ban.service'; +import { NotificationService } from '../../core/services/notification.service'; +import { ProfilService } from '../../core/services/profil.service'; +import { TrajetService } from '../../core/services/trajet.service'; +import { ServeurIndisponibleError } from '../../core/interceptors/timeout.interceptor'; +import { estDateJjMmAAAAValide } from '../../shared/validators/date-jj-mm-aaaa.validator'; + +/** Longueur maximale d'une adresse (Exigence 1.1, 6.2). */ +export const ADRESSE_MAX = 255; + +/** Longueur maximale d'un commentaire (Exigence 1.10). */ +export const COMMENTAIRE_MAX = 500; + +/** Délai de debounce avant interrogation de la BAN (design.md). */ +export const BAN_DEBOUNCE_MS = 300; + +/** Mode d'utilisation du formulaire. */ +export type TrajetFormMode = 'create' | 'edit' | 'insert'; + +/** + * Données de pré-remplissage en mode insertion : adresse de départ et/ou d'arrivée + * issues du Trajet_Précédent / Trajet_Suivant (Exigences 11.1–11.4). Les champs + * restent modifiables. La position d'insertion est gérée par le composant appelant + * (liste), le formulaire ne fait que porter les valeurs pré-remplies. + */ +export interface TrajetInsertionPrefill { + adresseDepart?: string | null; + adresseArrivee?: string | null; + vehiculeId?: number | null; +} + +/** Identifie lequel des deux champs adresse est concerné. */ +type ChampAdresse = 'depart' | 'arrivee'; + +/** Coordonnées géographiques mémorisées après une sélection BAN. */ +interface Coordonnees { + latitude: number; + longitude: number; +} + +/** + * `TrajetFormComponent` — saisie, édition et insertion d'un Trajet. + * + * Modes (Exigence 5.1, 11.1–11.4) : + * - `create` : formulaire vierge. + * - `edit` : pré-rempli depuis `@Input() trajet`. + * - `insert` : pré-rempli depuis `@Input() prefill` (adresses du Trajet_Précédent / + * Trajet_Suivant) ; les champs restent modifiables. + * + * Autocomplétion BAN (Exigences 1.13–1.15) : dès 3 caractères, avec debounce 300 ms ; + * à la sélection d'une suggestion, les coordonnées (lat, lon) sont mémorisées localement + * et le libellé normalisé remplit le champ ; elles sont transmises avec le trajet. + * + * Saisie manuelle sans sélection BAN (Exigences 1.16, 1.17) : les coordonnées sont + * absentes et un avertissement discret indique que la distance pourrait ne pas être + * calculée automatiquement. + * + * Le composant est réutilisable : il émet le trajet créé/modifié via `@Output` + * (`created` / `updated`) afin que la liste / consultation puisse réagir. + * + * Sélecteur d'Adresse_Nommee (Exigences 1.18, 1.19, 11.10, 11.11, 15.16) : pour chaque + * champ adresse (départ / arrivée), un sélecteur liste les Adresses_Nommées du + * Carnet_Adresses (par Dénomination), chargées à l'initialisation. À la sélection, le + * champ est rempli avec le texte d'adresse et les coordonnées associées sont mémorisées + * (réutilisation du helper `appliquerAdresse`). Disponible en modes `create` ET `insert` ; + * en insertion, la sélection remplace le contenu pré-rempli. Si l'Adresse_Nommee n'a pas + * de coordonnées (géocodage échoué), le texte est rempli mais les coordonnées restent + * nulles (le Backend les résoudra, Exigence 2.4). + */ +@Component({ + selector: 'app-trajet-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './trajet-form.component.html', + styleUrls: ['./trajet-form.component.scss'] +}) +export class TrajetFormComponent implements OnInit, OnDestroy { + private readonly fb = inject(FormBuilder); + private readonly banService = inject(BANService); + private readonly trajetService = inject(TrajetService); + private readonly profilService = inject(ProfilService); + private readonly adresseNommeeService = inject(AdresseNommeeService); + private readonly notifications = inject(NotificationService); + + /** Trajet à éditer (active le mode `edit`). */ + @Input() trajet: Trajet | null = null; + + /** Pré-remplissage pour le mode insertion (adresses Trajet_Précédent / Suivant). */ + @Input() prefill: TrajetInsertionPrefill | null = null; + + /** Véhicule cible du trajet (null → véhicule courant côté backend). */ + @Input() vehiculeId: number | null = null; + + /** + * Date par défaut (ISO `yyyy-MM-dd`) à pré-remplir en création / insertion : + * typiquement la journée actuellement affichée dans la consultation. En édition, + * la date du trajet prime (voir `appliquerPreRemplissage`). + */ + @Input() dateParDefaut: string | null = null; + + /** Émis après une création réussie (Exigence 1.8) — réutilisabilité du composant. */ + @Output() created = new EventEmitter(); + + /** Émis après une modification réussie. */ + @Output() updated = new EventEmitter(); + + /** Émis lorsque l'utilisateur annule la saisie. */ + @Output() cancelled = new EventEmitter(); + + /** Mode courant, dérivé des entrées. */ + mode: TrajetFormMode = 'create'; + + /** Formulaire réactif. */ + form!: FormGroup; + + /** Constantes exposées au template. */ + readonly adresseMax = ADRESSE_MAX; + readonly commentaireMax = COMMENTAIRE_MAX; + + /** + * Date du jour au format ISO `yyyy-MM-dd`, utilisée comme borne `max` de l'input + * date natif afin d'empêcher la sélection d'une date future côté UI (Exigence 12.5). + */ + readonly maxDate = this.isoAujourdHui(); + + /** Suggestions BAN par champ. */ + readonly suggestionsDepart = signal([]); + readonly suggestionsArrivee = signal([]); + + /** + * Adresses_Nommées du Carnet_Adresses proposées dans le sélecteur (Exigences 1.18, + * 11.10). Chargées à l'initialisation, triées par Dénomination côté backend. + */ + readonly adressesNommees = signal([]); + + /** Indique si l'autocomplétion BAN est temporairement indisponible (Exigence 1.16). */ + readonly banIndisponible = signal(false); + + /** Soumission en cours (désactive le bouton). */ + readonly soumissionEnCours = signal(false); + + /** Profil (pour le bouton « Utiliser mon domicile »). */ + private readonly profil = signal(null); + + /** Coordonnées mémorisées après sélection BAN (absentes si saisie manuelle). */ + private readonly coordDepart = signal(null); + private readonly coordArrivee = signal(null); + + /** + * Indique que l'adresse du champ provient d'une source fiable et résolvable + * (suggestion BAN, Adresse_Nommee du carnet, domicile du profil, ou pré-remplissage + * depuis un trajet existant en insertion) — même si les coordonnées ne sont pas + * disponibles côté frontend, le Backend saura les résoudre (Exigence 2.4). Dans ce + * cas, l'avertissement de coordonnées manquantes (Exigence 1.17) ne doit PAS s'afficher. + * Toute frappe manuelle repasse le champ en source non fiable. + */ + private readonly sourceFiableDepart = signal(false); + private readonly sourceFiableArrivee = signal(false); + + /** Flux de saisie utilisateur par champ, pour l'autocomplétion débouncée. */ + private readonly saisieDepart$ = new Subject(); + private readonly saisieArrivee$ = new Subject(); + private readonly destroy$ = new Subject(); + + ngOnInit(): void { + this.mode = this.determinerMode(); + this.form = this.construireFormulaire(); + this.appliquerPreRemplissage(); + this.configurerAutocompletion('depart', this.saisieDepart$); + this.configurerAutocompletion('arrivee', this.saisieArrivee$); + this.chargerProfil(); + this.chargerAdressesNommees(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // --- Getters template --- + + get dateCtrl() { + return this.form.get('date')!; + } + get departCtrl() { + return this.form.get('adresseDepart')!; + } + get arriveeCtrl() { + return this.form.get('adresseArrivee')!; + } + get commentaireCtrl() { + return this.form.get('commentaire')!; + } + + /** Vrai si le domicile est configuré (bouton « Utiliser mon domicile » actif, Exigences 10.5, 10.6). */ + get domicileDisponible(): boolean { + return !!this.profil()?.adresseDomicile?.trim(); + } + + /** Libellé de l'action principale selon le mode. */ + get libelleAction(): string { + return this.mode === 'edit' ? 'Enregistrer les modifications' : 'Ajouter le trajet'; + } + + /** + * Avertissement (Exigence 1.17) : une adresse a été saisie sans coordonnées + * (ni sélection BAN, ni domicile résolu). La distance pourrait ne pas être calculée. + */ + get avertissementCoordonneesManquantes(): boolean { + // On n'alerte que pour une adresse SAISIE MANUELLEMENT (source non fiable) sans + // coordonnées. Les adresses issues du domicile, du carnet, d'une suggestion BAN ou + // d'un pré-remplissage d'insertion sont résolvables côté Backend : pas d'alerte. + const departManquant = + !!this.departCtrl.value?.trim() && !this.coordDepart() && !this.sourceFiableDepart(); + const arriveeManquante = + !!this.arriveeCtrl.value?.trim() && !this.coordArrivee() && !this.sourceFiableArrivee(); + return departManquant || arriveeManquante; + } + + // --- Autocomplétion BAN --- + + /** Signale une frappe dans le champ adresse de départ. */ + onSaisieDepart(valeur: string): void { + // Toute frappe manuelle invalide la sélection BAN précédente (coordonnées obsolètes) + // et repasse le champ en source non fiable (adresse potentiellement non résolvable). + this.coordDepart.set(null); + this.sourceFiableDepart.set(false); + this.saisieDepart$.next(valeur); + } + + /** Signale une frappe dans le champ adresse d'arrivée. */ + onSaisieArrivee(valeur: string): void { + this.coordArrivee.set(null); + this.sourceFiableArrivee.set(false); + this.saisieArrivee$.next(valeur); + } + + /** Applique une suggestion BAN sélectionnée sur le champ départ (Exigence 1.14). */ + choisirSuggestionDepart(feature: BANFeature): void { + this.appliquerAdresse('depart', feature.properties.label, { + latitude: feature.geometry.coordinates[1], + longitude: feature.geometry.coordinates[0] + }); + this.suggestionsDepart.set([]); + } + + /** Applique une suggestion BAN sélectionnée sur le champ arrivée (Exigence 1.14). */ + choisirSuggestionArrivee(feature: BANFeature): void { + this.appliquerAdresse('arrivee', feature.properties.label, { + latitude: feature.geometry.coordinates[1], + longitude: feature.geometry.coordinates[0] + }); + this.suggestionsArrivee.set([]); + } + + /** Remplit le champ départ avec l'Adresse_Domicile du profil (Exigence 10.4). */ + utiliserDomicileDepart(): void { + this.remplirDepuisDomicile('depart'); + } + + /** Remplit le champ arrivée avec l'Adresse_Domicile du profil (Exigence 10.4). */ + utiliserDomicileArrivee(): void { + this.remplirDepuisDomicile('arrivee'); + } + + // --- Sélecteur d'Adresse_Nommee (carnet d'adresses) --- + + /** + * Applique l'Adresse_Nommee sélectionnée (par son id) sur le champ départ + * (Exigences 1.19, 11.11, 15.16). En insertion, remplace le contenu pré-rempli. + */ + choisirAdresseNommeeDepart(id: string): void { + this.appliquerAdresseNommee('depart', id); + } + + /** + * Applique l'Adresse_Nommee sélectionnée (par son id) sur le champ arrivée + * (Exigences 1.19, 11.11, 15.16). En insertion, remplace le contenu pré-rempli. + */ + choisirAdresseNommeeArrivee(id: string): void { + this.appliquerAdresseNommee('arrivee', id); + } + + // --- Soumission --- + + /** Soumet le formulaire (création ou modification selon le mode). */ + soumettre(): void { + if (this.form.invalid) { + // Affiche les erreurs synchrones sur tous les champs (Exigences 1.4, 1.5). + this.form.markAllAsTouched(); + return; + } + + this.soumissionEnCours.set(true); + + if (this.mode === 'edit' && this.trajet) { + this.modifier(this.trajet.id, this.construireUpdateDto()); + return; + } + + this.creer(this.construireCreateDto()); + } + + /** Annule la saisie et émet l'événement d'annulation. */ + annuler(): void { + this.cancelled.emit(); + } + + // --- Interne --- + + private determinerMode(): TrajetFormMode { + if (this.trajet) { + return 'edit'; + } + if (this.prefill) { + return 'insert'; + } + return 'create'; + } + + private construireFormulaire(): FormGroup { + return this.fb.group({ + // Le contrôle stocke la valeur ISO `yyyy-MM-dd` (format natif de l'input date) ; + // la conversion vers JJ/MM/AAAA attendue par le backend est faite dans les DTO. + date: ['', [Validators.required, dateFutureInterditeValidator(() => this.maxDate)]], + adresseDepart: [ + '', + [Validators.required, Validators.minLength(1), Validators.maxLength(ADRESSE_MAX)] + ], + adresseArrivee: [ + '', + [Validators.required, Validators.minLength(1), Validators.maxLength(ADRESSE_MAX)] + ], + commentaire: ['', [Validators.maxLength(COMMENTAIRE_MAX)]] + }); + } + + private appliquerPreRemplissage(): void { + if (this.mode === 'edit' && this.trajet) { + this.form.patchValue({ + // L'input date natif attend une valeur ISO `yyyy-MM-dd`. + date: this.versIsoInput(this.trajet.date), + adresseDepart: this.trajet.adresseDepart, + adresseArrivee: this.trajet.adresseArrivee, + commentaire: this.trajet.commentaire ?? '' + }); + // 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 + // trajet existant sont déjà résolues → source fiable, pas d'avertissement tant + // que l'utilisateur ne les modifie pas (une frappe repassera en source non fiable). + if (this.trajet.adresseDepart?.trim()) { + this.sourceFiableDepart.set(true); + } + if (this.trajet.adresseArrivee?.trim()) { + this.sourceFiableArrivee.set(true); + } + this.vehiculeId = this.trajet.vehiculeId ?? this.vehiculeId; + return; + } + + // Création / insertion : pré-remplit la date avec la journée affichée si fournie. + const dateDefaut = this.versIsoInput(this.dateParDefaut ?? ''); + if (dateDefaut) { + this.form.patchValue({ date: dateDefaut }); + } + + if (this.mode === 'insert' && this.prefill) { + this.form.patchValue({ + adresseDepart: this.prefill.adresseDepart ?? '', + adresseArrivee: this.prefill.adresseArrivee ?? '' + }); + // Les adresses pré-remplies proviennent de trajets existants (déjà résolus par le + // Backend) : source fiable, pas d'avertissement de coordonnées manquantes. + if (this.prefill.adresseDepart?.trim()) { + this.sourceFiableDepart.set(true); + } + if (this.prefill.adresseArrivee?.trim()) { + this.sourceFiableArrivee.set(true); + } + if (this.prefill.vehiculeId != null) { + this.vehiculeId = this.prefill.vehiculeId; + } + } + } + + private configurerAutocompletion(champ: ChampAdresse, source$: Subject): void { + source$ + .pipe( + debounceTime(BAN_DEBOUNCE_MS), + map((valeur) => (valeur ?? '').trim()), + distinctUntilChanged(), + filter((valeur) => valeur.length >= BAN_MIN_CARACTERES), + switchMap((valeur) => + this.banService.searchAdresses(valeur).pipe( + map((features) => ({ ok: true as const, features })), + catchError(() => of({ ok: false as const, features: [] as BANFeature[] })) + ) + ), + takeUntil(this.destroy$) + ) + .subscribe((resultat) => { + // Indisponibilité BAN → mode dégradé, saisie libre autorisée (Exigence 1.16). + this.banIndisponible.set(!resultat.ok); + if (champ === 'depart') { + this.suggestionsDepart.set(resultat.features); + } else { + this.suggestionsArrivee.set(resultat.features); + } + }); + } + + private appliquerAdresse( + champ: ChampAdresse, + libelle: string, + coord: Coordonnees | null + ): void { + const controle = champ === 'depart' ? this.departCtrl : this.arriveeCtrl; + controle.setValue(libelle); + controle.markAsTouched(); + if (champ === 'depart') { + this.coordDepart.set(coord); + this.sourceFiableDepart.set(true); + this.suggestionsDepart.set([]); + } else { + this.coordArrivee.set(coord); + this.sourceFiableArrivee.set(true); + this.suggestionsArrivee.set([]); + } + } + + /** + * Applique une Adresse_Nommee sur le champ visé (Exigences 1.19, 11.11, 15.16) : + * remplit le texte d'adresse et associe ses coordonnées via `appliquerAdresse`. + * Si l'Adresse_Nommee n'a pas de coordonnées (géocodage échoué), le texte est rempli + * mais les coordonnées restent nulles — le Backend les résoudra (Exigence 2.4). + * En mode insertion, remplace le contenu pré-rempli (Exigence 11.11). + */ + private appliquerAdresseNommee(champ: ChampAdresse, id: string): void { + if (!id) { + return; + } + const adresse = this.adressesNommees().find((a) => String(a.id) === id); + if (!adresse) { + return; + } + const coord = + adresse.latitude != null && adresse.longitude != null + ? { latitude: adresse.latitude, longitude: adresse.longitude } + : null; + this.appliquerAdresse(champ, adresse.adresse, coord); + } + + private remplirDepuisDomicile(champ: ChampAdresse): void { + const adresse = this.profil()?.adresseDomicile?.trim(); + if (!adresse) { + return; + } + // On remplit le libellé du domicile ; les coordonnées seront résolues côté + // backend via le Cache_Adresses ou la BAN (Exigence 2.4). On ne dispose pas + // des coordonnées du domicile dans le profil exposé au frontend. + const controle = champ === 'depart' ? this.departCtrl : this.arriveeCtrl; + controle.setValue(adresse); + controle.markAsTouched(); + if (champ === 'depart') { + this.coordDepart.set(null); + this.sourceFiableDepart.set(true); + this.suggestionsDepart.set([]); + } else { + this.coordArrivee.set(null); + this.sourceFiableArrivee.set(true); + this.suggestionsArrivee.set([]); + } + } + + private chargerProfil(): void { + this.profilService + .getProfil() + .pipe( + catchError(() => of(null)), + takeUntil(this.destroy$) + ) + .subscribe((profil) => this.profil.set(profil)); + } + + /** + * Charge les Adresses_Nommées du Carnet_Adresses pour alimenter le sélecteur + * (Exigences 1.18, 11.10). En cas d'échec, on reste en mode saisie manuelle : le + * sélecteur affiche simplement une liste vide, sans bloquer le formulaire. + */ + private chargerAdressesNommees(): void { + this.adresseNommeeService + .getAdressesNommees() + .pipe( + catchError(() => of([] as AdresseNommee[])), + takeUntil(this.destroy$) + ) + .subscribe((adresses) => this.adressesNommees.set(adresses)); + } + + private construireCreateDto(): CreateTrajet { + const coordDepart = this.coordDepart(); + const coordArrivee = this.coordArrivee(); + return { + date: this.isoVersJjMmAAAA(this.dateCtrl.value), + adresseDepart: this.departCtrl.value.trim(), + departLatitude: coordDepart?.latitude ?? null, + departLongitude: coordDepart?.longitude ?? null, + adresseArrivee: this.arriveeCtrl.value.trim(), + arriveeLatitude: coordArrivee?.latitude ?? null, + arriveeLongitude: coordArrivee?.longitude ?? null, + commentaire: this.normaliserCommentaire(), + vehiculeId: this.vehiculeId + }; + } + + private construireUpdateDto(): UpdateTrajet { + const coordDepart = this.coordDepart(); + const coordArrivee = this.coordArrivee(); + return { + date: this.isoVersJjMmAAAA(this.dateCtrl.value), + adresseDepart: this.departCtrl.value.trim(), + departLatitude: coordDepart?.latitude ?? null, + departLongitude: coordDepart?.longitude ?? null, + adresseArrivee: this.arriveeCtrl.value.trim(), + arriveeLatitude: coordArrivee?.latitude ?? null, + arriveeLongitude: coordArrivee?.longitude ?? null, + commentaire: this.normaliserCommentaire(), + vehiculeId: this.vehiculeId + }; + } + + private normaliserCommentaire(): string | null { + const valeur = (this.commentaireCtrl.value ?? '').trim(); + return valeur.length > 0 ? valeur : null; + } + + private creer(dto: CreateTrajet): void { + this.trajetService + .createTrajet(dto) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (trajet) => { + this.soumissionEnCours.set(false); + this.notifications.success('Trajet enregistré.'); + this.created.emit(trajet); + this.reinitialiser(); + }, + error: (err) => this.gererErreurSoumission(err) + }); + } + + private modifier(id: number, dto: UpdateTrajet): void { + this.trajetService + .updateTrajet(id, dto) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (trajet) => { + this.soumissionEnCours.set(false); + this.notifications.success('Trajet modifié.'); + this.updated.emit(trajet); + }, + error: (err) => this.gererErreurSoumission(err) + }); + } + + private gererErreurSoumission(erreur: unknown): void { + this.soumissionEnCours.set(false); + // Cas serveur indisponible (timeout 10 s, Exigence 1.9) : on conserve la saisie. + // La notification d'indisponibilité est déjà émise par l'intercepteur. + if (erreur instanceof ServeurIndisponibleError) { + return; + } + this.notifications.error("L'enregistrement du trajet a échoué. Veuillez vérifier les données."); + } + + private reinitialiser(): void { + this.form.reset({ + date: '', + adresseDepart: '', + adresseArrivee: '', + commentaire: '' + }); + this.coordDepart.set(null); + this.coordArrivee.set(null); + this.sourceFiableDepart.set(false); + this.sourceFiableArrivee.set(false); + this.suggestionsDepart.set([]); + this.suggestionsArrivee.set([]); + this.banIndisponible.set(false); + } + + /** Convertit une date ISO (yyyy-MM-dd) en JJ/MM/AAAA attendue par le backend. */ + private isoVersJjMmAAAA(iso: string): string { + const valeur = (iso ?? '').trim(); + if (!valeur) { + return ''; + } + // Date déjà au format JJ/MM/AAAA : renvoyée telle quelle. + if (estDateJjMmAAAAValide(valeur)) { + return valeur; + } + const correspondance = /^(\d{4})-(\d{2})-(\d{2})/.exec(valeur); + if (!correspondance) { + return valeur; + } + const [, annee, mois, jour] = correspondance; + return `${jour}/${mois}/${annee}`; + } + + /** + * Convertit une date (ISO `yyyy-MM-dd` ou JJ/MM/AAAA) vers la valeur ISO + * `yyyy-MM-dd` attendue par l'input date natif. Retourne `''` si non convertible. + */ + private versIsoInput(valeur: string): string { + const v = (valeur ?? '').trim(); + if (!v) { + return ''; + } + // Déjà ISO (éventuellement avec suffixe horaire) : on tronque à 10 caractères. + const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(v); + if (iso) { + return `${iso[1]}-${iso[2]}-${iso[3]}`; + } + // Format JJ/MM/AAAA → ISO. + const jjmm = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(v); + if (jjmm) { + return `${jjmm[3]}-${jjmm[2]}-${jjmm[1]}`; + } + return ''; + } + + /** Date du jour au format ISO local `yyyy-MM-dd`. */ + private isoAujourdHui(): string { + const d = new Date(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${mm}-${dd}`; + } +} + +/** + * Validateur pour un contrôle de date ISO `yyyy-MM-dd` : rejette une date postérieure + * à `maxDateIso()` (aujourd'hui) via l'erreur `{ dateFuture: true }` (Exigence 12.5). + * Une valeur vide est laissée à `Validators.required`. + */ +function dateFutureInterditeValidator(maxDateIso: () => string): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const valeur = control.value; + if (valeur === null || valeur === undefined || valeur === '') { + return null; + } + if (typeof valeur !== 'string') { + return { dateInvalide: true }; + } + // Comparaison lexicographique valide sur des dates ISO `yyyy-MM-dd`. + return valeur.slice(0, 10) > maxDateIso() ? { dateFuture: true } : null; + }; +} diff --git a/frontend/src/app/features/trajet-list/trajet-list.component.html b/frontend/src/app/features/trajet-list/trajet-list.component.html new file mode 100644 index 0000000..d1f7101 --- /dev/null +++ b/frontend/src/app/features/trajet-list/trajet-list.component.html @@ -0,0 +1,110 @@ +@if (trajetsTries().length === 0) { +

Aucun trajet pour cette journée.

+} @else { +
    + @for (trajet of trajetsTries(); track trajet.id; let i = $index) { +
  • + + + +
    +
    + {{ trajet.date | date: 'dd/MM/yyyy' }} + @if (trajet.commentaire) { + {{ trajet.commentaire }} + } +
    +
    + {{ trajet.adresseDepart }} + + {{ trajet.adresseArrivee }} +
    +
    + +
    {{ distanceAffichee(trajet) }}
    + +
    +
    + + +
    +
    + @if (!estDernier(i)) { + + } + +
    +
    +
  • + } +
+} + + + diff --git a/frontend/src/app/features/trajet-list/trajet-list.component.scss b/frontend/src/app/features/trajet-list/trajet-list.component.scss new file mode 100644 index 0000000..489bbb3 --- /dev/null +++ b/frontend/src/app/features/trajet-list/trajet-list.component.scss @@ -0,0 +1,164 @@ +.liste-vide { + margin: 0; + padding: 1.5rem; + text-align: center; + color: #6b7280; + font-style: italic; +} + +.trajets { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.trajet { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 0.75rem; + border: 1px solid #e2e6ea; + border-radius: 8px; + background: #fff; + + &.cdk-drag-preview { + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); + } + + &.cdk-drag-placeholder { + opacity: 0.4; + } + + &__infos { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; + } + + &__ligne { + display: flex; + align-items: center; + gap: 0.5rem; + } + + &__date { + font-weight: 600; + font-size: 0.85rem; + color: #475569; + } + + &__commentaire { + font-size: 0.78rem; + color: #6b7280; + font-style: italic; + line-height: 1.3; + word-break: break-word; + // Séparateur visuel discret entre la date et le commentaire. + &::before { + content: '— '; + color: #cbd5e1; + } + } + + &__adresses { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + // Adresses en taille réduite (secondaires par rapport à la date/commentaire). + font-size: 0.8rem; + color: #334155; + } + + &__fleche { + color: #94a3b8; + } + + &__distance { + flex: 0 0 auto; + font-weight: 600; + color: #1f2937; + white-space: nowrap; + } + + &__actions { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 0.4rem; + align-items: flex-end; + } + + &__actions-ligne { + display: flex; + gap: 0.4rem; + justify-content: flex-end; + } +} + +.cdk-drag-animating { + transition: transform 200ms cubic-bezier(0, 0, 0.2, 1); +} + +.poignee { + flex: 0 0 auto; + border: none; + background: transparent; + color: #94a3b8; + font-size: 1.1rem; + cursor: grab; + padding: 0.25rem; + + &:active { + cursor: grabbing; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.action { + padding: 0.35rem 0.65rem; + border: 1px solid #c4c9d1; + border-radius: 6px; + background: #fff; + color: #33475b; + font-size: 0.8rem; + cursor: pointer; + + &:hover:not(:disabled) { + background: #f3f5f8; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &--danger { + border-color: #e3a3a5; + color: #d13438; + + &:hover:not(:disabled) { + background: #fdecec; + } + } + + &--icone { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + font-size: 1rem; + line-height: 1; + } +} diff --git a/frontend/src/app/features/trajet-list/trajet-list.component.ts b/frontend/src/app/features/trajet-list/trajet-list.component.ts new file mode 100644 index 0000000..ad908c2 --- /dev/null +++ b/frontend/src/app/features/trajet-list/trajet-list.component.ts @@ -0,0 +1,276 @@ +import { CommonModule } from '@angular/common'; +import { + CdkDragDrop, + DragDropModule, + moveItemInArray +} from '@angular/cdk/drag-drop'; +import { + Component, + EventEmitter, + Input, + Output, + inject, + signal +} from '@angular/core'; + +import { ReordonnerTrajets, Trajet, TrajetPosition } from '../../core/models'; +import { NotificationService } from '../../core/services/notification.service'; +import { TrajetService } from '../../core/services/trajet.service'; +import { ServeurIndisponibleError } from '../../core/interceptors/timeout.interceptor'; +import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component'; +import { TrajetInsertionPrefill } from '../trajet-form/trajet-form.component'; + +/** + * Contexte d'insertion émis vers le conteneur (ConsultationJour) : le pré-remplissage + * du `TrajetFormComponent` et le repère de position. + * + * - `insérer après` (Exigence 11.1/11.2) : `adresseDepart` = arrivée du trajet courant, + * `positionApres` = Position du trajet courant. + * - `insérer avant le suivant` (Exigence 11.3/11.4) : les deux adresses sont pré-remplies + * et la position d'insertion cible se situe entre `positionApres` et `positionAvant`. + * + * Le composant conteneur reste responsable de l'ouverture du formulaire en mode + * `insert` et du calcul final de position côté backend. + */ +export interface TrajetInsertionContext { + prefill: TrajetInsertionPrefill; + /** Position du trajet après lequel on insère (borne basse). */ + positionApres: number; + /** Position du trajet suivant (borne haute) — présente pour « insérer avant le suivant ». */ + positionAvant?: number; +} + +/** + * `TrajetListComponent` — liste des trajets d'une journée (Exigences 5.1, 5.4, 5.7, 11.6, 11.9). + * + * Affichage : une ligne par trajet, triée par `Position` croissante, avec date, + * adresse de départ, adresse d'arrivée, distance et une icône commentaire (avec + * tooltip) lorsqu'un commentaire est présent. + * + * Actions par trajet : + * - modifier → émet `edit(trajet)` (le conteneur ouvre `TrajetFormComponent` pré-rempli) ; + * - supprimer → confirmation puis `TrajetService.deleteTrajet` (Exigence 5.4) ; + * - insérer après → émet `insertAfter` avec `adresseDepart` = arrivée du trajet courant ; + * - insérer avant le suivant (si le trajet n'est pas le dernier) → émet `insertBetween` + * avec les deux adresses pré-remplies. + * + * Réordonnancement par glisser-déposer (Angular CDK DragDrop, Exigence 11.9) : au dépôt, + * les positions sont recalculées et `TrajetService.reordonner` est appelé. + * + * Rafraîchissement (Exigences 5.7, 11.6) : après suppression ou réordonnancement réussi, + * le composant émet `changed` afin que le conteneur recharge la journée sans rechargement + * complet de la page (< 1 000 ms). + * + * Le composant est réutilisable : la sélection de véhicule, le chargement des trajets et + * l'ouverture du formulaire sont gérés par le conteneur `ConsultationJourComponent` (16.4). + */ +@Component({ + selector: 'app-trajet-list', + standalone: true, + imports: [CommonModule, DragDropModule, ConfirmDialogComponent], + templateUrl: './trajet-list.component.html', + styleUrls: ['./trajet-list.component.scss'] +}) +export class TrajetListComponent { + private readonly trajetService = inject(TrajetService); + private readonly notifications = inject(NotificationService); + + /** + * Trajets de la journée. Toujours affichés triés par `Position` croissante : + * une copie triée est maintenue dans `trajetsTries` à chaque changement d'entrée. + */ + @Input() set trajets(valeur: Trajet[] | null) { + this._trajets = valeur ?? []; + this.trajetsTries.set([...this._trajets].sort((a, b) => a.position - b.position)); + } + get trajets(): Trajet[] { + return this._trajets; + } + private _trajets: Trajet[] = []; + + /** Émis lorsque l'utilisateur demande la modification d'un trajet. */ + @Output() edit = new EventEmitter(); + + /** Émis pour insérer un nouveau trajet après le trajet courant (Exigence 11.1/11.2). */ + @Output() insertAfter = new EventEmitter(); + + /** Émis pour insérer un trajet entre le courant et le suivant (Exigence 11.3/11.4). */ + @Output() insertBetween = new EventEmitter(); + + /** + * Émis lorsque la liste a changé (suppression, réordonnancement) et que le + * conteneur doit recharger la journée / le résumé mensuel (Exigences 5.7, 11.6). + */ + @Output() changed = new EventEmitter(); + + /** + * Émis au survol d'une ligne de trajet : l'id du trajet survolé au `mouseenter`, + * `null` au `mouseleave`. Permet au conteneur de mettre en évidence le tracé + * correspondant sur la carte. + */ + @Output() survol = new EventEmitter(); + + /** Émet l'id du trajet survolé (mouseenter) ou null (mouseleave). */ + onSurvol(id: number | null): void { + this.survol.emit(id); + } + + /** Liste triée par Position (source d'affichage). */ + readonly trajetsTries = signal([]); + + /** Trajet en attente de confirmation de suppression (null si aucune). */ + readonly trajetASupprimer = signal(null); + + /** True pendant l'appel de suppression / réordonnancement (désactive les actions). */ + readonly operationEnCours = signal(false); + + // --- Helpers d'affichage --- + + /** Vrai si le trajet est le dernier de la journée (pas d'« insérer avant le suivant »). */ + estDernier(index: number): boolean { + return index === this.trajetsTries().length - 1; + } + + /** Distance formatée à 2 décimales, ou « — » si non calculée (Exigence 2.4). */ + distanceAffichee(trajet: Trajet): string { + if (trajet.distance == null) { + return '—'; + } + return `${trajet.distance.toFixed(2)} km`; + } + + /** Identité stable pour le suivi de la liste (drag & drop). */ + trackById(_index: number, trajet: Trajet): number { + return trajet.id; + } + + // --- Actions par trajet --- + + /** Demande la modification d'un trajet (Exigence 5.1). */ + modifier(trajet: Trajet): void { + this.edit.emit(trajet); + } + + /** + * Insérer après le trajet courant : pré-remplit l'adresse de départ avec + * l'adresse d'arrivée du trajet courant (Exigence 11.1/11.2). + */ + insererApres(trajet: Trajet): void { + this.insertAfter.emit({ + prefill: { + adresseDepart: trajet.adresseArrivee, + vehiculeId: trajet.vehiculeId + }, + positionApres: trajet.position + }); + } + + /** + * Insérer avant le trajet suivant : pré-remplit l'adresse de départ avec l'arrivée + * du trajet courant et l'adresse d'arrivée avec le départ du trajet suivant + * (Exigence 11.3/11.4). + */ + insererAvantSuivant(index: number): void { + const trajetsTries = this.trajetsTries(); + const courant = trajetsTries[index]; + const suivant = trajetsTries[index + 1]; + if (!suivant) { + return; + } + this.insertBetween.emit({ + prefill: { + adresseDepart: courant.adresseArrivee, + adresseArrivee: suivant.adresseDepart, + vehiculeId: courant.vehiculeId + }, + positionApres: courant.position, + positionAvant: suivant.position + }); + } + + // --- Suppression (avec confirmation, Exigence 5.4) --- + + /** Ouvre la confirmation de suppression. */ + demanderSuppression(trajet: Trajet): void { + this.trajetASupprimer.set(trajet); + } + + /** Annule la suppression. */ + annulerSuppression(): void { + this.trajetASupprimer.set(null); + } + + /** Confirme et exécute la suppression (Exigence 5.4), puis notifie le rafraîchissement. */ + confirmerSuppression(): void { + const trajet = this.trajetASupprimer(); + if (!trajet) { + return; + } + this.trajetASupprimer.set(null); + this.operationEnCours.set(true); + + this.trajetService.deleteTrajet(trajet.id).subscribe({ + next: () => { + this.operationEnCours.set(false); + this.notifications.success('Trajet supprimé.'); + // Rafraîchissement sans rechargement de page (Exigences 5.7, 11.6). + this.changed.emit(); + }, + error: (err) => this.gererErreur(err, 'La suppression du trajet a échoué.') + }); + } + + // --- Glisser-déposer (Exigence 11.9) --- + + /** + * Au dépôt d'un trajet : réordonne localement, recalcule les positions (pas de 1.0) + * et persiste via `PATCH /api/trajets/reordonner`. En cas d'échec, l'ordre local est + * rétabli à partir des positions d'origine. + */ + onDrop(event: CdkDragDrop): void { + if (event.previousIndex === event.currentIndex) { + return; + } + + const ordreInitial = [...this.trajetsTries()]; + const reordonnes = [...this.trajetsTries()]; + moveItemInArray(reordonnes, event.previousIndex, event.currentIndex); + + // Recalcule les positions séquentielles (1.0, 2.0, …) selon le nouvel ordre. + const positions: TrajetPosition[] = reordonnes.map((trajet, index) => ({ + id: trajet.id, + position: index + 1 + })); + + // Reflète immédiatement le nouvel ordre dans l'UI (feedback < 1 000 ms). + this.trajetsTries.set(reordonnes.map((trajet, index) => ({ + ...trajet, + position: index + 1 + }))); + + this.operationEnCours.set(true); + const dto: ReordonnerTrajets = { trajets: positions }; + + this.trajetService.reordonner(dto).subscribe({ + next: () => { + this.operationEnCours.set(false); + // Recharge la journée / le résumé mensuel (Exigences 5.7, 11.6). + this.changed.emit(); + }, + error: (err) => { + // Rétablit l'ordre précédent en cas d'échec. + this.trajetsTries.set(ordreInitial); + this.gererErreur(err, 'Le réordonnancement a échoué.'); + } + }); + } + + private gererErreur(erreur: unknown, message: string): void { + this.operationEnCours.set(false); + // L'intercepteur affiche déjà le message d'indisponibilité serveur (Exigence 1.9). + if (erreur instanceof ServeurIndisponibleError) { + return; + } + this.notifications.error(message); + } +} diff --git a/frontend/src/app/features/vehicules/reaffectation-dialog.component.ts b/frontend/src/app/features/vehicules/reaffectation-dialog.component.ts new file mode 100644 index 0000000..9bbe5c1 --- /dev/null +++ b/frontend/src/app/features/vehicules/reaffectation-dialog.component.ts @@ -0,0 +1,115 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +/** + * Dialog (modale) de proposition de réaffectation des trajets à un nouveau véhicule. + * + * Affichée lorsque la création d'un véhicule renvoie `reaffectationProposee = true` + * (des trajets dont la date >= date d'effet ont une puissance fiscale différente). + * + * Deux choix (Exigences 10.16 / 10.17) : + * - "Réaffecter au nouveau véhicule" → émet `decision` avec `accepter = true`. + * - "Conserver l'existant" → émet `decision` avec `accepter = false`. + * + * Composant modale minimaliste sans dépendance UI externe. + */ +@Component({ + selector: 'app-reaffectation-dialog', + standalone: true, + imports: [CommonModule], + template: ` + + `, + styles: [` + .reaffectation-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + .reaffectation-dialog { + background: #fff; + border-radius: 8px; + padding: 1.5rem; + max-width: 480px; + width: calc(100% - 2rem); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + } + .reaffectation-dialog h2 { + margin-top: 0; + font-size: 1.15rem; + } + .reaffectation-message { + color: #444; + } + .reaffectation-question { + font-weight: 600; + } + .reaffectation-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.25rem; + } + button { + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; + } + .btn-principal { + background: #1565c0; + color: #fff; + border: 1px solid #1565c0; + } + .btn-secondaire { + background: #fff; + color: #333; + border: 1px solid #bbb; + } + `] +}) +export class ReaffectationDialogComponent { + /** Nombre de trajets concernés par la réaffectation (Exigence 10.15). */ + @Input() nombreTrajetsConcernes = 0; + + /** Émis avec la décision de l'utilisateur : true = réaffecter, false = conserver. */ + @Output() decision = new EventEmitter(); + + /** "Réaffecter au nouveau véhicule" (accepter = true, Exigence 10.16). */ + reaffecter(): void { + this.decision.emit(true); + } + + /** "Conserver l'existant" (accepter = false, Exigence 10.17). */ + conserver(): void { + this.decision.emit(false); + } +} diff --git a/frontend/src/app/features/vehicules/vehicules.component.html b/frontend/src/app/features/vehicules/vehicules.component.html new file mode 100644 index 0000000..d41528f --- /dev/null +++ b/frontend/src/app/features/vehicules/vehicules.component.html @@ -0,0 +1,79 @@ +
+

Véhicules

+ +

Ajouter un véhicule

+
+
+ + + @if (form.get('denomination')?.touched && form.get('denomination')?.invalid) { + La dénomination est obligatoire (1 à 100 caractères). + } +
+ +
+ + + @if (form.get('plaque')?.touched && form.get('plaque')?.invalid) { + La plaque est obligatoire (1 à 15 caractères). + } +
+ +
+ + + @if (form.get('puissanceFiscale')?.touched && form.get('puissanceFiscale')?.invalid) { + La puissance fiscale doit être un entier positif. + } +
+ +
+ + + @if (form.get('dateEffet')?.touched && form.get('dateEffet')?.errors?.['dateFuture']) { + La date d'effet ne peut pas être dans le futur. + } @else if (form.get('dateEffet')?.touched && form.get('dateEffet')?.errors?.['required']) { + La date d'effet est obligatoire. + } +
+ +
+ +
+
+ +

Historique des véhicules

+ @if (vehicules().length === 0) { +

Aucun véhicule enregistré.

+ } @else { + + + + + + + + + + + @for (vehicule of vehicules(); track vehicule.id) { + + + + + + + } + +
DénominationPlaquePuissance fiscaleDate d'effet
{{ vehicule.denomination }}{{ vehicule.plaque }}{{ vehicule.puissanceFiscale }} CV{{ vehicule.dateEffet | date: 'dd/MM/yyyy' }}
+ } +
+ +@if (reaffectationEnAttente) { + +} diff --git a/frontend/src/app/features/vehicules/vehicules.component.scss b/frontend/src/app/features/vehicules/vehicules.component.scss new file mode 100644 index 0000000..faa43a3 --- /dev/null +++ b/frontend/src/app/features/vehicules/vehicules.component.scss @@ -0,0 +1,77 @@ +.vehicules { + padding: 1.5rem; + max-width: 720px; +} + +h1 { + font-size: 1.4rem; + margin-bottom: 1rem; +} + +h2 { + font-size: 1.1rem; + margin: 1.5rem 0 0.75rem; +} + +.champ { + display: flex; + flex-direction: column; + margin-bottom: 1rem; +} + +.champ label { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.champ input { + padding: 0.5rem; + border: 1px solid #bbb; + border-radius: 6px; + font-size: 0.95rem; +} + +.erreur { + color: #c62828; + margin-top: 0.25rem; +} + +.actions { + margin-top: 0.5rem; +} + +.btn-principal { + padding: 0.55rem 1.25rem; + background: #1565c0; + color: #fff; + border: 1px solid #1565c0; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; +} + +.btn-principal:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.vide { + color: #777; +} + +.historique { + width: 100%; + border-collapse: collapse; +} + +.historique th, +.historique td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #e0e0e0; +} + +.historique th { + font-weight: 600; + background: #f5f5f5; +} diff --git a/frontend/src/app/features/vehicules/vehicules.component.ts b/frontend/src/app/features/vehicules/vehicules.component.ts new file mode 100644 index 0000000..0a13420 --- /dev/null +++ b/frontend/src/app/features/vehicules/vehicules.component.ts @@ -0,0 +1,154 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject, signal } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { NotificationService } from '../../core/services/notification.service'; +import { VehiculeService } from '../../core/services/vehicule.service'; +import { CreateVehicule, Reaffectation, Vehicule, VehiculeCree } from '../../core/models'; +import { ReaffectationDialogComponent } from './reaffectation-dialog.component'; + +/** + * Gestion des véhicules (Exigences 10.9, 10.10, 10.11, 10.15). + * + * - Affiche l'historique des véhicules (plaque, puissance fiscale, date d'effet), + * trié par date d'effet décroissante. + * - Formulaire d'ajout : plaque, puissance fiscale, date d'effet <= aujourd'hui + * (le sélecteur de date bloque les dates futures via l'attribut `max`). + * - À la création, si `reaffectationProposee` est true, ouvre le dialog de + * réaffectation. + */ +@Component({ + selector: 'app-vehicules', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, ReaffectationDialogComponent], + templateUrl: './vehicules.component.html', + styleUrl: './vehicules.component.scss' +}) +export class VehiculesComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly vehiculeService = inject(VehiculeService); + private readonly notification = inject(NotificationService); + + form!: FormGroup; + + /** Historique des véhicules trié par date d'effet décroissante (signal). */ + readonly vehicules = signal([]); + + /** Date du jour au format ISO (yyyy-MM-dd) pour borner le sélecteur de date. */ + readonly aujourdhuiIso = new Date().toISOString().slice(0, 10); + + /** true pendant l'appel de création. */ + enregistrement = false; + + /** Résultat de création en attente de décision de réaffectation (dialog ouvert si non nul). */ + reaffectationEnAttente: VehiculeCree | null = null; + + ngOnInit(): void { + this.form = this.fb.group({ + denomination: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]], + plaque: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(15)]], + puissanceFiscale: [null, [Validators.required, Validators.min(1)]], + dateEffet: ['', [Validators.required, this.dateNonFutureValidator()]] + }); + + this.chargerVehicules(); + } + + /** Charge et trie l'historique des véhicules. */ + private chargerVehicules(): void { + this.vehiculeService.getVehicules().subscribe({ + next: (liste) => { + this.vehicules.set( + [...liste].sort((a, b) => b.dateEffet.localeCompare(a.dateEffet)) + ); + }, + error: () => { + this.notification.error("Impossible de charger l'historique des véhicules."); + } + }); + } + + /** + * Validateur bloquant les dates postérieures à aujourd'hui (Exigence 10.11). + * La valeur du contrôle est au format ISO (yyyy-MM-dd) issu de l'input date. + */ + private dateNonFutureValidator() { + return (control: { value: unknown }) => { + const valeur = control.value as string; + if (!valeur) { + return null; + } + return valeur > this.aujourdhuiIso ? { dateFuture: true } : null; + }; + } + + /** Enregistre un nouveau véhicule via `VehiculeService.createVehicule`. */ + enregistrer(): void { + if (this.form.invalid || this.enregistrement) { + this.form.markAllAsTouched(); + return; + } + + const valeur = this.form.getRawValue(); + const dto: CreateVehicule = { + denomination: (valeur.denomination ?? '').trim(), + plaque: (valeur.plaque ?? '').trim(), + puissanceFiscale: Number(valeur.puissanceFiscale), + dateEffet: this.isoVersJjMmAAAA(valeur.dateEffet) + }; + + this.enregistrement = true; + this.vehiculeService.createVehicule(dto).subscribe({ + next: (resultat) => { + this.enregistrement = false; + this.notification.success('Véhicule enregistré.'); + this.form.reset({ denomination: '', plaque: '', puissanceFiscale: null, dateEffet: '' }); + this.chargerVehicules(); + + if (resultat.reaffectationProposee) { + this.reaffectationEnAttente = resultat; + } + }, + error: () => { + this.enregistrement = false; + this.notification.error("Échec de l'enregistrement du véhicule."); + } + }); + } + + /** Nombre de trajets concernés par la réaffectation en attente. */ + get nombreTrajetsConcernes(): number { + return this.reaffectationEnAttente?.nombreTrajetsConcernes ?? 0; + } + + /** + * Décision de l'utilisateur dans le dialog de réaffectation. + * @param accepter true = réaffecter (Exigence 10.16), false = conserver (Exigence 10.17). + */ + onDecisionReaffectation(accepter: boolean): void { + const cree = this.reaffectationEnAttente; + if (!cree) { + return; + } + + const dto: Reaffectation = { vehiculeId: cree.vehicule.id, accepter }; + this.vehiculeService.reaffecter(dto).subscribe({ + next: () => { + this.reaffectationEnAttente = null; + this.notification.success( + accepter ? 'Trajets réaffectés au nouveau véhicule.' : 'Trajets existants conservés.' + ); + }, + error: () => { + this.reaffectationEnAttente = null; + this.notification.error('Échec du traitement de la réaffectation.'); + } + }); + } + + /** Conversion ISO (yyyy-MM-dd) → JJ/MM/AAAA attendu par le backend. */ + private isoVersJjMmAAAA(iso: string): string { + const [annee, mois, jour] = iso.split('-'); + return `${jour}/${mois}/${annee}`; + } +} diff --git a/frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.html b/frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.html new file mode 100644 index 0000000..3e3224a --- /dev/null +++ b/frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.html @@ -0,0 +1,31 @@ +@if (open) { + +} diff --git a/frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.scss b/frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.scss new file mode 100644 index 0000000..25c991a --- /dev/null +++ b/frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.scss @@ -0,0 +1,72 @@ +.overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.45); + z-index: 1000; + padding: 1rem; +} + +.dialog { + background: #fff; + border-radius: 10px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25); + max-width: 420px; + width: 100%; + padding: 1.25rem 1.5rem 1.5rem; + + &__titre { + margin: 0 0 0.5rem; + font-size: 1.15rem; + color: #1f2937; + } + + &__message { + margin: 0 0 1.25rem; + color: #374151; + line-height: 1.4; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + + button { + padding: 0.55rem 1.1rem; + border-radius: 6px; + font: inherit; + cursor: pointer; + } + } +} + +.btn-secondaire { + border: 1px solid #c4c9d1; + background: #fff; + color: #33475b; + + &:hover { + background: #f3f5f8; + } +} + +.btn-confirmer { + border: none; + background: #2b6cb0; + color: #fff; + + &:hover { + background: #245a94; + } + + &--danger { + background: #d13438; + + &:hover { + background: #b12b2e; + } + } +} diff --git a/frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts b/frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts new file mode 100644 index 0000000..57b33c9 --- /dev/null +++ b/frontend/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts @@ -0,0 +1,56 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +/** + * `ConfirmDialogComponent` — boîte de dialogue de confirmation réutilisable. + * + * Affichée en surimpression (overlay modal) lorsque `open` est `true`. Émet + * `confirmed` si l'utilisateur valide et `cancelled` s'il annule (ou ferme via + * l'arrière-plan / la touche Échap). Le composant est purement présentiel : le + * composant parent contrôle l'ouverture/fermeture via `[open]` et réagit aux + * événements. + * + * Utilisé notamment pour la confirmation de suppression d'un trajet (Exigence 5.4). + */ +@Component({ + selector: 'app-confirm-dialog', + standalone: true, + imports: [CommonModule], + templateUrl: './confirm-dialog.component.html', + styleUrls: ['./confirm-dialog.component.scss'] +}) +export class ConfirmDialogComponent { + /** Contrôle l'affichage de la boîte de dialogue. */ + @Input() open = false; + + /** Titre affiché en en-tête. */ + @Input() titre = 'Confirmation'; + + /** Message principal (question posée à l'utilisateur). */ + @Input() message = 'Êtes-vous sûr ?'; + + /** Libellé du bouton de confirmation. */ + @Input() libelleConfirmer = 'Confirmer'; + + /** Libellé du bouton d'annulation. */ + @Input() libelleAnnuler = 'Annuler'; + + /** Style d'accentuation du bouton de confirmation (danger pour suppression). */ + @Input() danger = false; + + /** Émis lorsque l'utilisateur confirme l'action. */ + @Output() confirmed = new EventEmitter(); + + /** Émis lorsque l'utilisateur annule ou ferme la boîte de dialogue. */ + @Output() cancelled = new EventEmitter(); + + /** Valide l'action. */ + confirmer(): void { + this.confirmed.emit(); + } + + /** Annule l'action. */ + annuler(): void { + this.cancelled.emit(); + } +} diff --git a/frontend/src/app/shared/validators/date-jj-mm-aaaa.validator.ts b/frontend/src/app/shared/validators/date-jj-mm-aaaa.validator.ts new file mode 100644 index 0000000..49d32ad --- /dev/null +++ b/frontend/src/app/shared/validators/date-jj-mm-aaaa.validator.ts @@ -0,0 +1,64 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +/** + * Expression validant strictement le format JJ/MM/AAAA + * (2 chiffres / 2 chiffres / 4 chiffres). + */ +const FORMAT_JJ_MM_AAAA = /^(\d{2})\/(\d{2})\/(\d{4})$/; + +/** + * Vérifie qu'une chaîne au format JJ/MM/AAAA correspond à une date réelle + * (mois 1–12, jour cohérent avec le mois et l'année bissextile). + * + * @returns `true` si le format est correct ET la date existe, sinon `false`. + */ +export function estDateJjMmAAAAValide(valeur: string): boolean { + const correspondance = FORMAT_JJ_MM_AAAA.exec(valeur); + if (!correspondance) { + return false; + } + + const jour = Number(correspondance[1]); + const mois = Number(correspondance[2]); + const annee = Number(correspondance[3]); + + if (mois < 1 || mois > 12) { + return false; + } + if (jour < 1) { + return false; + } + + // Nombre de jours du mois (tient compte des années bissextiles via Date). + const joursDansMois = new Date(annee, mois, 0).getDate(); + if (jour > joursDansMois) { + return false; + } + + return true; +} + +/** + * Validateur Angular pour un contrôle de date au format JJ/MM/AAAA. + * + * Retourne `{ dateInvalide: true }` si la valeur est présente mais mal formatée + * ou correspond à une date inexistante (Exigence 1.5). Une valeur vide est + * considérée valide ici : l'obligation de présence relève du validateur + * `Validators.required`. + */ +export function dateJjMmAAAAValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const valeur = control.value; + + // Champ vide : on laisse `required` gérer l'obligation. + if (valeur === null || valeur === undefined || valeur === '') { + return null; + } + + if (typeof valeur !== 'string' || !estDateJjMmAAAAValide(valeur)) { + return { dateInvalide: true }; + } + + return null; + }; +} diff --git a/frontend/src/assets/.gitkeep b/frontend/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts new file mode 100644 index 0000000..8834ba0 --- /dev/null +++ b/frontend/src/environments/environment.prod.ts @@ -0,0 +1,12 @@ +// Environnement de production. +// L'application est servie par nginx qui reverse-proxy `/api` vers le backend. +// L'API est donc consommée en same-origin via le chemin relatif `/api`. +export const environment = { + production: true, + // Same-origin : nginx proxifie /api -> backend:8080 (cf. nginx.conf / docker-compose). + apiUrl: '/api', + // Timeout HTTP appliqué par le TimeoutInterceptor (Exigence 1.9 : 10 secondes). + httpTimeoutMs: 10000, + // API Base Adresse Nationale (autocomplétion adresses françaises). + banUrl: 'https://api-adresse.data.gouv.fr' +}; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts new file mode 100644 index 0000000..c6c751f --- /dev/null +++ b/frontend/src/environments/environment.ts @@ -0,0 +1,13 @@ +// Environnement de développement. +// En dev, le frontend Angular tourne sur son propre serveur (ng serve, port 4200) +// et adresse directement le backend ASP.NET Core. +export const environment = { + production: false, + // URL de base de l'API backend en développement. + // Le backend écoute par défaut sur http://localhost:8080 (cf. Docker / Program.cs). + apiUrl: 'http://localhost:8080/api', + // Timeout HTTP appliqué par le TimeoutInterceptor (Exigence 1.9 : 10 secondes). + httpTimeoutMs: 10000, + // API Base Adresse Nationale (autocomplétion adresses françaises). + banUrl: 'https://api-adresse.data.gouv.fr' +}; diff --git a/frontend/src/favicon.ico b/frontend/src/favicon.ico new file mode 100644 index 0000000..57614f9 Binary files /dev/null and b/frontend/src/favicon.ico differ diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..9b1a822 --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,13 @@ + + + + + TrajetKmTracker + + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..72940b4 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,9 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +// Application en mode ZONELESS (cf. app.config.ts : provideZonelessChangeDetection). +// Aucun polyfill zone.js n'est chargé pour l'exécution (polyfills vide dans +// angular.json). La détection de changement repose sur les signals Angular. +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss new file mode 100644 index 0000000..911aad8 --- /dev/null +++ b/frontend/src/styles.scss @@ -0,0 +1,55 @@ +/* You can add global styles to this file, and also import other style files */ + +/* Styles globaux des éléments de carte injectés par Leaflet (hors encapsulation + des composants) : pastilles de marqueurs et contenu des infobulles. */ +.carte-marqueur { + background: transparent; + border: none; +} + +.carte-marqueur__pastille { + display: block; + width: 14px; + height: 14px; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + /* La couleur de fond et la bordure sont fournies en style inline par le composant + (couleur du tracé). Marqueur simple, sans texte : les points ne sont plus + numérotés (le survol identifie le trajet via surbrillance + infobulle). */ +} + +.carte-infobulle { + font-size: 0.8rem; + line-height: 1.35; + /* Autorise le retour à la ligne des adresses longues (pas de débordement horizontal). */ + overflow-wrap: break-word; + word-break: break-word; + + div { + margin: 2px 0; + } +} + +/* Contrainte de taille des popups Leaflet pour éviter tout débordement de la zone + d'affichage. On borne la largeur ET on autorise le retour à la ligne du contenu + (Leaflet fixe sinon une largeur au contenu, ce qui tronquait les adresses longues). */ +.leaflet-popup-content { + max-width: 240px; + max-height: 220px; + overflow-y: auto; + overflow-x: hidden; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal; +} + +/* Le conteneur du popup ne doit pas imposer une largeur supérieure à la zone visible. */ +.leaflet-popup-content-wrapper { + max-width: 260px; +} + +.carte-approx { + margin-top: 4px; + font-style: italic; + color: #b71c1c; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..374cc9d --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..56b644d --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,32 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json new file mode 100644 index 0000000..be7e9da --- /dev/null +++ b/frontend/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/run-dev.sh b/run-dev.sh new file mode 100755 index 0000000..61d4b63 --- /dev/null +++ b/run-dev.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Lance en mode développement le backend (port 8080) et le frontend Angular (port 4200) +# avec la base SQLite réelle. À utiliser hors Docker, pour le développement local. +# +# Usage : ./run-dev.sh +# Arrêt : Ctrl+C (les deux processus sont arrêtés ensemble). + +set -uo pipefail +RACINE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# --- Backend (.NET 8) ------------------------------------------------------- +export PATH="$HOME/.dotnet:$HOME/.dotnet/tools:$PATH" +export DOTNET_ROOT="$HOME/.dotnet" + +# Base réelle, chemin ABSOLU pour éviter deux bases selon le dossier de lancement. +export ConnectionStrings__Default="Data Source=$RACINE/backend/TrajetKmTracker.Api/trajets.db" +export ASPNETCORE_ENVIRONMENT=Development + +# Clé OpenRouteService : lue depuis .env (ORS_API_KEY) et injectée sous le nom attendu +# par la configuration .NET (ORS__ApiKey). Sans clé valide, le backend calcule les +# distances à vol d'oiseau (fallback Haversine) au lieu de la distance routière réelle. +if [[ -z "${ORS__ApiKey:-}" && -f "$RACINE/.env" ]]; then + ORS_KEY_FROM_ENV="$(grep -E '^ORS_API_KEY=' "$RACINE/.env" | head -n1 | cut -d= -f2- || true)" + if [[ -n "$ORS_KEY_FROM_ENV" ]]; then + export ORS__ApiKey="$ORS_KEY_FROM_ENV" + echo "→ Clé ORS chargée depuis .env (calcul de distance routière activé)." + else + echo "⚠ Aucune clé ORS dans .env : distances calculées à vol d'oiseau." + fi +fi + +echo "→ Démarrage du backend sur http://localhost:8080 (base : $RACINE/backend/TrajetKmTracker.Api/trajets.db)" +dotnet run --project "$RACINE/backend/TrajetKmTracker.Api" --urls http://localhost:8080 & +PID_BACK=$! + +# --- Frontend (Angular via nvm/Node 22) ------------------------------------- +export NVM_DIR="$HOME/.nvm" +# shellcheck source=/dev/null +. "$NVM_DIR/nvm.sh" +nvm use 22 >/dev/null + +# Vide le cache de compilation Angular pour garantir un bundle à jour, notamment +# le polyfill zone.js (sans lui, la vue ne se rafraîchit pas sur les réponses HTTP : +# `window.Zone` doit être défini au runtime). Un cache périmé peut resservir un +# ancien polyfills sans zone.js. +echo "→ Nettoyage du cache Angular (.angular/cache)" +rm -rf "$RACINE/frontend/.angular/cache" + +echo "→ Démarrage du frontend sur http://localhost:4200 (première compilation : patiente…)" +( cd "$RACINE/frontend" && npm start ) & +PID_FRONT=$! + +# --- Arrêt propre des deux processus ---------------------------------------- +cleanup() { + echo "" + echo "→ Arrêt…" + kill "$PID_BACK" "$PID_FRONT" 2>/dev/null || true + pkill -f "TrajetKmTracker.Api" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# --- Attente active : ng serve prêt à répondre en HTTP 200 ------------------ +# La première compilation (surtout après vidage du cache) peut prendre 30–60 s. +# Tant que ng serve n'a pas fini, le port répond « connexion refusée » (HTTP 000) +# et le navigateur chargerait une page incomplète (zone.js absent → window.Zone +# undefined). On ne montre l'invitation qu'une fois le serveur réellement prêt. +attendre_frontend() { + local url="http://localhost:4200/" + local max=120 # secondes + local t=0 + printf "→ Compilation du frontend" + while [ "$t" -lt "$max" ]; do + # Le process ng serve est-il toujours vivant ? + if ! kill -0 "$PID_FRONT" 2>/dev/null; then + echo "" + echo "✗ Le frontend s'est arrêté avant d'être prêt (voir les logs ci-dessus)." + return 1 + fi + local code + code=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo 000) + if [ "$code" = "200" ]; then + echo " prêt." + return 0 + fi + printf "." + sleep 2 + t=$((t + 2)) + done + echo "" + echo "✗ Le frontend n'a pas répondu 200 au bout de ${max}s. Vérifie les logs ng serve." + return 1 +} + +if attendre_frontend; then + echo "" + echo "======================================================================" + echo " Application prête." + echo " Backend : http://localhost:8080/api" + echo " Frontend : http://localhost:4200 ← ouvre cette URL maintenant" + 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 "" +fi + +wait diff --git a/run-docker.sh b/run-docker.sh new file mode 100755 index 0000000..8245810 --- /dev/null +++ b/run-docker.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Lancement de SchotzIAD KM Tracker SANS docker compose (topologie équivalente). +# Utile quand docker compose est indisponible/cassé. +# +# Usage : +# ./run-docker.sh up # build + démarre backend et frontend +# ./run-docker.sh down # arrête et supprime les conteneurs (garde le volume/les données) +# ./run-docker.sh destroy # arrête tout ET supprime le volume SQLite (perte des données) +# ./run-docker.sh logs # affiche les logs des deux conteneurs +# +# Selon votre config Docker, préfixez par sudo : sudo ./run-docker.sh up +set -euo pipefail + +# Détecte s'il faut sudo (utilisateur hors du groupe docker). +DOCKER="docker" +if ! docker info >/dev/null 2>&1; then + if sudo -n docker info >/dev/null 2>&1 || sudo docker info >/dev/null 2>&1; then + DOCKER="sudo docker" + echo "[info] Docker requiert sudo — utilisation de 'sudo docker'." + else + echo "[erreur] Démon Docker inaccessible (ni direct, ni via sudo)." >&2 + exit 1 + fi +fi + +ROOT="$(cd "$(dirname "$0")" && pwd)" +NET="schotziad-net" +VOL="trajets-data" +ORS_KEY="${ORS_API_KEY:-}" + +# Charge ORS_API_KEY depuis .env s'il existe et n'est pas déjà défini. +if [[ -z "$ORS_KEY" && -f "$ROOT/.env" ]]; then + ORS_KEY="$(grep -E '^ORS_API_KEY=' "$ROOT/.env" | head -n1 | cut -d= -f2- || true)" +fi + +up() { + echo "[1/5] Réseau et volume…" + $DOCKER network inspect "$NET" >/dev/null 2>&1 || $DOCKER network create "$NET" + $DOCKER volume inspect "$VOL" >/dev/null 2>&1 || $DOCKER volume create "$VOL" + + echo "[2/5] Build backend…" + $DOCKER build -t schotziad-backend "$ROOT/backend" + + echo "[3/5] Build frontend…" + $DOCKER build -t schotziad-frontend "$ROOT/frontend" + + echo "[4/5] Démarrage backend (nom d'hôte réseau: backend)…" + $DOCKER rm -f schotziad-backend >/dev/null 2>&1 || true + $DOCKER run -d --name schotziad-backend --network "$NET" --network-alias backend \ + -e "ASPNETCORE_URLS=http://+:8080" \ + -e "ConnectionStrings__Default=Data Source=/data/trajets.db" \ + -e "ORS__ApiKey=${ORS_KEY}" \ + -v "$VOL:/data" \ + --restart unless-stopped \ + schotziad-backend + + echo "[5/5] Démarrage frontend (http://localhost:8080)…" + $DOCKER rm -f schotziad-frontend >/dev/null 2>&1 || true + $DOCKER run -d --name schotziad-frontend --network "$NET" \ + -p 8080:80 \ + --restart unless-stopped \ + schotziad-frontend + + echo + echo "Prêt. Ouvrez http://localhost:8080" + echo "Logs : ./run-docker.sh logs | Arrêt : ./run-docker.sh down" +} + +down() { + $DOCKER rm -f schotziad-frontend schotziad-backend >/dev/null 2>&1 || true + echo "Conteneurs arrêtés (le volume '$VOL' et les données sont conservés)." +} + +destroy() { + down + $DOCKER volume rm "$VOL" >/dev/null 2>&1 || true + $DOCKER network rm "$NET" >/dev/null 2>&1 || true + echo "Volume, réseau et données supprimés." +} + +logs() { + echo "=== backend ==="; $DOCKER logs --tail 40 schotziad-backend 2>&1 || true + echo "=== frontend ==="; $DOCKER logs --tail 20 schotziad-frontend 2>&1 || true +} + +case "${1:-up}" in + up) up ;; + down) down ;; + destroy) destroy ;; + logs) logs ;; + *) echo "Usage: $0 {up|down|destroy|logs}" >&2; exit 2 ;; +esac