48 Commits

Author SHA1 Message Date
ElPoyo cb94badafe fix(flutter_map): fix type inference and const constructor issues 2026-06-05 15:17:29 +02:00
ElPoyo 30d90a05fd fix: revert latlong2 import path 2026-06-05 15:15:06 +02:00
ElPoyo 68fa2b4587 fix(flutter_map): update imports and constructors for flutter_map 8 and latlong2 2026-06-05 15:12:12 +02:00
ElPoyo 9e3169b225 chore: clean up test and scratch files 2026-06-05 15:04:18 +02:00
ElPoyo 671136ac4b Merge branch 'feature/travel-cost-calculator' into main 2026-06-05 15:04:12 +02:00
ElPoyo adb0a2e7c9 fix(travel.js): fix Ulys API parsing issues and ID extraction 2026-06-05 14:11:06 +02:00
ElPoyo 69a65d83f2 fix(travel): Mapbox route optimized 2026-06-05 13:53:22 +02:00
ElPoyo cb35ddac22 feat(travel): implement Mapbox route recreation using Mapbox Directions API 2026-06-05 12:09:17 +02:00
ElPoyo 21d7bc8b87 feat: (broken) implement route map and address autocomplete widgets with associated infrastructure testing scripts 2026-06-05 11:10:32 +02:00
ElPoyo 8c01a21728 fix(travel): revert to clean Ulys /legs implementation as requested 2026-06-05 10:14:19 +02:00
ElPoyo 20c44cfb8b fix(travel): revert global geometric extraction and add surgical fallback for missing origin systems 2026-06-05 00:14:18 +02:00
ElPoyo 0744665fe2 fix: polyline decoding bug with large bounds and fix ulys API toll pricing precision 2026-06-04 16:40:50 +02:00
ElPoyo 555629760d fix: flutter_map latlng bounds assertion et overflow du dropdown des depôts 2026-06-04 15:05:08 +02:00
ElPoyo d52d40ad74 style: changement des couleurs des carburants (diesel en orange, essence en vert) 2026-06-04 14:55:01 +02:00
ElPoyo 1d825c0233 feat: mise a jour des libelles des categories de peage (1 a 5) 2026-06-04 14:53:57 +02:00
ElPoyo 55cdd168dc fix: firestore rules pour vehicules/depots et selection autocomplete overlay 2026-06-04 14:51:15 +02:00
ElPoyo 61b74f7465 fix: utilise la variable API_MAPS pour la cle Google Maps 2026-06-04 14:34:23 +02:00
ElPoyo 1bf5c8061f fix: corrections build - _showOverlay supprime, passageDate Ulys ajoute, withValues deprecation 2026-06-04 14:32:03 +02:00
ElPoyo e14b333a67 feat: calculateur de frais de déplacement - backend et modèles Flutter
- Cloud Function travel.js : autocomplete Google Places + calcul itinéraires
  via Google Routes API avec péages Ulys /legs (precision=6) + /rate
- Modèles : VehicleModel, DepotModel, RouteResultModel + FuelPrices
- Services : VehicleService, TravelService (Firestore CRUD + API calls)
- Gestion des données : 3 nouveaux onglets (Dépôts, Véhicules, Prix carburants)
- Autocomplétion adresse dans le formulaire événement
- Dialog calcul frais : config + carte flutter_map OSM + sélection itinéraire
- Injection option FRAIS_KM dans l'événement à la sélection
- flutter_map 7.0.2 + latlong2 0.9.1 ajoutés
- npm: csv-parser + @mapbox/polyline installés dans functions
2026-06-04 14:28:22 +02:00
ElPoyo 4d18956abe feat: add event details description component and environment configuration file 2026-05-28 00:04:48 +02:00
ElPoyo d9cd251bb7 feat: implement event creation flow and management widgets with preparation tracking buttons 2026-05-27 23:50:02 +02:00
ElPoyo faff06e4df feat: implement equipment and container loading rollback functionality with corresponding backend cloud functions 2026-05-27 22:04:46 +02:00
ElPoyo 64a9fe382a feat: updated container management system with core models, providers, and UI pages 2026-05-26 21:34:35 +02:00
ElPoyo fb740d97a3 feat: implement authentication flow with AppStartGate, AuthGuard, and calendar page initialization 2026-05-26 20:34:46 +02:00
ElPoyo ea1e1335e3 feat: implement comprehensive Firebase Functions backend for equipment management and migrate core repository services 2026-05-26 15:35:48 +02:00
ElPoyo 323df01afe style: add margins to cards, move quantity display to badge column with identical size, and implement long press to edit 2026-05-26 15:01:09 +02:00
ElPoyo 854b0a9bb0 style: redesign equipment list cards to improve spacing and center-align the availability badge 2026-05-26 14:54:38 +02:00
ElPoyo f8f6cfb102 bugfix: resolve runtime issues - encoding accents, ListView dynamic heights, notifyListeners exceptions during build phase, and layout overflows 2026-05-26 14:48:39 +02:00
ElPoyo 9bc4e88e46 fix: ajout des imports manquants pour UserModel et AvailabilityConflict suite au refactoring 2026-05-26 13:59:02 +02:00
ElPoyo f56615451e perf: suppression du blocage d'authentification au démarrage et chargement non bloquant de l'utilisateur 2026-05-26 13:57:14 +02:00
ElPoyo 845b6e91d2 perf: suppression du téléchargement massif d'événements côté client (appel de la CF checkContainerAvailability et lecture synchrone des quantités) 2026-05-26 13:50:35 +02:00
ElPoyo 93c102012b perf: optimization des rebuilds (ValueNotifier pour calendrier/container_form, Selector pour pages de gestion et mon compte) 2026-05-26 13:48:50 +02:00
ElPoyo 6ee63ed29c Merge branch 'perf/cleanup' 2026-05-26 13:43:46 +02:00
ElPoyo c35e633568 Merge branch 'perf/search-debounce' into perf/listview-optimization 2026-05-26 13:43:24 +02:00
ElPoyo 4284142b1e perf: ajout de ListView itemExtent/prototypeItem pour l'optimisation des performances 2026-05-26 13:43:18 +02:00
ElPoyo 32f1718a8c perf: ajout d'un Debouncer 400ms sur toutes les barres de recherche 2026-05-26 13:41:21 +02:00
ElPoyo a59deb19a9 perf: nettoyage code mort, sécurisation clé, et remplacement des prints 2026-05-26 13:40:33 +02:00
ElPoyo 0bbc77ffc8 feat: mise à jour de la version de l'application à 1.2.1 et ajout d'un assistant IA pour la gestion des équipements 2026-05-25 23:55:59 +02:00
ElPoyo 19d3dcef69 fix: correction du merge de equipment_selection_dialog.dart (structure invalide) 2026-05-25 23:42:00 +02:00
ElPoyo 32a279e0ae feat: merge branche IA (beta) - Intégration assistant IA logisticien Gemini 2026-05-25 23:35:40 +02:00
ElPoyo 7258509528 feat: amélioration de l'assistant IA logisticien et de la gestion des containers
- **Backend (Cloud Functions)** :
    - Mise à jour de `firebase-functions` vers la version `7.2.5`.
    - Amélioration de la sécurité et de la flexibilité des clés API Gemini (support des variables d'environnement `.env` et `.env.local`).
    - Optimisation de la recherche d'équipements avec une stratégie multi-passes (exacte, par tokens, puis catégorielle/fuzzy).
    - Ajout de nouveaux outils pour l'IA : `check_container_availability` et `check_container_availability_batch` pour vérifier la disponibilité des flight-cases.
    - Implémentation d'un post-traitement automatique suggérant des containers complets si tous leurs équipements internes sont requis par l'événement.
    - Amélioration de la résilience aux erreurs 429/503 de Gemini avec une stratégie d'exponential backoff.

- **Frontend (Flutter)** :
    - Mise à jour du service `AiEquipmentAssistantService` pour gérer les métadonnées détaillées des containers (rationale, items manquants/matchings, disponibilité).
    - Refonte de l'interface `AiEquipmentAssistantDialog` :
        - Affichage enrichi des containers dans le récapitulatif.
        - Ajout de la possibilité de sélectionner/désélectionner manuellement les containers (notamment ceux marqués comme "partiels").
        - Amélioration visuelle (ombres, bordures, icônes de statut de disponibilité).
        - Marquage de l'assistant en mode "BETA".

- **Général** :
    - Mise à jour du `.gitignore` pour inclure `functions/.env.local`.
    - Correction de typos et amélioration du logging de debug dans le backend.
2026-05-25 23:00:43 +02:00
ElPoyo 7fc28f4374 feat: (BETA) Amélioration de l'assistant IA logisticien (Gemini) et support des documents
- **Amélioration de l'IA (Cloud Functions)** :
    - Mise à jour du modèle vers `gemini-3.1-flash-lite` et augmentation de la limite des résultats de recherche à 50.
    - Optimisation de la gestion des outils : augmentation du nombre d'appels simultanés (`MAX_TOOL_CALLS_PER_ITERATION`) à 40.
    - Refonte du système de recherche d'équipements avec une stratégie en deux passes (recherche précise puis catégorielle avec normalisation agressive).
    - Nouvelles consignes strictes pour la gestion des unités uniques (quantité de 1 par ID) et priorité aux flight cases (containers).
    - Ajout d'une gestion de retry avec temporisation pour les erreurs de quota (429) et de surcharge (503).
    - Support de l'analyse de documents joints (devis, listes) envoyés en `inlineData`.

- **Interface de l'Assistant (`AiEquipmentAssistantDialog`)** :
    - Ajout de la possibilité de joindre des documents (PDF, images, texte) via `FilePicker` pour analyse par l'IA.
    - Implémentation d'une vue de logs de debug détaillée pour suivre le raisonnement de l'IA et les appels d'outils.
    - Amélioration visuelle de la discussion : bulles de message stylisées et structuration automatique des réponses (sections "Matériel ajouté" vs "Matériel non trouvé").
    - Nouvelles options de confirmation : "Tout ajouter" ou "Ajouter sans alternatives".

- **Modèles et Services** :
    - Mise à jour de `EventEquipment` pour inclure un champ `rationale` (justification du choix de l'équipement).
    - Correction dans `EventAssignedEquipmentSection` pour ajouter automatiquement les équipements enfants lors de l'ajout d'un container proposé par l'IA.
    - Ajout de la gestion des logs et des documents dans `AiEquipmentAssistantService`.

- **UI Divers** :
    - Mise à jour de `EquipmentFormPage` pour clarifier le comportement de l'identifiant (auto-génération recommandée).
2026-05-25 20:33:59 +02:00
ElPoyo af5ecaeee1 feat: optimisation du démarrage de l'application et de la gestion de l'authentification
- **Refonte du démarrage** : Mise en place d'un `AppInitializer` pour gérer l'initialisation asynchrone de Firebase et du cache en arrière-plan, réduisant le travail synchrone au lancement.
- **Sécurisation de l'authentification** :
    - Création d'un `AppStartGate` pour gérer proprement la restauration de la session Firebase Auth et les erreurs potentielles sur le Web.
    - Amélioration du `LocalUserProvider` avec un "bootstrap léger" permettant de rendre l'UID disponible immédiatement avant le chargement complet du profil.
    - Ajout de protections contre les erreurs d'accès à `FirebaseAuth.instance` (notamment pour les problèmes d'interop JS sur le Web).
- **Optimisation de l'UI** :
    - Remplacement du `AutoLoginWrapper` par une gestion plus robuste de la navigation post-authentification.
    - Amélioration de l'`AuthGuard` pour permettre l'affichage de certains écrans (comme le calendrier) pendant le chargement des données utilisateur (`allowWhileLoading`).
    - Ajout d'un écran de splash screen uniformisé (`StartupSplashScreen`).
- **Services & Cache** :
    - Introduction de `CacheService` utilisant `shared_preferences` pour le stockage local léger.
    - Refactoring des services (`AlertService`, `EmailService`, `FirebaseStorageManager`) pour accéder aux instances Firebase de manière plus flexible via des getters.
    - Mise à jour des dépendances dans `pubspec.yaml` pour inclure `shared_preferences`.
- **Calendrier** : Ajout d'une logique de chargement initial différé des événements (`_scheduleInitialEventsLoad`) pour éviter les appels redondants au démarrage.
- **Maintenance** : Mise à jour de la version de l'application à `1.1.23` et nettoyage des fichiers de cache de déploiement.
2026-05-05 12:25:45 +02:00
ElPoyo eac103491f feat: recherche d'événements et gestion avancée de la suppression d'équipement
- **Recherche d'événements** : Ajout d'une fonctionnalité de recherche (titre, description, lieu) dans le calendrier et d'une nouvelle fonction Cloud `searchEvents` avec gestion des permissions.
- **Suppression d'équipement avec forçage** :
    - Mise à jour de la fonction Cloud `deleteEquipment` pour détecter les assignations à des événements futurs.
    - Ajout d'une option `forceDelete` pour passer outre les conflits d'assignation.
    - Création de `EquipmentDeleteUtils` pour gérer uniformément les dialogues de confirmation et les erreurs de conflit (HTTP 409).
    - Intégration de la logique de suppression sécurisée dans `EquipmentDetailPage` et `EquipmentManagementPage`.
- **Calendrier** :
    - Refonte de l'interface mobile pour intégrer la barre de recherche.
    - Optimisation du chargement des événements lors de la sélection d'un résultat de recherche (lazy loading du mois concerné).
    - Amélioration de la stabilité de la sélection d'événements et du filtrage par utilisateur.
- **Services & Providers** :
    - Amélioration de la gestion des erreurs dans `ApiService` pour faciliter le re-throw des exceptions personnalisées.
    - Ajout du support de la suppression forcée dans `DataService` et `EquipmentProvider`.
- **Refactoring** : Nettoyage du code, amélioration du formatage et ajout de logs de debug dans les services de données et d'équipements.
2026-04-22 12:25:37 +02:00
ElPoyo 0551f0b9c1 feat: Mise à jour à la version 1.1.20 et amélioration de la recherche d'équipements
- Mise à jour de la version de l'application à `1.1.20` dans `app_version.dart`, `version.json` et `CHANGELOG.md`.
- Optimisation de la fonction Cloud `getEquipmentsPaginated` pour supporter la recherche par ID exact (document ID ou ID legacy) et améliorer la recherche textuelle avec filtrage par lots.
- Amélioration de la gestion des alertes dans `processEquipmentValidation.js` :
    - Ajout d'un statut `NOT_TAKEN` pour éviter les fausses alertes d'équipements perdus s'ils n'ont jamais été emportés.
    - Refonte complète du parsing des dates Firestore pour une meilleure robustesse dans les alertes.
    - Correction de la validation des quantités (vérification du type `number`).
- Ajout de méthodes statiques dans `EventPreparationService` (`shouldMarkEquipmentAsLost`, `isEquipmentNotTakenToEvent`) pour centraliser la logique de détermination du statut des équipements au retour.
- Mise à jour de `EventPreparationPage` pour intégrer le nouveau statut `NOT_TAKEN` et utiliser la logique centralisée du service de préparation.
- Mise à jour des fichiers de cache Firebase Hosting.
2026-03-30 17:12:48 +02:00
ElPoyo 89ab3673c4 ### Key Changes:
**AI Equipment Proposal (`functions/aiEquipmentProposal.js`):**
- Updated Gemini model to `gemini-3.1-flash-lite-preview` and updated the API key.
- Increased `MAX_TOOL_ITERATIONS` from 12 to 20.
- Added a new tool `list_equipment_by_category` to allow the AI to browse equipment when specific searches fail.
- Enhanced the system prompt with instructions to handle typos via category exploration and authorized more creative equipment suggestions based on event descriptions.
- Improved the user prompt to include more event context (name, location, notes, and options).
- Set `responseMimeType: 'application/json'` in the generation config for better reliability.
- Improved error logging and user-facing error messages for timeouts.

**UI & Pagination (`lib/views/`):**
- **ContainerFormPage**: Replaced `StreamBuilder` with a paginated list using `DataService` for equipment selection. Added a scroll controller to support infinite scrolling and updated UI colors to use the newer `withValues` API.
- **EquipmentSelectionDialog**:
    - Increased pagination limit from 25 to 50 items.
    - Implemented `_checkIfMoreItemsNeeded` logic to automatically fetch more pages if filters (like hiding conflicting items) leave the view too empty.
    - Added a `NotificationListener` to the `ListView` to trigger pagination on scroll.
    - Fixed minor encoding issues in comments.

---

### Proposed Commit Message:

feat: Mise à jour du modèle Gemini et optimisation de la sélection du matériel avec pagination

- Mise à jour du modèle d'IA vers `gemini-3.1-flash-lite-preview` et augmentation de la limite d'itérations des outils à 20.
- Ajout de l'outil `list_equipment_by_category` pour permettre à l'IA d'explorer les alternatives en cas d'échec de recherche textuelle.
- Enrichissement du prompt système et du contexte envoyé à l'IA (nom, lieu, notes et options de l'événement).
- Implémentation de la pagination dans `ContainerFormPage` pour la sélection d'équipements afin d'améliorer les performances.
- Optimisation de `EquipmentSelectionDialog` avec chargement automatique des pages suivantes si les filtres réduisent trop la liste visible.
- Passage à `withValues` pour la gestion des couleurs et amélioration de la gestion des erreurs et du logging.
2026-03-30 12:32:33 +02:00
ElPoyo cf13b4a986 .env gitignore 2026-03-28 21:38:56 +01:00
ElPoyo 84c882ac0b feat: Intégration d'un assistant IA logisticien basé sur Gemini
- Ajout d'une Cloud Function `aiEquipmentProposal` utilisant le modèle Gemini avec function calling pour suggérer du matériel et des containers.
- Implémentation de plusieurs outils (tools) côté serveur pour permettre à l'IA d'interagir avec Firestore : `search_equipment`, `check_availability_batch`, `get_past_events`, `search_event_reference` et `search_containers`.
- Ajout de la dépendance `@google/generative-ai` dans le backend.
- Création d'un service Flutter `AiEquipmentAssistantService` pour communiquer avec la nouvelle Cloud Function.
- Ajout d'une interface de dialogue `AiEquipmentAssistantDialog` permettant aux utilisateurs de discuter avec l'IA pour affiner les propositions de matériel.
- Intégration de l'assistant IA dans la section de gestion du matériel des événements (`EventAssignedEquipmentSection`).
- Mise à jour de `DataService` avec de nouvelles méthodes de recherche et de vérification de disponibilité optimisées pour l'assistant.
- Activation du mode développement et configuration des identifiants de test dans `env.dart`.
- Optimisation des paramètres de la Cloud Function (timeout de 300s et 1GiB de RAM) pour supporter les traitements IA.
2026-03-24 12:00:30 +01:00
118 changed files with 18074 additions and 8704 deletions
+1
View File
@@ -149,3 +149,4 @@ app.*.symbols
.python-version
.gclient_previous_custom_vars
.gclient_previous_sync_commits
em2rp/lib/config/env.dart
+13 -13
View File
@@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1773324020831,d5cd7334d7c3a990dbff0821b9aaab39129803e306b0d96599b8adc6d4f433a6
index.html,1773324025840,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1773324116910,8582e401e070055f59183c207cf7a7e6a9219a50f5089a24a77d91d3ff77dcbc
flutter_bootstrap.js,1773324025827,74eaa66055c715df232ee96fc4114d5473f67717278fb4effa38d8b1b362e303
assets/FontManifest.json,1773324113335,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/AssetManifest.json,1773324113335,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/AssetManifest.bin.json,1773324113335,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/AssetManifest.bin,1773324113335,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773324115847,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1773324113551,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/fonts/MaterialIcons-Regular.otf,1773324115852,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c
assets/NOTICES,1773324113339,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79
main.dart.js,1773324112059,bfc66ab7e817db63dee4b996af3dea0629c4c4e87ba91070c15b133ab5104848
version.json,1779802456392,7626a7c596308bd2eb1add2ed984cd6dda5d4a3f0dedb3338244d2ae45c496cf
index.html,1779802461141,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1779802555396,fadec2c9a1e8e16c22e332aef080b0b2aacc3998c4e260e5821b79afb9e000da
flutter_bootstrap.js,1779802461128,ad20054b92acf16bb75fbffd65f81c63c6d3cb6d752f799230dca5f2118af783
assets/FontManifest.json,1779802551869,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/AssetManifest.json,1779802551869,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/AssetManifest.bin.json,1779802551869,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/AssetManifest.bin,1779802551869,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/shaders/ink_sparkle.frag,1779802552052,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779802554433,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/fonts/MaterialIcons-Regular.otf,1779802554440,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
assets/NOTICES,1779802551871,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
main.dart.js,1779802550741,ab892e930c97940c1ea4ff33079922082c7f688047d307acad0644a78cfda2d7
+3
View File
@@ -45,3 +45,6 @@ app.*.map.json
# Environment configuration with credentials
lib/config/env.dev.dart
functions/.env
.env
env.dart
functions/.env.local
+18
View File
@@ -2,6 +2,24 @@
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
## 27/05/2026
Ajout de la fonction retour en arriere pour la validation du chargement des equipements et des conteneurs.
## 26/05/2026
Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.
## 25/05/2026
Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.
## 04/05/2026
Optimisation du lancement de l'application et amélioration de la gestion du cache.
## 22/04/2026
Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement
## 30/03/2026
Patch bug envoi d'alerte equipement perdu, date dans les alertes, recherche par ID d'équipement.
## 24/03/2026
Fix BUG : Problème de cache avec les équipements non affichés dans le dialog de sélection d'équipement. Amélioration de la gestion du cache pour éviter les problèmes d'affichage.
+11
View File
@@ -22,6 +22,17 @@ service cloud.firestore {
allow read, write: if false;
}
// Autoriser l'accès aux collections de configuration de l'application
match /depots/{document=**} {
allow read, write: if request.auth != null;
}
match /vehicles/{document=**} {
allow read, write: if request.auth != null;
}
match /app_config/{document=**} {
allow read, write: if request.auth != null;
}
// ========================================================================
// EXCEPTIONS OPTIONNELLES pour les listeners temps réel
// ========================================================================
+2
View File
@@ -7,3 +7,5 @@ SMTP_PASS="aL8@Rx8xqFrNij$a"
# URL de l'application
APP_URL="https://app.em2events.fr"
GEMINI_API_KEY="AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc"
API_MAPS="AIzaSyDt2d-T9YRmHO3-QEq1uWomdqVbJqXfO04"
+9 -1
View File
@@ -4,7 +4,7 @@ module.exports = {
node: true,
},
parserOptions: {
"ecmaVersion": 2018,
"ecmaVersion": 2020,
},
extends: [
"eslint:recommended",
@@ -14,6 +14,14 @@ module.exports = {
"no-restricted-globals": ["error", "name", "length"],
"prefer-arrow-callback": "error",
"quotes": ["error", "double", {"allowTemplateLiterals": true}],
"max-len": "off",
"valid-jsdoc": "off",
"require-jsdoc": "off",
"guard-for-in": "off",
"no-unused-vars": "warn",
"brace-style": "off",
"object-curly-spacing": "off",
"arrow-parens": "off",
},
overrides: [
{
+1
View File
@@ -2,3 +2,4 @@ node_modules/
*.local
.env
.env.local
serviceAccountKey.json
File diff suppressed because it is too large Load Diff
+40 -71
View File
@@ -1,60 +1,22 @@
const {onRequest} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const nodemailer = require('nodemailer');
const logger = require('firebase-functions/logger');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require('./utils/emailTemplates');
const auth = require('./utils/auth');
// Configuration CORS
const setCorsHeaders = (res, req) => {
// Utiliser l'origin de la requête pour permettre les credentials
const origin = req.headers.origin || '*';
res.set('Access-Control-Allow-Origin', origin);
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
if (origin !== '*') {
res.set('Access-Control-Allow-Credentials', 'true');
}
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
res.set('Access-Control-Max-Age', '3600');
};
const withCors = (handler) => {
return async (req, res) => {
setCorsHeaders(res, req);
// Gérer les requêtes preflight OPTIONS immédiatement
if (req.method === 'OPTIONS') {
res.status(204).send('');
return;
}
try {
await handler(req, res);
} catch (error) {
logger.error("Unhandled error:", error);
if (!res.headersSent) {
res.status(500).json({error: error.message});
}
}
};
};
const {onCall} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const nodemailer = require("nodemailer");
const logger = require("firebase-functions/logger");
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require("./utils/emailTemplates");
/**
* Crée une alerte et envoie les notifications
* Gère tout le processus côté backend de A à Z
*/
exports.createAlert = onRequest({
cors: false,
invoker: 'public',
region: 'europe-west9'
}, withCors(async (req, res) => {
const handler = async (request) => {
try {
const {auth, data} = request;
// Vérifier l'authentification
const decodedToken = await auth.authenticateUser(req);
const data = req.body.data || req.body;
if (!auth) {
throw new Error("L'utilisateur doit être authentifié");
}
const {
@@ -70,7 +32,7 @@ exports.createAlert = onRequest({
// Validation des données
if (!type || !severity || !message) {
res.status(400).json({error: 'type, severity et message sont requis'});
res.status(400).json({error: "type, severity et message sont requis"});
return;
}
@@ -78,12 +40,12 @@ exports.createAlert = onRequest({
const userIds = await determineTargetUsers(type, severity, eventId);
if (userIds.length === 0) {
res.status(400).json({error: 'Aucun utilisateur à notifier'});
res.status(400).json({error: "Aucun utilisateur à notifier"});
return;
}
// 2. Créer l'alerte dans Firestore
const alertRef = admin.firestore().collection('alerts').doc();
const alertRef = admin.firestore().collection("alerts").doc();
const alertData = {
id: alertRef.id,
type,
@@ -96,17 +58,17 @@ exports.createAlert = onRequest({
metadata: metadata || {},
assignedTo: userIds,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
createdBy: decodedToken.uid,
createdBy: auth.uid,
isRead: false,
emailSent: false,
status: 'ACTIVE',
status: "ACTIVE",
};
await alertRef.set(alertData);
// 3. Envoyer les emails si alerte critique
let emailResults = {};
if (severity === 'CRITICAL') {
if (severity === "CRITICAL") {
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
// Mettre à jour le statut d'envoi
@@ -117,17 +79,17 @@ exports.createAlert = onRequest({
});
}
res.status(200).json({
return {
success: true,
alertId: alertRef.id,
usersNotified: userIds.length,
emailsSent: Object.values(emailResults).filter((v) => v).length,
});
};
} catch (error) {
logger.error('[createAlert] Erreur:', error);
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
logger.error("[createAlert] Erreur:", error);
throw error;
}
}));
};
/**
* Détermine les utilisateurs à notifier selon le type d'alerte
@@ -137,23 +99,23 @@ async function determineTargetUsers(alertType, severity, eventId) {
const targetUserIds = new Set();
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
const allUsersSnapshot = await db.collection('users').get();
const allUsersSnapshot = await db.collection("users").get();
allUsersSnapshot.forEach((doc) => {
const user = doc.data();
if (user.role) {
// Le rôle peut être une référence Firestore ou une string
let rolePath = '';
if (typeof user.role === 'string') {
let rolePath = "";
if (typeof user.role === "string") {
rolePath = user.role;
} else if (user.role.path) {
rolePath = user.role.path;
} else if (user.role._path && user.role._path.segments) {
rolePath = user.role._path.segments.join('/');
rolePath = user.role._path.segments.join("/");
}
// Vérifier si c'est un admin (path = "roles/ADMIN")
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
if (rolePath === "roles/ADMIN" || rolePath === "ADMIN") {
targetUserIds.add(doc.id);
}
}
@@ -162,7 +124,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
// 2. Si un événement est lié, ajouter tous les membres de la workforce
if (eventId) {
try {
const eventDoc = await db.collection('events').doc(eventId).get();
const eventDoc = await db.collection("events").doc(eventId).get();
if (eventDoc.exists) {
const event = eventDoc.data();
@@ -177,7 +139,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
}
} catch (error) {
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
logger.error("[determineTargetUsers] Erreur récupération événement:", error);
}
}
@@ -189,7 +151,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
*/
async function sendAlertEmails(alertId, alertData, userIds) {
const results = {};
const transporter = nodemailer.createTransporter(getSmtpConfig());
const transporter = nodemailer.createTransport(getSmtpConfig());
// Envoyer les emails en parallèle (batch de 5)
const batches = [];
@@ -222,7 +184,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
const db = admin.firestore();
// Récupérer l'utilisateur
const userDoc = await db.collection('users').doc(userId).get();
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
return false;
@@ -250,7 +212,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
const templateData = await prepareTemplateData(alertData, user);
// Rendre le template
const html = await renderTemplate('alert-individual', templateData);
const html = await renderTemplate("alert-individual", templateData);
// Envoyer l'email
await transporter.sendMail({
@@ -269,3 +231,10 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
}
}
exports.createAlert = onCall({
cors: true,
region: "europe-west9",
}, handler);
exports.handler = handler;
+21 -21
View File
@@ -4,9 +4,9 @@
* Avec système de cache dans Firebase Storage
*/
const textToSpeech = require('@google-cloud/text-to-speech');
const crypto = require('crypto');
const logger = require('firebase-functions/logger');
const textToSpeech = require("@google-cloud/text-to-speech");
const crypto = require("crypto");
const logger = require("firebase-functions/logger");
/**
* Génère un hash MD5 pour le texte (utilisé comme clé de cache)
@@ -16,10 +16,10 @@ const logger = require('firebase-functions/logger');
function generateCacheKey(text, voiceConfig = {}) {
const cacheString = JSON.stringify({
text,
lang: voiceConfig.languageCode || 'fr-FR',
voice: voiceConfig.name || 'fr-FR-Standard-B',
lang: voiceConfig.languageCode || "fr-FR",
voice: voiceConfig.name || "fr-FR-Standard-B",
});
return crypto.createHash('md5').update(cacheString).digest('hex');
return crypto.createHash("md5").update(cacheString).digest("hex");
}
/**
@@ -34,18 +34,18 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
try {
// Validation du texte
if (!text || text.trim().length === 0) {
throw new Error('Text cannot be empty');
throw new Error("Text cannot be empty");
}
if (text.length > 5000) {
throw new Error('Text too long (max 5000 characters)');
throw new Error("Text too long (max 5000 characters)");
}
// Configuration par défaut de la voix
const defaultVoiceConfig = {
languageCode: 'fr-FR',
name: 'fr-FR-Standard-B', // Voix masculine française (Standard = gratuit)
ssmlGender: 'MALE',
languageCode: "fr-FR",
name: "fr-FR-Standard-B", // Voix masculine française (Standard = gratuit)
ssmlGender: "MALE",
};
const finalVoiceConfig = {...defaultVoiceConfig, ...voiceConfig};
@@ -59,11 +59,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
const [exists] = await file.exists();
if (exists) {
logger.info('[generateTTS] ✓ Cache HIT', { cacheKey, text: text.substring(0, 50) });
logger.info("[generateTTS] ✓ Cache HIT", {cacheKey, text: text.substring(0, 50)});
// Générer une URL signée valide 7 jours
const [url] = await file.getSignedUrl({
action: 'read',
action: "read",
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
});
@@ -74,7 +74,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
};
}
logger.info('[generateTTS] ○ Cache MISS - Generating audio', {
logger.info("[generateTTS] ○ Cache MISS - Generating audio", {
cacheKey,
text: text.substring(0, 50),
voice: finalVoiceConfig.name,
@@ -88,7 +88,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
input: {text: text},
voice: finalVoiceConfig,
audioConfig: {
audioEncoding: 'MP3',
audioEncoding: "MP3",
speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension
pitch: -2.0, // Voix un peu plus grave
volumeGainDb: 0.0,
@@ -99,17 +99,17 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
const [response] = await client.synthesizeSpeech(request);
if (!response.audioContent) {
throw new Error('No audio content returned from TTS API');
throw new Error("No audio content returned from TTS API");
}
logger.info('[generateTTS] ✓ Audio generated', {
logger.info("[generateTTS] ✓ Audio generated", {
size: response.audioContent.length,
});
// Sauvegarder dans Firebase Storage
await file.save(response.audioContent, {
metadata: {
contentType: 'audio/mpeg',
contentType: "audio/mpeg",
metadata: {
text: text.substring(0, 100), // Premier 100 caractères pour debug
voice: finalVoiceConfig.name,
@@ -118,11 +118,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
},
});
logger.info('[generateTTS] ✓ Audio cached', { fileName });
logger.info("[generateTTS] ✓ Audio cached", {fileName});
// Générer une URL signée
const [url] = await file.getSignedUrl({
action: 'read',
action: "read",
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
});
@@ -132,7 +132,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
cacheKey,
};
} catch (error) {
logger.error('[generateTTS] ✗ Error', {
logger.error("[generateTTS] ✗ Error", {
error: error.message,
code: error.code,
text: text?.substring(0, 50),
+336 -3888
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -2,17 +2,17 @@
* Script de migration : Active les emails pour tous les utilisateurs existants
* À exécuter une seule fois après le déploiement
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
// AJOUTER CECI : Charger le fichier de clé
const serviceAccount = require('./serviceAccountKey.json');
const serviceAccount = require("./serviceAccountKey.json");
// Initialiser Firebase Admin avec les credentials explicites
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici
projectId: 'em2rp-951dc',
projectId: "em2rp-951dc",
});
}
@@ -22,11 +22,11 @@ const db = admin.firestore();
* Active les notifications par email pour tous les utilisateurs existants
*/
async function migrateEmailPreferences() {
console.log('=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n');
console.log("=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n");
try {
// 1. Récupérer tous les utilisateurs
const usersSnapshot = await db.collection('users').get();
const usersSnapshot = await db.collection("users").get();
console.log(`${usersSnapshot.size} utilisateurs trouvés\n`);
// 2. Préparer les updates
@@ -49,7 +49,7 @@ async function migrateEmailPreferences() {
updates.push({
ref: doc.ref,
data: {
'notificationPreferences.emailEnabled': true,
"notificationPreferences.emailEnabled": true,
},
});
}
@@ -83,7 +83,7 @@ async function migrateEmailPreferences() {
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
}
console.log('=== FIN MIGRATION ===');
console.log("=== FIN MIGRATION ===");
return {
success: true,
total: usersSnapshot.size,
@@ -91,7 +91,7 @@ async function migrateEmailPreferences() {
updated: toUpdate,
};
} catch (error) {
console.error('❌ ERREUR MIGRATION:', error);
console.error("❌ ERREUR MIGRATION:", error);
throw error;
}
}
@@ -100,11 +100,11 @@ async function migrateEmailPreferences() {
if (require.main === module) {
migrateEmailPreferences()
.then((result) => {
console.log('\n✓ Migration réussie:', result);
console.log("\n✓ Migration réussie:", result);
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Migration échouée:', error);
console.error("\n❌ Migration échouée:", error);
process.exit(1);
});
}
+20 -21
View File
@@ -5,28 +5,28 @@
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
*/
const admin = require('firebase-admin');
const serviceAccount = require('./serviceAccountKey.json');
const admin = require("firebase-admin");
const serviceAccount = require("./serviceAccountKey.json");
// Initialiser Firebase Admin
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
credential: admin.credential.cert(serviceAccount),
});
const db = admin.firestore();
async function migrateEquipmentIds() {
console.log('🔧 Migration: Ajout du champ id aux équipements');
console.log('================================================\n');
console.log("🔧 Migration: Ajout du champ id aux équipements");
console.log("================================================\n");
try {
// Récupérer tous les équipements
const equipmentsSnapshot = await db.collection('equipments').get();
const equipmentsSnapshot = await db.collection("equipments").get();
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
let missingIdCount = 0;
let updatedCount = 0;
let errorCount = 0;
const errorCount = 0;
const batch = db.batch();
let batchCount = 0;
@@ -34,9 +34,9 @@ async function migrateEquipmentIds() {
const data = doc.data();
// Vérifier si le champ 'id' est manquant ou vide
if (!data.id || data.id === '') {
if (!data.id || data.id === "") {
missingIdCount++;
console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`);
console.log(`❌ Équipement ${doc.id} (${data.name || "Sans nom"}) : champ 'id' manquant`);
// Ajouter au batch
batch.update(doc.ref, {id: doc.id});
@@ -58,25 +58,24 @@ async function migrateEquipmentIds() {
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
}
console.log('\n================================================');
console.log('📊 RÉSUMÉ DE LA MIGRATION');
console.log('================================================');
console.log("\n================================================");
console.log("📊 RÉSUMÉ DE LA MIGRATION");
console.log("================================================");
console.log(`Total d'équipements: ${equipmentsSnapshot.size}`);
console.log(`Équipements avec 'id' manquant: ${missingIdCount}`);
console.log(`Équipements mis à jour: ${updatedCount}`);
console.log(`Erreurs: ${errorCount}`);
console.log('================================================\n');
console.log("================================================\n");
if (missingIdCount === 0) {
console.log('✅ Tous les équipements ont déjà un champ id !');
console.log("✅ Tous les équipements ont déjà un champ id !");
} else if (updatedCount === missingIdCount) {
console.log('✅ Migration terminée avec succès !');
console.log("✅ Migration terminée avec succès !");
} else {
console.log('⚠️ Migration terminée avec des erreurs');
console.log("⚠️ Migration terminée avec des erreurs");
}
} catch (error) {
console.error('❌ Erreur lors de la migration:', error);
console.error("❌ Erreur lors de la migration:", error);
throw error;
}
}
@@ -84,10 +83,10 @@ async function migrateEquipmentIds() {
// Exécuter la migration
migrateEquipmentIds()
.then(() => {
console.log('\n✅ Script terminé');
console.log("\n✅ Script terminé");
process.exit(0);
})
.catch(error => {
console.error('\n❌ Script échoué:', error);
.catch((error) => {
console.error("\n❌ Script échoué:", error);
process.exit(1);
});
+19 -5
View File
@@ -8,11 +8,12 @@
"dependencies": {
"@google-cloud/storage": "^7.18.0",
"@google-cloud/text-to-speech": "^5.4.0",
"@google/generative-ai": "^0.21.0",
"axios": "^1.13.2",
"dotenv": "^17.2.3",
"envdot": "^0.0.3",
"firebase-admin": "^12.6.0",
"firebase-functions": "^7.0.3",
"firebase-functions": "^7.2.5",
"handlebars": "^4.7.8",
"nodemailer": "^6.10.1"
},
@@ -785,6 +786,15 @@
"node": ">=14.0.0"
}
},
"node_modules/@google/generative-ai": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz",
"integrity": "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
@@ -3354,9 +3364,9 @@
}
},
"node_modules/firebase-functions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz",
"integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==",
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz",
"integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -3375,7 +3385,8 @@
"peerDependencies": {
"@apollo/server": "^5.2.0",
"@as-integrations/express4": "^1.1.2",
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0",
"graphql": "^16.12.0"
},
"peerDependenciesMeta": {
"@apollo/server": {
@@ -3383,6 +3394,9 @@
},
"@as-integrations/express4": {
"optional": true
},
"graphql": {
"optional": true
}
}
},
+4 -1
View File
@@ -16,11 +16,14 @@
"dependencies": {
"@google-cloud/storage": "^7.18.0",
"@google-cloud/text-to-speech": "^5.4.0",
"@google/generative-ai": "^0.21.0",
"@mapbox/polyline": "^1.2.1",
"axios": "^1.13.2",
"csv-parser": "^3.2.1",
"dotenv": "^17.2.3",
"envdot": "^0.0.3",
"firebase-admin": "^12.6.0",
"firebase-functions": "^7.0.3",
"firebase-functions": "^7.2.5",
"handlebars": "^4.7.8",
"nodemailer": "^6.10.1"
},
+111 -63
View File
@@ -1,23 +1,20 @@
const {onCall} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const nodemailer = require('nodemailer');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
const {onCall} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const nodemailer = require("nodemailer");
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
/**
* Traite la validation du matériel d'un événement
* Appelée par le client lors du chargement/déchargement
* Crée automatiquement les alertes nécessaires
*/
exports.processEquipmentValidation = onCall({
cors: true,
region: 'europe-west9'
}, async (request) => {
const handler = async (request) => {
try {
// L'authentification est automatique avec onCall
const {auth, data} = request;
if (!auth) {
throw new Error('L\'utilisateur doit être authentifié');
throw new Error("L'utilisateur doit être authentifié");
}
const {
@@ -28,34 +25,39 @@ exports.processEquipmentValidation = onCall({
// Validation
if (!eventId || !equipmentList || !validationType) {
throw new Error('eventId, equipmentList et validationType sont requis');
throw new Error("eventId, equipmentList et validationType sont requis");
}
const db = admin.firestore();
const alerts = [];
// 1. Récupérer les détails de l'événement
const eventRef = db.collection('events').doc(eventId);
const eventRef = db.collection("events").doc(eventId);
const eventDoc = await eventRef.get();
if (!eventDoc.exists) {
throw new Error('Événement introuvable');
throw new Error("Événement introuvable");
}
const event = eventDoc.data();
const eventName = event.Name || event.name || 'Événement inconnu';
const eventName = event.Name || event.name || "Événement inconnu";
const eventDate = formatEventDate(event);
// 2. Analyser les équipements et détecter les problèmes
for (const equipment of equipmentList) {
const {equipmentId, status, quantity, expectedQuantity} = equipment;
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
if (status === "NOT_TAKEN") {
continue;
}
// Cas 1: Équipement PERDU
if (status === 'LOST') {
if (status === "LOST") {
const alertData = await createAlertInFirestore({
type: 'LOST',
severity: 'CRITICAL',
title: 'Équipement perdu',
type: "LOST",
severity: "CRITICAL",
title: "Équipement perdu",
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
@@ -71,11 +73,11 @@ exports.processEquipmentValidation = onCall({
}
// Cas 2: Équipement MANQUANT
if (status === 'MISSING') {
if (status === "MISSING") {
const alertData = await createAlertInFirestore({
type: 'EQUIPMENT_MISSING',
severity: 'WARNING',
title: 'Équipement manquant',
type: "EQUIPMENT_MISSING",
severity: "WARNING",
title: "Équipement manquant",
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
@@ -91,11 +93,13 @@ exports.processEquipmentValidation = onCall({
}
// Cas 3: Quantité incorrecte
if (expectedQuantity && quantity !== expectedQuantity) {
const hasExpectedQuantity = typeof expectedQuantity === "number";
const hasActualQuantity = typeof quantity === "number";
if (hasExpectedQuantity && hasActualQuantity && quantity !== expectedQuantity) {
const alertData = await createAlertInFirestore({
type: 'QUANTITY_MISMATCH',
severity: 'INFO',
title: 'Quantité incorrecte',
type: "QUANTITY_MISMATCH",
severity: "INFO",
title: "Quantité incorrecte",
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
equipmentId,
eventId,
@@ -113,11 +117,11 @@ exports.processEquipmentValidation = onCall({
}
// Cas 4: Équipement endommagé
if (status === 'DAMAGED') {
if (status === "DAMAGED") {
const alertData = await createAlertInFirestore({
type: 'DAMAGED',
severity: 'WARNING',
title: 'Équipement endommagé',
type: "DAMAGED",
severity: "WARNING",
title: "Équipement endommagé",
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
@@ -133,9 +137,8 @@ exports.processEquipmentValidation = onCall({
}
}
// 3. Mettre à jour les équipements de l'événement
// 3. Mettre à jour les équipements de l'événement (uniquement lastValidation, assignedEquipment est déjà mis à jour par le client)
await eventRef.update({
equipment: equipmentList,
lastValidation: {
type: validationType,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
@@ -144,7 +147,7 @@ exports.processEquipmentValidation = onCall({
});
// 4. Envoyer les notifications pour les alertes critiques
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL");
if (criticalAlerts.length > 0) {
for (const alert of criticalAlerts) {
try {
@@ -162,7 +165,7 @@ exports.processEquipmentValidation = onCall({
alertIds: alerts.map((a) => a.id),
};
} catch (error) {
logger.error('[processEquipmentValidation] Erreur:', error);
logger.error("[processEquipmentValidation] Erreur:", error);
throw error;
}
});
@@ -172,14 +175,14 @@ exports.processEquipmentValidation = onCall({
*/
async function createAlertInFirestore(alertData) {
const db = admin.firestore();
const alertRef = db.collection('alerts').doc();
const alertRef = db.collection("alerts").doc();
const fullAlertData = {
id: alertRef.id,
...alertData,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
isRead: false,
status: 'ACTIVE',
status: "ACTIVE",
emailSent: false,
assignedTo: [],
};
@@ -199,7 +202,7 @@ async function sendAlertNotifications(alert, eventId) {
try {
// 1. Récupérer TOUS les utilisateurs et leurs permissions
const allUsersSnapshot = await db.collection('users').get();
const allUsersSnapshot = await db.collection("users").get();
// Créer un map pour stocker les références de rôles à récupérer
const roleRefs = new Map();
@@ -212,17 +215,17 @@ async function sendAlertNotifications(alert, eventId) {
}
// Extraire le chemin du rôle
let rolePath = '';
let roleId = '';
let rolePath = "";
let roleId = "";
if (typeof user.role === 'string') {
if (typeof user.role === "string") {
rolePath = user.role;
roleId = user.role.split('/').pop();
roleId = user.role.split("/").pop();
} else if (user.role.path) {
rolePath = user.role.path;
roleId = user.role.path.split('/').pop();
roleId = user.role.path.split("/").pop();
} else if (user.role._path && user.role._path.segments) {
rolePath = user.role._path.segments.join('/');
rolePath = user.role._path.segments.join("/");
roleId = user.role._path.segments[user.role._path.segments.length - 1];
}
@@ -238,14 +241,14 @@ async function sendAlertNotifications(alert, eventId) {
// 2. Récupérer les permissions de chaque rôle unique
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
try {
const roleDoc = await db.collection('roles').doc(roleId).get();
const roleDoc = await db.collection("roles").doc(roleId).get();
if (roleDoc.exists) {
const roleData = roleDoc.data();
const permissions = roleData.permissions || [];
// Vérifier si le rôle a la permission view_all_events
if (permissions.includes('view_all_events')) {
if (permissions.includes("view_all_events")) {
users.forEach((userId) => {
usersWithPermission.add(userId);
targetUserIds.add(userId);
@@ -259,7 +262,7 @@ async function sendAlertNotifications(alert, eventId) {
// 3. Ajouter la workforce de l'événement
if (eventId) {
const eventDoc = await db.collection('events').doc(eventId).get();
const eventDoc = await db.collection("events").doc(eventId).get();
if (eventDoc.exists) {
const event = eventDoc.data();
@@ -269,14 +272,14 @@ async function sendAlertNotifications(alert, eventId) {
// Extraire l'userId selon différentes structures possibles
let userId = null;
if (typeof member === 'string') {
if (typeof member === "string") {
userId = member;
} else if (member.userId) {
userId = member.userId;
} else if (member.id) {
userId = member.id;
} else if (member.user) {
if (typeof member.user === 'string') {
if (typeof member.user === "string") {
userId = member.user;
} else if (member.user.id) {
userId = member.user.id;
@@ -293,18 +296,18 @@ async function sendAlertNotifications(alert, eventId) {
const userIds = Array.from(targetUserIds);
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
await db.collection('alerts').doc(alert.id).update({
await db.collection("alerts").doc(alert.id).update({
assignedTo: userIds,
});
// 5. Envoyer les emails si alerte critique
if (alert.severity === 'CRITICAL') {
if (alert.severity === "CRITICAL") {
await sendAlertEmails(alert, userIds);
}
return userIds;
} catch (error) {
logger.error('[sendAlertNotifications] Erreur:', error);
logger.error("[sendAlertNotifications] Erreur:", error);
throw error;
}
}
@@ -314,12 +317,12 @@ async function sendAlertNotifications(alert, eventId) {
*/
async function sendAlertEmails(alert, userIds) {
try {
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
const {renderTemplate, getEmailSubject, prepareTemplateData} = require("./utils/emailTemplates");
const db = admin.firestore();
// Vérifier que EMAIL_CONFIG est disponible
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré');
logger.error("[sendAlertEmails] EMAIL_CONFIG non configuré");
return 0;
}
@@ -336,7 +339,7 @@ async function sendAlertEmails(alert, userIds) {
const promises = batch.map(async (userId) => {
try {
// Récupérer l'utilisateur
const userDoc = await db.collection('users').doc(userId).get();
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
return false;
@@ -358,13 +361,13 @@ async function sendAlertEmails(alert, userIds) {
let html;
try {
const templateData = await prepareTemplateData(alert, user);
html = await renderTemplate('alert-individual', templateData);
html = await renderTemplate("alert-individual", templateData);
} catch (templateError) {
logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError);
html = `
<html>
<body>
<h2>${alert.title || 'Nouvelle alerte'}</h2>
<h2>${alert.title || "Nouvelle alerte"}</h2>
<p>${alert.message}</p>
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
</body>
@@ -392,7 +395,7 @@ async function sendAlertEmails(alert, userIds) {
}
// Mettre à jour l'alerte
await db.collection('alerts').doc(alert.id).update({
await db.collection("alerts").doc(alert.id).update({
emailSent: true,
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
emailsSentCount: successCount,
@@ -400,7 +403,7 @@ async function sendAlertEmails(alert, userIds) {
return successCount;
} catch (error) {
logger.error('[sendAlertEmails] Erreur globale:', error);
logger.error("[sendAlertEmails] Erreur globale:", error);
return 0;
}
}
@@ -409,10 +412,55 @@ async function sendAlertEmails(alert, userIds) {
* Formate la date d'un événement
*/
function formatEventDate(event) {
if (event.startDate) {
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate);
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'});
}
return 'Date inconnue';
const rawDate =
event?.StartDateTime ||
event?.startDateTime ||
event?.startDate ||
event?.eventDate;
const parsedDate = parseFirestoreDate(rawDate);
const safeDate = parsedDate || new Date();
return safeDate.toLocaleDateString("fr-FR", {
day: "numeric",
month: "numeric",
year: "numeric",
});
}
function parseFirestoreDate(value) {
if (!value) {
return null;
}
if (typeof value.toDate === "function") {
return value.toDate();
}
if (value instanceof Date) {
return value;
}
if (typeof value === "string" || typeof value === "number") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
if (typeof value === "object" && typeof value.seconds === "number") {
return new Date(value.seconds * 1000);
}
if (typeof value === "object" && typeof value._seconds === "number") {
return new Date(value._seconds * 1000);
}
return null;
}
exports.processEquipmentValidation = onCall({
cors: true,
region: "europe-west9",
}, handler);
exports.handler = handler;
+185
View File
@@ -0,0 +1,185 @@
const {onCall} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
/**
* Reverts the validation progress of an event to a target step.
* Resets subsequent step statuses and validation flags of assigned equipment.
* If rolling back from a completed return, it decrements the consumable stock quantities
* that were restored during return validation.
*/
const handler = async (request) => {
try {
const {auth, data} = request;
if (!auth) {
throw new Error("L'utilisateur doit être authentifié");
}
const {eventId, targetStep} = data;
if (!eventId || !targetStep) {
throw new Error("eventId et targetStep sont requis");
}
const db = admin.firestore();
const eventRef = db.collection("events").doc(eventId);
await db.runTransaction(async (transaction) => {
const eventDoc = await transaction.get(eventRef);
if (!eventDoc.exists) {
throw new Error("Événement introuvable");
}
const event = eventDoc.data();
const assignedEquipment = event.assignedEquipment || [];
// Si le retour était complété et qu'on revient en arrière, on doit annuler la restauration des stocks
const shouldRevertStocks = event.stocksRestored === true;
if (shouldRevertStocks) {
// Charger tous les équipements uniques de l'événement pour ajuster leur stock
const equipmentIds = Array.from(new Set(assignedEquipment.map((eq) => eq.equipmentId).filter(Boolean)));
const equipmentDocsMap = {};
for (const eqId of equipmentIds) {
const eqRef = db.collection("equipments").doc(eqId);
const eqDoc = await transaction.get(eqRef);
if (eqDoc.exists) {
equipmentDocsMap[eqId] = eqDoc.data();
}
}
for (const eq of assignedEquipment) {
const eqId = eq.equipmentId;
const equipmentData = equipmentDocsMap[eqId];
if (!equipmentData) continue;
const hasQuantity = equipmentData.hasQuantity === true ||
equipmentData.category === "CABLE" ||
equipmentData.category === "CONSUMABLE";
if (hasQuantity) {
// C'est un consommable, on doit déduire la quantité qui avait été restaurée
const qtyAtRet = Number(eq.quantityAtReturn) || 0;
if (qtyAtRet > 0) {
const eqRef = db.collection("equipments").doc(eqId);
const currentAvailable = Number(equipmentData.availableQuantity) || 0;
// S'assurer de ne pas descendre en dessous de 0 (ou autoriser le négatif si stock virtuel)
const newAvailable = Math.max(0, currentAvailable - qtyAtRet);
transaction.update(eqRef, {
availableQuantity: newAvailable,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
logger.info(`[rollbackEventStep] Annulé la restauration de ${qtyAtRet} pour ${eqId}. Ancien stock: ${currentAvailable}, Nouveau stock: ${newAvailable}`);
}
}
}
}
// Préparer les nouvelles valeurs des étapes
let prepStatus = event.preparationStatus;
let loadStatus = event.loadingStatus;
let unloadStatus = event.unloadingStatus;
let retStatus = event.returnStatus;
if (targetStep === 'PREPARATION') {
prepStatus = 'IN_PROGRESS';
loadStatus = 'NOT_STARTED';
unloadStatus = 'NOT_STARTED';
retStatus = 'NOT_STARTED';
} else if (targetStep === 'LOADING') {
loadStatus = 'IN_PROGRESS';
unloadStatus = 'NOT_STARTED';
retStatus = 'NOT_STARTED';
} else if (targetStep === 'UNLOADING') {
unloadStatus = 'IN_PROGRESS';
retStatus = 'NOT_STARTED';
} else if (targetStep === 'RETURN') {
retStatus = 'IN_PROGRESS';
} else {
throw new Error("targetStep invalide. Doit être PREPARATION, LOADING, UNLOADING ou RETURN");
}
// Nettoyer les champs de validation des équipements pour les étapes annulées
const updatedEquipment = assignedEquipment.map((eq) => {
let isPrepared = eq.isPrepared;
let isMissingAtPreparation = eq.isMissingAtPreparation;
let quantityAtPreparation = eq.quantityAtPreparation;
let isLoaded = eq.isLoaded;
let isMissingAtLoading = eq.isMissingAtLoading;
let quantityAtLoading = eq.quantityAtLoading;
let isUnloaded = eq.isUnloaded;
let isMissingAtUnloading = eq.isMissingAtUnloading;
let quantityAtUnloading = eq.quantityAtUnloading;
let isReturned = eq.isReturned;
let isMissingAtReturn = eq.isMissingAtReturn;
let quantityAtReturn = eq.quantityAtReturn;
if (targetStep === 'PREPARATION') {
isLoaded = false;
isMissingAtLoading = false;
quantityAtLoading = null;
isUnloaded = false;
isMissingAtUnloading = false;
quantityAtUnloading = null;
isReturned = false;
isMissingAtReturn = false;
quantityAtReturn = null;
} else if (targetStep === 'LOADING') {
isUnloaded = false;
isMissingAtUnloading = false;
quantityAtUnloading = null;
isReturned = false;
isMissingAtReturn = false;
quantityAtReturn = null;
} else if (targetStep === 'UNLOADING') {
isReturned = false;
isMissingAtReturn = false;
quantityAtReturn = null;
}
return {
...eq,
isPrepared,
isMissingAtPreparation,
quantityAtPreparation,
isLoaded,
isMissingAtLoading,
quantityAtLoading,
isUnloaded,
isMissingAtUnloading,
quantityAtUnloading,
isReturned,
isMissingAtReturn,
quantityAtReturn,
};
});
// Mettre à jour le document de l'événement
transaction.update(eventRef, {
preparationStatus: prepStatus,
loadingStatus: loadStatus,
unloadingStatus: unloadStatus,
returnStatus: retStatus,
assignedEquipment: updatedEquipment,
stocksRestored: false,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
});
logger.info(`[rollbackEventStep] Événement ${eventId} réinitialisé avec succès à l'étape ${targetStep}`);
return {success: true};
} catch (error) {
logger.error("[rollbackEventStep] Erreur:", error);
throw error;
}
};
exports.rollbackEventStep = onCall({
cors: true,
region: "europe-west9",
}, handler);
exports.handler = handler;
+67 -63
View File
@@ -1,51 +1,48 @@
const {onCall} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
const fs = require('fs').promises;
const path = require('path');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
const {onCall} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const nodemailer = require("nodemailer");
const handlebars = require("handlebars");
const fs = require("fs").promises;
const path = require("path");
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
/**
* Envoie un email d'alerte à un utilisateur
* Appelé par le client Dart via callable function
*/
exports.sendAlertEmail = onCall({
region: 'europe-west9',
cors: true
}, async (request) => {
const handler = async (request) => {
// Vérifier l'authentification
if (!request.auth) {
throw new Error('L\'utilisateur doit être authentifié');
throw new Error("L'utilisateur doit être authentifié");
}
const {alertId, userId, templateType} = request.data;
if (!alertId || !userId) {
throw new Error('alertId et userId sont requis');
throw new Error("alertId et userId sont requis");
}
try {
// Récupérer l'alerte depuis Firestore
const alertDoc = await admin.firestore()
.collection('alerts')
.collection("alerts")
.doc(alertId)
.get();
if (!alertDoc.exists) {
throw new Error('Alerte introuvable');
throw new Error("Alerte introuvable");
}
const alert = alertDoc.data();
// Récupérer l'utilisateur
const userDoc = await admin.firestore()
.collection('users')
.collection("users")
.doc(userId)
.get();
if (!userDoc.exists) {
throw new Error('Utilisateur introuvable');
throw new Error("Utilisateur introuvable");
}
const user = userDoc.data();
@@ -54,7 +51,7 @@ exports.sendAlertEmail = onCall({
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
console.log(`Email désactivé pour l'utilisateur ${userId}`);
return {success: true, skipped: true, reason: 'email_disabled'};
return {success: true, skipped: true, reason: "email_disabled"};
}
// Vérifier la préférence pour ce type d'alerte
@@ -62,7 +59,7 @@ exports.sendAlertEmail = onCall({
const shouldSend = checkAlertPreference(alertType, prefs);
if (!shouldSend) {
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
return {success: true, skipped: true, reason: 'alert_type_disabled'};
return {success: true, skipped: true, reason: "alert_type_disabled"};
}
// Préparer les données pour le template
@@ -70,12 +67,12 @@ exports.sendAlertEmail = onCall({
// Rendre le template HTML
const html = await renderTemplate(
templateType || 'alert-individual',
templateType || "alert-individual",
templateData,
);
// Configurer le transporteur SMTP
const transporter = nodemailer.createTransporter(getSmtpConfig());
const transporter = nodemailer.createTransport(getSmtpConfig());
// Envoyer l'email
const info = await transporter.sendMail({
@@ -88,7 +85,7 @@ exports.sendAlertEmail = onCall({
text: alert.message,
});
console.log('Email envoyé:', info.messageId);
console.log("Email envoyé:", info.messageId);
// Marquer l'email comme envoyé dans l'alerte
await alertDoc.ref.update({
@@ -102,7 +99,7 @@ exports.sendAlertEmail = onCall({
skipped: false,
};
} catch (error) {
console.error('Erreur envoi email:', error);
console.error("Erreur envoi email:", error);
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
}
});
@@ -112,13 +109,13 @@ exports.sendAlertEmail = onCall({
*/
function checkAlertPreference(alertType, preferences) {
const typeMapping = {
'EVENT_CREATED': 'eventsNotifications',
'EVENT_MODIFIED': 'eventsNotifications',
'EVENT_CANCELLED': 'eventsNotifications',
'LOST': 'equipmentNotifications',
'EQUIPMENT_MISSING': 'equipmentNotifications',
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
'STOCK_LOW': 'stockNotifications',
"EVENT_CREATED": "eventsNotifications",
"EVENT_MODIFIED": "eventsNotifications",
"EVENT_CANCELLED": "eventsNotifications",
"LOST": "equipmentNotifications",
"EQUIPMENT_MISSING": "equipmentNotifications",
"MAINTENANCE_REMINDER": "maintenanceNotifications",
"STOCK_LOW": "stockNotifications",
};
const prefKey = typeMapping[alertType];
@@ -130,12 +127,12 @@ function checkAlertPreference(alertType, preferences) {
*/
async function prepareTemplateData(alert, user) {
const data = {
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'Utilisateur',
userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() ||
"Utilisateur",
alertTitle: getAlertTitle(alert.type),
alertMessage: alert.message,
isCritical: alert.severity === 'CRITICAL',
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
isCritical: alert.severity === "CRITICAL",
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`,
appUrl: EMAIL_CONFIG.appUrl,
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
year: new Date().getFullYear(),
@@ -146,7 +143,7 @@ async function prepareTemplateData(alert, user) {
if (alert.eventId) {
try {
const eventDoc = await admin.firestore()
.collection('events')
.collection("events")
.doc(alert.eventId)
.get();
@@ -155,22 +152,22 @@ async function prepareTemplateData(alert, user) {
data.eventName = event.Name;
if (event.StartDateTime) {
const date = event.StartDateTime.toDate();
data.eventDate = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
data.eventDate = date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
}
} catch (error) {
console.error('Erreur récupération événement:', error);
console.error("Erreur récupération événement:", error);
}
}
if (alert.equipmentId) {
try {
const eqDoc = await admin.firestore()
.collection('equipments')
.collection("equipments")
.doc(alert.equipmentId)
.get();
@@ -178,7 +175,7 @@ async function prepareTemplateData(alert, user) {
data.equipmentName = eqDoc.data().name;
}
} catch (error) {
console.error('Erreur récupération équipement:', error);
console.error("Erreur récupération équipement:", error);
}
}
@@ -190,16 +187,16 @@ async function prepareTemplateData(alert, user) {
*/
function getEmailSubject(alert) {
const subjects = {
'EVENT_CREATED': '📅 Nouvel événement créé',
'EVENT_MODIFIED': '📝 Événement modifié',
'EVENT_CANCELLED': '❌ Événement annulé',
'LOST': '🔴 Alerte critique : Équipement perdu',
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
'STOCK_LOW': '📦 Stock faible',
"EVENT_CREATED": "📅 Nouvel événement créé",
"EVENT_MODIFIED": "📝 Événement modifié",
"EVENT_CANCELLED": "❌ Événement annulé",
"LOST": "🔴 Alerte critique : Équipement perdu",
"EQUIPMENT_MISSING": "⚠️ Équipement manquant",
"MAINTENANCE_REMINDER": "🔧 Rappel de maintenance",
"STOCK_LOW": "📦 Stock faible",
};
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events";
}
/**
@@ -207,16 +204,16 @@ function getEmailSubject(alert) {
*/
function getAlertTitle(type) {
const titles = {
'EVENT_CREATED': 'Nouvel événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'EVENT_CANCELLED': 'Événement annulé',
'LOST': 'Équipement perdu',
'EQUIPMENT_MISSING': 'Équipement manquant',
'MAINTENANCE_REMINDER': 'Maintenance requise',
'STOCK_LOW': 'Stock faible',
"EVENT_CREATED": "Nouvel événement créé",
"EVENT_MODIFIED": "Événement modifié",
"EVENT_CANCELLED": "Événement annulé",
"LOST": "Équipement perdu",
"EQUIPMENT_MISSING": "Équipement manquant",
"MAINTENANCE_REMINDER": "Maintenance requise",
"STOCK_LOW": "Stock faible",
};
return titles[type] || 'Nouvelle alerte';
return titles[type] || "Nouvelle alerte";
}
/**
@@ -225,16 +222,16 @@ function getAlertTitle(type) {
async function renderTemplate(templateName, data) {
try {
// Lire le template de base
const basePath = path.join(__dirname, 'templates', 'base-template.html');
const baseTemplate = await fs.readFile(basePath, 'utf8');
const basePath = path.join(__dirname, "templates", "base-template.html");
const baseTemplate = await fs.readFile(basePath, "utf8");
// Lire le template de contenu
const contentPath = path.join(
__dirname,
'templates',
"templates",
`${templateName}.html`,
);
const contentTemplate = await fs.readFile(contentPath, 'utf8');
const contentTemplate = await fs.readFile(contentPath, "utf8");
// Compiler les templates
const compileContent = handlebars.compile(contentTemplate);
@@ -249,7 +246,7 @@ async function renderTemplate(templateName, data) {
content: renderedContent,
});
} catch (error) {
console.error('Erreur rendu template:', error);
console.error("Erreur rendu template:", error);
// Fallback vers un template simple
return `
<html>
@@ -263,3 +260,10 @@ async function renderTemplate(templateName, data) {
}
}
exports.sendAlertEmail = onCall({
region: "europe-west9",
cors: true,
}, handler);
exports.handler = handler;
+41 -41
View File
@@ -3,10 +3,10 @@
* S'exécute tous les jours à 8h00 (Europe/Paris)
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const nodemailer = require('nodemailer');
const { getSmtpConfig } = require('./utils/emailConfig');
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const nodemailer = require("nodemailer");
const {getSmtpConfig} = require("./utils/emailConfig");
/**
* Fonction principale : envoie le digest quotidien
@@ -14,11 +14,11 @@ const { getSmtpConfig } = require('./utils/emailConfig');
async function sendDailyDigest() {
const db = admin.firestore();
logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====');
logger.info("[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====");
try {
// 1. Récupérer tous les utilisateurs avec email activé
const usersSnapshot = await db.collection('users').get();
const usersSnapshot = await db.collection("users").get();
const eligibleUsers = [];
usersSnapshot.forEach((doc) => {
@@ -30,8 +30,8 @@ async function sendDailyDigest() {
eligibleUsers.push({
uid: doc.id,
email: user.email,
firstName: user.firstName || 'Utilisateur',
lastName: user.lastName || '',
firstName: user.firstName || "Utilisateur",
lastName: user.lastName || "",
});
}
});
@@ -48,11 +48,11 @@ async function sendDailyDigest() {
for (const user of eligibleUsers) {
try {
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
const alertsSnapshot = await db.collection('alerts')
.where('assignedTo', 'array-contains', user.uid)
.where('isRead', '==', false)
.where('createdAt', '>=', yesterday)
.orderBy('createdAt', 'desc')
const alertsSnapshot = await db.collection("alerts")
.where("assignedTo", "array-contains", user.uid)
.where("isRead", "==", false)
.where("createdAt", ">=", yesterday)
.orderBy("createdAt", "desc")
.get();
if (alertsSnapshot.empty) {
@@ -77,11 +77,11 @@ async function sendDailyDigest() {
}
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
logger.info("[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====");
return {success: true, emailsSent};
} catch (error) {
logger.error('[sendDailyDigest] Erreur globale:', error);
logger.error("[sendDailyDigest] Erreur globale:", error);
throw error;
}
}
@@ -92,9 +92,9 @@ async function sendDailyDigest() {
async function sendDigestEmail(transporter, user, alerts) {
try {
// Grouper les alertes par sévérité
const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL');
const warningAlerts = alerts.filter(a => a.severity === 'WARNING');
const infoAlerts = alerts.filter(a => a.severity === 'INFO');
const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL");
const warningAlerts = alerts.filter((a) => a.severity === "WARNING");
const infoAlerts = alerts.filter((a) => a.severity === "INFO");
// Construire le HTML
const html = buildDigestHtml(user, {
@@ -125,7 +125,7 @@ async function sendDigestEmail(transporter, user, alerts) {
function buildDigestHtml(user, alertsByType) {
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
let alertsHtml = '';
let alertsHtml = "";
// Alertes critiques
if (alertsByType.critical.length > 0) {
@@ -134,7 +134,7 @@ function buildDigestHtml(user, alertsByType) {
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
🔴 Alertes critiques (${alertsByType.critical.length})
</h3>
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
${alertsByType.critical.map((alert) => formatAlertItem(alert)).join("")}
</div>
`;
}
@@ -146,7 +146,7 @@ function buildDigestHtml(user, alertsByType) {
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
⚠️ Avertissements (${alertsByType.warning.length})
</h3>
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
${alertsByType.warning.map((alert) => formatAlertItem(alert)).join("")}
</div>
`;
}
@@ -158,7 +158,7 @@ function buildDigestHtml(user, alertsByType) {
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
️ Informations (${alertsByType.info.length})
</h3>
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
${alertsByType.info.map((alert) => formatAlertItem(alert)).join("")}
</div>
`;
}
@@ -216,24 +216,24 @@ function buildDigestHtml(user, alertsByType) {
*/
function formatAlertItem(alert) {
const date = alert.createdAt?.toDate ?
new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
new Date(alert.createdAt.toDate()).toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}) :
'Date inconnue';
"Date inconnue";
// Type d'alerte en français
const typeLabels = {
'EQUIPMENT_MISSING': 'Équipement manquant',
'LOST': 'Équipement perdu',
'DAMAGED': 'Équipement endommagé',
'QUANTITY_MISMATCH': 'Écart de quantité',
'EVENT_CREATED': 'Événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'WORKFORCE_ADDED': 'Ajout à la workforce',
"EQUIPMENT_MISSING": "Équipement manquant",
"LOST": "Équipement perdu",
"DAMAGED": "Équipement endommagé",
"QUANTITY_MISMATCH": "Écart de quantité",
"EVENT_CREATED": "Événement créé",
"EVENT_MODIFIED": "Événement modifié",
"WORKFORCE_ADDED": "Ajout à la workforce",
};
const typeLabel = typeLabels[alert.type] || alert.type;
@@ -245,7 +245,7 @@ function formatAlertItem(alert) {
<span style="color: #6b7280; font-size: 13px;">${date}</span>
</div>
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
${alert.message || 'Aucun message'}
${alert.message || "Aucun message"}
</p>
</div>
`;
@@ -256,10 +256,10 @@ function formatAlertItem(alert) {
*/
function getSeverityColor(severity) {
switch (severity) {
case 'CRITICAL': return '#dc2626';
case 'WARNING': return '#f59e0b';
case 'INFO': return '#3b82f6';
default: return '#6b7280';
case "CRITICAL": return "#dc2626";
case "WARNING": return "#f59e0b";
case "INFO": return "#3b82f6";
default: return "#6b7280";
}
}
+72
View File
@@ -0,0 +1,72 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Récupère toutes les alertes (filtrées et limitées)
exports.getAlerts = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("alerts")
.orderBy("createdAt", "desc")
.limit(100)
.get();
const alerts = snapshot.docs.map((doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ["createdAt"]),
};
});
res.status(200).json({alerts});
} catch (error) {
logger.error("Error fetching alerts:", error);
res.status(500).json({error: error.message});
}
};
// Marquer une alerte comme lue
exports.markAlertAsRead = async (req, res) => {
try {
await auth.authenticateUser(req);
const alertId = req.body.data?.alertId;
if (!alertId) {
res.status(400).json({error: "alertId is required"});
return;
}
await db.collection("alerts").doc(alertId).update({
isRead: true,
});
res.status(200).json({success: true});
} catch (error) {
logger.error("Error marking alert as read:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer une alerte
exports.deleteAlert = async (req, res) => {
try {
await auth.authenticateUser(req);
const alertId = req.body.data?.alertId;
if (!alertId) {
res.status(400).json({error: "alertId is required"});
return;
}
await db.collection("alerts").doc(alertId).delete();
res.status(200).json({success: true});
} catch (error) {
logger.error("Error deleting alert:", error);
res.status(500).json({error: error.message});
}
};
+628
View File
@@ -0,0 +1,628 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Vérifie si un équipement est disponible pour une plage de dates
exports.checkEquipmentAvailability = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentId, startDate, endDate, excludeEventId} = req.body.data;
if (!equipmentId || !startDate || !endDate) {
res.status(400).json({error: "equipmentId, startDate, and endDate are required"});
return;
}
logger.info(`Checking availability for equipment ${equipmentId} from ${startDate} to ${endDate}, excluding event: ${excludeEventId}`);
const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate));
const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate));
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
logger.info(`Found ${eventsSnapshot.docs.length} events to check`);
const conflicts = [];
for (const eventDoc of eventsSnapshot.docs) {
const event = eventDoc.data();
if (excludeEventId && eventDoc.id === excludeEventId) {
continue;
}
let eventStart; let eventEnd;
if (event.StartDateTime) {
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
}
if (event.EndDateTime) {
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
}
if (!eventStart || !eventEnd) {
continue;
}
const assignedEquipment = event.assignedEquipment || [];
const assignedContainers = event.assignedContainers || [];
const isEquipmentDirectlyAssigned = assignedEquipment.some((eq) => eq.equipmentId === equipmentId);
let isEquipmentInAssignedContainer = false;
if (assignedContainers.length > 0) {
logger.info(`Event ${eventDoc.id} has ${assignedContainers.length} assigned containers`);
for (const containerId of assignedContainers) {
const containerDoc = await db.collection("containers").doc(containerId).get();
if (containerDoc.exists) {
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
logger.info(`Container ${containerId} contains equipment IDs: ${equipmentIds.join(", ")}`);
if (equipmentIds.includes(equipmentId)) {
isEquipmentInAssignedContainer = true;
logger.info(`Equipment ${equipmentId} found in container ${containerId} for event ${eventDoc.id}`);
break;
}
}
}
}
if (isEquipmentDirectlyAssigned) {
logger.info(`Equipment ${equipmentId} is directly assigned to event ${eventDoc.id}`);
}
if (!isEquipmentDirectlyAssigned && !isEquipmentInAssignedContainer) {
continue;
}
const requestStart = startTimestamp.toDate();
const requestEnd = endTimestamp.toDate();
const installationTime = event.InstallationTime || 0;
const disassemblyTime = event.DisassemblyTime || 0;
const eventStartWithSetup = new Date(eventStart);
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime);
const eventEndWithTeardown = new Date(eventEnd);
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime);
const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup;
if (hasOverlap) {
const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup));
const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown));
const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24));
logger.info(`Conflict detected: Equipment ${equipmentId} conflicts with event ${eventDoc.id} (${event.Name})`);
const eventData = helpers.serializeTimestamps(event);
conflicts.push({
eventId: eventDoc.id,
eventName: event.Name,
eventData: eventData,
startDate: eventStart.toISOString(),
endDate: eventEnd.toISOString(),
overlapDays: overlapDays,
});
}
}
logger.info(`Total conflicts found: ${conflicts.length}`);
res.status(200).json({conflicts, available: conflicts.length === 0});
} catch (error) {
logger.error("Error checking equipment availability:", error);
res.status(500).json({error: error.message || "Failed to check equipment availability"});
}
};
// Vérifie la disponibilité d'un container et de son contenu
exports.checkContainerAvailability = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {containerId, startDate, endDate, excludeEventId} = req.body.data;
if (!containerId || !startDate || !endDate) {
res.status(400).json({error: "containerId, startDate, and endDate are required"});
return;
}
const containerDoc = await db.collection("containers").doc(containerId).get();
if (!containerDoc.exists) {
throw new Error("Container not found");
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate));
const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate));
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const containerConflicts = [];
const equipmentConflicts = {};
for (const eventDoc of eventsSnapshot.docs) {
const event = eventDoc.data();
if (excludeEventId && eventDoc.id === excludeEventId) {
continue;
}
let eventStart; let eventEnd;
if (event.StartDateTime) {
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
}
if (event.EndDateTime) {
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
}
if (!eventStart || !eventEnd) {
continue;
}
const assignedContainers = event.assignedContainers || [];
const isContainerAssigned = assignedContainers.includes(containerId);
const assignedEquipment = event.assignedEquipment || [];
const conflictingEquipmentIds = equipmentIds.filter((eqId) =>
assignedEquipment.some((eq) => eq.equipmentId === eqId),
);
if (!isContainerAssigned && conflictingEquipmentIds.length === 0) {
continue;
}
const requestStart = startTimestamp.toDate();
const requestEnd = endTimestamp.toDate();
const installationTime = event.InstallationTime || 0;
const disassemblyTime = event.DisassemblyTime || 0;
const eventStartWithSetup = new Date(eventStart);
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime);
const eventEndWithTeardown = new Date(eventEnd);
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime);
const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup;
if (hasOverlap) {
const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup));
const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown));
const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24));
const conflictInfo = {
eventId: eventDoc.id,
eventName: event.Name,
startDate: eventStart.toISOString(),
endDate: eventEnd.toISOString(),
overlapDays: overlapDays,
};
if (isContainerAssigned) {
containerConflicts.push(conflictInfo);
}
conflictingEquipmentIds.forEach((eqId) => {
if (!equipmentConflicts[eqId]) {
equipmentConflicts[eqId] = [];
}
equipmentConflicts[eqId].push(conflictInfo);
});
}
}
const hasContainerConflict = containerConflicts.length > 0;
const hasPartialConflict = Object.keys(equipmentConflicts).length > 0 && !hasContainerConflict;
const conflictType = hasContainerConflict ? "complete" : (hasPartialConflict ? "partial" : "none");
res.status(200).json({
conflictType,
containerConflicts,
equipmentConflicts,
isAvailable: conflictType === "none",
});
} catch (error) {
logger.error("Error checking container availability:", error);
res.status(500).json({error: error.message || "Failed to check container availability"});
}
};
// Récupère tous les équipements et conteneurs en conflit pour une période donnée
exports.getConflictingEquipmentIds = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {startDate, endDate, excludeEventId, installationTime = 0, disassemblyTime = 0} = req.body.data;
if (!startDate || !endDate) {
res.status(400).json({error: "startDate and endDate are required"});
return;
}
logger.info(`Getting conflicting equipment IDs for period ${startDate} to ${endDate}`);
const requestStartDate = new Date(startDate);
requestStartDate.setHours(requestStartDate.getHours() - installationTime);
const requestEndDate = new Date(endDate);
requestEndDate.setHours(requestEndDate.getHours() + disassemblyTime);
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
logger.info(`Found ${eventsSnapshot.docs.length} events to check`);
const equipmentsSnapshot = await db.collection("equipments").get();
const equipmentsInfo = {};
equipmentsSnapshot.docs.forEach((doc) => {
const data = doc.data();
equipmentsInfo[doc.id] = {
category: data.category,
totalQuantity: data.totalQuantity || 0,
hasQuantity: data.category === "CABLE" || data.category === "CONSUMABLE",
};
});
const conflictingEquipmentIds = new Set();
const conflictingContainerIds = new Set();
const conflictDetails = {};
const equipmentQuantities = {};
for (const eventDoc of eventsSnapshot.docs) {
if (excludeEventId && eventDoc.id === excludeEventId) {
continue;
}
const event = eventDoc.data();
let eventStart; let eventEnd;
if (event.StartDateTime) {
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
}
if (event.EndDateTime) {
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
}
if (!eventStart || !eventEnd) {
continue;
}
const eventInstallTime = event.InstallationTime || 0;
const eventDisassemblyTime = event.DisassemblyTime || 0;
const eventStartWithSetup = new Date(eventStart);
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - eventInstallTime);
const eventEndWithTeardown = new Date(eventEnd);
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + eventDisassemblyTime);
const hasOverlap = requestStartDate < eventEndWithTeardown && requestEndDate > eventStartWithSetup;
if (!hasOverlap) {
continue;
}
const assignedEquipment = event.assignedEquipment || [];
const assignedContainers = event.assignedContainers || [];
const conflictInfo = {
eventId: eventDoc.id,
eventName: event.Name,
startDate: eventStart.toISOString(),
endDate: eventEnd.toISOString(),
};
for (const eq of assignedEquipment) {
const equipmentId = eq.equipmentId;
const quantity = eq.quantity || 1;
const equipInfo = equipmentsInfo[equipmentId];
if (equipInfo && equipInfo.hasQuantity) {
if (!equipmentQuantities[equipmentId]) {
equipmentQuantities[equipmentId] = {
totalQuantity: equipInfo.totalQuantity,
reservedQuantity: 0,
availableQuantity: equipInfo.totalQuantity,
reservations: [],
};
}
equipmentQuantities[equipmentId].reservedQuantity += quantity;
equipmentQuantities[equipmentId].availableQuantity = equipInfo.totalQuantity - equipmentQuantities[equipmentId].reservedQuantity;
equipmentQuantities[equipmentId].reservations.push({
...conflictInfo,
quantity: quantity,
});
if (equipmentQuantities[equipmentId].availableQuantity <= 0) {
conflictingEquipmentIds.add(equipmentId);
}
} else {
conflictingEquipmentIds.add(equipmentId);
}
if (!conflictDetails[equipmentId]) {
conflictDetails[equipmentId] = [];
}
conflictDetails[equipmentId].push({
...conflictInfo,
quantity: quantity,
});
}
for (const containerId of assignedContainers) {
conflictingContainerIds.add(containerId);
if (!conflictDetails[containerId]) {
conflictDetails[containerId] = [];
}
conflictDetails[containerId].push(conflictInfo);
const containerDoc = await db.collection("containers").doc(containerId).get();
if (containerDoc.exists) {
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
for (const equipmentId of equipmentIds) {
conflictingEquipmentIds.add(equipmentId);
if (!conflictDetails[equipmentId]) {
conflictDetails[equipmentId] = [];
}
conflictDetails[equipmentId].push({
...conflictInfo,
viaContainer: containerId,
viaContainerName: containerData.name || "Conteneur inconnu",
});
}
}
}
}
logger.info(`Found ${conflictingEquipmentIds.size} conflicting equipment(s) and ${conflictingContainerIds.size} conflicting container(s)`);
res.status(200).json({
conflictingEquipmentIds: Array.from(conflictingEquipmentIds),
conflictingContainerIds: Array.from(conflictingContainerIds),
conflictDetails: conflictDetails,
equipmentQuantities: equipmentQuantities,
});
} catch (error) {
logger.error("Error getting conflicting equipment IDs:", error);
res.status(500).json({error: error.message || "Failed to get conflicting equipment IDs"});
}
};
/**
* Trouver des alternatives (même modèle) disponibles pour une période donnée
*/
exports.findAlternativeEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {model, startDate, endDate} = req.body.data;
if (!model || !startDate || !endDate) {
res.status(400).json({error: "model, startDate and endDate are required"});
return;
}
const start = admin.firestore.Timestamp.fromDate(new Date(startDate));
const end = admin.firestore.Timestamp.fromDate(new Date(endDate));
// Récupérer tous les équipements du même modèle
const equipmentsSnapshot = await db.collection("equipments")
.where("model", "==", model)
.get();
// Récupérer tous les événements qui chevauchent la période
const eventsSnapshot = await db.collection("events")
.where("StartDateTime", "<=", end)
.where("EndDateTime", ">=", start)
.where("status", "!=", "CANCELLED")
.get();
// Créer un set des équipements en conflit
const conflictingEquipmentIds = new Set();
eventsSnapshot.docs.forEach((doc) => {
const eventData = doc.data();
const assignedEquipment = eventData.assignedEquipment || [];
assignedEquipment.forEach((eq) => conflictingEquipmentIds.add(eq.equipmentId));
});
// Filtrer les équipements disponibles
const alternatives = [];
equipmentsSnapshot.docs.forEach((doc) => {
const data = doc.data();
if (!conflictingEquipmentIds.has(doc.id) && data.status === "AVAILABLE") {
alternatives.push({
id: doc.id,
...helpers.serializeTimestamps(data, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
});
}
});
res.status(200).json({alternatives});
} catch (error) {
logger.error("Error finding alternative equipment:", error);
res.status(500).json({error: error.message});
}
};
/**
* Calculer le statut réel d'un ou plusieurs équipements basé sur les événements en cours
*/
exports.calculateEquipmentStatuses = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentIds} = req.body.data;
if (!equipmentIds || !Array.isArray(equipmentIds)) {
res.status(400).json({error: "equipmentIds array is required"});
return;
}
// Récupérer tous les événements en cours (préparation complétée mais pas encore retournés)
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const equipmentIdsInUse = new Set();
const containerIdsInUse = new Set();
eventsSnapshot.docs.forEach((doc) => {
const event = doc.data();
const isPrepared = event.preparationStatus === "completed" ||
event.preparationStatus === "completedWithMissing";
const isReturned = event.returnStatus === "completed" ||
event.returnStatus === "completedWithMissing";
if (isPrepared && !isReturned) {
// Ajouter les équipements directs
const assignedEquipment = event.assignedEquipment || [];
assignedEquipment.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId));
// Ajouter les conteneurs
const assignedContainers = event.assignedContainers || [];
assignedContainers.forEach((containerId) => containerIdsInUse.add(containerId));
}
});
// Récupérer les équipements dans les conteneurs en cours d'utilisation
if (containerIdsInUse.size > 0) {
const containersSnapshot = await db.collection("containers")
.where(admin.firestore.FieldPath.documentId(), "in", Array.from(containerIdsInUse))
.get();
containersSnapshot.docs.forEach((doc) => {
const containerData = doc.data();
const equipmentList = containerData.equipment || [];
equipmentList.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId));
});
}
// Récupérer les données des équipements demandés
const statuses = {};
for (const equipmentId of equipmentIds) {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (!equipmentDoc.exists) {
statuses[equipmentId] = null;
continue;
}
const equipmentData = equipmentDoc.data();
let calculatedStatus = equipmentData.status;
// Si l'équipement est perdu ou HS, garder ce statut
if (equipmentData.status === "LOST" || equipmentData.status === "OUT_OF_SERVICE") {
calculatedStatus = equipmentData.status;
} else if (equipmentIdsInUse.has(equipmentId)) {
calculatedStatus = "IN_USE";
} else if (equipmentData.status === "MAINTENANCE" ||
equipmentData.status === "RENTED") {
calculatedStatus = equipmentData.status;
} else {
calculatedStatus = "AVAILABLE";
}
statuses[equipmentId] = calculatedStatus;
}
res.status(200).json({statuses});
} catch (error) {
logger.error("Error calculating equipment statuses:", error);
res.status(500).json({error: error.message});
}
};
/**
* Récupérer tous les événements en cours (pour le calcul de statuts)
*/
exports.getActiveEvents = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_events");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_events permission"});
return;
}
// Récupérer les événements en cours (préparation complétée mais pas encore retournés)
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const activeEvents = [];
eventsSnapshot.docs.forEach((doc) => {
const event = doc.data();
const isPrepared = event.preparationStatus === "completed" ||
event.preparationStatus === "completedWithMissing";
const isReturned = event.returnStatus === "completed" ||
event.returnStatus === "completedWithMissing";
if (isPrepared && !isReturned) {
activeEvents.push({
id: doc.id,
assignedEquipment: event.assignedEquipment || [],
assignedContainers: event.assignedContainers || [],
preparationStatus: event.preparationStatus,
returnStatus: event.returnStatus,
});
}
});
res.status(200).json({events: activeEvents});
} catch (error) {
logger.error("Error fetching active events:", error);
res.status(500).json({error: error.message});
}
};
+504
View File
@@ -0,0 +1,504 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer un container
exports.createContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const containerData = req.body.data;
const containerId = containerData.id;
if (!containerId) {
res.status(400).json({error: "Container ID is required"});
return;
}
const existingDoc = await db.collection("containers").doc(containerId).get();
if (existingDoc.exists) {
res.status(409).json({error: "Container ID already exists"});
return;
}
const dataToSave = helpers.deserializeTimestamps(containerData, ["createdAt", "updatedAt"]);
await db.collection("containers").doc(containerId).set(dataToSave);
res.status(201).json({id: containerId, message: "Container created successfully"});
} catch (error) {
logger.error("Error creating container:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un container
exports.updateContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId, data} = req.body.data;
if (!containerId) {
res.status(400).json({error: "Container ID is required"});
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
await db.collection("containers").doc(containerId).update(data);
res.status(200).json({message: "Container updated successfully"});
} catch (error) {
logger.error("Error updating container:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un container
exports.deleteContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId} = req.body.data;
if (!containerId) {
res.status(400).json({error: "Container ID is required"});
return;
}
// Récupérer le container pour obtenir les équipements
const containerDoc = await db.collection("containers").doc(containerId).get();
if (containerDoc.exists) {
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
// Retirer le container des parentBoxIds de chaque équipement
for (const equipmentId of equipmentIds) {
try {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const parentBoxIds = (equipmentData.parentBoxIds || []).filter((boxId) => boxId !== containerId);
await db.collection("equipments").doc(equipmentId).update({
parentBoxIds: parentBoxIds,
updatedAt: admin.firestore.Timestamp.now(),
});
}
} catch (err) {
logger.error(`Error updating equipment ${equipmentId} when deleting container:`, err);
}
}
}
await db.collection("containers").doc(containerId).delete();
res.status(200).json({message: "Container deleted successfully"});
} catch (error) {
logger.error("Error deleting container:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer les containers contenant un équipement
exports.getContainersByEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentId} = req.body.data || {};
if (!equipmentId) {
res.status(400).json({error: "equipmentId is required"});
return;
}
const snapshot = await db.collection("containers")
.where("equipmentIds", "array-contains", equipmentId)
.get();
const containers = [];
snapshot.forEach((doc) => {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
containers.push(data);
});
res.status(200).json({containers});
} catch (error) {
logger.error("Error getting containers by equipment:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer plusieurs containers par leurs IDs
exports.getContainersByIds = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {containerIds} = req.body.data || {};
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
res.status(400).json({error: "containerIds array is required and must not be empty"});
return;
}
if (containerIds.length > 100) {
res.status(400).json({error: "Maximum 100 container IDs per request"});
return;
}
const promises = containerIds.map((id) => db.collection("containers").doc(id).get());
const docs = await Promise.all(promises);
const containers = [];
for (const doc of docs) {
if (doc.exists) {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
containers.push(data);
}
}
res.status(200).json({containers});
} catch (error) {
logger.error("Error getting containers by IDs:", error);
res.status(500).json({error: error.message});
}
};
// Ajouter un équipement à un container
exports.addEquipmentToContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId, equipmentId, userId} = req.body.data;
if (!containerId || !equipmentId) {
res.status(400).json({error: "containerId and equipmentId are required"});
return;
}
const containerDoc = await db.collection("containers").doc(containerId).get();
if (!containerDoc.exists) {
res.status(404).json({success: false, message: "Container non trouvé"});
return;
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
if (equipmentIds.includes(equipmentId)) {
res.status(400).json({success: false, message: "Cet équipement est déjà dans ce container"});
return;
}
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (!equipmentDoc.exists) {
res.status(404).json({success: false, message: "Équipement non trouvé"});
return;
}
const equipmentData = equipmentDoc.data();
const parentBoxIds = equipmentData.parentBoxIds || [];
const warnings = [];
if (parentBoxIds.length > 0) {
const otherContainersPromises = parentBoxIds.map((boxId) =>
db.collection("containers").doc(boxId).get(),
);
const otherContainersDocs = await Promise.all(otherContainersPromises);
const otherNames = otherContainersDocs
.filter((doc) => doc.exists)
.map((doc) => doc.data().name);
if (otherNames.length > 0) {
warnings.push(`Attention : cet équipement est également dans les boites suivants : ${otherNames.join(", ")}`);
}
}
await db.collection("containers").doc(containerId).update({
equipmentIds: [...equipmentIds, equipmentId],
updatedAt: admin.firestore.Timestamp.now(),
});
await db.collection("equipments").doc(equipmentId).update({
parentBoxIds: [...parentBoxIds, containerId],
updatedAt: admin.firestore.Timestamp.now(),
});
const history = containerData.history || [];
const historyEntry = {
timestamp: admin.firestore.Timestamp.now(),
action: "equipment_added",
equipmentId: equipmentId,
newValue: equipmentId,
userId: userId || decodedToken.uid,
};
const updatedHistory = [...history, historyEntry].slice(-100);
await db.collection("containers").doc(containerId).update({
history: updatedHistory,
});
res.status(200).json({
success: true,
message: "Équipement ajouté avec succès",
warnings: warnings.length > 0 ? warnings[0] : null,
});
} catch (error) {
logger.error("Error adding equipment to container:", error);
res.status(500).json({success: false, message: error.message});
}
};
// Retirer un équipement d'un container
exports.removeEquipmentFromContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId, equipmentId, userId} = req.body.data;
if (!containerId || !equipmentId) {
res.status(400).json({error: "containerId and equipmentId are required"});
return;
}
const containerDoc = await db.collection("containers").doc(containerId).get();
if (!containerDoc.exists) {
res.status(404).json({error: "Container non trouvé"});
return;
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
const updatedEquipmentIds = equipmentIds.filter((id) => id !== equipmentId);
await db.collection("containers").doc(containerId).update({
equipmentIds: updatedEquipmentIds,
updatedAt: admin.firestore.Timestamp.now(),
});
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const parentBoxIds = equipmentData.parentBoxIds || [];
const updatedParentBoxIds = parentBoxIds.filter((id) => id !== containerId);
await db.collection("equipments").doc(equipmentId).update({
parentBoxIds: updatedParentBoxIds,
updatedAt: admin.firestore.Timestamp.now(),
});
}
const history = containerData.history || [];
const historyEntry = {
timestamp: admin.firestore.Timestamp.now(),
action: "equipment_removed",
equipmentId: equipmentId,
previousValue: equipmentId,
userId: userId || decodedToken.uid,
};
const updatedHistory = [...history, historyEntry].slice(-100);
await db.collection("containers").doc(containerId).update({
history: updatedHistory,
});
res.status(200).json({success: true});
} catch (error) {
logger.error("Error removing equipment from container:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer les containers avec pagination et filtrage côté serveur
exports.getContainersPaginated = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canView) {
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
return;
}
// Récupérer les paramètres de la requête
const params = req.method === "GET" ? req.query : (req.body?.data || {});
const limit = Math.min(parseInt(params.limit) || 20, 100);
const startAfterId = params.startAfter || null;
const type = params.type ? params.type.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null;
const searchQuery = params.searchQuery?.toLowerCase() || null;
const category = params.category ? params.category.toUpperCase() : null;
const sortBy = params.sortBy || "id";
const sortOrder = params.sortOrder === "desc" ? "desc" : "asc";
logger.info(`[getContainersPaginated] Params: limit=${limit}, startAfter=${startAfterId}, type=${type}, status=${status}, category=${category}, search=${searchQuery}`);
let query = db.collection("containers");
const queryLimit = (searchQuery || category) ? Math.min(limit * 10, 200) : limit;
if (type) {
query = query.where("type", "==", type);
}
if (status) {
query = query.where("status", "==", status);
}
if (sortBy === "id") {
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
} else {
query = query.orderBy(sortBy, sortOrder);
}
if (startAfterId) {
const startAfterDoc = await db.collection("containers").doc(startAfterId).get();
if (startAfterDoc.exists) {
query = query.startAfter(startAfterDoc);
}
}
query = query.limit(queryLimit + 1);
const snapshot = await query.get();
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > queryLimit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
let containers = docsToProcess.map((doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ["createdAt", "updatedAt"]),
};
});
const allEquipmentIds = new Set();
containers.forEach((c) => {
if (c.equipmentIds && Array.isArray(c.equipmentIds)) {
c.equipmentIds.forEach((id) => allEquipmentIds.add(id));
}
});
const equipmentMap = new Map();
if (allEquipmentIds.size > 0) {
const equipmentIdArray = Array.from(allEquipmentIds);
const batchSize = 30;
for (let i = 0; i < equipmentIdArray.length; i += batchSize) {
const batch = equipmentIdArray.slice(i, i + batchSize);
const equipmentSnapshot = await db.collection("equipments")
.where(admin.firestore.FieldPath.documentId(), "in", batch)
.get();
equipmentSnapshot.docs.forEach((doc) => {
const equipmentData = doc.data();
equipmentMap.set(doc.id, {
id: doc.id,
...helpers.serializeTimestamps(equipmentData),
});
});
}
}
containers = containers.map((container) => ({
...container,
equipment: (container.equipmentIds || [])
.map((eqId) => equipmentMap.get(eqId))
.filter((eq) => eq !== undefined),
}));
if (category) {
containers = containers.filter((c) => {
return c.equipment.some((eq) => eq.category === category);
});
}
if (searchQuery) {
containers = containers.filter((c) => {
const searchableText = [
c.name || "",
c.id || "",
...(c.equipment || []).map((eq) => eq.name || ""),
].join(" ").toLowerCase();
return searchableText.includes(searchQuery);
});
}
const limitedContainers = containers.slice(0, limit);
const lastVisible = limitedContainers.length > 0 ? limitedContainers[limitedContainers.length - 1].id : null;
const totalEquipmentCount = limitedContainers.reduce((sum, c) => sum + (c.equipment?.length || 0), 0);
logger.info(`[getContainersPaginated] Returning ${limitedContainers.length} containers with ${totalEquipmentCount} total equipment(s)`);
limitedContainers.forEach((c) => {
logger.info(` - Container ${c.id}: ${c.equipment?.length || 0} equipment(s)`);
});
res.status(200).json({
containers: limitedContainers,
hasMore: containers.length > limit || hasMoreDocs,
lastVisible,
total: limitedContainers.length,
});
} catch (error) {
logger.error("Error fetching paginated containers:", error);
res.status(500).json({error: error.message});
}
};
+668
View File
@@ -0,0 +1,668 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer un équipement (admin ou manage_equipment)
exports.createEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const equipmentData = req.body.data;
const equipmentId = equipmentData.id;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
// Vérifier unicité de l'ID
const existingDoc = await db.collection("equipments").doc(equipmentId).get();
if (existingDoc.exists) {
res.status(409).json({error: "Equipment ID already exists"});
return;
}
// Convertir les timestamps
const dataToSave = helpers.deserializeTimestamps(equipmentData, [
"createdAt", "updatedAt", "purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate",
]);
await db.collection("equipments").doc(equipmentId).set(dataToSave);
res.status(201).json({id: equipmentId, message: "Equipment created successfully"});
} catch (error) {
logger.error("Error creating equipment:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un équipement
exports.updateEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {equipmentId, data} = req.body.data;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
if (!data || typeof data !== "object" || Object.keys(data).length === 0) {
res.status(400).json({error: "Update data is required and must be a non-empty object"});
return;
}
// Empêcher la modification de l'ID
delete data.id;
// Ajouter updatedAt
data.updatedAt = admin.firestore.Timestamp.now();
const dataToSave = helpers.deserializeTimestamps(data, [
"purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate",
]);
await db.collection("equipments").doc(equipmentId).update(dataToSave);
res.status(200).json({message: "Equipment updated successfully"});
} catch (error) {
logger.error("Error updating equipment:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un équipement
exports.deleteEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {equipmentId, forceDelete = false} = req.body.data;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
// Vérifier si l'équipement est utilisé dans des événements à venir
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const now = new Date();
const upcomingEvents = [];
for (const eventDoc of eventsSnapshot.docs) {
const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || [];
if (!assignedEquipment.some((eq) => eq.equipmentId === equipmentId)) {
continue;
}
let eventStart = null;
if (eventData.StartDateTime) {
eventStart = eventData.StartDateTime.toDate ?
eventData.StartDateTime.toDate() :
new Date(eventData.StartDateTime);
}
if (eventStart && eventStart > now) {
upcomingEvents.push({
eventId: eventDoc.id,
eventName: eventData.Name || "",
startDate: eventStart.toISOString(),
});
}
}
if (upcomingEvents.length > 0 && !forceDelete) {
res.status(409).json({
error: "FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events",
upcomingEvents,
});
return;
}
await db.collection("equipments").doc(equipmentId).delete();
res.status(200).json({message: "Equipment deleted successfully"});
} catch (error) {
logger.error("Error deleting equipment:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer un équipement par ID
exports.getEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentId} = req.body.data || req.query;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
const doc = await db.collection("equipments").doc(equipmentId).get();
if (!doc.exists) {
res.status(404).json({error: "Equipment not found"});
return;
}
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
// Masquer les prix si pas de permission manage_equipment
data = helpers.maskSensitiveFields(data, hasManageAccess);
res.status(200).json({equipment: data});
} catch (error) {
logger.error("Error getting equipment:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer plusieurs équipements par leurs IDs
exports.getEquipmentsByIds = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentIds} = req.body.data || {};
if (!equipmentIds || !Array.isArray(equipmentIds) || equipmentIds.length === 0) {
res.status(400).json({error: "equipmentIds array is required and must not be empty"});
return;
}
// Limiter à 100 équipements max par requête
if (equipmentIds.length > 100) {
res.status(400).json({error: "Maximum 100 equipment IDs per request"});
return;
}
// Récupérer tous les documents en parallèle
const promises = equipmentIds.map((id) => db.collection("equipments").doc(id).get());
const docs = await Promise.all(promises);
const equipments = [];
for (const doc of docs) {
if (doc.exists) {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
// Masquer les prix si pas de permission manage_equipment
data = helpers.maskSensitiveFields(data, hasManageAccess);
equipments.push(data);
}
}
res.status(200).json({equipments});
} catch (error) {
logger.error("Error getting equipments by IDs:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour uniquement le statut d'un équipement
exports.updateEquipmentStatusOnly = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {equipmentId, status, availableQuantity} = req.body.data;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
// Vérifier les permissions
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const updateData = {updatedAt: admin.firestore.Timestamp.now()};
if (status) updateData.status = status;
if (availableQuantity !== undefined) updateData.availableQuantity = availableQuantity;
await db.collection("equipments").doc(equipmentId).update(updateData);
res.status(200).json({message: "Equipment status updated successfully"});
} catch (error) {
logger.error("Error updating equipment status:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour le statut de plusieurs équipements (pour préparation/retour)
exports.updateEquipmentStatus = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {eventId, updates} = req.body.data;
if (!eventId || !updates || !Array.isArray(updates)) {
res.status(400).json({error: "Event ID and updates array are required"});
return;
}
// Vérifier que l'utilisateur est assigné à l'événement ou est admin
const isAssigned = await auth.isAssignedToEvent(decodedToken.uid, eventId);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAssigned && !isAdminUser) {
res.status(403).json({error: "Forbidden: Not assigned to this event"});
return;
}
// Batch update
const batch = db.batch();
for (const update of updates) {
const {equipmentId, status} = update;
if (equipmentId && status) {
const equipmentRef = db.collection("equipments").doc(equipmentId);
batch.update(equipmentRef, {status});
}
}
await batch.commit();
res.status(200).json({message: "Equipment statuses updated successfully"});
} catch (error) {
logger.error("Error updating equipment statuses:", error);
res.status(500).json({error: error.message});
}
};
// Récupère les équipements avec pagination et filtrage côté serveur
exports.getEquipmentsPaginated = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment");
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canManage && !canView) {
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
return;
}
// Récupérer les paramètres de la requête
const params = req.method === "GET" ? req.query : (req.body?.data || {});
const limit = Math.min(parseInt(params.limit) || 20, 100);
const startAfterId = params.startAfter || null;
const category = params.category ? params.category.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null;
const rawSearchQuery = typeof params.searchQuery === "string" ? params.searchQuery.trim() : "";
const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null;
const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, "") : null;
const sortBy = params.sortBy || "id";
const sortOrder = params.sortOrder === "desc" ? "desc" : "asc";
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`);
// Fast-path pour une recherche d'ID exact
if (searchQuery && !startAfterId) {
const exactIdCandidates = Array.from(new Set([
rawSearchQuery,
rawSearchQuery.toUpperCase(),
rawSearchQuery.toLowerCase(),
].filter(Boolean)));
for (const candidateId of exactIdCandidates) {
const exactDoc = await db.collection("equipments").doc(candidateId).get();
if (!exactDoc.exists) {
continue;
}
const exactData = exactDoc.data() || {};
const matchesCategory = !category || exactData.category === category;
const matchesStatus = !status || exactData.status === status;
if (!matchesCategory || !matchesStatus) {
continue;
}
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
id: exactDoc.id,
};
logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1,
});
return;
}
// Compatibilité legacy
for (const legacyId of exactIdCandidates) {
let legacyIdQuery = db.collection("equipments").where("id", "==", legacyId);
if (category) {
legacyIdQuery = legacyIdQuery.where("category", "==", category);
}
if (status) {
legacyIdQuery = legacyIdQuery.where("status", "==", status);
}
const legacySnapshot = await legacyIdQuery.limit(1).get();
if (legacySnapshot.empty) {
continue;
}
const exactDoc = legacySnapshot.docs[0];
const exactData = exactDoc.data() || {};
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
id: exactDoc.id,
};
logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1,
});
return;
}
}
// Construire la requête Firestore
let query = db.collection("equipments");
if (category) {
query = query.where("category", "==", category);
}
if (status) {
query = query.where("status", "==", status);
}
if (sortBy === "id") {
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
} else {
query = query.orderBy(sortBy, sortOrder);
}
if (startAfterId) {
const startAfterDoc = await db.collection("equipments").doc(startAfterId).get();
if (startAfterDoc.exists) {
query = query.startAfter(startAfterDoc);
}
}
const timestampFields = ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"];
const mapEquipmentDoc = (doc) => {
const data = {...(doc.data() || {})};
if (!canManage) {
delete data.purchasePrice;
delete data.rentalPrice;
}
const legacyId = typeof data.id === "string" ? data.id : "";
return {
...helpers.serializeTimestamps(data, timestampFields),
id: doc.id,
_legacyId: legacyId,
};
};
const matchesSearchQuery = (equipment) => {
const searchableText = [
equipment.name || "",
equipment.id || "",
equipment._legacyId || "",
equipment.model || "",
equipment.brand || "",
equipment.subCategory || "",
].join(" ").toLowerCase();
if (searchableText.includes(searchQuery)) {
return true;
}
if (!compactSearchQuery) {
return false;
}
const compactSearchableText = searchableText.replace(/[\s_-]+/g, "");
return compactSearchableText.includes(compactSearchQuery);
};
if (!searchQuery) {
const snapshot = await query.limit(limit + 1).get();
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > limit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, limit) : snapshot.docs;
const limitedEquipments = docsToProcess
.map(mapEquipmentDoc)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreDocs,
lastVisible,
total: limitedEquipments.length,
});
return;
}
const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200);
const matchedEquipments = [];
let scannedDocuments = 0;
let searchQueryRef = query;
let hasMoreMatches = false;
let hasMoreDocsToScan = true;
while (hasMoreDocsToScan && !hasMoreMatches) {
const snapshot = await searchQueryRef.limit(searchBatchSize).get();
if (snapshot.empty) {
hasMoreDocsToScan = false;
break;
}
scannedDocuments += snapshot.docs.length;
for (const doc of snapshot.docs) {
const equipment = mapEquipmentDoc(doc);
if (!matchesSearchQuery(equipment)) {
continue;
}
matchedEquipments.push(equipment);
if (matchedEquipments.length > limit) {
hasMoreMatches = true;
break;
}
}
if (hasMoreMatches) {
break;
}
if (snapshot.docs.length < searchBatchSize) {
hasMoreDocsToScan = false;
break;
}
const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1];
searchQueryRef = query.startAfter(lastDocInBatch);
}
const limitedEquipments = matchedEquipments
.slice(0, limit)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreMatches,
lastVisible,
total: limitedEquipments.length,
});
} catch (error) {
logger.error("Error fetching paginated equipments:", error);
res.status(500).json({error: error.message});
}
};
// Recherche rapide d'équipements et containers pour l'autocomplétion
exports.quickSearch = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canView) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const params = req.method === "GET" ? req.query : (req.body?.data || {});
const searchQuery = params.query?.toLowerCase() || "";
const limit = Math.min(parseInt(params.limit) || 10, 50);
const includeEquipments = params.includeEquipments !== "false";
const includeContainers = params.includeContainers !== "false";
if (!searchQuery || searchQuery.length < 2) {
res.status(200).json({results: []});
return;
}
const results = [];
// Rechercher dans les équipements
if (includeEquipments) {
const equipmentSnapshot = await db.collection("equipments")
.orderBy("id")
.limit(limit * 2)
.get();
equipmentSnapshot.docs.forEach((doc) => {
const data = doc.data();
const searchableText = [
data.name || "",
doc.id || "",
data.model || "",
data.brand || "",
].join(" ").toLowerCase();
if (searchableText.includes(searchQuery)) {
results.push({
type: "equipment",
id: doc.id,
name: data.name,
category: data.category,
model: data.model,
brand: data.brand,
});
}
});
}
// Rechercher dans les containers
if (includeContainers) {
const containerSnapshot = await db.collection("containers")
.orderBy("id")
.limit(limit * 2)
.get();
containerSnapshot.docs.forEach((doc) => {
const data = doc.data();
const searchableText = [
data.name || "",
doc.id || "",
].join(" ").toLowerCase();
if (searchableText.includes(searchQuery)) {
results.push({
type: "container",
id: doc.id,
name: data.name,
containerType: data.type,
});
}
});
}
const limitedResults = results
.sort((a, b) => {
const aStarts = a.id.toLowerCase().startsWith(searchQuery);
const bStarts = b.id.toLowerCase().startsWith(searchQuery);
if (aStarts && !bStarts) return -1;
if (!aStarts && bStarts) return 1;
return 0;
})
.slice(0, limit);
res.status(200).json({results: limitedResults});
} catch (error) {
logger.error("Error in quick search:", error);
res.status(500).json({error: error.message});
}
};
+836
View File
@@ -0,0 +1,836 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Helper functions for search
const normalizeSearchText = (value) => {
return (value || "")
.toString()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim();
};
const getEventStartDate = (eventData) => {
const startValue = eventData.StartDateTime;
if (!startValue) {
return null;
}
if (startValue.toDate) {
return startValue.toDate();
}
const parsedDate = new Date(startValue);
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
};
const getEventWorkforceUids = (eventData) => {
if (!eventData.workforce || !Array.isArray(eventData.workforce)) {
return [];
}
return eventData.workforce
.map((userRef) => {
if (userRef && userRef.id) {
return userRef.id;
}
if (typeof userRef === "string" && userRef.startsWith("users/")) {
return userRef.split("/")[1];
}
return null;
})
.filter((uid) => uid !== null);
};
const serializeEventSearchResult = (doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data),
workforce: getEventWorkforceUids(data),
};
};
// Créer un événement
exports.createEvent = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "edit_event");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires edit_event permission"});
return;
}
const eventData = req.body.data;
let dataToSave = helpers.deserializeTimestamps(eventData, [
"StartDateTime", "EndDateTime", "createdAt", "updatedAt",
]);
dataToSave = helpers.convertIdsToReferences(dataToSave);
const docRef = await db.collection("events").add(dataToSave);
res.status(201).json({id: docRef.id, message: "Event created successfully"});
} catch (error) {
logger.error("Error creating event:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un événement
exports.updateEvent = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "edit_event");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires edit_event permission"});
return;
}
const requestData = req.body.data;
logger.info(`Update event - requestData keys: ${Object.keys(requestData || {}).join(", ")}`);
const eventId = requestData.eventId;
logger.info(`Update event - eventId: ${eventId}`);
if (!eventId) {
logger.error("Event ID is missing from request");
res.status(400).json({error: "Event ID is required"});
return;
}
const {eventId: _, ...data} = requestData;
if (!data || Object.keys(data).length === 0) {
res.status(400).json({error: "No data to update"});
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
let dataToSave = helpers.deserializeTimestamps(data, [
"StartDateTime", "EndDateTime",
]);
dataToSave = helpers.convertIdsToReferences(dataToSave);
await db.collection("events").doc(eventId).update(dataToSave);
res.status(200).json({message: "Event updated successfully"});
} catch (error) {
logger.error("Error updating event:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un événement
exports.deleteEvent = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "delete_event");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires delete_event permission"});
return;
}
const {eventId} = req.body.data;
if (!eventId) {
res.status(400).json({error: "Event ID is required"});
return;
}
await db.collection("events").doc(eventId).delete();
res.status(200).json({message: "Event deleted successfully"});
} catch (error) {
logger.error("Error deleting event:", error);
res.status(500).json({error: error.message});
}
};
// Met à jour les équipements d'un événement
exports.updateEventEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {eventId, assignedEquipment, preparationStatus, loadingStatus, unloadingStatus, returnStatus} = req.body.data;
if (!eventId) {
res.status(400).json({error: "Event ID is required"});
return;
}
const eventDoc = await db.collection("events").doc(eventId).get();
if (!eventDoc.exists) {
res.status(404).json({error: "Event not found"});
return;
}
const eventData = eventDoc.data();
const isAdminUser = await auth.hasPermission(decodedToken.uid, "edit_event");
let isAssigned = false;
if (eventData.workforce && Array.isArray(eventData.workforce)) {
isAssigned = eventData.workforce.some((ref) => {
if (!ref || !ref.path) return false;
return ref.path.endsWith(decodedToken.uid) || ref.path === `/users/${decodedToken.uid}`;
});
}
if (!isAssigned && !isAdminUser) {
res.status(403).json({error: "Forbidden: Not assigned to this event"});
return;
}
const updateData = {};
if (assignedEquipment) {
updateData.assignedEquipment = assignedEquipment.map((eq) =>
helpers.deserializeTimestamps(eq, []),
);
}
if (preparationStatus) updateData.preparationStatus = preparationStatus;
if (loadingStatus) updateData.loadingStatus = loadingStatus;
if (unloadingStatus) updateData.unloadingStatus = unloadingStatus;
if (returnStatus) updateData.returnStatus = returnStatus;
await db.collection("events").doc(eventId).update(updateData);
res.status(200).json({message: "Event equipment updated successfully"});
} catch (error) {
logger.error("Error updating event equipment:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer les événements utilisant un type d'événement
exports.getEventsByEventType = async (req, res) => {
try {
await auth.authenticateUser(req);
const {eventTypeId} = req.body.data;
if (!eventTypeId) {
res.status(400).json({error: "Event type ID is required"});
return;
}
const eventsSnapshot = await db.collection("events")
.where("eventTypeId", "==", eventTypeId)
.get();
const events = eventsSnapshot.docs.map((doc) => ({
id: doc.id,
name: doc.data().name,
startDateTime: doc.data().StartDateTime,
}));
res.status(200).json({events});
} catch (error) {
logger.error("Error fetching events by type:", error);
res.status(500).json({error: error.message});
}
};
// Récupère tous les événements (filtrés selon permissions)
exports.getEvents = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {userId} = req.body.data || {};
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events");
let eventsSnapshot;
if (canViewAll) {
eventsSnapshot = await db.collection("events").get();
} else {
const userRef = db.collection("users").doc(userId || decodedToken.uid);
eventsSnapshot = await db.collection("events")
.where("workforce", "array-contains", userRef)
.get();
}
const userIdsSet = new Set();
eventsSnapshot.docs.forEach((doc) => {
const data = doc.data();
if (data.workforce && Array.isArray(data.workforce)) {
data.workforce.forEach((userRef) => {
if (userRef && userRef.id) {
userIdsSet.add(userRef.id);
} else if (typeof userRef === "string" && userRef.startsWith("users/")) {
userIdsSet.add(userRef.split("/")[1]);
}
});
}
});
const usersMap = {};
if (userIdsSet.size > 0) {
const userIds = Array.from(userIdsSet);
const batchSize = 30;
const batchPromises = [];
for (let i = 0; i < userIds.length; i += batchSize) {
const batch = userIds.slice(i, i + batchSize);
batchPromises.push(
db.collection("users")
.where(admin.firestore.FieldPath.documentId(), "in", batch)
.get(),
);
}
const results = await Promise.all(batchPromises);
results.forEach((usersSnapshot) => {
usersSnapshot.docs.forEach((userDoc) => {
const userData = userDoc.data();
usersMap[userDoc.id] = {
uid: userDoc.id,
firstName: userData.firstName || "",
lastName: userData.lastName || "",
email: userData.email || "",
phoneNumber: userData.phoneNumber || "",
profilePhotoUrl: userData.profilePhotoUrl || "",
};
});
});
}
const events = eventsSnapshot.docs.map((doc) => {
const data = doc.data();
let workforceUids = [];
if (data.workforce && Array.isArray(data.workforce)) {
workforceUids = data.workforce.map((userRef) => {
if (userRef && userRef.id) {
return userRef.id;
} else if (typeof userRef === "string" && userRef.startsWith("users/")) {
return userRef.split("/")[1];
}
return null;
}).filter((uid) => uid !== null);
}
return {
id: doc.id,
...helpers.serializeTimestamps(data),
workforce: workforceUids,
};
});
res.status(200).json({
events,
users: usersMap,
});
} catch (error) {
logger.error("Error fetching events:", error);
res.status(500).json({error: error.message});
}
};
// Récupère les événements d'un mois spécifique (lazy loading optimisé)
exports.getEventsByMonth = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {userId, year, month} = req.body.data || {};
if (!year || !month) {
res.status(400).json({error: "year and month are required"});
return;
}
logger.info(`Fetching events for ${year}-${month}`);
const startOfMonth = admin.firestore.Timestamp.fromDate(
new Date(year, month - 1, 1, 0, 0, 0),
);
const endOfMonth = admin.firestore.Timestamp.fromDate(
new Date(year, month, 0, 23, 59, 59),
);
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events");
let eventsQuery = db.collection("events")
.where("StartDateTime", ">=", startOfMonth)
.where("StartDateTime", "<=", endOfMonth);
if (!canViewAll) {
const userRef = db.collection("users").doc(userId || decodedToken.uid);
eventsQuery = eventsQuery.where("workforce", "array-contains", userRef);
}
const eventsSnapshot = await eventsQuery.get();
logger.info(`Found ${eventsSnapshot.docs.length} events for ${year}-${month}`);
const userIdsSet = new Set();
eventsSnapshot.docs.forEach((doc) => {
const data = doc.data();
if (data.workforce && Array.isArray(data.workforce)) {
data.workforce.forEach((userRef) => {
if (userRef && userRef.id) {
userIdsSet.add(userRef.id);
} else if (typeof userRef === "string" && userRef.startsWith("users/")) {
userIdsSet.add(userRef.split("/")[1]);
}
});
}
});
const usersMap = {};
if (userIdsSet.size > 0) {
const userIds = Array.from(userIdsSet);
const batchSize = 30;
const batchPromises = [];
for (let i = 0; i < userIds.length; i += batchSize) {
const batch = userIds.slice(i, i + batchSize);
batchPromises.push(
db.collection("users")
.where(admin.firestore.FieldPath.documentId(), "in", batch)
.get(),
);
}
const results = await Promise.all(batchPromises);
results.forEach((usersSnapshot) => {
usersSnapshot.docs.forEach((userDoc) => {
const userData = userDoc.data();
usersMap[userDoc.id] = {
uid: userDoc.id,
firstName: userData.firstName || "",
lastName: userData.lastName || "",
email: userData.email || "",
phoneNumber: userData.phoneNumber || "",
profilePhotoUrl: userData.profilePhotoUrl || "",
};
});
});
}
const events = eventsSnapshot.docs.map((doc) => {
const data = doc.data();
let workforceUids = [];
if (data.workforce && Array.isArray(data.workforce)) {
workforceUids = data.workforce.map((userRef) => {
if (userRef && userRef.id) {
return userRef.id;
} else if (typeof userRef === "string" && userRef.startsWith("users/")) {
return userRef.split("/")[1];
}
return null;
}).filter((uid) => uid !== null);
}
return {
id: doc.id,
...helpers.serializeTimestamps(data),
workforce: workforceUids,
};
});
logger.info(`Returning ${events.length} events with ${Object.keys(usersMap).length} unique users`);
res.status(200).json({
events,
users: usersMap,
month: {year, month},
});
} catch (error) {
logger.error("Error fetching events by month:", error);
res.status(500).json({error: error.message});
}
};
// Recherche des événements accessibles à l'utilisateur
exports.searchEvents = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {userId, query, limit = 20} = req.body.data || {};
const maxResults = Number.isFinite(Number(limit)) ? Math.max(1, Number(limit)) : 20;
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
res.status(200).json({events: []});
return;
}
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events");
let eventsSnapshot;
if (canViewAll) {
eventsSnapshot = await db.collection("events").get();
} else {
const userRef = db.collection("users").doc(userId || decodedToken.uid);
eventsSnapshot = await db.collection("events")
.where("workforce", "array-contains", userRef)
.get();
}
const matchingEvents = eventsSnapshot.docs
.filter((doc) => {
const eventData = doc.data();
const startDate = getEventStartDate(eventData);
const searchableText = normalizeSearchText([
eventData.Name,
eventData.Description,
eventData.Address,
startDate ? startDate.toLocaleString("fr-FR") : "",
startDate ? startDate.toISOString() : "",
].join(" "));
return searchableText.includes(normalizedQuery);
})
.sort((a, b) => {
const startA = getEventStartDate(a.data()) || new Date(0);
const startB = getEventStartDate(b.data()) || new Date(0);
return startA.getTime() - startB.getTime();
})
.slice(0, maxResults)
.map((doc) => serializeEventSearchResult(doc));
res.status(200).json({events: matchingEvents});
} catch (error) {
logger.error("Error searching events:", error);
res.status(500).json({error: error.message});
}
};
// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
exports.getEventWithDetails = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {eventId} = req.body.data || {};
if (!eventId) {
res.status(400).json({error: "eventId is required"});
return;
}
const eventDoc = await db.collection("events").doc(eventId).get();
if (!eventDoc.exists) {
res.status(404).json({error: "Event not found"});
return;
}
const eventData = eventDoc.data();
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events");
if (!canViewAll) {
const userRef = db.collection("users").doc(decodedToken.uid);
const isInWorkforce = eventData.workforce && eventData.workforce.some((ref) =>
(ref.id && ref.id === decodedToken.uid) ||
(typeof ref === "string" && ref === `users/${decodedToken.uid}`),
);
if (!isInWorkforce) {
res.status(403).json({error: "Forbidden: Not assigned to this event"});
return;
}
}
logger.info(`[getEventWithDetails] Loading details for event ${eventId}`);
const equipmentIds = new Set();
const containerIds = new Set();
if (eventData.assignedEquipment && Array.isArray(eventData.assignedEquipment)) {
eventData.assignedEquipment.forEach((eq) => {
if (eq.equipmentId) {
equipmentIds.add(eq.equipmentId);
}
});
}
if (eventData.assignedContainers && Array.isArray(eventData.assignedContainers)) {
eventData.assignedContainers.forEach((id) => containerIds.add(id));
}
logger.info(`[getEventWithDetails] Loading ${equipmentIds.size} equipments and ${containerIds.size} containers`);
const equipmentPromises = Array.from(equipmentIds).map((id) =>
db.collection("equipments").doc(id).get(),
);
const equipmentDocs = await Promise.all(equipmentPromises);
const equipmentMap = {};
for (const doc of equipmentDocs) {
if (doc.exists) {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
equipmentMap[doc.id] = data;
}
}
const containerPromises = Array.from(containerIds).map((id) =>
db.collection("containers").doc(id).get(),
);
const containerDocs = await Promise.all(containerPromises);
const childEquipmentIds = new Set();
for (const doc of containerDocs) {
if (doc.exists) {
const containerData = doc.data();
if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) {
containerData.equipmentIds.forEach((id) => childEquipmentIds.add(id));
}
}
}
logger.info(`[getEventWithDetails] Loading ${childEquipmentIds.size} child equipments from containers`);
const childEquipmentPromises = Array.from(childEquipmentIds).map((id) =>
db.collection("equipments").doc(id).get(),
);
const childEquipmentDocs = await Promise.all(childEquipmentPromises);
for (const doc of childEquipmentDocs) {
if (doc.exists && !equipmentMap[doc.id]) {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
equipmentMap[doc.id] = data;
}
}
const containerMap = {};
for (const doc of containerDocs) {
if (doc.exists) {
let containerData = {id: doc.id, ...doc.data()};
containerData = helpers.serializeTimestamps(containerData);
containerData = helpers.serializeReferences(containerData);
if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) {
containerData.children = containerData.equipmentIds
.map((id) => equipmentMap[id])
.filter((eq) => eq !== undefined);
} else {
containerData.children = [];
}
containerMap[doc.id] = containerData;
}
}
const event = {
id: eventDoc.id,
...helpers.serializeTimestamps(eventData),
workforce: eventData.workforce ? eventData.workforce.map((ref) =>
(ref.id || (typeof ref === "string" ? ref.split("/")[1] : null)),
).filter((uid) => uid !== null) : [],
};
logger.info(`[getEventWithDetails] Returning event with ${Object.keys(equipmentMap).length} equipments and ${Object.keys(containerMap).length} containers`);
res.status(200).json({
event,
equipments: equipmentMap,
containers: containerMap,
});
} catch (error) {
logger.error("Error getting event with details:", error);
res.status(500).json({error: error.message});
}
};
// Valider un équipement individuel pour le chargement
exports.validateEquipmentLoading = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const canManage = await auth.hasPermission(decodedToken.uid, "manage_events");
if (!canManage) {
res.status(403).json({error: "Forbidden: Requires manage_events permission"});
return;
}
const {eventId, equipmentId} = req.body.data;
if (!eventId || !equipmentId) {
res.status(400).json({error: "eventId and equipmentId are required"});
return;
}
const eventDoc = await db.collection("events").doc(eventId).get();
if (!eventDoc.exists) {
res.status(404).json({error: "Event not found"});
return;
}
const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || [];
const updatedEquipment = assignedEquipment.map((eq) => {
if (eq.equipmentId === equipmentId) {
return {...eq, isLoaded: true};
}
return eq;
});
const allLoaded = updatedEquipment.every((eq) => eq.isLoaded);
const updateData = {
assignedEquipment: updatedEquipment,
loadingStatus: allLoaded ? "completed" : "inProgress",
};
await db.collection("events").doc(eventId).update(updateData);
res.status(200).json({success: true, allLoaded});
} catch (error) {
logger.error("Error validating equipment loading:", error);
res.status(500).json({error: error.message});
}
};
// Valider tous les équipements pour le chargement
exports.validateAllLoading = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const canManage = await auth.hasPermission(decodedToken.uid, "manage_events");
if (!canManage) {
res.status(403).json({error: "Forbidden: Requires manage_events permission"});
return;
}
const {eventId} = req.body.data;
if (!eventId) {
res.status(400).json({error: "eventId is required"});
return;
}
const eventDoc = await db.collection("events").doc(eventId).get();
if (!eventDoc.exists) {
res.status(404).json({error: "Event not found"});
return;
}
const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || [];
const updatedEquipment = assignedEquipment.map((eq) => ({
...eq,
isLoaded: true,
}));
await db.collection("events").doc(eventId).update({
assignedEquipment: updatedEquipment,
loadingStatus: "completed",
});
res.status(200).json({success: true});
} catch (error) {
logger.error("Error validating all loading:", error);
res.status(500).json({error: error.message});
}
};
// Valider un équipement individuel pour le déchargement
exports.validateEquipmentUnloading = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const canManage = await auth.hasPermission(decodedToken.uid, "manage_events");
if (!canManage) {
res.status(403).json({error: "Forbidden: Requires manage_events permission"});
return;
}
const {eventId, equipmentId} = req.body.data;
if (!eventId || !equipmentId) {
res.status(400).json({error: "eventId and equipmentId are required"});
return;
}
const eventDoc = await db.collection("events").doc(eventId).get();
if (!eventDoc.exists) {
res.status(404).json({error: "Event not found"});
return;
}
const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || [];
const updatedEquipment = assignedEquipment.map((eq) => {
if (eq.equipmentId === equipmentId) {
return {...eq, isUnloaded: true};
}
return eq;
});
const allUnloaded = updatedEquipment.every((eq) => eq.isUnloaded);
const updateData = {
assignedEquipment: updatedEquipment,
unloadingStatus: allUnloaded ? "completed" : "inProgress",
};
await db.collection("events").doc(eventId).update(updateData);
res.status(200).json({success: true, allUnloaded});
} catch (error) {
logger.error("Error validating equipment unloading:", error);
res.status(500).json({error: error.message});
}
};
// Valider tous les équipements pour le déchargement
exports.validateAllUnloading = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const canManage = await auth.hasPermission(decodedToken.uid, "manage_events");
if (!canManage) {
res.status(403).json({error: "Forbidden: Requires manage_events permission"});
return;
}
const {eventId} = req.body.data;
if (!eventId) {
res.status(400).json({error: "eventId is required"});
return;
}
const eventDoc = await db.collection("events").doc(eventId).get();
if (!eventDoc.exists) {
res.status(404).json({error: "Event not found"});
return;
}
const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || [];
const updatedEquipment = assignedEquipment.map((eq) => ({
...eq,
isUnloaded: true,
}));
await db.collection("events").doc(eventId).update({
assignedEquipment: updatedEquipment,
unloadingStatus: "completed",
});
res.status(200).json({success: true});
} catch (error) {
logger.error("Error validating all unloading:", error);
res.status(500).json({error: error.message});
}
};
+328
View File
@@ -0,0 +1,328 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer une maintenance
exports.createMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"});
return;
}
const maintenanceData = req.body.data;
const dataToSave = helpers.deserializeTimestamps(maintenanceData, [
"scheduledDate", "completedDate", "createdAt", "updatedAt",
]);
const docRef = await db.collection("maintenances").add(dataToSave);
const maintenanceId = docRef.id;
if (maintenanceData.equipmentIds && Array.isArray(maintenanceData.equipmentIds)) {
for (const equipmentId of maintenanceData.equipmentIds) {
try {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const maintenanceIds = equipmentData.maintenanceIds || [];
if (!maintenanceIds.includes(maintenanceId)) {
maintenanceIds.push(maintenanceId);
await db.collection("equipments").doc(equipmentId).update({
maintenanceIds: maintenanceIds,
});
}
}
if (maintenanceData.scheduledDate) {
const scheduledDate = maintenanceData.scheduledDate.toDate ?
maintenanceData.scheduledDate.toDate() :
new Date(maintenanceData.scheduledDate);
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
if (scheduledDate <= sevenDaysFromNow) {
const existingAlerts = await db.collection("alerts")
.where("equipmentId", "==", equipmentId)
.where("type", "==", "maintenanceDue")
.where("isRead", "==", false)
.get();
let alertExists = false;
for (const alertDoc of existingAlerts.docs) {
const alertData = alertDoc.data();
if (alertData.message && alertData.message.includes(maintenanceData.name || "")) {
alertExists = true;
break;
}
}
if (!alertExists) {
const equipmentName = equipmentDoc.exists ?
(equipmentDoc.data().name || equipmentId) :
equipmentId;
const daysUntil = Math.ceil((scheduledDate - new Date()) / (1000 * 60 * 60 * 24));
await db.collection("alerts").add({
type: "maintenanceDue",
message: `Maintenance "${maintenanceData.name || "Sans nom"}" prévue dans ${daysUntil} jour(s) pour ${equipmentName}`,
equipmentId: equipmentId,
createdAt: admin.firestore.Timestamp.now(),
isRead: false,
});
}
}
}
} catch (err) {
logger.error(`Error updating equipment ${equipmentId} for maintenance:`, err);
}
}
}
res.status(201).json({id: maintenanceId, message: "Maintenance created successfully"});
} catch (error) {
logger.error("Error creating maintenance:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour une maintenance
exports.updateMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"});
return;
}
const {maintenanceId, data} = req.body.data;
if (!maintenanceId) {
res.status(400).json({error: "Maintenance ID is required"});
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
const dataToSave = helpers.deserializeTimestamps(data, [
"scheduledDate", "completedDate",
]);
await db.collection("maintenances").doc(maintenanceId).update(dataToSave);
res.status(200).json({message: "Maintenance updated successfully"});
} catch (error) {
logger.error("Error updating maintenance:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer toutes les maintenances
exports.getMaintenances = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {equipmentId} = req.body.data || {};
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canView) {
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
return;
}
let query = db.collection("maintenances");
if (equipmentId) {
query = query.where("equipmentIds", "array-contains", equipmentId);
}
const snapshot = await query.get();
const maintenances = snapshot.docs.map((doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ["scheduledDate", "completedDate", "createdAt", "updatedAt"]),
};
});
res.status(200).json({maintenances});
} catch (error) {
logger.error("Error fetching maintenances:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer une maintenance
exports.deleteMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!canManage) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const maintenanceId = req.body.data?.maintenanceId;
if (!maintenanceId) {
res.status(400).json({error: "maintenanceId is required"});
return;
}
const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get();
if (maintenanceDoc.exists) {
const maintenance = maintenanceDoc.data();
if (maintenance.equipmentIds) {
for (const equipmentId of maintenance.equipmentIds) {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const maintenanceIds = (equipmentData.maintenanceIds || []).filter((id) => id !== maintenanceId);
await db.collection("equipments").doc(equipmentId).update({maintenanceIds});
}
}
}
}
await db.collection("maintenances").doc(maintenanceId).delete();
res.status(200).json({success: true});
} catch (error) {
logger.error("Error deleting maintenance:", error);
res.status(500).json({error: error.message});
}
};
/**
* Vérifier les maintenances à venir et créer des alertes
*/
exports.checkUpcomingMaintenances = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
const now = admin.firestore.Timestamp.now();
const sevenDaysTimestamp = admin.firestore.Timestamp.fromDate(sevenDaysFromNow);
// Récupérer les maintenances planifiées dans les 7 prochains jours
const maintenancesSnapshot = await db.collection("maintenances")
.where("scheduledDate", "<=", sevenDaysTimestamp)
.where("scheduledDate", ">=", now)
.get();
const alertsCreated = [];
for (const doc of maintenancesSnapshot.docs) {
const maintenance = doc.data();
// Vérifier si une alerte existe déjà pour cette maintenance
const existingAlertSnapshot = await db.collection("alerts")
.where("type", "==", "MAINTENANCE_DUE")
.where("relatedMaintenanceId", "==", doc.id)
.get();
if (existingAlertSnapshot.empty) {
// Créer une nouvelle alerte
const alertData = {
type: "MAINTENANCE_DUE",
title: `Maintenance à venir`,
message: `Une maintenance est prévue pour ${maintenance.equipmentIds?.length || 0} équipement(s)`,
severity: "MEDIUM",
isRead: false,
relatedMaintenanceId: doc.id,
createdAt: admin.firestore.Timestamp.now(),
};
const alertRef = await db.collection("alerts").add(alertData);
alertsCreated.push({id: alertRef.id, ...alertData});
}
}
res.status(200).json({
success: true,
alertsCreated: alertsCreated.length,
alerts: alertsCreated,
});
} catch (error) {
logger.error("Error checking upcoming maintenances:", error);
res.status(500).json({error: error.message});
}
};
/**
* Compléter une maintenance
*/
exports.completeMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {maintenanceId, performedBy, cost} = req.body.data;
if (!maintenanceId) {
res.status(400).json({error: "maintenanceId is required"});
return;
}
const now = admin.firestore.Timestamp.now();
const updateData = {
completedDate: now,
updatedAt: now,
};
if (performedBy) {
updateData.performedBy = performedBy;
}
if (cost !== undefined && cost !== null) {
updateData.cost = cost;
}
// Mettre à jour la maintenance
await db.collection("maintenances").doc(maintenanceId).update(updateData);
// Récupérer la maintenance pour mettre à jour les équipements
const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get();
const maintenanceData = maintenanceDoc.data();
// Mettre à jour la date de dernière maintenance des équipements
if (maintenanceData && maintenanceData.equipmentIds) {
const updatePromises = maintenanceData.equipmentIds.map((equipmentId) =>
db.collection("equipments").doc(equipmentId).update({
lastMaintenanceDate: now,
updatedAt: now,
}),
);
await Promise.all(updatePromises);
}
res.status(200).json({success: true});
} catch (error) {
logger.error("Error completing maintenance:", error);
res.status(500).json({error: error.message});
}
};
+263
View File
@@ -0,0 +1,263 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Récupérer toutes les options (public pour utilisateurs authentifiés)
exports.getOptions = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("options").get();
const options = snapshot.docs.map((doc) => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data()),
}));
res.status(200).json({options});
} catch (error) {
logger.error("Error fetching options:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer tous les types d'événements (public pour utilisateurs authentifiés)
exports.getEventTypes = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("eventTypes").get();
const eventTypes = snapshot.docs.map((doc) => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data()),
}));
res.status(200).json({eventTypes});
} catch (error) {
logger.error("Error fetching event types:", error);
res.status(500).json({error: error.message});
}
};
// Créer une option
exports.createOption = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const optionData = req.body.data;
const optionId = optionData.id;
if (!optionId) {
res.status(400).json({error: "Option ID is required"});
return;
}
await db.collection("options").doc(optionId).set(optionData);
res.status(201).json({id: optionId, message: "Option created successfully"});
} catch (error) {
logger.error("Error creating option:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour une option
exports.updateOption = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {optionId, data} = req.body.data;
if (!optionId) {
res.status(400).json({error: "Option ID is required"});
return;
}
delete data.id;
await db.collection("options").doc(optionId).update(data);
res.status(200).json({message: "Option updated successfully"});
} catch (error) {
logger.error("Error updating option:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer une option
exports.deleteOption = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {optionId} = req.body.data;
if (!optionId) {
res.status(400).json({error: "Option ID is required"});
return;
}
await db.collection("options").doc(optionId).delete();
res.status(200).json({message: "Option deleted successfully"});
} catch (error) {
logger.error("Error deleting option:", error);
res.status(500).json({error: error.message});
}
};
// Créer un type d'événement
exports.createEventType = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
if (!isAdmin) {
res.status(403).json({error: "Forbidden: Admin permission required"});
return;
}
const {name, defaultPrice} = req.body.data;
if (!name || defaultPrice === undefined) {
res.status(400).json({error: "Name and defaultPrice are required"});
return;
}
const existingSnapshot = await db.collection("eventTypes")
.where("name", "==", name)
.get();
if (!existingSnapshot.empty) {
res.status(409).json({error: "Event type name already exists"});
return;
}
const eventTypeData = {
name,
defaultPrice,
createdAt: admin.firestore.Timestamp.now(),
};
const docRef = await db.collection("eventTypes").add(eventTypeData);
res.status(201).json({id: docRef.id, message: "Event type created successfully"});
} catch (error) {
logger.error("Error creating event type:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un type d'événement
exports.updateEventType = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
if (!isAdmin) {
res.status(403).json({error: "Forbidden: Admin permission required"});
return;
}
const {eventTypeId, name, defaultPrice} = req.body.data;
if (!eventTypeId) {
res.status(400).json({error: "Event type ID is required"});
return;
}
const docRef = db.collection("eventTypes").doc(eventTypeId);
const doc = await docRef.get();
if (!doc.exists) {
res.status(404).json({error: "Event type not found"});
return;
}
if (name) {
const existingSnapshot = await db.collection("eventTypes")
.where("name", "==", name)
.get();
const hasDuplicate = existingSnapshot.docs.some((d) => d.id !== eventTypeId);
if (hasDuplicate) {
res.status(409).json({error: "Event type name already exists"});
return;
}
}
const updateData = {};
if (name) updateData.name = name;
if (defaultPrice !== undefined) updateData.defaultPrice = defaultPrice;
await docRef.update(updateData);
res.status(200).json({message: "Event type updated successfully"});
} catch (error) {
logger.error("Error updating event type:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un type d'événement
exports.deleteEventType = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
if (!isAdmin) {
res.status(403).json({error: "Forbidden: Admin permission required"});
return;
}
const {eventTypeId} = req.body.data;
if (!eventTypeId) {
res.status(400).json({error: "Event type ID is required"});
return;
}
const eventsSnapshot = await db.collection("events")
.where("eventTypeId", "==", eventTypeId)
.get();
const now = admin.firestore.Timestamp.now();
const futureEvents = eventsSnapshot.docs.filter((doc) => {
const startDate = doc.data().StartDateTime;
return startDate && startDate > now;
});
if (futureEvents.length > 0) {
res.status(409).json({
error: "Cannot delete event type with future events",
futureEventsCount: futureEvents.length,
});
return;
}
await db.collection("eventTypes").doc(eventTypeId).delete();
res.status(200).json({message: "Event type deleted successfully"});
} catch (error) {
logger.error("Error deleting event type:", error);
res.status(500).json({error: error.message});
}
};
+33
View File
@@ -0,0 +1,33 @@
const admin = require("firebase-admin");
const {Storage} = require("@google-cloud/storage");
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const storage = new Storage();
exports.moveEventFileV2 = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {sourcePath, destinationPath} = req.body.data || {};
if (!sourcePath || !destinationPath) {
res.status(400).json({error: "Source and destination paths are required."});
return;
}
const bucketName = admin.storage().bucket().name;
const bucket = storage.bucket(bucketName);
await bucket.file(sourcePath).copy(bucket.file(destinationPath));
await bucket.file(sourcePath).delete();
const [url] = await bucket.file(destinationPath).getSignedUrl({
action: "read",
expires: "03-01-2500",
});
res.status(200).json({url});
} catch (error) {
logger.error("Error moving file:", error);
res.status(500).json({error: error.message});
}
};
+375
View File
@@ -0,0 +1,375 @@
'use strict';
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const csv = require('csv-parser');
const polylineLib = require('@mapbox/polyline');
const auth = require('../utils/auth');
const logger = require('firebase-functions/logger');
// ─────────────────────────────────────────────
// Chargement du CSV des gares de péage (cache mémoire)
// ─────────────────────────────────────────────
let _tollStations = null;
function loadTollStations() {
return new Promise((resolve, reject) => {
if (_tollStations) return resolve(_tollStations);
const csvPath = path.join(__dirname, '../travel/gares_peage_export.csv');
if (!fs.existsSync(csvPath)) {
logger.warn('[Travel] CSV not found at ' + csvPath);
_tollStations = [];
return resolve(_tollStations);
}
const results = [];
fs.createReadStream(csvPath)
.pipe(csv())
.on('data', (row) => {
if (row.id_gare && row.lat && row.lon) {
results.push({
id: row.id_gare,
operatorId: row.id_gare.substring(0, 2),
tollId: row.id_gare.substring(2, 5),
name: row.nom || '',
lat: parseFloat(row.lat),
lon: parseFloat(row.lon),
});
}
})
.on('end', () => { _tollStations = results; resolve(results); })
.on('error', reject);
});
}
// ─────────────────────────────────────────────
// Ulys — Détection des péages sur un tracé
// POST https://api-ulys.azure-api.net/placemark/v2/legs?precision=6&includeLayersIds=GaresPeage
// Body = la polyline encodée (string brute, pas de JSON wrapper)
// ─────────────────────────────────────────────
async function getUlysTollLegs(encodedPolyline) {
try {
const polylineCoords = polylineLib.decode(encodedPolyline);
const ulysUrl = 'https://api-ulys.azure-api.net/placemark/v2/legs?precision=5&includeLayersIds=GaresPeage';
let finalPolyline = encodedPolyline;
// OPTION 1 : Mapbox Route Recreation
if (process.env.MAPBOX_API_KEY && polylineCoords.length > 2) {
logger.info('[Travel] MAPBOX_API_KEY is present. Recreating route with Mapbox for Ulys precision...');
try {
// Envoyer uniquement le point de départ et le point d'arrivée
// Mapbox s'occupe de recréer l'itinéraire complet de la meilleure façon
const waypoints = [polylineCoords[0], polylineCoords[polylineCoords.length - 1]];
// Mapbox expects longitude,latitude
const coordinatesString = waypoints.map(p => `${p[1]},${p[0]}`).join(';');
const mapboxUrl = `https://api.mapbox.com/directions/v5/mapbox/driving/${coordinatesString}?geometries=polyline&overview=full&access_token=${process.env.MAPBOX_API_KEY}`;
const mapboxRes = await axios.get(mapboxUrl);
if (mapboxRes.data && mapboxRes.data.routes && mapboxRes.data.routes.length > 0) {
finalPolyline = mapboxRes.data.routes[0].geometry;
logger.info('[Travel] Mapbox route recreation successful.');
}
} catch (mapboxErr) {
logger.error('[Travel] Mapbox API error:', mapboxErr.response ? mapboxErr.response.data : mapboxErr.message);
// Fallback to Google Maps polyline if Mapbox fails
}
}
// Appeler Ulys /legs
const res = await axios.post(
ulysUrl,
JSON.stringify(finalPolyline),
{
headers: {
'Content-Type': 'application/json',
'Host': 'api-ulys.azure-api.net'
},
timeout: 10000,
}
);
return res.data;
} catch (e) {
logger.warn('[Travel] Ulys /legs failed:', e.message);
return null;
}
}
// ─────────────────────────────────────────────
// Ulys — Tarif pour un segment (entrée → sortie)
// POST https://api-ulys.azure-api.net/tollstation/v1/rate
// ─────────────────────────────────────────────
async function getUlysRate(vehicleCategory, passages) {
try {
const now = new Date().toISOString();
const payload = {
vehicleCategory: String(vehicleCategory),
paymentOption: 2,
tollPassages: passages.map((p) => ({
toll: { operatorId: p.operatorId, tollId: p.tollId },
passageDate: now,
})),
};
const body = JSON.stringify(payload);
const res = await axios.post(
'https://api-ulys.azure-api.net/tollstation/v1/rate',
payload,
{
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body).toString(),
'Host': 'api-ulys.azure-api.net',
},
timeout: 8000,
},
);
const data = res.data;
if (Array.isArray(data) && data.length > 0) {
if (passages.length === 2) {
// We expect a single closed system response
if (data.length === 1 && data[0].entranceToll && data[0].exitToll && data[0].price > 0) {
return data[0].price;
}
return null;
} else if (passages.length === 1) {
if (data.length === 1 && data[0].price > 0) {
return data[0].price;
}
return null;
}
}
return null;
} catch (e) {
return null;
}
}
// ─────────────────────────────────────────────
// Calcul du total de péage via Ulys /legs puis /rate
// ─────────────────────────────────────────────
async function calculateTollCost(encodedPolyline, vehicleCategory) {
try {
// 1. Demander à Ulys les gares sur le tracé
const legsData = await getUlysTollLegs(encodedPolyline);
const features = Array.isArray(legsData) ? legsData : (legsData && legsData.features ? legsData.features : []);
if (features && features.length > 0) {
// Extraire les gares dans l'ordre du tracé
const tollGates = [];
for (const feature of features) {
const props = feature.properties || feature.Placemark || feature.placemark || {};
// La réponse Ulys peut utiliser différents noms de champs
// On cherche l'identifiant de la gare dans tous les champs connus
let id =
props.id_gare ||
props.idGare ||
props.id ||
props.gareId ||
props.gare_id ||
props.tollStationId;
if (!id && props.Code) {
id = props.Code.split('_')[0];
}
if (!id) continue;
const idStr = String(id);
if (idStr.length < 5) continue;
tollGates.push({
id: idStr,
operatorId: idStr.substring(0, 2),
tollId: idStr.substring(2, 5),
name: props.nom || props.name || props.label || idStr,
});
}
logger.info(`[Travel] Ulys /legs found ${tollGates.length} toll gates`);
if (tollGates.length === 0) return 0;
// Greedy: trouver les segments fermés + barrières ouvertes
return await _computeTollFromGates(tollGates, vehicleCategory);
}
// Fallback : pas de résultat Ulys /legs, retourner 0
logger.info('[Travel] Ulys /legs returned no toll gates for this route');
return 0;
} catch (e) {
logger.error('[Travel] calculateTollCost error:', e);
return 0;
}
}
async function _computeTollFromGates(gates, vehicleCategory) {
let total = 0;
let i = 0;
while (i < gates.length) {
let found = false;
// Essayer le segment fermé le plus long possible (greedy backward)
for (let j = gates.length - 1; j > i; j--) {
const price = await getUlysRate(vehicleCategory, [gates[i], gates[j]]);
if (price !== null && price > 0) {
total += price;
i = j;
found = true;
break;
}
}
if (!found) {
// Barrière ouverte : tarif unitaire
const price = await getUlysRate(vehicleCategory, [gates[i]]);
if (price !== null && price > 0 && price < 20) {
total += price;
}
i++;
}
}
return total;
}
// ─────────────────────────────────────────────
// EXPORT: Google Maps Autocomplete (proxy CORS)
// ─────────────────────────────────────────────
exports.googleMapsAutocomplete = async (req, res) => {
if (req.method === 'OPTIONS') {
return res.status(204).send('');
}
try {
await auth.authenticateUser(req);
const body = req.body.data || req.body;
const query = body.query || req.query.query;
if (!query) return res.status(400).json({ error: 'query is required' });
const apiKey = process.env.API_MAPS;
if (!apiKey) return res.status(500).json({ error: 'API_MAPS not configured in .env' });
const url = new URL('https://maps.googleapis.com/maps/api/place/autocomplete/json');
url.searchParams.set('input', query);
url.searchParams.set('key', apiKey);
url.searchParams.set('language', 'fr');
url.searchParams.set('components', 'country:fr');
url.searchParams.set('types', 'address');
const gRes = await axios.get(url.toString(), { timeout: 5000 });
return res.status(200).json(gRes.data);
} catch (e) {
logger.error('[Travel] googleMapsAutocomplete error:', e.message);
return res.status(500).json({ error: e.message });
}
};
// ─────────────────────────────────────────────
// EXPORT: Google Maps Compute Route (2 itinéraires + péages Ulys)
// ─────────────────────────────────────────────
exports.googleMapsComputeRoute = async (req, res) => {
if (req.method === 'OPTIONS') {
return res.status(204).send('');
}
try {
await auth.authenticateUser(req);
const body = req.body.data || req.body;
const { origin, destination, vehicleTollCategory = 2 } = body;
if (!origin || !destination) {
return res.status(400).json({ error: 'origin and destination are required' });
}
const apiKey = process.env.API_MAPS;
if (!apiKey) return res.status(500).json({ error: 'API_MAPS not configured in .env' });
const routesUrl = 'https://routes.googleapis.com/directions/v2:computeRoutes';
const fieldMask = [
'routes.distanceMeters',
'routes.duration',
'routes.polyline.encodedPolyline',
'routes.travelAdvisory.tollInfo',
].join(',');
const commonPayload = {
travelMode: 'DRIVE',
routingPreference: 'TRAFFIC_AWARE',
origin: { address: origin },
destination: { address: destination },
};
const [resToll, resNoToll] = await Promise.all([
axios.post(routesUrl, { ...commonPayload, routeModifiers: { avoidTolls: false } }, {
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': fieldMask,
},
timeout: 15000,
}),
axios.post(routesUrl, { ...commonPayload, routeModifiers: { avoidTolls: true } }, {
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': fieldMask,
},
timeout: 15000,
}),
]);
const routes = [];
console.log("resToll.data.routes length:", resToll.data.routes ? resToll.data.routes.length : 0);
if (!resToll.data.routes) console.log("resToll.data:", JSON.stringify(resToll.data, null, 2));
// --- Route avec péage ---
if (resToll.data.routes && resToll.data.routes.length > 0) {
const r = resToll.data.routes[0];
const poly = r.polyline?.encodedPolyline || '';
let tollCost = 0;
if (poly) {
tollCost = await calculateTollCost(poly, vehicleTollCategory);
}
routes.push({
routeType: 'TOLL',
distanceMeters: r.distanceMeters || 0,
durationSeconds: _parseDuration(r.duration),
encodedPolyline: poly,
tollCost,
});
}
// --- Route sans péage ---
if (resNoToll.data.routes && resNoToll.data.routes.length > 0) {
const r = resNoToll.data.routes[0];
const poly = r.polyline?.encodedPolyline || '';
// N'ajouter que si différente de la route avec péage
const isDifferent = routes.length === 0 ||
r.distanceMeters !== routes[0].distanceMeters ||
Math.abs(_parseDuration(r.duration) - routes[0].durationSeconds) > 60;
if (isDifferent) {
routes.push({
routeType: 'TOLL_FREE',
distanceMeters: r.distanceMeters || 0,
durationSeconds: _parseDuration(r.duration),
encodedPolyline: poly,
tollCost: 0,
});
}
}
return res.status(200).json({ routes });
} catch (e) {
logger.error('[Travel] googleMapsComputeRoute error:', e.message, e.response?.data);
return res.status(500).json({ error: e.message });
}
};
function _parseDuration(durationStr) {
if (!durationStr) return 0;
if (typeof durationStr === 'number') return durationStr;
// Format: "1234s"
const match = String(durationStr).match(/^(\d+)s?$/);
return match ? parseInt(match[1]) : 0;
}
exports.getUlysTollLegs = getUlysTollLegs;
+58
View File
@@ -0,0 +1,58 @@
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
exports.generateTTSV2 = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
logger.info("[generateTTSV2] Request from user:", {
uid: decodedToken.uid,
email: decodedToken.email,
});
const {text, voiceConfig} = req.body.data || {};
if (!text) {
res.status(400).json({error: "Text parameter is required"});
return;
}
if (text.length > 5000) {
res.status(400).json({error: "Text too long (max 5000 characters)"});
return;
}
const {Storage} = require("@google-cloud/storage");
const storage = new Storage();
const bucketName = admin.storage().bucket().name;
const bucket = storage.bucket(bucketName);
const {generateTTS} = require("../generateTTS");
const result = await generateTTS(text, storage, bucket, voiceConfig);
logger.info("[generateTTSV2] ✓ Success", {
cached: result.cached,
cacheKey: result.cacheKey,
});
res.status(200).json({
audioUrl: result.audioUrl,
cached: result.cached,
cacheKey: result.cacheKey,
});
} catch (error) {
logger.error("[generateTTSV2] ✗ Error:", {
error: error.message,
code: error.code,
});
if (error.code === "PERMISSION_DENIED") {
res.status(403).json({error: "Permission denied. Check Google Cloud TTS API is enabled."});
} else if (error.code === "QUOTA_EXCEEDED") {
res.status(429).json({error: "TTS quota exceeded. Try again later."});
} else {
res.status(500).json({error: error.message});
}
}
};
+336
View File
@@ -0,0 +1,336 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer un utilisateur
exports.createUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const userData = req.body.data;
const userId = userData.uid;
if (!userId) {
res.status(400).json({error: "User ID is required"});
return;
}
await db.collection("users").doc(userId).set(userData);
res.status(201).json({id: userId, message: "User created successfully"});
} catch (error) {
logger.error("Error creating user:", error);
res.status(500).json({error: error.message});
}
};
// Créer un utilisateur avec invitation par email
exports.createUserWithInvite = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {email, firstName, lastName, phoneNumber, roleId} = req.body.data;
if (!email || !firstName || !lastName || !roleId) {
res.status(400).json({error: "email, firstName, lastName, and roleId are required"});
return;
}
const tempPassword = Math.random().toString(36).slice(-12) + "Aa1!";
let userRecord;
try {
userRecord = await admin.auth().createUser({
email: email,
password: tempPassword,
emailVerified: false,
displayName: `${firstName} ${lastName}`,
});
} catch (authError) {
logger.error("Error creating user in Auth:", authError);
res.status(500).json({error: `Failed to create user in Auth: ${authError.message}`});
return;
}
try {
await db.collection("users").doc(userRecord.uid).set({
firstName: firstName,
lastName: lastName,
email: email,
phoneNumber: phoneNumber || "",
profilePhotoUrl: "",
role: db.collection("roles").doc(roleId),
createdAt: admin.firestore.FieldValue.serverTimestamp(),
createdBy: decodedToken.uid,
});
} catch (firestoreError) {
logger.error("Error creating user in Firestore:", firestoreError);
try {
await admin.auth().deleteUser(userRecord.uid);
} catch (cleanupError) {
logger.error("Error cleaning up Auth user:", cleanupError);
}
res.status(500).json({error: `Failed to create user in Firestore: ${firestoreError.message}`});
return;
}
try {
const axios = require("axios");
const firebaseApiKey = "AIzaSyARQL4P-t5l-cNjQNP9cMokQrLJ8BorF0U";
await axios.post(
`https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=${firebaseApiKey}`,
{
requestType: "PASSWORD_RESET",
email: email,
},
);
logger.info(`Password reset email sent to ${email}`);
} catch (emailError) {
logger.warn(`Could not send password reset email to ${email}: ${emailError.message}`);
}
logger.info(`User ${userRecord.uid} created by ${decodedToken.uid}`);
res.status(201).json({
id: userRecord.uid,
message: "User created successfully. Password reset email sent.",
});
} catch (error) {
logger.error("Error in createUserWithInvite:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un utilisateur
exports.updateUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {userId, data} = req.body.data;
if (!userId) {
res.status(400).json({error: "User ID is required"});
return;
}
// Un utilisateur ne peut modifier que son propre compte, sauf s'il est admin
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (decodedToken.uid !== userId && !isAdminUser) {
res.status(403).json({error: "Forbidden: Cannot update other user accounts"});
return;
}
// Empêcher les non-admins de modifier le rôle
if (!isAdminUser && data.role) {
delete data.role;
}
// Si le rôle est fourni et est un string, le convertir en DocumentReference
if (data.role && typeof data.role === "string") {
data.role = db.collection("roles").doc(data.role);
}
await db.collection("users").doc(userId).update(data);
res.status(200).json({message: "User updated successfully"});
} catch (error) {
logger.error("Error updating user:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un utilisateur
exports.deleteUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {userId} = req.body.data;
if (!userId) {
res.status(400).json({error: "User ID is required"});
return;
}
if (decodedToken.uid === userId) {
res.status(400).json({error: "Cannot delete your own account"});
return;
}
await db.collection("users").doc(userId).delete();
try {
await admin.auth().deleteUser(userId);
} catch (authError) {
logger.warn(`Could not delete user from Auth: ${authError.message}`);
}
res.status(200).json({message: "User deleted successfully"});
} catch (error) {
logger.error("Error deleting user:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer tous les utilisateurs (selon permissions)
exports.getUsers = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_users");
if (!canViewAll) {
const userDoc = await db.collection("users").doc(decodedToken.uid).get();
if (!userDoc.exists) {
res.status(404).json({error: "User not found"});
return;
}
let userData = userDoc.data();
userData = helpers.serializeTimestamps(userData);
userData = helpers.serializeReferences(userData);
res.status(200).json({
users: [{
id: userDoc.id,
...userData,
}],
});
return;
}
const snapshot = await db.collection("users").get();
const users = snapshot.docs.map((doc) => {
let data = doc.data();
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
return {
id: doc.id,
...data,
};
});
res.status(200).json({users});
} catch (error) {
logger.error("Error fetching users:", error);
res.status(500).json({error: error.message});
}
};
// Récupère un utilisateur spécifique
exports.getUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {userId} = req.body.data || req.body || {};
if (!userId) {
res.status(400).json({error: "userId is required"});
return;
}
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
res.status(404).json({error: "User not found"});
return;
}
const user = userDoc.data();
const userData = {
id: userDoc.id,
uid: user.uid || userDoc.id,
email: user.email || "",
firstName: user.firstName || "",
lastName: user.lastName || "",
phoneNumber: user.phoneNumber || "",
profilePhotoUrl: user.profilePhotoUrl || "",
};
if (user.role) {
const roleDoc = await user.role.get();
if (roleDoc.exists) {
userData.role = {
id: roleDoc.id,
...roleDoc.data(),
};
}
}
res.status(200).json({user: userData});
} catch (error) {
logger.error("Error fetching user:", error);
res.status(500).json({error: error.message});
}
};
// Récupère l'utilisateur actuellement authentifié avec son rôle
exports.getCurrentUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const userId = decodedToken.uid;
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
res.status(404).json({error: "User not found"});
return;
}
const userData = userDoc.data();
let roleData = null;
if (userData.role) {
const roleDoc = await userData.role.get();
if (roleDoc.exists) {
roleData = {id: roleDoc.id, ...roleDoc.data()};
}
}
res.status(200).json({
user: {
uid: userId,
...helpers.serializeTimestamps(userData),
role: roleData,
},
});
} catch (error) {
logger.error("Error getting current user:", error);
res.status(500).json({error: error.message});
}
};
// Récupère tous les rôles
exports.getRoles = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("roles").get();
const roles = snapshot.docs.map((doc) => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data()),
}));
res.status(200).json({roles});
} catch (error) {
logger.error("Error fetching roles:", error);
res.status(500).json({error: error.message});
}
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,643 @@
id_gare,nom,lat,lon
09242,BEAUPONT,46.44094,5.26993
10157,BELLEGARDE,46.11602,5.79506
09147,SYLANS,46.15853,5.6484
09146,ST MARTIN-DU-FRESNE,46.13243,5.54164
09156,LA CROIX-CHALON,46.17772,5.5589
09145,BOURG SUD,46.14563,5.28633
09144,VIRIAT,46.23576,5.27355
09149,SAINT GENIS,46.27197,5.01576
09143,BOURG NORD,46.2599,5.16695
09142,REPLONGES,46.30076,4.89584
09141,FEILLENS,46.32883,4.88241
09150,BEYNOST,45.82045,4.99411
09153,LA BOISSE,45.82704,5.03146
09253,LA COTIERE,45.83409,5.0324
09251,LA BOISSE - MONTLUEL,45.8407,5.04702
09422,MIONNAY,45.883,4.89889
09151,BALAN,45.85092,5.09938
09152,PEROUGES,45.86086,5.1813
09154,AMBERIEU,45.97765,5.3127
09155,PONT D AIN,46.04812,5.32788
09443,CROTTET,46.28678,4.87001
07144,CHATEAU-THIERRY,49.07832,3.39881
07141,MONTREUIL,49.01321,3.15531
07146,DORMANS,49.15379,3.71689
07032,ST QUENTIN SUD,49.81227,3.29479
07031,ST QUENTIN NORD,49.86139,3.24072
07033,LA FERE,49.6872,3.46533
07034,LAON,49.6078,3.66102
07136,REIMS,49.32216,3.98417
07135,GUIGNICOURT,49.42699,3.92801
09062,FORET DE TRONCAIS,46.50647,2.63033
09064,MONTMARAULT,46.32764,2.96645
06060,MANOSQUE,43.80781,5.81289
06059,ST PAUL LES DURANCE,43.70249,5.7346
06061,LA BRILLANNE,43.92395,5.89373
06063,PEYRUIS,44.03919,5.96354
06065,AUBIGNOSC,44.12443,5.98428
06064,AUBIGNOSC OUEST,44.12513,5.97854
06067,SISTERON SUD,44.17168,5.95542
06068,SISTERON NORD,44.2236,5.91579
06014,ANTIBES OUEST,43.6028,7.07248
06024,SOPHIA,43.60275,7.08124
06022,ANTIBES PV NORD,43.60463,7.06645
06023,ANTIBES EST SORTIE,43.60421,7.08636
06017,CAGNES OUEST SUD,43.64321,7.13404
06016,CAGNES EST,43.65933,7.14863
06015,CAGNES OUEST,43.64804,7.13906
06013,ANTIBES EST,43.60301,7.08397
06019,ST ISIDORE ECH OUEST,43.70408,7.19005
06025,MONACO,43.74513,7.37264
06026,LA TURBIE ECH.,43.74346,7.37835
04358,PAMIERS,43.15415,1.61603
04357,MAZERES SAVERDUN,43.23448,1.63595
09173,SAINT THIBAULT,48.22341,4.12962
09174,TORVILLIERS,48.2844,3.96602
09183,THENNELIERES,48.28933,4.16302
09172,MAGNANT,48.17907,4.44056
09171,VILLE SOUS LAFERTE,48.11496,4.78555
09170,CHAUMONT-SEMOUTIERS,48.04283,5.05935
07186,VALLEE DE L'AUBE,48.52551,4.18556
07185,CHARMONT-S/BARBUISE,48.41039,4.14267
04339,NARBONNE-SUD,43.16443,2.99012
04338,NARBONNE-EST,43.17904,3.03496
04340,SIGEAN,43.03314,2.95619
04341,LEUCATE,42.93912,2.97304
04350,CASTELNAUDARY,43.29079,1.94671
04349,BRAM,43.23897,2.10001
04348,CARCASSONNE-O,43.19993,2.31004
04347,CARCASSONNE-E,43.20205,2.4182
04346,LEZIGNAN,43.17187,2.74107
04213,CAVAILLON,43.81988,5.02777
04214,SENAS,43.74201,5.08971
04215,SALON NORD,43.65943,5.10259
04279,A8: AIX-EN-PROVENCE,43.55106,5.23769
04278,COUDOUX,43.55294,5.23863
06001,CANET DE MEYREUIL,43.49465,5.52496
06035,CASSIS,43.22517,5.58151
06034,LA CIOTAT ECH.,43.20208,5.59476
06054,PERTUIS SUD,43.66218,5.49758
06038,PAS DE TRETS,43.3863,5.6018
06036,PONT DE L ETOILE,43.32437,5.59801
04267,GRANS,43.62304,5.08354
04266,SALON OUEST,43.6364,5.02274
04219,SALON SUD,43.62598,5.1015
08612,DOZULE FL ECH,49.22992,-0.08247
08621,TROARN FL,49.1778,-0.20296
08631,CAGNY FL,49.16833,-0.24512
04536,ST-JEAN D'ANGELY,45.96286,-0.54589
04537,SAINTES,45.75224,-0.66399
04538,PONS,45.5745,-0.59558
04540,SAINT-AUBIN,45.25434,-0.5478
04539,MIRAMBEAU,45.37766,-0.57955
04545,TONNAY-CHARENTE,45.95515,-0.88097
05056,VIERZON-EST,47.2118,2.11835
05055,VIERZON-NORD,47.24397,2.06412
05057,BOURGES,47.04492,2.34171
09061,ST AMAND-MONTROND,46.722,2.45831
04118,MANSAC,45.15247,1.37821
04123,TULLE NORD,45.32901,1.76396
04124,TULLE EST,45.31812,1.85046
04125,EGLETONS,45.40739,2.02119
04126,USSEL OUEST,45.51246,2.25888
04127,USSEL EST,45.58897,2.41459
09111,BIERRE-LES-SEMUR,47.43663,4.30944
09110,AVALLON,47.50932,3.99198
09114,BEAUNE SUD,47.0024,4.85476
09129,BEAUNE NORD,47.04201,4.85075
09124,NUITS-ST-GEORGES,47.13099,4.96743
09160,DIJON-ARC S/TILLE,47.34461,5.15933
09126,DIJON-CRIMOLOIS,47.27707,5.13804
09161,TIL CHATEL,47.53859,5.19472
09139,SEURRE,47.02937,5.17033
09130,SOIRANS,47.20534,5.305
04104,MONTPON,44.98597,0.15622
04105,MUSSIDAN SUD,45.01073,0.37757
04107,MUSSIDAN EST,45.06515,0.43001
04106,MUSSIDAN BARRIERE,45.06529,0.42986
04114,THENON EST,45.15161,1.16785
04112,THENON,45.15124,1.16747
04116,LA BACHELLERIE SUD,45.14813,1.17472
09128,L ISLE-S/LE-DOUBS,47.41251,6.58553
09133,BAUME-LES-DAMES,47.37102,6.37965
09131,BESANCON EST,47.33224,6.1513
09134,BESANCON NORD,47.27603,5.98798
09135,BESANCON OUEST,47.23478,5.89813
04204,VALENCE-N,44.97018,4.88768
04203,TAIN,45.06844,4.8685
04205,VALENCE-S,44.90511,4.88009
04206,LORIOL,44.75681,4.79136
04207,MONTELIMAR-N,44.66896,4.79483
04208,MONTELIMAR-S,44.48123,4.76353
03092,LA BAUME D'HOSTUN,45.06467,5.20806
03091,CHATUZANGE BARRIERE,45.02608,5.0969
08532,HEUDEBOUVILLE FL ECH PARIS,49.19492,1.23192
08551,BOURG ACHARD FL ECH,49.36642,0.81813
08571,BOURNEVILLE FL ECH,49.37708,0.62672
08581,TOUTAINVILLE FL ECH,49.36423,0.46766
08592,BEUZEVILLE FL ECH PARIS,49.33757,0.36939
12030,BROGLIE/ORBEC SENS 2,49.03165,0.44483
12031,BROGLIE/ORBEC SENS 1,49.03719,0.4416
12040,BERNAY,49.13768,0.57809
12050,BRIONNE,49.24206,0.77249
05306,ARTENAY,48.0843,1.8557
05304,ALLAINES,48.20533,1.84601
05605,CHARTRES-EST,48.45632,1.53784
05607,THIVARS,48.36159,1.44648
05609,LUIGNY,48.23459,1.03313
05608,ILLIERS-COMBRAY,48.29444,1.27031
04222,REMOULINS,43.93725,4.5982
04221,ROQUEMAURE,44.02563,4.73546
04224,NIMES-O,43.81363,4.34275
04223,NIMES-E,43.85615,4.42069
04275,LUNEL,43.70284,4.11962
04225,GALLARGUES,43.7229,4.18087
04260,NIMES CENTRE,43.80757,4.37448
04261,GARONS,43.76132,4.42753
04356,NAILLOUX,43.38237,1.61688
04352,MONTGISCARD,43.46088,1.58831
04351,VILLEFRANCHE,43.39858,1.6962
04458,SAINT-JORY,43.71824,1.39796
04455,EUROCENTRE,43.76503,1.38044
04646,MONTREJEAU,43.10017,0.59546
04644,LANNEMEZAN,43.09782,0.39023
04648,ST GAUDENS,43.11568,0.7566
04650,LESTELLE,43.12029,0.89548
04651,LESTELLE ST MARTORY,43.11773,0.89297
04470,L'UNION,43.64518,1.49797
04467,PODENSAC,44.60754,-0.36681
04466,LANGON,44.54422,-0.26201
04465,LA REOLE,44.51137,-0.04954
04464,MARMANDE,44.43479,0.13293
04101,ARVEYRES,44.88514,-0.26839
04102,LIBOURNE NORD,44.95703,-0.24522
04103,COUTRAS,45.012,-0.09983
04276,BAILLARGUES,43.67053,4.01301
04333,SETE,43.47755,3.68525
04331,MONTPELLIER ST-JEAN,43.56153,3.8305
04335,AGDE-PEZENAS,43.37414,3.41816
04334,BEZIERS CABRIALS,43.3433,3.28907
04337,BEZIERS-OUEST,43.30445,3.2191
05318,AMBOISE CH.RENAULT,47.54281,0.98489
05320,TOURS-C/MONNAIE,47.49058,0.81927
05486,TOURS-NORD,47.45219,0.73926
05490,CHAMBRAY,47.34906,0.70388
05485,LA THIBAUDIERE,47.33078,0.68772
05484,MONTS - SORIGNY,47.25456,0.67091
05524,SAINTE MAURE,47.10728,0.58766
05522,TOURS-C/SORIGNY,47.21845,0.65771
05478,NEUILLE PONT PIERRE,47.55541,0.59565
05967,BOURGUEIL,47.25319,0.16665
05960,VIVY,47.31056,-0.03177
05014,BLERE,47.28654,0.98447
04216,AUBERIVES,45.3898,4.80535
04202,CHANAS,45.32115,4.80972
03022,CROLLES BRIGNOUD,45.2716,5.90105
03023,LE TOUVET,45.34745,5.96371
03021,CROLLES BARRIERE,45.27169,5.90089
03024,PONTCHARRA,45.42579,5.99511
03071,CHESNES,45.65572,5.09788
03002,ST QUENTIN FAL. BRETELLE,45.64785,5.11979
03062,VILLEFONTAINE,45.62843,5.16432
03003,ISLE D'ABEAU CENTRE,45.60516,5.2344
03004,BOURGOIN,45.58235,5.30027
03005,LA TOUR DU PIN,45.56194,5.42886
03072,LA TOUR DU PIN EST,45.55669,5.46452
03006,LES ABRETS,45.57134,5.60416
03061,SAINT GENIX SUR GUIERS,45.57277,5.65913
03085,RIVES,45.38415,5.47313
03086,VOIRON,45.34763,5.56633
03083,MOIRANS NORD,45.32415,5.60512
03087,VOREPPE BARRIERE,45.28323,5.622
03084,MOIRANS,45.32035,5.60783
03095,TULLINS,45.287,5.52175
03093,SAINT MARCELLIN,45.13824,5.32621
03094,VINAY,45.19954,5.41944
09136,GENDREY,47.18353,5.70894
09137,DOLE,47.1361,5.50619
09138,CHOISEY,47.06457,5.44674
09238,ARLAY,46.77782,5.51859
09240,BEAUREPAIRE,46.66637,5.41894
04907,BENESSE,43.62393,-1.40031
04906,CAPBRETON,43.63235,-1.39224
04908,ONDRES,43.54151,-1.4356
04624,PEYREHORADE,43.51704,-1.10384
04687,SALIES,43.5098,-0.92187
05314,MER,47.72856,1.50862
05312,MEUNG SUR LOIRE,47.8328,1.669
05316,BLOIS,47.62149,1.34635
05053,LAMOTTE-BEUVRON,47.58173,1.99103
05054,SALBRIS,47.41849,2.0257
05013,ST ROMAIN SUR CHER,47.30652,1.35312
05011,CHEMERY,47.32467,1.50014
05010,VILLEFRANCHE S/ CHER,47.32452,1.76561
04178,MONTBRISON,45.63736,4.19611
04177,FEURS,45.73754,4.18636
04174,NOIRETABLE,45.84935,3.79423
04175,ST GERMAIN L.,45.86092,4.04202
04180,BALBIGNY,45.84111,4.16441
05246,ANCENIS/NANTES,47.40254,-1.19352
05245,ANGERS/ANCENIS,47.40243,-1.1935
05248,NANTES/ANCENIS,47.39927,-1.19276
04556,AIGREFEUILLE,47.0693,-1.43729
04557,BIGNON,47.11481,-1.49175
05309,GIDY,47.96698,1.85157
05308,ORLEANS-NORD,47.94948,1.85462
14380,SAVIGNY /CLAIRIS,48.05598,3.08802
14375,ST HILAIRE,48.03677,3.02262
14365,GONDREVILLE A77/S,48.06084,2.66766
14370,FONTENAY /LOING,48.0642,2.76711
14360,GONDREVILLE A77/N,48.0608,2.66765
14355,AUXY,48.08539,2.47274
14350,ESCRENNES,48.11633,2.19111
05052,OLIVET,47.84093,1.86936
05050,ORLEANS-CENTRE,47.89819,1.85323
09404,LE TOURNEAU,47.99293,2.67768
04408,MARTEL,44.99329,1.53144
04406,SOUILLAC,44.90066,1.50424
04405,LABASTIDE MURAT,44.69812,1.58057
04404,CAHORS NORD,44.53225,1.50626
04403,CAHORS SUD,44.34273,1.49406
04463,AIGUILLON,44.28452,0.26986
04469,Agen Ouest,44.1875,0.54602
04462,AGEN,44.16498,0.60573
04783,SEICHES,47.56645,-0.32803
04782,DURTAL,47.66779,-0.25964
04784,CORZE,47.53933,-0.3408
05280,ST JEAN DE LINIERES,47.46658,-0.68606
05274,SAINT GERMAIN,47.43168,-0.81633
05958,BEAUFORT,47.46812,-0.19348
05959,LONGUE,47.40448,-0.11597
04561,THOUARCE,47.3295,-0.59635
04563,CHEMILLE,47.23593,-0.72764
04564,CHOLET NORD,47.08333,-0.82795
04565,CHOLET SUD,47.01894,-0.88073
07190,REIMS EST,49.21144,4.07834
07154,REIMS SUD,49.20521,4.00643
07191,CHALONS LA VEUVE,49.04479,4.32266
07189,ST GIBRIEN,48.97356,4.28868
07192,CHALONS MOURMELON,49.04094,4.32093
07193,ST ETIENNE AU TEMPLE,49.03349,4.43586
07194,STE MENEHOULD,49.0764,4.88366
07616,CLERMONT EN ARGONNE,49.09443,5.10176
07137,LA NEUVILLETTE,49.29793,3.99801
07188,MONT CHOISY,48.91073,4.28809
07187,SOMMESOUS,48.73061,4.22984
07633,VATRY,48.76782,4.24012
09162,LANGRES SUD,47.79236,5.22621
09163,LANGRES NORD,47.93512,5.28682
09164,MONTIGNY-LE-ROI,47.99736,5.51194
05821,VAIGES,48.05551,-0.48728
05823,LAVAL-EST,48.10762,-0.73857
05825,LAVAL-OUEST,48.10462,-0.83436
07198,JARNY,49.19991,5.90167
07199,BEAUMONT,49.19882,5.92401
09168,COLOMBEY-LES-BELLES,48.54066,5.90884
07195,VOIE SACREE,49.09382,5.27836
07196,VERDUN,49.11096,5.41385
07197,FRESNES EN WOEVRE,49.12909,5.62133
07177,STE-MARIE,49.19333,5.98902
07716,BOULAY,49.1411,6.46657
07719,ST AVOLD,49.13582,6.71291
07721,FAREBERSVILLER,49.10816,6.85335
07707,PUTTELANGE,49.06986,6.91814
07701,LOUPERSHOUSE,49.07553,6.89666
07702,SARREGUEMINES,49.04476,7.02883
07704,PHALSBOURG,48.77206,7.24069
07705,SAVERNE,48.76172,7.38999
07029,MARQUION,50.20249,3.10689
07017,CAMBRAI,50.17563,3.19272
07030,MASNIERES,50.06855,3.17244
07005,SENLIS BONSECOURS,49.20703,2.60921
07006,SENLIS,49.2154,2.62813
07007,PONT STE MAXENCE,49.32069,2.69478
07008,COMPIEGNE OUEST,49.39898,2.69922
07009,RESSONS,49.52169,2.71574
07414,MERU,49.21073,2.15071
07415,BEAUVAIS CENTRE,49.39922,2.12564
07416,BEAUVAIS NORD,49.43377,2.12615
07417,HARDIVILLERS,49.60999,2.20284
05142,ALENCON NORD,48.45298,0.12411
17111,Entrée SEES,48.63813,0.18495
12210,ARGENTAN,48.63284,0.1909
12020,GACE SENS 2,48.77175,0.30896
12021,GACE SENS 1,48.77694,0.30347
07012,ALBERT,49.96568,2.86338
07013,BAPAUME,50.1042,2.8679
07014,ARRAS,50.2694,2.86387
07434,BERCK,50.41161,1.6899
07436,ETAPLES-LE TOUQUET,50.50672,1.68025
07437,NEUFCHATEL HARDELOT,50.61112,1.64983
07438,BOULOGNE SUD,50.67396,1.65981
07021,VALLEE DE LA HEM,50.82097,2.06379
07022,ST-OMER B,50.7229,2.16749
07020,CALAIS,50.71905,2.17249
07024,AIRE SUR LA LYS,50.66821,2.25608
07023,ST-OMER,50.71935,2.1729
07025,LILLERS,50.55304,2.46281
07026,BETHUNE,50.514,2.61776
07060,NOEUX LES MINES,50.4869,2.68389
07027,LIEVIN,50.43782,2.70728
07015,DOURGES,50.32504,2.9107
07028,ARRAS,50.34517,2.7875
09076,COMBRONDE,45.99599,3.10472
09077,RIOM,45.89546,3.14816
09078,GERZAT-VILLE,45.84223,3.15962
04128,ST JULIEN SANCY,45.66634,2.68254
04129,VULCANIA BROMONT,45.8339,2.82261
04130,MANZAT,45.95393,2.98285
04171,LEZOUX,45.84864,3.38363
04172,THIERS-OUEST,45.86003,3.50381
04173,THIERS-EST,45.87882,3.62491
04905,BAYONNE SUD,43.4623,-1.49849
04903,BIARRITZ,43.45046,-1.55445
04982,ST JEAN DE LUZ NORD,43.37193,-1.67494
04902,ST JEAN DE LUZ SUD,43.37196,-1.67775
04620,GUICHE,43.51222,-1.22266
04689,ORTHEZ,43.46716,-0.74861
04691,LESCAR,43.34524,-0.4188
04690,ARTIX,43.39508,-0.55123
04692,PAU CENTRE,43.33051,-0.35045
04695,TARBES OUEST,43.22081,0.02358
04638,TARBES EST,43.21372,0.10822
04640,TOURNAY,43.17743,0.23992
04642,CAPVERN,43.10327,0.34273
04342,PERPIGNAN-NORD,42.7818,2.89739
04343,PERPIGNAN-SUD,42.66674,2.85891
04344,LE BOULOU,42.52366,2.81767
07703,SARRE UNION,48.91392,7.12449
07708,HOCHFELDEN OUEST,48.76926,7.60208
09221,VILLEFRANCHE-NORD,46.02229,4.72208
09120,BELLEVILLE S/SAONE,46.1038,4.75047
09121,VILLEFRANCHE-VILLE,45.97728,4.73366
04268,CONDRIEU ENTREE,45.50581,4.84228
04269,CONDRIEU SORTIE,45.50521,4.83904
09421,GENAY,45.90029,4.81942
04181,TARARE OUEST,45.89156,4.4031
04183,TARARE EST F,45.87151,4.50903
04184,TARARE EST ENTREE O,45.87624,4.52202
09115,CHALON CENTRE,46.80244,4.82981
09116,CHALON SUD,46.75343,4.83299
09117,TOURNUS,46.57911,4.90124
09140,MACON CENTRE,46.3382,4.84688
09118,MACON NORD,46.36589,4.83918
09119,MACON SUD,46.28308,4.79296
09241,LE MIROIR,46.54741,5.32655
05611,LA FERTE BERNARD,48.14989,0.68724
05612,CONNERRE,48.07532,0.47932
05617,LE MANS-OUEST,48.0217,0.12745
05615,LE MANS NORD,48.05045,0.17391
04780,LE MANS SUD,47.97373,0.05757
04781,SABLE LA FLECHE,47.77553,-0.20865
05169,MONTABON,47.68608,0.3714
05168,ECOMMOY,47.82037,0.30169
05153,PARIGNE L'EVEQUE,47.95569,0.32017
05151,AUVOURS,48.0047,0.31254
05131,MARESCHE,48.1967,0.16871
05133,ROUESSE FONTAINE,48.31156,0.13349
05143,ALENCON SUD,48.39788,0.1047
05819,JOUE EN CHARNIE,48.00434,-0.21729
03008,CHAMBERY NORD,45.60265,5.88708
03009,AIX SUD,45.65367,5.92169
03007,AIGUEBELETTE,45.57691,5.79935
03027,CHIGNIN BRETELLE,45.5092,6.00634
03025,CHIGNIN LES MARCHES,45.50898,6.00623
03031,MONTMELIAN,45.49492,6.05763
03032,SAINT PIERRE D'ALBIGNY,45.54852,6.1603
03033,AITON,45.55604,6.24815
03034,STE HELENE BARRIERE,45.61976,6.31206
02050,ST PIERRE DE BELLEVILLE,45.46979,6.28709
02051,STE MARIE DE CUINES,45.34596,6.30541
02052,HERMILLON,45.29886,6.35492
02053,ST JULIEN MONTDENIS,45.25162,6.39687
02054,ST MICHEL ECHANGEUR,45.21844,6.45893
02057,ST MICHEL-MODANE,45.21651,6.46586
10002,CLUSES AMONT,46.04652,6.59638
10003,CLUSES AVAL,46.04893,6.58956
10004,SCIONZIER,46.06839,6.55374
10012,BONNEVILLE OUEST,46.07345,6.38294
10158,ELOISE,46.06521,5.8639
03011,RUMILLY,45.81591,6.00686
03020,SEYNOD SUD,45.84596,6.05773
03012,ANNECY CENTRE,45.89783,6.09258
03013,ANNECY NORD,45.93882,6.11694
03014,ALLONZIER,45.98984,6.12869
03016,CRUSEILLES A 410,45.99337,6.12816
08311,ST ROMAIN SO,49.55165,0.33583
08322,ST ROMAIN SF,49.55101,0.33873
08341,BOLBEC,49.58133,0.44307
08351,FECAMP,49.63236,0.64
08361,YVETOT,49.62746,0.80626
08371,YERVILLE,49.64775,0.84262
08381,BEAUTOT,49.6353,1.05065
08391,COTTEVRARD,49.64675,1.24268
07443,AUMALE OUEST,49.75625,1.69842
07444,AUMALE EST,49.75939,1.7031
07172,ST-JEAN LES 2 JUMEAU,48.9469,3.03972
07174,MONTREUIL AUX LIONS (19),49.01316,3.1554
09178,CHATILLON-LABORDE,48.54222,2.79737
09179,ST-GERMAIN-LAXIS,48.58811,2.72483
09176,MAROLLES-SUR-SEINE,48.38015,3.02023
09177,FORGES,48.42142,2.94341
09102,URY,48.33869,2.59548
09104,NEMOURS,48.26951,2.7133
09103,FONTAINEBLEAU,48.28882,2.68315
09201,VAL DE LOING-SOUPPES,48.17632,2.76729
09403,DORDIVES,48.17139,2.76706
05198,DOURDAN,48.56902,1.99025
05302,ALLAINVILLE,48.45643,1.90827
05603,ABLIS,48.52877,1.83225
05601,LA FOLIE-B/PARIS,48.55324,1.93062
08511,CHAMBOURCY FL,48.9118,2.04672
04533,SOUDAN,46.4255,-0.08208
04534,NIORT EST,46.35524,-0.33118
04547,VOUILLE,46.30743,-0.36642
04535,NIORT-S,46.244,-0.46061
04548,NIORT NORD,46.41932,-0.39707
07010,ROYE,49.70606,2.76919
07053,GARE TGV,49.85555,2.83067
07011,PERONNE,49.87697,2.83976
07418,ESSERTAUX,49.73973,2.22877
07422,SALOUEL,49.85977,2.21381
07420,AMIENS SUD,49.85416,2.25022
07425,AMIENS OUEST,49.89176,2.23839
07426,AMIENS NORD,49.93383,2.24381
07428,FLIXECOURT,50.02951,2.06974
07431,ABBEVILLE NORD,50.13538,1.81102
07430,ABBEVILLE EST,50.09981,1.86941
07432,COTE PICARDE,50.25441,1.74658
07446,POIX-DE-PICARDIE,49.80846,1.96876
07052,VILLERS BRETONNEUX,49.85496,2.52207
07054,ATHIES,49.83871,2.98978
04402,CAUSSADE,44.14993,1.51564
04461,VALENCE D'AGEN,44.06418,0.86653
04460,CASTELSARRASIN,44.0558,1.09731
04459,MONTAUBAN,43.92898,1.31427
06003,POURRIERES,43.47756,5.75573
06004,ST.MAXIMIN,43.44938,5.87706
06007,LE MUY,43.46068,6.55084
06008,PUGET ECHANGEUR,43.45693,6.68943
06049,FREJUS OUEST,43.46935,6.72917
06010,FREJUS,43.47221,6.74342
06032,BANDOL ECH.,43.14438,5.76866
06042,PUGET VILLE,43.25791,6.12263
06046,CARNOULES,43.29345,6.20046
06006,CANNET DES MAURES,43.39342,6.35218
04209,BOLLENE,44.29026,4.75111
04217,ORANGE-N,44.16422,4.76458
04210,ORANGE,44.13527,4.79569
04218,ORANGE-S,44.11089,4.84525
04211,AVIGNON-N,43.9819,4.88828
04212,AVIGNON-S,43.89289,4.91565
04555,MONTAIGU,46.9596,-1.35294
04554,LES ESSARTS,46.79043,-1.19453
04553,CHANTONNAY,46.62728,-1.15449
04552,STE HERMINE,46.5336,-1.07897
04550,FONTENAY CENTRE,46.43595,-0.82151
04570,FONTENAY OUEST,46.46462,-0.87667
04549,NIORT OUEST,46.38784,-0.64788
04566,LA VERRIE,46.94473,-0.9765
04567,LES HERBIERS,46.90208,-1.04676
05528,CHATELLERAULT-SUD,46.77866,0.50452
05526,CHATELLERAULT-NORD,46.83697,0.53082
05530,POITIERS-NORD,46.62136,0.34394
05529,FUTUROSCOPE,46.67011,0.35987
05532,POITIERS-SUD,46.54907,0.28938
09265,ROBECOURT,48.14478,5.68904
09166,BULGNEVILLE,48.21578,5.8325
09167,CHATENOIS,48.29947,5.85293
09175,VULAINES,48.23784,3.60136
09184,ST-DENIS-LES-SENS,48.23696,3.26209
09105,COURTENAY,48.0598,3.09537
09106,JOIGNY,47.9397,3.24406
09107,AUXERRE NORD,47.85162,3.54878
09108,AUXERRE SUD,47.79676,3.65284
09109,NITRY,47.65906,3.87958
09181,VILLENEUVE-DONDAGRE,48.14968,3.17133
07002,CHANTILLY,49.08425,2.55161
07018,THUN L'EVEQUE,50.22928,3.27218
07171,COUTEVROULT,48.85356,2.83874
07140,MONTREUIL AUX LIONS,49.00953,3.14581
07718,ST AVOLD,49.1362,6.70162
09180,LES EPRUNES,48.58892,2.65679
09101,FLEURY-EN-BIERE,48.42582,2.53979
09112,POUILLY-EN-AUXOIS,47.25241,4.56116
04288,VIENNE SUD,45.47464,4.83439
04201,VIENNE,45.47693,4.83243
04220,LANCON,43.59398,5.17255
06002,LA BARQUE,43.48327,5.53839
04345,LE PERTHUS,42.52788,2.82113
04390,LE BOULOU (O),42.52367,2.81752
04541,VIRSAC,45.02368,-0.43507
08521,BUCHELAY FL,48.99097,1.64331
08611,DOZULE FL,49.22841,-0.08116
08501,MONTESSON FL,48.91429,2.15109
07413,AMBLAINVILLE,49.20513,2.1689
07439,HERQUELINGUE,50.68885,1.64206
04401,MONTAUBAN NORD,44.05229,1.41021
05177,ST CHRISTOPHE,47.63838,0.49703
12010,SEES,48.63297,0.19109
12060,ROUMOIS,49.35396,0.83522
08601,QUETTEVILLE FL,49.32057,0.31114
07451,JULES VERNE,49.85868,2.39867
09169,GYE,48.62975,5.88358
09431,FONTAINE-LARIVIERE,47.6758,6.98183
09132,SAINT MAURICE,47.4255,6.67126
10011,NANGY,46.15316,6.29518
10159,VIRY,46.12023,6.00951
06070,LA SAULCE,44.44035,6.0286
03041,LE CROZET,45.04556,5.67876
06037,AURIOL,43.36689,5.64395
04262,ARLES,43.69045,4.5449
04265,SAINT MARTIN DE CRAU,43.63771,4.85783
04355,TOULOUSE-SUD/EST,43.5449,1.50046
04468,SAINT-SELVE,44.65901,-0.45426
04457,TOULOUSE-NORD/OUEST,43.65838,1.42803
04456,TOULOUSE-NORD/EST,43.65805,1.42699
19001,BPV SAUGNAC,44.34693,-0.85998
19003,BPV CASTETS,43.83527,-1.18061
04901,BIRIATOU,43.34098,-1.74938
04622,SAMES,43.52955,-1.18678
04476,MURET,43.50526,1.35223
18001,BAZAS,44.44527,-0.24235
18002,CAPTIEUX,44.28603,-0.22806
18003,ROQUEFORT,44.04489,-0.34321
18004,MONT DE MARSAN,43.94698,-0.39392
18006,AIRE SUR L'ADOUR N,43.72216,-0.27088
18007,AIRE SUR L'ADOUR S,43.66454,-0.27668
18008,GARLIN,43.56528,-0.29261
18009,THEZE,43.47137,-0.32105
04472,TOULOUSE EST,43.64715,1.50782
09063,MONTLUCON,46.39634,2.71132
04179,VEAUCHETTE,45.56095,4.24203
13001,VIADUC DE MILLAU,44.13397,3.02535
09405,MYENNES,47.43731,2.94081
05827,LA GRAVELLE VITRE,48.08263,-1.02758
05968,RESTIGNE,47.27017,0.25391
05016,VEIGNE,47.31191,0.7353
05015,ESVRES,47.30717,0.79607
05713,VELIZY,48.7828,2.15728
05712,VAUCRESSON,48.83241,2.14747
05711,RUEIL,48.86991,2.15805
04562,BEAULIEU SUR LAYON,47.32641,-0.60428
04568,LA ROCHE SUR YON,46.67234,-1.34583
17112,Sortie SEES,48.63603,0.1844
17114,RONAI vers SEES,48.81632,-0.12945
17113,RONAI vers FALAISE,48.8164,-0.12919
17116,Sortie NECY,48.82346,-0.13664
04122,ST GERMAIN LES VERGN,45.28384,1.61737
04170,LES MARTRES ARTIERE,45.8278,3.24112
08561,BOURNEVILLE FL,49.38496,0.60381
20001,BOUVILLE,49.54825,0.92179
08541,INCARVILLE FL,49.24809,1.17938
09125,DIJON SUD,47.26023,5.03208
07152,REIMS OUEST(THILLOIS,49.24947,3.95591
09239,BERSAILLIN,46.84932,5.57458
09165,GROISSIAT,46.22366,5.6142
09065,GANNAT,46.0981,3.13554
09465,VICHY,46.13841,3.3513
04544,CABARIOT,45.94391,-0.84935
08211,PONT DE TANCARVILLE,49.46365,0.47404
04801,PYRENEES ORIENTALES,42.53958,1.82401
08222,PONT DE NORMANDIE,49.45001,0.2717
06021,ST.ISIDORE ECH. EST,43.70655,7.19071
06028,LAGHET,43.7454,7.37858
06055,MEYRARGUES,43.66118,5.50356
06056,PERTUIS NORD,43.66162,5.5034
06039,BELCODENE,43.41783,5.5752
08593,BEUZEVILLE FL ECH CAEN,49.33906,0.36807
05247,ANCENIS/ANGERS,47.39936,-1.19277
05273,VIEILLEVILLE,47.29114,-1.4804
07722,HOCHFELDEN,48.76874,7.60218
03010,AIX NORD,45.71614,5.92225
10014,BONNEVILLE EST,46.07018,6.42445
08321,EPRETOT BPV,49.55172,0.3355
06005,BRIGNOLES,43.4191,6.06505
06011,LES ADRETS,43.54408,6.81245
07001,ROISSY CDG,49.21556,2.62796
07706,SCHWINDRATZHEIM,48.76953,7.60203
09122,VILLEFRANCHE-LIMAS,45.97344,4.73193
06009,CAPITOU,43.46857,6.72924
06012,ANTIBES P/V,43.60255,7.0783
06027,LA TURBIE P/V,43.74367,7.37827
06020,ST.ISIDORE P/V,43.70752,7.19138
05270,ANCENIS BARRIERE,47.40069,-1.19587
08531,HEUDEBOUVILLE FL,49.19512,1.23011
08591,BEUZEVILLE FL,49.33815,0.3678
08533,HEUDEBOUVILLE FL ECH CAEN,49.19667,1.22974
04407,GIGNAC,44.99076,1.52646
07441,HAUDRICOURT,49.75948,1.70294
10001,CLUSES,46.04695,6.5966
03026,CHIGNIN BARRIERE,45.51256,6.00209
02056,ST MICHEL BARRIERE,45.21869,6.45903
03001,ST QUENTIN FAL. BARRIERE,45.64866,5.12021
06033,LA CIOTAT P/V,43.20485,5.59054
06031,BANDOL P/V,43.14628,5.77188
04354,TOULOUSE-SUD/OUEST,43.54469,1.50019
04904,LA NEGRESSE,43.44839,-1.55338
09079,CLERMONT-BARRIERE,45.84133,3.16053
17115,Entrée NECY,48.8235,-0.13463
04182,ST ROMAIN POPEY,45.87177,4.50891
07150,REIMS NORD,49.24633,3.96251
03015,ST MARTIN BELLEVUE A410,45.98981,6.12837
1 id_gare nom lat lon
2 09242 BEAUPONT 46.44094 5.26993
3 10157 BELLEGARDE 46.11602 5.79506
4 09147 SYLANS 46.15853 5.6484
5 09146 ST MARTIN-DU-FRESNE 46.13243 5.54164
6 09156 LA CROIX-CHALON 46.17772 5.5589
7 09145 BOURG SUD 46.14563 5.28633
8 09144 VIRIAT 46.23576 5.27355
9 09149 SAINT GENIS 46.27197 5.01576
10 09143 BOURG NORD 46.2599 5.16695
11 09142 REPLONGES 46.30076 4.89584
12 09141 FEILLENS 46.32883 4.88241
13 09150 BEYNOST 45.82045 4.99411
14 09153 LA BOISSE 45.82704 5.03146
15 09253 LA COTIERE 45.83409 5.0324
16 09251 LA BOISSE - MONTLUEL 45.8407 5.04702
17 09422 MIONNAY 45.883 4.89889
18 09151 BALAN 45.85092 5.09938
19 09152 PEROUGES 45.86086 5.1813
20 09154 AMBERIEU 45.97765 5.3127
21 09155 PONT D AIN 46.04812 5.32788
22 09443 CROTTET 46.28678 4.87001
23 07144 CHATEAU-THIERRY 49.07832 3.39881
24 07141 MONTREUIL 49.01321 3.15531
25 07146 DORMANS 49.15379 3.71689
26 07032 ST QUENTIN SUD 49.81227 3.29479
27 07031 ST QUENTIN NORD 49.86139 3.24072
28 07033 LA FERE 49.6872 3.46533
29 07034 LAON 49.6078 3.66102
30 07136 REIMS 49.32216 3.98417
31 07135 GUIGNICOURT 49.42699 3.92801
32 09062 FORET DE TRONCAIS 46.50647 2.63033
33 09064 MONTMARAULT 46.32764 2.96645
34 06060 MANOSQUE 43.80781 5.81289
35 06059 ST PAUL LES DURANCE 43.70249 5.7346
36 06061 LA BRILLANNE 43.92395 5.89373
37 06063 PEYRUIS 44.03919 5.96354
38 06065 AUBIGNOSC 44.12443 5.98428
39 06064 AUBIGNOSC OUEST 44.12513 5.97854
40 06067 SISTERON SUD 44.17168 5.95542
41 06068 SISTERON NORD 44.2236 5.91579
42 06014 ANTIBES OUEST 43.6028 7.07248
43 06024 SOPHIA 43.60275 7.08124
44 06022 ANTIBES PV NORD 43.60463 7.06645
45 06023 ANTIBES EST SORTIE 43.60421 7.08636
46 06017 CAGNES OUEST SUD 43.64321 7.13404
47 06016 CAGNES EST 43.65933 7.14863
48 06015 CAGNES OUEST 43.64804 7.13906
49 06013 ANTIBES EST 43.60301 7.08397
50 06019 ST ISIDORE ECH OUEST 43.70408 7.19005
51 06025 MONACO 43.74513 7.37264
52 06026 LA TURBIE ECH. 43.74346 7.37835
53 04358 PAMIERS 43.15415 1.61603
54 04357 MAZERES SAVERDUN 43.23448 1.63595
55 09173 SAINT THIBAULT 48.22341 4.12962
56 09174 TORVILLIERS 48.2844 3.96602
57 09183 THENNELIERES 48.28933 4.16302
58 09172 MAGNANT 48.17907 4.44056
59 09171 VILLE SOUS LAFERTE 48.11496 4.78555
60 09170 CHAUMONT-SEMOUTIERS 48.04283 5.05935
61 07186 VALLEE DE L'AUBE 48.52551 4.18556
62 07185 CHARMONT-S/BARBUISE 48.41039 4.14267
63 04339 NARBONNE-SUD 43.16443 2.99012
64 04338 NARBONNE-EST 43.17904 3.03496
65 04340 SIGEAN 43.03314 2.95619
66 04341 LEUCATE 42.93912 2.97304
67 04350 CASTELNAUDARY 43.29079 1.94671
68 04349 BRAM 43.23897 2.10001
69 04348 CARCASSONNE-O 43.19993 2.31004
70 04347 CARCASSONNE-E 43.20205 2.4182
71 04346 LEZIGNAN 43.17187 2.74107
72 04213 CAVAILLON 43.81988 5.02777
73 04214 SENAS 43.74201 5.08971
74 04215 SALON NORD 43.65943 5.10259
75 04279 A8: AIX-EN-PROVENCE 43.55106 5.23769
76 04278 COUDOUX 43.55294 5.23863
77 06001 CANET DE MEYREUIL 43.49465 5.52496
78 06035 CASSIS 43.22517 5.58151
79 06034 LA CIOTAT ECH. 43.20208 5.59476
80 06054 PERTUIS SUD 43.66218 5.49758
81 06038 PAS DE TRETS 43.3863 5.6018
82 06036 PONT DE L ETOILE 43.32437 5.59801
83 04267 GRANS 43.62304 5.08354
84 04266 SALON OUEST 43.6364 5.02274
85 04219 SALON SUD 43.62598 5.1015
86 08612 DOZULE FL ECH 49.22992 -0.08247
87 08621 TROARN FL 49.1778 -0.20296
88 08631 CAGNY FL 49.16833 -0.24512
89 04536 ST-JEAN D'ANGELY 45.96286 -0.54589
90 04537 SAINTES 45.75224 -0.66399
91 04538 PONS 45.5745 -0.59558
92 04540 SAINT-AUBIN 45.25434 -0.5478
93 04539 MIRAMBEAU 45.37766 -0.57955
94 04545 TONNAY-CHARENTE 45.95515 -0.88097
95 05056 VIERZON-EST 47.2118 2.11835
96 05055 VIERZON-NORD 47.24397 2.06412
97 05057 BOURGES 47.04492 2.34171
98 09061 ST AMAND-MONTROND 46.722 2.45831
99 04118 MANSAC 45.15247 1.37821
100 04123 TULLE NORD 45.32901 1.76396
101 04124 TULLE EST 45.31812 1.85046
102 04125 EGLETONS 45.40739 2.02119
103 04126 USSEL OUEST 45.51246 2.25888
104 04127 USSEL EST 45.58897 2.41459
105 09111 BIERRE-LES-SEMUR 47.43663 4.30944
106 09110 AVALLON 47.50932 3.99198
107 09114 BEAUNE SUD 47.0024 4.85476
108 09129 BEAUNE NORD 47.04201 4.85075
109 09124 NUITS-ST-GEORGES 47.13099 4.96743
110 09160 DIJON-ARC S/TILLE 47.34461 5.15933
111 09126 DIJON-CRIMOLOIS 47.27707 5.13804
112 09161 TIL CHATEL 47.53859 5.19472
113 09139 SEURRE 47.02937 5.17033
114 09130 SOIRANS 47.20534 5.305
115 04104 MONTPON 44.98597 0.15622
116 04105 MUSSIDAN SUD 45.01073 0.37757
117 04107 MUSSIDAN EST 45.06515 0.43001
118 04106 MUSSIDAN BARRIERE 45.06529 0.42986
119 04114 THENON EST 45.15161 1.16785
120 04112 THENON 45.15124 1.16747
121 04116 LA BACHELLERIE SUD 45.14813 1.17472
122 09128 L ISLE-S/LE-DOUBS 47.41251 6.58553
123 09133 BAUME-LES-DAMES 47.37102 6.37965
124 09131 BESANCON EST 47.33224 6.1513
125 09134 BESANCON NORD 47.27603 5.98798
126 09135 BESANCON OUEST 47.23478 5.89813
127 04204 VALENCE-N 44.97018 4.88768
128 04203 TAIN 45.06844 4.8685
129 04205 VALENCE-S 44.90511 4.88009
130 04206 LORIOL 44.75681 4.79136
131 04207 MONTELIMAR-N 44.66896 4.79483
132 04208 MONTELIMAR-S 44.48123 4.76353
133 03092 LA BAUME D'HOSTUN 45.06467 5.20806
134 03091 CHATUZANGE BARRIERE 45.02608 5.0969
135 08532 HEUDEBOUVILLE FL ECH PARIS 49.19492 1.23192
136 08551 BOURG ACHARD FL ECH 49.36642 0.81813
137 08571 BOURNEVILLE FL ECH 49.37708 0.62672
138 08581 TOUTAINVILLE FL ECH 49.36423 0.46766
139 08592 BEUZEVILLE FL ECH PARIS 49.33757 0.36939
140 12030 BROGLIE/ORBEC SENS 2 49.03165 0.44483
141 12031 BROGLIE/ORBEC SENS 1 49.03719 0.4416
142 12040 BERNAY 49.13768 0.57809
143 12050 BRIONNE 49.24206 0.77249
144 05306 ARTENAY 48.0843 1.8557
145 05304 ALLAINES 48.20533 1.84601
146 05605 CHARTRES-EST 48.45632 1.53784
147 05607 THIVARS 48.36159 1.44648
148 05609 LUIGNY 48.23459 1.03313
149 05608 ILLIERS-COMBRAY 48.29444 1.27031
150 04222 REMOULINS 43.93725 4.5982
151 04221 ROQUEMAURE 44.02563 4.73546
152 04224 NIMES-O 43.81363 4.34275
153 04223 NIMES-E 43.85615 4.42069
154 04275 LUNEL 43.70284 4.11962
155 04225 GALLARGUES 43.7229 4.18087
156 04260 NIMES CENTRE 43.80757 4.37448
157 04261 GARONS 43.76132 4.42753
158 04356 NAILLOUX 43.38237 1.61688
159 04352 MONTGISCARD 43.46088 1.58831
160 04351 VILLEFRANCHE 43.39858 1.6962
161 04458 SAINT-JORY 43.71824 1.39796
162 04455 EUROCENTRE 43.76503 1.38044
163 04646 MONTREJEAU 43.10017 0.59546
164 04644 LANNEMEZAN 43.09782 0.39023
165 04648 ST GAUDENS 43.11568 0.7566
166 04650 LESTELLE 43.12029 0.89548
167 04651 LESTELLE ST MARTORY 43.11773 0.89297
168 04470 L'UNION 43.64518 1.49797
169 04467 PODENSAC 44.60754 -0.36681
170 04466 LANGON 44.54422 -0.26201
171 04465 LA REOLE 44.51137 -0.04954
172 04464 MARMANDE 44.43479 0.13293
173 04101 ARVEYRES 44.88514 -0.26839
174 04102 LIBOURNE NORD 44.95703 -0.24522
175 04103 COUTRAS 45.012 -0.09983
176 04276 BAILLARGUES 43.67053 4.01301
177 04333 SETE 43.47755 3.68525
178 04331 MONTPELLIER ST-JEAN 43.56153 3.8305
179 04335 AGDE-PEZENAS 43.37414 3.41816
180 04334 BEZIERS CABRIALS 43.3433 3.28907
181 04337 BEZIERS-OUEST 43.30445 3.2191
182 05318 AMBOISE CH.RENAULT 47.54281 0.98489
183 05320 TOURS-C/MONNAIE 47.49058 0.81927
184 05486 TOURS-NORD 47.45219 0.73926
185 05490 CHAMBRAY 47.34906 0.70388
186 05485 LA THIBAUDIERE 47.33078 0.68772
187 05484 MONTS - SORIGNY 47.25456 0.67091
188 05524 SAINTE MAURE 47.10728 0.58766
189 05522 TOURS-C/SORIGNY 47.21845 0.65771
190 05478 NEUILLE PONT PIERRE 47.55541 0.59565
191 05967 BOURGUEIL 47.25319 0.16665
192 05960 VIVY 47.31056 -0.03177
193 05014 BLERE 47.28654 0.98447
194 04216 AUBERIVES 45.3898 4.80535
195 04202 CHANAS 45.32115 4.80972
196 03022 CROLLES BRIGNOUD 45.2716 5.90105
197 03023 LE TOUVET 45.34745 5.96371
198 03021 CROLLES BARRIERE 45.27169 5.90089
199 03024 PONTCHARRA 45.42579 5.99511
200 03071 CHESNES 45.65572 5.09788
201 03002 ST QUENTIN FAL. BRETELLE 45.64785 5.11979
202 03062 VILLEFONTAINE 45.62843 5.16432
203 03003 ISLE D'ABEAU CENTRE 45.60516 5.2344
204 03004 BOURGOIN 45.58235 5.30027
205 03005 LA TOUR DU PIN 45.56194 5.42886
206 03072 LA TOUR DU PIN EST 45.55669 5.46452
207 03006 LES ABRETS 45.57134 5.60416
208 03061 SAINT GENIX SUR GUIERS 45.57277 5.65913
209 03085 RIVES 45.38415 5.47313
210 03086 VOIRON 45.34763 5.56633
211 03083 MOIRANS NORD 45.32415 5.60512
212 03087 VOREPPE BARRIERE 45.28323 5.622
213 03084 MOIRANS 45.32035 5.60783
214 03095 TULLINS 45.287 5.52175
215 03093 SAINT MARCELLIN 45.13824 5.32621
216 03094 VINAY 45.19954 5.41944
217 09136 GENDREY 47.18353 5.70894
218 09137 DOLE 47.1361 5.50619
219 09138 CHOISEY 47.06457 5.44674
220 09238 ARLAY 46.77782 5.51859
221 09240 BEAUREPAIRE 46.66637 5.41894
222 04907 BENESSE 43.62393 -1.40031
223 04906 CAPBRETON 43.63235 -1.39224
224 04908 ONDRES 43.54151 -1.4356
225 04624 PEYREHORADE 43.51704 -1.10384
226 04687 SALIES 43.5098 -0.92187
227 05314 MER 47.72856 1.50862
228 05312 MEUNG SUR LOIRE 47.8328 1.669
229 05316 BLOIS 47.62149 1.34635
230 05053 LAMOTTE-BEUVRON 47.58173 1.99103
231 05054 SALBRIS 47.41849 2.0257
232 05013 ST ROMAIN SUR CHER 47.30652 1.35312
233 05011 CHEMERY 47.32467 1.50014
234 05010 VILLEFRANCHE S/ CHER 47.32452 1.76561
235 04178 MONTBRISON 45.63736 4.19611
236 04177 FEURS 45.73754 4.18636
237 04174 NOIRETABLE 45.84935 3.79423
238 04175 ST GERMAIN L. 45.86092 4.04202
239 04180 BALBIGNY 45.84111 4.16441
240 05246 ANCENIS/NANTES 47.40254 -1.19352
241 05245 ANGERS/ANCENIS 47.40243 -1.1935
242 05248 NANTES/ANCENIS 47.39927 -1.19276
243 04556 AIGREFEUILLE 47.0693 -1.43729
244 04557 BIGNON 47.11481 -1.49175
245 05309 GIDY 47.96698 1.85157
246 05308 ORLEANS-NORD 47.94948 1.85462
247 14380 SAVIGNY /CLAIRIS 48.05598 3.08802
248 14375 ST HILAIRE 48.03677 3.02262
249 14365 GONDREVILLE A77/S 48.06084 2.66766
250 14370 FONTENAY /LOING 48.0642 2.76711
251 14360 GONDREVILLE A77/N 48.0608 2.66765
252 14355 AUXY 48.08539 2.47274
253 14350 ESCRENNES 48.11633 2.19111
254 05052 OLIVET 47.84093 1.86936
255 05050 ORLEANS-CENTRE 47.89819 1.85323
256 09404 LE TOURNEAU 47.99293 2.67768
257 04408 MARTEL 44.99329 1.53144
258 04406 SOUILLAC 44.90066 1.50424
259 04405 LABASTIDE MURAT 44.69812 1.58057
260 04404 CAHORS NORD 44.53225 1.50626
261 04403 CAHORS SUD 44.34273 1.49406
262 04463 AIGUILLON 44.28452 0.26986
263 04469 Agen Ouest 44.1875 0.54602
264 04462 AGEN 44.16498 0.60573
265 04783 SEICHES 47.56645 -0.32803
266 04782 DURTAL 47.66779 -0.25964
267 04784 CORZE 47.53933 -0.3408
268 05280 ST JEAN DE LINIERES 47.46658 -0.68606
269 05274 SAINT GERMAIN 47.43168 -0.81633
270 05958 BEAUFORT 47.46812 -0.19348
271 05959 LONGUE 47.40448 -0.11597
272 04561 THOUARCE 47.3295 -0.59635
273 04563 CHEMILLE 47.23593 -0.72764
274 04564 CHOLET NORD 47.08333 -0.82795
275 04565 CHOLET SUD 47.01894 -0.88073
276 07190 REIMS EST 49.21144 4.07834
277 07154 REIMS SUD 49.20521 4.00643
278 07191 CHALONS LA VEUVE 49.04479 4.32266
279 07189 ST GIBRIEN 48.97356 4.28868
280 07192 CHALONS MOURMELON 49.04094 4.32093
281 07193 ST ETIENNE AU TEMPLE 49.03349 4.43586
282 07194 STE MENEHOULD 49.0764 4.88366
283 07616 CLERMONT EN ARGONNE 49.09443 5.10176
284 07137 LA NEUVILLETTE 49.29793 3.99801
285 07188 MONT CHOISY 48.91073 4.28809
286 07187 SOMMESOUS 48.73061 4.22984
287 07633 VATRY 48.76782 4.24012
288 09162 LANGRES SUD 47.79236 5.22621
289 09163 LANGRES NORD 47.93512 5.28682
290 09164 MONTIGNY-LE-ROI 47.99736 5.51194
291 05821 VAIGES 48.05551 -0.48728
292 05823 LAVAL-EST 48.10762 -0.73857
293 05825 LAVAL-OUEST 48.10462 -0.83436
294 07198 JARNY 49.19991 5.90167
295 07199 BEAUMONT 49.19882 5.92401
296 09168 COLOMBEY-LES-BELLES 48.54066 5.90884
297 07195 VOIE SACREE 49.09382 5.27836
298 07196 VERDUN 49.11096 5.41385
299 07197 FRESNES EN WOEVRE 49.12909 5.62133
300 07177 STE-MARIE 49.19333 5.98902
301 07716 BOULAY 49.1411 6.46657
302 07719 ST AVOLD 49.13582 6.71291
303 07721 FAREBERSVILLER 49.10816 6.85335
304 07707 PUTTELANGE 49.06986 6.91814
305 07701 LOUPERSHOUSE 49.07553 6.89666
306 07702 SARREGUEMINES 49.04476 7.02883
307 07704 PHALSBOURG 48.77206 7.24069
308 07705 SAVERNE 48.76172 7.38999
309 07029 MARQUION 50.20249 3.10689
310 07017 CAMBRAI 50.17563 3.19272
311 07030 MASNIERES 50.06855 3.17244
312 07005 SENLIS BONSECOURS 49.20703 2.60921
313 07006 SENLIS 49.2154 2.62813
314 07007 PONT STE MAXENCE 49.32069 2.69478
315 07008 COMPIEGNE OUEST 49.39898 2.69922
316 07009 RESSONS 49.52169 2.71574
317 07414 MERU 49.21073 2.15071
318 07415 BEAUVAIS CENTRE 49.39922 2.12564
319 07416 BEAUVAIS NORD 49.43377 2.12615
320 07417 HARDIVILLERS 49.60999 2.20284
321 05142 ALENCON NORD 48.45298 0.12411
322 17111 Entrée SEES 48.63813 0.18495
323 12210 ARGENTAN 48.63284 0.1909
324 12020 GACE SENS 2 48.77175 0.30896
325 12021 GACE SENS 1 48.77694 0.30347
326 07012 ALBERT 49.96568 2.86338
327 07013 BAPAUME 50.1042 2.8679
328 07014 ARRAS 50.2694 2.86387
329 07434 BERCK 50.41161 1.6899
330 07436 ETAPLES-LE TOUQUET 50.50672 1.68025
331 07437 NEUFCHATEL HARDELOT 50.61112 1.64983
332 07438 BOULOGNE SUD 50.67396 1.65981
333 07021 VALLEE DE LA HEM 50.82097 2.06379
334 07022 ST-OMER B 50.7229 2.16749
335 07020 CALAIS 50.71905 2.17249
336 07024 AIRE SUR LA LYS 50.66821 2.25608
337 07023 ST-OMER 50.71935 2.1729
338 07025 LILLERS 50.55304 2.46281
339 07026 BETHUNE 50.514 2.61776
340 07060 NOEUX LES MINES 50.4869 2.68389
341 07027 LIEVIN 50.43782 2.70728
342 07015 DOURGES 50.32504 2.9107
343 07028 ARRAS 50.34517 2.7875
344 09076 COMBRONDE 45.99599 3.10472
345 09077 RIOM 45.89546 3.14816
346 09078 GERZAT-VILLE 45.84223 3.15962
347 04128 ST JULIEN SANCY 45.66634 2.68254
348 04129 VULCANIA BROMONT 45.8339 2.82261
349 04130 MANZAT 45.95393 2.98285
350 04171 LEZOUX 45.84864 3.38363
351 04172 THIERS-OUEST 45.86003 3.50381
352 04173 THIERS-EST 45.87882 3.62491
353 04905 BAYONNE SUD 43.4623 -1.49849
354 04903 BIARRITZ 43.45046 -1.55445
355 04982 ST JEAN DE LUZ NORD 43.37193 -1.67494
356 04902 ST JEAN DE LUZ SUD 43.37196 -1.67775
357 04620 GUICHE 43.51222 -1.22266
358 04689 ORTHEZ 43.46716 -0.74861
359 04691 LESCAR 43.34524 -0.4188
360 04690 ARTIX 43.39508 -0.55123
361 04692 PAU CENTRE 43.33051 -0.35045
362 04695 TARBES OUEST 43.22081 0.02358
363 04638 TARBES EST 43.21372 0.10822
364 04640 TOURNAY 43.17743 0.23992
365 04642 CAPVERN 43.10327 0.34273
366 04342 PERPIGNAN-NORD 42.7818 2.89739
367 04343 PERPIGNAN-SUD 42.66674 2.85891
368 04344 LE BOULOU 42.52366 2.81767
369 07703 SARRE UNION 48.91392 7.12449
370 07708 HOCHFELDEN OUEST 48.76926 7.60208
371 09221 VILLEFRANCHE-NORD 46.02229 4.72208
372 09120 BELLEVILLE S/SAONE 46.1038 4.75047
373 09121 VILLEFRANCHE-VILLE 45.97728 4.73366
374 04268 CONDRIEU ENTREE 45.50581 4.84228
375 04269 CONDRIEU SORTIE 45.50521 4.83904
376 09421 GENAY 45.90029 4.81942
377 04181 TARARE OUEST 45.89156 4.4031
378 04183 TARARE EST F 45.87151 4.50903
379 04184 TARARE EST ENTREE O 45.87624 4.52202
380 09115 CHALON CENTRE 46.80244 4.82981
381 09116 CHALON SUD 46.75343 4.83299
382 09117 TOURNUS 46.57911 4.90124
383 09140 MACON CENTRE 46.3382 4.84688
384 09118 MACON NORD 46.36589 4.83918
385 09119 MACON SUD 46.28308 4.79296
386 09241 LE MIROIR 46.54741 5.32655
387 05611 LA FERTE BERNARD 48.14989 0.68724
388 05612 CONNERRE 48.07532 0.47932
389 05617 LE MANS-OUEST 48.0217 0.12745
390 05615 LE MANS NORD 48.05045 0.17391
391 04780 LE MANS SUD 47.97373 0.05757
392 04781 SABLE LA FLECHE 47.77553 -0.20865
393 05169 MONTABON 47.68608 0.3714
394 05168 ECOMMOY 47.82037 0.30169
395 05153 PARIGNE L'EVEQUE 47.95569 0.32017
396 05151 AUVOURS 48.0047 0.31254
397 05131 MARESCHE 48.1967 0.16871
398 05133 ROUESSE FONTAINE 48.31156 0.13349
399 05143 ALENCON SUD 48.39788 0.1047
400 05819 JOUE EN CHARNIE 48.00434 -0.21729
401 03008 CHAMBERY NORD 45.60265 5.88708
402 03009 AIX SUD 45.65367 5.92169
403 03007 AIGUEBELETTE 45.57691 5.79935
404 03027 CHIGNIN BRETELLE 45.5092 6.00634
405 03025 CHIGNIN LES MARCHES 45.50898 6.00623
406 03031 MONTMELIAN 45.49492 6.05763
407 03032 SAINT PIERRE D'ALBIGNY 45.54852 6.1603
408 03033 AITON 45.55604 6.24815
409 03034 STE HELENE BARRIERE 45.61976 6.31206
410 02050 ST PIERRE DE BELLEVILLE 45.46979 6.28709
411 02051 STE MARIE DE CUINES 45.34596 6.30541
412 02052 HERMILLON 45.29886 6.35492
413 02053 ST JULIEN MONTDENIS 45.25162 6.39687
414 02054 ST MICHEL ECHANGEUR 45.21844 6.45893
415 02057 ST MICHEL-MODANE 45.21651 6.46586
416 10002 CLUSES AMONT 46.04652 6.59638
417 10003 CLUSES AVAL 46.04893 6.58956
418 10004 SCIONZIER 46.06839 6.55374
419 10012 BONNEVILLE OUEST 46.07345 6.38294
420 10158 ELOISE 46.06521 5.8639
421 03011 RUMILLY 45.81591 6.00686
422 03020 SEYNOD SUD 45.84596 6.05773
423 03012 ANNECY CENTRE 45.89783 6.09258
424 03013 ANNECY NORD 45.93882 6.11694
425 03014 ALLONZIER 45.98984 6.12869
426 03016 CRUSEILLES A 410 45.99337 6.12816
427 08311 ST ROMAIN SO 49.55165 0.33583
428 08322 ST ROMAIN SF 49.55101 0.33873
429 08341 BOLBEC 49.58133 0.44307
430 08351 FECAMP 49.63236 0.64
431 08361 YVETOT 49.62746 0.80626
432 08371 YERVILLE 49.64775 0.84262
433 08381 BEAUTOT 49.6353 1.05065
434 08391 COTTEVRARD 49.64675 1.24268
435 07443 AUMALE OUEST 49.75625 1.69842
436 07444 AUMALE EST 49.75939 1.7031
437 07172 ST-JEAN LES 2 JUMEAU 48.9469 3.03972
438 07174 MONTREUIL AUX LIONS (19) 49.01316 3.1554
439 09178 CHATILLON-LABORDE 48.54222 2.79737
440 09179 ST-GERMAIN-LAXIS 48.58811 2.72483
441 09176 MAROLLES-SUR-SEINE 48.38015 3.02023
442 09177 FORGES 48.42142 2.94341
443 09102 URY 48.33869 2.59548
444 09104 NEMOURS 48.26951 2.7133
445 09103 FONTAINEBLEAU 48.28882 2.68315
446 09201 VAL DE LOING-SOUPPES 48.17632 2.76729
447 09403 DORDIVES 48.17139 2.76706
448 05198 DOURDAN 48.56902 1.99025
449 05302 ALLAINVILLE 48.45643 1.90827
450 05603 ABLIS 48.52877 1.83225
451 05601 LA FOLIE-B/PARIS 48.55324 1.93062
452 08511 CHAMBOURCY FL 48.9118 2.04672
453 04533 SOUDAN 46.4255 -0.08208
454 04534 NIORT EST 46.35524 -0.33118
455 04547 VOUILLE 46.30743 -0.36642
456 04535 NIORT-S 46.244 -0.46061
457 04548 NIORT NORD 46.41932 -0.39707
458 07010 ROYE 49.70606 2.76919
459 07053 GARE TGV 49.85555 2.83067
460 07011 PERONNE 49.87697 2.83976
461 07418 ESSERTAUX 49.73973 2.22877
462 07422 SALOUEL 49.85977 2.21381
463 07420 AMIENS SUD 49.85416 2.25022
464 07425 AMIENS OUEST 49.89176 2.23839
465 07426 AMIENS NORD 49.93383 2.24381
466 07428 FLIXECOURT 50.02951 2.06974
467 07431 ABBEVILLE NORD 50.13538 1.81102
468 07430 ABBEVILLE EST 50.09981 1.86941
469 07432 COTE PICARDE 50.25441 1.74658
470 07446 POIX-DE-PICARDIE 49.80846 1.96876
471 07052 VILLERS BRETONNEUX 49.85496 2.52207
472 07054 ATHIES 49.83871 2.98978
473 04402 CAUSSADE 44.14993 1.51564
474 04461 VALENCE D'AGEN 44.06418 0.86653
475 04460 CASTELSARRASIN 44.0558 1.09731
476 04459 MONTAUBAN 43.92898 1.31427
477 06003 POURRIERES 43.47756 5.75573
478 06004 ST.MAXIMIN 43.44938 5.87706
479 06007 LE MUY 43.46068 6.55084
480 06008 PUGET ECHANGEUR 43.45693 6.68943
481 06049 FREJUS OUEST 43.46935 6.72917
482 06010 FREJUS 43.47221 6.74342
483 06032 BANDOL ECH. 43.14438 5.76866
484 06042 PUGET VILLE 43.25791 6.12263
485 06046 CARNOULES 43.29345 6.20046
486 06006 CANNET DES MAURES 43.39342 6.35218
487 04209 BOLLENE 44.29026 4.75111
488 04217 ORANGE-N 44.16422 4.76458
489 04210 ORANGE 44.13527 4.79569
490 04218 ORANGE-S 44.11089 4.84525
491 04211 AVIGNON-N 43.9819 4.88828
492 04212 AVIGNON-S 43.89289 4.91565
493 04555 MONTAIGU 46.9596 -1.35294
494 04554 LES ESSARTS 46.79043 -1.19453
495 04553 CHANTONNAY 46.62728 -1.15449
496 04552 STE HERMINE 46.5336 -1.07897
497 04550 FONTENAY CENTRE 46.43595 -0.82151
498 04570 FONTENAY OUEST 46.46462 -0.87667
499 04549 NIORT OUEST 46.38784 -0.64788
500 04566 LA VERRIE 46.94473 -0.9765
501 04567 LES HERBIERS 46.90208 -1.04676
502 05528 CHATELLERAULT-SUD 46.77866 0.50452
503 05526 CHATELLERAULT-NORD 46.83697 0.53082
504 05530 POITIERS-NORD 46.62136 0.34394
505 05529 FUTUROSCOPE 46.67011 0.35987
506 05532 POITIERS-SUD 46.54907 0.28938
507 09265 ROBECOURT 48.14478 5.68904
508 09166 BULGNEVILLE 48.21578 5.8325
509 09167 CHATENOIS 48.29947 5.85293
510 09175 VULAINES 48.23784 3.60136
511 09184 ST-DENIS-LES-SENS 48.23696 3.26209
512 09105 COURTENAY 48.0598 3.09537
513 09106 JOIGNY 47.9397 3.24406
514 09107 AUXERRE NORD 47.85162 3.54878
515 09108 AUXERRE SUD 47.79676 3.65284
516 09109 NITRY 47.65906 3.87958
517 09181 VILLENEUVE-DONDAGRE 48.14968 3.17133
518 07002 CHANTILLY 49.08425 2.55161
519 07018 THUN L'EVEQUE 50.22928 3.27218
520 07171 COUTEVROULT 48.85356 2.83874
521 07140 MONTREUIL AUX LIONS 49.00953 3.14581
522 07718 ST AVOLD 49.1362 6.70162
523 09180 LES EPRUNES 48.58892 2.65679
524 09101 FLEURY-EN-BIERE 48.42582 2.53979
525 09112 POUILLY-EN-AUXOIS 47.25241 4.56116
526 04288 VIENNE SUD 45.47464 4.83439
527 04201 VIENNE 45.47693 4.83243
528 04220 LANCON 43.59398 5.17255
529 06002 LA BARQUE 43.48327 5.53839
530 04345 LE PERTHUS 42.52788 2.82113
531 04390 LE BOULOU (O) 42.52367 2.81752
532 04541 VIRSAC 45.02368 -0.43507
533 08521 BUCHELAY FL 48.99097 1.64331
534 08611 DOZULE FL 49.22841 -0.08116
535 08501 MONTESSON FL 48.91429 2.15109
536 07413 AMBLAINVILLE 49.20513 2.1689
537 07439 HERQUELINGUE 50.68885 1.64206
538 04401 MONTAUBAN NORD 44.05229 1.41021
539 05177 ST CHRISTOPHE 47.63838 0.49703
540 12010 SEES 48.63297 0.19109
541 12060 ROUMOIS 49.35396 0.83522
542 08601 QUETTEVILLE FL 49.32057 0.31114
543 07451 JULES VERNE 49.85868 2.39867
544 09169 GYE 48.62975 5.88358
545 09431 FONTAINE-LARIVIERE 47.6758 6.98183
546 09132 SAINT MAURICE 47.4255 6.67126
547 10011 NANGY 46.15316 6.29518
548 10159 VIRY 46.12023 6.00951
549 06070 LA SAULCE 44.44035 6.0286
550 03041 LE CROZET 45.04556 5.67876
551 06037 AURIOL 43.36689 5.64395
552 04262 ARLES 43.69045 4.5449
553 04265 SAINT MARTIN DE CRAU 43.63771 4.85783
554 04355 TOULOUSE-SUD/EST 43.5449 1.50046
555 04468 SAINT-SELVE 44.65901 -0.45426
556 04457 TOULOUSE-NORD/OUEST 43.65838 1.42803
557 04456 TOULOUSE-NORD/EST 43.65805 1.42699
558 19001 BPV SAUGNAC 44.34693 -0.85998
559 19003 BPV CASTETS 43.83527 -1.18061
560 04901 BIRIATOU 43.34098 -1.74938
561 04622 SAMES 43.52955 -1.18678
562 04476 MURET 43.50526 1.35223
563 18001 BAZAS 44.44527 -0.24235
564 18002 CAPTIEUX 44.28603 -0.22806
565 18003 ROQUEFORT 44.04489 -0.34321
566 18004 MONT DE MARSAN 43.94698 -0.39392
567 18006 AIRE SUR L'ADOUR N 43.72216 -0.27088
568 18007 AIRE SUR L'ADOUR S 43.66454 -0.27668
569 18008 GARLIN 43.56528 -0.29261
570 18009 THEZE 43.47137 -0.32105
571 04472 TOULOUSE EST 43.64715 1.50782
572 09063 MONTLUCON 46.39634 2.71132
573 04179 VEAUCHETTE 45.56095 4.24203
574 13001 VIADUC DE MILLAU 44.13397 3.02535
575 09405 MYENNES 47.43731 2.94081
576 05827 LA GRAVELLE VITRE 48.08263 -1.02758
577 05968 RESTIGNE 47.27017 0.25391
578 05016 VEIGNE 47.31191 0.7353
579 05015 ESVRES 47.30717 0.79607
580 05713 VELIZY 48.7828 2.15728
581 05712 VAUCRESSON 48.83241 2.14747
582 05711 RUEIL 48.86991 2.15805
583 04562 BEAULIEU SUR LAYON 47.32641 -0.60428
584 04568 LA ROCHE SUR YON 46.67234 -1.34583
585 17112 Sortie SEES 48.63603 0.1844
586 17114 RONAI vers SEES 48.81632 -0.12945
587 17113 RONAI vers FALAISE 48.8164 -0.12919
588 17116 Sortie NECY 48.82346 -0.13664
589 04122 ST GERMAIN LES VERGN 45.28384 1.61737
590 04170 LES MARTRES ARTIERE 45.8278 3.24112
591 08561 BOURNEVILLE FL 49.38496 0.60381
592 20001 BOUVILLE 49.54825 0.92179
593 08541 INCARVILLE FL 49.24809 1.17938
594 09125 DIJON SUD 47.26023 5.03208
595 07152 REIMS OUEST(THILLOIS 49.24947 3.95591
596 09239 BERSAILLIN 46.84932 5.57458
597 09165 GROISSIAT 46.22366 5.6142
598 09065 GANNAT 46.0981 3.13554
599 09465 VICHY 46.13841 3.3513
600 04544 CABARIOT 45.94391 -0.84935
601 08211 PONT DE TANCARVILLE 49.46365 0.47404
602 04801 PYRENEES ORIENTALES 42.53958 1.82401
603 08222 PONT DE NORMANDIE 49.45001 0.2717
604 06021 ST.ISIDORE ECH. EST 43.70655 7.19071
605 06028 LAGHET 43.7454 7.37858
606 06055 MEYRARGUES 43.66118 5.50356
607 06056 PERTUIS NORD 43.66162 5.5034
608 06039 BELCODENE 43.41783 5.5752
609 08593 BEUZEVILLE FL ECH CAEN 49.33906 0.36807
610 05247 ANCENIS/ANGERS 47.39936 -1.19277
611 05273 VIEILLEVILLE 47.29114 -1.4804
612 07722 HOCHFELDEN 48.76874 7.60218
613 03010 AIX NORD 45.71614 5.92225
614 10014 BONNEVILLE EST 46.07018 6.42445
615 08321 EPRETOT BPV 49.55172 0.3355
616 06005 BRIGNOLES 43.4191 6.06505
617 06011 LES ADRETS 43.54408 6.81245
618 07001 ROISSY CDG 49.21556 2.62796
619 07706 SCHWINDRATZHEIM 48.76953 7.60203
620 09122 VILLEFRANCHE-LIMAS 45.97344 4.73193
621 06009 CAPITOU 43.46857 6.72924
622 06012 ANTIBES P/V 43.60255 7.0783
623 06027 LA TURBIE P/V 43.74367 7.37827
624 06020 ST.ISIDORE P/V 43.70752 7.19138
625 05270 ANCENIS BARRIERE 47.40069 -1.19587
626 08531 HEUDEBOUVILLE FL 49.19512 1.23011
627 08591 BEUZEVILLE FL 49.33815 0.3678
628 08533 HEUDEBOUVILLE FL ECH CAEN 49.19667 1.22974
629 04407 GIGNAC 44.99076 1.52646
630 07441 HAUDRICOURT 49.75948 1.70294
631 10001 CLUSES 46.04695 6.5966
632 03026 CHIGNIN BARRIERE 45.51256 6.00209
633 02056 ST MICHEL BARRIERE 45.21869 6.45903
634 03001 ST QUENTIN FAL. BARRIERE 45.64866 5.12021
635 06033 LA CIOTAT P/V 43.20485 5.59054
636 06031 BANDOL P/V 43.14628 5.77188
637 04354 TOULOUSE-SUD/OUEST 43.54469 1.50019
638 04904 LA NEGRESSE 43.44839 -1.55338
639 09079 CLERMONT-BARRIERE 45.84133 3.16053
640 17115 Entrée NECY 48.8235 -0.13463
641 04182 ST ROMAIN POPEY 45.87177 4.50891
642 07150 REIMS NORD 49.24633 3.96251
643 03015 ST MARTIN BELLEVUE A410 45.98981 6.12837
+15 -15
View File
@@ -1,24 +1,24 @@
/**
* Utilitaires d'authentification et d'autorisation
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
/**
* Vérifie le token Firebase et retourne l'utilisateur
*/
async function authenticateUser(req) {
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) {
throw new Error('Unauthorized: No token provided');
if (!req.headers.authorization || !req.headers.authorization.startsWith("Bearer ")) {
throw new Error("Unauthorized: No token provided");
}
const idToken = req.headers.authorization.split('Bearer ')[1];
const idToken = req.headers.authorization.split("Bearer ")[1];
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
return decodedToken;
} catch (e) {
logger.error("Error verifying Firebase ID token:", e);
throw new Error('Unauthorized: Invalid token');
throw new Error("Unauthorized: Invalid token");
}
}
@@ -26,7 +26,7 @@ async function authenticateUser(req) {
* Récupère les données utilisateur depuis Firestore
*/
async function getUserData(uid) {
const userDoc = await admin.firestore().collection('users').doc(uid).get();
const userDoc = await admin.firestore().collection("users").doc(uid).get();
if (!userDoc.exists) {
return null;
}
@@ -40,7 +40,7 @@ async function getRolePermissions(roleRef) {
if (!roleRef) return [];
let roleId;
if (typeof roleRef === 'string') {
if (typeof roleRef === "string") {
roleId = roleRef;
} else if (roleRef.id) {
roleId = roleRef.id;
@@ -48,7 +48,7 @@ async function getRolePermissions(roleRef) {
return [];
}
const roleDoc = await admin.firestore().collection('roles').doc(roleId).get();
const roleDoc = await admin.firestore().collection("roles").doc(roleId).get();
if (!roleDoc.exists) return [];
return roleDoc.data().permissions || [];
@@ -74,7 +74,7 @@ async function isAdmin(uid) {
let roleId;
const roleField = userData.role;
if (typeof roleField === 'string') {
if (typeof roleField === "string") {
roleId = roleField;
} else if (roleField && roleField.id) {
roleId = roleField.id;
@@ -82,22 +82,22 @@ async function isAdmin(uid) {
return false;
}
return roleId === 'ADMIN';
return roleId === "ADMIN";
}
/**
* Vérifie si l'utilisateur est assigné à un événement
*/
async function isAssignedToEvent(uid, eventId) {
const eventDoc = await admin.firestore().collection('events').doc(eventId).get();
const eventDoc = await admin.firestore().collection("events").doc(eventId).get();
if (!eventDoc.exists) return false;
const eventData = eventDoc.data();
const workforce = eventData.workforce || [];
// workforce contient des références DocumentReference
return workforce.some(ref => {
if (typeof ref === 'string') return ref === uid;
return workforce.some((ref) => {
if (typeof ref === "string") return ref === uid;
if (ref && ref.id) return ref.id === uid;
return false;
});
@@ -142,7 +142,7 @@ async function requireAdmin(req, res, next) {
try {
const adminAccess = await isAdmin(req.uid);
if (!adminAccess) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
next();
+8 -8
View File
@@ -7,12 +7,12 @@
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
const getSmtpConfig = () => {
return {
host: process.env.SMTP_HOST || 'mail.em2events.fr',
port: parseInt(process.env.SMTP_PORT || '465'),
host: process.env.SMTP_HOST || "mail.em2events.fr",
port: parseInt(process.env.SMTP_PORT || "465"),
secure: true, // true pour port 465, false pour autres ports
auth: {
user: process.env.SMTP_USER || 'notify@em2events.fr',
pass: process.env.SMTP_PASS || '',
user: process.env.SMTP_USER || "notify@em2events.fr",
pass: process.env.SMTP_PASS || "",
},
tls: {
// Ne pas échouer sur certificats invalides
@@ -24,12 +24,12 @@ const getSmtpConfig = () => {
// Configuration email par défaut
const EMAIL_CONFIG = {
from: {
name: 'EM2 Events',
address: 'notify@em2events.fr',
name: "EM2 Events",
address: "notify@em2events.fr",
},
replyTo: 'contact@em2events.fr',
replyTo: "contact@em2events.fr",
// URL de l'application pour les liens
appUrl: process.env.APP_URL || 'https://app.em2events.fr',
appUrl: process.env.APP_URL || "https://app.em2events.fr",
};
module.exports = {
+50 -50
View File
@@ -1,23 +1,23 @@
const admin = require('firebase-admin');
const handlebars = require('handlebars');
const fs = require('fs').promises;
const path = require('path');
const {EMAIL_CONFIG} = require('./emailConfig');
const admin = require("firebase-admin");
const handlebars = require("handlebars");
const fs = require("fs").promises;
const path = require("path");
const {EMAIL_CONFIG} = require("./emailConfig");
/**
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
*/
function checkAlertPreference(alertType, preferences) {
const typeMapping = {
'EVENT_CREATED': 'eventsNotifications',
'EVENT_MODIFIED': 'eventsNotifications',
'EVENT_CANCELLED': 'eventsNotifications',
'LOST': 'equipmentNotifications',
'EQUIPMENT_MISSING': 'equipmentNotifications',
'DAMAGED': 'equipmentNotifications',
'QUANTITY_MISMATCH': 'equipmentNotifications',
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
'STOCK_LOW': 'stockNotifications',
"EVENT_CREATED": "eventsNotifications",
"EVENT_MODIFIED": "eventsNotifications",
"EVENT_CANCELLED": "eventsNotifications",
"LOST": "equipmentNotifications",
"EQUIPMENT_MISSING": "equipmentNotifications",
"DAMAGED": "equipmentNotifications",
"QUANTITY_MISMATCH": "equipmentNotifications",
"MAINTENANCE_REMINDER": "maintenanceNotifications",
"STOCK_LOW": "stockNotifications",
};
const prefKey = typeMapping[alertType];
@@ -29,12 +29,12 @@ function checkAlertPreference(alertType, preferences) {
*/
async function prepareTemplateData(alert, user) {
const data = {
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'Utilisateur',
userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() ||
"Utilisateur",
alertTitle: getAlertTitle(alert.type),
alertMessage: alert.message,
isCritical: alert.severity === 'CRITICAL',
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
isCritical: alert.severity === "CRITICAL",
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`,
appUrl: EMAIL_CONFIG.appUrl,
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
year: new Date().getFullYear(),
@@ -45,20 +45,20 @@ async function prepareTemplateData(alert, user) {
if (alert.eventId) {
try {
const eventDoc = await admin.firestore()
.collection('events')
.collection("events")
.doc(alert.eventId)
.get();
if (eventDoc.exists) {
const event = eventDoc.data();
data.eventName = event.Name || event.name || 'Événement';
data.eventName = event.Name || event.name || "Événement";
if (event.StartDateTime || event.startDate) {
const dateField = event.StartDateTime || event.startDate;
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
data.eventDate = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
data.eventDate = date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
}
@@ -70,7 +70,7 @@ async function prepareTemplateData(alert, user) {
if (alert.equipmentId) {
try {
const eqDoc = await admin.firestore()
.collection('equipments')
.collection("equipments")
.doc(alert.equipmentId)
.get();
@@ -90,18 +90,18 @@ async function prepareTemplateData(alert, user) {
*/
function getEmailSubject(alert) {
const subjects = {
'EVENT_CREATED': '📅 Nouvel événement créé',
'EVENT_MODIFIED': '📝 Événement modifié',
'EVENT_CANCELLED': '❌ Événement annulé',
'LOST': '🔴 Alerte critique : Équipement perdu',
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
'DAMAGED': '⚠️ Équipement endommagé',
'QUANTITY_MISMATCH': '️ Quantité incorrecte',
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
'STOCK_LOW': '📦 Stock faible',
"EVENT_CREATED": "📅 Nouvel événement créé",
"EVENT_MODIFIED": "📝 Événement modifié",
"EVENT_CANCELLED": "❌ Événement annulé",
"LOST": "🔴 Alerte critique : Équipement perdu",
"EQUIPMENT_MISSING": "⚠️ Équipement manquant",
"DAMAGED": "⚠️ Équipement endommagé",
"QUANTITY_MISMATCH": "️ Quantité incorrecte",
"MAINTENANCE_REMINDER": "🔧 Rappel de maintenance",
"STOCK_LOW": "📦 Stock faible",
};
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events";
}
/**
@@ -109,18 +109,18 @@ function getEmailSubject(alert) {
*/
function getAlertTitle(type) {
const titles = {
'EVENT_CREATED': 'Nouvel événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'EVENT_CANCELLED': 'Événement annulé',
'LOST': 'Équipement perdu',
'EQUIPMENT_MISSING': 'Équipement manquant',
'DAMAGED': 'Équipement endommagé',
'QUANTITY_MISMATCH': 'Quantité incorrecte',
'MAINTENANCE_REMINDER': 'Maintenance requise',
'STOCK_LOW': 'Stock faible',
"EVENT_CREATED": "Nouvel événement créé",
"EVENT_MODIFIED": "Événement modifié",
"EVENT_CANCELLED": "Événement annulé",
"LOST": "Équipement perdu",
"EQUIPMENT_MISSING": "Équipement manquant",
"DAMAGED": "Équipement endommagé",
"QUANTITY_MISMATCH": "Quantité incorrecte",
"MAINTENANCE_REMINDER": "Maintenance requise",
"STOCK_LOW": "Stock faible",
};
return titles[type] || 'Nouvelle alerte';
return titles[type] || "Nouvelle alerte";
}
/**
@@ -129,17 +129,17 @@ function getAlertTitle(type) {
async function renderTemplate(templateName, data) {
try {
// Lire le template de base
const basePath = path.join(__dirname, '..', 'templates', 'base-template.html');
const baseTemplate = await fs.readFile(basePath, 'utf8');
const basePath = path.join(__dirname, "..", "templates", "base-template.html");
const baseTemplate = await fs.readFile(basePath, "utf8");
// Lire le template de contenu
const contentPath = path.join(
__dirname,
'..',
'templates',
"..",
"templates",
`${templateName}.html`,
);
const contentTemplate = await fs.readFile(contentPath, 'utf8');
const contentTemplate = await fs.readFile(contentPath, "utf8");
// Compiler les templates
const compileContent = handlebars.compile(contentTemplate);
+21 -21
View File
@@ -1,7 +1,7 @@
/**
* Helpers pour la manipulation de données Firestore
*/
const admin = require('firebase-admin');
const admin = require("firebase-admin");
/**
* Convertit les Timestamps Firestore en ISO strings pour JSON
@@ -29,31 +29,31 @@ function serializeTimestamps(data) {
}
// Gérer les Timestamps Firestore
if (value.toDate && typeof value.toDate === 'function') {
if (value.toDate && typeof value.toDate === "function") {
result[key] = value.toDate().toISOString();
}
// Gérer les DocumentReference
else if (value.path && value.id && typeof value.path === 'string') {
else if (value.path && value.id && typeof value.path === "string") {
result[key] = value.path;
}
// Gérer les GeoPoint
else if (value.latitude !== undefined && value.longitude !== undefined) {
result[key] = {
latitude: value.latitude,
longitude: value.longitude
longitude: value.longitude,
};
}
// Gérer les tableaux
else if (Array.isArray(value)) {
result[key] = value.map(item => {
if (!item || typeof item !== 'object') return item;
result[key] = value.map((item) => {
if (!item || typeof item !== "object") return item;
// DocumentReference dans un tableau
if (item.path && item.id) {
return item.path;
}
// Timestamp dans un tableau
if (item.toDate && typeof item.toDate === 'function') {
if (item.toDate && typeof item.toDate === "function") {
return item.toDate().toISOString();
}
// Objet normal
@@ -61,7 +61,7 @@ function serializeTimestamps(data) {
});
}
// Gérer les objets imbriqués (mais pas les objets Firestore)
else if (typeof value === 'object' && !value._firestore && !value._path) {
else if (typeof value === "object" && !value._firestore && !value._path) {
result[key] = serializeTimestamps(value);
}
}
@@ -78,7 +78,7 @@ function deserializeTimestamps(data, timestampFields = []) {
const result = {...data};
for (const field of timestampFields) {
if (result[field] && typeof result[field] === 'string') {
if (result[field] && typeof result[field] === "string") {
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
}
}
@@ -95,12 +95,12 @@ function serializeReferences(data) {
const result = {...data};
for (const key in result) {
if (result[key] && result[key].path && typeof result[key].path === 'string') {
if (result[key] && result[key].path && typeof result[key].path === "string") {
// C'est une DocumentReference
result[key] = result[key].id;
} else if (Array.isArray(result[key])) {
result[key] = result[key].map(item => {
if (item && item.path && typeof item.path === 'string') {
result[key] = result[key].map((item) => {
if (item && item.path && typeof item.path === "string") {
return item.id;
}
return item;
@@ -143,13 +143,13 @@ function paginate(query, limit = 50, startAfter = null) {
* Filtre les événements annulés
*/
function filterCancelledEvents(events) {
return events.filter(event => event.status !== 'CANCELLED');
return events.filter((event) => event.status !== "CANCELLED");
}
/**
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
* @param {Object} data - Données de l'événement
* @returns {Object} - Données avec DocumentReference
* @return {Object} - Données avec DocumentReference
*/
function convertIdsToReferences(data) {
if (!data) return data;
@@ -157,20 +157,20 @@ function convertIdsToReferences(data) {
const result = {...data};
// Convertir EventType (ID → DocumentReference)
if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) {
result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType);
if (result.EventType && typeof result.EventType === "string" && !result.EventType.includes("/")) {
result.EventType = admin.firestore().collection("eventTypes").doc(result.EventType);
}
// Convertir customer (ID → DocumentReference)
if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) {
result.customer = admin.firestore().collection('customers').doc(result.customer);
if (result.customer && typeof result.customer === "string" && !result.customer.includes("/")) {
result.customer = admin.firestore().collection("customers").doc(result.customer);
}
// Convertir workforce (IDs → DocumentReference)
if (Array.isArray(result.workforce)) {
result.workforce = result.workforce.map(item => {
if (typeof item === 'string' && !item.includes('/')) {
return admin.firestore().collection('users').doc(item);
result.workforce = result.workforce.map((item) => {
if (typeof item === "string" && !item.includes("/")) {
return admin.firestore().collection("users").doc(item);
}
return item;
});
+1 -1
View File
@@ -1,6 +1,6 @@
/// Configuration de la version de l'application
class AppVersion {
static const String version = '1.1.19';
static const String version = '1.2.4';
/// Retourne la version complète de l'application
static String get fullVersion => 'v$version';
@@ -256,6 +256,20 @@ class EventFormController extends ChangeNotifier {
notifyListeners();
}
/// Ajoute ou met à jour l'option FRAIS_KM avec le prix calculé.
/// L'option est au format attendu par Firestore : { id: "FRAIS_KM", price: <valeur> }
void addTravelCostOption(double price) {
// Retirer l'éventuelle option FRAIS_KM existante
_selectedOptions.removeWhere((opt) => opt['id'] == 'FRAIS_KM');
// Ajouter la nouvelle
_selectedOptions.add({
'id': 'FRAIS_KM',
'price': double.parse(price.toStringAsFixed(2)),
});
_onAnyFieldChanged();
notifyListeners();
}
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
_assignedEquipment = equipment;
_assignedContainers = containers;
@@ -433,6 +447,11 @@ class EventFormController extends ChangeNotifier {
}
}
Future<bool> submitAsConfirmed(BuildContext context) async {
_selectedStatus = EventStatus.confirmed;
return await submitForm(context);
}
Future<bool> deleteEvent(BuildContext context, String eventId) async {
_isLoading = true;
_error = null;
+89 -172
View File
@@ -1,3 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:em2rp/providers/users_provider.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart';
@@ -5,7 +9,6 @@ import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/maintenance_provider.dart';
import 'package:em2rp/providers/alert_provider.dart';
import 'package:em2rp/utils/auth_guard_widget.dart';
import 'package:em2rp/utils/performance_monitor.dart';
import 'package:em2rp/views/alerts_page.dart';
import 'package:em2rp/views/calendar_page.dart';
import 'package:em2rp/views/login_page.dart';
@@ -19,10 +22,8 @@ import 'package:em2rp/views/event_statistics_page.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'package:em2rp/services/app_initializer.dart';
import 'utils/colors.dart';
import 'views/my_account_page.dart';
import 'views/user_management_page.dart';
@@ -30,35 +31,27 @@ import 'package:provider/provider.dart';
import 'providers/local_user_provider.dart';
import 'views/reset_password_page.dart';
import 'config/env.dart';
import 'services/update_service.dart';
import 'views/widgets/common/update_dialog.dart';
import 'config/api_config.dart';
import 'utils/app_start_gate.dart';
import 'views/widgets/common/startup_splash_screen.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
void main() async {
void main() {
// Ne pas effectuer d'initialisations asynchrones lourdes ici.
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Configuration des émulateurs en mode développement
if (ApiConfig.isDevelopment) {
print('🔧 Mode développement activé - Utilisation des émulateurs');
// Configurer l'émulateur Auth
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
print('✓ Auth émulateur configuré: localhost:9199');
// Configurer l'émulateur Firestore
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
print('✓ Firestore émulateur configuré: localhost:8088');
if (kReleaseMode) {
debugPrint = (String? message, {int? wrapWidth}) {};
}
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
runZonedGuarded(
() {
runApp(
MultiProvider(
providers: [
// Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond)
ChangeNotifierProvider<AppInitializer>(
create: (_) => AppInitializer(),
),
// LocalUserProvider pour la gestion de l'authentification
ChangeNotifierProvider<LocalUserProvider>(
create: (context) => LocalUserProvider()),
@@ -94,13 +87,71 @@ void main() async {
child: const MyApp(),
),
);
},
(error, stackTrace) {
if (kDebugMode) {
print('Uncaught error: $error\n$stackTrace');
}
},
zoneSpecification: ZoneSpecification(
print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
if (!kReleaseMode) {
parent.print(zone, line);
}
},
),
);
}
class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final Future<void> _startupFuture;
@override
void initState() {
super.initState();
_startupFuture = _bootstrapApp();
}
Future<void> _bootstrapApp() async {
final initializer = context.read<AppInitializer>();
final localAuthProvider = context.read<LocalUserProvider>();
await initializer.initialize();
// Lancer la connexion automatique en dev sans bloquer le démarrage initial
if (Env.isDevelopment && FirebaseAuth.instance.currentUser == null) {
unawaited(
localAuthProvider.signInWithEmailAndPassword(
Env.devAdminEmail,
Env.devAdminPassword,
).then((_) {
return localAuthProvider.loadUserData();
}).catchError((e) {
if (kDebugMode) debugPrint('Dev auto-login failed: $e');
}),
);
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _startupFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: StartupSplashScreen(),
);
}
return MaterialApp(
title: 'EM2 Hub',
theme: ThemeData(
@@ -137,15 +188,15 @@ class MyApp extends StatelessWidget {
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
initialRoute: '/',
routes: {
'/': (context) => const AutoLoginWrapper(),
'/login': (context) => const LoginPage(),
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
'/calendar': (context) => const AuthGuard(
allowWhileLoading: true, child: CalendarPage()),
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
'/user_management': (context) => const AuthGuard(
requiredPermission: "view_all_users", child: UserManagementPage()),
requiredPermission: "view_all_users",
child: UserManagementPage()),
'/reset_password': (context) {
final args = ModalRoute.of(context)!.settings.arguments
as Map<String, dynamic>;
@@ -173,14 +224,16 @@ class MyApp extends StatelessWidget {
);
},
'/container_detail': (context) {
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel;
final container = ModalRoute.of(context)!.settings.arguments
as ContainerModel;
return AuthGuard(
requiredPermission: "view_equipment",
child: ContainerDetailPage(container: container),
);
},
'/event_preparation': (context) {
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
final args = ModalRoute.of(context)!.settings.arguments
as Map<String, dynamic>;
final event = args['event'] as EventModel;
return AuthGuard(
child: EventPreparationPage(
@@ -189,148 +242,12 @@ class MyApp extends StatelessWidget {
);
},
'/event_statistics': (context) => const AuthGuard(
requiredPermission: 'generate_reports', child: EventStatisticsPage()),
requiredPermission: 'generate_reports',
child: EventStatisticsPage()),
},
home: const AppStartGate(),
);
},
);
}
}
class AutoLoginWrapper extends StatefulWidget {
const AutoLoginWrapper({super.key});
@override
State<AutoLoginWrapper> createState() => _AutoLoginWrapperState();
}
class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
@override
void initState() {
super.initState();
// Attendre la fin du premier build avant de naviguer
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoLogin();
// Vérifier les mises à jour après un délai pour ne pas interférer avec l'autologin
_checkForUpdateDelayed();
});
}
/// Vérifie les mises à jour après un délai
Future<void> _checkForUpdateDelayed() async {
try {
// Attendre que l'app soit complètement chargée (navigation effectuée, etc.)
await Future.delayed(const Duration(seconds: 3));
if (!mounted) return;
final updateInfo = await UpdateService.checkForUpdate();
if (updateInfo != null && mounted) {
// Attendre encore un peu pour être sûr que le bon contexte est disponible
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
showDialog(
context: context,
barrierDismissible: !updateInfo.forceUpdate,
builder: (context) => UpdateDialog(updateInfo: updateInfo),
);
}
}
} catch (e) {
print('[AutoLoginWrapper] Error checking for update: $e');
}
}
Future<void> _autoLogin() async {
PerformanceMonitor.start('App.autoLogin');
try {
final localAuthProvider =
Provider.of<LocalUserProvider>(context, listen: false);
// Vérifier si l'utilisateur est déjà connecté
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
PerformanceMonitor.start('App.signIn');
// Connexion automatique en mode développement
await localAuthProvider.signInWithEmailAndPassword(
Env.devAdminEmail,
Env.devAdminPassword,
);
PerformanceMonitor.end('App.signIn');
}
if (mounted) {
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
// En Flutter Web, on peut vérifier window.location.hash
final currentUri = Uri.base;
final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts
print('[AutoLoginWrapper] Fragment URL: $fragment');
// Navigation immédiate sans attendre le chargement des données
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
print('[AutoLoginWrapper] Redirection vers: $fragment');
Navigator.of(context).pushReplacementNamed(fragment);
} else {
// Route par défaut : calendrier
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
Navigator.of(context).pushReplacementNamed('/calendar');
}
PerformanceMonitor.end('App.autoLogin');
PerformanceMonitor.printSummary();
// Charger les données utilisateur en arrière-plan
localAuthProvider.loadUserData().catchError((e) {
print('Error loading user data: $e');
});
}
} catch (e) {
print('Auto login failed: $e');
PerformanceMonitor.end('App.autoLogin');
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo de l'application
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 200,
height: 200,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 80,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 40),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
),
const SizedBox(height: 20),
const Text(
'Chargement...',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
fontWeight: FontWeight.w400,
),
),
],
),
),
);
}
}
+43 -21
View File
@@ -242,34 +242,55 @@ class ContainerModel {
/// Factory depuis Firestore
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
// Fonction helper pour convertir de manière sécurisée en double
double? parseDouble(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
// Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime
DateTime? parseDate(dynamic value) {
if (value == null) return null;
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value);
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
return null;
}
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
// Gestion sécurisée de la liste d'IDs d'équipements
final List<String> equipmentIds = [];
if (map['equipmentIds'] is List) {
for (final e in map['equipmentIds'] as List) {
if (e != null) {
equipmentIds.add(e.toString());
}
}
}
final List<dynamic> historyRaw = map['history'] ?? [];
final List<ContainerHistoryEntry> history = historyRaw
.map((e) => ContainerHistoryEntry.fromMap(e as Map<String, dynamic>))
.toList();
// Gestion sécurisée de l'historique
final List<ContainerHistoryEntry> history = [];
if (map['history'] is List) {
for (final e in map['history'] as List) {
if (e is Map<String, dynamic>) {
history.add(ContainerHistoryEntry.fromMap(e));
}
}
}
return ContainerModel(
id: id,
name: map['name'] ?? '',
type: containerTypeFromString(map['type']),
status: equipmentStatusFromString(map['status']),
weight: map['weight']?.toDouble(),
length: map['length']?.toDouble(),
width: map['width']?.toDouble(),
height: map['height']?.toDouble(),
name: (map['name'] ?? '').toString(),
type: containerTypeFromString(map['type']?.toString()),
status: equipmentStatusFromString(map['status']?.toString()),
weight: parseDouble(map['weight']),
length: parseDouble(map['length']),
width: parseDouble(map['width']),
height: parseDouble(map['height']),
equipmentIds: equipmentIds,
eventId: map['eventId'],
notes: map['notes'],
eventId: map['eventId']?.toString(),
notes: map['notes']?.toString(),
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
history: history,
@@ -355,16 +376,17 @@ class ContainerHistoryEntry {
if (value == null) return DateTime.now();
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
return DateTime.now();
}
return ContainerHistoryEntry(
timestamp: parseDate(map['timestamp']),
action: map['action'] ?? '',
equipmentId: map['equipmentId'],
previousValue: map['previousValue'],
newValue: map['newValue'],
userId: map['userId'],
action: (map['action'] ?? '').toString(),
equipmentId: map['equipmentId']?.toString(),
previousValue: map['previousValue']?.toString(),
newValue: map['newValue']?.toString(),
userId: map['userId']?.toString(),
);
}
+49
View File
@@ -0,0 +1,49 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class DepotModel {
final String id;
final String name;
final String address;
final DateTime? createdAt;
const DepotModel({
required this.id,
required this.name,
required this.address,
this.createdAt,
});
factory DepotModel.fromMap(Map<String, dynamic> map, String id) {
return DepotModel(
id: id,
name: (map['name'] ?? '').toString(),
address: (map['address'] ?? '').toString(),
createdAt: map['createdAt'] is Timestamp
? (map['createdAt'] as Timestamp).toDate()
: null,
);
}
factory DepotModel.fromFirestore(DocumentSnapshot doc) {
return DepotModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
}
Map<String, dynamic> toMap() {
return {
'name': name,
'address': address,
'createdAt': createdAt != null
? Timestamp.fromDate(createdAt!)
: FieldValue.serverTimestamp(),
};
}
DepotModel copyWith({String? id, String? name, String? address}) {
return DepotModel(
id: id ?? this.id,
name: name ?? this.name,
address: address ?? this.address,
createdAt: createdAt,
);
}
}
+45 -21
View File
@@ -387,40 +387,64 @@ class EquipmentModel {
});
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
// Fonction helper pour convertir de manière sécurisée en double
double? parseDouble(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
// Fonction helper pour convertir de manière sécurisée en int
int? parseInt(dynamic value) {
if (value == null) return null;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
// Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime
DateTime? parseDate(dynamic value) {
if (value == null) return null;
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value);
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
return null;
}
// Gestion des listes
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
// Gestion sécurisée des listes d'IDs de maintenance
final List<String> maintenanceIds = [];
if (map['maintenanceIds'] is List) {
for (final e in map['maintenanceIds'] as List) {
if (e != null) {
maintenanceIds.add(e.toString());
}
}
}
return EquipmentModel(
id: id,
name: map['name'] ?? '',
brand: map['brand'],
model: map['model'],
category: equipmentCategoryFromString(map['category']),
subCategory: map['subCategory'],
status: equipmentStatusFromString(map['status']),
purchasePrice: map['purchasePrice']?.toDouble(),
rentalPrice: map['rentalPrice']?.toDouble(),
totalQuantity: map['totalQuantity']?.toInt(),
availableQuantity: map['availableQuantity']?.toInt(),
criticalThreshold: map['criticalThreshold']?.toInt(),
weight: map['weight']?.toDouble(),
length: map['length']?.toDouble(),
width: map['width']?.toDouble(),
height: map['height']?.toDouble(),
name: (map['name'] ?? '').toString(),
brand: map['brand']?.toString(),
model: map['model']?.toString(),
category: equipmentCategoryFromString(map['category']?.toString()),
subCategory: map['subCategory']?.toString(),
status: equipmentStatusFromString(map['status']?.toString()),
purchasePrice: parseDouble(map['purchasePrice']),
rentalPrice: parseDouble(map['rentalPrice']),
totalQuantity: parseInt(map['totalQuantity']),
availableQuantity: parseInt(map['availableQuantity']),
criticalThreshold: parseInt(map['criticalThreshold']),
weight: parseDouble(map['weight']),
length: parseDouble(map['length']),
width: parseDouble(map['width']),
height: parseDouble(map['height']),
purchaseDate: parseDate(map['purchaseDate']),
lastMaintenanceDate: parseDate(map['lastMaintenanceDate']),
nextMaintenanceDate: parseDate(map['nextMaintenanceDate']),
maintenanceIds: maintenanceIds,
imageUrl: map['imageUrl'],
notes: map['notes'],
imageUrl: map['imageUrl']?.toString(),
notes: map['notes']?.toString(),
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
);
+6
View File
@@ -174,6 +174,7 @@ ReturnStatus returnStatusFromString(String? status) {
class EventEquipment {
final String equipmentId; // ID de l'équipement
final int quantity; // Quantité initiale assignée
final String? rationale; // Explication/Justification (ex: IA alternative)
final bool isPrepared; // Validé en préparation
final bool isLoaded; // Validé au chargement
final bool isUnloaded; // Validé au déchargement
@@ -194,6 +195,7 @@ class EventEquipment {
EventEquipment({
required this.equipmentId,
this.quantity = 1,
this.rationale,
this.isPrepared = false,
this.isLoaded = false,
this.isUnloaded = false,
@@ -212,6 +214,7 @@ class EventEquipment {
return EventEquipment(
equipmentId: map['equipmentId'] ?? '',
quantity: map['quantity'] ?? 1,
rationale: map['rationale'],
isPrepared: map['isPrepared'] ?? false,
isLoaded: map['isLoaded'] ?? false,
isUnloaded: map['isUnloaded'] ?? false,
@@ -231,6 +234,7 @@ class EventEquipment {
return {
'equipmentId': equipmentId,
'quantity': quantity,
'rationale': rationale,
'isPrepared': isPrepared,
'isLoaded': isLoaded,
'isUnloaded': isUnloaded,
@@ -249,6 +253,7 @@ class EventEquipment {
EventEquipment copyWith({
String? equipmentId,
int? quantity,
String? rationale,
bool? isPrepared,
bool? isLoaded,
bool? isUnloaded,
@@ -265,6 +270,7 @@ class EventEquipment {
return EventEquipment(
equipmentId: equipmentId ?? this.equipmentId,
quantity: quantity ?? this.quantity,
rationale: rationale ?? this.rationale,
isPrepared: isPrepared ?? this.isPrepared,
isLoaded: isLoaded ?? this.isLoaded,
isUnloaded: isUnloaded ?? this.isUnloaded,
+24 -9
View File
@@ -60,29 +60,44 @@ class MaintenanceModel {
});
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
// Fonction helper pour convertir de manière sécurisée en double
double? parseDouble(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
// Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime
DateTime? parseDate(dynamic value) {
if (value == null) return null;
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value);
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
return null;
}
// Gestion de la liste des équipements
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
final List<String> equipmentIds = [];
if (map['equipmentIds'] is List) {
for (final e in map['equipmentIds'] as List) {
if (e != null) {
equipmentIds.add(e.toString());
}
}
}
return MaintenanceModel(
id: id,
equipmentIds: equipmentIds,
type: maintenanceTypeFromString(map['type']),
type: maintenanceTypeFromString(map['type']?.toString()),
scheduledDate: parseDate(map['scheduledDate']) ?? DateTime.now(),
completedDate: parseDate(map['completedDate']),
name: map['name'] ?? '',
description: map['description'] ?? '',
performedBy: map['performedBy'],
cost: map['cost']?.toDouble(),
notes: map['notes'],
name: (map['name'] ?? '').toString(),
description: (map['description'] ?? '').toString(),
performedBy: map['performedBy']?.toString(),
cost: parseDouble(map['cost']),
notes: map['notes']?.toString(),
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
);
+158
View File
@@ -0,0 +1,158 @@
/// Résultat d'un itinéraire calculé par Google Maps + Ulys.
class RouteResult {
/// 'TOLL' ou 'TOLL_FREE'
final String routeType;
final int distanceMeters;
final int durationSeconds;
final String encodedPolyline;
final double tollCost;
const RouteResult({
required this.routeType,
required this.distanceMeters,
required this.durationSeconds,
required this.encodedPolyline,
required this.tollCost,
});
factory RouteResult.fromMap(Map<String, dynamic> map) {
return RouteResult(
routeType: (map['routeType'] ?? 'TOLL').toString(),
distanceMeters: _parseInt(map['distanceMeters'] ?? 0),
durationSeconds: _parseInt(map['durationSeconds'] ?? 0),
encodedPolyline: (map['encodedPolyline'] ?? '').toString(),
tollCost: _parseDouble(map['tollCost'] ?? 0),
);
}
bool get isTollFree => routeType == 'TOLL_FREE';
double get distanceKm => distanceMeters / 1000.0;
double get durationMinutes => durationSeconds / 60.0;
double get durationHours => durationSeconds / 3600.0;
/// Calcule le coût carburant.
/// [consumptionPer100km] : L/100km (ou kWh/100km si électrique)
/// [fuelPricePerLiter] : €/L ou €/kWh
/// [freeZoneKm] : km gratuits à déduire (zone de gratuité)
double fuelCost({
required double consumptionPer100km,
required double fuelPricePerLiter,
double freeZoneKm = 0,
}) {
final effectiveKm = (distanceKm - freeZoneKm).clamp(0, double.infinity);
return (effectiveKm / 100.0) * consumptionPer100km * fuelPricePerLiter;
}
/// Calcule le coût de maintenance.
double maintenanceCost({
required double costPerKm,
double freeZoneKm = 0,
}) {
final effectiveKm = (distanceKm - freeZoneKm).clamp(0, double.infinity);
return effectiveKm * costPerKm;
}
/// Calcule le coût de main-d'œuvre (techniciens).
/// [freeZoneMinutes] : minutes gratuites à déduire (zone de gratuité)
double laborCost({
required int nbTechnicians,
required double hourlyRate,
double freeZoneMinutes = 0,
}) {
final effectiveMinutes =
(durationMinutes - freeZoneMinutes).clamp(0, double.infinity);
return (effectiveMinutes / 60.0) * nbTechnicians * hourlyRate;
}
/// Calcule le coût total pour un aller simple.
double totalCost({
required double consumptionPer100km,
required double fuelPricePerLiter,
required double maintenanceCostPerKm,
required int nbTechnicians,
required double hourlyRate,
bool applyFreeZone = false,
}) {
const freeKm = 20.0;
const freeMinutes = 20.0;
return fuelCost(
consumptionPer100km: consumptionPer100km,
fuelPricePerLiter: fuelPricePerLiter,
freeZoneKm: applyFreeZone ? freeKm : 0,
) +
maintenanceCost(
costPerKm: maintenanceCostPerKm,
freeZoneKm: applyFreeZone ? freeKm : 0,
) +
laborCost(
nbTechnicians: nbTechnicians,
hourlyRate: hourlyRate,
freeZoneMinutes: applyFreeZone ? freeMinutes : 0,
) +
tollCost;
}
static double _parseDouble(dynamic v) {
if (v is double) return v;
if (v is int) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0.0;
return 0.0;
}
static int _parseInt(dynamic v) {
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
}
/// Prix des carburants (stocké dans Firestore app_config/fuel_prices)
class FuelPrices {
final double diesel; // €/L
final double essence; // €/L
final double electricite; // €/kWh
const FuelPrices({
this.diesel = 1.60,
this.essence = 1.75,
this.electricite = 0.22,
});
factory FuelPrices.fromMap(Map<String, dynamic> map) {
return FuelPrices(
diesel: _parseDouble(map['diesel'] ?? 1.60),
essence: _parseDouble(map['essence'] ?? 1.75),
electricite: _parseDouble(map['electricite'] ?? 0.22),
);
}
Map<String, dynamic> toMap() => {
'diesel': diesel,
'essence': essence,
'electricite': electricite,
};
double priceForFuelType(String fuelType) {
switch (fuelType.toLowerCase()) {
case 'diesel':
return diesel;
case 'essence':
return essence;
case 'electrique':
case 'électrique':
return electricite;
default:
return diesel;
}
}
static double _parseDouble(dynamic v) {
if (v is double) return v;
if (v is int) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0.0;
return 0.0;
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class VehicleModel {
final String id;
final String name;
final double consumptionPer100km; // L/100km (ou kWh/100km si électrique)
final String fuelType; // 'Diesel', 'Essence', 'Electrique'
final double maintenanceCostPerKm; // €/km
final int tollCategoryId; // 1 à 5 (catégorie Ulys)
final DateTime? createdAt;
const VehicleModel({
required this.id,
required this.name,
required this.consumptionPer100km,
required this.fuelType,
required this.maintenanceCostPerKm,
required this.tollCategoryId,
this.createdAt,
});
factory VehicleModel.fromMap(Map<String, dynamic> map, String id) {
return VehicleModel(
id: id,
name: (map['name'] ?? '').toString(),
consumptionPer100km: _parseDouble(map['consumptionPer100km'] ?? 0),
fuelType: (map['fuelType'] ?? 'Diesel').toString(),
maintenanceCostPerKm: _parseDouble(map['maintenanceCostPerKm'] ?? 0),
tollCategoryId: _parseInt(map['tollCategoryId'] ?? 2),
createdAt: map['createdAt'] is Timestamp
? (map['createdAt'] as Timestamp).toDate()
: null,
);
}
factory VehicleModel.fromFirestore(DocumentSnapshot doc) {
return VehicleModel.fromMap(
doc.data() as Map<String, dynamic>,
doc.id,
);
}
Map<String, dynamic> toMap() {
return {
'name': name,
'consumptionPer100km': consumptionPer100km,
'fuelType': fuelType,
'maintenanceCostPerKm': maintenanceCostPerKm,
'tollCategoryId': tollCategoryId,
'createdAt': createdAt != null
? Timestamp.fromDate(createdAt!)
: FieldValue.serverTimestamp(),
};
}
VehicleModel copyWith({
String? id,
String? name,
double? consumptionPer100km,
String? fuelType,
double? maintenanceCostPerKm,
int? tollCategoryId,
}) {
return VehicleModel(
id: id ?? this.id,
name: name ?? this.name,
consumptionPer100km: consumptionPer100km ?? this.consumptionPer100km,
fuelType: fuelType ?? this.fuelType,
maintenanceCostPerKm: maintenanceCostPerKm ?? this.maintenanceCostPerKm,
tollCategoryId: tollCategoryId ?? this.tollCategoryId,
createdAt: createdAt,
);
}
/// Label lisible pour l'unité de consommation
String get consumptionUnit {
if (fuelType == 'Electrique') return 'kWh/100km';
return 'L/100km';
}
static double _parseDouble(dynamic v) {
if (v is double) return v;
if (v is int) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0.0;
return 0.0;
}
static int _parseInt(dynamic v) {
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) return int.tryParse(v) ?? 2;
return 2;
}
}
+5 -5
View File
@@ -40,7 +40,7 @@ class AlertProvider extends ChangeNotifier {
return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
}).toList();
} catch (e) {
print('Error loading alerts: $e');
if (kDebugMode) debugPrint('Error loading alerts: $e');
_alerts = [];
} finally {
_isLoading = false;
@@ -67,7 +67,7 @@ class AlertProvider extends ChangeNotifier {
notifyListeners();
}
} catch (e) {
print('Error marking alert as read: $e');
if (kDebugMode) debugPrint('Error marking alert as read: $e');
rethrow;
}
}
@@ -81,7 +81,7 @@ class AlertProvider extends ChangeNotifier {
_alerts.removeWhere((a) => a.id == alertId);
notifyListeners();
} catch (e) {
print('Error deleting alert: $e');
if (kDebugMode) debugPrint('Error deleting alert: $e');
rethrow;
}
}
@@ -95,7 +95,7 @@ class AlertProvider extends ChangeNotifier {
await markAsRead(alertId);
}
} catch (e) {
print('Error marking all alerts as read: $e');
if (kDebugMode) debugPrint('Error marking all alerts as read: $e');
rethrow;
}
}
@@ -109,7 +109,7 @@ class AlertProvider extends ChangeNotifier {
await deleteAlert(alertId);
}
} catch (e) {
print('Error deleting read alerts: $e');
if (kDebugMode) debugPrint('Error deleting read alerts: $e');
rethrow;
}
}
@@ -1,62 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/alert_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class AlertProvider extends ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
List<AlertModel> _alerts = [];
bool _isLoading = false;
List<AlertModel> get alerts => _alerts;
bool get isLoading => _isLoading;
/// Nombre d'alertes non lues
int get unreadCount => _alerts.where((a) => !a.isRead).length;
/// Charger toutes les alertes via l'API
Future<void> loadAlerts() async {
_isLoading = true;
notifyListeners();
try {
final alertsData = await _dataService.getAlerts();
_alerts = alertsData.map((data) {
return AlertModel.fromMap(data, data['id'] as String);
}).toList();
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error loading alerts: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharger les alertes
Future<void> refresh() async {
await loadAlerts();
}
/// Obtenir les alertes non lues
List<AlertModel> get unreadAlerts {
return _alerts.where((a) => !a.isRead).toList();
}
/// Obtenir les alertes par type
List<AlertModel> getByType(AlertType type) {
return _alerts.where((a) => a.type == type).toList();
}
/// Obtenir les alertes critiques (stock bas, équipement perdu)
List<AlertModel> get criticalAlerts {
return _alerts.where((a) =>
a.type == AlertType.lowStock || a.type == AlertType.lost
).toList();
}
}
+7 -7
View File
@@ -66,7 +66,7 @@ class ContainerProvider with ChangeNotifier {
// Charger toutes les pages en boucle
while (hasMore) {
pageCount++;
print('[ContainerProvider] Loading page $pageCount...');
DebugLog.info('[ContainerProvider] Loading page $pageCount...');
final result = await _dataService.getContainersPaginated(
limit: 100, // Charger 100 par page pour aller plus vite
@@ -86,14 +86,14 @@ class ContainerProvider with ChangeNotifier {
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
DebugLog.info('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
}
_isLoading = false;
_isInitialized = true;
notifyListeners();
} catch (e) {
print('Error loading containers: $e');
DebugLog.error('[ContainerProvider] Error loading containers', e);
_isLoading = false;
notifyListeners();
}
@@ -292,7 +292,7 @@ class ContainerProvider with ChangeNotifier {
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
if (containerIds.isEmpty) return [];
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
DebugLog.info('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
try {
// Vérifier d'abord le cache local
@@ -320,7 +320,7 @@ class ContainerProvider with ChangeNotifier {
}
}
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
DebugLog.info('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
// Si tous sont en cache, retourner directement
if (missingIds.isEmpty) {
@@ -341,12 +341,12 @@ class ContainerProvider with ChangeNotifier {
}
}
print('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
DebugLog.info('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
// Retourner tous les conteneurs (cache + chargés)
return [...cachedContainers, ...loadedContainers];
} catch (e) {
print('[ContainerProvider] Error loading containers by IDs: $e');
DebugLog.error('[ContainerProvider] Error loading containers by IDs', e);
rethrow;
}
}
+16 -16
View File
@@ -29,6 +29,7 @@ class EquipmentProvider extends ChangeNotifier {
String _searchQuery = '';
bool _isLoading = false;
bool _isInitialized = false;
bool _isFullListLoaded = false;
// Mode de chargement (pagination vs full)
bool _usePagination = false;
@@ -48,6 +49,7 @@ class EquipmentProvider extends ChangeNotifier {
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
bool get isInitialized => _isInitialized;
bool get isFullListLoaded => _isFullListLoaded;
bool get usePagination => _usePagination;
/// S'assure que les équipements sont chargés (charge si nécessaire)
@@ -58,16 +60,8 @@ class EquipmentProvider extends ChangeNotifier {
return;
}
// Si initialisé MAIS _equipment est vide, forcer le rechargement
if (_isInitialized && _equipment.isEmpty) {
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
_isInitialized = false; // Réinitialiser le flag
await loadEquipments();
return;
}
// Si déjà initialisé avec des données, ne rien faire
if (_isInitialized) {
// Si déjà initialisé avec le cache complet des données, ne rien faire
if (_isFullListLoaded) {
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
return;
}
@@ -80,7 +74,7 @@ class EquipmentProvider extends ChangeNotifier {
Future<void> loadEquipments() async {
print('[EquipmentProvider] Starting to load ALL equipments...');
_isLoading = true;
notifyListeners();
scheduleMicrotask(notifyListeners);
try {
_equipment.clear();
@@ -120,7 +114,7 @@ class EquipmentProvider extends ChangeNotifier {
// Extraire les modèles et marques uniques
_extractUniqueValues();
_isFullListLoaded = true;
_isInitialized = true;
_isLoading = false;
notifyListeners();
@@ -272,7 +266,7 @@ class EquipmentProvider extends ChangeNotifier {
_lastVisible = null;
_hasMore = true;
_isLoading = true;
notifyListeners();
scheduleMicrotask(notifyListeners);
try {
await loadNextPage();
@@ -296,7 +290,7 @@ class EquipmentProvider extends ChangeNotifier {
_isLoadingMore = true;
_isLoading = true;
notifyListeners();
scheduleMicrotask(notifyListeners);
try {
final result = await _dataService.getEquipmentsPaginated(
@@ -433,9 +427,11 @@ class EquipmentProvider extends ChangeNotifier {
}
/// Supprimer un équipement
Future<void> deleteEquipment(String equipmentId) async {
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) async {
try {
await _dataService.deleteEquipment(equipmentId);
await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete);
_isFullListLoaded = false;
_equipment.clear();
if (_usePagination) {
await reload();
} else {
@@ -451,6 +447,8 @@ class EquipmentProvider extends ChangeNotifier {
Future<void> addEquipment(EquipmentModel equipment) async {
try {
await _dataService.createEquipment(equipment.id, equipment.toMap());
_isFullListLoaded = false;
_equipment.clear();
if (_usePagination) {
await reload();
} else {
@@ -466,6 +464,8 @@ class EquipmentProvider extends ChangeNotifier {
Future<void> updateEquipment(EquipmentModel equipment) async {
try {
await _dataService.updateEquipment(equipment.id, equipment.toMap());
_isFullListLoaded = false;
_equipment.clear();
if (_usePagination) {
await reload();
} else {
+75 -29
View File
@@ -19,7 +19,8 @@ class EventProvider with ChangeNotifier {
bool _lastCanViewAll = false;
// Nouveau: Cache par mois pour le lazy loading
final Map<String, List<EventModel>> _eventsByMonth = {}; // "2026-02" => [events]
final Map<String, List<EventModel>> _eventsByMonth =
{}; // "2026-02" => [events]
String? _currentMonth; // Mois actuellement affiché
List<EventModel> get events => _events;
@@ -28,7 +29,8 @@ class EventProvider with ChangeNotifier {
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
bool _shouldReload(String userId, bool canViewAllEvents) {
if (_lastLoadTime == null) return true;
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true;
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents)
return true;
final now = DateTime.now();
final difference = now.difference(_lastLoadTime!);
@@ -36,12 +38,14 @@ class EventProvider with ChangeNotifier {
}
/// Charger les événements d'un utilisateur via l'API
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async {
Future<void> loadUserEvents(String userId,
{bool canViewAllEvents = false, bool forceReload = false}) async {
PerformanceMonitor.start('EventProvider.loadUserEvents');
// Éviter les rechargements inutiles
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
print(
'Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
PerformanceMonitor.end('EventProvider.loadUserEvents');
return;
}
@@ -50,7 +54,8 @@ class EventProvider with ChangeNotifier {
notifyListeners();
try {
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
print(
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
PerformanceMonitor.start('EventProvider.getEvents_API');
// Charger via l'API - les permissions sont vérifiées côté serveur
@@ -61,9 +66,8 @@ class EventProvider with ChangeNotifier {
final usersData = result['users'] as Map<String, dynamic>;
// Stocker les utilisateurs dans le cache
_usersCache = usersData.map((key, value) =>
MapEntry(key, value as Map<String, dynamic>)
);
_usersCache = usersData
.map((key, value) => MapEntry(key, value as Map<String, dynamic>));
print('Found ${eventsData.length} events from API');
@@ -74,7 +78,8 @@ class EventProvider with ChangeNotifier {
// Parser chaque événement
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(eventData, eventData['id'] as String);
final event =
EventModel.fromMap(eventData, eventData['id'] as String);
allEvents.add(event);
} catch (e) {
print('Failed to parse event ${eventData['id']}: $e');
@@ -88,7 +93,8 @@ class EventProvider with ChangeNotifier {
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
print('Successfully loaded ${_events.length} events ($failedCount failed)');
print(
'Successfully loaded ${_events.length} events ($failedCount failed)');
_isLoading = false;
notifyListeners();
@@ -104,8 +110,9 @@ class EventProvider with ChangeNotifier {
/// Charger les événements d'un mois spécifique (lazy loading optimisé)
Future<void> loadMonthEvents(String userId, int year, int month,
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async {
{bool canViewAllEvents = false,
bool forceReload = false,
bool silent = false}) async {
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
// Vérifier le cache
@@ -130,19 +137,15 @@ class EventProvider with ChangeNotifier {
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
final result = await _dataService.getEventsByMonth(
userId: userId,
year: year,
month: month
);
userId: userId, year: year, month: month);
PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
final eventsData = result['events'] as List<Map<String, dynamic>>;
final usersData = result['users'] as Map<String, dynamic>;
// Mettre à jour le cache utilisateurs (addAll pour cumuler)
_usersCache.addAll(
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>))
);
_usersCache.addAll(usersData
.map((key, value) => MapEntry(key, value as Map<String, dynamic>)));
print('[EventProvider] Found ${eventsData.length} events for $monthKey');
@@ -153,7 +156,8 @@ class EventProvider with ChangeNotifier {
// Parser les événements
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(eventData, eventData['id'] as String);
final event =
EventModel.fromMap(eventData, eventData['id'] as String);
monthEvents.add(event);
} catch (e) {
print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
@@ -176,7 +180,8 @@ class EventProvider with ChangeNotifier {
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
print(
'[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
if (!silent) {
_isLoading = false;
@@ -195,7 +200,6 @@ class EventProvider with ChangeNotifier {
/// Précharger les mois adjacents en arrière-plan
void preloadAdjacentMonths(String userId, int year, int month,
{bool canViewAllEvents = false}) {
// Mois précédent
final prevMonth = month == 1 ? 12 : month - 1;
final prevYear = month == 1 ? year - 1 : year;
@@ -230,8 +234,10 @@ class EventProvider with ChangeNotifier {
}
/// Recharger les événements (utilise le dernier userId)
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
Future<void> refreshEvents(String userId,
{bool canViewAllEvents = false}) async {
await loadUserEvents(userId,
canViewAllEvents: canViewAllEvents, forceReload: true);
}
/// Récupérer un événement spécifique par ID
@@ -243,6 +249,41 @@ class EventProvider with ChangeNotifier {
}
}
/// Recherche des événements accessibles à l'utilisateur.
Future<List<EventModel>> searchEvents({
required String userId,
required String query,
int limit = 20,
}) async {
final trimmedQuery = query.trim();
if (trimmedQuery.isEmpty) {
return [];
}
final result = await _dataService.searchEvents(
userId: userId,
query: trimmedQuery,
limit: limit,
);
final events = <EventModel>[];
for (final eventData in result) {
try {
final eventId = eventData['id'] as String?;
if (eventId == null || eventId.isEmpty) {
continue;
}
events.add(EventModel.fromMap(eventData, eventId));
} catch (e) {
print('Failed to parse searched event ${eventData['id']}: $e');
}
}
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
return events;
}
/// Ajouter un nouvel événement
Future<void> addEvent(EventModel event) async {
try {
@@ -250,7 +291,8 @@ class EventProvider with ChangeNotifier {
_events.add(event);
// Ajouter dans le cache par mois
final monthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
final monthKey =
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
if (_eventsByMonth.containsKey(monthKey)) {
_eventsByMonth[monthKey]!.add(event);
}
@@ -272,8 +314,10 @@ class EventProvider with ChangeNotifier {
_events[index] = event;
// Mettre à jour dans le cache par mois
final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
final oldMonthKey =
'${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
final newMonthKey =
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
// Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
if (oldMonthKey != newMonthKey) {
@@ -286,7 +330,8 @@ class EventProvider with ChangeNotifier {
} else {
// Même mois, juste mettre à jour
if (_eventsByMonth.containsKey(newMonthKey)) {
final monthIndex = _eventsByMonth[newMonthKey]!.indexWhere((e) => e.id == event.id);
final monthIndex = _eventsByMonth[newMonthKey]!
.indexWhere((e) => e.id == event.id);
if (monthIndex != -1) {
_eventsByMonth[newMonthKey]![monthIndex] = event;
}
@@ -308,7 +353,8 @@ class EventProvider with ChangeNotifier {
// Trouver l'événement pour obtenir sa date avant de le supprimer
final eventToDelete = _events.firstWhere((e) => e.id == eventId);
final monthKey = '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
final monthKey =
'${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
// Supprimer de _events
_events.removeWhere((event) => event.id == eventId);
+51 -5
View File
@@ -12,7 +12,7 @@ import '../utils/performance_monitor.dart';
class LocalUserProvider with ChangeNotifier {
UserModel? _currentUser;
RoleModel? _currentRole;
final FirebaseAuth _auth = FirebaseAuth.instance;
FirebaseAuth? _auth;
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
final DataService _dataService = DataService(apiService);
@@ -43,11 +43,41 @@ class LocalUserProvider with ChangeNotifier {
/// Charge les données de l'utilisateur actuel via Cloud Function
Future<void> loadUserData({bool forceReload = false}) async {
if (_auth.currentUser == null) {
// Si FirebaseAuth n'est pas encore disponible
final FirebaseAuth auth;
try {
auth = _getAuthInstance();
} catch (e) {
print('Auth instance not ready in loadUserData: $e');
return;
}
if (auth.currentUser == null) {
print('No current user in Auth');
return;
}
// Bootstrap léger : rendre l'UID disponible tout de suite pour les écrans
// qui en ont besoin, même si le profil complet n'est pas encore chargé.
if (_currentUser == null) {
final firebaseUser = auth.currentUser!;
_currentUser = UserModel(
uid: firebaseUser.uid,
email: firebaseUser.email ?? '',
firstName: '',
lastName: '',
role: 'USER',
phoneNumber: '',
profilePhotoUrl: firebaseUser.photoURL ?? '',
);
_currentRole = RoleModel(
id: 'USER',
name: '',
permissions: const [],
);
notifyListeners();
}
// Éviter les rechargements inutiles
if (!forceReload && !_shouldReloadUserData()) {
print('Using cached user data');
@@ -62,7 +92,7 @@ class LocalUserProvider with ChangeNotifier {
_isLoadingUserData = true;
PerformanceMonitor.start('LocalUserProvider.loadUserData');
print('Loading user data for: ${_auth.currentUser!.uid}');
print('Loading user data for: ${_auth!.currentUser!.uid}');
try {
// Utiliser la Cloud Function getCurrentUser
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
@@ -194,7 +224,8 @@ class LocalUserProvider with ChangeNotifier {
Future<UserCredential> signInWithEmailAndPassword(
String email, String password) async {
try {
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
final auth = _getAuthInstance();
UserCredential userCredential = await auth.signInWithEmailAndPassword(
email: email, password: password);
// Note: loadUserData() sera appelé en arrière-plan dans main.dart
// pour ne pas bloquer la navigation
@@ -206,10 +237,25 @@ class LocalUserProvider with ChangeNotifier {
/// Déconnexion
Future<void> signOut() async {
await _auth.signOut();
try {
final auth = _getAuthInstance();
await auth.signOut();
} catch (e) {
debugPrint('Error during signOut: $e');
}
clearUser();
}
FirebaseAuth _getAuthInstance() {
try {
_auth ??= FirebaseAuth.instance;
return _auth!;
} catch (e, st) {
debugPrint('[LocalUserProvider] FirebaseAuth.instance access error: $e\n$st');
throw Exception('FirebaseAuth not available');
}
}
/// Vérifie si l'utilisateur a une permission spécifique
bool hasPermission(String permission) {
return _currentRole?.permissions.contains(permission) ?? false;
@@ -1,51 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/maintenance_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class MaintenanceProvider extends ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
List<MaintenanceModel> _maintenances = [];
bool _isLoading = false;
List<MaintenanceModel> get maintenances => _maintenances;
bool get isLoading => _isLoading;
/// Charger toutes les maintenances via l'API
Future<void> loadMaintenances({String? equipmentId}) async {
_isLoading = true;
notifyListeners();
try {
final maintenancesData = await _dataService.getMaintenances(
equipmentId: equipmentId,
);
_maintenances = maintenancesData.map((data) {
return MaintenanceModel.fromMap(data, data['id'] as String);
}).toList();
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error loading maintenances: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharger les maintenances
Future<void> refresh({String? equipmentId}) async {
await loadMaintenances(equipmentId: equipmentId);
}
/// Obtenir les maintenances pour un équipement spécifique
List<MaintenanceModel> getForEquipment(String equipmentId) {
return _maintenances.where((m) =>
m.equipmentIds.contains(equipmentId)
).toList();
}
}
@@ -0,0 +1,38 @@
import 'package:em2rp/services/api_service.dart';
/// Repository pour gérer toutes les opérations sur les alertes.
class AlertRepository {
final ApiService _apiService;
AlertRepository(this._apiService);
/// Récupère toutes les alertes
Future<List<Map<String, dynamic>>> getAlerts() async {
try {
final result = await _apiService.call('getAlerts', {});
final alerts = result['alerts'] as List<dynamic>?;
if (alerts == null) return [];
return alerts.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des alertes: $e');
}
}
/// Marque une alerte comme lue
Future<void> markAlertAsRead(String alertId) async {
try {
await _apiService.call('markAlertAsRead', {'alertId': alertId});
} catch (e) {
throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e');
}
}
/// Supprime une alerte
Future<void> deleteAlert(String alertId) async {
try {
await _apiService.call('deleteAlert', {'alertId': alertId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'alerte: $e');
}
}
}
@@ -0,0 +1,128 @@
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Repository pour gérer toutes les opérations sur les conteneurs.
class ContainerRepository {
final ApiService _apiService;
ContainerRepository(this._apiService);
/// Récupère tous les conteneurs
Future<List<Map<String, dynamic>>> getContainers() async {
try {
final result = await _apiService.call('getContainers', {});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des conteneurs: $e');
}
}
/// Récupère plusieurs containers par leurs IDs
Future<List<Map<String, dynamic>>> getContainersByIds(
List<String> containerIds) async {
try {
if (containerIds.isEmpty) return [];
print(
'[ContainerRepository] Getting containers by IDs: ${containerIds.length} items');
final result = await _apiService.call('getContainersByIds', {
'containerIds': containerIds,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) {
print('[ContainerRepository] No containers in result');
return [];
}
print('[ContainerRepository] Found ${containers.length} containers by IDs');
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[ContainerRepository] Error getting containers by IDs: $e');
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
/// Récupère les containers avec pagination et filtrage
Future<Map<String, dynamic>> getContainersPaginated({
int limit = 20,
String? startAfter,
String? type,
String? status,
String? searchQuery,
String? category,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (type != null) params['type'] = type;
if (status != null) params['status'] = status;
if (category != null) params['category'] = category;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result =
await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getContainersPaginated',
params,
);
return {
'containers': (result['containers'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ??
[],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) {
DebugLog.error('[ContainerRepository] Error in getContainersPaginated', e);
throw Exception(
'Erreur lors de la récupération paginée des containers: $e');
}
}
/// Récupère les containers contenant un équipement
Future<List<Map<String, dynamic>>> getContainersByEquipment(
String equipmentId) async {
try {
final result = await _apiService.call('getContainersByEquipment', {
'equipmentId': equipmentId,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
/// Vérifie la disponibilité d'un container
Future<Map<String, dynamic>> checkContainerAvailability({
required String containerId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
try {
final result = await _apiService.call('checkContainerAvailability', {
'containerId': containerId,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
if (excludeEventId != null) 'excludeEventId': excludeEventId,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la vérification de disponibilité du container: $e');
}
}
}
@@ -0,0 +1,350 @@
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Repository pour gérer toutes les opérations sur les équipements.
class EquipmentRepository {
final ApiService _apiService;
EquipmentRepository(this._apiService);
/// Récupère tous les équipements (avec masquage des prix selon permissions)
Future<List<Map<String, dynamic>>> getEquipments() async {
try {
print('[EquipmentRepository] Calling getEquipments API...');
final result = await _apiService.call('getEquipments', {});
print('[EquipmentRepository] API call successful, parsing result...');
final equipments = result['equipments'] as List<dynamic>?;
if (equipments == null) {
print('[EquipmentRepository] No equipments in result');
return [];
}
print('[EquipmentRepository] Found ${equipments.length} equipments');
return equipments.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[EquipmentRepository] Error getting equipments: $e');
throw Exception('Erreur lors de la récupération des équipements: $e');
}
}
/// Récupère plusieurs équipements par leurs IDs
Future<List<Map<String, dynamic>>> getEquipmentsByIds(
List<String> equipmentIds) async {
try {
if (equipmentIds.isEmpty) return [];
print(
'[EquipmentRepository] Getting equipments by IDs: ${equipmentIds.length} items');
final result = await _apiService.call('getEquipmentsByIds', {
'equipmentIds': equipmentIds,
});
final equipments = result['equipments'] as List<dynamic>?;
if (equipments == null) {
print('[EquipmentRepository] No equipments in result');
return [];
}
print('[EquipmentRepository] Found ${equipments.length} equipments by IDs');
return equipments.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[EquipmentRepository] Error getting equipments by IDs: $e');
throw Exception('Erreur lors de la récupération des équipements: $e');
}
}
/// Récupère les équipements avec pagination et filtrage
Future<Map<String, dynamic>> getEquipmentsPaginated({
int limit = 20,
String? startAfter,
String? category,
String? status,
String? searchQuery,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (category != null) params['category'] = category;
if (status != null) params['status'] = status;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result =
await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getEquipmentsPaginated',
params,
);
return {
'equipments': (result['equipments'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ??
[],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) {
DebugLog.error('[EquipmentRepository] Error in getEquipmentsPaginated', e);
throw Exception(
'Erreur lors de la récupération paginée des équipements: $e');
}
}
/// Crée un équipement
Future<void> createEquipment(
String equipmentId, Map<String, dynamic> data) async {
try {
final equipmentData = Map<String, dynamic>.from(data);
equipmentData['id'] = equipmentId;
await _apiService.call('createEquipment', equipmentData);
} catch (e) {
throw Exception('Erreur lors de la création de l\'équipement: $e');
}
}
/// Met à jour un équipement
Future<void> updateEquipment(
String equipmentId, Map<String, dynamic> data) async {
try {
await _apiService.call('updateEquipment', {
'equipmentId': equipmentId,
'data': data,
});
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
}
}
/// Supprime un équipement
Future<void> deleteEquipment(String equipmentId,
{bool forceDelete = false}) async {
try {
await _apiService.call('deleteEquipment', {
'equipmentId': equipmentId,
'forceDelete': forceDelete,
});
} on ApiException {
rethrow;
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
}
}
/// Met à jour uniquement le statut d'un équipement
Future<void> updateEquipmentStatusOnly({
required String equipmentId,
String? status,
int? availableQuantity,
}) async {
try {
final data = <String, dynamic>{'equipmentId': equipmentId};
if (status != null) data['status'] = status;
if (availableQuantity != null) {
data['availableQuantity'] = availableQuantity;
}
await _apiService.call('updateEquipmentStatusOnly', data);
} catch (e) {
throw Exception(
'Erreur lors de la mise à jour du statut de l\'équipement: $e');
}
}
/// Recherche rapide (autocomplétion)
Future<List<Map<String, dynamic>>> quickSearch(
String query, {
int limit = 10,
bool includeEquipments = true,
bool includeContainers = true,
}) async {
try {
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
query,
limit: limit,
includeEquipments: includeEquipments,
includeContainers: includeContainers,
);
} catch (e) {
DebugLog.error('[EquipmentRepository] Error in quickSearch', e);
return [];
}
}
/// Recherche des équipements pour l'assistant IA avec fallback paginé.
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({
required String query,
int limit = 12,
}) async {
final normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
return [];
}
try {
final quickResults = await quickSearch(
normalizedQuery,
limit: limit,
includeEquipments: true,
includeContainers: false,
);
final equipmentResults = quickResults
.where((item) =>
(item['type']?.toString().toLowerCase() ?? '') == 'equipment')
.map(_normalizeAssistantEquipment)
.toList();
if (equipmentResults.isNotEmpty) {
return equipmentResults;
}
final paginated = await getEquipmentsPaginated(
limit: limit,
searchQuery: normalizedQuery,
sortBy: 'id',
sortOrder: 'asc',
);
final equipments =
paginated['equipments'] as List<Map<String, dynamic>>? ?? [];
return equipments.map(_normalizeAssistantEquipment).toList();
} catch (e) {
DebugLog.error('[EquipmentRepository] Error in searchEquipmentsForAssistant', e);
throw Exception('Erreur lors de la recherche de matériel: $e');
}
}
/// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA.
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
required String equipmentId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
try {
final result = await checkEquipmentAvailability(
equipmentId: equipmentId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
final available = result['available'] as bool? ?? true;
final conflicts = (result['conflicts'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map((conflict) {
final eventData =
conflict['eventData'] as Map<String, dynamic>? ?? const {};
final eventName =
(eventData['Name'] ?? conflict['eventName'] ?? '').toString();
return {
'eventId': conflict['eventId']?.toString() ?? '',
'eventName': eventName,
'overlapDays': conflict['overlapDays'] as int? ?? 0,
};
}).toList();
return {
'equipmentId': equipmentId,
'available': available,
'conflictCount': conflicts.length,
'conflicts': conflicts,
};
} catch (e) {
DebugLog.error(
'[EquipmentRepository] Error in checkEquipmentAvailabilityForAssistant', e);
throw Exception('Erreur lors de la vérification de disponibilité: $e');
}
}
/// Vérifie la disponibilité d'un équipement
Future<Map<String, dynamic>> checkEquipmentAvailability({
required String equipmentId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
try {
final result = await _apiService.call('checkEquipmentAvailability', {
'equipmentId': equipmentId,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
if (excludeEventId != null) 'excludeEventId': excludeEventId,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la vérification de disponibilité: $e');
}
}
/// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période
/// Optimisé : une seule requête au lieu d'une par équipement
Future<Map<String, dynamic>> getConflictingEquipmentIds({
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
int installationTime = 0,
int disassemblyTime = 0,
}) async {
try {
final result = await _apiService.call('getConflictingEquipmentIds', {
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
if (excludeEventId != null) 'excludeEventId': excludeEventId,
'installationTime': installationTime,
'disassemblyTime': disassemblyTime,
});
return result;
} catch (e) {
throw Exception(
'Erreur lors de la récupération des équipements en conflit: $e');
}
}
Map<String, dynamic> _normalizeAssistantEquipment(Map<String, dynamic> item) {
return {
'id': (item['id'] ?? '').toString(),
'name': (item['name'] ?? item['id'] ?? '').toString(),
'category': (item['category'] ?? '').toString(),
'status': (item['status'] ?? '').toString(),
'brand': item['brand']?.toString(),
'model': item['model']?.toString(),
'availableQuantity': item['availableQuantity'],
'totalQuantity': item['totalQuantity'],
};
}
/// Récupère toutes les maintenances
Future<List<Map<String, dynamic>>> getMaintenances(
{String? equipmentId}) async {
try {
final data = <String, dynamic>{};
if (equipmentId != null) data['equipmentId'] = equipmentId;
final result = await _apiService.call('getMaintenances', data);
final maintenances = result['maintenances'] as List<dynamic>?;
if (maintenances == null) return [];
return maintenances.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des maintenances: $e');
}
}
/// Supprime une maintenance
Future<void> deleteMaintenance(String maintenanceId) async {
try {
await _apiService
.call('deleteMaintenance', {'maintenanceId': maintenanceId});
} catch (e) {
throw Exception('Erreur lors de la suppression de la maintenance: $e');
}
}
}
@@ -0,0 +1,179 @@
import 'package:em2rp/services/api_service.dart';
/// Repository pour gérer toutes les opérations sur les événements.
class EventRepository {
final ApiService _apiService;
EventRepository(this._apiService);
/// Met à jour les équipements d'un événement
Future<void> updateEventEquipment({
required String eventId,
List<Map<String, dynamic>>? assignedEquipment,
String? preparationStatus,
String? loadingStatus,
String? unloadingStatus,
String? returnStatus,
}) async {
try {
final data = <String, dynamic>{'eventId': eventId};
if (assignedEquipment != null) {
data['assignedEquipment'] = assignedEquipment;
}
if (preparationStatus != null) {
data['preparationStatus'] = preparationStatus;
}
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
if (returnStatus != null) data['returnStatus'] = returnStatus;
await _apiService.call('updateEventEquipment', data);
} catch (e) {
throw Exception(
'Erreur lors de la mise à jour des équipements de l\'événement: $e');
}
}
/// Met à jour un événement
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
try {
final requestData = {'eventId': eventId, ...data};
await _apiService.call('updateEvent', requestData);
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
}
}
/// Supprime un événement
Future<void> deleteEvent(String eventId) async {
try {
await _apiService.call('deleteEvent', {'eventId': eventId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'événement: $e');
}
}
/// Récupère les événements utilisant un type d'événement donné
Future<List<Map<String, dynamic>>> getEventsByEventType(
String eventTypeId) async {
try {
final result = await _apiService
.call('getEventsByEventType', {'eventTypeId': eventTypeId});
final events = result['events'] as List<dynamic>?;
if (events == null) return [];
return events.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des événements: $e');
}
}
/// Récupère tous les événements (filtrés selon permissions)
/// Retourne { events: List<Map>, users: Map<String, Map> }
Future<Map<String, dynamic>> getEvents({String? userId}) async {
try {
final data = <String, dynamic>{};
if (userId != null) data['userId'] = userId;
final result = await _apiService.call('getEvents', data);
// Extraire events et users
final events = result['events'] as List<dynamic>? ?? [];
final users = result['users'] as Map<String, dynamic>? ?? {};
return {
'events': events.map((e) => e as Map<String, dynamic>).toList(),
'users': users,
};
} catch (e) {
throw Exception('Erreur lors de la récupération des événements: $e');
}
}
/// Récupère les événements d'un mois spécifique (lazy loading optimisé)
Future<Map<String, dynamic>> getEventsByMonth({
required String userId,
required int year,
required int month,
}) async {
try {
print('[EventRepository] Calling getEventsByMonth for $year-$month');
final result = await _apiService.call('getEventsByMonth', {
'userId': userId,
'year': year,
'month': month,
});
// Extraire events et users
final events = result['events'] as List<dynamic>? ?? [];
final users = result['users'] as Map<String, dynamic>? ?? {};
print(
'[EventRepository] Events loaded for $year-$month: ${events.length} events');
return {
'events': events.map((e) => e as Map<String, dynamic>).toList(),
'users': users,
};
} catch (e) {
print('[EventRepository] Error getting events by month: $e');
throw Exception(
'Erreur lors de la récupération des événements du mois: $e');
}
}
/// Recherche des événements accessibles à l'utilisateur.
Future<List<Map<String, dynamic>>> searchEvents({
required String userId,
required String query,
int limit = 20,
}) async {
try {
final result = await _apiService.call('searchEvents', {
'userId': userId,
'query': query,
'limit': limit,
});
final events = result['events'] as List<dynamic>?;
if (events == null) {
return [];
}
return events.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la recherche d\'événements: $e');
}
}
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
try {
print('[EventRepository] Getting event with details: $eventId');
final result = await _apiService.call('getEventWithDetails', {
'eventId': eventId,
});
final event = result['event'] as Map<String, dynamic>?;
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
final containers = result['containers'] as Map<String, dynamic>? ?? {};
if (event == null) {
throw Exception('Event not found');
}
print(
'[EventRepository] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
return {
'event': event,
'equipments': equipments,
'containers': containers,
};
} catch (e) {
print('[EventRepository] Error getting event with details: $e');
throw Exception(
'Erreur lors de la récupération de l\'événement avec détails: $e');
}
}
}
@@ -0,0 +1,109 @@
import 'package:em2rp/services/api_service.dart';
/// Repository pour gérer toutes les opérations sur les options et types d'événements.
class OptionRepository {
final ApiService _apiService;
OptionRepository(this._apiService);
/// Récupère toutes les options
Future<List<Map<String, dynamic>>> getOptions() async {
try {
final result = await _apiService.call('getOptions', {});
final options = result['options'] as List<dynamic>?;
if (options == null) return [];
return options.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des options: $e');
}
}
/// Récupère tous les types d'événements
Future<List<Map<String, dynamic>>> getEventTypes() async {
try {
final result = await _apiService.call('getEventTypes', {});
final eventTypes = result['eventTypes'] as List<dynamic>?;
if (eventTypes == null) return [];
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception(
'Erreur lors de la récupération des types d\'événements: $e');
}
}
/// Crée un type d'événement
Future<String> createEventType({
required String name,
required double defaultPrice,
}) async {
try {
final result = await _apiService.call('createEventType', {
'name': name,
'defaultPrice': defaultPrice,
});
return result['id'] as String;
} catch (e) {
throw Exception('Erreur lors de la création du type d\'événement: $e');
}
}
/// Met à jour un type d'événement
Future<void> updateEventType({
required String eventTypeId,
String? name,
double? defaultPrice,
}) async {
try {
final data = <String, dynamic>{'eventTypeId': eventTypeId};
if (name != null) data['name'] = name;
if (defaultPrice != null) data['defaultPrice'] = defaultPrice;
await _apiService.call('updateEventType', data);
} catch (e) {
throw Exception('Erreur lors de la mise à jour du type d\'événement: $e');
}
}
/// Supprime un type d'événement
Future<void> deleteEventType(String eventTypeId) async {
try {
await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId});
} catch (e) {
throw Exception('Erreur lors de la suppression du type d\'événement: $e');
}
}
/// Crée une option
Future<String> createOption(String code, Map<String, dynamic> data) async {
try {
final requestData = {
'id': code,
'code': code,
...data
};
final result = await _apiService.call('createOption', requestData);
return result['id'] as String? ?? code;
} catch (e) {
throw Exception('Erreur lors de la création de l\'option: $e');
}
}
/// Met à jour une option
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
try {
final requestData = {'optionId': optionId, 'data': data};
await _apiService.call('updateOption', requestData);
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
}
}
/// Supprime une option
Future<void> deleteOption(String optionId) async {
try {
await _apiService.call('deleteOption', {'optionId': optionId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'option: $e');
}
}
}
@@ -0,0 +1,99 @@
import 'package:em2rp/services/api_service.dart';
/// Repository pour gérer toutes les opérations sur les utilisateurs et les rôles.
class UserRepository {
final ApiService _apiService;
UserRepository(this._apiService);
/// Récupère l'utilisateur actuellement authentifié avec son rôle
Future<Map<String, dynamic>> getCurrentUser() async {
try {
print('[UserRepository] Calling getCurrentUser API...');
final result = await _apiService.call('getCurrentUser', {});
print('[UserRepository] Current user loaded successfully');
return result['user'] as Map<String, dynamic>;
} catch (e) {
print('[UserRepository] Error getting current user: $e');
throw Exception(
'Erreur lors de la récupération de l\'utilisateur actuel: $e');
}
}
/// Récupère tous les utilisateurs (selon permissions)
Future<List<Map<String, dynamic>>> getUsers() async {
try {
final result = await _apiService.call('getUsers', {});
final users = result['users'] as List<dynamic>?;
if (users == null) return [];
return users.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des utilisateurs: $e');
}
}
/// Récupère un utilisateur spécifique
Future<Map<String, dynamic>> getUser(String userId) async {
try {
final result = await _apiService.call('getUser', {'userId': userId});
return result['user'] as Map<String, dynamic>;
} catch (e) {
throw Exception('Erreur lors de la récupération de l\'utilisateur: $e');
}
}
/// Supprime un utilisateur (Auth + Firestore)
Future<void> deleteUser(String userId) async {
try {
await _apiService.call('deleteUser', {'userId': userId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'utilisateur: $e');
}
}
/// Met à jour un utilisateur
Future<void> updateUser(String userId, Map<String, dynamic> data) async {
try {
await _apiService.call('updateUser', {
'userId': userId,
'data': data,
});
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e');
}
}
/// Crée un utilisateur avec invitation par email
Future<Map<String, dynamic>> createUserWithInvite({
required String email,
required String firstName,
required String lastName,
String? phoneNumber,
required String roleId,
}) async {
try {
final result = await _apiService.call('createUserWithInvite', {
'email': email,
'firstName': firstName,
'lastName': lastName,
'phoneNumber': phoneNumber ?? '',
'roleId': roleId,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la création de l\'utilisateur: $e');
}
}
/// Récupère tous les rôles
Future<List<Map<String, dynamic>>> getRoles() async {
try {
final result = await _apiService.call('getRoles', {});
final roles = result['roles'] as List<dynamic>?;
if (roles == null) return [];
return roles.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des rôles: $e');
}
}
}
@@ -0,0 +1,280 @@
import 'dart:async';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Représente un tour de conversation dans le chat.
class AiAssistantChatTurn {
final bool isUser;
final String text;
const AiAssistantChatTurn({required this.isUser, required this.text});
}
/// Document à attacher pour demander à l'IA d'analyser un devis, etc.
class AiEquipmentDocument {
final String base64Data;
final String mimeType;
final String? fileName;
const AiEquipmentDocument({
required this.base64Data,
required this.mimeType,
this.fileName,
});
}
/// Un item proposé par l'IA dans la liste de matériel.
class AiEquipmentProposalItem {
final String equipmentId;
final int quantity;
final String rationale;
const AiEquipmentProposalItem({
required this.equipmentId,
required this.quantity,
required this.rationale,
});
}
/// Métadonnées pour un container proposé par l'IA.
class AiEquipmentProposalContainer {
final String containerId;
final String rationale;
final List<String> equipmentIds;
final List<String> matchingEquipmentIds;
final List<String> missingEquipmentIds;
final bool partial;
final bool? available;
final dynamic availabilityDetail;
const AiEquipmentProposalContainer({
required this.containerId,
required this.rationale,
this.equipmentIds = const [],
this.matchingEquipmentIds = const [],
this.missingEquipmentIds = const [],
this.partial = false,
this.available,
this.availabilityDetail,
});
}
/// Proposition complète retournée par l'IA.
class AiEquipmentProposal {
final String summary;
final List<AiEquipmentProposalItem> items;
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
final List<EventEquipment> asEventEquipment;
/// Containers (métadonnées) proposés par l'IA.
final List<AiEquipmentProposalContainer> containers;
List<String> get containerIds => containers.map((c) => c.containerId).toList();
const AiEquipmentProposal({
required this.summary,
required this.items,
required this.asEventEquipment,
required this.containers,
});
}
/// Réponse complète de l'assistant IA (message + proposition optionnelle).
class AiEquipmentAssistantResponse {
final String assistantMessage;
final AiEquipmentProposal? proposal;
final List<String> debugLogs;
const AiEquipmentAssistantResponse({
required this.assistantMessage,
this.proposal,
this.debugLogs = const [],
});
}
/// Service assistant IA logisticien.
/// Délègue tous les appels Gemini à la Cloud Function [aiEquipmentProposal].
/// L'authentification Firebase (token Bearer) suffit — aucune clé API côté client.
class AiEquipmentAssistantService {
final ApiService _apiService;
AiEquipmentAssistantService({ApiService? apiService})
: _apiService = apiService ?? FirebaseFunctionsApiService();
/// Envoie un message et retourne la réponse de l'assistant IA.
Future<AiEquipmentAssistantResponse> generateProposal({
required DateTime startDate,
required DateTime endDate,
required List<AiAssistantChatTurn> history,
required String userMessage,
String? eventTypeId,
String? excludeEventId,
List<EventEquipment> currentAssignedEquipment = const [],
List<EventEquipment> workingProposalEquipment = const [],
AiEquipmentDocument? document,
}) async {
final payload = <String, dynamic>{
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
'userMessage': userMessage.trim(),
'history': history
.where((turn) => turn.text.trim().isNotEmpty)
.map((turn) => {'isUser': turn.isUser, 'text': turn.text.trim()})
.toList(),
'currentEquipment': currentAssignedEquipment
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
.toList(),
'workingProposal': workingProposalEquipment
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
.toList(),
};
if (eventTypeId != null) payload['eventTypeId'] = eventTypeId;
if (excludeEventId != null) payload['excludeEventId'] = excludeEventId;
if (document != null) {
payload['document'] = {
'mimeType': document.mimeType,
'data': document.base64Data,
if (document.fileName != null) 'fileName': document.fileName,
};
}
try {
DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function');
final result = await _apiService.call('aiEquipmentProposal', payload);
final assistantMessage = result['assistantMessage']?.toString().trim() ?? '';
final proposal = _parseProposal(result['proposal']);
final rawLogs = result['debugLogs'];
final debugLogs = (rawLogs is List) ? rawLogs.map((e) => e.toString()).toList() : <String>[];
DebugLog.info(
'[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}',
);
return AiEquipmentAssistantResponse(
assistantMessage: assistantMessage.isNotEmpty
? assistantMessage
: 'Je n\'ai pas pu générer de réponse.',
proposal: proposal,
debugLogs: debugLogs,
);
} on ApiException catch (e) {
DebugLog.error('[AiEquipmentAssistantService] API error', e);
if (e.isUnauthorized) {
throw Exception('Vous n\'êtes pas authentifié. Reconnectez-vous et réessayez.');
}
throw Exception('Erreur du service IA (${e.statusCode}): ${e.message}');
} catch (e) {
DebugLog.error('[AiEquipmentAssistantService] Error', e);
rethrow;
}
}
AiEquipmentProposal? _parseProposal(dynamic rawProposal) {
if (rawProposal == null || rawProposal is! Map<String, dynamic>) return null;
final proposalItems = <AiEquipmentProposalItem>[];
final eventEquipmentList = <EventEquipment>[];
// legacy containerIds variable removed (we now use containersMeta)
final rawItems = rawProposal['items'];
if (rawItems is List) {
for (final rawItem in rawItems) {
if (rawItem is! Map) continue;
final item = Map<String, dynamic>.from(rawItem);
final equipmentId = item['equipmentId']?.toString().trim() ?? '';
final quantity = int.tryParse(item['quantity']?.toString() ?? '1') ?? 1;
if (equipmentId.isEmpty || quantity <= 0) continue;
final rationale = item['rationale']?.toString().trim() ?? 'Proposition IA';
proposalItems.add(AiEquipmentProposalItem(
equipmentId: equipmentId,
quantity: quantity,
rationale: rationale,
));
eventEquipmentList.add(EventEquipment(
equipmentId: equipmentId,
quantity: quantity,
rationale: rationale,
));
}
}
final containersMeta = <AiEquipmentProposalContainer>[];
final rawContainers = rawProposal['containers'];
if (rawContainers is List) {
for (final rawContainer in rawContainers) {
if (rawContainer is String) {
final cid = rawContainer.toString().trim();
if (cid.isNotEmpty) {
containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA'));
}
continue;
}
if (rawContainer is! Map) continue;
final container = Map<String, dynamic>.from(rawContainer);
final containerId = container['containerId']?.toString().trim() ?? '';
if (containerId.isEmpty) continue;
final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA';
final equipmentIds = <String>[];
final matching = <String>[];
final missing = <String>[];
if (container['equipmentIds'] is List) {
for (final v in container['equipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) equipmentIds.add(s);
}
}
if (container['matchingEquipmentIds'] is List) {
for (final v in container['matchingEquipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) matching.add(s);
}
}
if (container['missingEquipmentIds'] is List) {
for (final v in container['missingEquipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) missing.add(s);
}
}
final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty);
final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null;
final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null;
containersMeta.add(AiEquipmentProposalContainer(
containerId: containerId,
rationale: rationale,
equipmentIds: equipmentIds,
matchingEquipmentIds: matching,
missingEquipmentIds: missing,
partial: partial,
available: available,
availabilityDetail: availabilityDetail,
));
}
}
if (proposalItems.isEmpty && containersMeta.isEmpty) return null;
return AiEquipmentProposal(
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
? rawProposal['summary'].toString().trim()
: 'Proposition matériel générée automatiquement.',
items: proposalItems,
asEventEquipment: eventEquipmentList,
containers: containersMeta,
);
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import 'api_service.dart' show FirebaseFunctionsApiService;
/// Architecture simplifiée : le client appelle uniquement les Cloud Functions
/// Toute la logique métier est gérée côté backend
class AlertService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseAuth _auth = FirebaseAuth.instance;
FirebaseFirestore get _firestore => FirebaseFirestore.instance;
FirebaseAuth get _auth => FirebaseAuth.instance;
/// Stream des alertes pour l'utilisateur connecté
Stream<List<AlertModel>> getAlertsStream() {
+2
View File
@@ -173,6 +173,8 @@ class FirebaseFunctionsApiService implements ApiService {
statusCode: response.statusCode,
);
}
} on ApiException {
rethrow;
} catch (e) {
DebugLog.error('[API] Error during request: $functionName', e);
throw ApiException(
+79
View File
@@ -0,0 +1,79 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../firebase_options.dart';
import '../config/api_config.dart';
import 'cache_service.dart';
/// Service responsable des initialisations lourdes en tâche de fond.
///
/// Objectif : réduire au maximum le travail synchrone dans main(),
/// afficher immédiatement une UI minimale, puis effectuer l'init asynchrone.
class AppInitializer with ChangeNotifier {
bool _isInitialized = false;
bool _isInitializing = false;
bool get isInitialized => _isInitialized;
bool get isInitializing => _isInitializing;
final CacheService cacheService = CacheService();
/// Démarre l'initialisation asynchrone. Idempotent.
Future<void> initialize() async {
if (_isInitialized || _isInitializing) return;
_isInitializing = true;
scheduleMicrotask(() => notifyListeners());
try {
// Initialiser Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Configurer les émulateurs en dev si demandé
if (ApiConfig.isDevelopment) {
try {
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
} catch (e) {
// Ignorer si non supporté
if (kDebugMode) print('Emulator setup failed: $e');
}
}
// Initialiser le cache local sans bloquer l'écran de démarrage.
unawaited(cacheService.init());
// Précharger des assets critiques de façon asynchrone
unawaited(_preloadAssets());
// TODO: lancer ici d'autres initialisations non bloquantes
_isInitialized = true;
_isInitializing = false;
notifyListeners();
} catch (e, st) {
if (kDebugMode) print('AppInitializer failed: $e\n$st');
_isInitializing = false;
// Ne rethrow pas pour éviter de planter l'app; laisser l'UI gérer les erreurs.
notifyListeners();
}
}
Future<void> _preloadAssets() async {
try {
// Charger quelques assets en mémoire pour rendre l'affichage initial fluide
await rootBundle.load('assets/logos/RectangleLogoBlack.png');
await rootBundle.load('assets/logos/SquareLogoWhite.png');
} catch (e) {
if (kDebugMode) print('Preload assets failed: $e');
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Service simple de cache local basé sur SharedPreferences.
///
/// Fonctionne sur mobile et sur Flutter Web pour conserver des données
/// locales légères quand cela apporte une vraie valeur.
class CacheService {
SharedPreferences? _prefs;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
bool ready() => _prefs != null;
Future<void> setJson(String key, Map<String, dynamic> value) async {
if (_prefs == null) return;
await _prefs!.setString(key, jsonEncode(value));
}
Map<String, dynamic>? getJson(String key) {
if (_prefs == null) return null;
final s = _prefs!.getString(key);
if (s == null) return null;
try {
return jsonDecode(s) as Map<String, dynamic>;
} catch (e) {
if (kDebugMode) print('CacheService getJson error: $e');
return null;
}
}
Future<void> setString(String key, String value) async {
if (_prefs == null) return;
await _prefs!.setString(key, value);
}
String? getString(String key) => _prefs?.getString(key);
}
+13 -36
View File
@@ -141,7 +141,6 @@ class ContainerService {
}
}
/// Vérifier la disponibilité d'un container et de son contenu pour un événement
Future<Map<String, dynamic>> checkContainerAvailability({
required String containerId,
required DateTime startDate,
@@ -149,43 +148,21 @@ class ContainerService {
String? excludeEventId,
}) async {
try {
final container = await getContainerById(containerId);
if (container == null) {
return {'available': false, 'message': 'Container non trouvé'};
}
// Vérifier le statut du container
if (container.status != EquipmentStatus.available) {
final result = await _dataService.checkContainerAvailability(
containerId: containerId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
return {
'available': false,
'message': 'Container ${container.name} n\'est pas disponible (statut: ${container.status})',
'available': result['isAvailable'] ?? false,
'message': result['isAvailable'] == true
? 'Container et tout son contenu disponibles'
: 'Container non disponible ou en conflit',
'conflictType': result['conflictType'],
'containerConflicts': result['containerConflicts'],
'equipmentConflicts': result['equipmentConflicts'],
};
}
// Vérifier la disponibilité de chaque équipement dans le container
List<String> unavailableEquipment = [];
if (container.equipmentIds.isNotEmpty) {
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
for (var data in equipmentsData) {
final id = data['id'] as String;
final equipment = EquipmentModel.fromMap(data, id);
if (equipment.status != EquipmentStatus.available) {
unavailableEquipment.add('${equipment.name} (${equipment.status})');
}
}
}
if (unavailableEquipment.isNotEmpty) {
return {
'available': false,
'message': 'Certains équipements ne sont pas disponibles',
'unavailableItems': unavailableEquipment,
};
}
return {'available': true, 'message': 'Container et tout son contenu disponibles'};
} catch (e) {
print('Error checking container availability: $e');
return {'available': false, 'message': 'Erreur: $e'};
+200 -641
View File
@@ -1,49 +1,55 @@
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/repositories/event_repository.dart';
import 'package:em2rp/repositories/equipment_repository.dart';
import 'package:em2rp/repositories/container_repository.dart';
import 'package:em2rp/repositories/alert_repository.dart';
import 'package:em2rp/repositories/user_repository.dart';
import 'package:em2rp/repositories/option_repository.dart';
/// Service générique pour les opérations de lecture de données via Cloud Functions
/// Service façade pour rétrocompatibilité.
/// Délègue les opérations aux Repositories de domaine respectifs.
class DataService {
final ApiService _apiService;
final EventRepository eventRepository;
final EquipmentRepository equipmentRepository;
final ContainerRepository containerRepository;
final AlertRepository alertRepository;
final UserRepository userRepository;
final OptionRepository optionRepository;
DataService(this._apiService);
DataService(ApiService apiService)
: eventRepository = EventRepository(apiService),
equipmentRepository = EquipmentRepository(apiService),
containerRepository = ContainerRepository(apiService),
alertRepository = AlertRepository(apiService),
userRepository = UserRepository(apiService),
optionRepository = OptionRepository(apiService);
/// Récupère toutes les options
Future<List<Map<String, dynamic>>> getOptions() async {
try {
final result = await _apiService.call('getOptions', {});
final options = result['options'] as List<dynamic>?;
if (options == null) return [];
return options.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des options: $e');
}
}
// ============================================================================
// OPTIONS & METADATA (delegated to OptionRepository)
// ============================================================================
Future<List<Map<String, dynamic>>> getOptions() => optionRepository.getOptions();
/// Récupère tous les types d'événements
Future<List<Map<String, dynamic>>> getEventTypes() async {
try {
final result = await _apiService.call('getEventTypes', {});
final eventTypes = result['eventTypes'] as List<dynamic>?;
if (eventTypes == null) return [];
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des types d\'événements: $e');
}
}
Future<List<Map<String, dynamic>>> getEventTypes() => optionRepository.getEventTypes();
/// Récupère tous les rôles
Future<List<Map<String, dynamic>>> getRoles() async {
try {
final result = await _apiService.call('getRoles', {});
final roles = result['roles'] as List<dynamic>?;
if (roles == null) return [];
return roles.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des rôles: $e');
}
}
Future<String> createEventType({required String name, required double defaultPrice}) =>
optionRepository.createEventType(name: name, defaultPrice: defaultPrice);
/// Met à jour les équipements d'un événement
Future<void> updateEventType({required String eventTypeId, String? name, double? defaultPrice}) =>
optionRepository.updateEventType(eventTypeId: eventTypeId, name: name, defaultPrice: defaultPrice);
Future<void> deleteEventType(String eventTypeId) => optionRepository.deleteEventType(eventTypeId);
Future<String> createOption(String code, Map<String, dynamic> data) =>
optionRepository.createOption(code, data);
Future<void> updateOption(String optionId, Map<String, dynamic> data) =>
optionRepository.updateOption(optionId, data);
Future<void> deleteOption(String optionId) => optionRepository.deleteOption(optionId);
// ============================================================================
// EVENTS (delegated to EventRepository)
// ============================================================================
Future<void> updateEventEquipment({
required String eventId,
List<Map<String, dynamic>>? assignedEquipment,
@@ -51,347 +57,45 @@ class DataService {
String? loadingStatus,
String? unloadingStatus,
String? returnStatus,
}) async {
try {
final data = <String, dynamic>{'eventId': eventId};
}) =>
eventRepository.updateEventEquipment(
eventId: eventId,
assignedEquipment: assignedEquipment,
preparationStatus: preparationStatus,
loadingStatus: loadingStatus,
unloadingStatus: unloadingStatus,
returnStatus: returnStatus,
);
if (assignedEquipment != null) data['assignedEquipment'] = assignedEquipment;
if (preparationStatus != null) data['preparationStatus'] = preparationStatus;
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
if (returnStatus != null) data['returnStatus'] = returnStatus;
Future<void> updateEvent(String eventId, Map<String, dynamic> data) =>
eventRepository.updateEvent(eventId, data);
await _apiService.call('updateEventEquipment', data);
} catch (e) {
throw Exception('Erreur lors de la mise à jour des équipements de l\'événement: $e');
}
}
Future<void> deleteEvent(String eventId) => eventRepository.deleteEvent(eventId);
/// Met à jour uniquement le statut d'un équipement
Future<void> updateEquipmentStatusOnly({
required String equipmentId,
String? status,
int? availableQuantity,
}) async {
try {
final data = <String, dynamic>{'equipmentId': equipmentId};
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) =>
eventRepository.getEventsByEventType(eventTypeId);
if (status != null) data['status'] = status;
if (availableQuantity != null) data['availableQuantity'] = availableQuantity;
Future<Map<String, dynamic>> getEvents({String? userId}) =>
eventRepository.getEvents(userId: userId);
await _apiService.call('updateEquipmentStatusOnly', data);
} catch (e) {
throw Exception('Erreur lors de la mise à jour du statut de l\'équipement: $e');
}
}
Future<Map<String, dynamic>> getEventsByMonth({required String userId, required int year, required int month}) =>
eventRepository.getEventsByMonth(userId: userId, year: year, month: month);
/// Met à jour un événement
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
try {
// Correction : fusionner eventId et les champs de data à la racine
final requestData = {'eventId': eventId, ...data};
await _apiService.call('updateEvent', requestData);
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
}
}
Future<List<Map<String, dynamic>>> searchEvents({required String userId, required String query, int limit = 20}) =>
eventRepository.searchEvents(userId: userId, query: query, limit: limit);
/// Supprime un événement
Future<void> deleteEvent(String eventId) async {
try {
await _apiService.call('deleteEvent', {'eventId': eventId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'événement: $e');
}
}
/// Crée un équipement
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) async {
try {
// S'assurer que l'ID est dans les données
final equipmentData = Map<String, dynamic>.from(data);
equipmentData['id'] = equipmentId;
await _apiService.call('createEquipment', equipmentData);
} catch (e) {
throw Exception('Erreur lors de la création de l\'équipement: $e');
}
}
/// Met à jour un équipement
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) async {
try {
await _apiService.call('updateEquipment', {
'equipmentId': equipmentId,
'data': data,
});
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
}
}
/// Supprime un équipement
Future<void> deleteEquipment(String equipmentId) async {
try {
await _apiService.call('deleteEquipment', {'equipmentId': equipmentId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
}
}
/// Récupère les événements utilisant un type d'événement donné
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) async {
try {
final result = await _apiService.call('getEventsByEventType', {'eventTypeId': eventTypeId});
final events = result['events'] as List<dynamic>?;
if (events == null) return [];
return events.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des événements: $e');
}
}
/// Crée un type d'événement
Future<String> createEventType({
required String name,
required double defaultPrice,
}) async {
try {
final result = await _apiService.call('createEventType', {
'name': name,
'defaultPrice': defaultPrice,
});
return result['id'] as String;
} catch (e) {
throw Exception('Erreur lors de la création du type d\'événement: $e');
}
}
/// Met à jour un type d'événement
Future<void> updateEventType({
required String eventTypeId,
String? name,
double? defaultPrice,
}) async {
try {
final data = <String, dynamic>{'eventTypeId': eventTypeId};
if (name != null) data['name'] = name;
if (defaultPrice != null) data['defaultPrice'] = defaultPrice;
await _apiService.call('updateEventType', data);
} catch (e) {
throw Exception('Erreur lors de la mise à jour du type d\'événement: $e');
}
}
/// Supprime un type d'événement
Future<void> deleteEventType(String eventTypeId) async {
try {
await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId});
} catch (e) {
throw Exception('Erreur lors de la suppression du type d\'événement: $e');
}
}
/// Crée une option
Future<String> createOption(String code, Map<String, dynamic> data) async {
try {
final requestData = {
'id': code, // Ajouter l'ID en utilisant le code comme identifiant
'code': code,
...data
};
final result = await _apiService.call('createOption', requestData);
return result['id'] as String? ?? code;
} catch (e) {
throw Exception('Erreur lors de la création de l\'option: $e');
}
}
/// Met à jour une option
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
try {
final requestData = {'optionId': optionId, 'data': data};
await _apiService.call('updateOption', requestData);
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
}
}
/// Supprime une option
Future<void> deleteOption(String optionId) async {
try {
await _apiService.call('deleteOption', {'optionId': optionId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'option: $e');
}
}
Future<Map<String, dynamic>> getEventWithDetails(String eventId) =>
eventRepository.getEventWithDetails(eventId);
// ============================================================================
// LECTURE DES DONNÉES (avec permissions côté serveur)
// EQUIPMENTS & AVAILABILITY (delegated to EquipmentRepository)
// ============================================================================
Future<List<Map<String, dynamic>>> getEquipments() =>
equipmentRepository.getEquipments();
/// Récupère tous les événements (filtrés selon permissions)
/// Retourne { events: List<Map>, users: Map<String, Map> }
Future<Map<String, dynamic>> getEvents({String? userId}) async {
try {
final data = <String, dynamic>{};
if (userId != null) data['userId'] = userId;
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) =>
equipmentRepository.getEquipmentsByIds(equipmentIds);
final result = await _apiService.call('getEvents', data);
// Extraire events et users
final events = result['events'] as List<dynamic>? ?? [];
final users = result['users'] as Map<String, dynamic>? ?? {};
return {
'events': events.map((e) => e as Map<String, dynamic>).toList(),
'users': users,
};
} catch (e) {
throw Exception('Erreur lors de la récupération des événements: $e');
}
}
/// Récupère les événements d'un mois spécifique (lazy loading optimisé)
Future<Map<String, dynamic>> getEventsByMonth({
required String userId,
required int year,
required int month,
}) async {
try {
print('[DataService] Calling getEventsByMonth for $year-$month');
final result = await _apiService.call('getEventsByMonth', {
'userId': userId,
'year': year,
'month': month,
});
// Extraire events et users
final events = result['events'] as List<dynamic>? ?? [];
final users = result['users'] as Map<String, dynamic>? ?? {};
print('[DataService] Events loaded for $year-$month: ${events.length} events');
return {
'events': events.map((e) => e as Map<String, dynamic>).toList(),
'users': users,
};
} catch (e) {
print('[DataService] Error getting events by month: $e');
throw Exception('Erreur lors de la récupération des événements du mois: $e');
}
}
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
try {
print('[DataService] Getting event with details: $eventId');
final result = await _apiService.call('getEventWithDetails', {
'eventId': eventId,
});
final event = result['event'] as Map<String, dynamic>?;
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
final containers = result['containers'] as Map<String, dynamic>? ?? {};
if (event == null) {
throw Exception('Event not found');
}
print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
return {
'event': event,
'equipments': equipments,
'containers': containers,
};
} catch (e) {
print('[DataService] Error getting event with details: $e');
throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e');
}
}
/// Récupère tous les équipements (avec masquage des prix selon permissions)
Future<List<Map<String, dynamic>>> getEquipments() async {
try {
print('[DataService] Calling getEquipments API...');
final result = await _apiService.call('getEquipments', {});
print('[DataService] API call successful, parsing result...');
final equipments = result['equipments'] as List<dynamic>?;
if (equipments == null) {
print('[DataService] No equipments in result');
return [];
}
print('[DataService] Found ${equipments.length} equipments');
return equipments.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[DataService] Error getting equipments: $e');
throw Exception('Erreur lors de la récupération des équipements: $e');
}
}
/// Récupère plusieurs équipements par leurs IDs
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) async {
try {
if (equipmentIds.isEmpty) return [];
print('[DataService] Getting equipments by IDs: ${equipmentIds.length} items');
final result = await _apiService.call('getEquipmentsByIds', {
'equipmentIds': equipmentIds,
});
final equipments = result['equipments'] as List<dynamic>?;
if (equipments == null) {
print('[DataService] No equipments in result');
return [];
}
print('[DataService] Found ${equipments.length} equipments by IDs');
return equipments.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[DataService] Error getting equipments by IDs: $e');
throw Exception('Erreur lors de la récupération des équipements: $e');
}
}
/// Récupère tous les conteneurs
Future<List<Map<String, dynamic>>> getContainers() async {
try {
final result = await _apiService.call('getContainers', {});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des conteneurs: $e');
}
}
/// Récupère plusieurs containers par leurs IDs
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async {
try {
if (containerIds.isEmpty) return [];
print('[DataService] Getting containers by IDs: ${containerIds.length} items');
final result = await _apiService.call('getContainersByIds', {
'containerIds': containerIds,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) {
print('[DataService] No containers in result');
return [];
}
print('[DataService] Found ${containers.length} containers by IDs');
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[DataService] Error getting containers by IDs: $e');
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
// ============================================================================
// EQUIPMENTS & CONTAINERS - Pagination
// ============================================================================
/// Récupère les équipements avec pagination et filtrage
Future<Map<String, dynamic>> getEquipmentsPaginated({
int limit = 20,
String? startAfter,
@@ -400,41 +104,92 @@ class DataService {
String? searchQuery,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (category != null) params['category'] = category;
if (status != null) params['status'] = status;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getEquipmentsPaginated',
params,
}) =>
equipmentRepository.getEquipmentsPaginated(
limit: limit,
startAfter: startAfter,
category: category,
status: status,
searchQuery: searchQuery,
sortBy: sortBy,
sortOrder: sortOrder,
);
return {
'equipments': (result['equipments'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ?? [],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) {
DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
throw Exception('Erreur lors de la récupération paginée des équipements: $e');
}
}
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) =>
equipmentRepository.createEquipment(equipmentId, data);
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) =>
equipmentRepository.updateEquipment(equipmentId, data);
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) =>
equipmentRepository.deleteEquipment(equipmentId, forceDelete: forceDelete);
Future<void> updateEquipmentStatusOnly({required String equipmentId, String? status, int? availableQuantity}) =>
equipmentRepository.updateEquipmentStatusOnly(
equipmentId: equipmentId,
status: status,
availableQuantity: availableQuantity,
);
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({required String query, int limit = 12}) =>
equipmentRepository.searchEquipmentsForAssistant(query: query, limit: limit);
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
required String equipmentId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) =>
equipmentRepository.checkEquipmentAvailabilityForAssistant(
equipmentId: equipmentId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
Future<Map<String, dynamic>> checkEquipmentAvailability({
required String equipmentId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) =>
equipmentRepository.checkEquipmentAvailability(
equipmentId: equipmentId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
Future<Map<String, dynamic>> getConflictingEquipmentIds({
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
int installationTime = 0,
int disassemblyTime = 0,
}) =>
equipmentRepository.getConflictingEquipmentIds(
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
installationTime: installationTime,
disassemblyTime: disassemblyTime,
);
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) =>
equipmentRepository.getMaintenances(equipmentId: equipmentId);
Future<void> deleteMaintenance(String maintenanceId) =>
equipmentRepository.deleteMaintenance(maintenanceId);
// ============================================================================
// CONTAINERS (delegated to ContainerRepository)
// ============================================================================
Future<List<Map<String, dynamic>>> getContainers() =>
containerRepository.getContainers();
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) =>
containerRepository.getContainersByIds(containerIds);
/// Récupère les containers avec pagination et filtrage
Future<Map<String, dynamic>> getContainersPaginated({
int limit = 20,
String? startAfter,
@@ -444,267 +199,71 @@ class DataService {
String? category,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (type != null) params['type'] = type;
if (status != null) params['status'] = status;
if (category != null) params['category'] = category;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getContainersPaginated',
params,
);
return {
'containers': (result['containers'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ?? [],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) {
DebugLog.error('[DataService] Error in getContainersPaginated', e);
throw Exception('Erreur lors de la récupération paginée des containers: $e');
}
}
/// Recherche rapide (autocomplétion)
Future<List<Map<String, dynamic>>> quickSearch(
String query, {
int limit = 10,
bool includeEquipments = true,
bool includeContainers = true,
}) async {
try {
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
query,
}) =>
containerRepository.getContainersPaginated(
limit: limit,
includeEquipments: includeEquipments,
includeContainers: includeContainers,
startAfter: startAfter,
type: type,
status: status,
searchQuery: searchQuery,
category: category,
sortBy: sortBy,
sortOrder: sortOrder,
);
} catch (e) {
DebugLog.error('[DataService] Error in quickSearch', e);
return [];
}
}
// ============================================================================
// USER - Current User
// ============================================================================
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) =>
containerRepository.getContainersByEquipment(equipmentId);
/// Récupère l'utilisateur actuellement authentifié avec son rôle
Future<Map<String, dynamic>> getCurrentUser() async {
try {
print('[DataService] Calling getCurrentUser API...');
final result = await _apiService.call('getCurrentUser', {});
print('[DataService] Current user loaded successfully');
return result['user'] as Map<String, dynamic>;
} catch (e) {
print('[DataService] Error getting current user: $e');
throw Exception('Erreur lors de la récupération de l\'utilisateur actuel: $e');
}
}
// ============================================================================
// ALERTS
// ============================================================================
/// Récupère toutes les alertes
Future<List<Map<String, dynamic>>> getAlerts() async {
try {
final result = await _apiService.call('getAlerts', {});
final alerts = result['alerts'] as List<dynamic>?;
if (alerts == null) return [];
return alerts.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des alertes: $e');
}
}
/// Marque une alerte comme lue
Future<void> markAlertAsRead(String alertId) async {
try {
await _apiService.call('markAlertAsRead', {'alertId': alertId});
} catch (e) {
throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e');
}
}
/// Supprime une alerte
Future<void> deleteAlert(String alertId) async {
try {
await _apiService.call('deleteAlert', {'alertId': alertId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'alerte: $e');
}
}
// ============================================================================
// EQUIPMENT AVAILABILITY
// ============================================================================
/// Vérifie la disponibilité d'un équipement
Future<Map<String, dynamic>> checkEquipmentAvailability({
required String equipmentId,
Future<Map<String, dynamic>> checkContainerAvailability({
required String containerId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
try {
final result = await _apiService.call('checkEquipmentAvailability', {
'equipmentId': equipmentId,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
if (excludeEventId != null) 'excludeEventId': excludeEventId,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la vérification de disponibilité: $e');
}
}
/// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période
/// Optimisé : une seule requête au lieu d'une par équipement
Future<Map<String, dynamic>> getConflictingEquipmentIds({
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
int installationTime = 0,
int disassemblyTime = 0,
}) async {
try {
final result = await _apiService.call('getConflictingEquipmentIds', {
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
if (excludeEventId != null) 'excludeEventId': excludeEventId,
'installationTime': installationTime,
'disassemblyTime': disassemblyTime,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la récupération des équipements en conflit: $e');
}
}
}) =>
containerRepository.checkContainerAvailability(
containerId: containerId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
// ============================================================================
// MAINTENANCES
// USERS (delegated to UserRepository)
// ============================================================================
Future<Map<String, dynamic>> getCurrentUser() => userRepository.getCurrentUser();
/// Récupère toutes les maintenances
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async {
try {
final data = <String, dynamic>{};
if (equipmentId != null) data['equipmentId'] = equipmentId;
Future<List<Map<String, dynamic>>> getUsers() => userRepository.getUsers();
final result = await _apiService.call('getMaintenances', data);
final maintenances = result['maintenances'] as List<dynamic>?;
if (maintenances == null) return [];
return maintenances.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des maintenances: $e');
}
}
Future<Map<String, dynamic>> getUser(String userId) => userRepository.getUser(userId);
/// Supprime une maintenance
Future<void> deleteMaintenance(String maintenanceId) async {
try {
await _apiService.call('deleteMaintenance', {'maintenanceId': maintenanceId});
} catch (e) {
throw Exception('Erreur lors de la suppression de la maintenance: $e');
}
}
Future<void> deleteUser(String userId) => userRepository.deleteUser(userId);
/// Récupère les containers contenant un équipement
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async {
try {
final result = await _apiService.call('getContainersByEquipment', {
'equipmentId': equipmentId,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
Future<void> updateUser(String userId, Map<String, dynamic> data) =>
userRepository.updateUser(userId, data);
// ============================================================================
// USERS
// ============================================================================
/// Récupère tous les utilisateurs (selon permissions)
Future<List<Map<String, dynamic>>> getUsers() async {
try {
final result = await _apiService.call('getUsers', {});
final users = result['users'] as List<dynamic>?;
if (users == null) return [];
return users.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des utilisateurs: $e');
}
}
/// Récupère un utilisateur spécifique
Future<Map<String, dynamic>> getUser(String userId) async {
try {
final result = await _apiService.call('getUser', {'userId': userId});
return result['user'] as Map<String, dynamic>;
} catch (e) {
throw Exception('Erreur lors de la récupération de l\'utilisateur: $e');
}
}
/// Supprime un utilisateur (Auth + Firestore)
Future<void> deleteUser(String userId) async {
try {
await _apiService.call('deleteUser', {'userId': userId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'utilisateur: $e');
}
}
/// Met à jour un utilisateur
Future<void> updateUser(String userId, Map<String, dynamic> data) async {
try {
await _apiService.call('updateUser', {
'userId': userId,
'data': data,
});
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e');
}
}
/// Crée un utilisateur avec invitation par email
Future<Map<String, dynamic>> createUserWithInvite({
required String email,
required String firstName,
required String lastName,
String? phoneNumber,
required String roleId,
}) async {
try {
final result = await _apiService.call('createUserWithInvite', {
'email': email,
'firstName': firstName,
'lastName': lastName,
'phoneNumber': phoneNumber ?? '',
'roleId': roleId,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la création de l\'utilisateur: $e');
}
}
}) =>
userRepository.createUserWithInvite(
email: email,
firstName: firstName,
lastName: lastName,
phoneNumber: phoneNumber,
roleId: roleId,
);
Future<List<Map<String, dynamic>>> getRoles() => userRepository.getRoles();
// ============================================================================
// ALERTS (delegated to AlertRepository)
// ============================================================================
Future<List<Map<String, dynamic>>> getAlerts() => alertRepository.getAlerts();
Future<void> markAlertAsRead(String alertId) => alertRepository.markAlertAsRead(alertId);
Future<void> deleteAlert(String alertId) => alertRepository.deleteAlert(alertId);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
/// Service d'envoi d'emails via Cloud Functions
class EmailService {
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9');
FirebaseFunctions get _functions => FirebaseFunctions.instanceFor(region: 'europe-west9');
/// Envoie un email d'alerte à un utilisateur
///
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
@@ -66,27 +67,19 @@ class AvailabilityConflict {
class EventAvailabilityService {
final DataService _dataService = DataService(apiService);
/// Helper pour récupérer uniquement la liste d'événements
Future<List<Map<String, dynamic>>> _getEventsList() async {
final result = await _dataService.getEvents();
final events = result['events'] as List<dynamic>? ?? [];
return events.map((e) => e as Map<String, dynamic>).toList();
}
/// Vérifie si un équipement est disponible pour une plage de dates via Cloud Function
Future<List<AvailabilityConflict>> checkEquipmentAvailability({
required String equipmentId,
required String equipmentName,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId, // Pour exclure l'événement en cours d'édition
String? excludeEventId,
}) async {
final conflicts = <AvailabilityConflict>[];
try {
print('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
if (kDebugMode) debugPrint('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
// Utiliser la Cloud Function pour vérifier la disponibilité
final result = await _dataService.checkEquipmentAvailability(
equipmentId: equipmentId,
startDate: startDate,
@@ -94,20 +87,12 @@ class EventAvailabilityService {
excludeEventId: excludeEventId,
);
print('[EventAvailabilityService] Result for $equipmentId: $result');
final available = result['available'] as bool? ?? true;
print('[EventAvailabilityService] Equipment $equipmentId available: $available');
if (!available) {
final conflictsData = result['conflicts'] as List<dynamic>? ?? [];
print('[EventAvailabilityService] Found ${conflictsData.length} conflicts for equipment $equipmentId');
for (final conflictData in conflictsData) {
final conflict = conflictData as Map<String, dynamic>;
final eventId = conflict['eventId'] as String;
// Le backend retourne déjà eventData
final eventData = conflict['eventData'] as Map<String, dynamic>?;
if (eventData != null && eventData.isNotEmpty) {
@@ -119,19 +104,16 @@ class EventAvailabilityService {
conflictingEvent: event,
overlapDays: conflict['overlapDays'] as int? ?? 0,
));
print('[EventAvailabilityService] Added conflict with event ${event.name}');
} catch (e) {
print('[EventAvailabilityService] Error creating EventModel: $e');
print('[EventAvailabilityService] EventData: $eventData');
if (kDebugMode) debugPrint('[EventAvailabilityService] Error creating EventModel: $e');
}
}
}
}
} catch (e) {
print('[EventAvailabilityService] Error checking availability: $e');
if (kDebugMode) debugPrint('[EventAvailabilityService] Error checking availability: $e');
}
print('[EventAvailabilityService] Returning ${conflicts.length} conflicts for equipment $equipmentId');
return conflicts;
}
@@ -159,164 +141,10 @@ class EventAvailabilityService {
}
}
return allConflicts;
}
/// Vérifie si deux plages de dates se chevauchent
bool _datesOverlap(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
// Deux plages se chevauchent si elles ne sont PAS complètement séparées
// Elles sont séparées si : end1 < start2 OU end2 < start1
// Donc elles se chevauchent si : NOT (end1 < start2 OU end2 < start1)
// Équivalent à : end1 >= start2 ET end2 >= start1
return !end1.isBefore(start2) && !end2.isBefore(start1);
}
/// Calcule le nombre de jours de chevauchement
int _calculateOverlapDays(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
final overlapStart = start1.isAfter(start2) ? start1 : start2;
final overlapEnd = end1.isBefore(end2) ? end1 : end2;
return overlapEnd.difference(overlapStart).inDays + 1;
}
/// Récupère la quantité disponible pour un consommable/câble
Future<int> getAvailableQuantity({
required EquipmentModel equipment,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
if (!equipment.hasQuantity) {
return 1; // Équipement non consommable
}
final totalQuantity = equipment.totalQuantity ?? 0;
int reservedQuantity = 0;
try {
// Récupérer tous les événements via Cloud Function
final eventsData = await _getEventsList();
for (var eventData in eventsData) {
final eventId = eventData['id'] as String;
if (excludeEventId != null && eventId == excludeEventId) {
continue;
}
try {
final event = EventModel.fromMap(eventData, eventId);
// Ignorer les événements annulés
if (event.status == EventStatus.canceled) {
continue;
}
// Calculer les dates réelles avec temps d'installation et démontage
final eventRealStartDate = event.startDateTime.subtract(
Duration(hours: event.installationTime),
);
final eventRealEndDate = event.endDateTime.add(
Duration(hours: event.disassemblyTime),
);
// Vérifier le chevauchement des dates
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
final assignedEquipment = event.assignedEquipment.firstWhere(
(eq) => eq.equipmentId == equipment.id,
orElse: () => EventEquipment(equipmentId: ''),
);
// Si l'équipement est assigné, réserver la quantité
// (peu importe le statut de préparation/retour)
if (assignedEquipment.equipmentId.isNotEmpty) {
reservedQuantity += assignedEquipment.quantity;
}
}
} catch (e) {
print('[EventAvailabilityService] Error processing event $eventId for quantity: $e');
}
}
} catch (e) {
print('[EventAvailabilityService] Error getting available quantity: $e');
}
return totalQuantity - reservedQuantity;
}
/// Vérifie la disponibilité d'un équipement avec gestion des quantités
Future<List<AvailabilityConflict>> checkEquipmentAvailabilityWithQuantity({
required EquipmentModel equipment,
required int requestedQuantity,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
final conflicts = <AvailabilityConflict>[];
// Si équipement quantifiable (consommable/câble)
if (equipment.hasQuantity) {
final totalQuantity = equipment.totalQuantity ?? 0;
final availableQty = await getAvailableQuantity(
equipment: equipment,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
final reservedQty = totalQuantity - availableQty;
// ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante
if (availableQty < requestedQuantity) {
// Trouver les événements qui réservent cette quantité
final eventsData = await _getEventsList();
for (var eventData in eventsData) {
final eventId = eventData['id'] as String;
if (excludeEventId != null && eventId == excludeEventId) continue;
try {
final event = EventModel.fromMap(eventData, eventId);
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
final assignedEquipment = event.assignedEquipment.firstWhere(
(eq) => eq.equipmentId == equipment.id,
orElse: () => EventEquipment(equipmentId: ''),
);
if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) {
conflicts.add(AvailabilityConflict(
equipmentId: equipment.id,
equipmentName: equipment.name,
conflictingEvent: event,
overlapDays: _calculateOverlapDays(startDate, endDate, event.startDateTime, event.endDateTime),
type: ConflictType.insufficientQuantity,
totalQuantity: totalQuantity,
availableQuantity: availableQty,
requestedQuantity: requestedQuantity,
reservedQuantity: reservedQty,
));
}
}
} catch (e) {
print('[EventAvailabilityService] Error processing event $eventId: $e');
}
}
}
} else {
// Équipement non quantifiable : vérification classique
return await checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
}
return conflicts;
}
/// Vérifie la disponibilité d'une boîte et de son contenu
/// Vérifie la disponibilité d'une boîte et de son contenu via le backend
Future<List<AvailabilityConflict>> checkContainerAvailability({
required ContainerModel container,
required List<EquipmentModel> containerEquipment,
@@ -325,99 +153,62 @@ class EventAvailabilityService {
String? excludeEventId,
}) async {
final conflicts = <AvailabilityConflict>[];
final conflictingChildrenIds = <String>[];
// Vérifier d'abord si la boîte complète est utilisée
final eventsData = await _getEventsList();
bool isContainerFullyUsed = false;
EventModel? containerConflictingEvent;
for (var eventData in eventsData) {
final eventId = eventData['id'] as String;
if (excludeEventId != null && eventId == excludeEventId) continue;
try {
final event = EventModel.fromMap(eventData, eventId);
// Ignorer les événements annulés
if (event.status == EventStatus.canceled) {
continue;
}
// Calculer les dates réelles avec temps d'installation et démontage
final eventRealStartDate = event.startDateTime.subtract(
Duration(hours: event.installationTime),
);
final eventRealEndDate = event.endDateTime.add(
Duration(hours: event.disassemblyTime),
);
// Vérifier si cette boîte est assignée
if (event.assignedContainers.contains(container.id)) {
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
isContainerFullyUsed = true;
containerConflictingEvent = event;
break;
}
}
} catch (e) {
print('[EventAvailabilityService] Error processing event $eventId: $e');
}
}
if (isContainerFullyUsed && containerConflictingEvent != null) {
// Boîte complète utilisée
conflicts.add(AvailabilityConflict(
equipmentId: container.id,
equipmentName: container.name,
conflictingEvent: containerConflictingEvent,
overlapDays: _calculateOverlapDays(
startDate,
endDate,
containerConflictingEvent.startDateTime,
containerConflictingEvent.endDateTime,
),
type: ConflictType.containerFullyUsed,
final result = await _dataService.checkContainerAvailability(
containerId: container.id,
containerName: container.name,
));
} else {
// Vérifier chaque équipement enfant individuellement
for (var equipment in containerEquipment) {
final equipmentConflicts = await checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
if (equipmentConflicts.isNotEmpty) {
conflictingChildrenIds.add(equipment.id);
conflicts.addAll(equipmentConflicts);
}
}
final isAvailable = result['isAvailable'] as bool? ?? true;
if (!isAvailable) {
final conflictType = result['conflictType'] as String?;
// Si au moins un enfant en conflit, ajouter un conflit pour la boîte
if (conflictingChildrenIds.isNotEmpty && conflicts.isNotEmpty) {
conflicts.insert(
0,
AvailabilityConflict(
if (conflictType == 'complete') {
final containerConflicts = result['containerConflicts'] as List<dynamic>? ?? [];
for (var conflictData in containerConflicts) {
final conflict = conflictData as Map<String, dynamic>;
final eventId = conflict['eventId'] as String;
final eventName = conflict['eventName'] as String? ?? '';
final startDateStr = conflict['startDate'] as String?;
final endDateStr = conflict['endDate'] as String?;
final event = EventModel(
id: eventId,
name: eventName,
description: '',
startDateTime: startDateStr != null ? DateTime.tryParse(startDateStr) ?? DateTime.now() : DateTime.now(),
endDateTime: endDateStr != null ? DateTime.tryParse(endDateStr) ?? DateTime.now() : DateTime.now(),
basePrice: 0.0,
installationTime: 0,
disassemblyTime: 0,
eventTypeId: '',
customerId: '',
address: '',
latitude: 0.0,
longitude: 0.0,
workforce: const [],
documents: const [],
);
conflicts.add(AvailabilityConflict(
equipmentId: container.id,
equipmentName: container.name,
conflictingEvent: conflicts.first.conflictingEvent,
overlapDays: conflicts.first.overlapDays,
type: ConflictType.containerPartiallyUsed,
conflictingEvent: event,
overlapDays: conflict['overlapDays'] as int? ?? 0,
type: ConflictType.containerFullyUsed,
containerId: container.id,
containerName: container.name,
conflictingChildrenIds: conflictingChildrenIds,
),
);
));
}
}
}
} catch (e) {
if (kDebugMode) debugPrint('[EventAvailabilityService] Error checking container availability: $e');
}
return conflicts;
}
}
@@ -1,39 +1,45 @@
import 'package:em2rp/services/equipment_status_calculator.dart';
import 'package:em2rp/services/api_service.dart';
class EventPreparationService {
final ApiService _apiService = apiService;
// === PRÉPARATION ===
/// Valider un équipement individuel en préparation
Future<void> validateEquipmentPreparation(String eventId, String equipmentId) async {
try {
await _apiService.call('validateEquipmentPreparation', {
'eventId': eventId,
'equipmentId': equipmentId,
});
} catch (e) {
print('Error validating equipment preparation: $e');
rethrow;
}
/// Retourne true si l'équipement était absent du flux événementiel.
///
/// Cas typique: matériel jamais emporté au départ, donc absent au retour,
/// mais qui ne doit jamais être classé en [LOST].
static bool isEquipmentNotTakenToEvent({
required bool isMissingAtReturn,
required bool isLoaded,
required bool isMissingAtLoading,
int? quantityAtLoading,
}) {
if (!isMissingAtReturn) {
return false;
}
/// Valider tous les équipements en préparation
Future<void> validateAllPreparation(String eventId) async {
try {
await _apiService.call('validateAllPreparation', {
'eventId': eventId,
});
final loadedQuantity = quantityAtLoading ?? 0;
return !isLoaded || isMissingAtLoading || loadedQuantity <= 0;
}
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all preparation: $e');
rethrow;
/// Retourne true uniquement si l'équipement doit être classé perdu.
static bool shouldMarkEquipmentAsLost({
required bool isReturnValidationStep,
required bool isMissingAtReturn,
required bool isLoaded,
required bool isMissingAtLoading,
int? quantityAtLoading,
}) {
if (!isReturnValidationStep || !isMissingAtReturn) {
return false;
}
return !isEquipmentNotTakenToEvent(
isMissingAtReturn: isMissingAtReturn,
isLoaded: isLoaded,
isMissingAtLoading: isMissingAtLoading,
quantityAtLoading: quantityAtLoading,
);
}
// Ces méthodes ne sont plus utilisées et devraient être remplacées par des Cloud Functions
// si nécessaire dans le futur
@@ -47,46 +53,8 @@ class EventPreparationService {
}
*/
// === RETOUR ===
/// Valider le retour d'un équipement individuel
Future<void> validateEquipmentReturn(
String eventId,
String equipmentId, {
int? returnedQuantity,
}) async {
try {
await _apiService.call('validateEquipmentReturn', {
'eventId': eventId,
'equipmentId': equipmentId,
if (returnedQuantity != null) 'returnedQuantity': returnedQuantity,
});
} catch (e) {
print('Error validating equipment return: $e');
rethrow;
}
}
/// Valider tous les retours
Future<void> validateAllReturn(
String eventId, [
Map<String, int>? returnedQuantities,
]) async {
try {
await _apiService.call('validateAllReturn', {
'eventId': eventId,
if (returnedQuantities != null) 'returnedQuantities': returnedQuantities,
});
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all return: $e');
rethrow;
}
}
/*
@Deprecated('Use Cloud Functions instead')
Future<void> completeReturnWithMissing(
+134
View File
@@ -0,0 +1,134 @@
import 'dart:convert';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:http/http.dart' as http;
import 'package:em2rp/config/api_config.dart';
import 'package:em2rp/models/depot_model.dart';
import 'package:em2rp/models/route_result_model.dart';
import 'package:em2rp/utils/debug_log.dart';
class TravelService {
final FirebaseFirestore _db = FirebaseFirestore.instance;
// ─── Auth token ───────────────────────────────────────────
Future<String?> _getToken() async {
final user = FirebaseAuth.instance.currentUser;
return await user?.getIdToken();
}
Future<Map<String, String>> _headers() async {
final token = await _getToken();
return {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
}
// ─── Autocomplétion d'adresses ────────────────────────────
Future<List<String>> autocompleteAddress(String query) async {
if (query.trim().length < 3) return [];
try {
final headers = await _headers();
final url = Uri.parse('${ApiConfig.baseUrl}/googleMapsAutocomplete');
final response = await http.post(
url,
headers: headers,
body: jsonEncode({'data': {'query': query}}),
);
if (response.statusCode != 200) return [];
final data = jsonDecode(response.body) as Map<String, dynamic>;
final predictions = data['predictions'] as List<dynamic>? ?? [];
return predictions
.map((p) => (p['description'] ?? '').toString())
.where((s) => s.isNotEmpty)
.toList();
} catch (e) {
DebugLog.error('[Travel] autocompleteAddress error', e);
return [];
}
}
// ─── Calcul des itinéraires ───────────────────────────────
Future<List<RouteResult>> computeRoutes({
required String origin,
required String destination,
int vehicleTollCategory = 2,
}) async {
try {
final headers = await _headers();
final url = Uri.parse('${ApiConfig.baseUrl}/googleMapsComputeRoute');
final response = await http.post(
url,
headers: headers,
body: jsonEncode({
'data': {
'origin': origin,
'destination': destination,
'vehicleTollCategory': vehicleTollCategory,
},
}),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body);
throw Exception('googleMapsComputeRoute: ${err['error']}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final routes = data['routes'] as List<dynamic>? ?? [];
return routes
.map((r) => RouteResult.fromMap(r as Map<String, dynamic>))
.toList();
} catch (e) {
DebugLog.error('[Travel] computeRoutes error', e);
rethrow;
}
}
// ─── Prix des carburants ───────────────────────────────────
Future<FuelPrices> getFuelPrices() async {
try {
final doc = await _db.collection('app_config').doc('fuel_prices').get();
if (!doc.exists) return const FuelPrices();
return FuelPrices.fromMap(doc.data()!);
} catch (e) {
return const FuelPrices();
}
}
Future<void> saveFuelPrices(FuelPrices prices) async {
await _db.collection('app_config').doc('fuel_prices').set(prices.toMap());
}
// ─── Dépôts ───────────────────────────────────────────────
Future<List<DepotModel>> getDepots() async {
final snap = await _db.collection('depots').orderBy('name').get();
return snap.docs.map((d) => DepotModel.fromFirestore(d)).toList();
}
Stream<List<DepotModel>> watchDepots() {
return _db
.collection('depots')
.orderBy('name')
.snapshots()
.map((s) => s.docs.map((d) => DepotModel.fromFirestore(d)).toList());
}
Future<String> addDepot(DepotModel depot) async {
final ref = await _db.collection('depots').add(depot.toMap());
return ref.id;
}
Future<void> updateDepot(DepotModel depot) async {
final map = depot.toMap();
map.remove('createdAt');
await _db.collection('depots').doc(depot.id).update(map);
}
Future<void> deleteDepot(String depotId) async {
await _db.collection('depots').doc(depotId).delete();
}
}
/// Instance singleton
final travelService = TravelService();
+46
View File
@@ -0,0 +1,46 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/vehicle_model.dart';
class VehicleService {
final FirebaseFirestore _db = FirebaseFirestore.instance;
static const String _collection = 'vehicles';
/// Récupère tous les véhicules, triés par nom.
Future<List<VehicleModel>> getVehicles() async {
final snapshot = await _db
.collection(_collection)
.orderBy('name')
.get();
return snapshot.docs
.map((doc) => VehicleModel.fromFirestore(doc))
.toList();
}
/// Stream en temps réel
Stream<List<VehicleModel>> watchVehicles() {
return _db
.collection(_collection)
.orderBy('name')
.snapshots()
.map((snap) =>
snap.docs.map((d) => VehicleModel.fromFirestore(d)).toList());
}
/// Ajoute un véhicule
Future<String> addVehicle(VehicleModel vehicle) async {
final ref = await _db.collection(_collection).add(vehicle.toMap());
return ref.id;
}
/// Modifie un véhicule existant
Future<void> updateVehicle(VehicleModel vehicle) async {
final map = vehicle.toMap();
map.remove('createdAt'); // Ne pas écraser la date de création
await _db.collection(_collection).doc(vehicle.id).update(map);
}
/// Supprime un véhicule
Future<void> deleteVehicle(String vehicleId) async {
await _db.collection(_collection).doc(vehicleId).delete();
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:async';
import '../views/login_page.dart';
import '../providers/local_user_provider.dart';
import '../views/widgets/common/startup_splash_screen.dart';
/// Gate de démarrage qui attend la restauration Firebase Auth avant
/// d'afficher soit le contenu connecté, soit la page de connexion.
class AppStartGate extends StatelessWidget {
const AppStartGate({super.key});
@override
Widget build(BuildContext context) {
// Sur le web, certaines erreurs natives (ex: cookies tiers bloqués)
// peuvent faire remonter une FirebaseException sur le stream d'auth.
// Pour éviter que StreamBuilder reçoive une erreur qui casse le build
// (TypeError JS interop), on "handleError" et on transforme l'erreur
// en une valeur nulle (pas d'utilisateur) afin de garder l'app stable.
// Accès protégé à `FirebaseAuth.instance` — sur le web certaines erreurs
// d'interop JS peuvent produire des TypeError non compatibles. Nous
// attrapons toute exception lors de l'accès et fournissons un stream
// neutre (pas d'utilisateur) afin de garder l'UI stable.
late final Stream<User?> safeAuthStream;
try {
safeAuthStream = FirebaseAuth.instance
.authStateChanges()
.handleError((error, stack) {
// Log pour debug ; ne rethrow pas
debugPrint('[AppStartGate] authStateChanges error: $error');
});
} catch (e, st) {
// Sur certaines configurations web l'accès à FirebaseAuth.instance
// peut échouer au niveau JS interop. On log puis on fournit un stream
// qui émet une seule valeur nulle pour indiquer "pas d'utilisateur".
debugPrint('[AppStartGate] FirebaseAuth.instance access error: $e\n$st');
safeAuthStream = Stream<User?>.value(null);
}
return StreamBuilder<User?>(
stream: safeAuthStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const StartupSplashScreen();
}
if (snapshot.hasError) {
// En théorie handleError évite d'arriver ici, mais on garde
// une protection supplémentaire.
debugPrint('[AppStartGate] snapshot error: ${snapshot.error}');
return const StartupSplashScreen(message: 'Erreur de connexion');
}
if (snapshot.data != null) {
return const _AuthenticatedBootstrap();
}
return const LoginPage();
},
);
}
}
class _AuthenticatedBootstrap extends StatefulWidget {
const _AuthenticatedBootstrap();
@override
State<_AuthenticatedBootstrap> createState() =>
_AuthenticatedBootstrapState();
}
class _AuthenticatedBootstrapState extends State<_AuthenticatedBootstrap> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_redirectAfterAuth();
});
}
Future<void> _redirectAfterAuth() async {
final fragment = Uri.base.fragment;
if (!mounted) return;
// Charger les données utilisateur de façon non bloquante
unawaited(
context.read<LocalUserProvider>().loadUserData().catchError((e) {
debugPrint('[AppStartGate] User data bootstrap failed: $e');
}),
);
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
Navigator.of(context).pushReplacementNamed(fragment);
} else {
Navigator.of(context).pushReplacementNamed('/calendar');
}
}
@override
Widget build(BuildContext context) {
return const StartupSplashScreen();
}
}
+19 -2
View File
@@ -1,27 +1,44 @@
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/login_page.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/views/login_page.dart';
import 'package:em2rp/views/widgets/common/startup_splash_screen.dart';
class AuthGuard extends StatelessWidget {
final Widget child;
final String? requiredPermission;
final bool allowWhileLoading;
const AuthGuard({
super.key,
required this.child,
this.requiredPermission,
this.allowWhileLoading = false,
});
@override
Widget build(BuildContext context) {
final localAuthProvider = Provider.of<LocalUserProvider>(context);
final firebaseUser = FirebaseAuth.instance.currentUser;
// Log pour débug
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
// Si Firebase n'a pas encore restauré la session ou si le profil charge,
// afficher un écran neutre plutôt que la page de connexion.
if (firebaseUser != null &&
(localAuthProvider.currentUser == null ||
localAuthProvider.isLoadingUserData)) {
if (allowWhileLoading) {
return child;
}
return const StartupSplashScreen(message: 'Chargement du profil...');
}
// Si l'utilisateur n'est pas connecté
if (localAuthProvider.currentUser == null) {
if (firebaseUser == null || localAuthProvider.currentUser == null) {
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
return const LoginPage();
}
+21
View File
@@ -0,0 +1,21 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Utilitaire pour différer l'exécution d'une action après un délai.
/// Utilisé principalement pour les champs de recherche afin d'éviter
/// des requêtes à chaque frappe clavier.
class Debouncer {
final Duration delay;
Timer? _timer;
Debouncer({this.delay = const Duration(milliseconds: 400)});
void call(VoidCallback action) {
_timer?.cancel();
_timer = Timer(delay, action);
}
void dispose() {
_timer?.cancel();
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:em2rp/services/api_service.dart';
import 'package:flutter/material.dart';
/// Utilitaires partages pour la suppression d'equipement avec forcage.
class EquipmentDeleteUtils {
static const String _legacyConflictToken = 'future_event_assignment';
static const List<String> _conflictMessageTokens = [
'cannot delete equipment because it is assigned to upcoming events',
'cannot delete equipment because it is assigned to future events',
'assigned to upcoming events',
'assigned to future events',
];
static const String deleteDialogTitle = 'Confirmer la suppression';
static const String deleteDialogCancelLabel = 'Annuler';
static const String deleteDialogConfirmLabel = 'Supprimer';
static const String deleteSuccessMessage = 'Équipement supprimé avec succès';
/// Retourne [name] si renseigne, sinon [id].
static String resolveEquipmentLabel({required String id, String? name}) {
final trimmedName = name?.trim();
if (trimmedName == null || trimmedName.isEmpty) {
return id;
}
return trimmedName;
}
/// Construit le message de confirmation de suppression d'un equipement.
static String buildSingleDeleteConfirmationMessage(String equipmentLabel) {
return 'Voulez-vous vraiment supprimer "$equipmentLabel" ?\n\n'
'Cette action est irréversible.';
}
/// Construit le message de confirmation de suppression multiple.
static String buildBulkDeleteConfirmationMessage(int selectedCount) {
return 'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?\n\n'
'Cette action est irréversible.';
}
/// Construit le message de succes de suppression multiple.
static String buildBulkDeleteSuccessMessage(int deletedCount) {
return '$deletedCount équipement(s) supprimé(s) avec succès';
}
/// Construit un message d'erreur de suppression homogene.
static String buildDeleteErrorMessage(Object error) {
return 'Erreur lors de la suppression : $error';
}
/// Indique si l'erreur correspond a un conflit de suppression 409.
static bool isFutureAssignmentDeleteConflict(Object error) {
if (error is ApiException && !error.isConflict) {
return false;
}
final normalizedMessage = _normalizeErrorMessage(error);
if (normalizedMessage.contains(_legacyConflictToken)) {
return true;
}
return _conflictMessageTokens.any(normalizedMessage.contains);
}
/// Affiche la confirmation de suppression forcee.
static Future<bool> showForceDeleteDialog(
BuildContext context, {
required String equipmentLabel,
}) async {
final shouldForceDelete = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Équipement utilisé dans un événement à venir'),
content: Text(
'"$equipmentLabel" est assigné à au moins un événement à venir.\n\n'
'Voulez-vous forcer la suppression ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Forcer la suppression'),
),
],
),
);
return shouldForceDelete == true;
}
/// Execute une suppression, puis propose un forcage en cas de conflit 409.
static Future<bool> deleteWithFutureAssignmentCheck({
required BuildContext context,
required String equipmentLabel,
required Future<void> Function({bool forceDelete}) deleteEquipment,
}) async {
try {
await deleteEquipment(forceDelete: false);
return true;
} catch (error) {
if (!isFutureAssignmentDeleteConflict(error)) {
rethrow;
}
if (!context.mounted) {
return false;
}
final shouldForceDelete = await showForceDeleteDialog(
context,
equipmentLabel: equipmentLabel,
);
if (!shouldForceDelete) {
return false;
}
await deleteEquipment(forceDelete: true);
return true;
}
}
static String _normalizeErrorMessage(Object error) {
if (error is ApiException) {
return error.message.toLowerCase();
}
return error.toString().toLowerCase();
}
}
@@ -5,8 +5,8 @@ import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class FirebaseStorageManager {
final FirebaseStorage _storage = FirebaseStorage.instance;
final DataService _dataService = DataService(FirebaseFunctionsApiService());
FirebaseStorage get _storage => FirebaseStorage.instance;
final DataService _dataService = DataService(apiService);
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
/// Pour le Web, on fixe l'extension .jpg.
+53
View File
@@ -0,0 +1,53 @@
import 'package:latlong2/latlong.dart';
List<LatLng> safeDecodePolyline(String encoded) {
if (encoded.isEmpty) return [];
try {
List<LatLng> poly = [];
int index = 0, len = encoded.length;
int lat = 0, lng = 0;
while (index < len) {
int b, shift = 0, result = 0;
do {
if (index >= len) break;
b = encoded.codeUnitAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
// Dart Web bitwise operations (~ and >>) can cause 32-bit unsigned wrap-around
// Using arithmetic avoids the issue where lat becomes 42995.xxxx (offset by 2^32)
int dlat = (result & 1) != 0 ? -((result >> 1) + 1) : (result >> 1);
lat += dlat;
// Correction manuelle au cas où un wrap unsigned 32-bit s'est produit
if (lat > 2147483647) lat -= 4294967296;
shift = 0;
result = 0;
do {
if (index >= len) break;
b = encoded.codeUnitAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
int dlng = (result & 1) != 0 ? -((result >> 1) + 1) : (result >> 1);
lng += dlng;
if (lng > 2147483647) lng -= 4294967296;
double finalLat = lat / 1e5;
double finalLng = lng / 1e5;
poly.add(LatLng(finalLat, finalLng));
}
return poly;
} catch (e) {
// ignore: avoid_print
print('[POLYLINE] Erreur décodage: $e');
return [];
}
}
+8 -11
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../providers/local_user_provider.dart';
@@ -33,22 +35,17 @@ class LoginViewModel extends ChangeNotifier {
passwordController.text,
);
// --- Étape 2: Charger les données utilisateur depuis Firestore ---
await localAuthProvider.loadUserData();
// --- Étape 2: Charger les données utilisateur en arrière-plan ---
unawaited(
localAuthProvider.loadUserData().catchError((e) {
debugPrint('Erreur chargement profil après connexion : $e');
}),
);
// Vérifier si le contexte est toujours valide
if (context.mounted) {
// Vérifier si l'utilisateur a bien été chargé dans le provider
if (localAuthProvider.currentUser != null) {
// Utiliser pushReplacementNamed pour une transition propre
Navigator.of(context, rootNavigator: true)
.pushReplacementNamed('/calendar');
} else {
errorMessage =
'Erreur inattendue après connexion: Données utilisateur non chargées.';
isLoading = false;
notifyListeners();
}
}
} on FirebaseAuthException catch (e) {
// Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.)
+615 -73
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:em2rp/providers/local_user_provider.dart';
@@ -6,10 +7,12 @@ import 'package:em2rp/utils/performance_monitor.dart';
import 'package:flutter/material.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:em2rp/views/widgets/common/startup_splash_screen.dart';
import 'package:provider/provider.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/views/widgets/calendar_widgets/event_details.dart';
import 'package:intl/intl.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
@@ -40,8 +43,18 @@ class _CalendarPageState extends State<CalendarPage> {
int _selectedEventIndex = 0;
String?
_selectedUserId; // Filtre par utilisateur (null = tous les événements)
final TextEditingController _searchController = TextEditingController();
Timer? _searchDebounce;
List<EventModel> _searchResults = [];
String _searchQuery = '';
String? _searchError;
bool _isSearching = false;
int _searchRequestId = 0;
bool _isMobileSearchVisible = false;
bool _isRefreshing = false;
double _detailsPaneFraction = 0.35;
final ValueNotifier<double> _detailsPaneFraction = ValueNotifier<double>(0.35);
String? _lastLoadedUserId;
bool _initialLoadScheduled = false;
@override
void initState() {
@@ -105,19 +118,22 @@ class _CalendarPageState extends State<CalendarPage> {
}
}
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
/// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place
Future<void> _loadEventsAsync() async {
PerformanceMonitor.start('CalendarPage.loadEventsAsync');
await _loadEvents();
// Sélectionner l'événement approprié après le chargement
if (mounted) {
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
_selectDefaultEvent();
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
void _scheduleInitialEventsLoad(String? userId) {
if (userId == null || userId == _lastLoadedUserId || _initialLoadScheduled) {
return;
}
PerformanceMonitor.end('CalendarPage.loadEventsAsync');
_initialLoadScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
if (!mounted) return;
if (_lastLoadedUserId == userId) return;
await _loadCurrentMonthEvents();
_lastLoadedUserId = userId;
} finally {
_initialLoadScheduled = false;
}
});
}
/// Sélectionne automatiquement l'événement le plus proche de maintenant
@@ -188,9 +204,16 @@ class _CalendarPageState extends State<CalendarPage> {
}
}
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif)
/// TEMPORAIREMENT DÉSACTIVÉ - À réactiver quand permission ajoutée dans Firestore
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
_detailsPaneFraction.dispose();
super.dispose();
}
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif).
List<EventModel> _filterEventsByUser(List<EventModel> allEvents) {
if (_selectedUserId == null) {
return allEvents; // Pas de filtre, retourner tous les événements
}
@@ -208,6 +231,536 @@ class _CalendarPageState extends State<CalendarPage> {
}).toList();
}
bool _isSameDay(DateTime left, DateTime right) {
return left.year == right.year &&
left.month == right.month &&
left.day == right.day;
}
List<EventModel> _getEventsForDay(
List<EventModel> events,
DateTime? day, {
EventModel? selectedEvent,
}) {
if (day == null) {
return [];
}
final dayEvents = events
.where((event) => _isSameDay(event.startDateTime, day))
.toList()
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
if (selectedEvent != null &&
_isSameDay(selectedEvent.startDateTime, day) &&
!dayEvents.any((event) => event.id == selectedEvent.id)) {
dayEvents.add(selectedEvent);
dayEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
}
return dayEvents;
}
List<EventModel> _getDetailsEvents(List<EventModel> events) {
final mergedEvents = [...events];
if (_selectedEvent != null &&
!mergedEvents.any((event) => event.id == _selectedEvent!.id)) {
mergedEvents.add(_selectedEvent!);
}
mergedEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
return mergedEvents;
}
String _formatSearchResultDate(DateTime dateTime) {
return DateFormat('EEE d MMM yyyy • HH:mm', 'fr_FR').format(dateTime);
}
Color _getStatusColor(EventStatus status) {
switch (status) {
case EventStatus.confirmed:
return Colors.green;
case EventStatus.canceled:
return Colors.red;
case EventStatus.waitingForApproval:
default:
return Colors.amber;
}
}
/// Combine uniquement le filtre utilisateur avec la vue calendrier.
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
return _filterEventsByUser(allEvents);
}
void _cancelPendingSearch() {
_searchDebounce?.cancel();
_searchDebounce = null;
}
void _scheduleSearch(String value) {
_cancelPendingSearch();
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
_runSearch(value);
});
}
void _onSearchChanged(String value) {
final isMobile = MediaQuery.of(context).size.width < 600;
if (isMobile && value.isNotEmpty && !_isMobileSearchVisible) {
setState(() {
_isMobileSearchVisible = true;
});
}
setState(() {
_searchQuery = value;
});
if (value.trim().isEmpty) {
_cancelPendingSearch();
setState(() {
_searchResults = [];
_searchError = null;
_isSearching = false;
});
return;
}
_scheduleSearch(value);
}
void _clearSearch() {
_cancelPendingSearch();
if (_searchController.text.isEmpty) {
return;
}
_searchController.clear();
setState(() {
_searchQuery = '';
_searchResults = [];
_searchError = null;
_isSearching = false;
});
}
Future<void> _runSearch(String value) async {
final query = value.trim();
if (query.isEmpty) {
return;
}
final localUserProvider = context.read<LocalUserProvider>();
final userId = localUserProvider.uid;
if (userId == null) {
return;
}
final searchId = ++_searchRequestId;
setState(() {
_isSearching = true;
_searchError = null;
_searchResults = [];
});
try {
final eventProvider = context.read<EventProvider>();
final results = await eventProvider.searchEvents(
userId: userId,
query: query,
);
if (!mounted) {
return;
}
if (_searchQuery.trim() != query) {
return;
}
if (searchId != _searchRequestId) {
return;
}
setState(() {
_searchResults = results;
_searchError = null;
_isSearching = false;
});
} catch (e) {
if (!mounted || _searchQuery.trim() != query) {
return;
}
setState(() {
_searchResults = [];
_searchError = 'Erreur lors de la recherche : $e';
_isSearching = false;
});
}
}
Widget _buildDesktopFiltersBar({required bool canViewAllUserEvents}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
color: Colors.grey[100],
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Rechercher (titre, description, lieu)',
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
tooltip: 'Effacer la recherche',
icon: const Icon(Icons.close),
onPressed: _clearSearch,
)
: null,
isDense: true,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
),
if (canViewAllUserEvents) ...[
const SizedBox(width: 12),
_buildCompactUserFilter(),
],
],
),
);
}
Widget _buildCompactUserFilter() {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Row(
children: [
Expanded(
child: UserFilterDropdown(
selectedUserId: _selectedUserId,
onUserSelected: (userId) {
setState(() {
_selectedUserId = userId;
});
},
),
),
],
),
);
}
Widget _buildMobileSearchBar() {
return Container(
color: Colors.grey[100],
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Column(
children: [
Row(
children: [
IconButton(
icon: Icon(
_isMobileSearchVisible ? Icons.search_off : Icons.search,
color: AppColors.rouge,
),
tooltip: _isMobileSearchVisible
? 'Masquer la recherche'
: 'Afficher la recherche',
onPressed: () {
setState(() {
_isMobileSearchVisible = !_isMobileSearchVisible;
});
},
),
Expanded(
child: Text(
_searchQuery.isEmpty
? 'Rechercher un événement'
: 'Recherche active',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
if (_searchQuery.isNotEmpty)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Effacer la recherche',
onPressed: _clearSearch,
),
],
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _isMobileSearchVisible
? Padding(
key: const ValueKey('mobile-search-visible'),
padding: const EdgeInsets.only(top: 4, left: 8, right: 8),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Titre, description ou lieu',
prefixIcon:
const Icon(Icons.search, color: AppColors.rouge),
isDense: true,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
)
: const SizedBox.shrink(
key: ValueKey('mobile-search-hidden'),
),
),
],
),
);
}
Widget _buildSearchResultsPanel({required bool isMobile}) {
final hasQuery = _searchQuery.trim().isNotEmpty;
if (!hasQuery && !_isSearching && _searchError == null) {
return const SizedBox.shrink();
}
final panelPadding = EdgeInsets.symmetric(
horizontal: isMobile ? 8 : 16,
vertical: 8,
);
return Container(
width: double.infinity,
padding: panelPadding,
color: Colors.grey[50],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.manage_search, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
hasQuery
? 'Résultats pour "$_searchQuery"'
: 'Recherche d’événements',
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
),
if (_isSearching)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
if (_searchError != null) ...[
const SizedBox(height: 8),
Text(
_searchError!,
style: const TextStyle(color: Colors.red),
),
] else if (!hasQuery) ...[
const SizedBox(height: 8),
Text(
'Saisissez un titre, une description ou un lieu pour lancer la recherche.',
style: TextStyle(color: Colors.grey.shade700),
),
] else if (!_isSearching) ...[
const SizedBox(height: 8),
if (_searchResults.isEmpty)
Text(
'Aucun résultat trouvé.',
style: TextStyle(color: Colors.grey.shade700),
)
else
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: isMobile ? 240 : 280,
),
child: ListView.builder(
shrinkWrap: true,
itemCount: _searchResults.length,
physics: const ClampingScrollPhysics(),
// ✅ prototypeItem : les résultats ont une hauteur variable
// selon la présence du champ adresse (~56px sans, ~70px avec).
// prototypeItem à 72px (cas avec adresse + padding) pour
// que Flutter estime correctement la hauteur scrollable.
// ListView.separated ne supporte pas itemExtent/prototypeItem,
// d'où la conversion en ListView.builder avec séparateur intégré.
prototypeItem: const SizedBox(height: 72),
itemBuilder: (context, index) {
final event = _searchResults[index];
final isSelected = _selectedEvent?.id == event.id;
final isLast = index == _searchResults.length - 1;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: isSelected
? AppColors.rouge.withOpacity(0.08)
: Colors.white,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _onSearchResultSelected(event),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _getStatusColor(event.status),
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
_formatSearchResultDate(
event.startDateTime),
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 12,
),
),
if (event.address.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
event.address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
),
),
],
],
),
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right,
color: Colors.grey),
],
),
),
),
),
if (!isLast) const SizedBox(height: 8),
],
);
},
),
),
],
],
),
);
}
Future<void> _onSearchResultSelected(EventModel event) async {
final localUserProvider = context.read<LocalUserProvider>();
final eventProvider = context.read<EventProvider>();
final userId = localUserProvider.uid;
if (userId == null) {
return;
}
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
final selectedDay = DateTime(
event.startDateTime.year,
event.startDateTime.month,
event.startDateTime.day,
);
final shouldLoadMonth = _focusedDay.year != event.startDateTime.year ||
_focusedDay.month != event.startDateTime.month ||
eventProvider.events.isEmpty;
if (shouldLoadMonth) {
await eventProvider.loadMonthEvents(
userId,
event.startDateTime.year,
event.startDateTime.month,
canViewAllEvents: canViewAllEvents,
);
eventProvider.preloadAdjacentMonths(
userId,
event.startDateTime.year,
event.startDateTime.month,
canViewAllEvents: canViewAllEvents,
);
}
if (!mounted) {
return;
}
final eventsForSelectedDay = _getEventsForDay(
eventProvider.events,
selectedDay,
selectedEvent: event,
);
final isMobile = MediaQuery.of(context).size.width < 600;
setState(() {
_focusedDay = selectedDay;
_selectedDay = selectedDay;
_selectedEvent = event;
_selectedEventIndex =
eventsForSelectedDay.indexWhere((e) => e.id == event.id);
if (_selectedEventIndex < 0) {
_selectedEventIndex = 0;
}
_calendarCollapsed = false;
if (isMobile) {
_isMobileSearchVisible = true;
}
});
}
void _changeWeek(int delta) {
setState(() {
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
@@ -238,10 +791,12 @@ class _CalendarPageState extends State<CalendarPage> {
Widget _buildDesktopDetailsPane(List<EventModel> filteredEvents) {
if (_selectedEvent != null) {
final detailsEvents = _getDetailsEvents(filteredEvents);
return EventDetails(
event: _selectedEvent!,
selectedDate: _selectedDay,
events: filteredEvents,
events: detailsEvents,
onSelectEvent: (event, date) {
setState(() {
_selectedEvent = event;
@@ -264,12 +819,10 @@ class _CalendarPageState extends State<CalendarPage> {
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) {
setState(() {
_detailsPaneFraction = _clampDetailsPaneFraction(
_detailsPaneFraction - (details.delta.dx / totalWidth),
_detailsPaneFraction.value = _clampDetailsPaneFraction(
_detailsPaneFraction.value - (details.delta.dx / totalWidth),
totalWidth,
);
});
},
child: SizedBox(
width: _desktopResizeHandleWidth,
@@ -292,10 +845,13 @@ class _CalendarPageState extends State<CalendarPage> {
Widget build(BuildContext context) {
final eventProvider = Provider.of<EventProvider>(context);
final localUserProvider = Provider.of<LocalUserProvider>(context);
_scheduleInitialEventsLoad(localUserProvider.uid);
final canCreateEvents = localUserProvider.hasPermission('create_events');
final canViewAllUserEvents =
localUserProvider.hasPermission('view_all_user_events');
final isMobile = MediaQuery.of(context).size.width < 600;
final showSearchResults =
_searchQuery.trim().isNotEmpty || _isSearching || _searchError != null;
// Appliquer le filtre utilisateur si actif
final filteredEvents = _getFilteredEvents(eventProvider.events);
@@ -309,11 +865,7 @@ class _CalendarPageState extends State<CalendarPage> {
}
if (eventProvider.isLoading) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
return const StartupSplashScreen(message: 'Chargement des événements...');
}
return Scaffold(
@@ -343,33 +895,11 @@ class _CalendarPageState extends State<CalendarPage> {
drawer: const MainDrawer(currentPage: '/calendar'),
body: Column(
children: [
// Filtre utilisateur dans le corps de la page
if (canViewAllUserEvents && !isMobile)
Container(
padding: const EdgeInsets.all(16),
color: Colors.grey[100],
child: Row(
children: [
const Icon(Icons.filter_list, color: AppColors.rouge),
const SizedBox(width: 12),
const Text(
'Filtrer par utilisateur :',
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
),
const SizedBox(width: 16),
Expanded(
child: UserFilterDropdown(
selectedUserId: _selectedUserId,
onUserSelected: (userId) {
setState(() {
_selectedUserId = userId;
});
},
),
),
],
),
),
if (isMobile)
_buildMobileSearchBar()
else
_buildDesktopFiltersBar(canViewAllUserEvents: canViewAllUserEvents),
if (showSearchResults) _buildSearchResultsPanel(isMobile: isMobile),
// Corps du calendrier
Expanded(
child: isMobile
@@ -401,8 +931,11 @@ class _CalendarPageState extends State<CalendarPage> {
return LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
return ValueListenableBuilder<double>(
valueListenable: _detailsPaneFraction,
builder: (context, fraction, child) {
final detailsPaneFraction =
_clampDetailsPaneFraction(_detailsPaneFraction, totalWidth);
_clampDetailsPaneFraction(fraction, totalWidth);
final detailsWidth = totalWidth * detailsPaneFraction;
final calendarWidth =
totalWidth - _desktopResizeHandleWidth - detailsWidth;
@@ -423,24 +956,31 @@ class _CalendarPageState extends State<CalendarPage> {
);
},
);
},
);
}
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
final eventsForSelectedDay = _selectedDay == null
? []
: filteredEvents
.where((e) =>
e.startDateTime.year == _selectedDay!.year &&
e.startDateTime.month == _selectedDay!.month &&
e.startDateTime.day == _selectedDay!.day)
.toList()
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
final eventsForSelectedDay = _getEventsForDay(
filteredEvents,
_selectedDay,
selectedEvent: _selectedEvent,
);
final hasEvents = eventsForSelectedDay.isNotEmpty;
final currentEvent =
hasEvents && _selectedEventIndex < eventsForSelectedDay.length
final selectedEventIndex = _selectedEvent == null
? -1
: eventsForSelectedDay
.indexWhere((event) => event.id == _selectedEvent!.id);
final currentEvent = hasEvents && selectedEventIndex >= 0
? eventsForSelectedDay[selectedEventIndex]
: hasEvents && _selectedEventIndex < eventsForSelectedDay.length
? eventsForSelectedDay[_selectedEventIndex]
: null;
return LayoutBuilder(
builder: (context, constraints) {
final maxHeight = constraints.maxHeight;
// GESTURE DETECTOR pour swipe vertical (plier/déplier) et horizontal (mois)
return GestureDetector(
onVerticalDragEnd: (details) {
@@ -489,12 +1029,12 @@ class _CalendarPageState extends State<CalendarPage> {
AnimatedPositioned(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
top: _calendarCollapsed ? -600 : 0, // cache le calendrier en haut
top: _calendarCollapsed ? -maxHeight : 0, // cache le calendrier en haut
left: 0,
right: 0,
height: _calendarCollapsed ? 0 : null,
child: SizedBox(
height: MediaQuery.of(context).size.height,
height: maxHeight,
child: Column(
children: [
_buildMonthHeader(context),
@@ -581,7 +1121,7 @@ class _CalendarPageState extends State<CalendarPage> {
child: EventDetails(
event: eventsForSelectedDay[_selectedEventIndex],
selectedDate: _selectedDay,
events: eventsForSelectedDay.cast<EventModel>(),
events: eventsForSelectedDay,
onSelectEvent: (event, date) {
final idx = eventsForSelectedDay
.indexWhere((e) => e.id == event.id);
@@ -600,17 +1140,17 @@ class _CalendarPageState extends State<CalendarPage> {
),
),
),
// Vue détail (prend tout l'espace quand calendrier caché)
// Vue détail (prend tout l'espace quand calendrier cache)
if (_calendarCollapsed && _selectedDay != null)
AnimatedPositioned(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
top: _calendarCollapsed ? 0 : 600,
top: _calendarCollapsed ? 0 : maxHeight,
left: 0,
right: 0,
bottom: 0,
child: SizedBox(
height: MediaQuery.of(context).size.height,
height: maxHeight,
child: Column(
children: [
_buildMonthHeader(context),
@@ -647,7 +1187,7 @@ class _CalendarPageState extends State<CalendarPage> {
child: EventDetails(
event: currentEvent,
selectedDate: _selectedDay,
events: eventsForSelectedDay.cast<EventModel>(),
events: eventsForSelectedDay,
onSelectEvent: (event, date) {
final idx = eventsForSelectedDay
.indexWhere((e) => e.id == event.id);
@@ -659,7 +1199,7 @@ class _CalendarPageState extends State<CalendarPage> {
),
),
if (!hasEvents)
Center(
const Center(
child: Text(
'Aucun événement ne démarre à cette date'),
),
@@ -673,6 +1213,8 @@ class _CalendarPageState extends State<CalendarPage> {
],
),
);
},
);
}
Widget _buildMonthHeader(BuildContext context) {
+230 -116
View File
@@ -7,6 +7,10 @@ import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/id_generator.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debouncer.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
class ContainerFormPage extends StatefulWidget {
final ContainerModel? container;
@@ -32,7 +36,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
// Form fields
ContainerType _selectedType = ContainerType.flightCase;
EquipmentStatus _selectedStatus = EquipmentStatus.available;
bool _autoGenerateId = true;
final ValueNotifier<bool> _autoGenerateIdNotifier = ValueNotifier<bool>(true);
final Set<String> _selectedEquipmentIds = {};
bool _isEditing = false;
@@ -58,11 +62,11 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
_heightController.text = container.height?.toString() ?? '';
_notesController.text = container.notes ?? '';
_selectedEquipmentIds.addAll(container.equipmentIds);
_autoGenerateId = false;
_autoGenerateIdNotifier.value = false;
}
void _updateIdFromName() {
if (_autoGenerateId && !_isEditing) {
if (_autoGenerateIdNotifier.value && !_isEditing) {
final name = _nameController.text;
if (name.isNotEmpty) {
final baseId = IdGenerator.generateContainerId(
@@ -75,7 +79,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
}
void _updateIdFromType() {
if (_autoGenerateId && !_isEditing) {
if (_autoGenerateIdNotifier.value && !_isEditing) {
final name = _nameController.text;
if (name.isNotEmpty) {
final baseId = IdGenerator.generateContainerId(
@@ -87,18 +91,65 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
}
}
Widget _buildCard({
required String title,
required IconData icon,
required List<Widget> children,
}) {
return Card(
elevation: 2,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade200, width: 1),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.noir,
),
),
],
),
const Divider(height: 24, thickness: 1),
...children,
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier boite' : 'Nouvelle boite'),
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
appBar: CustomAppBar(
title: _isEditing ? 'Modifier boîte' : 'Nouvelle boîte',
),
body: Form(
key: _formKey,
child: ListView(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Card 1: Informations Générales
_buildCard(
title: 'Informations générales',
icon: Icons.info_outline,
children: [
// Nom
TextFormField(
@@ -120,7 +171,10 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
const SizedBox(height: 16),
// ID
Row(
ValueListenableBuilder<bool>(
valueListenable: _autoGenerateIdNotifier,
builder: (context, autoGenerateId, child) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
@@ -132,7 +186,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.qr_code),
),
enabled: !_autoGenerateId || _isEditing,
enabled: !autoGenerateId || _isEditing,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un identifiant';
@@ -146,23 +200,23 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
const SizedBox(width: 8),
IconButton(
icon: Icon(
_autoGenerateId ? Icons.lock : Icons.lock_open,
color: _autoGenerateId ? AppColors.rouge : Colors.grey,
autoGenerateId ? Icons.lock : Icons.lock_open,
color: autoGenerateId ? AppColors.rouge : Colors.grey,
),
tooltip: _autoGenerateId
tooltip: autoGenerateId
? 'Génération automatique'
: 'Saisie manuelle',
onPressed: () {
setState(() {
_autoGenerateId = !_autoGenerateId;
if (_autoGenerateId) {
_autoGenerateIdNotifier.value = !autoGenerateId;
if (_autoGenerateIdNotifier.value) {
_updateIdFromName();
}
});
},
),
],
],
);
},
),
const SizedBox(height: 16),
@@ -177,7 +231,13 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
items: ContainerType.values.map((type) {
return DropdownMenuItem(
value: type,
child: Text(type.label),
child: Row(
children: [
type.getIcon(size: 20, color: AppColors.rouge),
const SizedBox(width: 8),
Text(type.label),
],
),
);
}).toList(),
onChanged: (value) {
@@ -197,7 +257,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
decoration: const InputDecoration(
labelText: 'Statut *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.info),
prefixIcon: Icon(Icons.info_outline),
),
items: [
EquipmentStatus.available,
@@ -235,18 +295,15 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
}
},
),
const SizedBox(height: 24),
// Section Caractéristiques physiques
Text(
'Caractéristiques physiques',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
],
),
),
const Divider(),
const SizedBox(height: 16),
const SizedBox(height: 20),
// Card 2: Caractéristiques Physiques
_buildCard(
title: 'Caractéristiques physiques',
icon: Icons.scale_outlined,
children: [
// Poids
TextFormField(
controller: _weightController,
@@ -256,8 +313,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.scale),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
@@ -279,8 +335,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Longueur (cm)',
border: OutlineInputBorder(),
),
keyboardType:
TextInputType.numberWithOptions(decimal: true),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
@@ -299,8 +354,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Largeur (cm)',
border: OutlineInputBorder(),
),
keyboardType:
TextInputType.numberWithOptions(decimal: true),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
@@ -319,8 +373,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
labelText: 'Hauteur (cm)',
border: OutlineInputBorder(),
),
keyboardType:
TextInputType.numberWithOptions(decimal: true),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (double.tryParse(value) == null) {
@@ -333,32 +386,33 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
),
],
),
const SizedBox(height: 24),
// Section Équipements
Text(
'Équipements dans ce container',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
],
),
),
const Divider(),
const SizedBox(height: 16),
const SizedBox(height: 20),
// Liste des équipements sélectionnés
// Card 3: Équipements dans ce container
_buildCard(
title: 'Équipements dans ce container',
icon: Icons.inventory_2_outlined,
children: [
if (_selectedEquipmentIds.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
border: Border.all(color: Colors.grey.shade200),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade50,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_selectedEquipmentIds.length} équipement(s) sélectionné(s)',
style: const TextStyle(fontWeight: FontWeight.bold),
'${_selectedEquipmentIds.length} équipement(s) sélectionné(s) :',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 8),
Wrap(
@@ -366,13 +420,19 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
runSpacing: 8,
children: _selectedEquipmentIds.map((id) {
return Chip(
label: Text(id),
deleteIcon: const Icon(Icons.close, size: 18),
label: Text(
id,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
_selectedEquipmentIds.remove(id);
});
},
backgroundColor: AppColors.rouge.withValues(alpha: 0.08),
side: BorderSide(color: AppColors.rouge.withValues(alpha: 0.2)),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}).toList(),
),
@@ -381,9 +441,9 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
)
else
Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
border: Border.all(color: Colors.grey.shade200),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade50,
),
@@ -394,7 +454,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
),
),
),
const SizedBox(height: 12),
const SizedBox(height: 16),
// Bouton pour ajouter des équipements
OutlinedButton.icon(
@@ -403,52 +463,66 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
label: const Text('Ajouter des équipements'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
side: BorderSide(color: AppColors.rouge),
foregroundColor: AppColors.rouge,
),
),
const SizedBox(height: 24),
],
),
const SizedBox(height: 20),
// Notes
// Card 4: Notes
_buildCard(
title: 'Notes & Remarques',
icon: Icons.notes_outlined,
children: [
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes',
labelText: 'Notes complémentaires',
hintText: 'Informations additionnelles...',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.notes),
prefixIcon: Icon(Icons.edit_note),
),
maxLines: 3,
),
],
),
const SizedBox(height: 32),
// Boutons
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
child: const Text('Annuler', style: TextStyle(fontSize: 16)),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: _saveContainer,
icon: const Icon(Icons.save, color: Colors.white),
label: Text(
_isEditing ? 'Mettre à jour' : 'Créer',
style: const TextStyle(color: Colors.white),
_isEditing ? 'Enregistrer' : 'Créer',
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
const SizedBox(height: 24),
],
),
),
),
),
),
);
}
@@ -629,6 +703,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
_widthController.dispose();
_heightController.dispose();
_notesController.dispose();
_autoGenerateIdNotifier.dispose();
super.dispose();
}
}
@@ -650,25 +725,88 @@ class _EquipmentSelectorDialog extends StatefulWidget {
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final DataService _dataService = DataService(FirebaseFunctionsApiService());
EquipmentCategory? _filterCategory;
String _searchQuery = '';
late Set<String> _tempSelectedIds;
late final Future<void> _loadingFuture;
final _searchDebouncer = Debouncer();
final List<EquipmentModel> _paginatedEquipments = [];
bool _isLoadingMore = false;
bool _hasMoreEquipments = true;
String? _lastEquipmentId;
@override
void initState() {
super.initState();
// Créer une copie temporaire des IDs sélectionnés
_tempSelectedIds = Set<String>.from(widget.selectedIds);
_loadingFuture = widget.equipmentProvider.loadEquipments();
_scrollController.addListener(_onScroll);
_loadNextPage();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.dispose();
_searchDebouncer.dispose();
super.dispose();
}
void _onScroll() {
if (_isLoadingMore) return;
if (_scrollController.hasClients &&
_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) {
if (_hasMoreEquipments) {
_loadNextPage();
}
}
}
Future<void> _loadNextPage() async {
if (_isLoadingMore || !_hasMoreEquipments) return;
setState(() => _isLoadingMore = true);
try {
final result = await _dataService.getEquipmentsPaginated(
limit: 50,
startAfter: _lastEquipmentId,
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
category: _filterCategory != null ? equipmentCategoryToString(_filterCategory!) : null,
sortBy: 'id',
sortOrder: 'asc',
);
final newEquipments = (result['equipments'] as List<dynamic>)
.map((data) => EquipmentModel.fromMap(data as Map<String, dynamic>, data['id'] as String))
.toList();
if (mounted) {
setState(() {
_paginatedEquipments.addAll(newEquipments);
_hasMoreEquipments = result['hasMore'] as bool? ?? false;
_lastEquipmentId = result['lastVisible'] as String?;
_isLoadingMore = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
}
}
Future<void> _reloadData() async {
setState(() {
_paginatedEquipments.clear();
_lastEquipmentId = null;
_hasMoreEquipments = true;
});
await _loadNextPage();
}
@override
Widget build(BuildContext context) {
return Dialog(
@@ -718,6 +856,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() {
_searchQuery = '';
});
_reloadData();
},
)
: null,
@@ -726,6 +865,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() {
_searchQuery = value;
});
_searchDebouncer(_reloadData);
},
),
const SizedBox(height: 16),
@@ -743,6 +883,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() {
_filterCategory = null;
});
_reloadData();
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
@@ -761,6 +902,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() {
_filterCategory = selected ? category : null;
});
_reloadData();
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
@@ -780,7 +922,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.rouge.withOpacity(0.1),
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
@@ -798,48 +940,22 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
// Liste des équipements
Expanded(
child: FutureBuilder<void>(
future: _loadingFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Erreur: ${snapshot.error}'));
}
var equipment = List<EquipmentModel>.from(
widget.equipmentProvider.allEquipment,
);
// Filtrer par catégorie
if (_filterCategory != null) {
equipment = equipment
.where((e) => e.category == _filterCategory)
.toList();
}
// Filtrer par recherche
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
equipment = equipment.where((e) {
return e.id.toLowerCase().contains(query) ||
(e.brand?.toLowerCase().contains(query) ?? false) ||
(e.model?.toLowerCase().contains(query) ?? false);
}).toList();
}
if (equipment.isEmpty) {
return const Center(
child: Text('Aucun équipement trouvé'),
);
}
return ListView.builder(
itemCount: equipment.length,
child: _paginatedEquipments.isEmpty && !_isLoadingMore
? const Center(child: Text('Aucun équipement trouvé'))
: ListView.builder(
controller: _scrollController,
itemCount: _paginatedEquipments.length + (_isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
final item = equipment[index];
if (index == _paginatedEquipments.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(),
),
);
}
final item = _paginatedEquipments[index];
final isSelected = _tempSelectedIds.contains(item.id);
return CheckboxListTile(
@@ -879,8 +995,6 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
activeColor: AppColors.rouge,
);
},
);
},
),
),
+18
View File
@@ -4,6 +4,9 @@ import 'package:em2rp/views/widgets/data_management/event_types_management.dart'
import 'package:em2rp/views/widgets/data_management/options_management.dart';
import 'package:em2rp/views/widgets/data_management/events_export.dart';
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
import 'package:em2rp/views/widgets/data_management/depot_management.dart';
import 'package:em2rp/views/widgets/data_management/vehicles_management.dart';
import 'package:em2rp/views/widgets/data_management/fuel_prices_management.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/utils/permission_gate.dart';
@@ -50,6 +53,21 @@ class _DataManagementPageState extends State<DataManagementPage> {
child: EventStatisticsTab(),
),
),
DataCategory(
title: 'Dépôts',
icon: Icons.warehouse_outlined,
widget: const DepotManagement(),
),
DataCategory(
title: 'Véhicules',
icon: Icons.directions_car_outlined,
widget: const VehiclesManagement(),
),
DataCategory(
title: 'Prix carburants',
icon: Icons.local_gas_station,
widget: const FuelPricesManagement(),
),
];
@override
+49 -23
View File
@@ -8,6 +8,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/qr_code_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/equipment_delete_utils.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
@@ -45,7 +46,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
Future<void> _loadMaintenances() async {
try {
final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id);
final maintenances = await _equipmentService
.getMaintenancesForEquipment(widget.equipment.id);
setState(() {
_maintenances = maintenances;
_isLoadingMaintenances = false;
@@ -57,8 +59,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
}
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
@@ -103,7 +103,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
const SizedBox(height: 24),
// 3. Notes
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[
if (widget.equipment.notes != null &&
widget.equipment.notes!.isNotEmpty) ...[
EquipmentNotesSection(notes: widget.equipment.notes!),
const SizedBox(height: 24),
],
@@ -185,7 +186,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
);
}
void _showQRCode() {
showDialog(
context: context,
@@ -249,10 +249,12 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
),
const SizedBox(height: 4),
Text(
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'
.trim(),
style: TextStyle(color: Colors.grey[700]),
),
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[
if (widget.equipment.subCategory != null &&
widget.equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'📁 ${widget.equipment.subCategory}',
@@ -389,7 +391,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
if (!hasPermission) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vous n\'avez pas la permission de gérer les maintenances'),
content:
Text('Vous n\'avez pas la permission de gérer les maintenances'),
backgroundColor: Colors.orange,
),
);
@@ -423,31 +426,50 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
}
void _deleteEquipment() {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: widget.equipment.id,
name: widget.equipment.name,
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.',
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
// Fermer le dialog
Navigator.pop(context);
Navigator.pop(dialogContext);
// Capturer le ScaffoldMessenger avant la suppression
final scaffoldMessenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final navigator = Navigator.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
await context
.read<EquipmentProvider>()
.deleteEquipment(widget.equipment.id);
final deleted =
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: equipmentLabel,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
widget.equipment.id,
forceDelete: forceDelete,
);
},
);
if (!deleted) {
return;
}
// Revenir à la page précédente
navigator.pop();
@@ -455,19 +477,23 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
// Afficher le snackbar (même si le widget est démonté)
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text('Équipement supprimé avec succès'),
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green,
),
);
} catch (e) {
// Afficher l'erreur
scaffoldMessenger.showSnackBar(
SnackBar(content: Text('Erreur: $e')),
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
+575 -166
View File
@@ -12,6 +12,7 @@ import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
import 'package:em2rp/views/equipment_form/subcategory_selector.dart';
import 'package:em2rp/utils/id_generator.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/debouncer.dart';
class EquipmentFormPage extends StatefulWidget {
final EquipmentModel? equipment;
@@ -38,28 +39,69 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
final TextEditingController _notesController = TextEditingController();
final TextEditingController _quantityToAddController = TextEditingController(text: '1');
// Physical characteristics controllers
final TextEditingController _weightController = TextEditingController();
final TextEditingController _lengthController = TextEditingController();
final TextEditingController _widthController = TextEditingController();
final TextEditingController _heightController = TextEditingController();
// State variables
EquipmentCategory _selectedCategory = EquipmentCategory.other;
EquipmentCategory? _selectedCategory; // Nullable by default to force selection
EquipmentStatus _selectedStatus = EquipmentStatus.available;
DateTime? _purchaseDate;
DateTime? _lastMaintenanceDate;
DateTime? _nextMaintenanceDate;
bool _isLoading = false;
bool _addMultiple = false;
String? _selectedBrand;
List<String> _filteredModels = [];
List<String> _filteredSubCategories = [];
// ID auto-generation and check
final ValueNotifier<bool> _autoGenerateIdNotifier = ValueNotifier<bool>(true);
final _idCheckDebouncer = Debouncer(delay: const Duration(milliseconds: 500));
String? _idConflictMessage;
List<String> _candidateIds = [];
bool _isCalculatingIds = false;
@override
void initState() {
_candidateIds = [];
_isCalculatingIds = false;
super.initState();
// Set default dates to today for new equipment
if (widget.equipment == null) {
_purchaseDate = DateTime.now();
_lastMaintenanceDate = DateTime.now();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = Provider.of<EquipmentProvider>(context, listen: false);
provider.loadBrands();
provider.loadModels();
if (widget.equipment != null) {
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
_loadFilteredModels(_selectedBrand!);
}
if (_selectedCategory != null) {
_loadFilteredSubCategories(_selectedCategory!);
}
}
});
if (widget.equipment != null) {
_populateFields();
} else {
// Set up listeners for auto-generation of ID
_brandController.addListener(_triggerCandidateIdsUpdate);
_modelController.addListener(_triggerCandidateIdsUpdate);
_quantityToAddController.addListener(_triggerCandidateIdsUpdate);
_identifierController.addListener(_onIdentifierManualChanged);
// Run initial check once the page is fully mounted/built
WidgetsBinding.instance.addPostFrameCallback((_) {
_triggerCandidateIdsUpdate();
});
}
}
@@ -81,19 +123,172 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
_lastMaintenanceDate = equipment.lastMaintenanceDate;
_nextMaintenanceDate = equipment.nextMaintenanceDate;
_notesController.text = equipment.notes ?? '';
_weightController.text = equipment.weight?.toString() ?? '';
_lengthController.text = equipment.length?.toString() ?? '';
_widthController.text = equipment.width?.toString() ?? '';
_heightController.text = equipment.height?.toString() ?? '';
});
// Disable auto-generation for editing
_autoGenerateIdNotifier.value = false;
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
_loadFilteredModels(_selectedBrand!);
}
// Charger les sous-catégories pour la catégorie sélectionnée
_loadFilteredSubCategories(_selectedCategory);
void _onIdentifierManualChanged() {
if (!_autoGenerateIdNotifier.value && widget.equipment == null) {
_triggerCandidateIdsUpdate();
}
}
void _triggerCandidateIdsUpdate() {
_idCheckDebouncer(() async {
if (!mounted || widget.equipment != null) return;
setState(() {
_isCalculatingIds = true;
});
try {
final ids = await _calculateCandidateIds();
if (!mounted) return;
setState(() {
_candidateIds = ids;
// If auto-generating, update the text field with the first generated ID
if (_autoGenerateIdNotifier.value && ids.isNotEmpty) {
_identifierController.removeListener(_onIdentifierManualChanged);
_identifierController.text = ids.first;
_identifierController.addListener(_onIdentifierManualChanged);
}
// Determine if there was an ID replacement/conflict
_idConflictMessage = null;
if (ids.isNotEmpty) {
final brand = _brandController.text.trim();
final model = _modelController.text.trim();
final quantityText = _quantityToAddController.text.trim();
final numbers = _parseQuantityOrRange(quantityText);
if (_autoGenerateIdNotifier.value) {
if (numbers != null && numbers.isNotEmpty) {
final firstExpectedNum = numbers.first;
final baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null);
final expectedFirstId = '${baseId}_#$firstExpectedNum';
if (ids.first != expectedFirstId) {
_idConflictMessage = "L'ID $expectedFirstId était déjà pris et a été remplacé par ${ids.first}";
}
}
} else {
final manualId = _identifierController.text.trim().toUpperCase();
if (manualId.isNotEmpty && ids.first != manualId) {
_idConflictMessage = "L'ID $manualId était déjà pris et a été remplacé par ${ids.first}";
}
}
}
});
} catch (e) {
DebugLog.error("Error calculating candidate IDs: $e");
} finally {
if (mounted) {
setState(() {
_isCalculatingIds = false;
});
}
}
});
}
List<int>? _parseQuantityOrRange(String text) {
text = text.trim();
if (text.isEmpty) return null;
// Try single number
final singleNum = int.tryParse(text);
if (singleNum != null) {
if (singleNum < 1 || singleNum > 100) return null;
return List<int>.generate(singleNum, (i) => i + 1);
}
// Try range pattern like "3-6" or "3 - 6"
final rangeRegex = RegExp(r'^(\d+)\s*-\s*(\d+)$');
final match = rangeRegex.firstMatch(text);
if (match != null) {
final start = int.tryParse(match.group(1)!);
final end = int.tryParse(match.group(2)!);
if (start != null && end != null && start > 0 && end >= start) {
if (end - start + 1 > 100) return null;
return List<int>.generate(end - start + 1, (i) => start + i);
}
}
return null;
}
IdParseResult _parseBaseAndNumber(String id) {
final match = RegExp(r'^(.*)_#(\d+)$').firstMatch(id);
if (match != null) {
return IdParseResult(match.group(1)!, int.parse(match.group(2)!));
}
return IdParseResult(id, null);
}
Future<List<String>> _calculateCandidateIds() async {
final brand = _brandController.text.trim();
final model = _modelController.text.trim();
final quantityText = _quantityToAddController.text.trim();
if (_autoGenerateIdNotifier.value && brand.isEmpty && model.isEmpty) {
return [];
}
// Get base ID
String baseId;
int? initialNumber;
if (_autoGenerateIdNotifier.value) {
baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null);
} else {
final manualId = _identifierController.text.trim().toUpperCase();
if (manualId.isEmpty) return [];
final parsed = _parseBaseAndNumber(manualId);
baseId = parsed.baseId;
initialNumber = parsed.number;
}
// Parse numbers
final numbers = _parseQuantityOrRange(quantityText);
if (numbers == null || numbers.isEmpty) return [];
// If the quantityText is just a single number (e.g. "5") and we have a manual ID with an initial number (e.g. "CUSTOM_#3"),
// we adjust the numbers to start from that initial number.
List<int> targetNumbers = numbers;
final isSingleNumberInput = int.tryParse(quantityText) != null;
if (isSingleNumberInput && initialNumber != null) {
targetNumbers = List<int>.generate(numbers.length, (i) => initialNumber! + i);
}
List<String> resultIds = [];
final Set<String> allocatedInBatch = {};
for (final num in targetNumbers) {
int currentNum = num;
String candidateId = '${baseId}_#$currentNum';
while (allocatedInBatch.contains(candidateId) || !(await _equipmentService.isIdUnique(candidateId))) {
currentNum++;
candidateId = '${baseId}_#$currentNum';
}
resultIds.add(candidateId);
allocatedInBatch.add(candidateId);
}
return resultIds;
}
Future<void> _loadFilteredModels(String brand) async {
try {
@@ -125,6 +320,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
@override
void dispose() {
_brandController.removeListener(_triggerCandidateIdsUpdate);
_modelController.removeListener(_triggerCandidateIdsUpdate);
_quantityToAddController.removeListener(_triggerCandidateIdsUpdate);
_identifierController.removeListener(_onIdentifierManualChanged);
_identifierController.dispose();
_brandController.dispose();
_modelController.dispose();
@@ -135,10 +335,57 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
_criticalThresholdController.dispose();
_notesController.dispose();
_quantityToAddController.dispose();
_weightController.dispose();
_lengthController.dispose();
_widthController.dispose();
_heightController.dispose();
_autoGenerateIdNotifier.dispose();
_idCheckDebouncer.dispose();
super.dispose();
}
bool get _isConsumable => _selectedCategory == EquipmentCategory.consumable || _selectedCategory == EquipmentCategory.cable;
bool get _isConsumable => _selectedCategory != null && (_selectedCategory == EquipmentCategory.consumable || _selectedCategory == EquipmentCategory.cable);
Widget _buildCard({
required String title,
required IconData icon,
required List<Widget> children,
}) {
return Card(
elevation: 2,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade200, width: 1),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.noir,
),
),
],
),
const Divider(height: 24, thickness: 1),
...children,
],
),
),
);
}
@override
Widget build(BuildContext context) {
@@ -152,95 +399,99 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Identifiant (généré ou saisi)
TextFormField(
// Card 1: Informations Générales
_buildCard(
title: 'Informations générales',
icon: Icons.info_outline,
children: [
// ID row with padlock and warning
ValueListenableBuilder<bool>(
valueListenable: _autoGenerateIdNotifier,
builder: (context, autoGenerateId, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextFormField(
controller: _identifierController,
decoration: InputDecoration(
labelText: 'Identifiant *',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.tag),
hintText: isEditing ? null : 'Laissez vide pour générer automatiquement',
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}',
hintText: isEditing ? null : 'Généré automatiquement',
helperText: isEditing ? 'Non modifiable' : 'Identifiant unique du matériel',
),
enabled: !isEditing,
enabled: !autoGenerateId && !isEditing,
validator: (value) {
if (value != null && value.isNotEmpty) {
// Empêcher les ID commençant par BOX_ (réservé aux containers)
if (value == null || value.isEmpty) {
return 'Veuillez entrer un identifiant';
}
if (value.toUpperCase().startsWith('BOX_')) {
return 'Les ID commençant par BOX_ sont réservés aux boites';
}
}
return null;
},
),
const SizedBox(height: 16),
// Case à cocher "Ajouter plusieurs" (uniquement en mode création)
),
if (!isEditing) ...[
const SizedBox(width: 8),
IconButton(
icon: Icon(
autoGenerateId ? Icons.lock : Icons.lock_open,
color: autoGenerateId ? AppColors.rouge : Colors.grey,
),
tooltip: autoGenerateId
? 'Génération automatique'
: 'Saisie manuelle',
onPressed: () {
_autoGenerateIdNotifier.value = !autoGenerateId;
if (_autoGenerateIdNotifier.value) {
_triggerCandidateIdsUpdate();
}
},
),
],
],
),
if (_idConflictMessage != null && !isEditing) ...[
const SizedBox(height: 6),
Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange.shade800, size: 16),
const SizedBox(width: 6),
Expanded(
flex: 2,
child: CheckboxListTile(
title: const Text('Ajouter plusieurs équipements'),
subtitle: const Text('Créer plusieurs équipements numérotés'),
value: _addMultiple,
contentPadding: EdgeInsets.zero,
onChanged: (bool? value) {
setState(() {
_addMultiple = value ?? false;
});
},
child: Text(
_idConflictMessage!,
style: TextStyle(
color: Colors.orange.shade800,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
if (_addMultiple) ...[
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _quantityToAddController,
decoration: const InputDecoration(
labelText: 'Quantité ou range',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.plus_one),
hintText: '5 ou 6-18',
helperText: 'Ex: 5 ou 6-18',
),
keyboardType: TextInputType.text,
validator: (value) {
if (_addMultiple) {
if (value == null || value.isEmpty) return 'Requis';
// Vérifier si c'est un nombre simple ou une range
if (value.contains('-')) {
final parts = value.split('-');
if (parts.length != 2) return 'Format invalide';
final start = int.tryParse(parts[0].trim());
final end = int.tryParse(parts[1].trim());
if (start == null || end == null) return 'Nombres invalides';
if (start >= end) return 'Le début doit être < fin';
if (end - start > 100) return 'Max 100 équipements';
} else {
final num = int.tryParse(value);
if (num == null || num < 1 || num > 100) return '1-100';
}
}
return null;
},
),
),
],
),
],
],
);
},
),
const SizedBox(height: 16),
],
// Sélecteur Marque/Modèle
// Marque & Modèle
BrandModelSelector(
brandController: _brandController,
modelController: _modelController,
@@ -268,6 +519,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
// Catégorie et Statut
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField<EquipmentCategory>(
@@ -283,6 +535,12 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
child: Text(category.label),
);
}).toList(),
validator: (value) {
if (value == null) {
return 'Catégorie obligatoire';
}
return null;
},
onChanged: (value) {
if (value != null) {
setState(() {
@@ -294,7 +552,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
},
),
),
// Afficher le statut uniquement si ce n'est pas un consommable ou câble
if (!_isConsumable) ...[
const SizedBox(width: 16),
Expanded(
@@ -303,7 +560,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
decoration: const InputDecoration(
labelText: 'Statut *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.info),
prefixIcon: Icon(Icons.info_outline),
),
items: EquipmentStatus.values.map((status) {
return DropdownMenuItem(
@@ -331,52 +588,102 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
selectedCategory: _selectedCategory,
filteredSubCategories: _filteredSubCategories,
onChanged: (value) {
setState(() {
// La valeur est déjà dans le controller
});
setState(() {});
},
),
const SizedBox(height: 16),
],
),
const SizedBox(height: 20),
// Prix
if (hasManagePermission) ...[
Row(
// Card 2: Quantité & Stock
if (!isEditing || _isConsumable) ...[
_buildCard(
title: 'Quantité & Stock',
icon: Icons.inventory_2_outlined,
children: [
Expanded(
child: TextFormField(
controller: _purchasePriceController,
if (!isEditing) ...[
TextFormField(
controller: _quantityToAddController,
decoration: const InputDecoration(
labelText: 'Prix d\'achat (€)',
labelText: 'Nombre d\'exemplaires à créer *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.euro),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _rentalPriceController,
decoration: const InputDecoration(
labelText: 'Prix de location (€)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.attach_money),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
),
prefixIcon: Icon(Icons.copy),
helperText: 'Exemples de valeurs acceptées :\n- "5" : crée 5 exemplaires (de #1 à #5)\n- "3-6" : crée 4 exemplaires (de #3 à #6)',
helperMaxLines: 3,
),
keyboardType: TextInputType.text,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9\s-]')),
],
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Veuillez entrer une quantité ou une plage';
}
final parsed = _parseQuantityOrRange(value);
if (parsed == null || parsed.isEmpty) {
return 'Format invalide (ex: "5" ou "3-6")';
}
if (parsed.length > 100) {
return 'La quantité maximale autorisée est de 100 exemplaires';
}
return null;
},
),
if (_candidateIds.isNotEmpty) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Identifiants qui seront créés (${_candidateIds.length}) :',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
if (_isCalculatingIds)
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _candidateIds.map((id) {
return Chip(
label: Text(
id,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
backgroundColor: AppColors.rouge.withValues(alpha: 0.08),
side: BorderSide(color: AppColors.rouge.withValues(alpha: 0.2)),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
);
}).toList(),
),
],
),
),
],
if (_isConsumable) const SizedBox(height: 16),
],
// Quantités pour consommables
if (_isConsumable) ...[
const Divider(),
const Text('Gestion des quantités', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
@@ -385,7 +692,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
decoration: const InputDecoration(
labelText: 'Quantité totale',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.inventory),
prefixIcon: Icon(Icons.format_list_numbered),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
@@ -398,7 +705,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
decoration: const InputDecoration(
labelText: 'Seuil critique',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.warning),
prefixIcon: Icon(Icons.warning_amber),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
@@ -406,40 +713,146 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
),
],
),
const SizedBox(height: 16),
],
],
),
const SizedBox(height: 20),
],
// Dates
const Divider(),
const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_buildDateField(label: 'Date d\'achat', icon: Icons.shopping_cart, value: _purchaseDate, onTap: () => _selectDate(context, 'purchase')),
const SizedBox(height: 16),
_buildDateField(label: 'Dernière maintenance', icon: Icons.build, value: _lastMaintenanceDate, onTap: () => _selectDate(context, 'lastMaintenance')),
const SizedBox(height: 16),
_buildDateField(label: 'Prochaine maintenance', icon: Icons.event, value: _nextMaintenanceDate, onTap: () => _selectDate(context, 'nextMaintenance')),
const SizedBox(height: 16),
// Card 3: Informations Financières
if (hasManagePermission) ...[
_buildCard(
title: 'Informations financières',
icon: Icons.euro_outlined,
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: _purchasePriceController,
decoration: const InputDecoration(
labelText: 'Prix d\'achat (€)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.shopping_bag_outlined),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _rentalPriceController,
decoration: const InputDecoration(
labelText: 'Prix de location (€)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.sell_outlined),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
),
),
],
),
],
),
const SizedBox(height: 20),
],
// Notes
const Divider(),
// Card 4: Caractéristiques physiques (Amélioration)
_buildCard(
title: 'Caractéristiques physiques',
icon: Icons.scale_outlined,
children: [
TextFormField(
controller: _weightController,
decoration: const InputDecoration(
labelText: 'Poids à vide (kg)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.scale),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _lengthController,
decoration: const InputDecoration(
labelText: 'Longueur (cm)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _widthController,
decoration: const InputDecoration(
labelText: 'Largeur (cm)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _heightController,
decoration: const InputDecoration(
labelText: 'Hauteur (cm)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
],
),
],
),
const SizedBox(height: 20),
// Card 5: Dates
_buildCard(
title: 'Dates & Maintenance',
icon: Icons.calendar_today_outlined,
children: [
_buildDateField(label: 'Date d\'achat', icon: Icons.shopping_cart_outlined, value: _purchaseDate, onTap: () => _selectDate(context, 'purchase')),
const SizedBox(height: 16),
_buildDateField(label: 'Dernière maintenance', icon: Icons.build_outlined, value: _lastMaintenanceDate, onTap: () => _selectDate(context, 'lastMaintenance')),
const SizedBox(height: 16),
_buildDateField(label: 'Prochaine maintenance', icon: Icons.event_outlined, value: _nextMaintenanceDate, onTap: () => _selectDate(context, 'nextMaintenance')),
],
),
const SizedBox(height: 20),
// Card 6: Notes
_buildCard(
title: 'Notes & Remarques',
icon: Icons.notes_outlined,
children: [
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes',
labelText: 'Notes complémentaires',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.notes),
prefixIcon: Icon(Icons.edit_note),
),
maxLines: 3,
),
const SizedBox(height: 24),
],
),
const SizedBox(height: 32),
// Boutons
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
child: const Text('Annuler', style: TextStyle(fontSize: 16)),
),
const SizedBox(width: 16),
ElevatedButton(
@@ -447,19 +860,27 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
isEditing ? 'Enregistrer' : 'Créer',
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)
),
child: Text(isEditing ? 'Enregistrer' : 'Créer', style: const TextStyle(color: Colors.white)),
),
],
),
const SizedBox(height: 24),
],
),
),
),
),
),
);
}
Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) {
return InkWell(
onTap: onTap,
@@ -521,6 +942,8 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
Future<void> _saveEquipment() async {
if (!_formKey.currentState!.validate()) return;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
setState(() => _isLoading = true);
try {
@@ -538,56 +961,32 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
}
// Validation marque/modèle obligatoires
String brand = _brandController.text.trim();
String model = _modelController.text.trim();
final brand = _brandController.text.trim();
final model = _modelController.text.trim();
if (brand.isEmpty || model.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
scaffoldMessenger.showSnackBar(
const SnackBar(content: Text('La marque et le modèle sont obligatoires')),
);
return;
}
// Génération d'identifiant si vide
// Génération d'identifiant
List<String> ids = [];
List<int> numbers = [];
if (!isEditing && _identifierController.text.isEmpty) {
// Gérer la range ou nombre simple
final quantityText = _quantityToAddController.text.trim();
if (_addMultiple && quantityText.contains('-')) {
// Range: ex "6-18"
final parts = quantityText.split('-');
final start = int.parse(parts[0].trim());
final end = int.parse(parts[1].trim());
for (int i = start; i <= end; i++) {
numbers.add(i);
}
} else if (_addMultiple) {
// Nombre simple
final nbToAdd = int.tryParse(quantityText) ?? 1;
for (int i = 1; i <= nbToAdd; i++) {
numbers.add(i);
}
}
// Générer les IDs
if (numbers.isEmpty) {
String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null);
String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService);
ids.add(uniqueId);
} else {
for (final num in numbers) {
String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: num);
String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService);
ids.add(uniqueId);
}
if (!isEditing) {
ids = await _calculateCandidateIds();
if (ids.isEmpty) {
scaffoldMessenger.showSnackBar(
const SnackBar(content: Text('Impossible de générer des identifiants valides')),
);
setState(() => _isLoading = false);
return;
}
} else {
ids.add(_identifierController.text.trim());
ids.add(_identifierController.text.trim().toUpperCase());
}
// Création des équipements
// Création/Mise à jour des équipements
for (final id in ids) {
final now = DateTime.now();
final equipment = EquipmentModel(
@@ -595,7 +994,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
name: id, // Utilisation de l'identifiant comme nom
brand: brand,
model: model,
category: _selectedCategory,
category: _selectedCategory!,
subCategory: _subCategoryController.text.trim().isNotEmpty ? _subCategoryController.text.trim() : null,
status: _selectedStatus,
purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null,
@@ -609,6 +1008,10 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
updatedAt: now,
availableQuantity: availableQuantity,
weight: _weightController.text.isNotEmpty ? double.tryParse(_weightController.text) : null,
length: _lengthController.text.isNotEmpty ? double.tryParse(_lengthController.text) : null,
width: _widthController.text.isNotEmpty ? double.tryParse(_widthController.text) : null,
height: _heightController.text.isNotEmpty ? double.tryParse(_heightController.text) : null,
);
if (isEditing) {
await equipmentProvider.updateEquipment(equipment);
@@ -618,11 +1021,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
}
if (mounted) {
Navigator.pop(context, true);
navigator.pop(true);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
scaffoldMessenger.showSnackBar(
SnackBar(content: Text('Erreur lors de l\'enregistrement : $e')),
);
}
@@ -631,3 +1034,9 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
}
}
}
class IdParseResult {
final String baseId;
final int? number;
IdParseResult(this.baseId, this.number);
}
+244 -155
View File
@@ -6,6 +6,7 @@ import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/views/equipment_form_page.dart';
@@ -16,9 +17,11 @@ import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/equipment_delete_utils.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
import 'package:em2rp/utils/debouncer.dart';
class EquipmentManagementPage extends StatefulWidget {
const EquipmentManagementPage({super.key});
@@ -28,11 +31,11 @@ class EquipmentManagementPage extends StatefulWidget {
_EquipmentManagementPageState();
}
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final _searchDebouncer = Debouncer();
EquipmentCategory? _selectedCategory;
List<EquipmentModel>? _cachedEquipment;
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
@@ -66,7 +69,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (_scrollController.hasClients &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
@@ -76,7 +78,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_isLoadingMore = false;
}).catchError((error) {
_isLoadingMore = false;
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
DebugLog.error(
'[EquipmentManagementPage] Error loading next page', error);
});
}
}
@@ -87,6 +90,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchController.dispose();
_searchDebouncer.dispose();
// Désactiver le mode pagination en quittant
context.read<EquipmentProvider>().disablePagination();
super.dispose();
@@ -140,7 +144,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
],
],
)
: CustomAppBar(
: const CustomAppBar(
title: 'Gestion du matériel',
),
drawer: const MainDrawer(currentPage: '/equipment_management'),
@@ -169,9 +173,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
@@ -342,9 +344,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
@@ -456,11 +456,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
Widget _buildEquipmentList() {
return Consumer<EquipmentProvider>(
builder: (context, provider, child) {
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
DebugLog.info(
'[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
// Afficher l'indicateur de chargement initial uniquement
if (provider.isLoading && provider.equipment.isEmpty) {
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
DebugLog.info(
'[EquipmentManagementPage] Showing initial loading indicator');
return const Center(child: CircularProgressIndicator());
}
@@ -490,7 +492,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
);
}
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
DebugLog.info(
'[EquipmentManagementPage] Building list with ${equipments.length} items');
// Calculer le nombre total d'items (équipements + indicateur de chargement)
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
@@ -498,10 +501,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
return ListView.builder(
controller: _scrollController,
itemCount: itemCount,
// ✅ Ajouter une estimation de la hauteur pour améliorer le scroll
// Note : À ajuster selon la hauteur réelle de vos cartes
// itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur
// ✅ Augmenter le cache pour un scroll plus fluide
// ✅ Augmenter le cache pour un scroll plus fluide (prototypeItem retiré car les hauteurs dynamiques varient selon le type d'équipement)
cacheExtent: 500, // Précharger 500px en plus
itemBuilder: (context, index) {
// Dernier élément = indicateur de chargement
@@ -528,41 +528,87 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
return RepaintBoundary(
key: ValueKey(equipment.id),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
margin: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isSelectionMode && isSelected
? AppColors.rouge.withValues(alpha: 0.1)
: null,
child: ListTile(
leading: isSelectionMode
? Checkbox(
? AppColors.rouge
: Colors.grey.shade200,
width: isSelectionMode && isSelected ? 2 : 1,
),
),
color: isSelectionMode && isSelected
? AppColors.rouge.withValues(alpha: 0.05)
: Colors.white,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: isSelectionMode
? () => toggleItemSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
onLongPress: isSelectionMode
? null
: () {
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
if (localUserProvider.hasPermission('manage_equipment')) {
_editEquipment(equipment);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Vous n'avez pas la permission de modifier cet équipement"),
),
);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 1. leading selection or icon
if (isSelectionMode)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Checkbox(
value: isSelected,
onChanged: (value) => toggleItemSelection(equipment.id),
activeColor: AppColors.rouge,
),
)
: CircleAvatar(
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
else
Padding(
padding: const EdgeInsets.only(right: 16),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: equipment.category.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: equipment.category.getIcon(
size: 20,
size: 22,
color: equipment.category.color,
),
),
title: Row(
children: [
),
),
// 2. Info details (ID, Brand/Model, Subcategory)
Expanded(
child: Text(
equipment.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// Afficher le badge de statut calculé dynamiquement
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
EquipmentStatusBadge(equipment: equipment),
],
),
subtitle: Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
equipment.id,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: AppColors.noir,
),
),
const SizedBox(height: 4),
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
@@ -570,31 +616,42 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
.isNotEmpty
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
: 'Marque/Modèle non défini',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
style: TextStyle(
color: Colors.grey[700],
fontSize: 13,
),
// Afficher la sous-catégorie si elle existe
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
),
// Sous-catégorie
if (equipment.subCategory != null &&
equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'📁 ${equipment.subCategory}',
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
fontSize: 11,
fontStyle: FontStyle.italic,
),
),
],
// Afficher la quantité disponible pour les consommables/câbles
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) ...[
const SizedBox(height: 4),
_buildQuantityDisplay(equipment),
],
],
),
trailing: isSelectionMode
? null
: Row(
),
const SizedBox(width: 16),
// 3. Status Badge OR Quantity Display (centered vertically in the row!)
Padding(
padding: const EdgeInsets.only(right: 16),
child: (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable)
? _buildQuantityDisplay(equipment)
: EquipmentStatusBadge(equipment: equipment),
),
// 4. Trailing Action Buttons
if (!isSelectionMode)
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton Restock (uniquement pour consommables/câbles avec permission)
@@ -604,45 +661,59 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.add_shopping_cart,
color: AppColors.rouge),
color: AppColors.rouge, size: 20),
tooltip: 'Restock',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _showRestockDialog(equipment),
),
),
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable)
const SizedBox(width: 8),
// Bouton QR Code
IconButton(
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
icon: const Icon(Icons.qr_code, color: AppColors.rouge, size: 20),
tooltip: 'QR Code',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(equipment),
builder: (context) =>
QRCodeDialog.forEquipment(equipment),
),
),
const SizedBox(width: 8),
// Bouton Modifier (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge),
icon: const Icon(Icons.edit, color: AppColors.rouge, size: 20),
tooltip: 'Modifier',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _editEquipment(equipment),
),
),
const SizedBox(width: 8),
// Bouton Supprimer (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
icon: const Icon(Icons.delete, color: Colors.red, size: 20),
tooltip: 'Supprimer',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _deleteEquipment(equipment),
),
),
],
),
onTap: isSelectionMode
? () => toggleItemSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
],
),
),
),
),
)
);
}
@@ -652,60 +723,26 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
final criticalThreshold = equipment.criticalThreshold ?? 0;
final isCritical =
criticalThreshold > 0 && availableQty <= criticalThreshold;
final color = isCritical ? Colors.red : Colors.grey.shade600;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isCritical
? Colors.red.withOpacity(0.15)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isCritical ? Colors.red : Colors.grey.shade400,
width: isCritical ? 2 : 1,
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isCritical ? Icons.warning : Icons.inventory,
size: 16,
color: isCritical ? Colors.red : Colors.grey[700],
),
const SizedBox(width: 6),
Text(
'Disponible: $availableQty / $totalQty',
child: Text(
'$availableQty / $totalQty',
style: TextStyle(
fontSize: 13,
fontWeight: isCritical ? FontWeight.bold : FontWeight.normal,
color: isCritical ? Colors.red : Colors.grey[700],
),
),
if (isCritical) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'CRITIQUE',
style: TextStyle(
fontSize: 10,
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
color: color,
),
),
),
],
],
),
);
}
// Actions
void _createNewEquipment() {
Navigator.push(
@@ -726,39 +763,64 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
}
void _deleteEquipment(EquipmentModel equipment) {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: equipment.id,
name: equipment.name,
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
await context
.read<EquipmentProvider>()
.deleteEquipment(equipment.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
final deleted =
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: equipmentLabel,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
equipment.id,
forceDelete: forceDelete,
);
},
);
if (!deleted) {
return;
}
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text('Équipement supprimé avec succès')),
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
@@ -768,46 +830,78 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
void _deleteSelectedEquipment() async {
if (!hasSelection) return;
final pageContext = context;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?',
EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage(
selectedCount,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
final provider = context.read<EquipmentProvider>();
final equipmentById = {
for (final equipment
in provider.equipment)
equipment.id: equipment,
};
var deletedCount = 0;
for (final id in selectedIds) {
await provider.deleteEquipment(id);
final label = EquipmentDeleteUtils.resolveEquipmentLabel(
id: id,
name: equipmentById[id]?.name,
);
final deleted = await EquipmentDeleteUtils
.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: label,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
id,
forceDelete: forceDelete,
);
},
);
if (deleted) {
deletedCount++;
}
}
disableSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
'$selectedCount équipement(s) supprimé(s) avec succès'),
EquipmentDeleteUtils.buildBulkDeleteSuccessMessage(
deletedCount,
),
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
@@ -829,17 +923,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
try {
// Récupérer les équipements sélectionnés
final provider = context.read<EquipmentProvider>();
final List<EquipmentModel> selectedEquipment = [];
// On doit récupérer les équipements depuis le stream
await for (final equipmentList in provider.equipmentStream.take(1)) {
for (final equipment in equipmentList) {
if (isItemSelected(equipment.id)) {
selectedEquipment.add(equipment);
}
}
break;
}
final List<EquipmentModel> selectedEquipment =
await provider.getEquipmentsByIds(selectedIds.toList());
// Fermer l'indicateur de chargement
if (mounted) {
@@ -853,7 +938,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) {
showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
builder: (context) =>
QRCodeDialog.forEquipment(selectedEquipment.first),
);
}
} else {
@@ -1046,7 +1132,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
updatedAt: DateTime.now(),
);
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
await context
.read<EquipmentProvider>()
.updateEquipment(updatedEquipment);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1184,7 +1272,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
content: Text(
'Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
backgroundColor: Colors.orange,
),
);
+147 -39
View File
@@ -77,7 +77,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
return;
}
final success = await _controller.submitForm(context, existingEvent: widget.event);
final success =
await _controller.submitForm(context, existingEvent: widget.event);
if (success && mounted) {
Navigator.of(context).pop();
}
@@ -141,6 +142,54 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
}
}
Widget _buildCard({
required String title,
required IconData icon,
required List<Widget> children,
}) {
return Card(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200, width: 1),
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFD32F2F).withOpacity(0.1), // AppColors.rouge
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: const Color(0xFFD32F2F), size: 22),
),
const SizedBox(width: 16),
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
],
),
const SizedBox(height: 24),
...children,
],
),
),
);
}
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 600;
@@ -157,25 +206,23 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
}
},
child: Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
title: Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'),
title: Text(
isEditMode ? 'Modifier un événement' : 'Créer un événement'),
elevation: 0,
),
body: Center(
child: SingleChildScrollView(
child: (isMobile
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: _buildFormContent(isMobile),
)
: Card(
elevation: 6,
margin: const EdgeInsets.all(24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
body: SingleChildScrollView(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
padding: EdgeInsets.symmetric(
horizontal: isMobile ? 16 : 32,
vertical: 32),
child: _buildFormContent(isMobile),
),
)),
),
),
),
),
@@ -186,20 +233,15 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
Widget _buildFormContent(bool isMobile) {
return Consumer<EventFormController>(
builder: (context, controller, child) {
// Trouver le nom du type d'événement pour le passer au sélecteur d'options
final selectedEventTypeIndex = controller.selectedEventTypeId != null
? controller.eventTypes.indexWhere((et) => et.id == controller.selectedEventTypeId)
: -1;
final selectedEventType = selectedEventTypeIndex != -1
? controller.eventTypes[selectedEventTypeIndex]
: null;
final selectedEventTypeName = selectedEventType?.name;
return Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCard(
title: 'Informations générales',
icon: Icons.event_note,
children: [
EventBasicInfoSection(
nameController: controller.nameController,
@@ -209,26 +251,31 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
selectedEventTypeId: controller.selectedEventTypeId,
startDateTime: controller.startDateTime,
endDateTime: controller.endDateTime,
onEventTypeChanged: (typeId) => controller.onEventTypeChanged(typeId, context),
selectedOptions: controller.selectedOptions,
onEventTypeChanged: (typeId) =>
controller.onEventTypeChanged(typeId, context),
onStartDateTimeChanged: controller.setStartDateTime,
onEndDateTimeChanged: controller.setEndDateTime,
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
onAnyFieldChanged: () {},
),
const SizedBox(height: 16),
OptionSelectorWidget(
eventType: controller.selectedEventTypeId, // Utilise l'ID au lieu du nom
eventType: controller.selectedEventTypeId,
selectedOptions: controller.selectedOptions,
onChanged: controller.setSelectedOptions,
onRemove: (optionId) {
final newOptions = List<Map<String, dynamic>>.from(controller.selectedOptions);
final newOptions = List<Map<String, dynamic>>.from(
controller.selectedOptions);
newOptions.removeWhere((o) => o['id'] == optionId);
controller.setSelectedOptions(newOptions);
},
eventTypeRequired: controller.selectedEventTypeId == null,
isMobile: isMobile,
),
const SizedBox(height: 16),
// Section Matériel Assigné
],
),
const SizedBox(height: 24),
// Section Matériel Assigné (gère sa propre carte pour inclure les boutons d'action dans le header)
EventAssignedEquipmentSection(
assignedEquipment: controller.assignedEquipment,
assignedContainers: controller.assignedContainers,
@@ -236,8 +283,13 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
endDate: controller.endDateTime,
onChanged: controller.setAssignedEquipment,
eventId: widget.event?.id,
eventTypeId: controller.selectedEventTypeId,
),
const SizedBox(height: 16),
const SizedBox(height: 24),
_buildCard(
title: 'Détails & Logistique',
icon: Icons.location_on_outlined,
children: [
EventDetailsSection(
descriptionController: controller.descriptionController,
installationController: controller.installationController,
@@ -248,7 +300,25 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
contactPhoneController: controller.contactPhoneController,
isMobile: isMobile,
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
onTravelCostSelected: (price) {
controller.addTravelCostOption(price);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Frais de déplacement ajoutés : ${price.toStringAsFixed(2)}'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 3),
),
);
},
),
],
),
const SizedBox(height: 24),
_buildCard(
title: 'Personnel & Documents',
icon: Icons.group_outlined,
children: [
EventStaffAndDocumentsSection(
allUsers: controller.allUsers,
selectedUserIds: controller.selectedUserIds,
@@ -262,24 +332,57 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
isMobile: isMobile,
onPickAndUploadFiles: controller.pickAndUploadFiles,
),
],
),
if (controller.error != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
padding: const EdgeInsets.only(top: 24.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200)
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
controller.error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
),
if (controller.success != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
padding: const EdgeInsets.only(top: 24.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.shade200)
),
child: Row(
children: [
Icon(Icons.check_circle_outline, color: Colors.green.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
controller.success!,
style: const TextStyle(color: Colors.green),
textAlign: TextAlign.center,
style: TextStyle(color: Colors.green.shade700),
),
),
],
),
),
),
const SizedBox(height: 24),
EventFormActions(
isLoading: controller.isLoading,
isEditMode: isEditMode,
@@ -290,10 +393,15 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
}
},
onSubmit: _submit,
onSetConfirmed: !isEditMode ? () {
onSetConfirmed: !isEditMode ? () async {
final success = await controller.submitAsConfirmed(context);
if (success && context.mounted) {
Navigator.of(context).pop();
}
} : null,
onDelete: isEditMode ? _deleteEvent : null, // Ajout du callback de suppression
onDelete: isEditMode ? _deleteEvent : null,
),
const SizedBox(height: 48), // Padding bottom for scrolling
],
),
);
+109 -55
View File
@@ -13,6 +13,7 @@ import 'package:em2rp/services/qr_code_processing_service.dart';
import 'package:em2rp/services/audio_feedback_service.dart';
import 'package:em2rp/services/smart_text_to_speech_service.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/event_preparation_service.dart';
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
@@ -87,22 +88,22 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
// Logique stricte : on avance étape par étape
// 1. Préparation dépôt
if (prep != PreparationStatus.completed) {
if (prep != PreparationStatus.completed && prep != PreparationStatus.completedWithMissing) {
return PreparationStep.preparation;
}
// 2. Chargement aller (après préparation complète)
if (loading != LoadingStatus.completed) {
if (loading != LoadingStatus.completed && loading != LoadingStatus.completedWithMissing) {
return PreparationStep.loadingOutbound;
}
// 3. Chargement retour (après chargement aller complet)
if (unloading != UnloadingStatus.completed) {
if (unloading != UnloadingStatus.completed && unloading != UnloadingStatus.completedWithMissing) {
return PreparationStep.unloadingReturn;
}
// 4. Retour dépôt (après déchargement complet)
if (returnStatus != ReturnStatus.completed) {
if (returnStatus != ReturnStatus.completed && returnStatus != ReturnStatus.completedWithMissing) {
return PreparationStep.return_;
}
@@ -131,7 +132,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isCurrentStepCompleted()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
const SnackBar(
content: Text('Cette étape est déjà terminée'),
backgroundColor: Colors.orange,
),
@@ -140,6 +141,17 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
return;
}
if (!_isPreviousStepCompleted()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('L\'étape précédente n\'est pas terminée. Impossible d\'accéder à cette étape.'),
backgroundColor: Colors.red,
),
);
Navigator.of(context).pop();
return;
}
// Charger les équipements après le premier frame pour éviter setState pendant build
_loadEquipmentAndContainers();
});
@@ -149,13 +161,34 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
bool _isCurrentStepCompleted() {
switch (_currentStep) {
case PreparationStep.preparation:
return (_currentEvent.preparationStatus ?? PreparationStatus.notStarted) == PreparationStatus.completed;
final status = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
return status == PreparationStatus.completed || status == PreparationStatus.completedWithMissing;
case PreparationStep.loadingOutbound:
return (_currentEvent.loadingStatus ?? LoadingStatus.notStarted) == LoadingStatus.completed;
final status = _currentEvent.loadingStatus ?? LoadingStatus.notStarted;
return status == LoadingStatus.completed || status == LoadingStatus.completedWithMissing;
case PreparationStep.unloadingReturn:
return (_currentEvent.unloadingStatus ?? UnloadingStatus.notStarted) == UnloadingStatus.completed;
final status = _currentEvent.unloadingStatus ?? UnloadingStatus.notStarted;
return status == UnloadingStatus.completed || status == UnloadingStatus.completedWithMissing;
case PreparationStep.return_:
return (_currentEvent.returnStatus ?? ReturnStatus.notStarted) == ReturnStatus.completed;
final status = _currentEvent.returnStatus ?? ReturnStatus.notStarted;
return status == ReturnStatus.completed || status == ReturnStatus.completedWithMissing;
}
}
/// Vérifie si l'étape précédente est bien complétée
bool _isPreviousStepCompleted() {
switch (_currentStep) {
case PreparationStep.preparation:
return true; // Première étape, toujours OK
case PreparationStep.loadingOutbound:
final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
return prep == PreparationStatus.completed || prep == PreparationStatus.completedWithMissing;
case PreparationStep.unloadingReturn:
final loading = _currentEvent.loadingStatus ?? LoadingStatus.notStarted;
return loading == LoadingStatus.completed || loading == LoadingStatus.completedWithMissing;
case PreparationStep.return_:
final unloading = _currentEvent.unloadingStatus ?? UnloadingStatus.notStarted;
return unloading == UnloadingStatus.completed || unloading == UnloadingStatus.completedWithMissing;
}
}
@@ -238,10 +271,15 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
break;
}
_quantitiesAtPreparation[eq.equipmentId] = eq.quantityAtPreparation ?? eq.quantity;
_quantitiesAtLoading[eq.equipmentId] = eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
_quantitiesAtUnloading[eq.equipmentId] = eq.quantityAtUnloading ?? eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
_quantitiesAtReturn[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantityAtUnloading ?? eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
if ((_currentStep == PreparationStep.return_ ||
_currentStep == PreparationStep.unloadingReturn) &&
(equipmentItem?.hasQuantity ?? false)) {
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity;
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantityAtUnloading ?? eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
}
}
@@ -417,9 +455,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
returnStatus: updateData['returnStatus'],
);
// Mettre à jour les statuts des équipements si nécessaire
if (_currentStep == PreparationStep.preparation ||
(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) {
// Mettre à jour les statuts des équipements si nécessaire (uniquement pour la préparation, le retour étant géré par le trigger Firestore Cloud Function)
if (_currentStep == PreparationStep.preparation) {
await _updateEquipmentStatuses(updatedEquipment);
}
@@ -504,6 +541,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
}
Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async {
final List<String> failedUpdates = [];
for (var eq in equipment) {
try {
final equipmentData = _equipmentCache[eq.equipmentId];
@@ -512,7 +551,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
// Déterminer le nouveau statut
EquipmentStatus newStatus;
if (eq.isReturned) {
newStatus = EquipmentStatus.available;
// Note : Le retour est géré par le trigger Firestore Cloud Function en tâche de fond.
// On évite les conflits d'écritures client/serveur et les double-restaurations de stock.
continue;
} else if (eq.isPrepared || eq.isLoaded) {
newStatus = EquipmentStatus.inUse;
} else {
@@ -526,19 +567,22 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
status: equipmentStatusToString(newStatus),
);
}
// Gérer les stocks pour les consommables
if (equipmentData.hasQuantity && eq.isReturned && eq.quantityAtReturn != null) {
final currentAvailable = equipmentData.availableQuantity ?? 0;
await _dataService.updateEquipmentStatusOnly(
equipmentId: eq.equipmentId,
availableQuantity: currentAvailable + eq.quantityAtReturn!,
);
}
} catch (e) {
// Erreur silencieuse pour ne pas bloquer le processus
DebugLog.error('[EventPreparationPage] Échec de la mise à jour du statut pour l\'équipement ${eq.equipmentId}', e);
failedUpdates.add(eq.equipmentId);
}
}
if (failedUpdates.isNotEmpty && mounted) {
final names = failedUpdates.map((id) => _equipmentCache[id]?.name ?? id).join(', ');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Attention : Échec de mise à jour du statut en base pour : $names. Le matériel a tout de même été validé pour l\'événement.'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 6),
),
);
}
}
String _getSuccessMessage() {
@@ -894,26 +938,28 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
_quantitiesAtReturn.addAll(quantities);
break;
}
}
/// Obtenir la quantité requise selon l'étape (nouvelle logique)
int _getTargetQuantity(EventEquipment eventEquipment) {
// Mettre à jour `_currentEvent.assignedEquipment` pour que l'UI se reconstruise avec les bonnes valeurs
final updatedList = _currentEvent.assignedEquipment.map((eq) {
final qty = quantities[eq.equipmentId];
if (qty != null) {
switch (_currentStep) {
case PreparationStep.preparation:
return eventEquipment.quantity; // Quantité initiale
return eq.copyWith(quantityAtPreparation: qty);
case PreparationStep.loadingOutbound:
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
return eq.copyWith(quantityAtLoading: qty);
case PreparationStep.unloadingReturn:
return eventEquipment.quantityAtLoading ??
eventEquipment.quantityAtPreparation ??
eventEquipment.quantity;
return eq.copyWith(quantityAtUnloading: qty);
case PreparationStep.return_:
return eventEquipment.quantityAtUnloading ??
eventEquipment.quantityAtLoading ??
eventEquipment.quantityAtPreparation ??
eventEquipment.quantity;
return eq.copyWith(quantityAtReturn: qty);
}
}
return eq;
}).toList();
_currentEvent = _currentEvent.copyWith(assignedEquipment: updatedList);
}
/// Afficher un message de succès
void _showSuccessFeedback(String message) {
@@ -1019,20 +1065,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Mettre à jour la quantité d'un équipement à l'étape actuelle
void _updateEquipmentQuantity(String equipmentId, int newQuantity) {
setState(() {
switch (_currentStep) {
case PreparationStep.preparation:
_quantitiesAtPreparation[equipmentId] = newQuantity;
break;
case PreparationStep.loadingOutbound:
_quantitiesAtLoading[equipmentId] = newQuantity;
break;
case PreparationStep.unloadingReturn:
_quantitiesAtUnloading[equipmentId] = newQuantity;
break;
case PreparationStep.return_:
_quantitiesAtReturn[equipmentId] = newQuantity;
break;
}
_updateQuantitiesMap({equipmentId: newQuantity});
});
}
@@ -1097,6 +1130,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Détermine le statut d'un équipement selon l'étape actuelle
String _determineEquipmentStatus(EventEquipment eq) {
if (_isNotTakenToEventAtReturn(eq)) {
return 'NOT_TAKEN';
}
// Vérifier d'abord si l'équipement est perdu (LOST)
if (_shouldMarkAsLost(eq)) {
return 'LOST';
@@ -1118,14 +1155,31 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Vérifie si un équipement doit être marqué comme LOST
bool _shouldMarkAsLost(EventEquipment eq) {
// Seulement aux étapes de retour
if (_currentStep != PreparationStep.return_ &&
!(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) {
return EventPreparationService.shouldMarkEquipmentAsLost(
isReturnValidationStep: _isReturnValidationStep,
isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool _isNotTakenToEventAtReturn(EventEquipment eq) {
if (!_isReturnValidationStep) {
return false;
}
// Si manquant maintenant mais PAS manquant à la préparation = LOST
return eq.isMissingAtReturn && !eq.isMissingAtPreparation;
return EventPreparationService.isEquipmentNotTakenToEvent(
isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool get _isReturnValidationStep {
return _currentStep == PreparationStep.return_ ||
(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously);
}
/// Vérifie si un équipement est manquant à l'étape actuelle
@@ -75,14 +75,15 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
title: 'Gestion des maintenances',
),
drawer: const MainDrawer(currentPage: '/maintenance_management'),
body: Consumer<MaintenanceProvider>(
builder: (context, maintenanceProvider, _) {
if (maintenanceProvider.isLoading) {
body: Selector<MaintenanceProvider, ({bool isLoading, List<MaintenanceModel> maintenances})>(
selector: (context, provider) => (isLoading: provider.isLoading, maintenances: provider.maintenances),
builder: (context, data, _) {
if (data.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final filteredMaintenances = _getFilteredMaintenances(
maintenanceProvider.maintenances,
data.maintenances,
);
return Column(
@@ -91,7 +92,7 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
_buildFilterChips(),
// Statistiques
_buildStatsCards(maintenanceProvider),
_buildStatsCards(data.maintenances),
// Liste des maintenances
Expanded(
@@ -148,10 +149,10 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
);
}
Widget _buildStatsCards(MaintenanceProvider provider) {
final upcoming = provider.maintenances.where((m) => !m.isCompleted && !m.isOverdue).length;
final overdue = provider.maintenances.where((m) => m.isOverdue).length;
final completed = provider.maintenances.where((m) => m.isCompleted).length;
Widget _buildStatsCards(List<MaintenanceModel> maintenances) {
final upcoming = maintenances.where((m) => !m.isCompleted && !m.isOverdue).length;
final overdue = maintenances.where((m) => m.isOverdue).length;
final completed = maintenances.where((m) => m.isCompleted).length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
+5 -5
View File
@@ -2,6 +2,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/views/widgets/inputs/styled_text_field.dart';
import 'package:em2rp/views/widgets/image/profile_picture_selector.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
@@ -17,10 +18,9 @@ class MyAccountPage extends StatelessWidget {
title: 'Mon compte',
),
drawer: const MainDrawer(currentPage: '/my_account'),
body: Consumer<LocalUserProvider>(
builder: (context, userProvider, child) {
final user = userProvider.currentUser;
body: Selector<LocalUserProvider, UserModel?>(
selector: (context, provider) => provider.currentUser,
builder: (context, user, child) {
if (user == null) {
return const Center(child: CircularProgressIndicator());
}
@@ -73,7 +73,7 @@ class MyAccountPage extends StatelessWidget {
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
userProvider.updateUserData(
context.read<LocalUserProvider>().updateUserData(
firstName: firstNameController.text,
lastName: lastNameController.text,
phoneNumber: phoneController.text,
+6 -5
View File
@@ -51,12 +51,13 @@ class _UserManagementPageState extends State<UserManagementPage> {
title: 'Gestion des utilisateurs',
),
drawer: const MainDrawer(currentPage: '/account_management'),
body: Consumer<UsersProvider>(
builder: (context, usersProvider, child) {
if (usersProvider.isLoading) {
body: Selector<UsersProvider, ({bool isLoading, List<UserModel> users})>(
selector: (context, provider) => (isLoading: provider.isLoading, users: provider.users),
builder: (context, data, child) {
if (data.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final users = usersProvider.users;
final users = data.users;
if (users.isEmpty) {
return const Center(child: Text("Aucun utilisateur trouvé"));
}
@@ -92,7 +93,7 @@ class _UserManagementPageState extends State<UserManagementPage> {
context: context,
builder: (_) => EditUserDialog(user: user)),
onResetPassword: () => _resetPassword(context, user),
onDelete: () => _confirmDeleteUser(context, usersProvider, user),
onDelete: () => _confirmDeleteUser(context, context.read<UsersProvider>(), user),
);
},
),
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:url_launcher/url_launcher.dart';
class EventDetailsDescription extends StatelessWidget {
final EventModel event;
@@ -45,6 +47,13 @@ class EventDetailsDescription extends StatelessWidget {
),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
event.address,
style: Theme.of(context).textTheme.bodyLarge,
@@ -57,6 +66,30 @@ class EventDetailsDescription extends StatelessWidget {
),
],
],
),
),
IconButton(
icon: const Icon(Icons.map, color: AppColors.rouge),
tooltip: 'Ouvrir dans Maps',
onPressed: () async {
final query = event.address;
if (query.isEmpty) return;
final url = Uri.parse('https://www.google.com/maps/search/?api=1&query=${Uri.encodeComponent(query)}');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible d\'ouvrir l\'application de carte')),
);
}
}
},
),
],
),
],
);
}
@@ -68,12 +101,22 @@ class EventDetailsDescription extends StatelessWidget {
_buildInfoRow(context, Icons.people, 'Jauge', '${event.jauge} personnes'),
const SizedBox(height: 8),
],
if (event.contactEmail != null) ...[
_buildInfoRow(context, Icons.email, 'Email', event.contactEmail!),
if (event.contactEmail != null && event.contactEmail!.isNotEmpty) ...[
_buildInfoRow(context, Icons.email, 'Email', event.contactEmail!, onTap: () async {
final url = Uri.parse('mailto:${event.contactEmail!}');
if (await canLaunchUrl(url)) {
await launchUrl(url);
}
}),
const SizedBox(height: 8),
],
if (event.contactPhone != null) ...[
_buildInfoRow(context, Icons.phone, 'Téléphone', event.contactPhone!),
if (event.contactPhone != null && event.contactPhone!.isNotEmpty) ...[
_buildInfoRow(context, Icons.phone, 'Téléphone', event.contactPhone!, onTap: () async {
final url = Uri.parse('tel:${event.contactPhone!}');
if (await canLaunchUrl(url)) {
await launchUrl(url);
}
}),
],
],
);
@@ -86,15 +129,25 @@ class EventDetailsDescription extends StatelessWidget {
children: [
if (event.jauge != null)
_buildInfoChip(context, Icons.people, 'Jauge', '${event.jauge} personnes'),
if (event.contactEmail != null)
_buildInfoChip(context, Icons.email, 'Email', event.contactEmail!),
if (event.contactPhone != null)
_buildInfoChip(context, Icons.phone, 'Téléphone', event.contactPhone!),
if (event.contactEmail != null && event.contactEmail!.isNotEmpty)
_buildInfoChip(context, Icons.email, 'Email', event.contactEmail!, onTap: () async {
final url = Uri.parse('mailto:${event.contactEmail!}');
if (await canLaunchUrl(url)) {
await launchUrl(url);
}
}),
if (event.contactPhone != null && event.contactPhone!.isNotEmpty)
_buildInfoChip(context, Icons.phone, 'Téléphone', event.contactPhone!, onTap: () async {
final url = Uri.parse('tel:${event.contactPhone!}');
if (await canLaunchUrl(url)) {
await launchUrl(url);
}
}),
],
);
}
Widget _buildInfoRow(BuildContext context, IconData icon, String label, String value) {
Widget _buildInfoRow(BuildContext context, IconData icon, String label, String value, {VoidCallback? onTap}) {
return Row(
children: [
Icon(icon, size: 20, color: Theme.of(context).primaryColor),
@@ -109,11 +162,23 @@ class EventDetailsDescription extends StatelessWidget {
color: Colors.grey[600],
),
),
SelectableText(
onTap == null
? SelectableText(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
)
: SelectableText.rich(
TextSpan(
text: value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: AppColors.rouge,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()..onTap = onTap,
),
),
],
),
@@ -122,7 +187,7 @@ class EventDetailsDescription extends StatelessWidget {
);
}
Widget _buildInfoChip(BuildContext context, IconData icon, String label, String value) {
Widget _buildInfoChip(BuildContext context, IconData icon, String label, String value, {VoidCallback? onTap}) {
final primaryColor = Theme.of(context).primaryColor;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -146,11 +211,23 @@ class EventDetailsDescription extends StatelessWidget {
color: Colors.grey[600],
),
),
SelectableText(
onTap == null
? SelectableText(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
)
: SelectableText.rich(
TextSpan(
text: value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: AppColors.rouge,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()..onTap = onTap,
),
),
],
),
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/event_preparation_page.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/colors.dart';
/// Boutons de préparation et retour d'événement
@@ -52,16 +54,16 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
IconData buttonIcon;
bool isCompleted = false;
if (prep != PreparationStatus.completed) {
if (prep != PreparationStatus.completed && prep != PreparationStatus.completedWithMissing) {
buttonText = 'Préparation dépôt';
buttonIcon = Icons.inventory_2;
} else if (loading != LoadingStatus.completed) {
} else if (loading != LoadingStatus.completed && loading != LoadingStatus.completedWithMissing) {
buttonText = 'Chargement aller';
buttonIcon = Icons.local_shipping;
} else if (unloading != UnloadingStatus.completed) {
} else if (unloading != UnloadingStatus.completed && unloading != UnloadingStatus.completedWithMissing) {
buttonText = 'Chargement retour';
buttonIcon = Icons.unarchive;
} else if (returnStatus != ReturnStatus.completed) {
} else if (returnStatus != ReturnStatus.completed && returnStatus != ReturnStatus.completedWithMissing) {
buttonText = 'Retour dépôt';
buttonIcon = Icons.assignment_return;
} else {
@@ -131,9 +133,9 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green, width: 1),
),
child: Row(
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
children: [
Icon(Icons.check_circle, color: Colors.green, size: 20),
SizedBox(width: 8),
Text(
@@ -147,9 +149,177 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
),
),
),
// Bouton de retour en arrière si au moins une étape est commencée/validée
if (prep != PreparationStatus.notStarted) ...[
const SizedBox(height: 8),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: OutlinedButton.icon(
onPressed: () => _showRollbackDialog(context, event),
icon: const Icon(Icons.undo, size: 18),
label: const Text('Revenir à une étape précédente'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orange[800],
side: BorderSide(color: Colors.orange[300]!),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
),
),
),
],
],
),
);
}
Future<void> _showRollbackDialog(BuildContext context, EventModel event) async {
final prep = event.preparationStatus ?? PreparationStatus.notStarted;
final loading = event.loadingStatus ?? LoadingStatus.notStarted;
final unloading = event.unloadingStatus ?? UnloadingStatus.notStarted;
final returnStatus = event.returnStatus ?? ReturnStatus.notStarted;
final List<Map<String, String>> steps = [];
if (prep == PreparationStatus.completed || prep == PreparationStatus.completedWithMissing) {
steps.add({'key': 'PREPARATION', 'label': 'Préparation dépôt'});
}
if (loading == LoadingStatus.completed || loading == LoadingStatus.completedWithMissing) {
steps.add({'key': 'LOADING', 'label': 'Chargement aller'});
}
if (unloading == UnloadingStatus.completed || unloading == UnloadingStatus.completedWithMissing) {
steps.add({'key': 'UNLOADING', 'label': 'Chargement retour'});
}
if (returnStatus == ReturnStatus.completed || returnStatus == ReturnStatus.completedWithMissing) {
steps.add({'key': 'RETURN', 'label': 'Retour dépôt'});
}
if (steps.isEmpty) return;
final String? selectedStep = await showDialog<String>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.undo, color: Colors.orange),
SizedBox(width: 8),
Text('Revenir en arrière'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Sélectionnez l\'étape à laquelle vous souhaitez revenir :'),
const SizedBox(height: 12),
...steps.map((step) {
return ListTile(
leading: const Icon(Icons.arrow_back, color: AppColors.rouge),
title: Text(step['label']!),
onTap: () => Navigator.of(context).pop(step['key']),
);
}),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
],
);
},
);
if (selectedStep != null && context.mounted) {
final confirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Confirmer le retour en arrière'),
content: const Text(
'Êtes-vous sûr de vouloir revenir à cette étape ?\n\n'
'Toutes les validations des étapes ultérieures seront effacées, '
'et si le retour était terminé, les stocks restaurés seront annulés.'
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
),
child: const Text('Confirmer'),
),
],
);
},
);
if (confirm == true && context.mounted) {
// Utiliser le rootNavigator pour s'assurer qu'on gère la bonne pile de navigation
final navigator = Navigator.of(context, rootNavigator: true);
showDialog(
context: context,
barrierDismissible: false,
useRootNavigator: true,
builder: (BuildContext dialogContext) {
return const PopScope(
canPop: false,
child: Center(child: CircularProgressIndicator()),
);
},
);
try {
final apiService = FirebaseFunctionsApiService();
await apiService.call('rollbackEventStep', {
'eventId': event.id,
'targetStep': selectedStep,
});
// Attendre un tout petit peu pour s'assurer que le showDialog a eu le temps
// de faire son animation d'entrée si l'API a répondu trop vite.
await Future.delayed(const Duration(milliseconds: 200));
if (context.mounted) {
final eventProvider = Provider.of<EventProvider>(context, listen: false);
final userProvider = Provider.of<LocalUserProvider>(context, listen: false);
if (userProvider.currentUser != null) {
await eventProvider.refreshEvents(userProvider.currentUser!.uid);
}
}
navigator.pop(); // Fermer le loader
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Retour en arrière effectué avec succès'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
navigator.pop(); // Fermer le loader
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur : $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
}
}
@@ -36,12 +36,15 @@ class MonthView extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
final rowCount = _computeRowCount(focusedDay);
// TableCalendar has internal vertical padding and margins (approx 16px) that cause overflow
// if not accounted for. We subtract an extra 16.0 pixels to be safe.
final availableHeight = constraints.maxHeight -
(_calendarPadding * 2) -
_headerHeight -
_headerVerticalPadding -
_daysOfWeekHeight;
final rowHeight = availableHeight / rowCount;
_daysOfWeekHeight -
16.0;
final rowHeight = (availableHeight > 0 ? availableHeight : 200.0) / rowCount;
return Container(
height: constraints.maxHeight,
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:em2rp/models/route_result_model.dart';
import '../../../utils/polyline_utils.dart';
/// Affiche 1 ou 2 itinéraires sur une carte OpenStreetMap.
/// Route TOLL = bleu, Route TOLL_FREE = vert.
class RouteMapWidget extends StatelessWidget {
final List<RouteResult> routes;
final RouteResult? selectedRoute;
const RouteMapWidget({
super.key,
required this.routes,
this.selectedRoute,
});
List<LatLng> _decode(String encoded) {
final pts = safeDecodePolyline(encoded);
// DEBUG: afficher dans la console du navigateur
// ignore: avoid_print
print('[MAP DEBUG] encoded length=${encoded.length}, decoded ${pts.length} points');
if (pts.isNotEmpty) {
// ignore: avoid_print
print('[MAP DEBUG] first=${pts.first.latitude},${pts.first.longitude} last=${pts.last.latitude},${pts.last.longitude}');
}
return pts;
}
LatLngBounds? _computeBounds(List<List<LatLng>> allPoints) {
final flat = allPoints.expand((e) => e).cast<LatLng>().toList();
if (flat.isEmpty) return null;
return LatLngBounds.fromPoints(flat);
}
@override
Widget build(BuildContext context) {
final allPolylines = <Polyline>[];
final allPointGroups = <List<LatLng>>[];
for (final route in routes) {
final pts = _decode(route.encodedPolyline);
if (pts.isEmpty) continue;
allPointGroups.add(pts);
final isSelected =
selectedRoute == null || selectedRoute!.routeType == route.routeType;
final isToll = route.routeType == 'TOLL';
allPolylines.add(Polyline(
points: pts,
strokeWidth: isSelected ? 5.0 : 3.0,
color: isToll
? (isSelected
? const Color(0xFF1565C0)
: const Color(0xFF1565C0).withValues(alpha: 0.4))
: (isSelected
? const Color(0xFF2E7D32)
: const Color(0xFF2E7D32).withValues(alpha: 0.4)),
));
}
final bounds = _computeBounds(allPointGroups);
final mapController = MapController();
// Marqueurs de départ / arrivée
final markers = <Marker>[];
for (final group in allPointGroups) {
if (group.isEmpty) continue;
// Départ
markers.add(Marker(
point: group.first,
width: 32,
height: 32,
child: const Icon(Icons.circle, color: Colors.green, size: 20),
));
// Arrivée
markers.add(Marker(
point: group.last,
width: 32,
height: 32,
child: const Icon(Icons.location_pin, color: Colors.red, size: 32),
));
}
return FlutterMap(
mapController: mapController,
options: MapOptions(
initialCameraFit: bounds != null
? CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(32),
)
: CameraFit.bounds(
bounds: LatLngBounds.fromPoints([LatLng(46.2276, 2.2137)]),
padding: const EdgeInsets.all(32),
),
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all,
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.em2events.em2rp',
),
PolylineLayer(polylines: allPolylines),
MarkerLayer(markers: markers),
],
);
}
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
class StartupSplashScreen extends StatelessWidget {
final String message;
const StartupSplashScreen({super.key, this.message = 'Démarrage...'});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 160,
height: 160,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 72,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 24),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
color: AppColors.noir,
fontSize: 16,
),
),
],
),
),
);
}
}
@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/depot_model.dart';
import 'package:em2rp/services/travel_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/inputs/address_autocomplete_field.dart';
class DepotManagement extends StatefulWidget {
const DepotManagement({super.key});
@override
State<DepotManagement> createState() => _DepotManagementState();
}
class _DepotManagementState extends State<DepotManagement> {
final _service = TravelService();
bool _isLoading = false;
void _showDepotDialog({DepotModel? depot}) {
final nameCtrl = TextEditingController(text: depot?.name ?? '');
final addressCtrl = TextEditingController(text: depot?.address ?? '');
final formKey = GlobalKey<FormState>();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(depot == null ? 'Ajouter un dépôt' : 'Modifier le dépôt'),
content: SizedBox(
width: 420,
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Nom du dépôt *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.warehouse_outlined),
hintText: 'ex: Dépôt principal',
),
validator: (v) =>
v == null || v.trim().isEmpty ? 'Requis' : null,
),
const SizedBox(height: 16),
AddressAutocompleteField(
controller: addressCtrl,
label: 'Adresse du dépôt *',
validator: (v) =>
v == null || v.trim().isEmpty ? 'Requis' : null,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
onPressed: () async {
if (!formKey.currentState!.validate()) return;
Navigator.pop(ctx);
setState(() => _isLoading = true);
try {
if (depot == null) {
await _service.addDepot(DepotModel(
id: '',
name: nameCtrl.text.trim(),
address: addressCtrl.text.trim(),
));
} else {
await _service.updateDepot(depot.copyWith(
name: nameCtrl.text.trim(),
address: addressCtrl.text.trim(),
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
},
child: Text(depot == null ? 'Ajouter' : 'Enregistrer'),
),
],
),
);
}
Future<void> _delete(DepotModel depot) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Supprimer le dépôt'),
content: Text('Supprimer "${depot.name}" ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
setState(() => _isLoading = true);
try {
await _service.deleteDepot(depot.id);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warehouse_outlined, color: AppColors.rouge, size: 28),
const SizedBox(width: 12),
Text(
'Dépôts',
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Ajouter un dépôt'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
onPressed: () => _showDepotDialog(),
),
],
),
const SizedBox(height: 8),
Text(
'Définissez les adresses de départ pour le calcul des frais de déplacement.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]),
),
const SizedBox(height: 24),
if (_isLoading) const Center(child: CircularProgressIndicator()),
Expanded(
child: StreamBuilder<List<DepotModel>>(
stream: _service.watchDepots(),
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final depots = snap.data ?? [];
if (depots.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warehouse_outlined,
size: 64, color: Colors.grey[300]),
const SizedBox(height: 16),
Text(
'Aucun dépôt configuré',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.grey[500]),
),
const SizedBox(height: 8),
const Text('Ajoutez un dépôt pour commencer.'),
],
),
);
}
return ListView.separated(
itemCount: depots.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final d = depots[i];
return ListTile(
leading: CircleAvatar(
backgroundColor: AppColors.rouge.withValues(alpha: 0.1),
child: Icon(Icons.warehouse_outlined, color: AppColors.rouge),
),
title: Text(d.name,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(d.address),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Modifier',
onPressed: () => _showDepotDialog(depot: d),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _delete(d),
),
],
),
);
},
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:em2rp/models/route_result_model.dart';
import 'package:em2rp/services/travel_service.dart';
import 'package:em2rp/utils/colors.dart';
class FuelPricesManagement extends StatefulWidget {
const FuelPricesManagement({super.key});
@override
State<FuelPricesManagement> createState() => _FuelPricesManagementState();
}
class _FuelPricesManagementState extends State<FuelPricesManagement> {
final _service = TravelService();
final _formKey = GlobalKey<FormState>();
final _dieselCtrl = TextEditingController();
final _essenceCtrl = TextEditingController();
final _electriqueCtrl = TextEditingController();
bool _isLoading = true;
bool _isSaving = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _isLoading = true);
final prices = await _service.getFuelPrices();
_dieselCtrl.text = prices.diesel.toStringAsFixed(3);
_essenceCtrl.text = prices.essence.toStringAsFixed(3);
_electriqueCtrl.text = prices.electricite.toStringAsFixed(3);
if (mounted) setState(() => _isLoading = false);
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isSaving = true);
try {
final prices = FuelPrices(
diesel: double.parse(_dieselCtrl.text.trim()),
essence: double.parse(_essenceCtrl.text.trim()),
electricite: double.parse(_electriqueCtrl.text.trim()),
);
await _service.saveFuelPrices(prices);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Prix mis à jour ✓'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
@override
void dispose() {
_dieselCtrl.dispose();
_essenceCtrl.dispose();
_electriqueCtrl.dispose();
super.dispose();
}
Widget _buildPriceField({
required TextEditingController controller,
required String label,
required String unit,
required IconData icon,
required Color color,
}) {
return Row(
children: [
CircleAvatar(
backgroundColor: color.withValues(alpha: 0.1),
child: Icon(icon, color: color),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
suffixText: unit,
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,3}'))
],
validator: (v) {
if (v == null || v.isEmpty) return 'Requis';
final parsed = double.tryParse(v);
if (parsed == null || parsed <= 0) return 'Valeur invalide';
return null;
},
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.local_gas_station, color: AppColors.rouge, size: 28),
const SizedBox(width: 12),
Text(
'Prix des carburants',
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
Text(
'Ces prix sont utilisés pour calculer automatiquement le coût en carburant des déplacements.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.grey[600]),
),
const SizedBox(height: 32),
if (_isLoading)
const Center(child: CircularProgressIndicator())
else
Form(
key: _formKey,
child: Column(
children: [
_buildPriceField(
controller: _dieselCtrl,
label: 'Prix du Diesel',
unit: '€/L',
icon: Icons.local_gas_station,
color: Colors.blue,
),
const SizedBox(height: 20),
_buildPriceField(
controller: _essenceCtrl,
label: 'Prix de l\'Essence',
unit: '€/L',
icon: Icons.local_gas_station,
color: Colors.orange,
),
const SizedBox(height: 20),
_buildPriceField(
controller: _electriqueCtrl,
label: 'Prix de l\'Électricité',
unit: '€/kWh',
icon: Icons.electric_bolt,
color: Colors.amber,
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: _isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.save_outlined),
label: Text(_isSaving ? 'Enregistrement...' : 'Enregistrer les prix'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: _isSaving ? null : _save,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,381 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:em2rp/models/vehicle_model.dart';
import 'package:em2rp/services/vehicle_service.dart';
import 'package:em2rp/utils/colors.dart';
class VehiclesManagement extends StatefulWidget {
const VehiclesManagement({super.key});
@override
State<VehiclesManagement> createState() => _VehiclesManagementState();
}
class _VehiclesManagementState extends State<VehiclesManagement> {
final _service = VehicleService();
bool _isLoading = false;
static const _fuelTypes = ['Diesel', 'Essence', 'Electrique'];
void _showVehicleDialog({VehicleModel? vehicle}) {
final formKey = GlobalKey<FormState>();
final nameCtrl =
TextEditingController(text: vehicle?.name ?? '');
final consoCtrl = TextEditingController(
text: vehicle?.consumptionPer100km.toString() ?? '');
final maintCtrl = TextEditingController(
text: vehicle?.maintenanceCostPerKm.toString() ?? '');
String fuelType = vehicle?.fuelType ?? 'Diesel';
int tollCategory = vehicle?.tollCategoryId ?? 2;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(builder: (ctx, setDlg) {
return AlertDialog(
title: Text(vehicle == null ? 'Ajouter un véhicule' : 'Modifier le véhicule'),
content: SizedBox(
width: 480,
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Nom
TextFormField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Nom du véhicule *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.directions_car_outlined),
hintText: 'ex: Renault Master',
),
validator: (v) =>
v == null || v.trim().isEmpty ? 'Requis' : null,
),
const SizedBox(height: 14),
// Type de carburant
DropdownButtonFormField<String>(
value: fuelType,
decoration: const InputDecoration(
labelText: 'Type de carburant *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.local_gas_station_outlined),
),
items: _fuelTypes
.map((f) => DropdownMenuItem(value: f, child: Text(f)))
.toList(),
onChanged: (v) => setDlg(() => fuelType = v!),
),
const SizedBox(height: 14),
// Consommation
TextFormField(
controller: consoCtrl,
decoration: InputDecoration(
labelText: fuelType == 'Electrique'
? 'Consommation (kWh/100km) *'
: 'Consommation (L/100km) *',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.speed_outlined),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'))
],
validator: (v) {
if (v == null || v.isEmpty) return 'Requis';
if (double.tryParse(v) == null) return 'Nombre invalide';
return null;
},
),
const SizedBox(height: 14),
// Coût maintenance
TextFormField(
controller: maintCtrl,
decoration: const InputDecoration(
labelText: 'Coût maintenance (€/km) *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.build_outlined),
hintText: 'ex: 0.08',
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,3}'))
],
validator: (v) {
if (v == null || v.isEmpty) return 'Requis';
if (double.tryParse(v) == null) return 'Nombre invalide';
return null;
},
),
const SizedBox(height: 14),
// Catégorie péage
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Catégorie de péage : $tollCategory',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
children: List.generate(5, (i) {
final cat = i + 1;
return Expanded(
child: GestureDetector(
onTap: () => setDlg(() => tollCategory = cat),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: tollCategory == cat
? AppColors.rouge
: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'$cat',
style: TextStyle(
color: tollCategory == cat
? Colors.white
: Colors.black87,
fontWeight: FontWeight.bold,
),
),
),
),
),
);
}),
),
const SizedBox(height: 4),
Text(
'Classe 1 Véhicules légers\n'
'Classe 2 Véhicules intermédiaires\n'
'Classe 3 Poids lourds, autocars et autres véhicules à 2 essieux\n'
'Classe 4 - Poids lourds et autres véhicules à 3 essieux et plus\n'
'Classe 5 Motos, side-cars et trikes',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
onPressed: () async {
if (!formKey.currentState!.validate()) return;
Navigator.pop(ctx);
setState(() => _isLoading = true);
try {
final v = VehicleModel(
id: vehicle?.id ?? '',
name: nameCtrl.text.trim(),
fuelType: fuelType,
consumptionPer100km:
double.parse(consoCtrl.text.trim()),
maintenanceCostPerKm:
double.parse(maintCtrl.text.trim()),
tollCategoryId: tollCategory,
);
if (vehicle == null) {
await _service.addVehicle(v);
} else {
await _service.updateVehicle(v);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
},
child: Text(vehicle == null ? 'Ajouter' : 'Enregistrer'),
),
],
);
}),
);
}
Future<void> _delete(VehicleModel v) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Supprimer le véhicule'),
content: Text('Supprimer "${v.name}" ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, foregroundColor: Colors.white),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
setState(() => _isLoading = true);
try {
await _service.deleteVehicle(v.id);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}
Icon _fuelIcon(String fuelType) {
switch (fuelType) {
case 'Electrique':
return const Icon(Icons.electric_bolt, color: Colors.amber);
case 'Essence':
return const Icon(Icons.local_gas_station, color: Colors.green);
default:
return const Icon(Icons.local_gas_station, color: Colors.orange);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.directions_car_outlined,
color: AppColors.rouge, size: 28),
const SizedBox(width: 12),
Text(
'Véhicules',
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Ajouter un véhicule'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
onPressed: () => _showVehicleDialog(),
),
],
),
const SizedBox(height: 8),
Text(
'Paramétrez la flotte de véhicules pour le calcul automatique des frais de déplacement.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.grey[600]),
),
const SizedBox(height: 24),
if (_isLoading) const LinearProgressIndicator(),
Expanded(
child: StreamBuilder<List<VehicleModel>>(
stream: _service.watchVehicles(),
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final vehicles = snap.data ?? [];
if (vehicles.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.directions_car_outlined,
size: 64, color: Colors.grey[300]),
const SizedBox(height: 16),
Text(
'Aucun véhicule',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.grey[500]),
),
const SizedBox(height: 8),
const Text('Ajoutez des véhicules pour commencer.'),
],
),
);
}
return ListView.separated(
itemCount: vehicles.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final v = vehicles[i];
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.grey[100],
child: _fuelIcon(v.fuelType),
),
title: Text(v.name,
style:
const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(
'${v.consumptionPer100km} ${v.consumptionUnit} • Maint. ${v.maintenanceCostPerKm} €/km • Classe péage ${v.tollCategoryId}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Chip(
label: Text(v.fuelType),
backgroundColor: v.fuelType == 'Electrique'
? Colors.amber[50]
: v.fuelType == 'Essence'
? Colors.green[50]
: Colors.orange[50],
side: BorderSide.none,
),
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Modifier',
onPressed: () => _showVehicleDialog(vehicle: v),
),
IconButton(
icon: const Icon(Icons.delete_outline,
color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _delete(v),
),
],
),
);
},
);
},
),
),
],
),
);
}
}
@@ -40,7 +40,9 @@ class _EquipmentAssociatedEventsSectionState
}
Future<void> _loadEvents() async {
if (mounted) {
setState(() => _isLoading = true);
}
try {
// Récupérer TOUS les événements via l'API
@@ -128,12 +130,16 @@ class _EquipmentAssociatedEventsSectionState
// Trier par date
filteredEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
if (mounted) {
setState(() {
_events = filteredEvents;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -207,7 +213,9 @@ class _EquipmentAssociatedEventsSectionState
],
),
onSelected: (filter) {
if (mounted) {
setState(() => _selectedFilter = filter);
}
_loadEvents();
},
itemBuilder: (context) => EventFilter.values.map((filter) {
@@ -12,7 +12,7 @@ enum ChecklistStep {
}
/// Widget pour afficher un équipement dans une checklist de préparation/retour
class EquipmentChecklistItem extends StatelessWidget {
class EquipmentChecklistItem extends StatefulWidget {
final EquipmentModel equipment;
final EventEquipment eventEquipment;
final ChecklistStep step;
@@ -34,92 +34,120 @@ class EquipmentChecklistItem extends StatelessWidget {
this.wasMissingBefore = false,
});
/// Retourne la quantité actuelle selon l'étape
@override
State<EquipmentChecklistItem> createState() => _EquipmentChecklistItemState();
}
class _EquipmentChecklistItemState extends State<EquipmentChecklistItem> {
late TextEditingController _quantityController;
int _getCurrentQuantity() {
switch (step) {
switch (widget.step) {
case ChecklistStep.preparation:
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
return widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
case ChecklistStep.loading:
return eventEquipment.quantityAtLoading ?? eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
return widget.eventEquipment.quantityAtLoading ?? widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
case ChecklistStep.unloading:
return eventEquipment.quantityAtUnloading ?? eventEquipment.quantityAtLoading ?? eventEquipment.quantity;
return widget.eventEquipment.quantityAtUnloading ?? widget.eventEquipment.quantityAtLoading ?? widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
case ChecklistStep.return_:
return eventEquipment.quantityAtReturn ?? eventEquipment.quantityAtUnloading ?? eventEquipment.quantity;
return widget.eventEquipment.quantityAtReturn ?? widget.eventEquipment.quantityAtUnloading ?? widget.eventEquipment.quantityAtLoading ?? widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
}
}
@override
void initState() {
super.initState();
_quantityController = TextEditingController(text: _getCurrentQuantity().toString());
}
@override
void didUpdateWidget(covariant EquipmentChecklistItem oldWidget) {
super.didUpdateWidget(oldWidget);
final currentQty = _getCurrentQuantity();
final controllerQty = int.tryParse(_quantityController.text);
if (controllerQty != currentQty) {
_quantityController.text = currentQty.toString();
}
}
@override
void dispose() {
_quantityController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final hasQuantity = equipment.hasQuantity;
final hasQuantity = widget.equipment.hasQuantity;
// Déterminer la quantité actuelle selon l'étape
final int currentQuantity = _getCurrentQuantity();
return Padding(
padding: EdgeInsets.only(
left: isChild ? 32.0 : 0.0, // Indentation pour les enfants
left: widget.isChild ? 32.0 : 0.0, // Indentation pour les enfants
top: 4.0,
bottom: 4.0,
),
child: Card(
margin: EdgeInsets.zero,
elevation: isChild ? 0 : 1, // Pas d'élévation pour les enfants
color: wasMissingBefore
elevation: widget.isChild ? 0 : 1, // Pas d'élévation pour les enfants
color: widget.wasMissingBefore
? Colors.orange.shade50
: (isChild ? Colors.grey.shade50 : Colors.white),
: (widget.isChild ? Colors.grey.shade50 : Colors.white),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: wasMissingBefore
color: widget.wasMissingBefore
? Colors.orange
: (isValidated ? Colors.green : Colors.grey.shade300),
width: (isValidated || wasMissingBefore) ? 2 : 1,
: (widget.isValidated ? Colors.green : Colors.grey.shade300),
width: (widget.isValidated || widget.wasMissingBefore) ? 2 : 1,
),
),
child: ListTile(
dense: isChild, // Plus compact pour les enfants
dense: widget.isChild, // Plus compact pour les enfants
contentPadding: EdgeInsets.symmetric(
horizontal: isChild ? 8.0 : 16.0,
vertical: isChild ? 4.0 : 8.0,
horizontal: widget.isChild ? 8.0 : 16.0,
vertical: widget.isChild ? 4.0 : 8.0,
),
leading: Container(
width: isChild ? 32 : 40,
height: isChild ? 32 : 40,
width: widget.isChild ? 32 : 40,
height: widget.isChild ? 32 : 40,
decoration: BoxDecoration(
color: wasMissingBefore
color: widget.wasMissingBefore
? Colors.orange.shade100
: (isValidated ? Colors.green.shade100 : Colors.grey.shade100),
: (widget.isValidated ? Colors.green.shade100 : Colors.grey.shade100),
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
icon: Icon(
wasMissingBefore
widget.wasMissingBefore
? Icons.warning
: (isValidated ? Icons.check_circle : Icons.radio_button_unchecked),
color: wasMissingBefore
: (widget.isValidated ? Icons.check_circle : Icons.radio_button_unchecked),
color: widget.wasMissingBefore
? Colors.orange
: (isValidated ? Colors.green : Colors.grey),
size: isChild ? 18 : 24,
: (widget.isValidated ? Colors.green : Colors.grey),
size: widget.isChild ? 18 : 24,
),
onPressed: onToggle,
onPressed: widget.onToggle,
padding: EdgeInsets.zero,
),
),
title: Text(
equipment.name,
widget.equipment.name,
style: TextStyle(
fontWeight: isChild ? FontWeight.w500 : FontWeight.w600,
fontSize: isChild ? 13 : 15,
decoration: isValidated ? TextDecoration.lineThrough : null,
color: isValidated ? Colors.grey : null,
fontWeight: widget.isChild ? FontWeight.w500 : FontWeight.w600,
fontSize: widget.isChild ? 13 : 15,
decoration: widget.isValidated ? TextDecoration.lineThrough : null,
color: widget.isValidated ? Colors.grey : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (equipment.model != null)
if (widget.equipment.model != null)
Text(
equipment.model!,
widget.equipment.model!,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
@@ -127,12 +155,12 @@ class EquipmentChecklistItem extends StatelessWidget {
),
// Indicateur si manquant à l'étape précédente
if (wasMissingBefore)
if (widget.wasMissingBefore)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
Icon(Icons.warning_amber, size: 14, color: Colors.orange),
const Icon(Icons.warning_amber, size: 14, color: Colors.orange),
const SizedBox(width: 4),
Text(
'Était manquant à l\'étape précédente',
@@ -151,7 +179,7 @@ class EquipmentChecklistItem extends StatelessWidget {
const SizedBox(height: 6),
Row(
children: [
Text(
const Text(
'Quantité : ',
style: TextStyle(
fontSize: 12,
@@ -159,11 +187,11 @@ class EquipmentChecklistItem extends StatelessWidget {
color: AppColors.bleuFonce,
),
),
if (onQuantityChanged != null)
if (widget.onQuantityChanged != null)
SizedBox(
width: 60,
child: TextFormField(
initialValue: currentQuantity.toString(),
controller: _quantityController,
keyboardType: TextInputType.number,
style: const TextStyle(fontSize: 12),
decoration: InputDecoration(
@@ -175,14 +203,14 @@ class EquipmentChecklistItem extends StatelessWidget {
),
onChanged: (value) {
final qty = int.tryParse(value) ?? currentQuantity;
onQuantityChanged!(qty);
widget.onQuantityChanged!(qty);
},
),
)
else
Text(
currentQuantity.toString(),
style: TextStyle(
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.bleuFonce,
@@ -193,16 +221,16 @@ class EquipmentChecklistItem extends StatelessWidget {
],
],
),
trailing: isValidated
trailing: widget.isValidated
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Row(
child: const Row(
mainAxisSize: MainAxisSize.min,
children: const [
children: [
Icon(Icons.check, size: 16, color: Colors.green),
SizedBox(width: 4),
Text(

Some files were not shown because too many files have changed in this diff Show More