Compare commits

18 Commits

Author SHA1 Message Date
ElPoyo
a182f1b922 refactor: Passage à la pagination côté serveur pour les équipements et containers
Cette mise à jour refactorise en profondeur le chargement des données pour les équipements et les containers, en remplaçant la récupération complète de la collection par un système de pagination côté serveur. Ce changement améliore considérablement les performances, réduit la consommation de mémoire et accélère le temps de chargement initial, en particulier pour les larges inventaires.

**Changements Backend (Cloud Functions) :**

-   **Nouveaux Endpoints Paginés :**
    -   `getEquipmentsPaginated` et `getContainersPaginated` ont été créés pour remplacer les anciens `getEquipments` et `getContainers`.
    -   Ces nouvelles fonctions supportent le filtrage (catégorie, statut, type), la recherche textuelle et le tri directement côté serveur, limitant la quantité de données transférées.
    -   La pagination est gérée via les paramètres `limit` et `startAfter`, assurant un chargement par lots efficace.
-   **Optimisation de `getContainersPaginated` :**
    -   Peuple désormais les containers avec leurs équipements enfants via une requête `in` optimisée, réduisant le nombre de lectures Firestore.
-   **Suppression des Anciens Endpoints :** Les fonctions `getEquipments` et `getContainers`, qui chargeaient l'intégralité des collections, ont été supprimées.
-   **Nouveau Script de Migration :** Ajout d'un script (`migrate_equipment_ids.js`) pour s'assurer que chaque équipement dans Firestore possède un champ `id` correspondant à son ID de document, ce qui est crucial pour le tri et la pagination.

**Changements Frontend (Flutter) :**

-   **`EquipmentProvider` et `ContainerProvider` :**
    -   La logique de chargement a été entièrement réécrite pour utiliser les nouveaux endpoints paginés.
    -   Introduction d'un mode `usePagination` pour basculer entre le chargement paginé (pour les pages de gestion) et le chargement complet (pour les dialogues de sélection).
    -   Implémentation de `loadFirstPage` et `loadNextPage` pour gérer le scroll infini.
    -   Ajout d'un "debouncing" sur la recherche pour éviter les appels API excessifs lors de la saisie.
-   **Pages de Gestion (`EquipmentManagementPage`, `ContainerManagementPage`) :**
    -   Utilisent désormais un `ScrollController` pour déclencher `loadNextPage` et implémenter un scroll infini.
    -   Le chargement initial et les rechargements (après filtre) sont beaucoup plus rapides.
    -   Refonte de l'UI avec un nouveau widget `SearchActionsBar` pour uniformiser la barre de recherche et les actions.
-   **Dialogue de Sélection d'Équipement (`EquipmentSelectionDialog`) :**
    -   Passe également à un système de lazy loading basé sur des `ChoiceChip` pour afficher soit les équipements, soit les containers.
    -   Charge les pages de manière asynchrone au fur et à mesure du scroll, améliorant drastiquement la réactivité du dialogue.
    -   La logique de chargement des données a été fiabilisée pour attendre la disponibilité des données avant l'affichage.
-   **Optimisations diverses :**
    -   Les sections qui listent les événements associés à un équipement (`EquipmentCurrentEventsSection`, etc.) chargent désormais uniquement les containers pertinents via `getContainersByIds` au lieu de toute la collection.
    -   Le calcul du statut d'un équipement (`EquipmentStatusBadge`) est maintenant synchrone, simplifiant le code et évitant des `FutureBuilder`.

**Correction mineure :**

-   **Nom de l'application :** Le nom de l'application a été mis à jour de "EM2 ERP" à "EM2 Hub" dans `main.dart` et dans les exports ICS.
2026-01-18 12:40:23 +01:00
ElPoyo
b79791ff7a refactor: Ajout des sous-catégories et refonte de la gestion de l'appartenance
Cette mise à jour structurelle améliore la classification des équipements en introduisant la notion de sous-catégories et supprime la gestion directe de l'appartenance d'un équipement à une boîte (`parentBoxIds`). L'appartenance est désormais uniquement définie côté conteneur. Une nouvelle catégorie "Régie / Backline" est également ajoutée.

**Changements majeurs :**

-   **Suppression de `parentBoxIds` sur `EquipmentModel` :**
    -   Le champ `parentBoxIds` a été retiré du modèle de données `EquipmentModel` et de toutes les logiques associées (création, mise à jour, copie).
    -   La responsabilité de lier un équipement à un conteneur est désormais exclusivement gérée par le `ContainerModel` via sa liste `equipmentIds`.
    -   La logique de synchronisation complexe dans `EquipmentFormPage` qui mettait à jour les conteneurs lors de la modification d'un équipement a été entièrement supprimée, simplifiant considérablement le code.
    -   Le sélecteur de boîtes parentes (`ParentBoxesSelector`) a été retiré du formulaire d'équipement.

-   **Ajout des sous-catégories :**
    -   Un champ optionnel `subCategory` (String) a été ajouté au `EquipmentModel`.
    -   Le formulaire de création/modification d'équipement inclut désormais un nouveau champ "Sous-catégorie" avec autocomplétion.
    -   Ce champ est contextuel : il propose des suggestions basées sur les sous-catégories existantes pour la catégorie principale sélectionnée (ex: "Console", "Micro" pour la catégorie "Son").
    -   La sous-catégorie est maintenant affichée sur les fiches de détail des équipements et dans les listes de la page de gestion, améliorant la visibilité du classement.

**Nouvelle catégorie d'équipement :**

-   Une nouvelle catégorie `backline` ("Régie / Backline") a été ajoutée à `EquipmentCategory` avec une icône (`Icons.piano`) et une couleur associée.

**Refactorisation et nettoyage :**

-   Le `EquipmentProvider` et `EquipmentService` ont été mis à jour pour charger et filtrer les sous-catégories.
-   De nombreuses instanciations d'un `EquipmentModel` vide (`dummy`) à travers l'application ont été nettoyées pour retirer la référence à `parentBoxIds`.

-   **Version de l'application :**
    -   La version a été incrémentée à `1.0.4`.
2026-01-17 12:07:20 +01:00
ElPoyo
7e111ec041 refactor: Amélioration de la génération des étiquettes PDF
Cette mise à jour refactorise en profondeur la génération des étiquettes PDF (formats moyen et grand) pour correspondre précisément aux dimensions de planches d'étiquettes standards, remplaçant la mise en page approximative par un placement calculé au millimètre près. La version de l'application est également incrémentée à `1.0.4`.

**Changements principaux sur le `PDFService` :**

-   **Précision des formats d'étiquettes :**
    -   **Format Moyen :**
        -   Calibré pour des étiquettes de 49 x 26 mm, disposées en 4 colonnes et 10 lignes (40 par page).
        -   La mise en page est entièrement refaite : le QR code est à gauche et le texte (logo, ID, titre) est à droite, optimisant l'espace horizontal.
        -   Un calcul manuel des marges (`leftMargin`, `topMargin`) assure un alignement précis sur la page A4, avec une correction pour un centrage parfait.
    -   **Format Large :**
        -   Calibré pour des étiquettes de 105 x 57 mm, disposées en 2 colonnes et 5 lignes (10 par page).
        -   Utilise la moitié de la largeur d'une page A4 (`PdfPageFormat.a4.width / 2`) pour garantir un ajustement parfait des colonnes.
        -   La mise en page a été ajustée pour un meilleur centrage vertical du contenu dans l'étiquette.
        -   Suppression des bordures décoratives pour une impression directe sur planches prédécoupées.

-   **Améliorations générales :**
    -   Le logo de l'entreprise est désormais inclus également sur les étiquettes de format moyen.
    -   Les tailles de police et la troncature du texte ont été ajustées pour chaque format afin d'éviter les débordements et d'améliorer la lisibilité.
    -   Le code a été nettoyé, supprimant des commentaires et des paramètres de mise en page obsolètes (`pw.Center`, `spacing`, `runSpacing`).

-   **Mise à jour de la version :**
    -   La version de l'application est passée de `1.0.3` à `1.0.4`.
2026-01-16 19:23:57 +01:00
ElPoyo
4e7af9119a fix: Amélioration de l'expérience utilisateur lors de la génération de QR codes
Cette mise à jour améliore la génération de QR codes pour les équipements et les containers en ajoutant un retour visuel à l'utilisateur et une gestion des erreurs plus robuste.

**Changements :**
- **Ajout d'un indicateur de chargement :** Un `CircularProgressIndicator` est désormais affiché pendant que les données des équipements ou des containers sélectionnés sont récupérées, informant l'utilisateur qu'une opération est en cours.
- **Gestion des erreurs :** Un bloc `try...catch` a été ajouté autour de la logique de génération dans les pages de gestion des équipements (`EquipmentManagementPage`) et des containers (`ContainerManagementPage`).
- **Affichage des erreurs :** En cas d'échec, le chargement est stoppé et une `SnackBar` rouge apparaît pour notifier l'utilisateur de l'erreur, améliorant ainsi la robustesse de la fonctionnalité.
2026-01-16 01:20:59 +01:00
ElPoyo
1ea5cea6fc fix: Amélioration de l'expérience utilisateur lors de la génération de QR codes
Cette mise à jour améliore la génération de QR codes pour les équipements et les containers en ajoutant un retour visuel à l'utilisateur et une gestion des erreurs plus robuste.

**Changements :**
- **Ajout d'un indicateur de chargement :** Un `CircularProgressIndicator` est désormais affiché pendant que les données des équipements ou des containers sélectionnés sont récupérées, informant l'utilisateur qu'une opération est en cours.
- **Gestion des erreurs :** Un bloc `try...catch` a été ajouté autour de la logique de génération dans les pages de gestion des équipements (`EquipmentManagementPage`) et des containers (`ContainerManagementPage`).
- **Affichage des erreurs :** En cas d'échec, le chargement est stoppé et une `SnackBar` rouge apparaît pour notifier l'utilisateur de l'erreur, améliorant ainsi la robustesse de la fonctionnalité.
2026-01-16 00:42:16 +01:00
ElPoyo
06f394b728 feat: Ajout du scan de QR Code pour retrouver équipements et conteneurs
Cette mise à jour introduit une fonctionnalité de scan de QR codes directement depuis l'application, permettant aux utilisateurs de retrouver et d'accéder rapidement à la page de détail d'un équipement ou d'un conteneur.

**Features :**
- **Scan de QR Code :**
    - Un nouveau bouton "Scanner QR Code" est ajouté sur les pages de gestion des équipements et des conteneurs.
    - L'appui sur ce bouton ouvre une nouvelle boîte de dialogue (`QRCodeScannerDialog`) utilisant la caméra de l'appareil pour scanner un QR code.
    - Le scanner affiche un overlay visuel clair avec un cadre de détection et fournit un retour visuel (icône de validation) lorsqu'un code est détecté avec succès.
- **Recherche et Redirection Intelligente :**
    - Une fois un QR code scanné, l'application recherche l'ID correspondant d'abord dans les équipements, puis dans les conteneurs.
    - Si une correspondance est trouvée, l'utilisateur est automatiquement redirigé vers la page de détail de l'élément correspondant (`EquipmentDetailPage` ou `ContainerDetailPage`).
    - Un message informe l'utilisateur si aucun élément ne correspond à l'ID scanné.

**Changements Techniques :**
- **Dépendance :** Ajout de la bibliothèque `mobile_scanner` pour gérer la fonctionnalité de scan.
- **Nouveau Widget :** Création du widget `QRCodeScannerDialog`, un dialogue réutilisable et stylisé pour le scan, incluant un overlay personnalisé (`_ScannerOverlayPainter`).
- **Intégration UI :**
    - Le `ManagementSearchBar` accepte désormais une liste de `additionalActions` pour permettre l'ajout de boutons personnalisés comme celui du scanner.
    - Ajout du bouton de scan sur les écrans `EquipmentManagementPage` et `ContainerManagementPage`, à la fois en version bureau (icône) et mobile (bouton plein).
- **Logique de Recherche :** Implémentation de la fonction `_scanQRCode` dans les deux pages de gestion pour orchestrer l'ouverture du scanner, la recherche dans les `EquipmentProvider` et `ContainerProvider`, et la navigation.
2026-01-16 00:35:10 +01:00
ElPoyo
67b85d323c refactor: Amélioration et stabilisation du sélecteur d'équipement
Cette mise à jour refactorise en profondeur la boîte de dialogue de sélection d'équipement pour améliorer ses performances, sa stabilité et son ergonomie. Le chargement des données a été entièrement revu pour être plus robuste et les interactions utilisateur sont désormais plus fluides et intuitives.

**Améliorations de performance et de stabilité :**
- **Chargement de données asynchrone et robuste :** Remplacement de la logique de chargement dans `initState` par une nouvelle méthode `_initializeData`. Cette méthode force le chargement des équipements et des conteneurs via `ensureLoaded()` et attend activement leur complétion avant de poursuivre, garantissant ainsi que toutes les données sont disponibles avant l'affichage.
- **Mise en cache locale :** Les données des équipements et conteneurs sont mises en cache (`_cachedEquipment`, `_cachedContainers`) après le chargement initial. Toute la boîte de dialogue utilise désormais ce cache, éliminant les appels répétés aux `Stream` et réduisant les rebuilds inutiles.
- **Fiabilisation des `setState` :** Les modifications de la sélection (`_selectedItems`) sont maintenant systématiquement wrappées dans des appels `setState` pour assurer que l'interface graphique se met à jour de manière fiable, corrigeant des bugs où la sélection n'était pas reflétée visuellement.

**Nouvelles fonctionnalités et améliorations de l'UX :**
- **Sections dépliables :** Les listes "Boîtes" et "Tous les équipements" sont désormais dans des sections qui peuvent être repliées, permettant à l'utilisateur de se concentrer sur l'une ou l'autre et d'améliorer la lisibilité sur les petits écrans.
- **Option d'affichage des conflits :** Ajout d'une checkbox "Afficher les équipements déjà utilisés". Lorsqu'elle est décochée, tous les équipements et boîtes en conflit avec les dates de l'événement sont masqués, simplifiant la recherche de matériel disponible.
- **Meilleure gestion des filtres :** Le filtre par catégorie s'applique désormais aussi aux boîtes, n'affichant que celles qui contiennent au moins un équipement de la catégorie sélectionnée.
- **Notifications de sélection affinées :** Le compteur dans le pied de page (`_selectedItems.length`) est maintenant mis à jour en temps réel à chaque modification de la sélection grâce à un `ValueListenableBuilder`.

**Refactorisation mineure :**
- **`EquipmentProvider` :** Ajout d'un getter `allEquipment` pour fournir un accès à la liste complète des équipements, non filtrée, utilisée par la boîte de dialogue pour sa logique de cache et de filtrage.
2026-01-15 23:59:25 +01:00
ElPoyo
beaabceda4 feat: Intégration d'un système complet d'alertes et de notifications par email
Cette mise à jour majeure introduit un système de notifications robuste, centré sur la création d'alertes et l'envoi d'emails via des Cloud Functions. Elle inclut la gestion des préférences utilisateur, la création automatique d'alertes lors d'événements critiques et une nouvelle interface dédiée.

**Backend (Cloud Functions) :**
- **Nouveau service d'alerting (`createAlert`, `processEquipmentValidation`) :**
    - `createAlert` : Nouvelle fonction pour créer une alerte. Elle détermine les utilisateurs à notifier (admins, workforce d'événement) et gère la persistance dans Firestore.
    - `processEquipmentValidation` : Endpoint appelé lors de la validation du matériel (chargement/déchargement). Il analyse l'état de l'équipement (`LOST`, `MISSING`, `DAMAGED`) et crée automatiquement les alertes correspondantes.
- **Système d'envoi d'emails (`sendAlertEmail`, `sendDailyDigest`) :**
    - `sendAlertEmail` : Cloud Function `onCall` pour envoyer un email d'alerte individuel. Elle respecte les préférences de notification de l'utilisateur (canal email, type d'alerte).
    - `sendDailyDigest` : Tâche planifiée (tous les jours à 8h) qui envoie un email récapitulatif des alertes non lues des dernières 24 heures aux utilisateurs concernés.
    - Ajout de templates HTML (`base-template`, `alert-individual`, `alert-digest`) avec `Handlebars` pour des emails riches.
    - Configuration centralisée du SMTP via des variables d'environnement (`.env`).
- **Triggers Firestore (`onEventCreated`, `onEventUpdated`) :**
    - Des triggers créent désormais des alertes d'information lorsqu'un événement est créé ou que de nouveaux membres sont ajoutés à la workforce.
- **Règles Firestore :**
    - Mises à jour pour autoriser les utilisateurs authentifiés à créer et modifier leurs propres alertes (marquer comme lue, supprimer), tout en sécurisant les accès.

**Frontend (Flutter) :**
- **Nouvel `AlertService` et `EmailService` :**
    - `AlertService` : Centralise la logique de création, lecture et gestion des alertes côté client en appelant les nouvelles Cloud Functions.
    - `EmailService` : Service pour déclencher l'envoi d'emails via la fonction `sendAlertEmail`. Il contient la logique pour déterminer si une notification doit être immédiate (critique) ou différée (digest).
- **Nouvelle page de Notifications (`/alerts`) :**
    - Interface dédiée pour lister toutes les alertes de l'utilisateur, avec des onglets pour filtrer par catégorie (Toutes, Événement, Maintenance, Équipement).
    - Permet de marquer les alertes comme lues, de les supprimer et de tout marquer comme lu.
- **Intégration dans l'UI :**
    - Ajout d'un badge de notification dans la `CustomAppBar` affichant le nombre d'alertes non lues en temps réel.
    - Le `AutoLoginWrapper` gère désormais la redirection vers des routes profondes (ex: `/alerts`) depuis une URL.
- **Gestion des Préférences de Notification :**
    - Ajout d'un widget `NotificationPreferencesWidget` dans la page "Mon Compte".
    - Les utilisateurs peuvent désormais activer/désactiver les notifications par email, ainsi que filtrer par type d'alerte (événements, maintenance, etc.).
    - Le `UserModel` et `LocalUserProvider` ont été étendus pour gérer ce nouveau modèle de préférences.
- **Création d'alertes contextuelles :**
    - Le service `EventFormService` crée maintenant automatiquement une alerte lorsqu'un événement est créé ou modifié.
    - La page de préparation d'événement (`EventPreparationPage`) appelle `processEquipmentValidation` à la fin de chaque étape pour une détection automatisée des anomalies.

**Dépendances et CI/CD :**
- Ajout des dépendances `cloud_functions` et `timeago` (Flutter), et `nodemailer`, `handlebars`, `dotenv` (Node.js).
- Ajout de scripts de déploiement PowerShell (`deploy_functions.ps1`, `deploy_firestore_rules.ps1`) pour simplifier les mises en production.
2026-01-15 23:15:25 +01:00
ElPoyo
60d0e1c6c4 feat: Refonte de la checklist de préparation avec gestion des manquants et des containers
Cette mise à jour refond entièrement l'interface et la logique de la checklist de préparation d'événement. Elle introduit la notion d'équipements "manquants", une gestion visuelle des containers et de leur contenu, et une logique plus fine pour le suivi des quantités et des statuts à chaque étape.

**Features et Améliorations :**

-   **Gestion des Équipements Manquants :**
    -   Le modèle `EventEquipment` a été enrichi pour tracer si un équipement est manquant à chaque étape (`isMissingAtPreparation`, `isMissingAtLoading`, etc.).
    -   Un équipement non validé lors de la confirmation d'une étape est désormais marqué comme "manquant" pour les étapes suivantes.
    -   Les équipements qui étaient manquants à l'étape précédente sont maintenant visuellement mis en évidence avec une bordure et une icône orange, et une confirmation est demandée pour les valider.

-   **Refonte de la Checklist (UI/UX) :**
    -   **Groupement par Container :** La checklist affiche désormais les containers comme des en-têtes de groupe. Les équipements qu'ils contiennent sont listés en dessous, avec une indentation visuelle.
    -   **Validation Groupée :** Il est possible de valider tous les équipements d'un container en un seul clic sur l'en-tête du container.
    -   **Nouveau Widget `ContainerChecklistItem` :** Créé pour afficher un container et ses équipements enfants dans la checklist.
    -   **Refonte de `EquipmentChecklistItem` :** Le widget a été entièrement revu pour un design plus clair, une meilleure gestion des états (validé, manquant), et un affichage compact pour les équipements enfants.

-   **Logique de Suivi Améliorée :**
    -   **Quantités par Étape :** Le modèle `EventEquipment` et l'interface de préparation permettent maintenant de suivre les quantités réelles à chaque étape (`quantityAtPreparation`, `quantityAtLoading`, etc.), au lieu d'une seule quantité de retour.
    -   **Marquage Automatique des "Perdus" :** À l'étape finale du retour, un équipement qui était présent au départ mais qui est maintenant manquant sera automatiquement marqué avec le statut "lost" dans la base de données.
    -   **Flux de Validation :** Le processus de confirmation distingue désormais la validation de tous les équipements et la confirmation de l'état actuel (y compris les manquants).

-   **Export ICS Enrichi :**
    -   L'export ICS inclut désormais les noms résolus des utilisateurs (main d'œuvre) pour plus de clarté, en plus des détails de l'événement.
    -   Le contenu généré mentionne la version de l'application.
2026-01-15 12:05:37 +01:00
ElPoyo
b30ae0f10a feat: Sécurisation Firestore, gestion des prix HT/TTC et refactorisation majeure
Cette mise à jour verrouille l'accès direct à Firestore depuis le client pour renforcer la sécurité et introduit une gestion complète des prix HT/TTC dans toute l'application. Elle apporte également des améliorations significatives des permissions, des optimisations de performance et de nouvelles fonctionnalités.

### Sécurité et Backend
- **Firestore Rules :** Ajout de `firestore.rules` qui bloque par défaut tous les accès en lecture/écriture depuis le client. Toutes les opérations de données doivent maintenant passer par les Cloud Functions, renforçant considérablement la sécurité.
- **Index Firestore :** Création d'un fichier `firestore.indexes.json` pour optimiser les requêtes sur la collection `events`.
- **Cloud Functions :** Les fonctions de création/mise à jour d'événements ont été adaptées pour accepter des ID de documents (utilisateurs, type d'événement) et les convertir en `DocumentReference` côté serveur, simplifiant les appels depuis le client.

### Gestion des Prix HT/TTC
- **Calcul Automatisé :** Introduction d'un helper `PriceHelpers` et d'un widget `PriceHtTtcFields` pour calculer et synchroniser automatiquement les prix HT et TTC dans le formulaire d'événement.
- **Affichage Détaillé :**
    - Les détails des événements et des options affichent désormais les prix HT, la TVA et le TTC séparément pour plus de clarté.
    - Le prix de base (`basePrice`) est maintenant traité comme un prix TTC dans toute l'application.

### Permissions et Rôles
- **Centralisation (`AppPermission`) :** Création d'une énumération `AppPermission` pour centraliser toutes les permissions de l'application, avec descriptions et catégories.
- **Rôles Prédéfinis :** Définition de rôles standards (Admin, Manager, Technicien, User) avec des jeux de permissions prédéfinis.
- **Filtre par Utilisateur :** Ajout d'un filtre par utilisateur sur la page Calendrier, visible uniquement pour les utilisateurs ayant la permission `view_all_user_events`.

### Améliorations et Optimisations (Frontend)
- **`DebugLog` :** Ajout d'un utilitaire `DebugLog` pour gérer les logs, qui sont automatiquement désactivés en mode production.
- **Optimisation du Sélecteur d'Équipement :**
    - La boîte de dialogue de sélection d'équipement a été lourdement optimisée pour éviter les reconstructions complètes de la liste lors de la sélection/désélection d'items.
    - Utilisation de `ValueNotifier` et de caches locaux (`_cachedContainers`, `_cachedEquipment`) pour des mises à jour d'UI plus ciblées et fluides.
    - La position du scroll est désormais préservée.
- **Catégorie d'Équipement :** Ajout de la catégorie `Vehicle` (Véhicule) pour les équipements.
- **Formulaires :** Les formulaires de création/modification d'événements et d'équipements ont été nettoyés de leurs logs de débogage excessifs.
2026-01-14 17:32:58 +01:00
ElPoyo
fb3f41df4d feat: refactor de la gestion des utilisateurs et migration de la logique métier vers les Cloud Functions
Cette mise à jour majeure refactorise entièrement la gestion des utilisateurs pour la faire passer par des Cloud Functions sécurisées et migre une part importante de la logique métier (gestion des événements, maintenances, containers) du client vers le backend.

**Gestion des Utilisateurs (Backend & Frontend):**
- **Nouvelle fonction `createUserWithInvite` :**
    - Crée l'utilisateur dans Firebase Auth avec un mot de passe temporaire.
    - Crée le document utilisateur correspondant dans Firestore.
    - Envoie automatiquement un e-mail de réinitialisation de mot de passe (via l'API REST de Firebase et `axios`) pour que l'utilisateur définisse son propre mot de passe, améliorant la sécurité et l'expérience d'intégration.
- **Refactorisation de `updateUser` et `deleteUser` :**
    - Les anciennes fonctions `onCall` sont remplacées par des fonctions `onRequest` (HTTP) standards, alignées avec le reste de l'API.
    - La logique de suppression gère désormais la suppression dans Auth et Firestore.
- **Réinitialisation de Mot de Passe (UI) :**
    - Ajout d'un bouton "Réinitialiser le mot de passe" sur la carte utilisateur, permettant aux administrateurs d'envoyer un e-mail de réinitialisation à n'importe quel utilisateur.
- **Amélioration de l'UI :**
    - Boîte de dialogue de confirmation améliorée pour la suppression d'un utilisateur.
    - Notifications (Snackbars) pour les opérations de création, suppression et réinitialisation de mot de passe.

**Migration de la Logique Métier vers les Cloud Functions:**
- **Gestion de la Préparation d'Événements :**
    - Migration complète de la logique de validation des étapes (préparation, chargement, déchargement, retour) du client vers de nouvelles Cloud Functions (`validateEquipmentPreparation`, `validateAllLoading`, etc.).
    - Le backend gère désormais la mise à jour des statuts de l'événement (`inProgress`, `completed`) et des équipements (`inUse`, `available`).
    - Le code frontend (`EventPreparationService`) a été simplifié pour appeler ces nouvelles fonctions au lieu d'effectuer des écritures directes sur Firestore.
- **Création de Maintenance :**
    - La fonction `createMaintenance` gère maintenant la mise à jour des équipements associés (`maintenanceIds`) et la création d'alertes (`maintenanceDue`) si une maintenance est prévue prochainement. La logique client a été supprimée.
- **Suppression de Container :**
    - La fonction `deleteContainer` a été améliorée pour nettoyer automatiquement les références (`parentBoxIds`) dans tous les équipements contenus avant de supprimer le container.

**Refactorisation et Corrections (Backend & Frontend) :**
- **Fiabilisation des Appels API (Frontend) :**
    - Le `ApiService` a été renforcé pour convertir de manière plus robuste les données (notamment les `Map` de type `_JsonMap`) en JSON standard avant de les envoyer aux Cloud Functions, évitant ainsi des erreurs de sérialisation.
- **Correction des Références (Backend) :**
    - La fonction `updateUser` convertit correctement les `roleId` (string) en `DocumentReference` Firestore.
    - Sécurisation de la vérification de l'assignation d'un utilisateur à un événement (`workforce`) pour éviter les erreurs sur des références nulles.
- **Dépendance (Backend) :**
    - Ajout de la librairie `axios` pour effectuer des appels à l'API REST de Firebase.
2026-01-14 12:05:03 +01:00
ElPoyo
4e4573f57b feat: refactor de la gestion des utilisateurs et migration de la logique métier vers les Cloud Functions
Cette mise à jour majeure refactorise entièrement la gestion des utilisateurs pour la faire passer par des Cloud Functions sécurisées et migre une part importante de la logique métier (gestion des événements, maintenances, containers) du client vers le backend.

**Gestion des Utilisateurs (Backend & Frontend):**
- **Nouvelle fonction `createUserWithInvite` :**
    - Crée l'utilisateur dans Firebase Auth avec un mot de passe temporaire.
    - Crée le document utilisateur correspondant dans Firestore.
    - Envoie automatiquement un e-mail de réinitialisation de mot de passe (via l'API REST de Firebase et `axios`) pour que l'utilisateur définisse son propre mot de passe, améliorant la sécurité et l'expérience d'intégration.
- **Refactorisation de `updateUser` et `deleteUser` :**
    - Les anciennes fonctions `onCall` sont remplacées par des fonctions `onRequest` (HTTP) standards, alignées avec le reste de l'API.
    - La logique de suppression gère désormais la suppression dans Auth et Firestore.
- **Réinitialisation de Mot de Passe (UI) :**
    - Ajout d'un bouton "Réinitialiser le mot de passe" sur la carte utilisateur, permettant aux administrateurs d'envoyer un e-mail de réinitialisation à n'importe quel utilisateur.
- **Amélioration de l'UI :**
    - Boîte de dialogue de confirmation améliorée pour la suppression d'un utilisateur.
    - Notifications (Snackbars) pour les opérations de création, suppression et réinitialisation de mot de passe.

**Migration de la Logique Métier vers les Cloud Functions:**
- **Gestion de la Préparation d'Événements :**
    - Migration complète de la logique de validation des étapes (préparation, chargement, déchargement, retour) du client vers de nouvelles Cloud Functions (`validateEquipmentPreparation`, `validateAllLoading`, etc.).
    - Le backend gère désormais la mise à jour des statuts de l'événement (`inProgress`, `completed`) et des équipements (`inUse`, `available`).
    - Le code frontend (`EventPreparationService`) a été simplifié pour appeler ces nouvelles fonctions au lieu d'effectuer des écritures directes sur Firestore.
- **Création de Maintenance :**
    - La fonction `createMaintenance` gère maintenant la mise à jour des équipements associés (`maintenanceIds`) et la création d'alertes (`maintenanceDue`) si une maintenance est prévue prochainement. La logique client a été supprimée.
- **Suppression de Container :**
    - La fonction `deleteContainer` a été améliorée pour nettoyer automatiquement les références (`parentBoxIds`) dans tous les équipements contenus avant de supprimer le container.

**Refactorisation et Corrections (Backend & Frontend) :**
- **Fiabilisation des Appels API (Frontend) :**
    - Le `ApiService` a été renforcé pour convertir de manière plus robuste les données (notamment les `Map` de type `_JsonMap`) en JSON standard avant de les envoyer aux Cloud Functions, évitant ainsi des erreurs de sérialisation.
- **Correction des Références (Backend) :**
    - La fonction `updateUser` convertit correctement les `roleId` (string) en `DocumentReference` Firestore.
    - Sécurisation de la vérification de l'assignation d'un utilisateur à un événement (`workforce`) pour éviter les erreurs sur des références nulles.
- **Dépendance (Backend) :**
    - Ajout de la librairie `axios` pour effectuer des appels à l'API REST de Firebase.
2026-01-14 11:18:49 +01:00
ElPoyo
4545bdba81 Fix : écran d'ajout de materiel, correction des conflits pour les cables et consommables 2026-01-13 18:50:46 +01:00
ElPoyo
272b4bc9c9 Fix : probleme de la détection d'utilisation par un autre événement 2026-01-13 18:07:16 +01:00
ElPoyo
0f7a886cf7 Fix : Mise a jour et création d'un événement 2026-01-13 17:26:09 +01:00
ElPoyo
2bcd1ca4c3 feat: Ajout de la gestion des utilisateurs et optimisation du chargement des données
Cette mise à jour introduit la gestion complète des utilisateurs (création, mise à jour, suppression) via des Cloud Functions et optimise de manière significative le chargement des données dans toute l'application.

**Features :**
- **Gestion des utilisateurs (Backend & Frontend) :**
    - Ajout des Cloud Functions `getUser`, `updateUser` et `deleteUser` pour gérer les utilisateurs de manière sécurisée, en respectant les permissions des rôles.
    - L'authentification passe désormais par `onCall` pour plus de sécurité.
- **Optimisation du chargement des données :**
    - Introduction de nouvelles Cloud Functions `getEquipmentsByIds` et `getContainersByIds` pour récupérer uniquement les documents nécessaires, réduisant ainsi la charge sur le client et Firestore.
    - Les fournisseurs (`EquipmentProvider`, `ContainerProvider`) ont été refactorisés pour utiliser un chargement à la demande (`ensureLoaded`) et mettre en cache les données récupérées.
    - Les écrans de détails et de préparation d'événements n'utilisent plus de `Stream` globaux, mais chargent les équipements et boites spécifiques via ces nouvelles fonctions, améliorant considérablement les performances.

**Refactorisation et Améliorations :**
- **Backend (Cloud Functions) :**
    - Le service de vérification de disponibilité (`checkEquipmentAvailability`) est désormais une Cloud Function, déplaçant la logique métier côté serveur.
    - La récupération des données (utilisateurs, événements, alertes) a été centralisée derrière des Cloud Functions, remplaçant les appels directs à Firestore depuis le client.
    - Amélioration de la sérialisation des données (timestamps, références) dans les réponses des fonctions.
- **Frontend (Flutter) :**
    - `LocalUserProvider` charge désormais les informations de l'utilisateur connecté via la fonction `getCurrentUser`, incluant son rôle et ses permissions en un seul appel.
    - `AlertProvider` utilise des fonctions pour charger et manipuler les alertes, abandonnant le `Stream` Firestore.
    - `EventAvailabilityService` utilise maintenant la Cloud Function `checkEquipmentAvailability` au lieu d'une logique client complexe.
    - Correction de la gestion des références de rôles (`roles/ADMIN`) et des `DocumentReference` pour les utilisateurs dans l'ensemble de l'application.
    - Le service d'export ICS (`IcsExportService`) a été simplifié, partant du principe que les données nécessaires (utilisateurs, options) sont déjà chargées dans l'application.
2026-01-13 01:40:28 +01:00
ElPoyo
f38d75362c refactor: Remplacement de l'accès direct à Firestore par des Cloud Functions
Migration complète du backend pour utiliser des Cloud Functions comme couche API sécurisée, en remplacement des appels directs à Firestore depuis le client.

**Backend (Cloud Functions):**
- **Centralisation CORS :** Ajout d'un middleware `withCors` et d'une configuration `httpOptions` pour gérer uniformément les en-têtes CORS et les requêtes `OPTIONS` sur toutes les fonctions.
- **Nouvelles Fonctions de Lecture (GET) :**
    - `getEquipments`, `getContainers`, `getEvents`, `getUsers`, `getOptions`, `getEventTypes`, `getRoles`, `getMaintenances`, `getAlerts`.
    - Ces fonctions gèrent les permissions côté serveur, masquant les données sensibles (ex: prix des équipements) pour les utilisateurs non-autorisés.
    - `getEvents` retourne également une map des utilisateurs (`usersMap`) pour optimiser le chargement des données de la main d'œuvre.
- **Nouvelle Fonction de Recherche :**
    - `getContainersByEquipment` : Endpoint dédié pour trouver efficacement tous les containers qui contiennent un équipement spécifique.
- **Nouvelles Fonctions d'Écriture (CRUD) :**
    - Fonctions CRUD complètes pour `eventTypes` (`create`, `update`, `delete`), incluant la validation (unicité du nom, vérification des événements futurs avant suppression).
- **Mise à jour de Fonctions Existantes :**
    - Toutes les fonctions CRUD existantes (`create/update/deleteEquipment`, `create/update/deleteContainer`, etc.) sont wrappées avec le nouveau gestionnaire CORS.

**Frontend (Flutter):**
- **Introduction du `DataService` :** Nouveau service centralisant tous les appels aux Cloud Functions, servant d'intermédiaire entre l'UI/Providers et l'API.
- **Refactorisation des Providers :**
    - `EquipmentProvider`, `ContainerProvider`, `EventProvider`, `UsersProvider`, `MaintenanceProvider` et `AlertProvider` ont été refactorisés pour utiliser le `DataService` au lieu d'accéder directement à Firestore.
    - Les `Stream` Firestore sont remplacés par des chargements de données via des méthodes `Future` (`loadEquipments`, `loadEvents`, etc.).
- **Gestion des Relations Équipement-Container :**
    - Le modèle `EquipmentModel` ne stocke plus `parentBoxIds`.
    - La relation est maintenant gérée par le `ContainerModel` qui contient `equipmentIds`.
    - Le `ContainerEquipmentService` est introduit pour utiliser la nouvelle fonction `getContainersByEquipment`.
    - L'affichage des boîtes parentes (`EquipmentParentContainers`) et le formulaire d'équipement (`EquipmentFormPage`) ont été mis à jour pour refléter ce nouveau modèle de données, synchronisant les ajouts/suppressions d'équipements dans les containers.
- **Amélioration de l'UI :**
    - Nouveau widget `ParentBoxesSelector` pour une sélection améliorée et visuelle des boîtes parentes dans le formulaire d'équipement.
    - Refonte visuelle de `EquipmentParentContainers` pour une meilleure présentation.
2026-01-12 20:38:46 +01:00
ElPoyo
13a890606d feat: ajout de la configuration des émulateurs Firebase et mise à jour des services pour utiliser le backend sécurisé 2026-01-06 23:43:36 +01:00
170 changed files with 19094 additions and 5254 deletions

View File

@@ -0,0 +1,47 @@
manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2
flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4
favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0
favicon.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
icons/Icon-maskable-512.png,1766235851206,adeda24772174dad916236f9385d1deaa05da836521af74912a11d217a3e18de
icons/Icon-maskable-192.png,1766235851135,fedfe0abc624a28f241f7f8e06ceab04c6c88a500290078410e1a7d12089952a
icons/Icon-512.png,1766235851087,adeda24772174dad916236f9385d1deaa05da836521af74912a11d217a3e18de
icons/Icon-192.png,1766235851013,fedfe0abc624a28f241f7f8e06ceab04c6c88a500290078410e1a7d12089952a
canvaskit/skwasm_heavy.wasm,1759914809247,509ac05ee7c60aaee61d52bad4527f40e1ce79511ca29908237472a1cd476180
canvaskit/skwasm_heavy.js.symbols,1759914809219,612ffa6a568de0500758c132cd0ea7d7c4f389157d618fe2b4255e73f3068e8f
canvaskit/skwasm_heavy.js,1759914809214,5552644d0313045f87d52097dd1e86a75f64b9e048a450ce2c885e313ed1b4c5
canvaskit/skwasm.wasm,1759914809212,85c6ff573c3f76f2d84f5553fab09bf0d0f715519c679f7626722ac0fb501640
canvaskit/skwasm.js.symbols,1759914809190,83718024df2bd4902e4c0fdfa47ea7e9ca401dcf7f31f4061c6da8478f12987f
canvaskit/skwasm.js,1759914809185,2e251855d712f083d8c6aa79bf49f6d2a8e15311f161115eb8a39bcf0688c878
canvaskit/canvaskit.wasm,1759914809134,52dedf2cd2d6bf150262bf145ffde2fc80e296d98a9d3764961eb6f84c8ce988
canvaskit/canvaskit.js.symbols,1759914809092,a3577bf24071e07f599ac61535dbee4ae4d37c5cc6ee6289379576773f9c336b
canvaskit/canvaskit.js,1759914809082,bb9141a62dec1f0a41e311b845569915df9ebb5f074dd2afc181f26b323d2dd1
canvaskit/chromium/canvaskit.wasm,1759914809184,4a868d7961a9740ae6694f62fc15b2b0ed76df50598e8311d61e8ee814d78229
canvaskit/chromium/canvaskit.js.symbols,1759914809141,f395278c466a0eaed0201edd6b14a3aa8fee0a16bfedee2d239835cd7e865472
canvaskit/chromium/canvaskit.js,1759914809136,ce5184f74e2501d849490df34d0506167a0708b9120be088039b785343335664
assets/packages/flutter_map/lib/assets/flutter_map_logo.png,1759916249804,26fe50c9203ccf93512b80d4ee1a7578184a910457b36a6a5b7d41b799efb966
assets/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d640313cd6a02692249cd41e4643c2771b4202cc84e0f07f5f65cdc77a36826f
assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f
assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee
assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703
assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1
assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c
assets/assets/logos/RectangleLogoBlack.png,1760462340000,536ebd370e55736b3622a673c684a150e23f5d3b82c71283d7a3f4a93564c02c
assets/assets/logos/LowQRectangleLogoBlack.png,1761139425319,ae4f8e428dd3634a14b45421a3c9b30fea8592ff33ff21f6962ed548e7db242b
assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb6399444016c67afe9e223fddf4ecdac1dad822198
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1768586208886,5a25871ae727f23c4b7258c34108085b8711aa94f6fcab512e0c3ca00a429a64
index.html,1768586225248,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1768586307073,4ea31c373e15f13c2916a12d9d799905af2a79ff7ed0bcceb4334707910c7721
flutter_bootstrap.js,1768586225225,e95b1b0bd493a475c8eed0e630e413d898f2ceff11cd9b24c6c564bbc2c5f5e9
assets/FontManifest.json,1768586302952,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/AssetManifest.json,1768586302952,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067
assets/AssetManifest.bin.json,1768586302952,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a
assets/AssetManifest.bin,1768586302952,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1768586306083,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1768586303187,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/fonts/MaterialIcons-Regular.otf,1768586306096,33efc485968dd28630ace587c22d6df359c195821b1114aaa85383e4d5394eac
assets/NOTICES,1768586302954,fc20c3c3c998057eb7e58ad2e009c7268bf748bfde685e95130431f4c54bd51c
main.dart.js,1768586301774,9b399ba21ab3247d46cf7dbcd5873aa248636bcd7864a1a0cedf1aae08608f9a

2
em2rp/.gitignore vendored
View File

@@ -44,4 +44,4 @@ app.*.map.json
# Environment configuration with credentials # Environment configuration with credentials
lib/config/env.dev.dart lib/config/env.dev.dart
functions/.env

37
em2rp/CHANGELOG.md Normal file
View File

@@ -0,0 +1,37 @@
# Changelog - EM2RP
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
## 🚀 Nouveautés de la mise à jour
Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :
* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.
* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.
* **Checklist de Préparation 2.0 :** L'interface de préparation a été repensée. Elle regroupe désormais les objets par conteneurs et permet de suivre visuellement les équipements manquants ou perdus à chaque étape (chargement, retour, etc.).
* **Sélecteur de Matériel Optimisé :** La recherche de matériel pour un événement est beaucoup plus rapide. Vous pouvez désormais masquer automatiquement les équipements déjà utilisés sur d'autres événements aux mêmes dates.
* **Gestion & Administration :** Affichage clair des prix HT/TTC partout dans l'application. Pour les administrateurs, l'ajout d'utilisateurs et la réinitialisation de mot de passe sont simplifiés via l'envoi d'emails automatiques.
### Ajouté
- Système de vérification automatique des mises à jour
- Dialog de notification de mise à jour avec notes de version
- Rechargement automatique du cache après mise à jour
## [1.0.0] - 2026-01-16
### Ajouté
- Scanner QR Code pour équipements et conteneurs
- Génération de QR codes pour conteneurs
- Indicateur de chargement pour génération QR
- Sections repliables dans le dialog de sélection d'équipement
- Filtrage des équipements en conflit
- Filtrage des boîtes par catégorie
### Amélioré
- Performance du dialog de sélection d'équipement
- Gestion du cache des équipements
- Interface utilisateur générale
### Corrigé
- Problème de cache avec les équipements non affichés
- Bouton de validation désactivé dans certains cas

View File

@@ -0,0 +1,337 @@
# Système de Gestion des Mises à Jour - EM2RP
## 📋 Vue d'ensemble
Ce système permet de gérer automatiquement les mises à jour de l'application web Flutter, en notifiant les utilisateurs et en forçant le rechargement du cache si nécessaire.
---
## 🔧 Architecture
### Fichiers impliqués
#### Configuration
- **`lib/config/app_version.dart`** : Fichier source de vérité pour la version
- **`web/version.json`** : Fichier déployé avec l'app pour vérification côté serveur
#### Services
- **`lib/services/update_service.dart`** : Service de vérification des mises à jour
- **`lib/views/widgets/common/update_dialog.dart`** : Widget d'affichage du dialog de mise à jour
#### Scripts
- **`scripts/increment_version.js`** : Incrémente automatiquement la version
- **`scripts/update_version_json.js`** : Génère version.json depuis app_version.dart
- **`deploy.bat`** : Script de déploiement complet
#### Documentation
- **`CHANGELOG.md`** : Notes de version (utilisées dans le dialog)
---
## 🚀 Workflow de déploiement
### 1. Développement normal
Travaillez normalement sur votre code en mode développement.
### 2. Déploiement d'une nouvelle version
```bash
deploy.bat
```
Ce script exécute automatiquement :
1. ✅ Bascule en mode PRODUCTION
2.**Incrémente la version** (0.3.8 → 0.3.9)
3.**Incrémente le buildNumber** (1 → 2)
4.**Génère version.json** depuis app_version.dart
5. ✅ Build Flutter Web
6. ✅ Déploie sur Firebase Hosting
7. ✅ Retour en mode DÉVELOPPEMENT
### 3. Mise à jour côté utilisateur
Au prochain chargement de l'app (ou après 2 secondes) :
- L'app vérifie `https://em2rp.web.app/version.json`
- Compare avec la version locale dans `app_version.dart`
- Si `buildNumber serveur > buildNumber local` → Affiche le dialog
---
## 📝 Format de version
### app_version.dart
```dart
class AppVersion {
static const String version = '0.3.8'; // Version sémantique
static const int buildNumber = 1; // Numéro de build (incrémenté automatiquement)
static String get fullVersion => 'v$version';
static String get fullVersionWithBuild => 'v$version+$buildNumber';
}
```
### version.json (déployé)
```json
{
"version": "0.3.8",
"buildNumber": 1,
"updateUrl": "https://em2rp.web.app",
"forceUpdate": false,
"releaseNotes": "• Scanner QR Code\n• Génération QR conteneurs\n• Performance améliorée"
}
```
---
## 🔄 Comparaison des versions
Le système compare uniquement le **buildNumber** :
- `buildNumber serveur > buildNumber local` → Mise à jour disponible
- Ignore les versions identiques même si la version sémantique change
**Exemple** :
- Local : `0.3.8+1`
- Serveur : `0.3.9+2`
- Résultat : Mise à jour proposée (2 > 1) ✅
---
## 🎨 Expérience utilisateur
### Mise à jour normale (forceUpdate: false)
```
┌────────────────────────────────────┐
│ 🔄 Mise à jour disponible │
├────────────────────────────────────┤
│ Version actuelle : 0.3.8 (1) │
│ Nouvelle version : 0.3.9 (2) │
│ │
│ Nouveautés : │
│ • Scanner QR Code │
│ • Performance améliorée │
│ │
│ [Plus tard] [Mettre à jour] 🔄 │
└────────────────────────────────────┘
```
### Mise à jour forcée (forceUpdate: true)
```
┌────────────────────────────────────┐
│ ⚠️ Mise à jour requise │
├────────────────────────────────────┤
│ Version actuelle : 0.3.8 (1) │
│ Nouvelle version : 0.3.9 (2) │
│ │
│ ⚠️ Cette mise à jour est │
│ obligatoire pour continuer │
│ │
│ [Mettre à jour] 🔄 │
└────────────────────────────────────┘
```
---
## 🛠️ Utilisation avancée
### Forcer une mise à jour critique
Si vous déployez un correctif critique :
1. Modifiez `web/version.json` **après le déploiement** :
```json
{
"version": "0.3.9",
"buildNumber": 2,
"forceUpdate": true, // ← Changer à true
"releaseNotes": "🔴 Correctif de sécurité important"
}
```
2. Les utilisateurs ne pourront plus fermer le dialog jusqu'à la mise à jour
### Personnaliser les notes de version
Éditez `CHANGELOG.md` avant le déploiement :
```markdown
## [0.3.9] - 2026-01-16
### Ajouté
- Scanner QR Code pour équipements
- Génération QR pour conteneurs
### Amélioré
- Performance du dialog de sélection
- Gestion du cache
### Corrigé
- Bug de cache des équipements
```
Les 5 premières lignes de la section seront utilisées dans le dialog.
---
## 🧪 Tests
### Test 1 : Vérification de version locale
```dart
// Dans n'importe quel fichier
import 'package:em2rp/config/app_version.dart';
print('Version: ${AppVersion.version}');
print('Build: ${AppVersion.buildNumber}');
print('Full: ${AppVersion.fullVersionWithBuild}');
```
### Test 2 : Forcer l'affichage du dialog
Modifiez temporairement `web/version.json` :
```json
{
"buildNumber": 999 // Très grand nombre
}
```
Rechargez l'app → Le dialog s'affiche immédiatement
### Test 3 : Tester le rechargement
1. Cliquez sur "Mettre à jour"
2. Vérifiez que la page se recharge
3. Vérifiez que le cache est vidé (nouvelles ressources chargées)
---
## 📊 Logs de debug
En mode debug, des logs sont affichés dans la console :
```
[UpdateService] Current version: 0.3.8+1
[UpdateService] Server version: 0.3.9+2
```
Si pas de mise à jour disponible, rien ne s'affiche.
---
## 🔐 Sécurité
### Headers HTTP pour forcer le non-cache
Le fichier `web/index.html` contient :
```html
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
```
### Cache-busting sur version.json
Chaque requête ajoute un timestamp :
```dart
final timestamp = DateTime.now().millisecondsSinceEpoch;
Uri.parse('$versionUrl?t=$timestamp')
```
Garantit que la version la plus récente est toujours récupérée.
---
## 🚨 Résolution de problèmes
### Problème : Le dialog ne s'affiche pas
**Causes possibles :**
1. Le `buildNumber` serveur n'est pas supérieur au local
2. Erreur réseau (timeout 10s)
3. Le fichier `version.json` n'existe pas sur le serveur
**Solution :**
```bash
# Vérifier la version déployée
curl https://em2rp.web.app/version.json
# Forcer un nouveau déploiement
deploy.bat
```
### Problème : Le cache ne se vide pas
**Causes possibles :**
1. Service Worker actif (ancienne version)
2. Cache navigateur très persistant
**Solution :**
```javascript
// Dans les DevTools du navigateur
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(r => r.unregister());
});
// Puis CTRL+SHIFT+R (rechargement forcé)
```
### Problème : Le script increment_version.js échoue
**Solution :**
```bash
# Vérifier la syntaxe du fichier app_version.dart
# Doit contenir exactement :
static const String version = '0.3.8';
static const int buildNumber = 1;
```
---
## 📈 Évolution future
### Fonctionnalités possibles
- [ ] Afficher un changelog complet dans le dialog
- [ ] Permettre de sauter une version (skip this version)
- [ ] Notifications push pour les mises à jour critiques
- [ ] Analytics sur le taux d'adoption des mises à jour
- [ ] Support des mises à jour en arrière-plan
### Améliorations techniques
- [ ] Utiliser un CDN pour version.json
- [ ] Implémenter un rollback automatique si erreur
- [ ] Ajouter une vérification de santé post-déploiement
---
## 🎯 Commandes rapides
```bash
# Déployer une nouvelle version
deploy.bat
# Incrémenter manuellement la version
node scripts\increment_version.js
# Générer version.json manuellement
node scripts\update_version_json.js
# Vérifier la version actuelle
type lib\config\app_version.dart
# Vérifier la version déployée
curl https://em2rp.web.app/version.json
```
---
## ✅ Checklist de déploiement
Avant chaque déploiement :
- [ ] Tester l'application en local
- [ ] Mettre à jour `CHANGELOG.md` avec les nouveautés
- [ ] Vérifier que tous les tests passent
- [ ] Exécuter `deploy.bat`
- [ ] Vérifier le déploiement sur https://em2rp.web.app
- [ ] Tester la mise à jour sur un navigateur propre
- [ ] Informer l'équipe de la nouvelle version
---
## 📞 Support
En cas de problème avec le système de mise à jour, vérifier :
1. Les logs dans la console du navigateur
2. Le fichier `version.json` déployé
3. Le fichier `app_version.dart` local
4. La connexion réseau de l'utilisateur
**Le système est conçu pour échouer silencieusement** : Si une erreur se produit, l'utilisateur peut continuer à utiliser l'app normalement sans être bloqué.

View File

@@ -25,6 +25,16 @@ if %ERRORLEVEL% NEQ 0 (
) )
echo. echo.
echo [1.5/4] Mise à jour du fichier version.json...
node scripts\update_version_json.js
if %ERRORLEVEL% NEQ 0 (
echo Erreur lors de la mise à jour de version.json
node scripts\toggle_env.js dev
pause
exit /b 1
)
echo.
echo [2/4] Build Flutter Web... echo [2/4] Build Flutter Web...
call flutter build web --release call flutter build web --release
if %ERRORLEVEL% NEQ 0 ( if %ERRORLEVEL% NEQ 0 (

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env pwsh
# Script de déploiement rapide - Corrections Alertes
Write-Host "=== DÉPLOIEMENT CORRECTIONS ALERTES ===" -ForegroundColor Cyan
Write-Host ""
# 1. Hot restart Flutter (si app en cours)
Write-Host "1. Hot restart recommandé (R dans le terminal Flutter)" -ForegroundColor Yellow
Write-Host ""
# 2. Pub get
Write-Host "2. Installation des dépendances..." -ForegroundColor Yellow
flutter pub get
# 3. Optionnel : Redéployer les fonctions si besoin
# Décommentez si vous avez modifié les Cloud Functions
# Write-Host "3. Déploiement Cloud Functions..." -ForegroundColor Yellow
# firebase deploy --only functions:sendAlertEmail
Write-Host ""
Write-Host "=== DÉPLOIEMENT TERMINÉ ===" -ForegroundColor Green
Write-Host ""
Write-Host "PROCHAINES ÉTAPES:" -ForegroundColor Cyan
Write-Host "1. Hot restart de l'application (R dans terminal Flutter)"
Write-Host "2. Vérifier que vous êtes connecté"
Write-Host "3. Créer un événement de test avec workforce"
Write-Host "4. Créer une alerte LOST (équipement perdu)"
Write-Host "5. Vérifier les logs (F12 → Console)"
Write-Host "6. Vérifier Firestore (Firebase Console)"
Write-Host ""
Write-Host "Voir CORRECTIONS_ALERTES_CIBLAGE.md pour détails" -ForegroundColor Yellow

View File

@@ -0,0 +1,25 @@
# Script de déploiement de la fonction onAlertCreated
Write-Host "=== Déploiement de onAlertCreated ===" -ForegroundColor Cyan
# Vérifier que nous sommes dans le bon répertoire
$currentPath = Get-Location
if ($currentPath.Path -notlike "*\em2rp") {
Write-Host "ERREUR: Ce script doit être exécuté depuis le répertoire em2rp" -ForegroundColor Red
exit 1
}
# S'assurer qu'on utilise le bon projet
Write-Host "`nVérification du projet Firebase..." -ForegroundColor Yellow
firebase use em2rp-951dc
# Déployer la fonction
Write-Host "`nDéploiement de la fonction..." -ForegroundColor Yellow
firebase deploy --only functions:onAlertCreated
if ($LASTEXITCODE -eq 0) {
Write-Host "`nDéploiement réussi!" -ForegroundColor Green
} else {
Write-Host "`nÉchec du déploiement" -ForegroundColor Red
exit 1
}

109
em2rp/deploy_backend.ps1 Normal file
View File

@@ -0,0 +1,109 @@
# Script de déploiement backend sécurisé
# Usage: .\deploy_backend.ps1 [test|prod]
param(
[Parameter(Mandatory=$true)]
[ValidateSet("test", "prod")]
[string]$mode
)
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Migration Backend - Déploiement" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Mode TEST : Lancer les émulateurs
if ($mode -eq "test") {
Write-Host "Mode: TEST (émulateurs)" -ForegroundColor Yellow
Write-Host "Lancement des émulateurs Firebase..." -ForegroundColor Yellow
Write-Host ""
firebase emulators:start
exit
}
# Mode PROD : Déploiement en production
Write-Host "Mode: PRODUCTION" -ForegroundColor Green
Write-Host ""
# Confirmation
Write-Host "ATTENTION: Vous allez déployer en PRODUCTION !" -ForegroundColor Red
$confirmation = Read-Host "Tapez 'OUI' pour confirmer"
if ($confirmation -ne "OUI") {
Write-Host "Déploiement annulé." -ForegroundColor Yellow
exit 0
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Étape 1/4 : Vérification du code" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# Vérifier que ApiConfig est en mode production
$apiConfigPath = "lib\config\api_config.dart"
$apiConfigContent = Get-Content $apiConfigPath -Raw
if ($apiConfigContent -match "isDevelopment = true") {
Write-Host "ERREUR: ApiConfig est en mode développement !" -ForegroundColor Red
Write-Host "Veuillez mettre 'isDevelopment = false' dans $apiConfigPath" -ForegroundColor Yellow
exit 1
}
Write-Host "✓ ApiConfig en mode production" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Étape 2/4 : Installation dépendances" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Push-Location functions
npm install
if ($LASTEXITCODE -ne 0) {
Write-Host "ERREUR: Installation des dépendances échouée" -ForegroundColor Red
Pop-Location
exit 1
}
Pop-Location
Write-Host "✓ Dépendances installées" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Étape 3/4 : Déploiement Cloud Functions" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
firebase deploy --only functions
if ($LASTEXITCODE -ne 0) {
Write-Host "ERREUR: Déploiement des functions échoué" -ForegroundColor Red
exit 1
}
Write-Host "✓ Cloud Functions déployées" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Étape 4/4 : Déploiement Firestore Rules" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
firebase deploy --only firestore:rules
if ($LASTEXITCODE -ne 0) {
Write-Host "ERREUR: Déploiement des règles échoué" -ForegroundColor Red
exit 1
}
Write-Host "✓ Firestore Rules déployées" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "Prochaines étapes :" -ForegroundColor Yellow
Write-Host "1. Tester les opérations CRUD (voir TESTING_PLAN.md)" -ForegroundColor Gray
Write-Host "2. Surveiller les logs: firebase functions:log" -ForegroundColor Gray
Write-Host "3. Vérifier les permissions utilisateurs" -ForegroundColor Gray
Write-Host ""
Write-Host "Console Firebase:" -ForegroundColor Cyan
Write-Host "https://console.firebase.google.com/project/em2rp-951dc/functions" -ForegroundColor Blue
Write-Host ""

View File

@@ -0,0 +1,85 @@
# Script de déploiement des règles Firestore
# Date : 15/01/2026
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " DÉPLOIEMENT RÈGLES FIRESTORE" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Vérifier que Firebase CLI est installé
Write-Host "Vérification Firebase CLI..." -ForegroundColor Yellow
$firebaseCmd = Get-Command firebase -ErrorAction SilentlyContinue
if ($null -eq $firebaseCmd) {
Write-Host "❌ Firebase CLI n'est pas installé !" -ForegroundColor Red
Write-Host ""
Write-Host "Installation requise :" -ForegroundColor Yellow
Write-Host " npm install -g firebase-tools" -ForegroundColor White
Write-Host ""
Write-Host "OU copier-coller manuellement dans Console Firebase" -ForegroundColor Yellow
exit 1
}
Write-Host "✓ Firebase CLI trouvé" -ForegroundColor Green
Write-Host ""
# Vérifier que le fichier firestore.rules existe
if (-Not (Test-Path "firestore.rules")) {
Write-Host "❌ Fichier firestore.rules introuvable !" -ForegroundColor Red
Write-Host "Vérifiez que vous êtes dans le bon répertoire" -ForegroundColor Yellow
exit 1
}
Write-Host "✓ Fichier firestore.rules trouvé" -ForegroundColor Green
Write-Host ""
# Afficher un aperçu des règles pour les alertes
Write-Host "Règles à déployer (extrait) :" -ForegroundColor Yellow
Write-Host "------------------------------" -ForegroundColor Gray
Get-Content "firestore.rules" | Select-String -Pattern "alerts" -Context 3 | Select-Object -First 10
Write-Host "------------------------------" -ForegroundColor Gray
Write-Host ""
# Demander confirmation
Write-Host "Déployer les règles Firestore ? (O/N)" -ForegroundColor Yellow -NoNewline
Write-Host " " -NoNewline
$confirmation = Read-Host
if ($confirmation -ne "O" -and $confirmation -ne "o") {
Write-Host "Déploiement annulé" -ForegroundColor Yellow
exit 0
}
Write-Host ""
Write-Host "Déploiement en cours..." -ForegroundColor Cyan
# Déployer les règles
try {
firebase deploy --only firestore:rules
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " ✅ DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "Les règles Firestore ont été déployées avec succès." -ForegroundColor White
Write-Host ""
Write-Host "Prochaines étapes :" -ForegroundColor Yellow
Write-Host " 1. Rafraîchir l'application (Ctrl+R)" -ForegroundColor White
Write-Host " 2. Créer un événement pour tester" -ForegroundColor White
Write-Host " 3. Vérifier qu'aucune erreur permission n'apparaît" -ForegroundColor White
Write-Host ""
} catch {
Write-Host ""
Write-Host "========================================" -ForegroundColor Red
Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
Write-Host ""
Write-Host "Erreur : $($_.Exception.Message)" -ForegroundColor Red
Write-Host ""
Write-Host "Solutions :" -ForegroundColor Yellow
Write-Host " 1. Vérifier connexion : firebase login" -ForegroundColor White
Write-Host " 2. Vérifier projet : firebase use" -ForegroundColor White
Write-Host " 3. OU déployer via Console Firebase" -ForegroundColor White
Write-Host ""
exit 1
}

View File

@@ -0,0 +1,66 @@
# EM2RP - Déploiement automatique du système d'alertes
# Ce script déploie les Cloud Functions et vérifie le déploiement
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " EM2RP - Déploiement Cloud Functions " -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Vérifier qu'on est dans le bon répertoire
if (-not (Test-Path ".\firebase.json")) {
Write-Host "❌ ERREUR: Vous devez lancer ce script depuis C:\src\EM2RP\em2rp\" -ForegroundColor Red
exit 1
}
# Vérifier que le fichier .env existe
if (-not (Test-Path ".\functions\.env")) {
Write-Host "❌ ERREUR: Le fichier functions\.env est manquant" -ForegroundColor Red
Write-Host " Créez ce fichier avec les identifiants SMTP" -ForegroundColor Yellow
exit 1
}
Write-Host "✅ Vérifications préliminaires OK" -ForegroundColor Green
Write-Host ""
# Déployer les fonctions
Write-Host "🚀 Déploiement des Cloud Functions en cours..." -ForegroundColor Cyan
Write-Host " (Cela peut prendre 3-5 minutes)" -ForegroundColor Gray
Write-Host ""
$deployResult = firebase deploy --only functions 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " ✅ DÉPLOIEMENT RÉUSSI" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
# Lister les fonctions déployées
Write-Host "📋 Fonctions déployées:" -ForegroundColor Cyan
firebase functions:list
Write-Host ""
Write-Host "🎯 Prochaines étapes:" -ForegroundColor Yellow
Write-Host " 1. Migrer les préférences utilisateurs: cd functions; node migrate_email_prefs.js" -ForegroundColor White
Write-Host " 2. Tester la création d'un événement avec workforce" -ForegroundColor White
Write-Host " 3. Vérifier les logs: firebase functions:log --limit 20" -ForegroundColor White
Write-Host ""
Write-Host "📚 Voir DEPLOY_NOW.md pour plus de détails" -ForegroundColor Gray
} else {
Write-Host ""
Write-Host "========================================" -ForegroundColor Red
Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
Write-Host ""
Write-Host "Erreur rencontrée:" -ForegroundColor Yellow
Write-Host $deployResult -ForegroundColor Red
Write-Host ""
Write-Host "💡 Solutions possibles:" -ForegroundColor Yellow
Write-Host " - Si 'Quota exceeded': Attendez 2 minutes et relancez" -ForegroundColor White
Write-Host " - Vérifiez que Firebase CLI est à jour: firebase --version" -ForegroundColor White
Write-Host " - Consultez les logs: firebase functions:log" -ForegroundColor White
exit 1
}

View File

@@ -1,70 +0,0 @@
# Export vers Google Calendar
## Fonctionnalité
L'application permet d'exporter un événement au format ICS (iCalendar), compatible avec Google Calendar, Apple Calendar, Outlook et la plupart des applications de calendrier.
## Utilisation
1. Ouvrir les détails d'un événement
2. Cliquer sur l'icône de calendrier 📅 dans l'en-tête
3. Le fichier `.ics` sera automatiquement téléchargé
4. Ouvrir le fichier pour l'importer dans votre application de calendrier
## Informations exportées
Le fichier ICS contient :
### Informations principales
- **Titre** : Nom de l'événement
- **Date de début** : Date et heure de début
- **Date de fin** : Date et heure de fin
- **Lieu** : Adresse de l'événement
- **Statut** : Confirmé / Annulé / En attente
### Description détaillée
- Type d'événement
- Description complète
- Jauge (nombre de personnes)
- Email de contact
- Téléphone de contact
- Temps d'installation et démontage
- Liste de la main d'œuvre
- Options sélectionnées (avec quantités)
- Prix de base
## Format du fichier
Le fichier généré suit le standard **RFC 5545** (iCalendar) et est nommé selon le format :
```
event_[nom_evenement]_[date].ics
```
Exemple : `event_Concert_Mairie_20251225.ics`
## Compatibilité
✅ Google Calendar
✅ Apple Calendar (macOS, iOS)
✅ Microsoft Outlook
✅ Thunderbird
✅ Autres applications supportant le format ICS
## Import dans Google Calendar
1. Télécharger le fichier `.ics`
2. Ouvrir Google Calendar
3. Cliquer sur l'icône ⚙️ (Paramètres)
4. Sélectionner "Importation et exportation"
5. Cliquer sur "Sélectionner un fichier sur votre ordinateur"
6. Choisir le fichier `.ics` téléchargé
7. Sélectionner le calendrier de destination
8. Cliquer sur "Importer"
## Notes techniques
- Les dates sont converties en UTC pour assurer la compatibilité internationale
- Les caractères spéciaux sont correctement échappés selon le standard ICS
- Un UID unique est généré pour chaque événement (`em2rp-[eventId]@em2rp.app`)
- Le fichier est encodé en UTF-8

View File

@@ -1,18 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//EM2RP//Event Manager//FR
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:em2rp-example123@em2rp.app
DTSTAMP:20251220T120000Z
DTSTART:20251225T190000Z
DTEND:20251225T230000Z
SUMMARY:Concert de Noël
DESCRIPTION:TYPE: Concert\n\nDESCRIPTION:\nConcert de Noël avec orchestre symphonique et chorale.\n\nJAUGE: 500 personnes\nEMAIL DE CONTACT: contact@example.com\nTÉLÉPHONE DE CONTACT: 06 12 34 56 78\n\nADRESSE: Salle des fêtes\, Place de la Mairie\, 75001 Paris\n\nINSTALLATION: 4h\nDÉMONTAGE: 2h\n\nMAIN D'ŒUVRE:\n - Jean Dupont\n - Marie Martin\n - Pierre Durand\n\nOPTIONS:\n - Système son professionnel\n - Éclairage scénique (x2)\n\nPRIX DE BASE: 2500.00€\n\n---\nGéré par EM2RP Event Manager
LOCATION:Salle des fêtes\, Place de la Mairie\, 75001 Paris
STATUS:CONFIRMED
CATEGORIES:Concert
END:VEVENT
END:VCALENDAR

View File

@@ -48,5 +48,25 @@
"destination": "/index.html" "destination": "/index.html"
} }
] ]
},
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"emulators": {
"functions": {
"port": 5051
},
"firestore": {
"port": 8088
},
"auth": {
"port": 9199
},
"ui": {
"enabled": true,
"port": 4040
},
"singleProjectMode": true
} }
} }

View File

@@ -0,0 +1,46 @@
{
"indexes": [
{
"collectionGroup": "events",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "EndDateTime",
"order": "ASCENDING"
},
{
"fieldPath": "StartDateTime",
"order": "ASCENDING"
},
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "__name__",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "events",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "StartDateTime",
"order": "ASCENDING"
},
{
"fieldPath": "EndDateTime",
"order": "ASCENDING"
}
]
}
],
"fieldOverrides": []
}

184
em2rp/firestore.rules Normal file
View File

@@ -0,0 +1,184 @@
rules_version = '2';
// ============================================================================
// RÈGLES FIRESTORE SÉCURISÉES - VERSION PRODUCTION
// ============================================================================
// Date de création : 14 janvier 2026
// Objectif : Bloquer tous les accès directs à Firestore depuis les clients
// Seules les Cloud Functions (côté serveur) peuvent lire/écrire les données
// ============================================================================
service cloud.firestore {
match /databases/{database}/documents {
// ========================================================================
// RÈGLE GLOBALE PAR DÉFAUT : TOUT BLOQUER
// ========================================================================
// Cette règle empêche tout accès direct depuis les clients (web/mobile)
// Les Cloud Functions ont un accès admin et ne sont pas affectées
match /{document=**} {
// ❌ REFUSER TOUS LES ACCÈS directs depuis les clients
allow read, write: if false;
}
// ========================================================================
// EXCEPTIONS OPTIONNELLES pour les listeners temps réel
// ========================================================================
// Si vous avez besoin de listeners en temps réel pour certaines collections,
// décommentez les règles ci-dessous.
//
// ⚠️ IMPORTANT : Ces règles permettent UNIQUEMENT la LECTURE.
// Toutes les ÉCRITURES doivent passer par les Cloud Functions.
// ========================================================================
/*
// Événements : Lecture seule pour utilisateurs authentifiés
match /events/{eventId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
// Équipements : Lecture seule pour utilisateurs authentifiés
match /equipments/{equipmentId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
// Conteneurs : Lecture seule pour utilisateurs authentifiés
match /containers/{containerId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
// Maintenances : Lecture seule pour utilisateurs authentifiés
match /maintenances/{maintenanceId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
*/
// Alertes : Lecture et création pour utilisateurs authentifiés
// Le trigger backend (onAlertCreated) s'occupe d'assigner les bonnes personnes
match /alerts/{alertId} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.createdBy == request.auth.uid; // Vérifier que l'utilisateur crée l'alerte en son nom
allow update: if request.auth != null
&& (
// L'utilisateur peut marquer comme lue uniquement s'il est assigné
(request.auth.uid in resource.data.assignedTo && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isRead', 'readAt']))
// Ou le backend peut tout modifier (processed, assignedTo, etc.)
|| !('createdBy' in resource.data) // Le trigger backend n'a pas de createdBy
);
allow delete: if request.auth != null && request.auth.uid in resource.data.assignedTo;
}
/*
// Utilisateurs : Lecture de son propre profil uniquement
match /users/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
allow write: if false; // ❌ Écriture interdite
}
// Types d'événements : Lecture seule
match /eventTypes/{typeId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
// Options : Lecture seule
match /options/{optionId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
// Clients : Lecture seule
match /customers/{customerId} {
allow read: if request.auth != null;
allow write: if false; // ❌ Écriture interdite
}
*/
// ========================================================================
// RÈGLES AVANCÉES avec vérification des permissions (OPTIONNEL)
// ========================================================================
// Décommentez ces règles si vous voulez des permissions basées sur les rôles
// pour la lecture en temps réel
//
// ⚠️ ATTENTION : Ces règles nécessitent une lecture supplémentaire dans
// la collection users, ce qui peut impacter les performances et les coûts.
// ========================================================================
/*
// Fonction helper : Récupérer les permissions de l'utilisateur
function getUserPermissions() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.permissions;
}
// Fonction helper : Vérifier si l'utilisateur a une permission
function hasPermission(permission) {
return request.auth != null && permission in getUserPermissions();
}
// Équipements : Lecture uniquement si permission view_equipment
match /equipments/{equipmentId} {
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
allow write: if false; // ❌ Écriture interdite
}
// Événements : Lecture selon permissions
match /events/{eventId} {
allow read: if hasPermission('view_events') || hasPermission('edit_event');
allow write: if false; // ❌ Écriture interdite
}
// Conteneurs : Lecture uniquement si permission view_equipment
match /containers/{containerId} {
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
allow write: if false; // ❌ Écriture interdite
}
// Maintenances : Lecture uniquement si permission view_equipment
match /maintenances/{maintenanceId} {
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
allow write: if false; // ❌ Écriture interdite
}
*/
}
}
// ============================================================================
// NOTES DE SÉCURITÉ
// ============================================================================
//
// 1. RÈGLE PAR DÉFAUT (allow read, write: if false)
// - Bloque TOUS les accès directs depuis les clients
// - Les Cloud Functions ne sont PAS affectées (elles ont un accès admin)
// - C'est la configuration la PLUS SÉCURISÉE
//
// 2. EXCEPTIONS DE LECTURE (commentées par défaut)
// - Permettent les listeners en temps réel pour certaines collections
// - UNIQUEMENT la LECTURE est autorisée
// - Les ÉCRITURES restent bloquées (doivent passer par Cloud Functions)
//
// 3. RÈGLES BASÉES SUR LES RÔLES (commentées par défaut)
// - Permettent un contrôle plus fin basé sur les permissions utilisateur
// - ⚠️ Impact sur les performances (lecture supplémentaire de la collection users)
// - À utiliser uniquement si nécessaire
//
// 4. TESTS APRÈS DÉPLOIEMENT
// - Vérifier que les Cloud Functions fonctionnent toujours
// - Tester qu'un accès direct depuis la console échoue
// - Surveiller les logs : firebase functions:log
//
// 5. ROLLBACK EN CAS DE PROBLÈME
// - Remplacer temporairement par :
// match /{document=**} {
// allow read, write: if request.auth != null;
// }
// - Déployer rapidement : firebase deploy --only firestore:rules
//
// ============================================================================

View File

@@ -1,2 +1,4 @@
node_modules/ node_modules/
*.local *.local
.env
.env.local

View File

@@ -0,0 +1,267 @@
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});
}
}
};
};
/**
* 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'}, withCors(async (req, res) => {
try {
// Vérifier l'authentification
const decodedToken = await auth.authenticateUser(req);
const data = req.body.data || req.body;
const {
type,
severity,
title,
message,
equipmentId,
eventId,
actionUrl,
metadata,
} = data;
// Validation des données
if (!type || !severity || !message) {
res.status(400).json({error: 'type, severity et message sont requis'});
return;
}
// 1. Déterminer les utilisateurs à notifier
const userIds = await determineTargetUsers(type, severity, eventId);
if (userIds.length === 0) {
res.status(400).json({error: 'Aucun utilisateur à notifier'});
return;
}
// 2. Créer l'alerte dans Firestore
const alertRef = admin.firestore().collection('alerts').doc();
const alertData = {
id: alertRef.id,
type,
severity,
title: title || getAlertTitle(type),
message,
equipmentId: equipmentId || null,
eventId: eventId || null,
actionUrl: actionUrl || null,
metadata: metadata || {},
assignedTo: userIds,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
createdBy: decodedToken.uid,
isRead: false,
emailSent: false,
status: 'ACTIVE',
};
await alertRef.set(alertData);
// 3. Envoyer les emails si alerte critique
let emailResults = {};
if (severity === 'CRITICAL') {
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
// Mettre à jour le statut d'envoi
await alertRef.update({
emailSent: true,
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
emailResults,
});
}
res.status(200).json({
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}`});
}
}));
/**
* Détermine les utilisateurs à notifier selon le type d'alerte
*/
async function determineTargetUsers(alertType, severity, eventId) {
const db = admin.firestore();
const targetUserIds = new Set();
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
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') {
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('/');
}
// Vérifier si c'est un admin (path = "roles/ADMIN")
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
targetUserIds.add(doc.id);
}
}
});
// 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();
if (eventDoc.exists) {
const event = eventDoc.data();
const workforce = event.workforce || [];
workforce.forEach((member) => {
if (member.userId) {
targetUserIds.add(member.userId);
}
});
} else {
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
}
} catch (error) {
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
}
}
return Array.from(targetUserIds);
}
/**
* Envoie les emails d'alerte à tous les utilisateurs
*/
async function sendAlertEmails(alertId, alertData, userIds) {
const results = {};
const transporter = nodemailer.createTransporter(getSmtpConfig());
// Envoyer les emails en parallèle (batch de 5)
const batches = [];
for (let i = 0; i < userIds.length; i += 5) {
batches.push(userIds.slice(i, i + 5));
}
for (const batch of batches) {
const promises = batch.map(async (userId) => {
try {
const sent = await sendSingleEmail(transporter, alertId, alertData, userId);
results[userId] = sent;
} catch (error) {
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
results[userId] = false;
}
});
await Promise.all(promises);
}
return results;
}
/**
* Envoie un email à un utilisateur spécifique
*/
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();
if (!userDoc.exists) {
return false;
}
const user = userDoc.data();
// Vérifier les préférences email
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
return false;
}
// Vérifier la préférence pour ce type d'alerte
if (!checkAlertPreference(alertData.type, prefs)) {
return false;
}
if (!user.email) {
return false;
}
try {
// Préparer les données du template
const templateData = await prepareTemplateData(alertData, user);
// Rendre le template
const html = await renderTemplate('alert-individual', templateData);
// Envoyer l'email
await transporter.sendMail({
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
to: user.email,
replyTo: EMAIL_CONFIG.replyTo,
subject: getEmailSubject(alertData),
html: html,
text: alertData.message,
});
return true;
} catch (error) {
logger.error(`[sendSingleEmail] Erreur envoi à ${userId}:`, error);
return false;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
/**
* 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');
// AJOUTER CECI : Charger le fichier de clé
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',
});
}
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');
try {
// 1. Récupérer tous les utilisateurs
const usersSnapshot = await db.collection('users').get();
console.log(`${usersSnapshot.size} utilisateurs trouvés\n`);
// 2. Préparer les updates
const updates = [];
let alreadyEnabled = 0;
let toUpdate = 0;
usersSnapshot.forEach((doc) => {
const user = doc.data();
const prefs = user.notificationPreferences || {};
// Vérifier si déjà activé
if (prefs.emailEnabled === true) {
alreadyEnabled++;
console.log(`${user.email || doc.id}: emails déjà activés`);
} else {
toUpdate++;
console.log(`${user.email || doc.id}: activation des emails`);
updates.push({
ref: doc.ref,
data: {
'notificationPreferences.emailEnabled': true,
},
});
}
});
console.log(`\n--- RÉSUMÉ ---`);
console.log(` Total utilisateurs: ${usersSnapshot.size}`);
console.log(` Déjà activés: ${alreadyEnabled}`);
console.log(` À mettre à jour: ${toUpdate}`);
// 3. Appliquer les mises à jour par batches de 500 (limite Firestore)
if (updates.length > 0) {
console.log(`\nApplication des mises à jour...`);
const batchSize = 500;
for (let i = 0; i < updates.length; i += batchSize) {
const batch = db.batch();
const currentBatch = updates.slice(i, i + batchSize);
currentBatch.forEach((update) => {
batch.update(update.ref, update.data);
});
await batch.commit();
console.log(` ✓ Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(updates.length / batchSize)} appliqué`);
}
console.log(`\n✓ Migration terminée avec succès !`);
console.log(` ${toUpdate} utilisateurs mis à jour\n`);
} else {
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
}
console.log('=== FIN MIGRATION ===');
return {
success: true,
total: usersSnapshot.size,
alreadyEnabled,
updated: toUpdate,
};
} catch (error) {
console.error('❌ ERREUR MIGRATION:', error);
throw error;
}
}
// Exécuter la migration si appelé directement
if (require.main === module) {
migrateEmailPreferences()
.then((result) => {
console.log('\n✓ Migration réussie:', result);
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Migration échouée:', error);
process.exit(1);
});
}
module.exports = { migrateEmailPreferences };

View File

@@ -0,0 +1,93 @@
/**
* Script de migration pour ajouter le champ 'id' aux équipements qui n'en ont pas
*
* Ce script parcourt tous les documents de la collection 'equipments' et ajoute
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
*/
const admin = require('firebase-admin');
const serviceAccount = require('./serviceAccountKey.json');
// Initialiser Firebase Admin
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
const db = admin.firestore();
async function migrateEquipmentIds() {
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();
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
let missingIdCount = 0;
let updatedCount = 0;
let errorCount = 0;
const batch = db.batch();
let batchCount = 0;
for (const doc of equipmentsSnapshot.docs) {
const data = doc.data();
// Vérifier si le champ 'id' est manquant ou vide
if (!data.id || data.id === '') {
missingIdCount++;
console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`);
// Ajouter au batch
batch.update(doc.ref, { id: doc.id });
batchCount++;
updatedCount++;
// Exécuter le batch tous les 500 documents (limite Firestore)
if (batchCount === 500) {
await batch.commit();
console.log(`✅ Batch de ${batchCount} documents mis à jour`);
batchCount = 0;
}
}
}
// Exécuter le dernier batch s'il reste des documents
if (batchCount > 0) {
await batch.commit();
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
}
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');
if (missingIdCount === 0) {
console.log('✅ Tous les équipements ont déjà un champ id !');
} else if (updatedCount === missingIdCount) {
console.log('✅ Migration terminée avec succès !');
} else {
console.log('⚠️ Migration terminée avec des erreurs');
}
} catch (error) {
console.error('❌ Erreur lors de la migration:', error);
throw error;
}
}
// Exécuter la migration
migrateEquipmentIds()
.then(() => {
console.log('\n✅ Script terminé');
process.exit(0);
})
.catch(error => {
console.error('\n❌ Script échoué:', error);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,14 @@
}, },
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@google-cloud/storage": "^7.18.0",
"axios": "^1.13.2",
"dotenv": "^17.2.3",
"envdot": "^0.0.3",
"firebase-admin": "^12.6.0", "firebase-admin": "^12.6.0",
"firebase-functions": "^6.0.1" "firebase-functions": "^7.0.3",
"handlebars": "^4.7.8",
"nodemailer": "^6.10.1"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.15.0", "eslint": "^8.15.0",

View File

@@ -0,0 +1,415 @@
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}, async (request) => {
try {
// L'authentification est automatique avec onCall
const {auth, data} = request;
if (!auth) {
throw new Error('L\'utilisateur doit être authentifié');
}
const {
eventId,
equipmentList, // [{equipmentId, status, quantity, etc.}]
validationType, // 'LOADING', 'UNLOADING', 'CHECK_OUT', 'CHECK_IN'
} = data;
// Validation
if (!eventId || !equipmentList || !validationType) {
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 eventDoc = await eventRef.get();
if (!eventDoc.exists) {
throw new Error('Événement introuvable');
}
const event = eventDoc.data();
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;
// Cas 1: Équipement PERDU
if (status === 'LOST') {
const alertData = await createAlertInFirestore({
type: 'LOST',
severity: 'CRITICAL',
title: 'Équipement perdu',
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
},
});
alerts.push(alertData);
}
// Cas 2: Équipement MANQUANT
if (status === 'MISSING') {
const alertData = await createAlertInFirestore({
type: 'EQUIPMENT_MISSING',
severity: 'WARNING',
title: 'Équipement manquant',
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
},
});
alerts.push(alertData);
}
// Cas 3: Quantité incorrecte
if (expectedQuantity && quantity !== expectedQuantity) {
const alertData = await createAlertInFirestore({
type: 'QUANTITY_MISMATCH',
severity: 'INFO',
title: 'Quantité incorrecte',
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
expected: expectedQuantity,
actual: quantity,
},
});
alerts.push(alertData);
}
// Cas 4: Équipement endommagé
if (status === 'DAMAGED') {
const alertData = await createAlertInFirestore({
type: 'DAMAGED',
severity: 'WARNING',
title: 'Équipement endommagé',
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
},
});
alerts.push(alertData);
}
}
// 3. Mettre à jour les équipements de l'événement
await eventRef.update({
equipment: equipmentList,
lastValidation: {
type: validationType,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
by: auth.uid,
},
});
// 4. Envoyer les notifications pour les alertes critiques
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
if (criticalAlerts.length > 0) {
for (const alert of criticalAlerts) {
try {
await sendAlertNotifications(alert, eventId);
} catch (notificationError) {
logger.error(`[processEquipmentValidation] Erreur notification alerte ${alert.id}:`, notificationError);
}
}
}
return {
success: true,
alertsCreated: alerts.length,
criticalAlertsCount: criticalAlerts.length,
alertIds: alerts.map((a) => a.id),
};
} catch (error) {
logger.error('[processEquipmentValidation] Erreur:', error);
throw error;
}
});
/**
* Crée une alerte dans Firestore
*/
async function createAlertInFirestore(alertData) {
const db = admin.firestore();
const alertRef = db.collection('alerts').doc();
const fullAlertData = {
id: alertRef.id,
...alertData,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
isRead: false,
status: 'ACTIVE',
emailSent: false,
assignedTo: [],
};
await alertRef.set(fullAlertData);
return {...fullAlertData, id: alertRef.id};
}
/**
* Détermine les utilisateurs à notifier et envoie les notifications
*/
async function sendAlertNotifications(alert, eventId) {
const db = admin.firestore();
const targetUserIds = new Set();
const usersWithPermission = new Set();
try {
// 1. Récupérer TOUS les utilisateurs et leurs permissions
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();
for (const doc of allUsersSnapshot.docs) {
const user = doc.data();
if (!user.role) {
continue;
}
// Extraire le chemin du rôle
let rolePath = '';
let roleId = '';
if (typeof user.role === 'string') {
rolePath = user.role;
roleId = user.role.split('/').pop();
} else if (user.role.path) {
rolePath = user.role.path;
roleId = user.role.path.split('/').pop();
} else if (user.role._path && user.role._path.segments) {
rolePath = user.role._path.segments.join('/');
roleId = user.role._path.segments[user.role._path.segments.length - 1];
}
if (roleId && !roleRefs.has(roleId)) {
roleRefs.set(roleId, {users: [], rolePath});
}
if (roleId) {
roleRefs.get(roleId).users.push(doc.id);
}
}
// 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();
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')) {
users.forEach((userId) => {
usersWithPermission.add(userId);
targetUserIds.add(userId);
});
}
}
} catch (error) {
logger.error(`[sendAlertNotifications] Erreur récupération rôle ${roleId}:`, error);
}
}
// 3. Ajouter la workforce de l'événement
if (eventId) {
const eventDoc = await db.collection('events').doc(eventId).get();
if (eventDoc.exists) {
const event = eventDoc.data();
const workforce = event.workforce || [];
workforce.forEach((member) => {
// Extraire l'userId selon différentes structures possibles
let userId = null;
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') {
userId = member.user;
} else if (member.user.id) {
userId = member.user.id;
}
}
if (userId) {
targetUserIds.add(userId);
}
});
}
}
const userIds = Array.from(targetUserIds);
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
await db.collection('alerts').doc(alert.id).update({
assignedTo: userIds,
});
// 5. Envoyer les emails si alerte critique
if (alert.severity === 'CRITICAL') {
await sendAlertEmails(alert, userIds);
}
return userIds;
} catch (error) {
logger.error('[sendAlertNotifications] Erreur:', error);
throw error;
}
}
/**
* Envoie les emails d'alerte
*/
async function sendAlertEmails(alert, userIds) {
try {
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é');
return 0;
}
const transporter = nodemailer.createTransport(getSmtpConfig());
let successCount = 0;
// Envoyer les emails par lots de 5
const batches = [];
for (let i = 0; i < userIds.length; i += 5) {
batches.push(userIds.slice(i, i + 5));
}
for (const batch of batches) {
const promises = batch.map(async (userId) => {
try {
// Récupérer l'utilisateur
const userDoc = await db.collection('users').doc(userId).get();
if (!userDoc.exists) {
return false;
}
const user = userDoc.data();
// Vérifier les préférences email
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
return false;
}
if (!user.email) {
return false;
}
// Préparer et envoyer l'email
let html;
try {
const templateData = await prepareTemplateData(alert, user);
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>
<p>${alert.message}</p>
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
</body>
</html>
`;
}
await transporter.sendMail({
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
to: user.email,
replyTo: EMAIL_CONFIG.replyTo,
subject: getEmailSubject(alert),
html: html,
text: alert.message,
});
return true;
} catch (error) {
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
return false;
}
});
const results = await Promise.all(promises);
successCount += results.filter((r) => r).length;
}
// Mettre à jour l'alerte
await db.collection('alerts').doc(alert.id).update({
emailSent: true,
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
emailsSentCount: successCount,
});
return successCount;
} catch (error) {
logger.error('[sendAlertEmails] Erreur globale:', error);
return 0;
}
}
/**
* 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';
}

View File

@@ -0,0 +1,277 @@
const functions = require('firebase-functions');
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 = functions.https.onCall(async (data, context) => {
// Vérifier l'authentification
if (!context.auth) {
throw new functions.https.HttpsError(
'unauthenticated',
'L\'utilisateur doit être authentifié',
);
}
const {alertId, userId, templateType} = data;
if (!alertId || !userId) {
throw new functions.https.HttpsError(
'invalid-argument',
'alertId et userId sont requis',
);
}
try {
// Récupérer l'alerte depuis Firestore
const alertDoc = await admin.firestore()
.collection('alerts')
.doc(alertId)
.get();
if (!alertDoc.exists) {
throw new functions.https.HttpsError(
'not-found',
'Alerte introuvable',
);
}
const alert = alertDoc.data();
// Récupérer l'utilisateur
const userDoc = await admin.firestore()
.collection('users')
.doc(userId)
.get();
if (!userDoc.exists) {
throw new functions.https.HttpsError(
'not-found',
'Utilisateur introuvable',
);
}
const user = userDoc.data();
// Vérifier les préférences email de l'utilisateur
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
console.log(`Email désactivé pour l'utilisateur ${userId}`);
return {success: true, skipped: true, reason: 'email_disabled'};
}
// Vérifier la préférence pour ce type d'alerte
const alertType = alert.type;
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'};
}
// Préparer les données pour le template
const templateData = await prepareTemplateData(alert, user);
// Rendre le template HTML
const html = await renderTemplate(
templateType || 'alert-individual',
templateData,
);
// Configurer le transporteur SMTP
const transporter = nodemailer.createTransporter(getSmtpConfig());
// Envoyer l'email
const info = await transporter.sendMail({
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
to: user.email,
replyTo: EMAIL_CONFIG.replyTo,
subject: getEmailSubject(alert),
html: html,
// Fallback texte brut
text: alert.message,
});
console.log('Email envoyé:', info.messageId);
// Marquer l'email comme envoyé dans l'alerte
await alertDoc.ref.update({
emailSent: true,
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
});
return {
success: true,
messageId: info.messageId,
skipped: false,
};
} catch (error) {
console.error('Erreur envoi email:', error);
throw new functions.https.HttpsError(
'internal',
`Erreur lors de l'envoi de l'email: ${error.message}`,
);
}
});
/**
* 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',
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
'STOCK_LOW': 'stockNotifications',
};
const prefKey = typeMapping[alertType];
return prefKey ? (preferences[prefKey] !== false) : true;
}
/**
* Prépare les données pour le template
*/
async function prepareTemplateData(alert, user) {
const data = {
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'}`,
appUrl: EMAIL_CONFIG.appUrl,
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
year: new Date().getFullYear(),
subject: getEmailSubject(alert),
};
// Ajouter des détails selon le type d'alerte
if (alert.eventId) {
try {
const eventDoc = await admin.firestore()
.collection('events')
.doc(alert.eventId)
.get();
if (eventDoc.exists) {
const event = eventDoc.data();
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',
});
}
}
} catch (error) {
console.error('Erreur récupération événement:', error);
}
}
if (alert.equipmentId) {
try {
const eqDoc = await admin.firestore()
.collection('equipments')
.doc(alert.equipmentId)
.get();
if (eqDoc.exists) {
data.equipmentName = eqDoc.data().name;
}
} catch (error) {
console.error('Erreur récupération équipement:', error);
}
}
return data;
}
/**
* Génère le titre de l'email selon le type d'alerte
*/
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',
};
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
}
/**
* Génère le titre pour le corps de l'email
*/
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',
};
return titles[type] || 'Nouvelle alerte';
}
/**
* Rend un template HTML avec Handlebars
*/
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');
// Lire le template de contenu
const contentPath = path.join(
__dirname,
'templates',
`${templateName}.html`,
);
const contentTemplate = await fs.readFile(contentPath, 'utf8');
// Compiler les templates
const compileContent = handlebars.compile(contentTemplate);
const compileBase = handlebars.compile(baseTemplate);
// Rendre le contenu
const renderedContent = compileContent(data);
// Rendre le template de base avec le contenu
return compileBase({
...data,
content: renderedContent,
});
} catch (error) {
console.error('Erreur rendu template:', error);
// Fallback vers un template simple
return `
<html>
<body>
<h2>${data.alertTitle}</h2>
<p>${data.alertMessage}</p>
<a href="${data.actionUrl}">Voir l'alerte</a>
</body>
</html>
`;
}
}

View File

@@ -0,0 +1,267 @@
/**
* Fonction schedulée : Envoie quotidienne d'un résumé des alertes non lues
* 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');
/**
* Fonction principale : envoie le digest quotidien
*/
async function sendDailyDigest() {
const db = admin.firestore();
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 eligibleUsers = [];
usersSnapshot.forEach((doc) => {
const user = doc.data();
const prefs = user.notificationPreferences || {};
// Vérifier si l'utilisateur a activé les emails
if (prefs.emailEnabled !== false && user.email) {
eligibleUsers.push({
uid: doc.id,
email: user.email,
firstName: user.firstName || 'Utilisateur',
lastName: user.lastName || '',
});
}
});
logger.info(`[sendDailyDigest] ${eligibleUsers.length} utilisateurs éligibles`);
// 2. Pour chaque utilisateur, récupérer ses alertes non lues des dernières 24h
const now = admin.firestore.Timestamp.now();
const yesterday = admin.firestore.Timestamp.fromMillis(now.toMillis() - 24 * 60 * 60 * 1000);
const transporter = nodemailer.createTransport(getSmtpConfig());
let emailsSent = 0;
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')
.get();
if (alertsSnapshot.empty) {
continue; // Pas d'alertes non lues pour cet utilisateur
}
const alerts = [];
alertsSnapshot.forEach((doc) => {
alerts.push({ id: doc.id, ...doc.data() });
});
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
// 3. Envoyer l'email de digest
const sent = await sendDigestEmail(transporter, user, alerts);
if (sent) {
emailsSent++;
}
} catch (error) {
logger.error(`[sendDailyDigest] Erreur pour ${user.email}:`, error);
}
}
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
return { success: true, emailsSent };
} catch (error) {
logger.error('[sendDailyDigest] Erreur globale:', error);
throw error;
}
}
/**
* Envoie l'email de digest à un utilisateur
*/
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');
// Construire le HTML
const html = buildDigestHtml(user, {
critical: criticalAlerts,
warning: warningAlerts,
info: infoAlerts,
});
// Envoyer l'email
await transporter.sendMail({
from: `"EM2RP Notifications" <${process.env.SMTP_USER}>`,
to: user.email,
subject: `📬 ${alerts.length} nouvelle(s) alerte(s) EM2RP`,
html,
});
logger.info(`[sendDigestEmail] ✓ Email envoyé à ${user.email}`);
return true;
} catch (error) {
logger.error(`[sendDigestEmail] Erreur pour ${user.email}:`, error);
return false;
}
}
/**
* Construit le HTML du digest
*/
function buildDigestHtml(user, alertsByType) {
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
let alertsHtml = '';
// Alertes critiques
if (alertsByType.critical.length > 0) {
alertsHtml += `
<div style="margin-bottom: 24px;">
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
🔴 Alertes critiques (${alertsByType.critical.length})
</h3>
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
</div>
`;
}
// Alertes warning
if (alertsByType.warning.length > 0) {
alertsHtml += `
<div style="margin-bottom: 24px;">
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
⚠️ Avertissements (${alertsByType.warning.length})
</h3>
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
</div>
`;
}
// Alertes info
if (alertsByType.info.length > 0) {
alertsHtml += `
<div style="margin-bottom: 24px;">
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
Informations (${alertsByType.info.length})
</h3>
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
</div>
`;
}
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
<!-- En-tête -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 32px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 28px;">📬 Résumé quotidien</h1>
<p style="color: rgba(255,255,255,0.9); margin: 8px 0 0 0; font-size: 16px;">
Bonjour ${user.firstName},
</p>
</div>
<!-- Contenu -->
<div style="background-color: white; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px 0;">
Vous avez <strong>${totalAlerts} nouvelle(s) alerte(s)</strong> dans les dernières 24 heures.
</p>
${alertsHtml}
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb; text-align: center;">
<a href="https://app.em2event.fr/#/alerts"
style="display: inline-block; background-color: #667eea; color: white; padding: 12px 32px; text-decoration: none; border-radius: 8px; font-weight: 600;">
Voir toutes les alertes
</a>
</div>
</div>
<!-- Pied de page -->
<div style="text-align: center; padding: 24px; color: #6b7280; font-size: 14px;">
<p style="margin: 0 0 8px 0;">EM2RP - Gestion d'événements</p>
<p style="margin: 0;">
<a href="https://app.em2event.fr/#/settings" style="color: #667eea; text-decoration: none;">
Gérer mes préférences de notification
</a>
</p>
</div>
</div>
</body>
</html>
`;
}
/**
* Formate un item d'alerte pour l'email
*/
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'
}) :
'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',
};
const typeLabel = typeLabels[alert.type] || alert.type;
return `
<div style="background-color: #f9fafb; padding: 16px; border-radius: 8px; margin-bottom: 12px; border-left: 4px solid ${getSeverityColor(alert.severity)};">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
<strong style="color: #111827; font-size: 15px;">${typeLabel}</strong>
<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'}
</p>
</div>
`;
}
/**
* Retourne la couleur selon la sévérité
*/
function getSeverityColor(severity) {
switch (severity) {
case 'CRITICAL': return '#dc2626';
case 'WARNING': return '#f59e0b';
case 'INFO': return '#3b82f6';
default: return '#6b7280';
}
}
module.exports = { sendDailyDigest };

View File

@@ -0,0 +1,107 @@
<div style="margin-bottom: 30px;">
<!-- En-tête du digest -->
<div style="margin-bottom: 25px;">
<h2 style="color: #111827; margin: 0 0 10px 0; font-size: 24px; font-weight: 600;">
📬 Votre résumé quotidien
</h2>
<p style="color: #6b7280; margin: 0; font-size: 14px;">
{{digestDate}} • {{alertCount}} nouvelle(s) alerte(s)
</p>
</div>
<!-- Message d'introduction -->
<p style="color: #374151; margin: 0 0 30px 0; font-size: 16px; line-height: 1.6;">
Bonjour <strong>{{userName}}</strong>,<br>
Voici le récapitulatif de vos alertes des dernières 24 heures.
</p>
<!-- Liste des alertes -->
{{#each alerts}}
<div style="background-color: #f9fafb; border-left: 4px solid {{#if this.isCritical}}#DC2626{{else}}#3B82F6{{/if}}; padding: 20px; margin-bottom: 15px; border-radius: 4px;">
<!-- Badge type -->
<div style="display: inline-block; padding: 4px 12px; border-radius: 12px; margin-bottom: 10px; background-color: {{#if this.isCritical}}#FEE2E2{{else}}#DBEAFE{{/if}}; color: {{#if this.isCritical}}#991B1B{{else}}#1E40AF{{/if}}; font-size: 11px; font-weight: 600; text-transform: uppercase;">
{{this.typeLabel}}
</div>
<!-- Titre de l'alerte -->
<h3 style="color: #111827; margin: 0 0 8px 0; font-size: 16px; font-weight: 600;">
{{this.title}}
</h3>
<!-- Message -->
<p style="color: #4b5563; margin: 0 0 12px 0; font-size: 14px; line-height: 1.5;">
{{this.message}}
</p>
<!-- Contexte -->
{{#if this.context}}
<p style="color: #6b7280; margin: 0; font-size: 13px;">
<strong>Contexte :</strong> {{this.context}}
</p>
{{/if}}
<!-- Timestamp -->
<p style="color: #9ca3af; margin: 8px 0 0 0; font-size: 12px;">
🕐 {{this.timestamp}}
</p>
</div>
{{/each}}
<!-- Aucune alerte -->
{{#unless alerts}}
<div style="background-color: #f0fdf4; border: 1px solid #86efac; padding: 20px; margin-bottom: 20px; border-radius: 8px; text-align: center;">
<p style="color: #166534; margin: 0; font-size: 16px;">
<strong>Aucune alerte aujourd'hui</strong><br>
<span style="font-size: 14px; color: #15803d;">Tout est en ordre !</span>
</p>
</div>
{{/unless}}
<!-- Bouton d'action principal -->
<div style="text-align: center; margin-top: 30px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 0 auto;">
<tr>
<td style="border-radius: 6px; background: #3B82F6;">
<a href="{{appUrl}}/alerts" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
Voir toutes mes alertes
</a>
</td>
</tr>
</table>
</div>
</div>
<!-- Statistiques -->
{{#if stats}}
<div style="margin-top: 30px; padding: 20px; background-color: #fef3c7; border-radius: 8px;">
<h3 style="color: #92400e; margin: 0 0 15px 0; font-size: 16px; font-weight: 600;">
📊 Vos statistiques
</h3>
<table style="width: 100%;">
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
<strong>Alertes non lues :</strong>
</td>
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
{{stats.unreadCount}}
</td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
<strong>Événements en cours :</strong>
</td>
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
{{stats.activeEvents}}
</td>
</tr>
</table>
</div>
{{/if}}
<!-- Note de bas de page -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
💡 Ce résumé est envoyé quotidiennement à 8h. Vous pouvez modifier cette préférence dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
</p>
</div>

View File

@@ -0,0 +1,81 @@
<div style="margin-bottom: 30px;">
<!-- Badge de sévérité -->
<div style="display: inline-block; padding: 8px 16px; border-radius: 20px; margin-bottom: 20px; {{#if isCritical}}background-color: #FEE2E2; color: #991B1B;{{else}}background-color: #FEF3C7; color: #92400E;{{/if}}">
<strong style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
{{#if isCritical}}🔴 Alerte Critique{{else}}⚠️ Attention{{/if}}
</strong>
</div>
<!-- Titre -->
<h2 style="color: #111827; margin: 0 0 20px 0; font-size: 24px; font-weight: 600;">
{{alertTitle}}
</h2>
<!-- Message -->
<p style="color: #374151; margin: 0 0 25px 0; font-size: 16px; line-height: 1.6;">
{{alertMessage}}
</p>
<!-- Détails de l'alerte -->
{{#if alertDetails}}
<div style="background-color: #f9fafb; border-left: 4px solid #3B82F6; padding: 16px; margin-bottom: 25px; border-radius: 4px;">
<p style="margin: 0; font-size: 14px; color: #6b7280;">
<strong style="color: #374151;">Détails :</strong><br>
{{alertDetails}}
</p>
</div>
{{/if}}
<!-- Informations contextuelles -->
{{#if eventName}}
<table style="width: 100%; margin-bottom: 25px; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
<strong style="color: #374151;">Événement :</strong>
</td>
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
{{eventName}}
</td>
</tr>
{{#if eventDate}}
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
<strong style="color: #374151;">Date :</strong>
</td>
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
{{eventDate}}
</td>
</tr>
{{/if}}
{{#if equipmentName}}
<tr>
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
<strong style="color: #374151;">Équipement :</strong>
</td>
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
{{equipmentName}}
</td>
</tr>
{{/if}}
</table>
{{/if}}
<!-- Bouton d'action -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="border-radius: 6px; {{#if isCritical}}background: #DC2626;{{else}}background: #3B82F6;{{/if}}">
<a href="{{actionUrl}}" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
{{#if isCritical}}Voir l'alerte immédiatement{{else}}Consulter les détails{{/if}}
</a>
</td>
</tr>
</table>
</div>
<!-- Note de bas de page -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
💡 <strong>Astuce :</strong> Vous pouvez gérer vos préférences de notifications dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
</p>
</div>

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{subject}}</title>
<style>
/* Reset styles */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
/* Responsive */
@media only screen and (max-width: 600px) {
.container { width: 100% !important; }
.content { padding: 20px !important; }
.button { width: 100% !important; display: block !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f3f4f6;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f3f4f6;">
<tr>
<td align="center" style="padding: 40px 0;">
<!-- Container -->
<table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td align="center" style="background: linear-gradient(135deg, #1E3A8A 0%, #3B82F6 100%); padding: 30px; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: bold;">
EM2 Events
</h1>
<p style="color: #E0E7FF; margin: 8px 0 0 0; font-size: 14px;">
Gestion d'événements professionnelle
</p>
</td>
</tr>
<!-- Content -->
<tr>
<td class="content" style="padding: 40px 30px;">
{{{content}}}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 15px 0; font-size: 13px; color: #6b7280; text-align: center;">
Cet email a été envoyé automatiquement par EM2 Events
</p>
<p style="margin: 15px 0 0 0; font-size: 11px; color: #9ca3af; text-align: center;">
© {{year}} EM2 Events. Tous droits réservés.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,165 @@
/**
* Utilitaires d'authentification et d'autorisation
*/
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');
}
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');
}
}
/**
* Récupère les données utilisateur depuis Firestore
*/
async function getUserData(uid) {
const userDoc = await admin.firestore().collection('users').doc(uid).get();
if (!userDoc.exists) {
return null;
}
return { uid, ...userDoc.data() };
}
/**
* Récupère les permissions d'un rôle
*/
async function getRolePermissions(roleRef) {
if (!roleRef) return [];
let roleId;
if (typeof roleRef === 'string') {
roleId = roleRef;
} else if (roleRef.id) {
roleId = roleRef.id;
} else {
return [];
}
const roleDoc = await admin.firestore().collection('roles').doc(roleId).get();
if (!roleDoc.exists) return [];
return roleDoc.data().permissions || [];
}
/**
* Vérifie si l'utilisateur a une permission spécifique
*/
async function hasPermission(uid, requiredPermission) {
const userData = await getUserData(uid);
if (!userData) return false;
const permissions = await getRolePermissions(userData.role);
return permissions.includes(requiredPermission);
}
/**
* Vérifie si l'utilisateur est admin
*/
async function isAdmin(uid) {
const userData = await getUserData(uid);
if (!userData) return false;
let roleId;
const roleField = userData.role;
if (typeof roleField === 'string') {
roleId = roleField;
} else if (roleField && roleField.id) {
roleId = roleField.id;
} else {
return false;
}
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();
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;
if (ref && ref.id) return ref.id === uid;
return false;
});
}
/**
* Middleware d'authentification pour les Cloud Functions HTTP
*/
async function authMiddleware(req, res, next) {
try {
const decodedToken = await authenticateUser(req);
req.user = decodedToken;
req.uid = decodedToken.uid;
next();
} catch (error) {
res.status(401).json({ error: error.message });
}
}
/**
* Middleware de vérification de permission
*/
function requirePermission(permission) {
return async (req, res, next) => {
try {
const hasAccess = await hasPermission(req.uid, permission);
if (!hasAccess) {
res.status(403).json({ error: `Forbidden: Requires permission '${permission}'` });
return;
}
next();
} catch (error) {
res.status(403).json({ error: error.message });
}
};
}
/**
* Middleware admin uniquement
*/
async function requireAdmin(req, res, next) {
try {
const adminAccess = await isAdmin(req.uid);
if (!adminAccess) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
return;
}
next();
} catch (error) {
res.status(403).json({ error: error.message });
}
}
module.exports = {
authenticateUser,
getUserData,
getRolePermissions,
hasPermission,
isAdmin,
isAssignedToEvent,
authMiddleware,
requirePermission,
requireAdmin,
};

View File

@@ -0,0 +1,39 @@
/**
* Configuration SMTP pour l'envoi d'emails
* Les credentials sont stockés dans les variables d'environnement
*/
// Configuration SMTP depuis les variables d'environnement
// 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'),
secure: true, // true pour port 465, false pour autres ports
auth: {
user: process.env.SMTP_USER || 'notify@em2events.fr',
pass: process.env.SMTP_PASS || '',
},
tls: {
// Ne pas échouer sur certificats invalides
rejectUnauthorized: false,
},
};
};
// Configuration email par défaut
const EMAIL_CONFIG = {
from: {
name: 'EM2 Events',
address: 'notify@em2events.fr',
},
replyTo: 'contact@em2events.fr',
// URL de l'application pour les liens
appUrl: process.env.APP_URL || 'https://em2rp-951dc.web.app',
};
module.exports = {
getSmtpConfig,
EMAIL_CONFIG,
};

View File

@@ -0,0 +1,177 @@
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',
};
const prefKey = typeMapping[alertType];
return prefKey ? (preferences[prefKey] !== false) : true;
}
/**
* Prépare les données pour le template
*/
async function prepareTemplateData(alert, user) {
const data = {
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'}`,
appUrl: EMAIL_CONFIG.appUrl,
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
year: new Date().getFullYear(),
subject: getEmailSubject(alert),
};
// Ajouter des détails selon le type d'alerte
if (alert.eventId) {
try {
const eventDoc = await admin.firestore()
.collection('events')
.doc(alert.eventId)
.get();
if (eventDoc.exists) {
const event = eventDoc.data();
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',
});
}
}
} catch (error) {
// Ignorer silencieusement
}
}
if (alert.equipmentId) {
try {
const eqDoc = await admin.firestore()
.collection('equipments')
.doc(alert.equipmentId)
.get();
if (eqDoc.exists) {
data.equipmentName = eqDoc.data().name;
}
} catch (error) {
// Ignorer silencieusement
}
}
return data;
}
/**
* Génère le titre de l'email selon le type d'alerte
*/
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',
};
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
}
/**
* Génère le titre pour le corps de l'email
*/
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',
};
return titles[type] || 'Nouvelle alerte';
}
/**
* Rend un template HTML avec Handlebars
*/
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');
// Lire le template de contenu
const contentPath = path.join(
__dirname,
'..',
'templates',
`${templateName}.html`,
);
const contentTemplate = await fs.readFile(contentPath, 'utf8');
// Compiler les templates
const compileContent = handlebars.compile(contentTemplate);
const compileBase = handlebars.compile(baseTemplate);
// Rendre le contenu
const renderedContent = compileContent(data);
// Rendre le template de base avec le contenu
return compileBase({
...data,
content: renderedContent,
});
} catch (error) {
// Fallback vers un template simple
return `
<html>
<body>
<h2>${data.alertTitle}</h2>
<p>${data.alertMessage}</p>
<a href="${data.actionUrl}">Voir l'alerte</a>
</body>
</html>
`;
}
}
module.exports = {
checkAlertPreference,
prepareTemplateData,
getEmailSubject,
getAlertTitle,
renderTemplate,
};

View File

@@ -0,0 +1,191 @@
/**
* Helpers pour la manipulation de données Firestore
*/
const admin = require('firebase-admin');
/**
* Convertit les Timestamps Firestore en ISO strings pour JSON
*/
function serializeTimestamps(data) {
if (!data) return data;
// Éviter la récursion sur les types Firestore spéciaux
if (data._firestore || data._path || data._converter) {
// C'est un objet Firestore interne, ne pas le traiter
if (data.id && data.path) {
// C'est une DocumentReference
return data.path;
}
return null;
}
const result = { ...data };
for (const key in result) {
const value = result[key];
if (!value) {
continue;
}
// Gérer les Timestamps Firestore
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') {
result[key] = value.path;
}
// Gérer les GeoPoint
else if (value.latitude !== undefined && value.longitude !== undefined) {
result[key] = {
latitude: value.latitude,
longitude: value.longitude
};
}
// Gérer les tableaux
else if (Array.isArray(value)) {
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') {
return item.toDate().toISOString();
}
// Objet normal
return serializeTimestamps(item);
});
}
// Gérer les objets imbriqués (mais pas les objets Firestore)
else if (typeof value === 'object' && !value._firestore && !value._path) {
result[key] = serializeTimestamps(value);
}
}
return result;
}
/**
* Convertit les ISO strings en Timestamps Firestore
*/
function deserializeTimestamps(data, timestampFields = []) {
if (!data) return data;
const result = { ...data };
for (const field of timestampFields) {
if (result[field] && typeof result[field] === 'string') {
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
}
}
return result;
}
/**
* Convertit les références DocumentReference en IDs
*/
function serializeReferences(data) {
if (!data) return data;
const result = { ...data };
for (const key in result) {
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') {
return item.id;
}
return item;
});
}
}
return result;
}
/**
* Masque les champs sensibles selon les permissions
*/
function maskSensitiveFields(data, canViewSensitive) {
if (canViewSensitive) return data;
const masked = { ...data };
// Masquer les prix si pas de permission manage_equipment
delete masked.purchasePrice;
delete masked.rentalPrice;
return masked;
}
/**
* Pagination helper
*/
function paginate(query, limit = 50, startAfter = null) {
let paginatedQuery = query.limit(limit);
if (startAfter) {
paginatedQuery = paginatedQuery.startAfter(startAfter);
}
return paginatedQuery;
}
/**
* Filtre les événements annulés
*/
function filterCancelledEvents(events) {
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
*/
function convertIdsToReferences(data) {
if (!data) return 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);
}
// Convertir customer (ID → DocumentReference)
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);
}
return item;
});
}
return result;
}
module.exports = {
serializeTimestamps,
deserializeTimestamps,
serializeReferences,
maskSensitiveFields,
paginate,
filterCancelledEvents,
convertIdsToReferences,
};

View File

@@ -0,0 +1,19 @@
/// Configuration de l'API backend
class ApiConfig {
// Mode développement : utilise les émulateurs locaux
static const bool isDevelopment = false; // false = utilise Cloud Functions prod
// URL de base pour les Cloud Functions
static const String productionUrl = 'https://us-central1-em2rp-951dc.cloudfunctions.net';
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/us-central1';
/// Retourne l'URL de base selon l'environnement
static String get baseUrl => isDevelopment ? developmentUrl : productionUrl;
/// Configuration du timeout
static const Duration requestTimeout = Duration(seconds: 30);
/// Nombre de tentatives en cas d'échec
static const int maxRetries = 3;
}

View File

@@ -1,10 +1,11 @@
/// Configuration de la version de l'application /// Configuration de la version de l'application
class AppVersion { class AppVersion {
static const String version = '0.3.7'; static const String version = '1.0.4';
/// Retourne la version complète de l'application /// Retourne la version complète de l'application
static String get fullVersion => 'v$version'; static String get fullVersion => 'v$version';
/// Retourne la version avec un préfixe personnalisé /// Retourne la version avec un préfixe personnalisé
static String getVersionWithPrefix(String prefix) => '$prefix $version'; static String getVersionWithPrefix(String prefix) => '$prefix $version';
} }

View File

@@ -5,6 +5,8 @@ import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/event_type_model.dart'; import 'package:em2rp/models/event_type_model.dart';
import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/services/event_form_service.dart'; import 'package:em2rp/services/event_form_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/providers/local_user_provider.dart';
@@ -125,7 +127,14 @@ class EventFormController extends ChangeNotifier {
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment); _assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
_assignedContainers = List<String>.from(event.assignedContainers); _assignedContainers = List<String>.from(event.assignedContainers);
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null; _selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
_selectedUserIds = event.workforce.map((ref) => ref.id).toList();
// Gérer workforce qui peut contenir String ou DocumentReference
_selectedUserIds = event.workforce.map((ref) {
if (ref is String) return ref;
if (ref is DocumentReference) return ref.id;
return '';
}).where((id) => id.isNotEmpty).toList();
_uploadedFiles = List<Map<String, String>>.from(event.documents); _uploadedFiles = List<Map<String, String>>.from(event.documents);
_selectedOptions = List<Map<String, dynamic>>.from(event.options); _selectedOptions = List<Map<String, dynamic>>.from(event.options);
_selectedStatus = event.status; _selectedStatus = event.status;
@@ -183,15 +192,15 @@ class EventFormController extends ChangeNotifier {
if (newTypeId != null) { if (newTypeId != null) {
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId); final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
// Utiliser le prix par défaut du type d'événement // Utiliser le prix par défaut du type d'événement (prix TTC stocké dans basePrice)
final defaultPrice = selectedType.defaultPrice; final defaultPriceTTC = selectedType.defaultPrice;
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.')); final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
final oldDefaultPrice = oldEventType?.defaultPrice; final oldDefaultPrice = oldEventType?.defaultPrice;
// Mettre à jour le prix si le champ est vide ou si c'était l'ancien prix par défaut // Mettre à jour le prix TTC si le champ est vide ou si c'était l'ancien prix par défaut
if (basePriceController.text.isEmpty || if (basePriceController.text.isEmpty ||
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) { (currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
basePriceController.text = defaultPrice.toStringAsFixed(2); basePriceController.text = defaultPriceTTC.toStringAsFixed(2);
} }
// Filtrer les options qui ne sont plus compatibles avec le nouveau type // Filtrer les options qui ne sont plus compatibles avec le nouveau type
@@ -285,7 +294,7 @@ class EventFormController extends ChangeNotifier {
try { try {
final eventTypeRef = _selectedEventTypeId != null final eventTypeRef = _selectedEventTypeId != null
? FirebaseFirestore.instance.collection('eventTypes').doc(_selectedEventTypeId) ? null // Les références Firestore ne sont plus nécessaires, l'ID suffit
: null; : null;
if (existingEvent != null) { if (existingEvent != null) {
@@ -325,9 +334,8 @@ class EventFormController extends ChangeNotifier {
eventTypeRef: eventTypeRef, eventTypeRef: eventTypeRef,
customerId: existingEvent.customerId, customerId: existingEvent.customerId,
address: addressController.text.trim(), address: addressController.text.trim(),
workforce: _selectedUserIds // Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
.map((id) => FirebaseFirestore.instance.collection('users').doc(id)) workforce: _selectedUserIds,
.toList(),
latitude: existingEvent.latitude, latitude: existingEvent.latitude,
longitude: existingEvent.longitude, longitude: existingEvent.longitude,
documents: finalDocuments, documents: finalDocuments,
@@ -370,9 +378,8 @@ class EventFormController extends ChangeNotifier {
eventTypeRef: eventTypeRef, eventTypeRef: eventTypeRef,
customerId: '', customerId: '',
address: addressController.text.trim(), address: addressController.text.trim(),
workforce: _selectedUserIds // Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
.map((id) => FirebaseFirestore.instance.collection('users').doc(id)) workforce: _selectedUserIds,
.toList(),
latitude: 0.0, latitude: 0.0,
longitude: 0.0, longitude: 0.0,
documents: _uploadedFiles, documents: _uploadedFiles,
@@ -386,8 +393,14 @@ class EventFormController extends ChangeNotifier {
); );
final eventId = await EventFormService.createEvent(newEvent); final eventId = await EventFormService.createEvent(newEvent);
// Déplacer et mettre à jour les fichiers uniquement s'il y en a
if (_uploadedFiles.isNotEmpty) {
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId); final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
if (newFiles.isNotEmpty) {
await EventFormService.updateEventDocuments(eventId, newFiles); await EventFormService.updateEventDocuments(eventId, newFiles);
}
}
// Reload events // Reload events
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false); final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
@@ -422,8 +435,9 @@ class EventFormController extends ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
// Supprimer l'événement de Firestore // Supprimer l'événement via l'API
await FirebaseFirestore.instance.collection('events').doc(eventId).delete(); final dataService = DataService(FirebaseFunctionsApiService());
await dataService.deleteEvent(eventId);
// Recharger la liste des événements // Recharger la liste des événements
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false); final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);

View File

@@ -5,6 +5,7 @@ import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/maintenance_provider.dart'; import 'package:em2rp/providers/maintenance_provider.dart';
import 'package:em2rp/providers/alert_provider.dart'; import 'package:em2rp/providers/alert_provider.dart';
import 'package:em2rp/utils/auth_guard_widget.dart'; import 'package:em2rp/utils/auth_guard_widget.dart';
import 'package:em2rp/views/alerts_page.dart';
import 'package:em2rp/views/calendar_page.dart'; import 'package:em2rp/views/calendar_page.dart';
import 'package:em2rp/views/login_page.dart'; import 'package:em2rp/views/login_page.dart';
import 'package:em2rp/views/equipment_management_page.dart'; import 'package:em2rp/views/equipment_management_page.dart';
@@ -15,6 +16,7 @@ import 'package:em2rp/views/event_preparation_page.dart';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
@@ -23,45 +25,61 @@ import 'views/my_account_page.dart';
import 'views/user_management_page.dart'; import 'views/user_management_page.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'providers/local_user_provider.dart'; import 'providers/local_user_provider.dart';
import 'services/user_service.dart';
import 'views/reset_password_page.dart'; import 'views/reset_password_page.dart';
import 'config/env.dart'; import 'config/env.dart';
import 'config/api_config.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'views/widgets/common/update_dialog.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp( await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform, 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');
}
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL); await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
// Injection du service UserService
Provider<UserService>(create: (_) => UserService()),
// LocalUserProvider pour la gestion de l'authentification // LocalUserProvider pour la gestion de l'authentification
ChangeNotifierProvider<LocalUserProvider>( ChangeNotifierProvider<LocalUserProvider>(
create: (context) => LocalUserProvider()), create: (context) => LocalUserProvider()),
// Injection des Providers en utilisant UserService // UsersProvider migré vers l'API
ChangeNotifierProvider<UsersProvider>( ChangeNotifierProvider<UsersProvider>(
create: (context) => UsersProvider(context.read<UserService>()), create: (context) => UsersProvider(),
), ),
// EventProvider pour la gestion des événements // EventProvider migré vers l'API
ChangeNotifierProvider<EventProvider>( ChangeNotifierProvider<EventProvider>(
create: (context) => EventProvider(), create: (context) => EventProvider(),
), ),
// Providers pour la gestion du matériel // EquipmentProvider migré vers l'API
ChangeNotifierProvider<EquipmentProvider>( ChangeNotifierProvider<EquipmentProvider>(
create: (context) => EquipmentProvider(), create: (context) => EquipmentProvider(),
), ),
// ContainerProvider migré vers l'API
ChangeNotifierProvider<ContainerProvider>( ChangeNotifierProvider<ContainerProvider>(
create: (context) => ContainerProvider(), create: (context) => ContainerProvider(),
), ),
// MaintenanceProvider migré vers l'API
ChangeNotifierProvider<MaintenanceProvider>( ChangeNotifierProvider<MaintenanceProvider>(
create: (context) => MaintenanceProvider(), create: (context) => MaintenanceProvider(),
), ),
@@ -79,8 +97,9 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return UpdateChecker(
title: 'EM2 ERP', child: MaterialApp(
title: 'EM2 Hub',
theme: ThemeData( theme: ThemeData(
primarySwatch: Colors.red, primarySwatch: Colors.red,
primaryColor: AppColors.noir, primaryColor: AppColors.noir,
@@ -115,9 +134,11 @@ class MyApp extends StatelessWidget {
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
home: const AutoLoginWrapper(), initialRoute: '/',
routes: { routes: {
'/': (context) => const AutoLoginWrapper(),
'/login': (context) => const LoginPage(), '/login': (context) => const LoginPage(),
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
'/calendar': (context) => const AuthGuard(child: CalendarPage()), '/calendar': (context) => const AuthGuard(child: CalendarPage()),
'/my_account': (context) => const AuthGuard(child: MyAccountPage()), '/my_account': (context) => const AuthGuard(child: MyAccountPage()),
'/user_management': (context) => const AuthGuard( '/user_management': (context) => const AuthGuard(
@@ -162,6 +183,7 @@ class MyApp extends StatelessWidget {
); );
}, },
}, },
),
); );
} }
} }
@@ -198,8 +220,23 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
await localAuthProvider.loadUserData(); await localAuthProvider.loadUserData();
if (mounted) { 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');
// Si une route spécifique est demandée (autre que / ou vide)
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'); Navigator.of(context).pushReplacementNamed('/calendar');
} }
}
} catch (e) { } catch (e) {
print('Auto login failed: $e'); print('Auto login failed: $e');
if (mounted) { if (mounted) {

View File

@@ -1,9 +1,27 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
/// Type d'alerte
enum AlertType { enum AlertType {
lowStock, // Stock faible lowStock, // Stock faible
maintenanceDue, // Maintenance à venir maintenanceDue, // Maintenance à venir
conflict // Conflit disponibilité conflict, // Conflit disponibilité
lost, // Équipement perdu
eventCreated, // Événement créé
eventModified, // Événement modifié
eventCancelled, // Événement annulé
eventAssigned, // Assigné à un événement
maintenanceReminder, // Rappel maintenance périodique
equipmentMissing, // Équipement manquant à une étape
quantityMismatch, // Quantité incorrecte
damaged, // Équipement endommagé
workforceAdded, // Ajouté à la workforce d'un événement
}
/// Gravité de l'alerte
enum AlertSeverity {
info, // Information (bleu)
warning, // Avertissement (orange)
critical, // Critique (rouge)
} }
String alertTypeToString(AlertType type) { String alertTypeToString(AlertType type) {
@@ -14,6 +32,26 @@ String alertTypeToString(AlertType type) {
return 'MAINTENANCE_DUE'; return 'MAINTENANCE_DUE';
case AlertType.conflict: case AlertType.conflict:
return 'CONFLICT'; return 'CONFLICT';
case AlertType.lost:
return 'LOST';
case AlertType.eventCreated:
return 'EVENT_CREATED';
case AlertType.eventModified:
return 'EVENT_MODIFIED';
case AlertType.eventCancelled:
return 'EVENT_CANCELLED';
case AlertType.eventAssigned:
return 'EVENT_ASSIGNED';
case AlertType.maintenanceReminder:
return 'MAINTENANCE_REMINDER';
case AlertType.equipmentMissing:
return 'EQUIPMENT_MISSING';
case AlertType.quantityMismatch:
return 'QUANTITY_MISMATCH';
case AlertType.damaged:
return 'DAMAGED';
case AlertType.workforceAdded:
return 'WORKFORCE_ADDED';
} }
} }
@@ -25,65 +63,211 @@ AlertType alertTypeFromString(String? type) {
return AlertType.maintenanceDue; return AlertType.maintenanceDue;
case 'CONFLICT': case 'CONFLICT':
return AlertType.conflict; return AlertType.conflict;
case 'LOST':
return AlertType.lost;
case 'EVENT_CREATED':
return AlertType.eventCreated;
case 'EVENT_MODIFIED':
return AlertType.eventModified;
case 'EVENT_CANCELLED':
return AlertType.eventCancelled;
case 'EVENT_ASSIGNED':
return AlertType.eventAssigned;
case 'MAINTENANCE_REMINDER':
return AlertType.maintenanceReminder;
case 'EQUIPMENT_MISSING':
return AlertType.equipmentMissing;
case 'QUANTITY_MISMATCH':
return AlertType.quantityMismatch;
case 'DAMAGED':
return AlertType.damaged;
case 'WORKFORCE_ADDED':
return AlertType.workforceAdded;
default: default:
return AlertType.conflict; return AlertType.conflict;
} }
} }
String alertSeverityToString(AlertSeverity severity) {
switch (severity) {
case AlertSeverity.info:
return 'INFO';
case AlertSeverity.warning:
return 'WARNING';
case AlertSeverity.critical:
return 'CRITICAL';
}
}
AlertSeverity alertSeverityFromString(String? severity) {
switch (severity) {
case 'INFO':
return AlertSeverity.info;
case 'WARNING':
return AlertSeverity.warning;
case 'CRITICAL':
return AlertSeverity.critical;
default:
return AlertSeverity.info;
}
}
class AlertModel { class AlertModel {
final String id; // ID généré automatiquement final String id; // ID généré automatiquement
final AlertType type; // Type d'alerte final AlertType type; // Type d'alerte
final AlertSeverity severity; // Gravité de l'alerte
final String message; // Message de l'alerte final String message; // Message de l'alerte
final List<String> assignedToUserIds; // Utilisateurs concernés
final String? eventId; // ID de l'événement concerné (optionnel)
final String? equipmentId; // ID de l'équipement concerné (optionnel) final String? equipmentId; // ID de l'équipement concerné (optionnel)
final String? createdByUserId; // Qui a déclenché l'alerte
final DateTime createdAt; // Date de création final DateTime createdAt; // Date de création
final DateTime? dueDate; // Date d'échéance (pour maintenance)
final String? actionUrl; // URL de redirection (deep link)
final bool isRead; // Statut lu/non lu final bool isRead; // Statut lu/non lu
final bool isResolved; // Résolue ou non
final String? resolution; // Message de résolution
final DateTime? resolvedAt; // Date de résolution
final String? resolvedByUserId; // Qui a résolu
AlertModel({ AlertModel({
required this.id, required this.id,
required this.type, required this.type,
this.severity = AlertSeverity.info,
required this.message, required this.message,
this.assignedToUserIds = const [],
this.eventId,
this.equipmentId, this.equipmentId,
this.createdByUserId,
required this.createdAt, required this.createdAt,
this.dueDate,
this.actionUrl,
this.isRead = false, this.isRead = false,
this.isResolved = false,
this.resolution,
this.resolvedAt,
this.resolvedByUserId,
}); });
factory AlertModel.fromMap(Map<String, dynamic> map, String id) { factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime _parseDate(dynamic value) {
if (value == null) return DateTime.now();
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
return DateTime.now();
}
// Parser les assignedToUserIds (peut être List ou null)
List<String> parseUserIds(dynamic value) {
if (value == null) return [];
if (value is List) return value.map((e) => e.toString()).toList();
return [];
}
return AlertModel( return AlertModel(
id: id, id: id,
type: alertTypeFromString(map['type']), type: alertTypeFromString(map['type']),
severity: alertSeverityFromString(map['severity']),
message: map['message'] ?? '', message: map['message'] ?? '',
assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']),
eventId: map['eventId'],
equipmentId: map['equipmentId'], equipmentId: map['equipmentId'],
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), createdByUserId: map['createdByUserId'] ?? map['createdBy'],
createdAt: _parseDate(map['createdAt']),
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
actionUrl: map['actionUrl'],
isRead: map['isRead'] ?? false, isRead: map['isRead'] ?? false,
isResolved: map['isResolved'] ?? false,
resolution: map['resolution'],
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null,
resolvedByUserId: map['resolvedByUserId'],
); );
} }
/// Factory depuis un document Firestore
factory AlertModel.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>?;
if (data == null) {
throw Exception('Document vide: ${doc.id}');
}
return AlertModel.fromMap(data, doc.id);
}
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'type': alertTypeToString(type), 'type': alertTypeToString(type),
'severity': alertSeverityToString(severity),
'message': message, 'message': message,
'equipmentId': equipmentId, 'assignedToUserIds': assignedToUserIds,
if (eventId != null) 'eventId': eventId,
if (equipmentId != null) 'equipmentId': equipmentId,
if (createdByUserId != null) 'createdByUserId': createdByUserId,
'createdAt': Timestamp.fromDate(createdAt), 'createdAt': Timestamp.fromDate(createdAt),
if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!),
if (actionUrl != null) 'actionUrl': actionUrl,
'isRead': isRead, 'isRead': isRead,
'isResolved': isResolved,
if (resolution != null) 'resolution': resolution,
if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!),
if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId,
}; };
} }
AlertModel copyWith({ AlertModel copyWith({
String? id, String? id,
AlertType? type, AlertType? type,
AlertSeverity? severity,
String? message, String? message,
List<String>? assignedToUserIds,
String? eventId,
String? equipmentId, String? equipmentId,
String? createdByUserId,
DateTime? createdAt, DateTime? createdAt,
DateTime? dueDate,
String? actionUrl,
bool? isRead, bool? isRead,
bool? isResolved,
String? resolution,
DateTime? resolvedAt,
String? resolvedByUserId,
}) { }) {
return AlertModel( return AlertModel(
id: id ?? this.id, id: id ?? this.id,
type: type ?? this.type, type: type ?? this.type,
severity: severity ?? this.severity,
message: message ?? this.message, message: message ?? this.message,
assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds,
eventId: eventId ?? this.eventId,
equipmentId: equipmentId ?? this.equipmentId, equipmentId: equipmentId ?? this.equipmentId,
createdByUserId: createdByUserId ?? this.createdByUserId,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
dueDate: dueDate ?? this.dueDate,
actionUrl: actionUrl ?? this.actionUrl,
isRead: isRead ?? this.isRead, isRead: isRead ?? this.isRead,
isResolved: isResolved ?? this.isResolved,
resolution: resolution ?? this.resolution,
resolvedAt: resolvedAt ?? this.resolvedAt,
resolvedByUserId: resolvedByUserId ?? this.resolvedByUserId,
); );
} }
/// Helper : Retourne true si l'alerte est pour un événement
bool get isEventAlert =>
type == AlertType.eventCreated ||
type == AlertType.eventModified ||
type == AlertType.eventCancelled ||
type == AlertType.eventAssigned;
/// Helper : Retourne true si l'alerte est pour la maintenance
bool get isMaintenanceAlert =>
type == AlertType.maintenanceDue ||
type == AlertType.maintenanceReminder;
/// Helper : Retourne true si l'alerte est pour un équipement
bool get isEquipmentAlert =>
type == AlertType.lost ||
type == AlertType.equipmentMissing ||
type == AlertType.lowStock;
} }

View File

@@ -242,6 +242,14 @@ class ContainerModel {
/// Factory depuis Firestore /// Factory depuis Firestore
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) { factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO 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);
return null;
}
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? []; final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList(); final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
@@ -262,8 +270,8 @@ class ContainerModel {
equipmentIds: equipmentIds, equipmentIds: equipmentIds,
eventId: map['eventId'], eventId: map['eventId'],
notes: map['notes'], notes: map['notes'],
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(), updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
history: history, history: history,
); );
} }
@@ -342,8 +350,16 @@ class ContainerHistoryEntry {
}); });
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) { factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
// Helper pour parser la date
DateTime _parseDate(dynamic value) {
if (value == null) return DateTime.now();
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
return DateTime.now();
}
return ContainerHistoryEntry( return ContainerHistoryEntry(
timestamp: (map['timestamp'] as Timestamp?)?.toDate() ?? DateTime.now(), timestamp: _parseDate(map['timestamp']),
action: map['action'] ?? '', action: map['action'] ?? '',
equipmentId: map['equipmentId'], equipmentId: map['equipmentId'],
previousValue: map['previousValue'], previousValue: map['previousValue'],

View File

@@ -55,6 +55,8 @@ enum EquipmentCategory {
structure, // Structure structure, // Structure
consumable, // Consommable consumable, // Consommable
cable, // Câble cable, // Câble
vehicle, // Véhicule
backline, // Régie / Backline
other // Autre other // Autre
} }
@@ -72,6 +74,10 @@ String equipmentCategoryToString(EquipmentCategory category) {
return 'CONSUMABLE'; return 'CONSUMABLE';
case EquipmentCategory.cable: case EquipmentCategory.cable:
return 'CABLE'; return 'CABLE';
case EquipmentCategory.vehicle:
return 'VEHICLE';
case EquipmentCategory.backline:
return 'BACKLINE';
case EquipmentCategory.other: case EquipmentCategory.other:
return 'OTHER'; return 'OTHER';
case EquipmentCategory.effect: case EquipmentCategory.effect:
@@ -93,6 +99,10 @@ EquipmentCategory equipmentCategoryFromString(String? category) {
return EquipmentCategory.consumable; return EquipmentCategory.consumable;
case 'CABLE': case 'CABLE':
return EquipmentCategory.cable; return EquipmentCategory.cable;
case 'VEHICLE':
return EquipmentCategory.vehicle;
case 'BACKLINE':
return EquipmentCategory.backline;
case 'EFFECT': case 'EFFECT':
return EquipmentCategory.effect; return EquipmentCategory.effect;
case 'OTHER': case 'OTHER':
@@ -120,6 +130,10 @@ extension EquipmentCategoryExtension on EquipmentCategory {
return 'Consommable'; return 'Consommable';
case EquipmentCategory.cable: case EquipmentCategory.cable:
return 'Câble'; return 'Câble';
case EquipmentCategory.vehicle:
return 'Véhicule';
case EquipmentCategory.backline:
return 'Régie / Backline';
case EquipmentCategory.other: case EquipmentCategory.other:
return 'Autre'; return 'Autre';
} }
@@ -142,6 +156,10 @@ extension EquipmentCategoryExtension on EquipmentCategory {
return Icons.inventory_2; return Icons.inventory_2;
case EquipmentCategory.cable: case EquipmentCategory.cable:
return Icons.cable; return Icons.cable;
case EquipmentCategory.vehicle:
return Icons.local_shipping;
case EquipmentCategory.backline:
return Icons.piano;
case EquipmentCategory.other: case EquipmentCategory.other:
return Icons.more_horiz; return Icons.more_horiz;
} }
@@ -164,6 +182,10 @@ extension EquipmentCategoryExtension on EquipmentCategory {
return Colors.orange; return Colors.orange;
case EquipmentCategory.cable: case EquipmentCategory.cable:
return Colors.grey; return Colors.grey;
case EquipmentCategory.vehicle:
return Colors.teal;
case EquipmentCategory.backline:
return Colors.indigo;
case EquipmentCategory.other: case EquipmentCategory.other:
return Colors.blueGrey; return Colors.blueGrey;
} }
@@ -176,7 +198,14 @@ extension EquipmentCategoryExtension on EquipmentCategory {
return 'assets/icons/truss.svg'; return 'assets/icons/truss.svg';
case EquipmentCategory.consumable: case EquipmentCategory.consumable:
return 'assets/icons/tape.svg'; return 'assets/icons/tape.svg';
default: case EquipmentCategory.lighting:
case EquipmentCategory.sound:
case EquipmentCategory.video:
case EquipmentCategory.effect:
case EquipmentCategory.cable:
case EquipmentCategory.vehicle:
case EquipmentCategory.backline:
case EquipmentCategory.other:
return null; return null;
} }
} }
@@ -295,6 +324,7 @@ class EquipmentModel {
final String? brand; // Marque (indexé) final String? brand; // Marque (indexé)
final String? model; // Modèle (indexé) final String? model; // Modèle (indexé)
final EquipmentCategory category; // Catégorie final EquipmentCategory category; // Catégorie
final String? subCategory; // Sous-catégorie (indexé par catégorie)
final EquipmentStatus status; // Statut actuel final EquipmentStatus status; // Statut actuel
// Prix (visible uniquement avec manage_equipment) // Prix (visible uniquement avec manage_equipment)
@@ -306,8 +336,6 @@ class EquipmentModel {
final int? availableQuantity; // Quantité disponible final int? availableQuantity; // Quantité disponible
final int? criticalThreshold; // Seuil critique pour alerte final int? criticalThreshold; // Seuil critique pour alerte
// Boîtes parentes (plusieurs possibles)
final List<String> parentBoxIds; // IDs des boîtes contenant cet équipement
// Caractéristiques physiques // Caractéristiques physiques
final double? weight; // Poids (kg) final double? weight; // Poids (kg)
@@ -337,13 +365,13 @@ class EquipmentModel {
this.brand, this.brand,
this.model, this.model,
required this.category, required this.category,
this.subCategory,
this.status = EquipmentStatus.available, this.status = EquipmentStatus.available,
this.purchasePrice, this.purchasePrice,
this.rentalPrice, this.rentalPrice,
this.totalQuantity, this.totalQuantity,
this.availableQuantity, this.availableQuantity,
this.criticalThreshold, this.criticalThreshold,
this.parentBoxIds = const [],
this.weight, this.weight,
this.length, this.length,
this.width, this.width,
@@ -359,10 +387,15 @@ class EquipmentModel {
}); });
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) { factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
// Gestion des listes // Fonction helper pour convertir Timestamp ou String ISO en DateTime
final List<dynamic> parentBoxIdsRaw = map['parentBoxIds'] ?? []; DateTime? _parseDate(dynamic value) {
final List<String> parentBoxIds = parentBoxIdsRaw.map((e) => e.toString()).toList(); if (value == null) return null;
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value);
return null;
}
// Gestion des listes
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? []; final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList(); final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
@@ -372,24 +405,24 @@ class EquipmentModel {
brand: map['brand'], brand: map['brand'],
model: map['model'], model: map['model'],
category: equipmentCategoryFromString(map['category']), category: equipmentCategoryFromString(map['category']),
subCategory: map['subCategory'],
status: equipmentStatusFromString(map['status']), status: equipmentStatusFromString(map['status']),
purchasePrice: map['purchasePrice']?.toDouble(), purchasePrice: map['purchasePrice']?.toDouble(),
rentalPrice: map['rentalPrice']?.toDouble(), rentalPrice: map['rentalPrice']?.toDouble(),
totalQuantity: map['totalQuantity']?.toInt(), totalQuantity: map['totalQuantity']?.toInt(),
availableQuantity: map['availableQuantity']?.toInt(), availableQuantity: map['availableQuantity']?.toInt(),
criticalThreshold: map['criticalThreshold']?.toInt(), criticalThreshold: map['criticalThreshold']?.toInt(),
parentBoxIds: parentBoxIds,
weight: map['weight']?.toDouble(), weight: map['weight']?.toDouble(),
length: map['length']?.toDouble(), length: map['length']?.toDouble(),
width: map['width']?.toDouble(), width: map['width']?.toDouble(),
height: map['height']?.toDouble(), height: map['height']?.toDouble(),
purchaseDate: (map['purchaseDate'] as Timestamp?)?.toDate(), purchaseDate: _parseDate(map['purchaseDate']),
nextMaintenanceDate: (map['nextMaintenanceDate'] as Timestamp?)?.toDate(), nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']),
maintenanceIds: maintenanceIds, maintenanceIds: maintenanceIds,
imageUrl: map['imageUrl'], imageUrl: map['imageUrl'],
notes: map['notes'], notes: map['notes'],
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(), updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
); );
} }
@@ -399,13 +432,13 @@ class EquipmentModel {
'brand': brand, 'brand': brand,
'model': model, 'model': model,
'category': equipmentCategoryToString(category), 'category': equipmentCategoryToString(category),
'subCategory': subCategory,
'status': equipmentStatusToString(status), 'status': equipmentStatusToString(status),
'purchasePrice': purchasePrice, 'purchasePrice': purchasePrice,
'rentalPrice': rentalPrice, 'rentalPrice': rentalPrice,
'totalQuantity': totalQuantity, 'totalQuantity': totalQuantity,
'availableQuantity': availableQuantity, 'availableQuantity': availableQuantity,
'criticalThreshold': criticalThreshold, 'criticalThreshold': criticalThreshold,
'parentBoxIds': parentBoxIds,
'weight': weight, 'weight': weight,
'length': length, 'length': length,
'width': width, 'width': width,
@@ -427,13 +460,13 @@ class EquipmentModel {
String? name, String? name,
String? model, String? model,
EquipmentCategory? category, EquipmentCategory? category,
String? subCategory,
EquipmentStatus? status, EquipmentStatus? status,
double? purchasePrice, double? purchasePrice,
double? rentalPrice, double? rentalPrice,
int? totalQuantity, int? totalQuantity,
int? availableQuantity, int? availableQuantity,
int? criticalThreshold, int? criticalThreshold,
List<String>? parentBoxIds,
double? weight, double? weight,
double? length, double? length,
double? width, double? width,
@@ -453,13 +486,13 @@ class EquipmentModel {
name: name ?? this.name, name: name ?? this.name,
model: model ?? this.model, model: model ?? this.model,
category: category ?? this.category, category: category ?? this.category,
subCategory: subCategory ?? this.subCategory,
status: status ?? this.status, status: status ?? this.status,
purchasePrice: purchasePrice ?? this.purchasePrice, purchasePrice: purchasePrice ?? this.purchasePrice,
rentalPrice: rentalPrice ?? this.rentalPrice, rentalPrice: rentalPrice ?? this.rentalPrice,
totalQuantity: totalQuantity ?? this.totalQuantity, totalQuantity: totalQuantity ?? this.totalQuantity,
availableQuantity: availableQuantity ?? this.availableQuantity, availableQuantity: availableQuantity ?? this.availableQuantity,
criticalThreshold: criticalThreshold ?? this.criticalThreshold, criticalThreshold: criticalThreshold ?? this.criticalThreshold,
parentBoxIds: parentBoxIds ?? this.parentBoxIds,
weight: weight ?? this.weight, weight: weight ?? this.weight,
length: length ?? this.length, length: length ?? this.length,
width: width ?? this.width, width: width ?? this.width,

View File

@@ -173,12 +173,23 @@ ReturnStatus returnStatusFromString(String? status) {
class EventEquipment { class EventEquipment {
final String equipmentId; // ID de l'équipement final String equipmentId; // ID de l'équipement
final int quantity; // Quantité (pour consommables) final int quantity; // Quantité initiale assignée
final bool isPrepared; // Validé en préparation final bool isPrepared; // Validé en préparation
final bool isLoaded; // Validé au chargement final bool isLoaded; // Validé au chargement
final bool isUnloaded; // Validé au déchargement final bool isUnloaded; // Validé au déchargement
final bool isReturned; // Validé au retour final bool isReturned; // Validé au retour
final int? returnedQuantity; // Quantité retournée (pour consommables)
// Tracking des manquants à chaque étape
final bool isMissingAtPreparation; // Manquant à la préparation
final bool isMissingAtLoading; // Manquant au chargement
final bool isMissingAtUnloading; // Manquant au déchargement
final bool isMissingAtReturn; // Manquant au retour
// Quantités réelles à chaque étape (pour les quantifiables)
final int? quantityAtPreparation; // Quantité comptée en préparation
final int? quantityAtLoading; // Quantité comptée au chargement
final int? quantityAtUnloading; // Quantité comptée au déchargement
final int? quantityAtReturn; // Quantité retournée
EventEquipment({ EventEquipment({
required this.equipmentId, required this.equipmentId,
@@ -187,7 +198,14 @@ class EventEquipment {
this.isLoaded = false, this.isLoaded = false,
this.isUnloaded = false, this.isUnloaded = false,
this.isReturned = false, this.isReturned = false,
this.returnedQuantity, this.isMissingAtPreparation = false,
this.isMissingAtLoading = false,
this.isMissingAtUnloading = false,
this.isMissingAtReturn = false,
this.quantityAtPreparation,
this.quantityAtLoading,
this.quantityAtUnloading,
this.quantityAtReturn,
}); });
factory EventEquipment.fromMap(Map<String, dynamic> map) { factory EventEquipment.fromMap(Map<String, dynamic> map) {
@@ -198,7 +216,14 @@ class EventEquipment {
isLoaded: map['isLoaded'] ?? false, isLoaded: map['isLoaded'] ?? false,
isUnloaded: map['isUnloaded'] ?? false, isUnloaded: map['isUnloaded'] ?? false,
isReturned: map['isReturned'] ?? false, isReturned: map['isReturned'] ?? false,
returnedQuantity: map['returnedQuantity'], isMissingAtPreparation: map['isMissingAtPreparation'] ?? false,
isMissingAtLoading: map['isMissingAtLoading'] ?? false,
isMissingAtUnloading: map['isMissingAtUnloading'] ?? false,
isMissingAtReturn: map['isMissingAtReturn'] ?? false,
quantityAtPreparation: map['quantityAtPreparation'],
quantityAtLoading: map['quantityAtLoading'],
quantityAtUnloading: map['quantityAtUnloading'],
quantityAtReturn: map['quantityAtReturn'],
); );
} }
@@ -210,7 +235,14 @@ class EventEquipment {
'isLoaded': isLoaded, 'isLoaded': isLoaded,
'isUnloaded': isUnloaded, 'isUnloaded': isUnloaded,
'isReturned': isReturned, 'isReturned': isReturned,
'returnedQuantity': returnedQuantity, 'isMissingAtPreparation': isMissingAtPreparation,
'isMissingAtLoading': isMissingAtLoading,
'isMissingAtUnloading': isMissingAtUnloading,
'isMissingAtReturn': isMissingAtReturn,
'quantityAtPreparation': quantityAtPreparation,
'quantityAtLoading': quantityAtLoading,
'quantityAtUnloading': quantityAtUnloading,
'quantityAtReturn': quantityAtReturn,
}; };
} }
@@ -221,7 +253,14 @@ class EventEquipment {
bool? isLoaded, bool? isLoaded,
bool? isUnloaded, bool? isUnloaded,
bool? isReturned, bool? isReturned,
int? returnedQuantity, bool? isMissingAtPreparation,
bool? isMissingAtLoading,
bool? isMissingAtUnloading,
bool? isMissingAtReturn,
int? quantityAtPreparation,
int? quantityAtLoading,
int? quantityAtUnloading,
int? quantityAtReturn,
}) { }) {
return EventEquipment( return EventEquipment(
equipmentId: equipmentId ?? this.equipmentId, equipmentId: equipmentId ?? this.equipmentId,
@@ -230,7 +269,14 @@ class EventEquipment {
isLoaded: isLoaded ?? this.isLoaded, isLoaded: isLoaded ?? this.isLoaded,
isUnloaded: isUnloaded ?? this.isUnloaded, isUnloaded: isUnloaded ?? this.isUnloaded,
isReturned: isReturned ?? this.isReturned, isReturned: isReturned ?? this.isReturned,
returnedQuantity: returnedQuantity ?? this.returnedQuantity, isMissingAtPreparation: isMissingAtPreparation ?? this.isMissingAtPreparation,
isMissingAtLoading: isMissingAtLoading ?? this.isMissingAtLoading,
isMissingAtUnloading: isMissingAtUnloading ?? this.isMissingAtUnloading,
isMissingAtReturn: isMissingAtReturn ?? this.isMissingAtReturn,
quantityAtPreparation: quantityAtPreparation ?? this.quantityAtPreparation,
quantityAtLoading: quantityAtLoading ?? this.quantityAtLoading,
quantityAtUnloading: quantityAtUnloading ?? this.quantityAtUnloading,
quantityAtReturn: quantityAtReturn ?? this.quantityAtReturn,
); );
} }
} }
@@ -250,7 +296,7 @@ class EventModel {
final String address; final String address;
final double latitude; final double latitude;
final double longitude; final double longitude;
final List<DocumentReference> workforce; final List<dynamic> workforce; // Peut contenir DocumentReference OU String (UIDs)
final List<Map<String, String>> documents; final List<Map<String, String>> documents;
final List<Map<String, dynamic>> options; final List<Map<String, dynamic>> options;
final EventStatus status; final EventStatus status;
@@ -300,25 +346,32 @@ class EventModel {
factory EventModel.fromMap(Map<String, dynamic> map, String id) { factory EventModel.fromMap(Map<String, dynamic> map, String id) {
try { try {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime _parseDate(dynamic value, DateTime defaultValue) {
if (value == null) return defaultValue;
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
return defaultValue;
}
// Gestion sécurisée des références workforce // Gestion sécurisée des références workforce
final List<dynamic> workforceRefs = map['workforce'] ?? []; final List<dynamic> workforceRefs = map['workforce'] ?? [];
final List<DocumentReference> safeWorkforce = []; final List<dynamic> safeWorkforce = [];
for (var ref in workforceRefs) { for (var ref in workforceRefs) {
if (ref is DocumentReference) { if (ref is DocumentReference) {
safeWorkforce.add(ref); safeWorkforce.add(ref);
} else if (ref is String) {
// Accepter directement les UIDs (envoyés par le backend)
safeWorkforce.add(ref);
} else { } else {
print('Warning: Invalid workforce reference in event $id: $ref'); print('Warning: Invalid workforce reference in event $id: $ref');
} }
} }
// Gestion sécurisée des timestamps // Gestion sécurisée des timestamps avec support ISO string
final Timestamp? startTimestamp = map['StartDateTime'] as Timestamp?; final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
final Timestamp? endTimestamp = map['EndDateTime'] as Timestamp?; final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
final DateTime startDate = startTimestamp?.toDate() ?? DateTime.now();
final DateTime endDate = endTimestamp?.toDate() ??
startDate.add(const Duration(hours: 1));
// Gestion sécurisée des documents // Gestion sécurisée des documents
final docsRaw = map['documents'] ?? []; final docsRaw = map['documents'] ?? [];
@@ -365,7 +418,13 @@ class EventModel {
eventTypeRef = map['EventType'] as DocumentReference; eventTypeRef = map['EventType'] as DocumentReference;
eventTypeId = eventTypeRef.id; eventTypeId = eventTypeRef.id;
} else if (map['EventType'] is String) { } else if (map['EventType'] is String) {
eventTypeId = map['EventType'] as String; final eventTypeString = map['EventType'] as String;
// Si c'est un path (ex: "eventTypes/Mariage"), extraire juste l'ID
if (eventTypeString.contains('/')) {
eventTypeId = eventTypeString.split('/').last;
} else {
eventTypeId = eventTypeString;
}
} }
// Gestion sécurisée du customer // Gestion sécurisée du customer
@@ -373,7 +432,13 @@ class EventModel {
if (map['customer'] is DocumentReference) { if (map['customer'] is DocumentReference) {
customerId = (map['customer'] as DocumentReference).id; customerId = (map['customer'] as DocumentReference).id;
} else if (map['customer'] is String) { } else if (map['customer'] is String) {
customerId = map['customer'] as String; final customerString = map['customer'] as String;
// Si c'est un path (ex: "clients/abc123"), extraire juste l'ID
if (customerString.contains('/')) {
customerId = customerString.split('/').last;
} else {
customerId = customerString;
}
} }
// Gestion des équipements assignés // Gestion des équipements assignés
@@ -470,12 +535,10 @@ class EventModel {
'BasePrice': basePrice, 'BasePrice': basePrice,
'InstallationTime': installationTime, 'InstallationTime': installationTime,
'DisassemblyTime': disassemblyTime, 'DisassemblyTime': disassemblyTime,
'EventType': eventTypeId.isNotEmpty // Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
? FirebaseFirestore.instance.collection('eventTypes').doc(eventTypeId) 'EventType': eventTypeId.isNotEmpty ? eventTypeId : null,
: null, // Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
'customer': customerId.isNotEmpty 'customer': customerId.isNotEmpty ? customerId : null,
? FirebaseFirestore.instance.collection('customers').doc(customerId)
: null,
'Address': address, 'Address': address,
'Position': GeoPoint(latitude, longitude), 'Position': GeoPoint(latitude, longitude),
'Latitude': latitude, 'Latitude': latitude,
@@ -495,4 +558,64 @@ class EventModel {
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null, 'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
}; };
} }
EventModel copyWith({
String? id,
String? name,
String? description,
DateTime? startDateTime,
DateTime? endDateTime,
double? basePrice,
int? installationTime,
int? disassemblyTime,
String? eventTypeId,
DocumentReference? eventTypeRef,
String? customerId,
String? address,
double? latitude,
double? longitude,
List<dynamic>? workforce,
List<Map<String, String>>? documents,
List<Map<String, dynamic>>? options,
EventStatus? status,
int? jauge,
String? contactEmail,
String? contactPhone,
List<EventEquipment>? assignedEquipment,
List<String>? assignedContainers,
PreparationStatus? preparationStatus,
LoadingStatus? loadingStatus,
UnloadingStatus? unloadingStatus,
ReturnStatus? returnStatus,
}) {
return EventModel(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
startDateTime: startDateTime ?? this.startDateTime,
endDateTime: endDateTime ?? this.endDateTime,
basePrice: basePrice ?? this.basePrice,
installationTime: installationTime ?? this.installationTime,
disassemblyTime: disassemblyTime ?? this.disassemblyTime,
eventTypeId: eventTypeId ?? this.eventTypeId,
eventTypeRef: eventTypeRef ?? this.eventTypeRef,
customerId: customerId ?? this.customerId,
address: address ?? this.address,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
workforce: workforce ?? this.workforce,
documents: documents ?? this.documents,
options: options ?? this.options,
status: status ?? this.status,
jauge: jauge ?? this.jauge,
contactEmail: contactEmail ?? this.contactEmail,
contactPhone: contactPhone ?? this.contactPhone,
assignedEquipment: assignedEquipment ?? this.assignedEquipment,
assignedContainers: assignedContainers ?? this.assignedContainers,
preparationStatus: preparationStatus ?? this.preparationStatus,
loadingStatus: loadingStatus ?? this.loadingStatus,
unloadingStatus: unloadingStatus ?? this.unloadingStatus,
returnStatus: returnStatus ?? this.returnStatus,
);
}
} }

View File

@@ -1,3 +1,5 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class EventTypeModel { class EventTypeModel {
final String id; final String id;
final String name; final String name;
@@ -12,11 +14,19 @@ class EventTypeModel {
}); });
factory EventTypeModel.fromMap(Map<String, dynamic> map, String id) { factory EventTypeModel.fromMap(Map<String, dynamic> map, String id) {
// Gérer createdAt qui peut être Timestamp (Firestore) ou String ISO (API)
DateTime parseCreatedAt(dynamic value) {
if (value == null) return DateTime.now();
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
return DateTime.now();
}
return EventTypeModel( return EventTypeModel(
id: id, id: id,
name: map['name'] ?? '', name: map['name'] ?? '',
defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(), defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(),
createdAt: map['createdAt']?.toDate() ?? DateTime.now(), createdAt: parseCreatedAt(map['createdAt']),
); );
} }

View File

@@ -60,6 +60,14 @@ class MaintenanceModel {
}); });
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) { factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
// Fonction helper pour convertir Timestamp ou String ISO 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);
return null;
}
// Gestion de la liste des équipements // Gestion de la liste des équipements
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? []; final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList(); final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
@@ -68,15 +76,15 @@ class MaintenanceModel {
id: id, id: id,
equipmentIds: equipmentIds, equipmentIds: equipmentIds,
type: maintenanceTypeFromString(map['type']), type: maintenanceTypeFromString(map['type']),
scheduledDate: (map['scheduledDate'] as Timestamp?)?.toDate() ?? DateTime.now(), scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(),
completedDate: (map['completedDate'] as Timestamp?)?.toDate(), completedDate: _parseDate(map['completedDate']),
name: map['name'] ?? '', name: map['name'] ?? '',
description: map['description'] ?? '', description: map['description'] ?? '',
performedBy: map['performedBy'], performedBy: map['performedBy'],
cost: map['cost']?.toDouble(), cost: map['cost']?.toDouble(),
notes: map['notes'], notes: map['notes'],
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(), updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
); );
} }

View File

@@ -0,0 +1,88 @@
/// Préférences de notifications pour un utilisateur
class NotificationPreferences {
final bool emailEnabled; // Recevoir emails
final bool pushEnabled; // Recevoir notifications push
final bool inAppEnabled; // Recevoir alertes in-app
// Préférences par type d'alerte
final bool eventsNotifications; // Alertes événements
final bool maintenanceNotifications; // Alertes maintenance
final bool stockNotifications; // Alertes stock
final bool equipmentNotifications; // Alertes équipement
// Token FCM (pour push)
final String? fcmToken;
const NotificationPreferences({
this.emailEnabled = true, // ✓ Activé par défaut
this.pushEnabled = false,
this.inAppEnabled = true,
this.eventsNotifications = true,
this.maintenanceNotifications = true,
this.stockNotifications = true,
this.equipmentNotifications = true,
this.fcmToken,
});
/// Valeurs par défaut pour un nouvel utilisateur
factory NotificationPreferences.defaults() {
return const NotificationPreferences(
emailEnabled: true, // ✓ Activé par défaut
pushEnabled: false,
inAppEnabled: true,
eventsNotifications: true,
maintenanceNotifications: true,
stockNotifications: true,
equipmentNotifications: true,
);
}
factory NotificationPreferences.fromMap(Map<String, dynamic> map) {
return NotificationPreferences(
emailEnabled: map['emailEnabled'] ?? true, // ✓ true par défaut
pushEnabled: map['pushEnabled'] ?? false,
inAppEnabled: map['inAppEnabled'] ?? true,
eventsNotifications: map['eventsNotifications'] ?? true,
maintenanceNotifications: map['maintenanceNotifications'] ?? true,
stockNotifications: map['stockNotifications'] ?? true,
equipmentNotifications: map['equipmentNotifications'] ?? true,
fcmToken: map['fcmToken'],
);
}
Map<String, dynamic> toMap() {
return {
'emailEnabled': emailEnabled,
'pushEnabled': pushEnabled,
'inAppEnabled': inAppEnabled,
'eventsNotifications': eventsNotifications,
'maintenanceNotifications': maintenanceNotifications,
'stockNotifications': stockNotifications,
'equipmentNotifications': equipmentNotifications,
if (fcmToken != null) 'fcmToken': fcmToken,
};
}
NotificationPreferences copyWith({
bool? emailEnabled,
bool? pushEnabled,
bool? inAppEnabled,
bool? eventsNotifications,
bool? maintenanceNotifications,
bool? stockNotifications,
bool? equipmentNotifications,
String? fcmToken,
}) {
return NotificationPreferences(
emailEnabled: emailEnabled ?? this.emailEnabled,
pushEnabled: pushEnabled ?? this.pushEnabled,
inAppEnabled: inAppEnabled ?? this.inAppEnabled,
eventsNotifications: eventsNotifications ?? this.eventsNotifications,
maintenanceNotifications: maintenanceNotifications ?? this.maintenanceNotifications,
stockNotifications: stockNotifications ?? this.stockNotifications,
equipmentNotifications: equipmentNotifications ?? this.equipmentNotifications,
fcmToken: fcmToken ?? this.fcmToken,
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/notification_preferences_model.dart';
class UserModel { class UserModel {
final String uid; final String uid;
@@ -8,6 +9,7 @@ class UserModel {
final String profilePhotoUrl; final String profilePhotoUrl;
final String email; final String email;
final String phoneNumber; final String phoneNumber;
final NotificationPreferences? notificationPreferences;
UserModel({ UserModel({
required this.uid, required this.uid,
@@ -17,19 +19,39 @@ class UserModel {
required this.profilePhotoUrl, required this.profilePhotoUrl,
required this.email, required this.email,
required this.phoneNumber, required this.phoneNumber,
this.notificationPreferences,
}); });
// Convertit une Map (Firestore) en UserModel // Convertit une Map (Firestore) en UserModel
factory UserModel.fromMap(Map<String, dynamic> data, String uid) { factory UserModel.fromMap(Map<String, dynamic> data, String uid) {
String roleString; String roleString;
final roleField = data['role']; final roleField = data['role'];
if (roleField is String) { if (roleField is String) {
// Cas 1 : role est déjà un String (ex: "roles/ADMIN")
roleString = roleField; roleString = roleField;
} else if (roleField is DocumentReference) { } else if (roleField is DocumentReference) {
// Cas 2 : role est une DocumentReference
roleString = roleField.id; roleString = roleField.id;
} else if (roleField is Map) {
// Cas 3 : role est un Map sérialisé (ex: {"_path": {"segments": ["roles", "ADMIN"]}})
// On extrait le path
final pathData = roleField['_path'];
if (pathData is Map && pathData['segments'] is List) {
final segments = pathData['segments'] as List;
if (segments.length >= 2) {
roleString = segments[1].toString(); // Ex: "ADMIN"
} else { } else {
roleString = 'USER'; roleString = 'USER';
} }
} else {
roleString = 'USER';
}
} else {
// Cas par défaut
roleString = 'USER';
}
return UserModel( return UserModel(
uid: uid, uid: uid,
firstName: data['firstName'] ?? '', firstName: data['firstName'] ?? '',
@@ -38,6 +60,9 @@ class UserModel {
profilePhotoUrl: data['profilePhotoUrl'] ?? '', profilePhotoUrl: data['profilePhotoUrl'] ?? '',
email: data['email'] ?? '', email: data['email'] ?? '',
phoneNumber: data['phoneNumber'] ?? '', phoneNumber: data['phoneNumber'] ?? '',
notificationPreferences: data['notificationPreferences'] != null
? NotificationPreferences.fromMap(data['notificationPreferences'] as Map<String, dynamic>)
: NotificationPreferences.defaults(),
); );
} }
@@ -46,10 +71,12 @@ class UserModel {
return { return {
'firstName': firstName, 'firstName': firstName,
'lastName': lastName, 'lastName': lastName,
'role': FirebaseFirestore.instance.collection('roles').doc(role), 'role': role, // Envoyer directement le string roleId au lieu de créer une DocumentReference
'profilePhotoUrl': profilePhotoUrl, 'profilePhotoUrl': profilePhotoUrl,
'email': email, 'email': email,
'phoneNumber': phoneNumber, 'phoneNumber': phoneNumber,
if (notificationPreferences != null)
'notificationPreferences': notificationPreferences!.toMap(),
}; };
} }
@@ -60,6 +87,7 @@ class UserModel {
String? profilePhotoUrl, String? profilePhotoUrl,
String? email, String? email,
String? phoneNumber, String? phoneNumber,
NotificationPreferences? notificationPreferences,
}) { }) {
return UserModel( return UserModel(
uid: uid, // L'UID ne change pas uid: uid, // L'UID ne change pas
@@ -69,6 +97,7 @@ class UserModel {
profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl, profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
email: email ?? this.email, email: email ?? this.email,
phoneNumber: phoneNumber ?? this.phoneNumber, phoneNumber: phoneNumber ?? this.phoneNumber,
notificationPreferences: notificationPreferences ?? this.notificationPreferences,
); );
} }
} }

View File

@@ -1,14 +1,16 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/alert_model.dart'; import 'package:em2rp/models/alert_model.dart';
import 'package:em2rp/services/api_service.dart';
class AlertProvider extends ChangeNotifier { class AlertProvider extends ChangeNotifier {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final ApiService _apiService = apiService;
List<AlertModel> _alerts = []; List<AlertModel> _alerts = [];
bool _isLoading = false;
// Getters // Getters
List<AlertModel> get alerts => _alerts; List<AlertModel> get alerts => _alerts;
bool get isLoading => _isLoading;
/// Nombre d'alertes non lues /// Nombre d'alertes non lues
int get unreadCount => _alerts.where((alert) => !alert.isRead).length; int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
@@ -25,57 +27,58 @@ class AlertProvider extends ChangeNotifier {
/// Alertes de conflit /// Alertes de conflit
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList(); List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
/// Stream des alertes /// Charger toutes les alertes via Cloud Function
Stream<List<AlertModel>> get alertsStream { Future<void> loadAlerts() async {
return _firestore _isLoading = true;
.collection('alerts') notifyListeners();
.orderBy('createdAt', descending: true)
.snapshots() try {
.map((snapshot) { final result = await _apiService.call('getAlerts', {});
_alerts = snapshot.docs final alertsData = result['alerts'] as List<dynamic>;
.map((doc) => AlertModel.fromMap(doc.data(), doc.id))
.toList(); _alerts = alertsData.map((data) {
return _alerts; return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
}); }).toList();
} catch (e) {
print('Error loading alerts: $e');
_alerts = [];
} finally {
_isLoading = false;
notifyListeners();
}
} }
/// Marquer une alerte comme lue /// Marquer une alerte comme lue via Cloud Function
Future<void> markAsRead(String alertId) async { Future<void> markAsRead(String alertId) async {
try { try {
await _firestore.collection('alerts').doc(alertId).update({ await _apiService.call('markAlertAsRead', {'alertId': alertId});
'isRead': true,
}); // Mettre à jour localement
final index = _alerts.indexWhere((a) => a.id == alertId);
if (index != -1) {
_alerts[index] = AlertModel(
id: _alerts[index].id,
type: _alerts[index].type,
message: _alerts[index].message,
equipmentId: _alerts[index].equipmentId,
isRead: true,
createdAt: _alerts[index].createdAt,
);
notifyListeners(); notifyListeners();
}
} catch (e) { } catch (e) {
print('Error marking alert as read: $e'); print('Error marking alert as read: $e');
rethrow; rethrow;
} }
} }
/// Marquer toutes les alertes comme lues /// Supprimer une alerte via Cloud Function
Future<void> markAllAsRead() async {
try {
final batch = _firestore.batch();
for (var alert in _alerts.where((a) => !a.isRead)) {
batch.update(
_firestore.collection('alerts').doc(alert.id),
{'isRead': true},
);
}
await batch.commit();
notifyListeners();
} catch (e) {
print('Error marking all alerts as read: $e');
rethrow;
}
}
/// Supprimer une alerte
Future<void> deleteAlert(String alertId) async { Future<void> deleteAlert(String alertId) async {
try { try {
await _firestore.collection('alerts').doc(alertId).delete(); await _apiService.call('deleteAlert', {'alertId': alertId});
// Supprimer localement
_alerts.removeWhere((a) => a.id == alertId);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
print('Error deleting alert: $e'); print('Error deleting alert: $e');
@@ -83,46 +86,32 @@ class AlertProvider extends ChangeNotifier {
} }
} }
/// Supprimer toutes les alertes lues /// Marquer toutes les alertes comme lues
Future<void> deleteReadAlerts() async { Future<void> markAllAsRead() async {
try { try {
final batch = _firestore.batch(); final unreadAlertIds = _alerts.where((a) => !a.isRead).map((a) => a.id).toList();
for (var alert in _alerts.where((a) => a.isRead)) { for (final alertId in unreadAlertIds) {
batch.delete(_firestore.collection('alerts').doc(alert.id)); await markAsRead(alertId);
}
} catch (e) {
print('Error marking all alerts as read: $e');
rethrow;
}
} }
await batch.commit(); /// Supprimer toutes les alertes lues via Cloud Function
notifyListeners(); Future<void> deleteReadAlerts() async {
try {
final readAlertIds = _alerts.where((a) => a.isRead).map((a) => a.id).toList();
for (final alertId in readAlertIds) {
await deleteAlert(alertId);
}
} catch (e) { } catch (e) {
print('Error deleting read alerts: $e'); print('Error deleting read alerts: $e');
rethrow; rethrow;
} }
} }
/// Créer une alerte (utilisé principalement par les services)
Future<void> createAlert(AlertModel alert) async {
try {
await _firestore.collection('alerts').doc(alert.id).set(alert.toMap());
notifyListeners();
} catch (e) {
print('Error creating alert: $e');
rethrow;
}
}
/// Récupérer les alertes pour un équipement spécifique
Stream<List<AlertModel>> getAlertsForEquipment(String equipmentId) {
return _firestore
.collection('alerts')
.where('equipmentId', isEqualTo: equipmentId)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => AlertModel.fromMap(doc.data(), doc.id))
.toList();
});
}
} }

View File

@@ -0,0 +1,62 @@
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();
}
}

View File

@@ -1,45 +1,269 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:async';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/container_service.dart'; import 'package:em2rp/services/container_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
class ContainerProvider with ChangeNotifier { class ContainerProvider with ChangeNotifier {
final ContainerService _containerService = ContainerService(); final ContainerService _containerService = ContainerService();
final DataService _dataService = DataService(FirebaseFunctionsApiService());
// Timer pour le debouncing de la recherche
Timer? _searchDebounceTimer;
// Liste paginée pour la page de gestion
List<ContainerModel> _paginatedContainers = [];
bool _hasMore = true;
bool _isLoadingMore = false;
String? _lastVisible;
// Cache complet pour compatibilité
List<ContainerModel> _containers = [];
// Filtres et recherche
ContainerType? _selectedType; ContainerType? _selectedType;
EquipmentStatus? _selectedStatus; EquipmentStatus? _selectedStatus;
String _searchQuery = ''; String _searchQuery = '';
bool _isLoading = false;
bool _isInitialized = false;
// Mode de chargement (pagination vs full)
bool _usePagination = false;
// Getters
List<ContainerModel> get containers => _usePagination ? _paginatedContainers : _containers;
ContainerType? get selectedType => _selectedType; ContainerType? get selectedType => _selectedType;
EquipmentStatus? get selectedStatus => _selectedStatus; EquipmentStatus? get selectedStatus => _selectedStatus;
String get searchQuery => _searchQuery; String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
bool get isInitialized => _isInitialized;
bool get usePagination => _usePagination;
/// Stream des containers avec filtres appliqués /// S'assure que les conteneurs sont chargés (charge si nécessaire)
Stream<List<ContainerModel>> get containersStream { Future<void> ensureLoaded() async {
return _containerService.getContainers( if (_isInitialized || _isLoading) {
return;
}
await loadContainers();
}
/// Charger tous les containers via l'API (avec pagination automatique)
Future<void> loadContainers() async {
_isLoading = true;
notifyListeners();
try {
_containers.clear();
String? lastVisible;
bool hasMore = true;
int pageCount = 0;
// Charger toutes les pages en boucle
while (hasMore) {
pageCount++;
print('[ContainerProvider] Loading page $pageCount...');
final result = await _dataService.getContainersPaginated(
limit: 100, // Charger 100 par page pour aller plus vite
startAfter: lastVisible,
sortBy: 'id',
sortOrder: 'asc',
type: _selectedType?.name,
status: _selectedStatus?.name,
searchQuery: _searchQuery,
);
final containers = (result['containers'] as List<dynamic>)
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
.toList();
_containers.addAll(containers);
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
}
_isLoading = false;
_isInitialized = true;
notifyListeners();
} catch (e) {
print('Error loading containers: $e');
_isLoading = false;
notifyListeners();
}
}
/// Récupérer les containers avec filtres appliqués
Future<List<ContainerModel>> getContainers() async {
return await _containerService.getContainers(
type: _selectedType, type: _selectedType,
status: _selectedStatus, status: _selectedStatus,
searchQuery: _searchQuery, searchQuery: _searchQuery,
); );
} }
/// Stream des containers - retourne un stream depuis les données en cache
/// Pour compatibilité avec les widgets existants qui utilisent StreamBuilder
Stream<List<ContainerModel>> get containersStream async* {
// Si les données ne sont pas chargées, charger d'abord
if (!_isInitialized) {
await loadContainers();
}
// Émettre les données actuelles
yield _containers;
// Continuer à émettre les mises à jour du cache
// Note: Pour un vrai temps réel, il faudrait implémenter un StreamController
// et notifier quand les données changent
}
/// Définir le type sélectionné /// Définir le type sélectionné
void setSelectedType(ContainerType? type) { void setSelectedType(ContainerType? type) async {
if (_selectedType == type) return;
_selectedType = type; _selectedType = type;
if (_usePagination) {
await reload();
} else {
notifyListeners(); notifyListeners();
} }
}
/// Définir le statut sélectionné /// Définir le statut sélectionné
void setSelectedStatus(EquipmentStatus? status) { void setSelectedStatus(EquipmentStatus? status) async {
if (_selectedStatus == status) return;
_selectedStatus = status; _selectedStatus = status;
if (_usePagination) {
await reload();
} else {
notifyListeners(); notifyListeners();
} }
}
/// Définir la requête de recherche /// Définir la requête de recherche (avec debouncing)
void setSearchQuery(String query) { void setSearchQuery(String query) {
if (_searchQuery == query) return;
_searchQuery = query; _searchQuery = query;
// Annuler le timer précédent
_searchDebounceTimer?.cancel();
if (_usePagination) {
// Attendre 500ms avant de recharger (debouncing)
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
reload();
});
} else {
notifyListeners(); notifyListeners();
} }
}
@override
void dispose() {
_searchDebounceTimer?.cancel();
super.dispose();
}
// ============================================================================
// PAGINATION - Nouvelles méthodes
// ============================================================================
/// Active le mode pagination (pour la page de gestion)
void enablePagination() {
if (!_usePagination) {
_usePagination = true;
DebugLog.info('[ContainerProvider] Pagination mode enabled');
}
}
/// Désactive le mode pagination (pour les autres pages)
void disablePagination() {
if (_usePagination) {
_usePagination = false;
DebugLog.info('[ContainerProvider] Pagination mode disabled');
}
}
/// Charge la première page (réinitialise tout)
Future<void> loadFirstPage() async {
DebugLog.info('[ContainerProvider] Loading first page...');
_paginatedContainers.clear();
_lastVisible = null;
_hasMore = true;
_isLoading = true;
notifyListeners();
try {
await loadNextPage();
_isInitialized = true;
} catch (e) {
DebugLog.error('[ContainerProvider] Error loading first page', e);
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Charge la page suivante (scroll infini)
Future<void> loadNextPage() async {
if (_isLoadingMore || !_hasMore) {
DebugLog.info('[ContainerProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
return;
}
DebugLog.info('[ContainerProvider] Loading next page... (current: ${_paginatedContainers.length})');
_isLoadingMore = true;
_isLoading = true;
notifyListeners();
try {
final result = await _dataService.getContainersPaginated(
limit: 20,
startAfter: _lastVisible,
type: _selectedType != null ? containerTypeToString(_selectedType!) : null,
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
sortBy: 'id',
sortOrder: 'asc',
);
final newContainers = (result['containers'] as List<dynamic>)
.map((data) {
final map = data as Map<String, dynamic>;
return ContainerModel.fromMap(map, map['id'] as String);
})
.toList();
_paginatedContainers.addAll(newContainers);
_hasMore = result['hasMore'] as bool? ?? false;
_lastVisible = result['lastVisible'] as String?;
DebugLog.info('[ContainerProvider] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMore');
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
} catch (e) {
DebugLog.error('[ContainerProvider] Error loading next page', e);
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharge en changeant de filtre ou recherche
Future<void> reload() async {
DebugLog.info('[ContainerProvider] Reloading with new filters...');
await loadFirstPage();
}
/// Créer un nouveau container /// Créer un nouveau container
Future<void> createContainer(ContainerModel container) async { Future<void> createContainer(ContainerModel container) async {
@@ -64,6 +288,69 @@ class ContainerProvider with ChangeNotifier {
return await _containerService.getContainerById(id); return await _containerService.getContainerById(id);
} }
/// Charge plusieurs conteneurs par leurs IDs (optimisé pour les détails d'événement)
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
if (containerIds.isEmpty) return [];
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
try {
// Vérifier d'abord le cache local
final cachedContainers = <ContainerModel>[];
final missingIds = <String>[];
for (final id in containerIds) {
final cached = _containers.firstWhere(
(c) => c.id == id,
orElse: () => ContainerModel(
id: '',
name: '',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (cached.id.isNotEmpty) {
cachedContainers.add(cached);
} else {
missingIds.add(id);
}
}
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
// Si tous sont en cache, retourner directement
if (missingIds.isEmpty) {
return cachedContainers;
}
// Charger les manquants depuis l'API
final containersData = await _dataService.getContainersByIds(missingIds);
final loadedContainers = containersData.map((data) {
return ContainerModel.fromMap(data, data['id'] as String);
}).toList();
// Ajouter au cache
for (final container in loadedContainers) {
if (!_containers.any((c) => c.id == container.id)) {
_containers.add(container);
}
}
print('[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');
rethrow;
}
}
/// Ajouter un équipement à un container /// Ajouter un équipement à un container
Future<Map<String, dynamic>> addEquipmentToContainer({ Future<Map<String, dynamic>> addEquipmentToContainer({
required String containerId, required String containerId,

View File

@@ -1,229 +1,533 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:async';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/equipment_service.dart'; import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/equipment_status_calculator.dart'; import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
class EquipmentProvider extends ChangeNotifier { class EquipmentProvider extends ChangeNotifier {
final EquipmentService _service = EquipmentService(); final DataService _dataService = DataService(FirebaseFunctionsApiService());
final EquipmentStatusCalculator _statusCalculator = EquipmentStatusCalculator();
// Timer pour le debouncing de la recherche
Timer? _searchDebounceTimer;
// Liste paginée pour la page de gestion
List<EquipmentModel> _paginatedEquipment = [];
bool _hasMore = true;
bool _isLoadingMore = false;
String? _lastVisible;
// Cache complet pour getEquipmentsByIds et compatibilité
List<EquipmentModel> _equipment = []; List<EquipmentModel> _equipment = [];
List<String> _models = []; List<String> _models = [];
List<String> _brands = []; List<String> _brands = [];
// Filtres et recherche
EquipmentCategory? _selectedCategory; EquipmentCategory? _selectedCategory;
EquipmentStatus? _selectedStatus; EquipmentStatus? _selectedStatus;
String? _selectedModel; String? _selectedModel;
String _searchQuery = ''; String _searchQuery = '';
bool _isLoading = false;
bool _isInitialized = false;
// Mode de chargement (pagination vs full)
bool _usePagination = false;
EquipmentProvider();
// Getters // Getters
List<EquipmentModel> get equipment => _equipment; List<EquipmentModel> get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment;
List<EquipmentModel> get allEquipment => _equipment;
List<String> get models => _models; List<String> get models => _models;
List<String> get brands => _brands; List<String> get brands => _brands;
EquipmentCategory? get selectedCategory => _selectedCategory; EquipmentCategory? get selectedCategory => _selectedCategory;
EquipmentStatus? get selectedStatus => _selectedStatus; EquipmentStatus? get selectedStatus => _selectedStatus;
String? get selectedModel => _selectedModel; String? get selectedModel => _selectedModel;
String get searchQuery => _searchQuery; String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
bool get isInitialized => _isInitialized;
bool get usePagination => _usePagination;
/// Stream des équipements avec filtres appliqués /// S'assure que les équipements sont chargés (charge si nécessaire)
Stream<List<EquipmentModel>> get equipmentStream { Future<void> ensureLoaded() async {
return _service.getEquipment( // Si déjà en train de charger, attendre
category: _selectedCategory, if (_isLoading) {
status: _selectedStatus, print('[EquipmentProvider] Equipment loading in progress, waiting...');
model: _selectedModel, return;
searchQuery: _searchQuery, }
// 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) {
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
return;
}
print('[EquipmentProvider] Equipment not loaded, loading now...');
await loadEquipments();
}
/// Charger tous les équipements via l'API (utilisé par les dialogs et sélection)
Future<void> loadEquipments() async {
print('[EquipmentProvider] Starting to load ALL equipments...');
_isLoading = true;
notifyListeners();
try {
_equipment.clear();
String? lastVisible;
bool hasMore = true;
int pageCount = 0;
// Charger toutes les pages en boucle
while (hasMore) {
pageCount++;
print('[EquipmentProvider] Loading page $pageCount...');
final result = await _dataService.getEquipmentsPaginated(
limit: 100, // Charger 100 par page pour aller plus vite
startAfter: lastVisible,
sortBy: 'id',
sortOrder: 'asc',
); );
final equipmentsData = result['equipments'] as List<dynamic>;
print('[EquipmentProvider] Page $pageCount: ${equipmentsData.length} equipments');
final pageEquipments = equipmentsData.map((data) {
final id = data['id'] as String;
return EquipmentModel.fromMap(data as Map<String, dynamic>, id);
}).toList();
_equipment.addAll(pageEquipments);
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
if (!hasMore) {
print('[EquipmentProvider] All pages loaded. Total: ${_equipment.length} equipments');
}
} }
/// Charger tous les modèles uniques // Extraire les modèles et marques uniques
Future<void> loadModels() async { _extractUniqueValues();
_isInitialized = true;
_isLoading = false;
notifyListeners();
print('[EquipmentProvider] Equipment loading complete: ${_equipment.length} equipments');
} catch (e) {
print('[EquipmentProvider] Error loading equipments: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Charge plusieurs équipements par leurs IDs (optimisé pour les détails d'événement)
Future<List<EquipmentModel>> getEquipmentsByIds(List<String> equipmentIds) async {
if (equipmentIds.isEmpty) return [];
print('[EquipmentProvider] Loading ${equipmentIds.length} equipments by IDs...');
try { try {
_models = await _service.getAllModels(); // Vérifier d'abord le cache local
final cachedEquipments = <EquipmentModel>[];
final missingIds = <String>[];
for (final id in equipmentIds) {
final cached = _equipment.firstWhere(
(eq) => eq.id == id,
orElse: () => EquipmentModel(
id: '',
name: '',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (cached.id.isNotEmpty) {
cachedEquipments.add(cached);
} else {
missingIds.add(id);
}
}
print('[EquipmentProvider] Found ${cachedEquipments.length} in cache, ${missingIds.length} missing');
// Si tous sont en cache, retourner directement
if (missingIds.isEmpty) {
return cachedEquipments;
}
// Charger les manquants depuis l'API
final equipmentsData = await _dataService.getEquipmentsByIds(missingIds);
final loadedEquipments = equipmentsData.map((data) {
final id = data['id'] as String; // L'ID vient du backend
return EquipmentModel.fromMap(data, id);
}).toList();
// Ajouter au cache
for (final eq in loadedEquipments) {
if (!_equipment.any((e) => e.id == eq.id)) {
_equipment.add(eq);
}
}
print('[EquipmentProvider] Loaded ${loadedEquipments.length} equipments from API');
// Retourner tous les équipements (cache + chargés)
return [...cachedEquipments, ...loadedEquipments];
} catch (e) {
print('[EquipmentProvider] Error loading equipments by IDs: $e');
rethrow;
}
}
/// Extraire modèles et marques uniques
void _extractUniqueValues() {
final modelSet = <String>{};
final brandSet = <String>{};
for (final eq in _equipment) {
if (eq.model != null && eq.model!.isNotEmpty) {
modelSet.add(eq.model!);
}
if (eq.brand != null && eq.brand!.isNotEmpty) {
brandSet.add(eq.brand!);
}
}
_models = modelSet.toList()..sort();
_brands = brandSet.toList()..sort();
}
/// Obtenir les équipements filtrés
List<EquipmentModel> get _filteredEquipment {
var filtered = _equipment;
if (_selectedCategory != null) {
filtered = filtered.where((eq) => eq.category == _selectedCategory).toList();
}
if (_selectedStatus != null) {
filtered = filtered.where((eq) => eq.status == _selectedStatus).toList();
}
if (_selectedModel != null && _selectedModel!.isNotEmpty) {
filtered = filtered.where((eq) => eq.model == _selectedModel).toList();
}
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
filtered = filtered.where((eq) {
return eq.name.toLowerCase().contains(query) ||
eq.id.toLowerCase().contains(query) ||
(eq.model?.toLowerCase().contains(query) ?? false) ||
(eq.brand?.toLowerCase().contains(query) ?? false);
}).toList();
}
return filtered;
}
// ============================================================================
// PAGINATION - Nouvelles méthodes
// ============================================================================
/// Active le mode pagination (pour la page de gestion)
void enablePagination() {
if (!_usePagination) {
_usePagination = true;
DebugLog.info('[EquipmentProvider] Pagination mode enabled');
}
}
/// Désactive le mode pagination (pour les autres pages)
void disablePagination() {
if (_usePagination) {
_usePagination = false;
DebugLog.info('[EquipmentProvider] Pagination mode disabled');
}
}
/// Charge la première page (réinitialise tout)
Future<void> loadFirstPage() async {
DebugLog.info('[EquipmentProvider] Loading first page...');
_paginatedEquipment.clear();
_lastVisible = null;
_hasMore = true;
_isLoading = true;
notifyListeners();
try {
await loadNextPage();
_isInitialized = true;
} catch (e) {
DebugLog.error('[EquipmentProvider] Error loading first page', e);
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Charge la page suivante (scroll infini)
Future<void> loadNextPage() async {
if (_isLoadingMore || !_hasMore) {
DebugLog.info('[EquipmentProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
return;
}
DebugLog.info('[EquipmentProvider] Loading next page... (current: ${_paginatedEquipment.length})');
_isLoadingMore = true;
_isLoading = true;
notifyListeners();
try {
final result = await _dataService.getEquipmentsPaginated(
limit: 20,
startAfter: _lastVisible,
category: _selectedCategory?.name,
status: _selectedStatus?.name,
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
sortBy: 'id',
sortOrder: 'asc',
);
final newEquipments = (result['equipments'] as List<dynamic>)
.map((data) {
final map = data as Map<String, dynamic>;
final id = map['id'] as String; // L'ID vient du backend dans le JSON
return EquipmentModel.fromMap(map, id);
})
.toList();
_paginatedEquipment.addAll(newEquipments);
_hasMore = result['hasMore'] as bool? ?? false;
_lastVisible = result['lastVisible'] as String?;
DebugLog.info('[EquipmentProvider] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipment.length}, hasMore: $_hasMore');
_isLoadingMore = false;
_isLoading = false;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
print('Error loading models: $e'); DebugLog.error('[EquipmentProvider] Error loading next page', e);
rethrow; _isLoadingMore = false;
} _isLoading = false;
}
/// Charger toutes les marques uniques
Future<void> loadBrands() async {
try {
_brands = await _service.getAllBrands();
notifyListeners(); notifyListeners();
} catch (e) {
print('Error loading brands: $e');
rethrow; rethrow;
} }
} }
/// Charger les modèles filtrés par marque /// Recharge en changeant de filtre ou recherche
Future<List<String>> loadModelsByBrand(String brand) async { Future<void> reload() async {
DebugLog.info('[EquipmentProvider] Reloading with new filters...');
await loadFirstPage();
}
/// Définir le filtre de catégorie
void setSelectedCategory(EquipmentCategory? category) async {
if (_selectedCategory == category) return;
_selectedCategory = category;
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir le filtre de statut
void setSelectedStatus(EquipmentStatus? status) async {
if (_selectedStatus == status) return;
_selectedStatus = status;
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir le filtre de modèle
void setSelectedModel(String? model) async {
if (_selectedModel == model) return;
_selectedModel = model;
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir la requête de recherche (avec debouncing)
void setSearchQuery(String query) {
if (_searchQuery == query) return;
_searchQuery = query;
// Annuler le timer précédent
_searchDebounceTimer?.cancel();
if (_usePagination) {
// Attendre 500ms avant de recharger (debouncing)
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
reload();
});
} else {
notifyListeners();
}
}
@override
void dispose() {
_searchDebounceTimer?.cancel();
super.dispose();
}
/// Réinitialiser tous les filtres
void clearFilters() async {
_selectedCategory = null;
_selectedStatus = null;
_selectedModel = null;
_searchQuery = '';
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
// ============================================================================
// MÉTHODES COMPATIBILITÉ (pour ancien code)
// ============================================================================
/// Recharger les équipements (ancien système)
Future<void> refresh() async {
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
}
/// Stream des équipements (pour compatibilité avec ancien code)
Stream<List<EquipmentModel>> get equipmentStream async* {
if (!_isInitialized && !_usePagination) {
await loadEquipments();
}
yield equipment;
}
/// Supprimer un équipement
Future<void> deleteEquipment(String equipmentId) async {
try { try {
return await _service.getModelsByBrand(brand); await _dataService.deleteEquipment(equipmentId);
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) { } catch (e) {
print('Error loading models by brand: $e'); DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
rethrow; rethrow;
} }
} }
/// Ajouter un équipement /// Ajouter un équipement
Future<void> addEquipment(EquipmentModel equipment) async { Future<void> addEquipment(EquipmentModel equipment) async {
try { try {
await _service.createEquipment(equipment); await _dataService.createEquipment(equipment.id, equipment.toMap());
if (_usePagination) {
// Recharger les modèles si un nouveau modèle a été ajouté await reload();
if (equipment.model != null && !_models.contains(equipment.model)) { } else {
await loadModels(); await loadEquipments();
} }
} catch (e) { } catch (e) {
print('Error adding equipment: $e'); DebugLog.error('[EquipmentProvider] Error adding equipment', e);
rethrow; rethrow;
} }
} }
/// Mettre à jour un équipement /// Mettre à jour un équipement
Future<void> updateEquipment(String id, Map<String, dynamic> data) async { Future<void> updateEquipment(EquipmentModel equipment) async {
try { try {
await _service.updateEquipment(id, data); await _dataService.updateEquipment(equipment.id, equipment.toMap());
if (_usePagination) {
// Recharger les modèles si le modèle a changé await reload();
if (data.containsKey('model')) { } else {
await loadModels(); await loadEquipments();
} }
} catch (e) { } catch (e) {
print('Error updating equipment: $e'); DebugLog.error('[EquipmentProvider] Error updating equipment', e);
rethrow; rethrow;
} }
} }
/// Supprimer un équipement /// Charger les marques
Future<void> deleteEquipment(String id) async { Future<void> loadBrands() async {
try { await ensureLoaded();
await _service.deleteEquipment(id); _extractUniqueValues();
} catch (e) { }
print('Error deleting equipment: $e');
rethrow; /// Charger les modèles
Future<void> loadModels() async {
await ensureLoaded();
_extractUniqueValues();
}
/// Charger les modèles d'une marque spécifique
Future<List<String>> loadModelsByBrand(String brand) async {
await ensureLoaded();
return _equipment
.where((eq) => eq.brand?.toLowerCase() == brand.toLowerCase())
.map((eq) => eq.model ?? '')
.where((model) => model.isNotEmpty)
.toSet()
.toList()
..sort();
}
/// Charger les sous-catégories d'une catégorie spécifique
Future<List<String>> loadSubCategoriesByCategory(EquipmentCategory category) async {
await ensureLoaded();
return _equipment
.where((eq) => eq.category == category)
.map((eq) => eq.subCategory ?? '')
.where((sub) => sub.isNotEmpty)
.toSet()
.toList()
..sort();
}
/// Calculer le statut réel d'un équipement (pour badge)
EquipmentStatus calculateRealStatus(EquipmentModel equipment) {
// Pour les consommables/câbles, vérifier le seuil critique
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) {
final availableQty = equipment.availableQuantity ?? 0;
final criticalThreshold = equipment.criticalThreshold ?? 0;
if (criticalThreshold > 0 && availableQty <= criticalThreshold) {
return EquipmentStatus.maintenance; // Utiliser maintenance pour indiquer un problème
} }
} }
/// Récupérer un équipement par ID // Sinon retourner le statut de base
Future<EquipmentModel?> getEquipmentById(String id) async { return equipment.status;
try {
return await _service.getEquipmentById(id);
} catch (e) {
print('Error getting equipment: $e');
rethrow;
}
}
/// Trouver des alternatives disponibles
Future<List<EquipmentModel>> findAlternatives(
String model,
DateTime startDate,
DateTime endDate,
) async {
try {
return await _service.findAlternatives(model, startDate, endDate);
} catch (e) {
print('Error finding alternatives: $e');
rethrow;
}
}
/// Vérifier la disponibilité d'un équipement
Future<List<String>> checkAvailability(
String equipmentId,
DateTime startDate,
DateTime endDate,
) async {
try {
return await _service.checkAvailability(equipmentId, startDate, endDate);
} catch (e) {
print('Error checking availability: $e');
rethrow;
}
}
/// Mettre à jour le stock d'un consommable
Future<void> updateStock(String id, int quantityChange) async {
try {
await _service.updateStock(id, quantityChange);
} catch (e) {
print('Error updating stock: $e');
rethrow;
}
}
/// Vérifier les stocks critiques
Future<void> checkCriticalStock() async {
try {
await _service.checkCriticalStock();
} catch (e) {
print('Error checking critical stock: $e');
rethrow;
}
}
/// Générer les données du QR code
String generateQRCodeData(String equipmentId) {
return _service.generateQRCodeData(equipmentId);
}
/// Vérifier si un ID est unique
Future<bool> isIdUnique(String id) async {
try {
return await _service.isIdUnique(id);
} catch (e) {
print('Error checking ID uniqueness: $e');
rethrow;
}
}
/// Calculer le statut réel d'un équipement (asynchrone)
Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async {
return await _statusCalculator.calculateRealStatus(equipment);
}
/// Invalider le cache du calculateur de statut
void invalidateStatusCache() {
_statusCalculator.invalidateCache();
}
// === FILTRES ===
/// Définir la catégorie sélectionnée
void setSelectedCategory(EquipmentCategory? category) {
_selectedCategory = category;
notifyListeners();
}
/// Définir le statut sélectionné
void setSelectedStatus(EquipmentStatus? status) {
_selectedStatus = status;
notifyListeners();
}
/// Définir le modèle sélectionné
void setSelectedModel(String? model) {
_selectedModel = model;
notifyListeners();
}
/// Définir la recherche
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
}
/// Réinitialiser tous les filtres
void resetFilters() {
_selectedCategory = null;
_selectedStatus = null;
_selectedModel = null;
_searchQuery = '';
notifyListeners();
} }
} }

View File

@@ -1,110 +1,99 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:em2rp/services/equipment_status_calculator.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class EventProvider with ChangeNotifier { class EventProvider with ChangeNotifier {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final DataService _dataService = DataService(FirebaseFunctionsApiService());
List<EventModel> _events = []; List<EventModel> _events = [];
bool _isLoading = false; bool _isLoading = false;
List<EventModel> get events => _events; List<EventModel> get events => _events;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
// Récupérer les événements pour un utilisateur spécifique // Cache des utilisateurs chargés depuis getEvents
Future<void> loadUserEvents(String userId, Map<String, Map<String, dynamic>> _usersCache = {};
{bool canViewAllEvents = false}) async {
/// Charger les événements d'un utilisateur via l'API
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false}) async {
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
try { // Sauvegarder les paramètres
print( _saveLastLoadParams(userId, canViewAllEvents);
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
QuerySnapshot eventsSnapshot = await _firestore.collection('events').get(); try {
print('Found ${eventsSnapshot.docs.length} events total'); print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
// Charger via l'API - les permissions sont vérifiées côté serveur
final result = await _dataService.getEvents(userId: userId);
final eventsData = result['events'] as List<Map<String, dynamic>>;
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>)
);
print('Found ${eventsData.length} events from API');
List<EventModel> allEvents = []; List<EventModel> allEvents = [];
int failedCount = 0; int failedCount = 0;
// Parser chaque événement individuellement pour éviter qu'une erreur interrompe tout // Parser chaque événement
for (var doc in eventsSnapshot.docs) { for (var eventData in eventsData) {
try { try {
final data = doc.data() as Map<String, dynamic>; final event = EventModel.fromMap(eventData, eventData['id'] as String);
final event = EventModel.fromMap(data, doc.id);
allEvents.add(event); allEvents.add(event);
} catch (e) { } catch (e) {
print('Failed to parse event ${doc.id}: $e'); print('Failed to parse event ${eventData['id']}: $e');
failedCount++; failedCount++;
// Continue avec les autres événements au lieu d'arrêter
} }
} }
// Filtrage amélioré pour les utilisateurs non-admin
if (canViewAllEvents) {
_events = allEvents; _events = allEvents;
print('Admin user: showing all ${_events.length} events'); print('Successfully loaded ${_events.length} events (${failedCount} failed)');
} else {
// Créer la référence utilisateur correctement
final userDocRef = _firestore.collection('users').doc(userId);
_events = allEvents.where((event) {
// Vérifier si l'utilisateur est dans l'équipe de l'événement
bool isInWorkforce = event.workforce.any((workforceRef) {
return workforceRef.path == userDocRef.path;
});
if (isInWorkforce) {
print('Event ${event.name} includes user $userId');
}
return isInWorkforce;
}).toList();
}
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
print('Error loading events: $e'); print('Error loading events: $e');
_isLoading = false; _isLoading = false;
_events = []; // S'assurer que la liste est vide en cas d'erreur
notifyListeners(); notifyListeners();
rethrow; rethrow;
} }
} }
// Récupérer un événement spécifique /// Recharger les événements (utilise le dernier userId)
Future<EventModel?> getEvent(String eventId) async { Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
try { await loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
final doc = await _firestore.collection('events').doc(eventId).get();
if (doc.exists) {
return EventModel.fromMap(doc.data()!, doc.id);
} }
return null;
/// Récupérer un événement spécifique par ID
EventModel? getEventById(String eventId) {
try {
return _events.firstWhere((event) => event.id == eventId);
} catch (e) { } catch (e) {
print('Error getting event: $e'); return null;
rethrow;
} }
} }
// Ajouter un nouvel événement /// Ajouter un nouvel événement
Future<void> addEvent(EventModel event) async { Future<void> addEvent(EventModel event) async {
try { try {
final docRef = await _firestore.collection('events').add(event.toMap()); // L'événement est créé via l'API dans le service
final newEvent = EventModel.fromMap(event.toMap(), docRef.id); await refreshEvents(_lastUserId ?? '', canViewAllEvents: _lastCanViewAll);
_events.add(newEvent);
notifyListeners();
} catch (e) { } catch (e) {
print('Error adding event: $e'); print('Error adding event: $e');
rethrow; rethrow;
} }
} }
// Mettre à jour un événement /// Mettre à jour un événement
Future<void> updateEvent(EventModel event) async { Future<void> updateEvent(EventModel event) async {
try { try {
await _firestore.collection('events').doc(event.id).update(event.toMap()); // Mise à jour locale immédiate
final index = _events.indexWhere((e) => e.id == event.id); final index = _events.indexWhere((e) => e.id == event.id);
if (index != -1) { if (index != -1) {
_events[index] = event; _events[index] = event;
@@ -116,15 +105,11 @@ class EventProvider with ChangeNotifier {
} }
} }
// Supprimer un événement /// Supprimer un événement
Future<void> deleteEvent(String eventId) async { Future<void> deleteEvent(String eventId) async {
try { try {
await _firestore.collection('events').doc(eventId).delete(); await _dataService.deleteEvent(eventId);
_events.removeWhere((event) => event.id == eventId); _events.removeWhere((event) => event.id == eventId);
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
print('Error deleting event: $e'); print('Error deleting event: $e');
@@ -132,9 +117,56 @@ class EventProvider with ChangeNotifier {
} }
} }
// Vider la liste des événements /// Récupérer les données d'un utilisateur depuis le cache
Map<String, dynamic>? getUserFromCache(String uid) {
return _usersCache[uid];
}
/// Récupérer les utilisateurs de la workforce d'un événement
List<Map<String, dynamic>> getWorkforceUsers(EventModel event) {
final users = <Map<String, dynamic>>[];
for (final dynamic userRef in event.workforce) {
try {
String? uid;
// Tenter d'extraire l'UID
if (userRef is String) {
uid = userRef;
} else {
// Essayer d'extraire l'ID si c'est une DocumentReference
final ref = userRef as DocumentReference?;
uid = ref?.id;
}
if (uid != null) {
final userData = getUserFromCache(uid);
if (userData != null) {
users.add(userData);
}
}
} catch (e) {
// Ignorer les références invalides
print('Skipping invalid workforce reference: $userRef');
}
}
return users;
}
/// Vider la liste des événements
void clearEvents() { void clearEvents() {
_events = []; _events = [];
notifyListeners(); notifyListeners();
} }
// Variables pour stocker le dernier appel
String? _lastUserId;
bool _lastCanViewAll = false;
/// Sauvegarder les paramètres du dernier chargement
void _saveLastLoadParams(String userId, bool canViewAllEvents) {
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
}
} }

View File

@@ -1,17 +1,19 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../models/user_model.dart'; import '../models/user_model.dart';
import '../models/role_model.dart'; import '../models/role_model.dart';
import '../models/notification_preferences_model.dart';
import '../utils/firebase_storage_manager.dart'; import '../utils/firebase_storage_manager.dart';
import '../services/api_service.dart';
import '../services/data_service.dart';
class LocalUserProvider with ChangeNotifier { class LocalUserProvider with ChangeNotifier {
UserModel? _currentUser; UserModel? _currentUser;
RoleModel? _currentRole; RoleModel? _currentRole;
final FirebaseAuth _auth = FirebaseAuth.instance; final FirebaseAuth _auth = FirebaseAuth.instance;
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseStorageManager _storageManager = FirebaseStorageManager(); final FirebaseStorageManager _storageManager = FirebaseStorageManager();
final DataService _dataService = DataService(apiService);
UserModel? get currentUser => _currentUser; UserModel? get currentUser => _currentUser;
String? get uid => _currentUser?.uid; String? get uid => _currentUser?.uid;
@@ -24,7 +26,7 @@ class LocalUserProvider with ChangeNotifier {
RoleModel? get currentRole => _currentRole; RoleModel? get currentRole => _currentRole;
List<String> get permissions => _currentRole?.permissions ?? []; List<String> get permissions => _currentRole?.permissions ?? [];
/// Charge les données de l'utilisateur actuel /// Charge les données de l'utilisateur actuel via Cloud Function
Future<void> loadUserData() async { Future<void> loadUserData() async {
if (_auth.currentUser == null) { if (_auth.currentUser == null) {
print('No current user in Auth'); print('No current user in Auth');
@@ -33,53 +35,31 @@ class LocalUserProvider with ChangeNotifier {
print('Loading user data for: ${_auth.currentUser!.uid}'); print('Loading user data for: ${_auth.currentUser!.uid}');
try { try {
DocumentSnapshot userDoc = await _firestore // Utiliser la Cloud Function getCurrentUser
.collection('users') final result = await apiService.call('getCurrentUser', {});
.doc(_auth.currentUser!.uid) final userData = result['user'] as Map<String, dynamic>;
.get();
if (userDoc.exists) { print('User data loaded from API: ${userData['uid']}');
print('User document found in Firestore');
final userData = userDoc.data() as Map<String, dynamic>;
print('User data: $userData');
// Si le document n'a pas d'UID, l'ajouter // Extraire le rôle
if (!userData.containsKey('uid')) { final roleData = userData['role'] as Map<String, dynamic>?;
await userDoc.reference.update({'uid': _auth.currentUser!.uid}); if (roleData != null) {
userData['uid'] = _auth.currentUser!.uid; _currentRole = RoleModel.fromMap(roleData, roleData['id'] as String);
} }
setUser(UserModel.fromMap(userData, userDoc.id)); // Créer le UserModel
print('User data loaded successfully'); _currentUser = UserModel(
await loadRole(); uid: userData['uid'] as String,
} else { email: userData['email'] as String? ?? '',
print('No user document found in Firestore'); firstName: userData['firstName'] as String? ?? '',
// Créer un document utilisateur par défaut lastName: userData['lastName'] as String? ?? '',
final defaultUser = UserModel( role: roleData?['id'] as String? ?? 'USER',
uid: _auth.currentUser!.uid, phoneNumber: userData['phoneNumber'] as String? ?? '',
email: _auth.currentUser!.email ?? '', profilePhotoUrl: userData['profilePhotoUrl'] as String? ?? '',
firstName: '',
lastName: '',
role: 'USER',
phoneNumber: '',
profilePhotoUrl: '',
); );
await _firestore.collection('users').doc(_auth.currentUser!.uid).set({ print('User data loaded successfully');
'uid': _auth.currentUser!.uid, notifyListeners();
'email': _auth.currentUser!.email,
'firstName': '',
'lastName': '',
'role': 'USER',
'phoneNumber': '',
'profilePhotoUrl': '',
'createdAt': FieldValue.serverTimestamp(),
});
setUser(defaultUser);
print('Default user document created');
await loadRole();
}
} catch (e) { } catch (e) {
print('Error loading user data: $e'); print('Error loading user data: $e');
rethrow; rethrow;
@@ -95,28 +75,55 @@ class LocalUserProvider with ChangeNotifier {
/// Efface les données utilisateur /// Efface les données utilisateur
void clearUser() { void clearUser() {
_currentUser = null; _currentUser = null;
_currentRole = null;
notifyListeners(); notifyListeners();
} }
/// Mise à jour des informations utilisateur /// Mise à jour des informations utilisateur via Cloud Function
Future<void> updateUserData( Future<void> updateUserData({
{String? firstName, String? lastName, String? phoneNumber}) async { String? firstName,
String? lastName,
String? phoneNumber,
}) async {
if (_currentUser == null) return; if (_currentUser == null) return;
try { try {
await _firestore.collection('users').doc(_currentUser!.uid).set({ await _dataService.updateUser(
_currentUser!.uid,
{
'firstName': firstName ?? _currentUser!.firstName, 'firstName': firstName ?? _currentUser!.firstName,
'lastName': lastName ?? _currentUser!.lastName, 'lastName': lastName ?? _currentUser!.lastName,
'phone': phoneNumber ?? _currentUser!.phoneNumber, 'phoneNumber': phoneNumber ?? _currentUser!.phoneNumber,
}, SetOptions(merge: true)); },
);
_currentUser = _currentUser!.copyWith( _currentUser = _currentUser!.copyWith(
firstName: firstName ?? _currentUser!.firstName, firstName: firstName,
lastName: lastName ?? _currentUser!.lastName, lastName: lastName,
phoneNumber: phoneNumber ?? _currentUser!.phoneNumber, phoneNumber: phoneNumber,
); );
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Erreur mise à jour utilisateur : $e'); debugPrint('Erreur mise à jour utilisateur : $e');
rethrow;
}
}
/// Mise à jour des préférences de notifications
Future<void> updateNotificationPreferences(NotificationPreferences preferences) async {
if (_currentUser == null) return;
try {
await _dataService.updateUser(
_currentUser!.uid,
{
'notificationPreferences': preferences.toMap(),
},
);
_currentUser = _currentUser!.copyWith(notificationPreferences: preferences);
notifyListeners();
} catch (e) {
debugPrint('Erreur mise à jour préférences notifications : $e');
rethrow;
} }
} }
@@ -129,16 +136,18 @@ class LocalUserProvider with ChangeNotifier {
uid: _currentUser!.uid, uid: _currentUser!.uid,
); );
if (newProfilePhotoUrl != null) { if (newProfilePhotoUrl != null) {
_firestore // Mettre à jour via Cloud Function
.collection('users') await _dataService.updateUser(
.doc(_currentUser!.uid) _currentUser!.uid,
.update({'profilePhotoUrl': newProfilePhotoUrl}); {'profilePhotoUrl': newProfilePhotoUrl},
_currentUser = );
_currentUser!.copyWith(profilePhotoUrl: newProfilePhotoUrl);
_currentUser = _currentUser!.copyWith(profilePhotoUrl: newProfilePhotoUrl);
notifyListeners(); notifyListeners();
} }
} catch (e) { } catch (e) {
debugPrint('Erreur mise à jour photo de profil : $e'); debugPrint('Erreur mise à jour photo de profil : $e');
rethrow;
} }
} }
@@ -161,23 +170,20 @@ class LocalUserProvider with ChangeNotifier {
clearUser(); clearUser();
} }
Future<void> loadRole() async { /// Vérifie si l'utilisateur a une permission spécifique
if (_currentUser == null) return;
final roleId = _currentUser!.role;
if (roleId.isEmpty) return;
try {
final doc = await _firestore.collection('roles').doc(roleId).get();
if (doc.exists) {
_currentRole =
RoleModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
notifyListeners();
}
} catch (e) {
print('Error loading role: $e');
}
}
bool hasPermission(String permission) { bool hasPermission(String permission) {
return _currentRole?.permissions.contains(permission) ?? false; return _currentRole?.permissions.contains(permission) ?? false;
} }
/// Vérifie si l'utilisateur a toutes les permissions données
bool hasAllPermissions(List<String> permissions) {
if (_currentRole == null) return false;
return permissions.every((p) => _currentRole!.permissions.contains(p));
}
/// Vérifie si l'utilisateur a au moins une des permissions données
bool hasAnyPermission(List<String> permissions) {
if (_currentRole == null) return false;
return permissions.any((p) => _currentRole!.permissions.contains(p));
}
} }

View File

@@ -10,14 +10,14 @@ class MaintenanceProvider extends ChangeNotifier {
// Getters // Getters
List<MaintenanceModel> get maintenances => _maintenances; List<MaintenanceModel> get maintenances => _maintenances;
/// Stream des maintenances pour un équipement spécifique /// Récupérer les maintenances pour un équipement spécifique
Stream<List<MaintenanceModel>> getMaintenancesStream(String equipmentId) { Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async {
return _service.getMaintenances(equipmentId); return await _service.getMaintenancesByEquipment(equipmentId);
} }
/// Stream de toutes les maintenances /// Récupérer toutes les maintenances
Stream<List<MaintenanceModel>> get allMaintenancesStream { Future<List<MaintenanceModel>> getAllMaintenances() async {
return _service.getAllMaintenances(); return await _service.getAllMaintenances();
} }
/// Créer une nouvelle maintenance /// Créer une nouvelle maintenance

View File

@@ -0,0 +1,51 @@
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();
}
}

View File

@@ -1,54 +1,54 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/user_model.dart';
import '../services/user_service.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:provider/provider.dart'; import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class UsersProvider with ChangeNotifier { class UsersProvider with ChangeNotifier {
final UserService _userService; final DataService _dataService = DataService(FirebaseFunctionsApiService());
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseAuth _auth = FirebaseAuth.instance;
List<UserModel> _users = []; List<UserModel> _users = [];
bool _isLoading = false; bool _isLoading = false;
List<UserModel> get users => _users; List<UserModel> get users => _users;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
UsersProvider(this._userService); /// Récupération de tous les utilisateurs via l'API
/// Récupération de tous les utilisateurs
Future<void> fetchUsers() async { Future<void> fetchUsers() async {
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
try { try {
final snapshot = await _firestore.collection('users').get(); final usersData = await _dataService.getUsers();
_users = snapshot.docs _users = usersData.map((data) {
.map((doc) => UserModel.fromMap(doc.data(), doc.id)) return UserModel.fromMap(data, data['id'] as String);
.toList(); }).toList();
} catch (e) { } catch (e) {
print('Error fetching users: $e'); print('Error fetching users: $e');
_users = [];
} }
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
} }
/// Mise à jour d'un utilisateur /// Recharger les utilisateurs
Future<void> updateUser(UserModel user, {String? roleId}) async { Future<void> refresh() async {
await fetchUsers();
}
/// Obtenir un utilisateur par ID
UserModel? getUserById(String uid) {
try { try {
await _firestore.collection('users').doc(user.uid).update({ return _users.firstWhere((u) => u.uid == uid);
'firstName': user.firstName, } catch (e) {
'lastName': user.lastName, return null;
'email': user.email, }
'phoneNumber': user.phoneNumber, }
'role': roleId != null
? _firestore.collection('roles').doc(roleId) /// Mettre à jour un utilisateur
: user.role, Future<void> updateUser(UserModel user) async {
'profilePhotoUrl': user.profilePhotoUrl, try {
}); await _dataService.updateUser(user.uid, user.toMap());
final index = _users.indexWhere((u) => u.uid == user.uid); final index = _users.indexWhere((u) => u.uid == user.uid);
if (index != -1) { if (index != -1) {
@@ -61,10 +61,10 @@ class UsersProvider with ChangeNotifier {
} }
} }
/// Suppression d'un utilisateur /// Suppression d'un utilisateur via Cloud Function
Future<void> deleteUser(String uid) async { Future<void> deleteUser(String uid) async {
try { try {
await _firestore.collection('users').doc(uid).delete(); await _dataService.deleteUser(uid);
_users.removeWhere((user) => user.uid == uid); _users.removeWhere((user) => user.uid == uid);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
@@ -73,97 +73,44 @@ class UsersProvider with ChangeNotifier {
} }
} }
/// Réinitialisation du mot de passe /// Créer un utilisateur avec invitation par email
Future<void> resetPassword(String email) async { Future<void> createUserWithEmailInvite({
await _userService.resetPassword(email); required String email,
} required String firstName,
required String lastName,
Future<void> createUserWithEmailInvite(BuildContext context, UserModel user, String? phoneNumber,
{String? roleId}) async { required String roleId,
String? authUid; }) async {
try { try {
// Vérifier l'état de l'authentification print('Creating user with email invite: $email');
final currentUser = _auth.currentUser;
print('Current user: ${currentUser?.email}');
if (currentUser == null) { // Appeler la Cloud Function pour créer l'utilisateur
throw Exception('Aucun utilisateur connecté'); await _dataService.createUserWithInvite(
} email: email,
firstName: firstName,
// Vérifier la permission via le provider lastName: lastName,
final localUserProvider = phoneNumber: phoneNumber,
Provider.of<LocalUserProvider>(context, listen: false); roleId: roleId,
if (!localUserProvider.hasPermission('add_user')) {
throw Exception(
'Vous n\'avez pas la permission de créer des utilisateurs');
}
try {
// Créer l'utilisateur dans Firebase Authentication
final userCredential = await _auth.createUserWithEmailAndPassword(
email: user.email,
password: 'TemporaryPassword123!', // Mot de passe temporaire
); );
authUid = userCredential.user!.uid; // Recharger la liste des utilisateurs
print('User created in Auth with UID: $authUid'); await fetchUsers();
// Créer le document dans Firestore avec l'UID de Auth comme ID print('User created successfully: $email');
await _firestore.collection('users').doc(authUid).set({
'uid': authUid,
'firstName': user.firstName,
'lastName': user.lastName,
'email': user.email,
'phoneNumber': user.phoneNumber,
'role': roleId != null
? _firestore.collection('roles').doc(roleId)
: user.role,
'profilePhotoUrl': user.profilePhotoUrl,
'createdAt': FieldValue.serverTimestamp(),
});
print('User document created in Firestore with Auth UID');
// Envoyer un email de réinitialisation de mot de passe
await _auth.sendPasswordResetEmail(
email: user.email,
actionCodeSettings: ActionCodeSettings(
url: 'http://app.em2events.fr/finishSignUp?email=${user.email}',
handleCodeInApp: true,
androidPackageName: 'com.em2rp.app',
androidInstallApp: true,
androidMinimumVersion: '12',
),
);
print('Password reset email sent');
// Ajouter l'utilisateur à la liste locale
final newUser = UserModel(
uid: authUid,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
phoneNumber: user.phoneNumber,
role: roleId ?? user.role,
profilePhotoUrl: user.profilePhotoUrl,
);
_users.add(newUser);
notifyListeners();
} catch (e) { } catch (e) {
// En cas d'erreur, supprimer l'utilisateur Auth si créé print('Error creating user with email invite: $e');
if (authUid != null) {
try {
await _auth.currentUser?.delete();
} catch (deleteError) {
print('Warning: Could not delete Auth user: $deleteError');
}
}
rethrow; rethrow;
} }
}
/// Réinitialisation du mot de passe
Future<void> resetPassword(String email) async {
try {
// Firebase Auth reste OK (ce n'est pas Firestore)
await FirebaseAuth.instance.sendPasswordResetEmail(email: email);
print('Email de réinitialisation envoyé à $email');
} catch (e) { } catch (e) {
print('Error creating user: $e'); print('Error reset password: $e');
rethrow; rethrow;
} }
} }

View File

@@ -0,0 +1,255 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../models/alert_model.dart';
import '../utils/debug_log.dart';
import 'api_service.dart' show FirebaseFunctionsApiService;
/// Service de gestion des alertes
/// 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;
/// Stream des alertes pour l'utilisateur connecté
Stream<List<AlertModel>> getAlertsStream() {
final user = _auth.currentUser;
if (user == null) {
DebugLog.info('[AlertService] Pas d\'utilisateur connecté');
return Stream.value([]);
}
DebugLog.info('[AlertService] Stream alertes pour utilisateur: ${user.uid}');
return _firestore
.collection('alerts')
.where('assignedTo', arrayContains: user.uid)
.where('status', isEqualTo: 'ACTIVE')
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
final alerts = snapshot.docs
.map((doc) => AlertModel.fromFirestore(doc))
.toList();
DebugLog.info('[AlertService] ${alerts.length} alertes actives');
return alerts;
});
}
/// Récupère les alertes non lues
Future<List<AlertModel>> getUnreadAlerts() async {
final user = _auth.currentUser;
if (user == null) return [];
try {
final snapshot = await _firestore
.collection('alerts')
.where('assignedTo', arrayContains: user.uid)
.where('isRead', isEqualTo: false)
.where('status', isEqualTo: 'ACTIVE')
.orderBy('createdAt', descending: true)
.get();
return snapshot.docs
.map((doc) => AlertModel.fromFirestore(doc))
.toList();
} catch (e) {
DebugLog.error('[AlertService] Erreur récupération alertes', e);
return [];
}
}
/// Marque une alerte comme lue
Future<void> markAsRead(String alertId) async {
try {
await _firestore.collection('alerts').doc(alertId).update({
'isRead': true,
'readAt': FieldValue.serverTimestamp(),
});
DebugLog.info('[AlertService] Alerte $alertId marquée comme lue');
} catch (e) {
DebugLog.error('[AlertService] Erreur marquage alerte', e);
rethrow;
}
}
/// Marque toutes les alertes comme lues
Future<void> markAllAsRead() async {
final user = _auth.currentUser;
if (user == null) return;
try {
final snapshot = await _firestore
.collection('alerts')
.where('assignedTo', arrayContains: user.uid)
.where('isRead', isEqualTo: false)
.get();
final batch = _firestore.batch();
for (var doc in snapshot.docs) {
batch.update(doc.reference, {
'isRead': true,
'readAt': FieldValue.serverTimestamp(),
});
}
await batch.commit();
DebugLog.info('[AlertService] ${snapshot.docs.length} alertes marquées comme lues');
} catch (e) {
DebugLog.error('[AlertService] Erreur marquage alertes', e);
rethrow;
}
}
/// Archive une alerte
Future<void> archiveAlert(String alertId) async {
try {
await _firestore.collection('alerts').doc(alertId).update({
'status': 'ARCHIVED',
'archivedAt': FieldValue.serverTimestamp(),
});
DebugLog.info('[AlertService] Alerte $alertId archivée');
} catch (e) {
DebugLog.error('[AlertService] Erreur archivage alerte', e);
rethrow;
}
}
/// Crée une alerte manuelle (appelée par l'utilisateur)
/// Cette méthode appelle la Cloud Function createAlert
Future<String> createManualAlert({
required AlertType type,
required AlertSeverity severity,
required String message,
String? title,
String? equipmentId,
String? eventId,
String? actionUrl,
Map<String, dynamic>? metadata,
}) async {
try {
DebugLog.info('[AlertService] === CRÉATION ALERTE MANUELLE ===');
DebugLog.info('[AlertService] Type: $type');
DebugLog.info('[AlertService] Severity: $severity');
final apiService = FirebaseFunctionsApiService();
final result = await apiService.call(
'createAlert',
{
'type': alertTypeToString(type),
'severity': severity.name.toUpperCase(),
'title': title,
'message': message,
'equipmentId': equipmentId,
'eventId': eventId,
'actionUrl': actionUrl,
'metadata': metadata ?? {},
},
);
final alertId = result['alertId'] as String;
DebugLog.info('[AlertService] ✓ Alerte créée: $alertId');
return alertId;
} catch (e, stackTrace) {
DebugLog.error('[AlertService] ❌ Erreur création alerte', e);
DebugLog.error('[AlertService] Stack', stackTrace);
rethrow;
}
}
/// Stream des alertes pour un utilisateur spécifique
Stream<List<AlertModel>> alertsStreamForUser(String userId) {
return _firestore
.collection('alerts')
.where('assignedTo', arrayContains: userId)
.where('status', isEqualTo: 'ACTIVE')
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) => snapshot.docs
.map((doc) => AlertModel.fromFirestore(doc))
.toList());
}
/// Récupère les alertes pour un utilisateur
Future<List<AlertModel>> getAlertsForUser(String userId) async {
try {
final snapshot = await _firestore
.collection('alerts')
.where('assignedTo', arrayContains: userId)
.where('status', isEqualTo: 'ACTIVE')
.orderBy('createdAt', descending: true)
.get();
return snapshot.docs
.map((doc) => AlertModel.fromFirestore(doc))
.toList();
} catch (e) {
DebugLog.error('[AlertService] Erreur récupération alertes', e);
return [];
}
}
/// Stream du nombre d'alertes non lues pour un utilisateur
Stream<int> unreadCountStreamForUser(String userId) {
return _firestore
.collection('alerts')
.where('assignedTo', arrayContains: userId)
.where('isRead', isEqualTo: false)
.where('status', isEqualTo: 'ACTIVE')
.snapshots()
.map((snapshot) => snapshot.docs.length);
}
/// Supprime une alerte
Future<void> deleteAlert(String alertId) async {
try {
await _firestore.collection('alerts').doc(alertId).delete();
DebugLog.info('[AlertService] Alerte $alertId supprimée');
} catch (e) {
DebugLog.error('[AlertService] Erreur suppression alerte', e);
rethrow;
}
}
/// Crée une alerte de création d'événement
Future<void> createEventCreatedAlert({
required String eventId,
required String eventName,
required DateTime eventDate,
}) async {
await createManualAlert(
type: AlertType.eventCreated,
severity: AlertSeverity.info,
message: 'Nouvel événement créé: "$eventName" le ${_formatDate(eventDate)}',
eventId: eventId,
metadata: {
'eventName': eventName,
'eventDate': eventDate.toIso8601String(),
},
);
}
/// Crée une alerte de modification d'événement
Future<void> createEventModifiedAlert({
required String eventId,
required String eventName,
required String modification,
}) async {
await createManualAlert(
type: AlertType.eventModified,
severity: AlertSeverity.info,
message: 'Événement "$eventName" modifié: $modification',
eventId: eventId,
metadata: {
'eventName': eventName,
'modification': modification,
},
);
}
String _formatDate(DateTime date) {
return '${date.day}/${date.month}/${date.year}';
}
}

View File

@@ -0,0 +1,360 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:em2rp/config/api_config.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Interface abstraite pour les opérations API
/// Permet de changer facilement de backend (Firebase Functions, REST API personnalisé, etc.)
abstract class ApiService {
Future<Map<String, dynamic>> call(String functionName, Map<String, dynamic> data);
Future<T?> get<T>(String endpoint, {Map<String, dynamic>? params});
Future<T> post<T>(String endpoint, Map<String, dynamic> data);
Future<T> put<T>(String endpoint, Map<String, dynamic> data);
Future<void> delete(String endpoint, {Map<String, dynamic>? data});
}
/// Implémentation pour Firebase Cloud Functions
class FirebaseFunctionsApiService implements ApiService {
// URL de base - gérée par ApiConfig
String get _baseUrl => ApiConfig.baseUrl;
/// Récupère le token d'authentification Firebase
Future<String?> _getAuthToken() async {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return null;
return await user.getIdToken();
}
/// Headers par défaut avec authentification
Future<Map<String, String>> _getHeaders() async {
final token = await _getAuthToken();
return {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
}
/// Convertit récursivement TOUT en types JSON standards (String, num, bool, List, Map)
/// Garantit que toutes les Maps sont des Map<String, dynamic> littérales
dynamic _toJsonSafe(dynamic value) {
if (value == null) return null;
// Types primitifs JSON-safe
if (value is String || value is num || value is bool) {
return value;
}
// Types Firestore
if (value is Timestamp) {
return value.toDate().toIso8601String();
}
if (value is DateTime) {
return value.toIso8601String();
}
if (value is DocumentReference) {
return value.path;
}
if (value is GeoPoint) {
// Créer une Map littérale explicite
return <String, dynamic>{
'latitude': value.latitude,
'longitude': value.longitude,
};
}
// Listes - créer une nouvelle List littérale
if (value is List) {
final result = <dynamic>[];
for (final item in value) {
result.add(_toJsonSafe(item));
}
return result;
}
// Maps - créer une nouvelle Map littérale explicite
if (value is Map) {
final result = <String, dynamic>{};
value.forEach((k, v) {
final key = k.toString();
final convertedValue = _toJsonSafe(v);
result[key] = convertedValue;
});
return result;
}
// Type non supporté - retourner en String
return value.toString();
}
/// Prépare les données pour jsonEncode en faisant un double passage
Map<String, dynamic> _prepareForJson(Map<String, dynamic> data) {
try {
// Premier passage : convertir tous les types Firestore
final safeData = _toJsonSafe(data);
// Deuxième passage : encoder puis décoder pour forcer la normalisation
// Cela garantit que tout est 100% compatible JSON et élimine tous les _JsonMap
final jsonString = jsonEncode(safeData);
final decoded = jsonDecode(jsonString);
// Force le type Map<String, dynamic>
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
// Fallback - ne devrait jamais arriver
return Map<String, dynamic>.from(safeData as Map);
} catch (e) {
// Si l'encodage échoue, essayer de créer une copie profonde manuelle
DebugLog.error('[API] Error in _prepareForJson', e);
DebugLog.info('[API] Trying manual deep copy...');
return _deepCopyMap(data);
}
}
/// Copie profonde manuelle d'une Map pour éviter les _JsonMap
Map<String, dynamic> _deepCopyMap(Map<String, dynamic> source) {
final result = <String, dynamic>{};
source.forEach((key, value) {
if (value is Map) {
result[key] = _deepCopyMap(Map<String, dynamic>.from(value));
} else if (value is List) {
result[key] = _deepCopyList(value);
} else {
result[key] = value;
}
});
return result;
}
/// Copie profonde manuelle d'une List
List<dynamic> _deepCopyList(List<dynamic> source) {
return source.map((item) {
if (item is Map) {
return _deepCopyMap(Map<String, dynamic>.from(item));
} else if (item is List) {
return _deepCopyList(item);
} else {
return item;
}
}).toList();
}
@override
Future<Map<String, dynamic>> call(String functionName, Map<String, dynamic> data) async {
final url = Uri.parse('$_baseUrl/$functionName');
final headers = await _getHeaders();
// Préparer les données avec double passage pour éviter les _JsonMap
final preparedData = _prepareForJson(data);
// Log pour débogage (seulement en mode debug)
DebugLog.info('[API] Calling $functionName with eventId: ${preparedData['eventId']}');
try {
// Encoder directement avec jsonEncode standard
final bodyJson = jsonEncode({'data': preparedData});
final response = await http.post(
url,
headers: headers,
body: bodyJson,
);
if (response.statusCode >= 200 && response.statusCode < 300) {
final responseData = jsonDecode(response.body);
return responseData is Map<String, dynamic> ? responseData : {};
} else {
final error = jsonDecode(response.body);
throw ApiException(
message: error['error'] ?? 'Unknown error',
statusCode: response.statusCode,
);
}
} catch (e) {
DebugLog.error('[API] Error during request: $functionName', e);
throw ApiException(
message: 'Error calling $functionName: $e',
statusCode: 0,
);
}
}
@override
Future<T?> get<T>(String endpoint, {Map<String, dynamic>? params}) async {
final url = Uri.parse('$_baseUrl/$endpoint').replace(queryParameters: params);
final headers = await _getHeaders();
final response = await http.get(url, headers: headers);
if (response.statusCode >= 200 && response.statusCode < 300) {
final responseData = jsonDecode(response.body);
return responseData as T?;
} else if (response.statusCode == 404) {
return null;
} else {
final error = jsonDecode(response.body);
throw ApiException(
message: error['error'] ?? 'Unknown error',
statusCode: response.statusCode,
);
}
}
@override
Future<T> post<T>(String endpoint, Map<String, dynamic> data) async {
final url = Uri.parse('$_baseUrl/$endpoint');
final headers = await _getHeaders();
// Préparer les données avec double passage
final preparedData = _prepareForJson(data);
final response = await http.post(
url,
headers: headers,
body: jsonEncode({'data': preparedData}),
);
if (response.statusCode >= 200 && response.statusCode < 300) {
final responseData = jsonDecode(response.body);
return responseData as T;
} else {
final error = jsonDecode(response.body);
throw ApiException(
message: error['error'] ?? 'Unknown error',
statusCode: response.statusCode,
);
}
}
@override
Future<T> put<T>(String endpoint, Map<String, dynamic> data) async {
final url = Uri.parse('$_baseUrl/$endpoint');
final headers = await _getHeaders();
// Préparer les données avec double passage
final preparedData = _prepareForJson(data);
final response = await http.put(
url,
headers: headers,
body: jsonEncode({'data': preparedData}),
);
if (response.statusCode >= 200 && response.statusCode < 300) {
final responseData = jsonDecode(response.body);
return responseData as T;
} else {
final error = jsonDecode(response.body);
throw ApiException(
message: error['error'] ?? 'Unknown error',
statusCode: response.statusCode,
);
}
}
@override
Future<void> delete(String endpoint, {Map<String, dynamic>? data}) async {
final url = Uri.parse('$_baseUrl/$endpoint');
final headers = await _getHeaders();
// Préparer les données avec double passage si data existe
final preparedData = data != null ? _prepareForJson(data) : null;
final response = await http.delete(
url,
headers: headers,
body: preparedData != null ? jsonEncode({'data': preparedData}) : null,
);
if (response.statusCode < 200 || response.statusCode >= 300) {
final error = jsonDecode(response.body);
throw ApiException(
message: error['error'] ?? 'Unknown error',
statusCode: response.statusCode,
);
}
}
/// Appelle une Cloud Function avec pagination
Future<Map<String, dynamic>> callPaginated(
String functionName,
Map<String, dynamic> params,
) async {
try {
final headers = await _getHeaders();
final url = Uri.parse('$_baseUrl/$functionName');
DebugLog.info('[API] Calling paginated function: $functionName with params: $params');
final response = await http.post(
url,
headers: headers,
body: jsonEncode({'data': params}),
);
DebugLog.info('[API] Response status: ${response.statusCode}');
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data;
} else {
DebugLog.error('[API] Error response: ${response.body}');
throw Exception('API call failed with status ${response.statusCode}: ${response.body}');
}
} catch (e) {
DebugLog.error('[API] Exception in callPaginated: $e');
rethrow;
}
}
/// Recherche rapide avec autocomplétion
Future<List<Map<String, dynamic>>> quickSearch(
String query, {
int limit = 10,
bool includeEquipments = true,
bool includeContainers = true,
}) async {
try {
final params = {
'query': query,
'limit': limit,
'includeEquipments': includeEquipments.toString(),
'includeContainers': includeContainers.toString(),
};
final response = await callPaginated('quickSearch', params);
final results = response['results'] as List<dynamic>? ?? [];
return results.cast<Map<String, dynamic>>();
} catch (e) {
DebugLog.error('[API] Error in quickSearch: $e');
return [];
}
}
}
/// Exception personnalisée pour les erreurs API
class ApiException implements Exception {
final String message;
final int statusCode;
ApiException({
required this.message,
required this.statusCode,
});
@override
String toString() => 'ApiException($statusCode): $message';
bool get isForbidden => statusCode == 403;
bool get isUnauthorized => statusCode == 401;
bool get isNotFound => statusCode == 404;
bool get isConflict => statusCode == 409;
}
/// Instance singleton du service API
final ApiService apiService = FirebaseFunctionsApiService();

View File

@@ -0,0 +1,52 @@
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
/// Service pour gérer la relation entre containers et équipements
/// Utilise le principe : seul le container stocke la référence aux équipements
class ContainerEquipmentService {
final DataService _dataService = DataService(apiService);
/// Récupère tous les containers contenant un équipement spécifique
/// Utilise une Cloud Function avec authentification et permissions
Future<List<ContainerModel>> getContainersByEquipment(String equipmentId) async {
try {
final containersData = await _dataService.getContainersByEquipment(equipmentId);
return containersData.map((data) {
// L'ID est dans le champ 'id' retourné par la fonction
final id = data['id'] as String;
return ContainerModel.fromMap(data, id);
}).toList();
} catch (e) {
print('[ContainerEquipmentService] Error getting containers for equipment $equipmentId: $e');
rethrow;
}
}
/// Vérifie si un équipement est dans au moins un container
Future<bool> isEquipmentInAnyContainer(String equipmentId) async {
try {
final containers = await getContainersByEquipment(equipmentId);
return containers.isNotEmpty;
} catch (e) {
print('[ContainerEquipmentService] Error checking if equipment is in container: $e');
return false;
}
}
/// Récupère le nombre de containers contenant un équipement
Future<int> getContainerCountForEquipment(String equipmentId) async {
try {
final containers = await getContainersByEquipment(equipmentId);
return containers.length;
} catch (e) {
print('[ContainerEquipmentService] Error getting container count: $e');
return 0;
}
}
}
/// Instance globale singleton
final containerEquipmentService = ContainerEquipmentService();

View File

@@ -1,61 +1,44 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/services/data_service.dart';
class ContainerService { class ContainerService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final ApiService _apiService = apiService;
final DataService _dataService = DataService(apiService);
// Collection references // ============================================================================
CollectionReference get _containersCollection => _firestore.collection('containers'); // CRUD Operations - Utilise le backend sécurisé
CollectionReference get _equipmentCollection => _firestore.collection('equipments'); // ============================================================================
// CRUD Operations /// Créer un nouveau container (via Cloud Function)
/// Créer un nouveau container
Future<void> createContainer(ContainerModel container) async { Future<void> createContainer(ContainerModel container) async {
try { try {
await _containersCollection.doc(container.id).set(container.toMap()); await _apiService.call('createContainer', container.toMap()..['id'] = container.id);
} catch (e) { } catch (e) {
print('Error creating container: $e'); print('Error creating container: $e');
rethrow; rethrow;
} }
} }
/// Mettre à jour un container /// Mettre à jour un container (via Cloud Function)
Future<void> updateContainer(String id, Map<String, dynamic> data) async { Future<void> updateContainer(String id, Map<String, dynamic> data) async {
try { try {
data['updatedAt'] = Timestamp.fromDate(DateTime.now()); await _apiService.call('updateContainer', {
await _containersCollection.doc(id).update(data); 'containerId': id,
'data': data,
});
} catch (e) { } catch (e) {
print('Error updating container: $e'); print('Error updating container: $e');
rethrow; rethrow;
} }
} }
/// Supprimer un container /// Supprimer un container (via Cloud Function)
Future<void> deleteContainer(String id) async { Future<void> deleteContainer(String id) async {
try { try {
// Récupérer le container pour obtenir les équipements await _apiService.call('deleteContainer', {'containerId': id});
final container = await getContainerById(id); // Note: La Cloud Function gère maintenant la mise à jour des équipements
if (container != null && container.equipmentIds.isNotEmpty) {
// Retirer le container des parentBoxIds de chaque équipement
for (final equipmentId in container.equipmentIds) {
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
if (equipmentDoc.exists) {
final equipment = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
final updatedParents = equipment.parentBoxIds.where((boxId) => boxId != id).toList();
await _equipmentCollection.doc(equipmentId).update({
'parentBoxIds': updatedParents,
'updatedAt': Timestamp.fromDate(DateTime.now()),
});
}
}
}
await _containersCollection.doc(id).delete();
} catch (e) { } catch (e) {
print('Error deleting container: $e'); print('Error deleting container: $e');
rethrow; rethrow;
@@ -65,11 +48,10 @@ class ContainerService {
/// Récupérer un container par ID /// Récupérer un container par ID
Future<ContainerModel?> getContainerById(String id) async { Future<ContainerModel?> getContainerById(String id) async {
try { try {
final doc = await _containersCollection.doc(id).get(); final containersData = await _dataService.getContainersByIds([id]);
if (doc.exists) { if (containersData.isEmpty) return null;
return ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
} return ContainerModel.fromMap(containersData.first, id);
return null;
} catch (e) { } catch (e) {
print('Error getting container: $e'); print('Error getting container: $e');
rethrow; rethrow;
@@ -77,30 +59,31 @@ class ContainerService {
} }
/// Récupérer tous les containers /// Récupérer tous les containers
Stream<List<ContainerModel>> getContainers({ Future<List<ContainerModel>> getContainers({
ContainerType? type, ContainerType? type,
EquipmentStatus? status, EquipmentStatus? status,
String? searchQuery, String? searchQuery,
}) { }) async {
try { try {
Query query = _containersCollection; final containersData = await _dataService.getContainers();
// Filtre par type var containerList = containersData
if (type != null) { .map((data) => ContainerModel.fromMap(data, data['id'] as String))
query = query.where('type', isEqualTo: containerTypeToString(type));
}
// Filtre par statut
if (status != null) {
query = query.where('status', isEqualTo: equipmentStatusToString(status));
}
return query.snapshots().map((snapshot) {
List<ContainerModel> containerList = snapshot.docs
.map((doc) => ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id))
.toList(); .toList();
// Filtre par recherche texte (côté client) // Filtres côté client
if (type != null) {
containerList = containerList
.where((c) => c.type == type)
.toList();
}
if (status != null) {
containerList = containerList
.where((c) => c.status == status)
.toList();
}
if (searchQuery != null && searchQuery.isNotEmpty) { if (searchQuery != null && searchQuery.isNotEmpty) {
final lowerSearch = searchQuery.toLowerCase(); final lowerSearch = searchQuery.toLowerCase();
containerList = containerList.where((container) { containerList = containerList.where((container) {
@@ -110,7 +93,6 @@ class ContainerService {
} }
return containerList; return containerList;
});
} catch (e) { } catch (e) {
print('Error getting containers: $e'); print('Error getting containers: $e');
rethrow; rethrow;
@@ -124,67 +106,16 @@ class ContainerService {
String? userId, String? userId,
}) async { }) async {
try { try {
// Récupérer le container final response = await _apiService.call('addEquipmentToContainer', {
final container = await getContainerById(containerId); 'containerId': containerId,
if (container == null) { 'equipmentId': equipmentId,
return {'success': false, 'message': 'Container non trouvé'}; if (userId != null) 'userId': userId,
}
// Vérifier si l'équipement n'est pas déjà dans ce container
if (container.equipmentIds.contains(equipmentId)) {
return {'success': false, 'message': 'Cet équipement est déjà dans ce container'};
}
// Récupérer l'équipement pour vérifier s'il est déjà dans d'autres containers
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
if (!equipmentDoc.exists) {
return {'success': false, 'message': 'Équipement non trouvé'};
}
final equipment = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
// Avertir si l'équipement est déjà dans d'autres containers
List<String> otherContainers = [];
if (equipment.parentBoxIds.isNotEmpty) {
for (final boxId in equipment.parentBoxIds) {
final box = await getContainerById(boxId);
if (box != null) {
otherContainers.add(box.name);
}
}
}
// Mettre à jour le container
final updatedEquipmentIds = [...container.equipmentIds, equipmentId];
await updateContainer(containerId, {
'equipmentIds': updatedEquipmentIds,
}); });
// Mettre à jour l'équipement
final updatedParentBoxIds = [...equipment.parentBoxIds, containerId];
await _equipmentCollection.doc(equipmentId).update({
'parentBoxIds': updatedParentBoxIds,
'updatedAt': Timestamp.fromDate(DateTime.now()),
});
// Ajouter une entrée dans l'historique
await _addHistoryEntry(
containerId: containerId,
action: 'equipment_added',
equipmentId: equipmentId,
newValue: equipmentId,
userId: userId,
);
return { return {
'success': true, 'success': response['success'] ?? false,
'message': 'Équipement ajouté avec succès', 'message': response['message'] ?? '',
'warnings': otherContainers.isNotEmpty 'warnings': response['warnings'],
? 'Attention : cet équipement est également dans les boites suivants : ${otherContainers.join(", ")}'
: null,
}; };
} catch (e) { } catch (e) {
print('Error adding equipment to container: $e'); print('Error adding equipment to container: $e');
@@ -199,38 +130,11 @@ class ContainerService {
String? userId, String? userId,
}) async { }) async {
try { try {
// Récupérer le container await _apiService.call('removeEquipmentFromContainer', {
final container = await getContainerById(containerId); 'containerId': containerId,
if (container == null) throw Exception('Container non trouvé'); 'equipmentId': equipmentId,
if (userId != null) 'userId': userId,
// Mettre à jour le container
final updatedEquipmentIds = container.equipmentIds.where((id) => id != equipmentId).toList();
await updateContainer(containerId, {
'equipmentIds': updatedEquipmentIds,
}); });
// Mettre à jour l'équipement
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
if (equipmentDoc.exists) {
final equipment = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
final updatedParentBoxIds = equipment.parentBoxIds.where((id) => id != containerId).toList();
await _equipmentCollection.doc(equipmentId).update({
'parentBoxIds': updatedParentBoxIds,
'updatedAt': Timestamp.fromDate(DateTime.now()),
});
}
// Ajouter une entrée dans l'historique
await _addHistoryEntry(
containerId: containerId,
action: 'equipment_removed',
equipmentId: equipmentId,
previousValue: equipmentId,
userId: userId,
);
} catch (e) { } catch (e) {
print('Error removing equipment from container: $e'); print('Error removing equipment from container: $e');
rethrow; rethrow;
@@ -260,14 +164,13 @@ class ContainerService {
// Vérifier la disponibilité de chaque équipement dans le container // Vérifier la disponibilité de chaque équipement dans le container
List<String> unavailableEquipment = []; List<String> unavailableEquipment = [];
for (final equipmentId in container.equipmentIds) {
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
if (equipmentDoc.exists) {
final equipment = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
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) { if (equipment.status != EquipmentStatus.available) {
unavailableEquipment.add('${equipment.name} (${equipment.status})'); unavailableEquipment.add('${equipment.name} (${equipment.status})');
} }
@@ -295,15 +198,16 @@ class ContainerService {
final container = await getContainerById(containerId); final container = await getContainerById(containerId);
if (container == null) return []; if (container == null) return [];
List<EquipmentModel> equipment = []; if (container.equipmentIds.isEmpty) return [];
for (final equipmentId in container.equipmentIds) {
final doc = await _equipmentCollection.doc(equipmentId).get();
if (doc.exists) {
equipment.add(EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id));
}
}
return equipment; final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
return equipmentsData
.map((data) {
final id = data['id'] as String;
return EquipmentModel.fromMap(data, id);
})
.toList();
} catch (e) { } catch (e) {
print('Error getting container equipment: $e'); print('Error getting container equipment: $e');
rethrow; rethrow;
@@ -313,12 +217,10 @@ class ContainerService {
/// Trouver tous les containers contenant un équipement spécifique /// Trouver tous les containers contenant un équipement spécifique
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async { Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
try { try {
final snapshot = await _containersCollection final containersData = await _dataService.getContainersByEquipment(equipmentId);
.where('equipmentIds', arrayContains: equipmentId)
.get();
return snapshot.docs return containersData
.map((doc) => ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id)) .map((data) => ContainerModel.fromMap(data, data['id'] as String))
.toList(); .toList();
} catch (e) { } catch (e) {
print('Error finding containers with equipment: $e'); print('Error finding containers with equipment: $e');
@@ -367,8 +269,8 @@ class ContainerService {
/// Vérifier si un ID de container existe déjà /// Vérifier si un ID de container existe déjà
Future<bool> checkContainerIdExists(String id) async { Future<bool> checkContainerIdExists(String id) async {
try { try {
final doc = await _containersCollection.doc(id).get(); final container = await getContainerById(id);
return doc.exists; return container != null;
} catch (e) { } catch (e) {
print('Error checking container ID: $e'); print('Error checking container ID: $e');
return false; return false;

View File

@@ -0,0 +1,646 @@
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Service générique pour les opérations de lecture de données via Cloud Functions
class DataService {
final ApiService _apiService;
DataService(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');
}
}
/// 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');
}
}
/// 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 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');
}
}
/// Met à jour un événement
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
try {
final requestData = {'eventId': eventId, 'data': 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');
}
}
/// 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 = {'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};
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');
}
}
// ============================================================================
// LECTURE DES DONNÉES (avec permissions côté serveur)
// ============================================================================
/// 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 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,
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('[DataService] Error in getEquipmentsPaginated', e);
throw Exception('Erreur lors de la récupération paginée des équipements: $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('[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,
limit: limit,
includeEquipments: includeEquipments,
includeContainers: includeContainers,
);
} catch (e) {
DebugLog.error('[DataService] Error in quickSearch', e);
return [];
}
}
// ============================================================================
// USER - Current User
// ============================================================================
/// 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,
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');
}
}
// ============================================================================
// MAINTENANCES
// ============================================================================
/// 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');
}
}
/// 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');
}
}
// ============================================================================
// 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');
}
}
}

View File

@@ -0,0 +1,150 @@
import 'package:cloud_functions/cloud_functions.dart';
import 'package:em2rp/models/alert_model.dart';
import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:firebase_auth/firebase_auth.dart';
/// Service d'envoi d'emails via Cloud Functions
class EmailService {
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'us-central1');
/// Envoie un email d'alerte à un utilisateur
///
/// [alert] : L'alerte à envoyer
/// [userId] : ID de l'utilisateur destinataire
/// [templateType] : Type de template à utiliser (par défaut: 'alert-individual')
Future<bool> sendAlertEmail({
required AlertModel alert,
required String userId,
String templateType = 'alert-individual',
}) async {
try {
// Vérifier que l'utilisateur est authentifié
final currentUser = FirebaseAuth.instance.currentUser;
if (currentUser == null) {
DebugLog.error('[EmailService] Utilisateur non authentifié');
return false;
}
DebugLog.info('[EmailService] Envoi email alerte ${alert.id} à $userId');
final result = await _functions.httpsCallable('sendAlertEmail').call({
'alertId': alert.id,
'userId': userId,
'templateType': templateType,
});
final data = result.data as Map<String, dynamic>;
final success = data['success'] as bool? ?? false;
final skipped = data['skipped'] as bool? ?? false;
if (skipped) {
final reason = data['reason'] as String? ?? 'unknown';
DebugLog.info('[EmailService] Email non envoyé: $reason');
return false;
}
if (success) {
DebugLog.info('[EmailService] Email envoyé avec succès');
return true;
}
return false;
} catch (e) {
DebugLog.error('[EmailService] Erreur envoi email', e);
return false;
}
}
/// Envoie un email d'alerte à plusieurs utilisateurs
///
/// [alert] : L'alerte à envoyer
/// [userIds] : Liste des IDs des utilisateurs destinataires
Future<Map<String, bool>> sendAlertEmailToMultipleUsers({
required AlertModel alert,
required List<String> userIds,
String templateType = 'alert-individual',
}) async {
final results = <String, bool>{};
DebugLog.info('[EmailService] Envoi emails à ${userIds.length} utilisateurs');
// Envoyer en parallèle (max 5 à la fois pour éviter surcharge)
final batches = <List<String>>[];
for (var i = 0; i < userIds.length; i += 5) {
batches.add(userIds.sublist(
i,
i + 5 > userIds.length ? userIds.length : i + 5,
));
}
for (final batch in batches) {
final futures = batch.map((userId) => sendAlertEmail(
alert: alert,
userId: userId,
templateType: templateType,
));
final batchResults = await Future.wait(futures);
for (var i = 0; i < batch.length; i++) {
results[batch[i]] = batchResults[i];
}
}
final successCount = results.values.where((v) => v).length;
DebugLog.info('[EmailService] $successCount/${ userIds.length} emails envoyés');
return results;
}
/// Détermine si une alerte doit être envoyée immédiatement ou en digest
///
/// [alert] : L'alerte à vérifier
/// Returns: true si immédiat, false si digest
bool shouldSendImmediate(AlertModel alert) {
// Les alertes critiques sont envoyées immédiatement
if (alert.severity == AlertSeverity.critical) {
return true;
}
// Types d'alertes toujours immédiates
const immediateTypes = [
AlertType.lost, // Équipement perdu
AlertType.eventCancelled, // Événement annulé
];
return immediateTypes.contains(alert.type);
}
/// Envoie un email d'alerte en tenant compte des préférences
///
/// [alert] : L'alerte à envoyer
/// [userIds] : Liste des IDs des utilisateurs destinataires
Future<void> sendAlertWithPreferences({
required AlertModel alert,
required List<String> userIds,
}) async {
if (userIds.isEmpty) {
DebugLog.warning('[EmailService] Aucun utilisateur à notifier');
return;
}
final immediate = shouldSendImmediate(alert);
if (immediate) {
DebugLog.info('[EmailService] Envoi immédiat (alerte critique)');
await sendAlertEmailToMultipleUsers(
alert: alert,
userIds: userIds,
templateType: 'alert-individual',
);
} else {
DebugLog.info('[EmailService] Ajout au digest (alerte non critique)');
// Les alertes non critiques seront envoyées dans le digest quotidien
// La Cloud Function sendDailyDigest s'en occupera
// Rien à faire ici, les alertes sont déjà dans Firestore
}
}
}

View File

@@ -1,57 +1,101 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/alert_model.dart';
import 'package:em2rp/models/maintenance_model.dart'; import 'package:em2rp/models/maintenance_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/maintenance_service.dart';
class EquipmentService { class EquipmentService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final ApiService _apiService = apiService;
final DataService _dataService = DataService(apiService);
// Collection references // ============================================================================
CollectionReference get _equipmentCollection => _firestore.collection('equipments'); // Helper privée - Charge TOUS les équipements avec pagination
CollectionReference get _alertsCollection => _firestore.collection('alerts'); // ============================================================================
CollectionReference get _eventsCollection => _firestore.collection('events');
// CRUD Operations /// Charge tous les équipements en utilisant la pagination
Future<List<Map<String, dynamic>>> _getAllEquipmentsPaginated() async {
final allEquipments = <Map<String, dynamic>>[];
String? lastVisible;
bool hasMore = true;
/// Créer un nouvel équipement while (hasMore) {
final result = await _dataService.getEquipmentsPaginated(
limit: 100,
startAfter: lastVisible,
sortBy: 'id',
sortOrder: 'asc',
);
final equipments = result['equipments'] as List<dynamic>;
allEquipments.addAll(equipments.cast<Map<String, dynamic>>());
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
}
return allEquipments;
}
// ============================================================================
// CRUD Operations - Utilise le backend sécurisé
// ============================================================================
/// Créer un nouvel équipement (via Cloud Function)
Future<void> createEquipment(EquipmentModel equipment) async { Future<void> createEquipment(EquipmentModel equipment) async {
try { try {
await _equipmentCollection.doc(equipment.id).set(equipment.toMap()); if (equipment.id.isEmpty) {
throw Exception('L\'ID de l\'équipement est requis pour la création');
}
final data = equipment.toMap();
data['id'] = equipment.id; // S'assurer que l'ID est inclus
await _apiService.call('createEquipment', data);
} catch (e) { } catch (e) {
print('Error creating equipment: $e'); print('Error creating equipment: $e');
rethrow; rethrow;
} }
} }
/// Mettre à jour un équipement /// Mettre à jour un équipement (via Cloud Function)
Future<void> updateEquipment(String id, Map<String, dynamic> data) async { Future<void> updateEquipment(String id, Map<String, dynamic> data) async {
try { try {
data['updatedAt'] = Timestamp.fromDate(DateTime.now()); if (data.isEmpty) {
await _equipmentCollection.doc(id).update(data); throw Exception('Aucune donnée à mettre à jour');
}
await _apiService.call('updateEquipment', {
'equipmentId': id,
'data': data,
});
} catch (e) { } catch (e) {
print('Error updating equipment: $e'); print('Error updating equipment: $e');
rethrow; rethrow;
} }
} }
/// Supprimer un équipement /// Supprimer un équipement (via Cloud Function)
Future<void> deleteEquipment(String id) async { Future<void> deleteEquipment(String id) async {
try { try {
await _equipmentCollection.doc(id).delete(); await _apiService.call('deleteEquipment', {'equipmentId': id});
} catch (e) { } catch (e) {
print('Error deleting equipment: $e'); print('Error deleting equipment: $e');
rethrow; rethrow;
} }
} }
// ============================================================================
// READ Operations - Utilise Firestore streams (temps réel)
// ============================================================================
/// Récupérer un équipement par ID /// Récupérer un équipement par ID
Future<EquipmentModel?> getEquipmentById(String id) async { Future<EquipmentModel?> getEquipmentById(String id) async {
try { try {
final doc = await _equipmentCollection.doc(id).get(); final equipmentsData = await _dataService.getEquipmentsByIds([id]);
if (doc.exists) { if (equipmentsData.isEmpty) return null;
return EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
} return EquipmentModel.fromMap(equipmentsData.first, id);
return null;
} catch (e) { } catch (e) {
print('Error getting equipment: $e'); print('Error getting equipment: $e');
rethrow; rethrow;
@@ -59,36 +103,41 @@ class EquipmentService {
} }
/// Récupérer les équipements avec filtres /// Récupérer les équipements avec filtres
Stream<List<EquipmentModel>> getEquipment({ Future<List<EquipmentModel>> getEquipment({
EquipmentCategory? category, EquipmentCategory? category,
EquipmentStatus? status, EquipmentStatus? status,
String? model, String? model,
String? searchQuery, String? searchQuery,
}) { }) async {
try { try {
Query query = _equipmentCollection; final equipmentsData = await _getAllEquipmentsPaginated();
// Filtre par catégorie var equipmentList = equipmentsData
if (category != null) { .map((data) {
query = query.where('category', isEqualTo: equipmentCategoryToString(category)); final id = data['id'] as String;
} return EquipmentModel.fromMap(data, id);
})
// Filtre par statut
if (status != null) {
query = query.where('status', isEqualTo: equipmentStatusToString(status));
}
// Filtre par modèle
if (model != null && model.isNotEmpty) {
query = query.where('model', isEqualTo: model);
}
return query.snapshots().map((snapshot) {
List<EquipmentModel> equipmentList = snapshot.docs
.map((doc) => EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id))
.toList(); .toList();
// Filtre par recherche texte (côté client car Firestore ne supporte pas les recherches texte complexes) // Filtres côté client
if (category != null) {
equipmentList = equipmentList
.where((e) => e.category == category)
.toList();
}
if (status != null) {
equipmentList = equipmentList
.where((e) => e.status == status)
.toList();
}
if (model != null && model.isNotEmpty) {
equipmentList = equipmentList
.where((e) => e.model == model)
.toList();
}
if (searchQuery != null && searchQuery.isNotEmpty) { if (searchQuery != null && searchQuery.isNotEmpty) {
final lowerSearch = searchQuery.toLowerCase(); final lowerSearch = searchQuery.toLowerCase();
equipmentList = equipmentList.where((equipment) { equipmentList = equipmentList.where((equipment) {
@@ -99,41 +148,32 @@ class EquipmentService {
} }
return equipmentList; return equipmentList;
});
} catch (e) { } catch (e) {
print('Error streaming equipment: $e'); print('Error getting equipment: $e');
rethrow; rethrow;
} }
} }
// ============================================================================
// Availability & Stock Management - Logique métier côté client
// ============================================================================
/// Vérifier la disponibilité d'un équipement pour une période donnée /// Vérifier la disponibilité d'un équipement pour une période donnée
Future<List<String>> checkAvailability( Future<List<Map<String, dynamic>>> checkAvailability(
String equipmentId, String equipmentId,
DateTime startDate, DateTime startDate,
DateTime endDate, DateTime endDate,
) async { ) async {
try { try {
final conflicts = <String>[]; final response = await _apiService.call('checkEquipmentAvailability', {
'equipmentId': equipmentId,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
});
// Récupérer tous les événements qui chevauchent la période final conflicts = (response['conflicts'] as List?)
final eventsQuery = await _eventsCollection ?.map((c) => c as Map<String, dynamic>)
.where('StartDateTime', isLessThanOrEqualTo: Timestamp.fromDate(endDate)) .toList() ?? [];
.where('EndDateTime', isGreaterThanOrEqualTo: Timestamp.fromDate(startDate))
.get();
for (var eventDoc in eventsQuery.docs) {
final eventData = eventDoc.data() as Map<String, dynamic>;
final assignedEquipmentRaw = eventData['assignedEquipment'] ?? [];
if (assignedEquipmentRaw is List) {
for (var eq in assignedEquipmentRaw) {
if (eq is Map && eq['equipmentId'] == equipmentId) {
conflicts.add(eventDoc.id);
break;
}
}
}
}
return conflicts; return conflicts;
} catch (e) { } catch (e) {
@@ -149,26 +189,19 @@ class EquipmentService {
DateTime endDate, DateTime endDate,
) async { ) async {
try { try {
// Récupérer tous les équipements du même modèle final response = await _apiService.call('findAlternativeEquipment', {
final equipmentQuery = await _equipmentCollection 'model': model,
.where('model', isEqualTo: model) 'startDate': startDate.toIso8601String(),
.get(); 'endDate': endDate.toIso8601String(),
});
final alternatives = <EquipmentModel>[]; final alternatives = (response['alternatives'] as List?)
?.map((a) {
for (var doc in equipmentQuery.docs) { final map = a as Map<String, dynamic>;
final equipment = EquipmentModel.fromMap( final id = map['id'] as String;
doc.data() as Map<String, dynamic>, return EquipmentModel.fromMap(map, id);
doc.id, })
); .toList() ?? [];
// Vérifier la disponibilité
final conflicts = await checkAvailability(equipment.id, startDate, endDate);
if (conflicts.isEmpty && equipment.status == EquipmentStatus.available) {
alternatives.add(equipment);
}
}
return alternatives; return alternatives;
} catch (e) { } catch (e) {
@@ -206,56 +239,22 @@ class EquipmentService {
} }
} }
/// Vérifier les stocks critiques et créer des alertes
Future<void> checkCriticalStock() async {
try {
final equipmentQuery = await _equipmentCollection
.where('category', whereIn: [
equipmentCategoryToString(EquipmentCategory.consumable),
equipmentCategoryToString(EquipmentCategory.cable),
])
.get();
for (var doc in equipmentQuery.docs) {
final equipment = EquipmentModel.fromMap(
doc.data() as Map<String, dynamic>,
doc.id,
);
if (equipment.isCriticalStock) {
await _createLowStockAlert(equipment);
}
}
} catch (e) {
print('Error checking critical stock: $e');
rethrow;
}
}
/// Créer une alerte de stock faible /// Créer une alerte de stock faible
Future<void> _createLowStockAlert(EquipmentModel equipment) async { Future<void> _createLowStockAlert(EquipmentModel equipment) async {
try { try {
// Vérifier si une alerte existe déjà pour cet équipement // Note: Cette fonction pourrait utiliser une Cloud Function dédiée dans le futur
final existingAlerts = await _alertsCollection // Pour l'instant, on utilise l'API directement pour éviter de créer trop de fonctions
.where('equipmentId', isEqualTo: equipment.id) // Cette méthode est appelée rarement et en arrière-plan
.where('type', isEqualTo: alertTypeToString(AlertType.lowStock)) await _apiService.call('createAlert', {
.where('isRead', isEqualTo: false) 'type': 'LOW_STOCK',
.get(); 'title': 'Stock critique',
'message': 'Stock critique pour ${equipment.name} (${equipment.model ?? ""}): ${equipment.availableQuantity}/${equipment.criticalThreshold}',
if (existingAlerts.docs.isEmpty) { 'severity': 'HIGH',
final alert = AlertModel( 'equipmentId': equipment.id,
id: _alertsCollection.doc().id, });
type: AlertType.lowStock,
message: 'Stock critique pour ${equipment.name} (${equipment.model ?? ""}): ${equipment.availableQuantity}/${equipment.criticalThreshold}',
equipmentId: equipment.id,
createdAt: DateTime.now(),
);
await _alertsCollection.doc(alert.id).set(alert.toMap());
}
} catch (e) { } catch (e) {
print('Error creating low stock alert: $e'); print('Error creating low stock alert: $e');
rethrow; // Ne pas rethrow pour ne pas bloquer le processus
} }
} }
@@ -266,63 +265,20 @@ class EquipmentService {
return equipmentId; return equipmentId;
} }
/// Récupérer tous les modèles uniques (pour l'indexation/autocomplete)
Future<List<String>> getAllModels() async {
try {
final equipmentQuery = await _equipmentCollection.get();
final models = <String>{};
for (var doc in equipmentQuery.docs) {
final data = doc.data() as Map<String, dynamic>;
final model = data['model'] as String?;
if (model != null && model.isNotEmpty) {
models.add(model);
}
}
return models.toList()..sort();
} catch (e) {
print('Error getting all models: $e');
rethrow;
}
}
/// Récupérer toutes les marques uniques (pour l'indexation/autocomplete)
Future<List<String>> getAllBrands() async {
try {
final equipmentQuery = await _equipmentCollection.get();
final brands = <String>{};
for (var doc in equipmentQuery.docs) {
final data = doc.data() as Map<String, dynamic>;
final brand = data['brand'] as String?;
if (brand != null && brand.isNotEmpty) {
brands.add(brand);
}
}
return brands.toList()..sort();
} catch (e) {
print('Error getting all brands: $e');
rethrow;
}
}
/// Récupérer les modèles filtrés par marque /// Récupérer les modèles filtrés par marque
Future<List<String>> getModelsByBrand(String brand) async { Future<List<String>> getModelsByBrand(String brand) async {
try { try {
final equipmentQuery = await _equipmentCollection final equipmentsData = await _getAllEquipmentsPaginated();
.where('brand', isEqualTo: brand)
.get();
final models = <String>{}; final models = <String>{};
for (var doc in equipmentQuery.docs) { for (var data in equipmentsData) {
final data = doc.data() as Map<String, dynamic>; if (data['brand'] == brand) {
final model = data['model'] as String?; final model = data['model'] as String?;
if (model != null && model.isNotEmpty) { if (model != null && model.isNotEmpty) {
models.add(model); models.add(model);
} }
} }
}
return models.toList()..sort(); return models.toList()..sort();
} catch (e) { } catch (e) {
@@ -331,37 +287,51 @@ class EquipmentService {
} }
} }
/// Récupérer les sous-catégories filtrées par catégorie
Future<List<String>> getSubCategoriesByCategory(EquipmentCategory category) async {
try {
final equipmentsData = await _getAllEquipmentsPaginated();
final subCategories = <String>{};
final categoryString = equipmentCategoryToString(category);
for (var data in equipmentsData) {
if (data['category'] == categoryString) {
final subCategory = data['subCategory'] as String?;
if (subCategory != null && subCategory.isNotEmpty) {
subCategories.add(subCategory);
}
}
}
return subCategories.toList()..sort();
} catch (e) {
print('Error getting subcategories by category: $e');
rethrow;
}
}
/// Vérifier si un ID existe déjà /// Vérifier si un ID existe déjà
Future<bool> isIdUnique(String id) async { Future<bool> isIdUnique(String id) async {
try { try {
final doc = await _equipmentCollection.doc(id).get(); final equipment = await getEquipmentById(id);
return !doc.exists; return equipment == null;
} catch (e) { } catch (e) {
print('Error checking ID uniqueness: $e'); print('Error checking ID uniqueness: $e');
rethrow; rethrow;
} }
} }
/// Récupérer toutes les boîtes (équipements qui peuvent contenir d'autres équipements) /// Récupérer toutes les boîtes/containers disponibles
Future<List<EquipmentModel>> getBoxes() async { Future<List<ContainerModel>> getBoxes() async {
try { try {
// Les boîtes sont généralement des équipements de catégorie "structure" ou "other" final containersData = await _dataService.getContainers();
// On pourrait aussi ajouter un champ spécifique "isBox" dans le modèle
final equipmentQuery = await _equipmentCollection
.where('category', whereIn: [
equipmentCategoryToString(EquipmentCategory.structure),
equipmentCategoryToString(EquipmentCategory.other),
])
.get();
final boxes = <EquipmentModel>[]; final boxes = <ContainerModel>[];
for (var doc in equipmentQuery.docs) { for (var data in containersData) {
final equipment = EquipmentModel.fromMap( final id = data['id'] as String;
doc.data() as Map<String, dynamic>, final container = ContainerModel.fromMap(data, id);
doc.id, boxes.add(container);
);
// On pourrait ajouter un filtre supplémentaire ici si besoin
boxes.add(equipment);
} }
return boxes; return boxes;
@@ -376,27 +346,14 @@ class EquipmentService {
try { try {
if (ids.isEmpty) return []; if (ids.isEmpty) return [];
final equipments = <EquipmentModel>[]; final equipmentsData = await _dataService.getEquipmentsByIds(ids);
// Firestore limite les requêtes whereIn à 10 éléments return equipmentsData
// On doit donc diviser en plusieurs requêtes si nécessaire .map((data) {
for (int i = 0; i < ids.length; i += 10) { final id = data['id'] as String;
final batch = ids.skip(i).take(10).toList(); return EquipmentModel.fromMap(data, id);
final query = await _equipmentCollection })
.where(FieldPath.documentId, whereIn: batch) .toList();
.get();
for (var doc in query.docs) {
equipments.add(
EquipmentModel.fromMap(
doc.data() as Map<String, dynamic>,
doc.id,
),
);
}
}
return equipments;
} catch (e) { } catch (e) {
print('Error getting equipments by IDs: $e'); print('Error getting equipments by IDs: $e');
rethrow; rethrow;
@@ -404,25 +361,13 @@ class EquipmentService {
} }
/// Récupérer les maintenances pour un équipement /// Récupérer les maintenances pour un équipement
/// Note: Cette méthode est maintenant déléguée au MaintenanceService
/// pour éviter la duplication de code
Future<List<MaintenanceModel>> getMaintenancesForEquipment(String equipmentId) async { Future<List<MaintenanceModel>> getMaintenancesForEquipment(String equipmentId) async {
try { try {
final maintenanceQuery = await _firestore // Déléguer au MaintenanceService qui utilise déjà les Cloud Functions
.collection('maintenances') final maintenanceService = MaintenanceService();
.where('equipmentIds', arrayContains: equipmentId) return await maintenanceService.getMaintenancesByEquipment(equipmentId);
.orderBy('scheduledDate', descending: true)
.get();
final maintenances = <MaintenanceModel>[];
for (var doc in maintenanceQuery.docs) {
maintenances.add(
MaintenanceModel.fromMap(
doc.data(),
doc.id,
),
);
}
return maintenances;
} catch (e) { } catch (e) {
print('Error getting maintenances for equipment: $e'); print('Error getting maintenances for equipment: $e');
rethrow; rethrow;

View File

@@ -1,14 +1,13 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/services/api_service.dart';
/// Service pour calculer dynamiquement le statut réel d'un équipement /// Service pour calculer dynamiquement le statut réel d'un équipement
/// basé sur les événements en cours /// basé sur les événements en cours
class EquipmentStatusCalculator { class EquipmentStatusCalculator {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final ApiService _apiService = apiService;
/// Cache des événements pour éviter de multiples requêtes /// Cache des statuts pour éviter de multiples requêtes
List<EventModel>? _cachedEvents; Map<String, EquipmentStatus>? _cachedStatuses;
DateTime? _cacheTime; DateTime? _cacheTime;
static const _cacheDuration = Duration(minutes: 1); static const _cacheDuration = Duration(minutes: 1);
@@ -25,205 +24,57 @@ class EquipmentStatusCalculator {
Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async { Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async {
print('[StatusCalculator] Calculating status for: ${equipment.id}'); print('[StatusCalculator] Calculating status for: ${equipment.id}');
// Si l'équipement est marqué comme perdu ou HS, on garde ce statut try {
// car c'est une information métier importante final statuses = await calculateMultipleStatuses([equipment]);
if (equipment.status == EquipmentStatus.lost || return statuses[equipment.id] ?? equipment.status;
equipment.status == EquipmentStatus.outOfService) { } catch (e) {
print('[StatusCalculator] ${equipment.id} is lost/outOfService -> keeping status'); print('[StatusCalculator] Error calculating status: $e');
return equipment.status; return equipment.status;
} }
// Charger les événements (avec cache)
await _loadEventsIfNeeded();
print('[StatusCalculator] Events loaded: ${_cachedEvents?.length ?? 0}');
// Vérifier si l'équipement est utilisé dans un événement en cours
final isInUse = await _isEquipmentInUse(equipment.id);
print('[StatusCalculator] ${equipment.id} isInUse: $isInUse');
if (isInUse) {
return EquipmentStatus.inUse;
}
// Vérifier si l'équipement est en maintenance
if (equipment.status == EquipmentStatus.maintenance) {
// On pourrait vérifier si la maintenance est toujours valide
// Pour l'instant on garde le statut
return EquipmentStatus.maintenance;
}
// Vérifier si l'équipement est loué
if (equipment.status == EquipmentStatus.rented) {
// On pourrait vérifier une date de retour prévue
// Pour l'instant on garde le statut
return EquipmentStatus.rented;
}
// Par défaut, l'équipement est disponible
print('[StatusCalculator] ${equipment.id} -> AVAILABLE');
return EquipmentStatus.available;
} }
/// Calcule les statuts pour une liste d'équipements (optimisé) /// Calcule les statuts pour une liste d'équipements (optimisé)
Future<Map<String, EquipmentStatus>> calculateMultipleStatuses( Future<Map<String, EquipmentStatus>> calculateMultipleStatuses(
List<EquipmentModel> equipments, List<EquipmentModel> equipments,
) async { ) async {
await _loadEventsIfNeeded(); try {
final equipmentIds = equipments.map((e) => e.id).toList();
final response = await _apiService.call('calculateEquipmentStatuses', {
'equipmentIds': equipmentIds,
});
final statusesMap = response['statuses'] as Map<String, dynamic>?;
if (statusesMap == null) {
throw Exception('Invalid response from calculateEquipmentStatuses');
}
final statuses = <String, EquipmentStatus>{}; final statuses = <String, EquipmentStatus>{};
statusesMap.forEach((equipmentId, statusString) {
// Trouver tous les équipements en cours d'utilisation if (statusString != null) {
final equipmentIdsInUse = <String>{}; statuses[equipmentId] = equipmentStatusFromString(statusString as String);
final containerIdsInUse = <String>{};
for (var event in _cachedEvents ?? []) {
// Un équipement est "en prestation" dès que la préparation est complétée
// et jusqu'à ce que le retour soit complété
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing;
final isReturned = event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
final isInProgress = isPrepared && !isReturned;
if (isInProgress) {
// Ajouter les équipements directs
for (var eq in event.assignedEquipment) {
equipmentIdsInUse.add(eq.equipmentId);
}
// Ajouter les conteneurs
containerIdsInUse.addAll(event.assignedContainers);
}
} }
});
// Récupérer les équipements dans les conteneurs en cours d'utilisation // Mise en cache
if (containerIdsInUse.isNotEmpty) { _cachedStatuses = statuses;
final containersSnapshot = await _firestore _cacheTime = DateTime.now();
.collection('containers')
.where(FieldPath.documentId, whereIn: containerIdsInUse.toList())
.get();
for (var doc in containersSnapshot.docs) {
final data = doc.data();
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
equipmentIdsInUse.addAll(equipmentIds);
}
}
// Calculer le statut pour chaque équipement
for (var equipment in equipments) {
// Si perdu ou HS, on garde le statut
if (equipment.status == EquipmentStatus.lost ||
equipment.status == EquipmentStatus.outOfService) {
statuses[equipment.id] = equipment.status;
continue;
}
// Si en cours d'utilisation
if (equipmentIdsInUse.contains(equipment.id)) {
statuses[equipment.id] = EquipmentStatus.inUse;
continue;
}
// Si en maintenance ou loué, on garde le statut
if (equipment.status == EquipmentStatus.maintenance ||
equipment.status == EquipmentStatus.rented) {
statuses[equipment.id] = equipment.status;
continue;
}
// Par défaut, disponible
statuses[equipment.id] = EquipmentStatus.available;
}
return statuses; return statuses;
}
/// Vérifie si un équipement est actuellement en cours d'utilisation
Future<bool> _isEquipmentInUse(String equipmentId) async {
print('[StatusCalculator] Checking if $equipmentId is in use...');
// Vérifier dans les événements directs
for (var event in _cachedEvents ?? []) {
// Un équipement est "en prestation" dès que la préparation est complétée
// et jusqu'à ce que le retour soit complété
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing;
final isReturned = event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
final isInProgress = isPrepared && !isReturned;
if (!isInProgress) continue;
print('[StatusCalculator] Event ${event.name} is IN PROGRESS (prepared and not returned)');
// Vérifier si l'équipement est directement assigné
if (event.assignedEquipment.any((eq) => eq.equipmentId == equipmentId)) {
print('[StatusCalculator] $equipmentId found DIRECTLY in event ${event.name}');
return true;
}
// Vérifier si l'équipement est dans un conteneur assigné
if (event.assignedContainers.isNotEmpty) {
print('[StatusCalculator] Checking containers for event ${event.name}: ${event.assignedContainers}');
final containersSnapshot = await _firestore
.collection('containers')
.where(FieldPath.documentId, whereIn: event.assignedContainers)
.get();
for (var doc in containersSnapshot.docs) {
final data = doc.data();
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
print('[StatusCalculator] Container ${doc.id} contains: $equipmentIds');
if (equipmentIds.contains(equipmentId)) {
print('[StatusCalculator] $equipmentId found in CONTAINER ${doc.id}');
return true;
}
}
}
}
print('[StatusCalculator] $equipmentId is NOT in use');
return false;
}
/// Charge les événements si le cache est expiré
Future<void> _loadEventsIfNeeded() async {
if (_cachedEvents != null &&
_cacheTime != null &&
DateTime.now().difference(_cacheTime!) < _cacheDuration) {
return; // Cache encore valide
}
try {
final eventsSnapshot = await _firestore.collection('events').get();
_cachedEvents = eventsSnapshot.docs
.map((doc) {
try {
return EventModel.fromMap(doc.data(), doc.id);
} catch (e) { } catch (e) {
print('[EquipmentStatusCalculator] Error parsing event ${doc.id}: $e'); print('[StatusCalculator] Error calculating multiple statuses: $e');
return null; // En cas d'erreur, retourner les statuts actuels
final fallbackStatuses = <String, EquipmentStatus>{};
for (var equipment in equipments) {
fallbackStatuses[equipment.id] = equipment.status;
} }
}) return fallbackStatuses;
.whereType<EventModel>()
.where((event) => event.status != EventStatus.canceled) // Ignorer les événements annulés
.toList();
_cacheTime = DateTime.now();
} catch (e) {
print('[EquipmentStatusCalculator] Error loading events: $e');
_cachedEvents = [];
} }
} }
/// Invalide le cache (à appeler après une modification d'événement) /// Invalide le cache (à appeler après une modification d'événement)
void invalidateCache() { void invalidateCache() {
_cachedEvents = null; _cachedStatuses = null;
_cacheTime = null; _cacheTime = null;
} }

View File

@@ -1,7 +1,8 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/services/data_service.dart';
/// Type de conflit /// Type de conflit
enum ConflictType { enum ConflictType {
@@ -63,9 +64,16 @@ class AvailabilityConflict {
/// Service pour vérifier la disponibilité du matériel /// Service pour vérifier la disponibilité du matériel
class EventAvailabilityService { class EventAvailabilityService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final DataService _dataService = DataService(apiService);
/// Vérifie si un équipement est disponible pour une plage de dates /// 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({ Future<List<AvailabilityConflict>> checkEquipmentAvailability({
required String equipmentId, required String equipmentId,
required String equipmentName, required String equipmentName,
@@ -76,73 +84,57 @@ class EventAvailabilityService {
final conflicts = <AvailabilityConflict>[]; final conflicts = <AvailabilityConflict>[];
try { try {
// Récupérer TOUS les événements (on filtre côté client car arrayContains avec objet ne marche pas) print('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
final eventsSnapshot = await _firestore.collection('events').get();
for (var doc in eventsSnapshot.docs) { // Utiliser la Cloud Function pour vérifier la disponibilité
if (excludeEventId != null && doc.id == excludeEventId) { final result = await _dataService.checkEquipmentAvailability(
continue; // Ignorer l'événement en cours d'édition equipmentId: equipmentId,
} startDate: startDate,
endDate: endDate,
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) {
try { try {
final data = doc.data(); final event = EventModel.fromMap(eventData, eventId);
final event = EventModel.fromMap(data, doc.id);
// Ignorer les événements annulés
if (event.status == EventStatus.canceled) {
continue;
}
// Vérifier si cet événement contient l'équipement recherché
final assignedEquipment = event.assignedEquipment.firstWhere(
(eq) => eq.equipmentId == equipmentId,
orElse: () => EventEquipment(equipmentId: ''),
);
// Si l'équipement est assigné à cet événement, il est indisponible
// (peu importe le statut de préparation/chargement/retour)
if (assignedEquipment.equipmentId.isNotEmpty) {
// 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 overlapDays = _calculateOverlapDays(
startDate,
endDate,
eventRealStartDate,
eventRealEndDate,
);
conflicts.add(AvailabilityConflict( conflicts.add(AvailabilityConflict(
equipmentId: equipmentId, equipmentId: equipmentId,
equipmentName: equipmentName, equipmentName: equipmentName,
conflictingEvent: event, conflictingEvent: event,
overlapDays: overlapDays, overlapDays: conflict['overlapDays'] as int? ?? 0,
)); ));
} print('[EventAvailabilityService] Added conflict with event ${event.name}');
}
} catch (e) { } catch (e) {
print('[EventAvailabilityService] Error processing event ${doc.id}: $e'); print('[EventAvailabilityService] Error creating EventModel: $e');
print('[EventAvailabilityService] EventData: $eventData');
}
}
} }
} }
} catch (e) { } catch (e) {
print('[EventAvailabilityService] Error checking availability: $e'); print('[EventAvailabilityService] Error checking availability: $e');
} }
print('[EventAvailabilityService] Returning ${conflicts.length} conflicts for equipment $equipmentId');
return conflicts; return conflicts;
} }
/// Helper pour formater les dates dans les logs
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
/// Vérifie la disponibilité pour une liste d'équipements /// Vérifie la disponibilité pour une liste d'équipements
Future<Map<String, List<AvailabilityConflict>>> checkMultipleEquipmentAvailability({ Future<Map<String, List<AvailabilityConflict>>> checkMultipleEquipmentAvailability({
required List<String> equipmentIds, required List<String> equipmentIds,
@@ -203,16 +195,17 @@ class EventAvailabilityService {
int reservedQuantity = 0; int reservedQuantity = 0;
try { try {
// Récupérer tous les événements (on filtre côté client) // Récupérer tous les événements via Cloud Function
final eventsSnapshot = await _firestore.collection('events').get(); final eventsData = await _getEventsList();
for (var doc in eventsSnapshot.docs) { for (var eventData in eventsData) {
if (excludeEventId != null && doc.id == excludeEventId) { final eventId = eventData['id'] as String;
if (excludeEventId != null && eventId == excludeEventId) {
continue; continue;
} }
try { try {
final event = EventModel.fromMap(doc.data(), doc.id); final event = EventModel.fromMap(eventData, eventId);
// Ignorer les événements annulés // Ignorer les événements annulés
if (event.status == EventStatus.canceled) { if (event.status == EventStatus.canceled) {
@@ -241,7 +234,7 @@ class EventAvailabilityService {
} }
} }
} catch (e) { } catch (e) {
print('[EventAvailabilityService] Error processing event ${doc.id} for quantity: $e'); print('[EventAvailabilityService] Error processing event $eventId for quantity: $e');
} }
} }
} catch (e) { } catch (e) {
@@ -275,13 +268,14 @@ class EventAvailabilityService {
// ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante // ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante
if (availableQty < requestedQuantity) { if (availableQty < requestedQuantity) {
// Trouver les événements qui réservent cette quantité // Trouver les événements qui réservent cette quantité
final eventsSnapshot = await _firestore.collection('events').get(); final eventsData = await _getEventsList();
for (var doc in eventsSnapshot.docs) { for (var eventData in eventsData) {
if (excludeEventId != null && doc.id == excludeEventId) continue; final eventId = eventData['id'] as String;
if (excludeEventId != null && eventId == excludeEventId) continue;
try { try {
final event = EventModel.fromMap(doc.data(), doc.id); final event = EventModel.fromMap(eventData, eventId);
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) { if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
final assignedEquipment = event.assignedEquipment.firstWhere( final assignedEquipment = event.assignedEquipment.firstWhere(
@@ -304,7 +298,7 @@ class EventAvailabilityService {
} }
} }
} catch (e) { } catch (e) {
print('[EventAvailabilityService] Error processing event ${doc.id}: $e'); print('[EventAvailabilityService] Error processing event $eventId: $e');
} }
} }
} }
@@ -334,15 +328,16 @@ class EventAvailabilityService {
final conflictingChildrenIds = <String>[]; final conflictingChildrenIds = <String>[];
// Vérifier d'abord si la boîte complète est utilisée // Vérifier d'abord si la boîte complète est utilisée
final eventsSnapshot = await _firestore.collection('events').get(); final eventsData = await _getEventsList();
bool isContainerFullyUsed = false; bool isContainerFullyUsed = false;
EventModel? containerConflictingEvent; EventModel? containerConflictingEvent;
for (var doc in eventsSnapshot.docs) { for (var eventData in eventsData) {
if (excludeEventId != null && doc.id == excludeEventId) continue; final eventId = eventData['id'] as String;
if (excludeEventId != null && eventId == excludeEventId) continue;
try { try {
final event = EventModel.fromMap(doc.data(), doc.id); final event = EventModel.fromMap(eventData, eventId);
// Ignorer les événements annulés // Ignorer les événements annulés
if (event.status == EventStatus.canceled) { if (event.status == EventStatus.canceled) {
@@ -366,7 +361,7 @@ class EventAvailabilityService {
} }
} }
} catch (e) { } catch (e) {
print('[EventAvailabilityService] Error processing event ${doc.id}: $e'); print('[EventAvailabilityService] Error processing event $eventId: $e');
} }
} }

View File

@@ -1,4 +1,3 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart'; import 'package:firebase_storage/firebase_storage.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
@@ -7,32 +6,46 @@ import 'dart:convert';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/event_type_model.dart'; import 'package:em2rp/models/event_type_model.dart';
import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/alert_service.dart';
import 'dart:developer' as developer; import 'dart:developer' as developer;
class EventFormService { class EventFormService {
static final ApiService _apiService = apiService;
static final DataService _dataService = DataService(FirebaseFunctionsApiService());
// ============================================================================
// READ Operations - Utilise l'API (sécurisé avec permissions côté serveur)
// ============================================================================
static Future<List<EventTypeModel>> fetchEventTypes() async { static Future<List<EventTypeModel>> fetchEventTypes() async {
developer.log('Fetching event types from Firestore...', name: 'EventFormService'); developer.log('Fetching event types via API...', name: 'EventFormService');
try { try {
final snapshot = await FirebaseFirestore.instance.collection('eventTypes').get(); final eventTypesData = await _dataService.getEventTypes();
final eventTypes = snapshot.docs.map((doc) => EventTypeModel.fromMap(doc.data(), doc.id)).toList(); final eventTypes = eventTypesData.map((data) => EventTypeModel.fromMap(data, data['id'] as String)).toList();
developer.log('${eventTypes.length} event types loaded.', name: 'EventFormService'); developer.log('${eventTypes.length} event types loaded.', name: 'EventFormService');
return eventTypes; return eventTypes;
} catch (e, s) { } catch (e, s) {
developer.log('Error fetching event types', name: 'EventFormService', error: e, stackTrace: s); developer.log('Error fetching event types', name: 'EventFormService', error: e, stackTrace: s);
throw Exception("Could not load event types. Please check Firestore permissions."); throw Exception("Could not load event types. Please check permissions.");
} }
} }
static Future<List<UserModel>> fetchUsers() async { static Future<List<UserModel>> fetchUsers() async {
try { try {
final snapshot = await FirebaseFirestore.instance.collection('users').get(); final usersData = await _dataService.getUsers();
return snapshot.docs.map((doc) => UserModel.fromMap(doc.data(), doc.id)).toList(); return usersData.map((data) => UserModel.fromMap(data, data['id'] as String)).toList();
} catch (e) { } catch (e) {
developer.log('Error fetching users', name: 'EventFormService', error: e); developer.log('Error fetching users', name: 'EventFormService', error: e);
throw Exception("Could not load users."); throw Exception("Could not load users.");
} }
} }
// ============================================================================
// STORAGE - Reste inchangé (déjà via Cloud Function)
// ============================================================================
static Future<List<Map<String, String>>> uploadFiles(List<PlatformFile> files) async { static Future<List<Map<String, String>>> uploadFiles(List<PlatformFile> files) async {
List<Map<String, String>> uploadedFiles = []; List<Map<String, String>> uploadedFiles = [];
@@ -90,14 +103,81 @@ class EventFormService {
} }
} }
// ============================================================================
// CRUD Operations - Utilise le backend sécurisé
// ============================================================================
static Future<String> createEvent(EventModel event) async { static Future<String> createEvent(EventModel event) async {
final docRef = await FirebaseFirestore.instance.collection('events').add(event.toMap()); try {
return docRef.id; final result = await _apiService.call('createEvent', event.toMap());
final eventId = result['id'] as String;
// NOUVEAU : Créer alerte automatique pour les utilisateurs assignés
try {
await AlertService().createEventCreatedAlert(
eventId: eventId,
eventName: event.name,
eventDate: event.startDateTime,
);
developer.log('Alert created for new event: $eventId', name: 'EventFormService');
} catch (alertError) {
// Ne pas bloquer la création de l'événement si l'alerte échoue
developer.log('Warning: Could not create alert for event',
name: 'EventFormService',
error: alertError);
}
return eventId;
} catch (e) {
developer.log('Error creating event', name: 'EventFormService', error: e);
rethrow;
}
} }
static Future<void> updateEvent(EventModel event) async { static Future<void> updateEvent(EventModel event) async {
final docRef = FirebaseFirestore.instance.collection('events').doc(event.id); try {
await docRef.update(event.toMap()); if (event.id.isEmpty) {
throw Exception("Cannot update event: Event ID is empty");
}
developer.log('Updating event with ID: ${event.id}', name: 'EventFormService');
final eventData = event.toMap();
eventData['eventId'] = event.id;
await _apiService.call('updateEvent', eventData);
developer.log('Event updated successfully', name: 'EventFormService');
// NOUVEAU : Créer alerte automatique pour les utilisateurs assignés
try {
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
if (currentUserId != null) {
await AlertService().createEventModifiedAlert(
eventId: event.id,
eventName: event.name,
modification: 'Informations modifiées',
);
developer.log('Alert created for modified event: ${event.id}', name: 'EventFormService');
}
} catch (alertError) {
// Ne pas bloquer la modification de l'événement si l'alerte échoue
developer.log('Warning: Could not create alert for event modification',
name: 'EventFormService',
error: alertError);
}
} catch (e) {
developer.log('Error updating event', name: 'EventFormService', error: e);
rethrow;
}
}
static Future<void> deleteEvent(String eventId) async {
try {
await _apiService.call('deleteEvent', {'eventId': eventId});
} catch (e) {
developer.log('Error deleting event', name: 'EventFormService', error: e);
rethrow;
}
} }
static Future<List<Map<String, String>>> moveFilesToEvent( static Future<List<Map<String, String>>> moveFilesToEvent(
@@ -135,9 +215,22 @@ class EventFormService {
} }
static Future<void> updateEventDocuments(String eventId, List<Map<String, String>> documents) async { static Future<void> updateEventDocuments(String eventId, List<Map<String, String>> documents) async {
await FirebaseFirestore.instance try {
.collection('events') if (eventId.isEmpty) {
.doc(eventId) throw Exception("Event ID cannot be empty");
.update({'documents': documents}); }
developer.log('Updating event documents for ID: $eventId (${documents.length} documents)', name: 'EventFormService');
await _apiService.call('updateEvent', {
'eventId': eventId,
'documents': documents,
});
developer.log('Event documents updated successfully', name: 'EventFormService');
} catch (e) {
developer.log('Error updating event documents', name: 'EventFormService', error: e);
throw Exception("Could not update event documents.");
}
} }
} }

View File

@@ -1,50 +1,18 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/equipment_status_calculator.dart'; import 'package:em2rp/services/equipment_status_calculator.dart';
import 'package:em2rp/services/api_service.dart';
class EventPreparationService { class EventPreparationService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final ApiService _apiService = apiService;
final EquipmentService _equipmentService = EquipmentService();
// Collection references
CollectionReference get _eventsCollection => _firestore.collection('events');
CollectionReference get _equipmentCollection => _firestore.collection('equipments');
// === PRÉPARATION === // === PRÉPARATION ===
/// Valider un équipement individuel en préparation /// Valider un équipement individuel en préparation
Future<void> validateEquipmentPreparation(String eventId, String equipmentId) async { Future<void> validateEquipmentPreparation(String eventId, String equipmentId) async {
try { try {
final event = await _getEvent(eventId); await _apiService.call('validateEquipmentPreparation', {
if (event == null) { 'eventId': eventId,
throw Exception('Event not found'); 'equipmentId': equipmentId,
} });
// Mettre à jour le statut de l'équipement dans la liste
final updatedEquipment = event.assignedEquipment.map((eq) {
if (eq.equipmentId == equipmentId) {
return eq.copyWith(isPrepared: true);
}
return eq;
}).toList();
// Vérifier si tous les équipements sont préparés
final allPrepared = updatedEquipment.every((eq) => eq.isPrepared);
final updateData = <String, dynamic>{
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
};
// Mettre à jour le statut selon la complétion
if (allPrepared) {
updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed);
} else {
updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.inProgress);
}
await _eventsCollection.doc(eventId).update(updateData);
} catch (e) { } catch (e) {
print('Error validating equipment preparation: $e'); print('Error validating equipment preparation: $e');
rethrow; rethrow;
@@ -54,69 +22,30 @@ class EventPreparationService {
/// Valider tous les équipements en préparation /// Valider tous les équipements en préparation
Future<void> validateAllPreparation(String eventId) async { Future<void> validateAllPreparation(String eventId) async {
try { try {
final event = await _getEvent(eventId); await _apiService.call('validateAllPreparation', {
if (event == null) { 'eventId': eventId,
throw Exception('Event not found');
}
// Marquer tous les équipements comme préparés
final updatedEquipment = event.assignedEquipment.map((eq) {
return eq.copyWith(isPrepared: true);
}).toList();
await _eventsCollection.doc(eventId).update({
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
'preparationStatus': preparationStatusToString(PreparationStatus.completed),
}); });
// Invalider le cache des statuts d'équipement // Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache(); EquipmentStatusCalculator.invalidateGlobalCache();
// Mettre à jour le statut des équipements à "inUse" (seulement pour les équipements qui existent)
for (var equipment in event.assignedEquipment) {
// Vérifier si l'équipement existe avant de mettre à jour son statut
final doc = await _equipmentCollection.doc(equipment.equipmentId).get();
if (doc.exists) {
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse);
}
}
} catch (e) { } catch (e) {
print('Error validating all preparation: $e'); print('Error validating all preparation: $e');
rethrow; rethrow;
} }
} }
/// Finaliser la préparation avec des équipements manquants // Ces méthodes ne sont plus utilisées et devraient être remplacées par des Cloud Functions
// si nécessaire dans le futur
/*
@Deprecated('Use Cloud Functions instead')
Future<void> completePreparationWithMissing( Future<void> completePreparationWithMissing(
String eventId, String eventId,
List<String> missingEquipmentIds, List<String> missingEquipmentIds,
) async { ) async {
try { throw UnimplementedError('This method is deprecated. Use Cloud Functions instead.');
final event = await _getEvent(eventId);
if (event == null) {
throw Exception('Event not found');
}
// Marquer comme complété avec manquants
await _eventsCollection.doc(eventId).update({
'preparationStatus': preparationStatusToString(PreparationStatus.completedWithMissing),
});
// Mettre à jour le statut des équipements préparés à "inUse"
for (var equipment in event.assignedEquipment) {
if (equipment.isPrepared && !missingEquipmentIds.contains(equipment.equipmentId)) {
// Vérifier si l'équipement existe avant de mettre à jour son statut
final doc = await _equipmentCollection.doc(equipment.equipmentId).get();
if (doc.exists) {
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse);
}
}
}
} catch (e) {
print('Error completing preparation with missing: $e');
rethrow;
}
} }
*/
// === RETOUR === // === RETOUR ===
@@ -128,55 +57,11 @@ class EventPreparationService {
int? returnedQuantity, int? returnedQuantity,
}) async { }) async {
try { try {
final event = await _getEvent(eventId); await _apiService.call('validateEquipmentReturn', {
if (event == null) { 'eventId': eventId,
throw Exception('Event not found'); 'equipmentId': equipmentId,
} if (returnedQuantity != null) 'returnedQuantity': returnedQuantity,
// Mettre à jour le statut de l'équipement dans la liste
final updatedEquipment = event.assignedEquipment.map((eq) {
if (eq.equipmentId == equipmentId) {
return eq.copyWith(
isReturned: true,
returnedQuantity: returnedQuantity,
);
}
return eq;
}).toList();
// Vérifier si tous les équipements sont retournés
final allReturned = updatedEquipment.every((eq) => eq.isReturned);
final updateData = <String, dynamic>{
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
};
// Mettre à jour le statut selon la complétion
if (allReturned) {
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
} else {
updateData['returnStatus'] = returnStatusToString(ReturnStatus.inProgress);
}
await _eventsCollection.doc(eventId).update(updateData);
// Mettre à jour le stock si c'est un consommable
if (returnedQuantity != null) {
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
if (equipmentDoc.exists) {
final equipment = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
if (equipment.hasQuantity) {
final currentAvailable = equipment.availableQuantity ?? 0;
await _equipmentCollection.doc(equipmentId).update({
'availableQuantity': currentAvailable + returnedQuantity,
}); });
}
}
}
} catch (e) { } catch (e) {
print('Error validating equipment return: $e'); print('Error validating equipment return: $e');
rethrow; rethrow;
@@ -189,53 +74,11 @@ class EventPreparationService {
Map<String, int>? returnedQuantities, Map<String, int>? returnedQuantities,
]) async { ]) async {
try { try {
final event = await _getEvent(eventId); await _apiService.call('validateAllReturn', {
if (event == null) { 'eventId': eventId,
throw Exception('Event not found'); if (returnedQuantities != null) 'returnedQuantities': returnedQuantities,
}
// Marquer tous les équipements comme retournés
final updatedEquipment = event.assignedEquipment.map((eq) {
final returnedQty = returnedQuantities?[eq.equipmentId] ??
eq.returnedQuantity ??
eq.quantity;
return eq.copyWith(
isReturned: true,
returnedQuantity: returnedQty,
);
}).toList();
await _eventsCollection.doc(eventId).update({
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
'returnStatus': returnStatusToString(ReturnStatus.completed),
}); });
// Mettre à jour le statut des équipements à "available" et gérer les stocks
for (var equipment in updatedEquipment) {
// Vérifier si le document existe
final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get();
if (equipmentDoc.exists) {
final equipmentData = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
// Mettre à jour le statut uniquement pour les équipements non quantifiables
if (!equipmentData.hasQuantity) {
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available);
}
// Restaurer le stock pour les consommables
if (equipmentData.hasQuantity && equipment.returnedQuantity != null) {
final currentAvailable = equipmentData.availableQuantity ?? 0;
await _equipmentCollection.doc(equipment.equipmentId).update({
'availableQuantity': currentAvailable + equipment.returnedQuantity!,
'updatedAt': Timestamp.fromDate(DateTime.now()),
});
}
}
}
// Invalider le cache des statuts d'équipement // Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache(); EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) { } catch (e) {
@@ -244,186 +87,18 @@ class EventPreparationService {
} }
} }
/// Finaliser le retour avec des équipements manquants /*
@Deprecated('Use Cloud Functions instead')
Future<void> completeReturnWithMissing( Future<void> completeReturnWithMissing(
String eventId, String eventId,
List<String> missingEquipmentIds, List<String> missingEquipmentIds,
) async { ) async {
try { throw UnimplementedError('This method is deprecated. Use Cloud Functions instead.');
final event = await _getEvent(eventId);
if (event == null) {
throw Exception('Event not found');
} }
// Marquer comme complété avec manquants // Les méthodes helper suivantes étaient uniquement utilisées par les méthodes deprecated ci-dessus.
await _eventsCollection.doc(eventId).update({ // Elles ont été supprimées car elles accédaient directement à Firestore.
'returnStatus': returnStatusToString(ReturnStatus.completedWithMissing), // Si ces fonctionnalités sont nécessaires à l'avenir, elles doivent être implémentées
}); // via des Cloud Functions pour respecter l'architecture.
*/
// Mettre à jour le statut des équipements retournés à "available"
for (var equipment in event.assignedEquipment) {
// Vérifier si le document existe
final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get();
if (!equipmentDoc.exists) {
continue; // Passer cet équipement s'il n'existe pas
}
final equipmentData = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
if (equipment.isReturned && !missingEquipmentIds.contains(equipment.equipmentId)) {
// Mettre à jour le statut uniquement pour les équipements non quantifiables
if (!equipmentData.hasQuantity) {
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available);
}
// Restaurer le stock pour les consommables
if (equipmentData.hasQuantity && equipment.returnedQuantity != null) {
final currentAvailable = equipmentData.availableQuantity ?? 0;
await _equipmentCollection.doc(equipment.equipmentId).update({
'availableQuantity': currentAvailable + equipment.returnedQuantity!,
'updatedAt': Timestamp.fromDate(DateTime.now()),
});
}
} else if (missingEquipmentIds.contains(equipment.equipmentId)) {
// Marquer comme perdu uniquement pour les équipements non quantifiables
if (!equipmentData.hasQuantity) {
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.lost);
}
}
}
} catch (e) {
print('Error completing return with missing: $e');
rethrow;
}
}
// === HELPERS ===
/// Mettre à jour le statut d'un équipement
Future<void> updateEquipmentStatus(String equipmentId, EquipmentStatus status) async {
try {
// Vérifier que le document existe avant de le mettre à jour
final doc = await _equipmentCollection.doc(equipmentId).get();
if (!doc.exists) {
print('Warning: Equipment document $equipmentId does not exist, skipping status update');
return;
}
await _equipmentCollection.doc(equipmentId).update({
'status': equipmentStatusToString(status),
'updatedAt': Timestamp.fromDate(DateTime.now()),
});
} catch (e) {
print('Error updating equipment status for $equipmentId: $e');
// Ne pas rethrow pour ne pas bloquer le processus si un équipement n'existe pas
}
}
/// Récupérer un événement
Future<EventModel?> _getEvent(String eventId) async {
try {
final doc = await _eventsCollection.doc(eventId).get();
if (doc.exists) {
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
}
return null;
} catch (e) {
print('Error getting event: $e');
rethrow;
}
}
/// Ajouter un équipement à un événement
Future<void> addEquipmentToEvent(
String eventId,
String equipmentId, {
int quantity = 1,
}) async {
try {
final event = await _getEvent(eventId);
if (event == null) {
throw Exception('Event not found');
}
// Vérifier que l'équipement n'est pas déjà ajouté
final alreadyAdded = event.assignedEquipment.any((eq) => eq.equipmentId == equipmentId);
if (alreadyAdded) {
throw Exception('Equipment already added to event');
}
final newEquipment = EventEquipment(
equipmentId: equipmentId,
quantity: quantity,
);
final updatedEquipment = [...event.assignedEquipment, newEquipment];
await _eventsCollection.doc(eventId).update({
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
});
// Décrémenter le stock pour les consommables
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
if (equipmentDoc.exists) {
final equipmentData = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
if (equipmentData.hasQuantity) {
final currentAvailable = equipmentData.availableQuantity ?? 0;
await _equipmentCollection.doc(equipmentId).update({
'availableQuantity': currentAvailable - quantity,
});
}
}
} catch (e) {
print('Error adding equipment to event: $e');
rethrow;
}
}
/// Retirer un équipement d'un événement
Future<void> removeEquipmentFromEvent(String eventId, String equipmentId) async {
try {
final event = await _getEvent(eventId);
if (event == null) {
throw Exception('Event not found');
}
final equipmentToRemove = event.assignedEquipment.firstWhere(
(eq) => eq.equipmentId == equipmentId,
);
final updatedEquipment = event.assignedEquipment
.where((eq) => eq.equipmentId != equipmentId)
.toList();
await _eventsCollection.doc(eventId).update({
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
});
// Restaurer le stock pour les consommables
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
if (equipmentDoc.exists) {
final equipmentData = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
if (equipmentData.hasQuantity) {
final currentAvailable = equipmentData.availableQuantity ?? 0;
await _equipmentCollection.doc(equipmentId).update({
'availableQuantity': currentAvailable + equipmentToRemove.quantity,
});
}
}
} catch (e) {
print('Error removing equipment from event: $e');
rethrow;
}
}
} }

View File

@@ -1,45 +1,21 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/equipment_status_calculator.dart'; import 'package:em2rp/services/equipment_status_calculator.dart';
import 'package:em2rp/services/api_service.dart';
/// Service étendu pour gérer les 4 étapes : Préparation, Chargement, Déchargement, Retour /// Service étendu pour gérer les 4 étapes : Préparation, Chargement, Déchargement, Retour
class EventPreparationServiceExtended { class EventPreparationServiceExtended {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final ApiService _apiService = apiService;
CollectionReference get _eventsCollection => _firestore.collection('events');
CollectionReference get _equipmentCollection => _firestore.collection('equipments');
// === CHARGEMENT (LOADING) === // === CHARGEMENT (LOADING) ===
/// Valider un équipement individuel pour le chargement /// Valider un équipement individuel pour le chargement
Future<void> validateEquipmentLoading(String eventId, String equipmentId) async { Future<void> validateEquipmentLoading(String eventId, String equipmentId) async {
try { try {
final event = await _getEvent(eventId); await _apiService.call('validateEquipmentLoading', {
if (event == null) throw Exception('Event not found'); 'eventId': eventId,
'equipmentId': equipmentId,
final updatedEquipment = event.assignedEquipment.map((eq) { });
if (eq.equipmentId == equipmentId) {
return eq.copyWith(isLoaded: true);
}
return eq;
}).toList();
// Vérifier si tous les équipements sont chargés
final allLoaded = updatedEquipment.every((eq) => eq.isLoaded);
final updateData = <String, dynamic>{
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
};
// Si tous sont chargés, mettre à jour le statut
if (allLoaded) {
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
} else {
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.inProgress);
}
await _eventsCollection.doc(eventId).update(updateData);
} catch (e) { } catch (e) {
print('Error validating equipment loading: $e'); print('Error validating equipment loading: $e');
rethrow; rethrow;
@@ -49,16 +25,8 @@ class EventPreparationServiceExtended {
/// Valider tous les équipements pour le chargement /// Valider tous les équipements pour le chargement
Future<void> validateAllLoading(String eventId) async { Future<void> validateAllLoading(String eventId) async {
try { try {
final event = await _getEvent(eventId); await _apiService.call('validateAllLoading', {
if (event == null) throw Exception('Event not found'); 'eventId': eventId,
final updatedEquipment = event.assignedEquipment.map((eq) {
return eq.copyWith(isLoaded: true);
}).toList();
await _eventsCollection.doc(eventId).update({
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
'loadingStatus': loadingStatusToString(LoadingStatus.completed),
}); });
// Invalider le cache des statuts d'équipement // Invalider le cache des statuts d'équipement
@@ -74,31 +42,10 @@ class EventPreparationServiceExtended {
/// Valider un équipement individuel pour le déchargement /// Valider un équipement individuel pour le déchargement
Future<void> validateEquipmentUnloading(String eventId, String equipmentId) async { Future<void> validateEquipmentUnloading(String eventId, String equipmentId) async {
try { try {
final event = await _getEvent(eventId); await _apiService.call('validateEquipmentUnloading', {
if (event == null) throw Exception('Event not found'); 'eventId': eventId,
'equipmentId': equipmentId,
final updatedEquipment = event.assignedEquipment.map((eq) { });
if (eq.equipmentId == equipmentId) {
return eq.copyWith(isUnloaded: true);
}
return eq;
}).toList();
// Vérifier si tous les équipements sont déchargés
final allUnloaded = updatedEquipment.every((eq) => eq.isUnloaded);
final updateData = <String, dynamic>{
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
};
// Si tous sont déchargés, mettre à jour le statut
if (allUnloaded) {
updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed);
} else {
updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.inProgress);
}
await _eventsCollection.doc(eventId).update(updateData);
} catch (e) { } catch (e) {
print('Error validating equipment unloading: $e'); print('Error validating equipment unloading: $e');
rethrow; rethrow;
@@ -108,16 +55,8 @@ class EventPreparationServiceExtended {
/// Valider tous les équipements pour le déchargement /// Valider tous les équipements pour le déchargement
Future<void> validateAllUnloading(String eventId) async { Future<void> validateAllUnloading(String eventId) async {
try { try {
final event = await _getEvent(eventId); await _apiService.call('validateAllUnloading', {
if (event == null) throw Exception('Event not found'); 'eventId': eventId,
final updatedEquipment = event.assignedEquipment.map((eq) {
return eq.copyWith(isUnloaded: true);
}).toList();
await _eventsCollection.doc(eventId).update({
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
'unloadingStatus': unloadingStatusToString(UnloadingStatus.completed),
}); });
// Invalider le cache des statuts d'équipement // Invalider le cache des statuts d'équipement
@@ -133,26 +72,13 @@ class EventPreparationServiceExtended {
/// Valider préparation ET chargement en même temps /// Valider préparation ET chargement en même temps
Future<void> validateAllPreparationAndLoading(String eventId) async { Future<void> validateAllPreparationAndLoading(String eventId) async {
try { try {
final event = await _getEvent(eventId); // Note: On pourrait créer une fonction cloud dédiée pour ça,
if (event == null) throw Exception('Event not found'); // mais pour l'instant on appelle les deux séquentiellement
await _apiService.call('validateAllPreparation', {'eventId': eventId});
await _apiService.call('validateAllLoading', {'eventId': eventId});
final updatedEquipment = event.assignedEquipment.map((eq) { // Invalider le cache
return eq.copyWith(isPrepared: true, isLoaded: true); EquipmentStatusCalculator.invalidateGlobalCache();
}).toList();
await _eventsCollection.doc(eventId).update({
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
'preparationStatus': preparationStatusToString(PreparationStatus.completed),
'loadingStatus': loadingStatusToString(LoadingStatus.completed),
});
// Mettre à jour le statut des équipements
for (var equipment in event.assignedEquipment) {
final doc = await _equipmentCollection.doc(equipment.equipmentId).get();
if (doc.exists) {
await _updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse);
}
}
} catch (e) { } catch (e) {
print('Error validating all preparation and loading: $e'); print('Error validating all preparation and loading: $e');
rethrow; rethrow;
@@ -167,81 +93,20 @@ class EventPreparationServiceExtended {
Map<String, int>? returnedQuantities, Map<String, int>? returnedQuantities,
) async { ) async {
try { try {
final event = await _getEvent(eventId); // Note: On pourrait créer une fonction cloud dédiée pour ça,
if (event == null) throw Exception('Event not found'); // mais pour l'instant on appelle les deux séquentiellement
await _apiService.call('validateAllUnloading', {'eventId': eventId});
final updatedEquipment = event.assignedEquipment.map((eq) { await _apiService.call('validateAllReturn', {
final returnedQty = returnedQuantities?[eq.equipmentId] ?? 'eventId': eventId,
eq.returnedQuantity ?? if (returnedQuantities != null) 'returnedQuantities': returnedQuantities,
eq.quantity;
return eq.copyWith(
isUnloaded: true,
isReturned: true,
returnedQuantity: returnedQty,
);
}).toList();
await _eventsCollection.doc(eventId).update({
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
'unloadingStatus': unloadingStatusToString(UnloadingStatus.completed),
'returnStatus': returnStatusToString(ReturnStatus.completed),
}); });
// Mettre à jour les statuts et stocks // Invalider le cache
for (var equipment in updatedEquipment) { EquipmentStatusCalculator.invalidateGlobalCache();
final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get();
if (equipmentDoc.exists) {
final equipmentData = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
if (!equipmentData.hasQuantity) {
await _updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available);
}
if (equipmentData.hasQuantity && equipment.returnedQuantity != null) {
final currentAvailable = equipmentData.availableQuantity ?? 0;
await _equipmentCollection.doc(equipment.equipmentId).update({
'availableQuantity': currentAvailable + equipment.returnedQuantity!,
'updatedAt': Timestamp.fromDate(DateTime.now()),
});
}
}
}
} catch (e) { } catch (e) {
print('Error validating all unloading and return: $e'); print('Error validating all unloading and return: $e');
rethrow; rethrow;
} }
} }
// === HELPERS ===
Future<void> _updateEquipmentStatus(String equipmentId, EquipmentStatus status) async {
try {
final doc = await _equipmentCollection.doc(equipmentId).get();
if (!doc.exists) return;
await _equipmentCollection.doc(equipmentId).update({
'status': equipmentStatusToString(status),
'updatedAt': Timestamp.fromDate(DateTime.now()),
});
} catch (e) {
print('Error updating equipment status: $e');
}
}
Future<EventModel?> _getEvent(String eventId) async {
try {
final doc = await _eventsCollection.doc(eventId).get();
if (doc.exists) {
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
}
return null;
} catch (e) {
print('Error getting event: $e');
rethrow;
}
}
} }

View File

@@ -1,24 +1,35 @@
import 'package:em2rp/config/app_version.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
class IcsExportService { class IcsExportService {
/// Génère un fichier ICS à partir d'un événement /// Génère un fichier ICS à partir d'un événement
static Future<String> generateIcsContent(EventModel event) async { ///
/// [eventTypeName] : Nom du type d'événement (optionnel, sera résolu si non fourni)
/// [userNames] : Map des IDs utilisateurs vers leurs noms complets (optionnel)
/// [optionNames] : Map des IDs options vers leurs noms (optionnel)
static Future<String> generateIcsContent(
EventModel event, {
String? eventTypeName,
Map<String, String>? userNames,
Map<String, String>? optionNames,
}) async {
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z'; final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z';
// Récupérer les informations supplémentaires // Récupérer les informations supplémentaires
final eventTypeName = await _getEventTypeName(event.eventTypeId); final resolvedEventTypeName = eventTypeName ?? await _getEventTypeName(event.eventTypeId);
final workforce = await _getWorkforceDetails(event.workforce); final workforce = await _getWorkforceDetails(event.workforce, userNames: userNames);
final optionsWithNames = await _getOptionsDetails(event.options); final optionsWithNames = await _getOptionsDetails(event.options, optionNames: optionNames);
// Formater les dates au format ICS (UTC) // Formater les dates au format ICS (UTC)
final startDate = _formatDateForIcs(event.startDateTime); final startDate = _formatDateForIcs(event.startDateTime);
final endDate = _formatDateForIcs(event.endDateTime); final endDate = _formatDateForIcs(event.endDateTime);
// Construire la description détaillée // Construire la description détaillée
final description = _buildDescription(event, eventTypeName, workforce, optionsWithNames); final description = _buildDescription(event, resolvedEventTypeName, workforce, optionsWithNames);
// Générer un UID unique basé sur l'ID de l'événement // Générer un UID unique basé sur l'ID de l'événement
final uid = 'em2rp-${event.id}@em2rp.app'; final uid = 'em2rp-${event.id}@em2rp.app';
@@ -38,52 +49,64 @@ SUMMARY:${_escapeIcsText(event.name)}
DESCRIPTION:${_escapeIcsText(description)} DESCRIPTION:${_escapeIcsText(description)}
LOCATION:${_escapeIcsText(event.address)} LOCATION:${_escapeIcsText(event.address)}
STATUS:${_getEventStatus(event.status)} STATUS:${_getEventStatus(event.status)}
CATEGORIES:${_escapeIcsText(eventTypeName)} CATEGORIES:${_escapeIcsText(resolvedEventTypeName)}
END:VEVENT END:VEVENT
END:VCALENDAR'''; END:VCALENDAR''';
return icsContent; return icsContent;
} }
/// Récupère le nom du type d'événement /// Récupère le nom du type d'événement depuis EventModel (déjà chargé)
/// Note: Les eventTypes sont maintenant chargés via Cloud Function dans l'EventModel
static Future<String> _getEventTypeName(String eventTypeId) async { static Future<String> _getEventTypeName(String eventTypeId) async {
if (eventTypeId.isEmpty) return 'Non spécifié'; if (eventTypeId.isEmpty) return 'Non spécifié';
try { // Les eventTypes sont publics et déjà chargés dans l'app via Cloud Function
final doc = await FirebaseFirestore.instance // On retourne simplement l'ID, le nom sera résolu par l'app
.collection('eventTypes')
.doc(eventTypeId)
.get();
if (doc.exists) {
return doc.data()?['name'] as String? ?? eventTypeId;
}
} catch (e) {
print('Erreur lors de la récupération du type d\'événement: $e');
}
return eventTypeId; return eventTypeId;
} }
/// Récupère les détails de la main d'œuvre /// Récupère les détails de la main d'œuvre
static Future<List<String>> _getWorkforceDetails(List<DocumentReference> workforce) async { /// Si userNames est fourni, utilise les noms déjà résolus pour de meilleures performances
static Future<List<String>> _getWorkforceDetails(
List<dynamic> workforce, {
Map<String, String>? userNames,
}) async {
final List<String> workforceNames = []; final List<String> workforceNames = [];
for (final ref in workforce) { for (final ref in workforce) {
try { try {
final doc = await ref.get(); // Si c'est déjà une Map avec les données, l'utiliser directement
if (doc.exists) { if (ref is Map<String, dynamic>) {
final data = doc.data() as Map<String, dynamic>?; final firstName = ref['firstName'] ?? '';
if (data != null) { final lastName = ref['lastName'] ?? '';
final firstName = data['firstName'] ?? '';
final lastName = data['lastName'] ?? '';
if (firstName.isNotEmpty || lastName.isNotEmpty) { if (firstName.isNotEmpty || lastName.isNotEmpty) {
workforceNames.add('$firstName $lastName'.trim()); workforceNames.add('$firstName $lastName'.trim());
} }
continue;
}
// Si c'est un String (UID) et qu'on a les noms résolus, les utiliser
if (ref is String) {
if (userNames != null && userNames.containsKey(ref)) {
workforceNames.add(userNames[ref]!);
} else {
workforceNames.add('Utilisateur $ref');
}
continue;
}
// Si c'est une DocumentReference
if (ref is DocumentReference) {
final userId = ref.id;
if (userNames != null && userNames.containsKey(userId)) {
workforceNames.add(userNames[userId]!);
} else {
workforceNames.add('Utilisateur $userId');
} }
} }
} catch (e) { } catch (e) {
print('Erreur lors de la récupération des détails utilisateur: $e'); print('Erreur lors du traitement des détails utilisateur: $e');
} }
} }
@@ -91,46 +114,32 @@ END:VCALENDAR''';
} }
/// Récupère les détails des options /// Récupère les détails des options
static Future<List<Map<String, dynamic>>> _getOptionsDetails(List<Map<String, dynamic>> options) async { /// Si optionNames est fourni, utilise les noms déjà résolus
static Future<List<Map<String, dynamic>>> _getOptionsDetails(
List<Map<String, dynamic>> options, {
Map<String, String>? optionNames,
}) async {
final List<Map<String, dynamic>> optionsWithNames = []; final List<Map<String, dynamic>> optionsWithNames = [];
for (final option in options) { for (final option in options) {
try { try {
String optionName = option['name'] ?? 'Option inconnue';
// Si on a l'ID de l'option et les noms résolus, utiliser le nom résolu
final optionId = option['id'] ?? option['optionId']; final optionId = option['id'] ?? option['optionId'];
if (optionId == null || optionId.toString().isEmpty) { if (optionId != null && optionNames != null && optionNames.containsKey(optionId)) {
// Si pas d'ID, garder le nom tel quel optionName = optionNames[optionId]!;
optionsWithNames.add({ } else if (optionName == 'Option inconnue' && optionId != null) {
'name': option['name'] ?? 'Option inconnue', optionName = 'Option $optionId';
'quantity': option['quantity'],
});
continue;
} }
// Récupérer le nom depuis Firestore
final doc = await FirebaseFirestore.instance
.collection('options')
.doc(optionId.toString())
.get();
if (doc.exists) {
final data = doc.data();
optionsWithNames.add({ optionsWithNames.add({
'name': data?['name'] ?? option['name'] ?? 'Option inconnue', 'name': optionName,
'quantity': option['quantity'], 'quantity': option['quantity'],
'price': option['price'],
}); });
} else {
// Document n'existe pas, garder le nom de l'option
optionsWithNames.add({
'name': option['name'] ?? 'Option inconnue',
'quantity': option['quantity'],
});
}
} catch (e) { } catch (e) {
print('Erreur lors de la récupération des détails option: $e'); print('Erreur lors du traitement des options: $e');
optionsWithNames.add({
'name': option['name'] ?? 'Option inconnue',
'quantity': option['quantity'],
});
} }
} }
@@ -222,7 +231,7 @@ END:VCALENDAR''';
// Lien vers l'application // Lien vers l'application
buffer.writeln(''); buffer.writeln('');
buffer.writeln('---'); buffer.writeln('---');
buffer.writeln('Géré par EM2RP Event Manager'); buffer.writeln('ré par EM2 Hub ${AppVersion.fullVersion} http://app.em2events.fr');
return buffer.toString(); return buffer.toString();
} }

View File

@@ -1,45 +1,32 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/maintenance_model.dart'; import 'package:em2rp/models/maintenance_model.dart';
import 'package:em2rp/models/alert_model.dart'; import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/equipment_service.dart';
class MaintenanceService { class MaintenanceService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final ApiService _apiService = apiService;
final EquipmentService _equipmentService = EquipmentService();
// Collection references
CollectionReference get _maintenancesCollection => _firestore.collection('maintenances');
CollectionReference get _equipmentCollection => _firestore.collection('equipment');
CollectionReference get _alertsCollection => _firestore.collection('alerts');
// CRUD Operations // ============================================================================
// CRUD Operations - Utilise le backend sécurisé
// ============================================================================
/// Créer une nouvelle maintenance /// Créer une nouvelle maintenance (via Cloud Function)
Future<void> createMaintenance(MaintenanceModel maintenance) async { Future<void> createMaintenance(MaintenanceModel maintenance) async {
try { try {
await _maintenancesCollection.doc(maintenance.id).set(maintenance.toMap()); await _apiService.call('createMaintenance', maintenance.toMap());
// Note: La Cloud Function gère maintenant la mise à jour des équipements et la création des alertes
// Mettre à jour les équipements concernés
for (String equipmentId in maintenance.equipmentIds) {
await _updateEquipmentMaintenanceList(equipmentId, maintenance.id);
// Si la maintenance est planifiée dans les 7 prochains jours, créer une alerte
if (maintenance.scheduledDate.isBefore(DateTime.now().add(const Duration(days: 7)))) {
await _createMaintenanceAlert(equipmentId, maintenance);
}
}
} catch (e) { } catch (e) {
print('Error creating maintenance: $e'); print('Error creating maintenance: $e');
rethrow; rethrow;
} }
} }
/// Mettre à jour une maintenance /// Mettre à jour une maintenance (via Cloud Function)
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async { Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
try { try {
data['updatedAt'] = Timestamp.fromDate(DateTime.now()); await _apiService.call('updateMaintenance', {
await _maintenancesCollection.doc(id).update(data); 'maintenanceId': id,
'data': data,
});
} catch (e) { } catch (e) {
print('Error updating maintenance: $e'); print('Error updating maintenance: $e');
rethrow; rethrow;
@@ -49,21 +36,10 @@ class MaintenanceService {
/// Supprimer une maintenance /// Supprimer une maintenance
Future<void> deleteMaintenance(String id) async { Future<void> deleteMaintenance(String id) async {
try { try {
// Récupérer la maintenance pour connaître les équipements await _apiService.call('deleteMaintenance', {
final doc = await _maintenancesCollection.doc(id).get(); 'maintenanceId': id,
if (doc.exists) { });
final maintenance = MaintenanceModel.fromMap( // Note: La Cloud Function gère la mise à jour des équipements
doc.data() as Map<String, dynamic>,
doc.id,
);
// Retirer la maintenance des équipements
for (String equipmentId in maintenance.equipmentIds) {
await _removeMaintenanceFromEquipment(equipmentId, id);
}
}
await _maintenancesCollection.doc(id).delete();
} catch (e) { } catch (e) {
print('Error deleting maintenance: $e'); print('Error deleting maintenance: $e');
rethrow; rethrow;
@@ -73,54 +49,54 @@ class MaintenanceService {
/// Récupérer une maintenance par ID /// Récupérer une maintenance par ID
Future<MaintenanceModel?> getMaintenanceById(String id) async { Future<MaintenanceModel?> getMaintenanceById(String id) async {
try { try {
final doc = await _maintenancesCollection.doc(id).get(); final response = await _apiService.call('getMaintenances', {
if (doc.exists) { 'maintenanceId': id,
return MaintenanceModel.fromMap(doc.data() as Map<String, dynamic>, doc.id); });
}
return null; final maintenances = (response['maintenances'] as List?)
?.map((m) => MaintenanceModel.fromMap(m as Map<String, dynamic>, m['id'] as String))
.toList();
return maintenances?.firstWhere(
(m) => m.id == id,
orElse: () => throw Exception('Maintenance not found'),
);
} catch (e) { } catch (e) {
print('Error getting maintenance: $e'); print('Error getting maintenance: $e');
rethrow; return null;
} }
} }
/// Récupérer l'historique des maintenances pour un équipement /// Récupérer l'historique des maintenances pour un équipement
Stream<List<MaintenanceModel>> getMaintenances(String equipmentId) { Future<List<MaintenanceModel>> getMaintenancesByEquipment(String equipmentId) async {
try { try {
return _maintenancesCollection final response = await _apiService.call('getMaintenances', {
.where('equipmentIds', arrayContains: equipmentId) 'equipmentId': equipmentId,
.orderBy('scheduledDate', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => MaintenanceModel.fromMap(
doc.data() as Map<String, dynamic>,
doc.id,
))
.toList();
}); });
final maintenances = (response['maintenances'] as List?)
?.map((m) => MaintenanceModel.fromMap(m as Map<String, dynamic>, m['id'] as String))
.toList() ?? [];
return maintenances;
} catch (e) { } catch (e) {
print('Error streaming maintenances: $e'); print('Error getting maintenances: $e');
rethrow; rethrow;
} }
} }
/// Récupérer toutes les maintenances /// Récupérer toutes les maintenances
Stream<List<MaintenanceModel>> getAllMaintenances() { Future<List<MaintenanceModel>> getAllMaintenances() async {
try { try {
return _maintenancesCollection final response = await _apiService.call('getMaintenances', {});
.orderBy('scheduledDate', descending: true)
.snapshots() final maintenances = (response['maintenances'] as List?)
.map((snapshot) { ?.map((m) => MaintenanceModel.fromMap(m as Map<String, dynamic>, m['id'] as String))
return snapshot.docs .toList() ?? [];
.map((doc) => MaintenanceModel.fromMap(
doc.data() as Map<String, dynamic>, return maintenances;
doc.id,
))
.toList();
});
} catch (e) { } catch (e) {
print('Error streaming all maintenances: $e'); print('Error getting all maintenances: $e');
rethrow; rethrow;
} }
} }
@@ -128,30 +104,11 @@ class MaintenanceService {
/// Marquer une maintenance comme complétée /// Marquer une maintenance comme complétée
Future<void> completeMaintenance(String id, {String? performedBy, double? cost}) async { Future<void> completeMaintenance(String id, {String? performedBy, double? cost}) async {
try { try {
final updateData = <String, dynamic>{ await _apiService.call('completeMaintenance', {
'completedDate': Timestamp.fromDate(DateTime.now()), 'maintenanceId': id,
'updatedAt': Timestamp.fromDate(DateTime.now()), if (performedBy != null) 'performedBy': performedBy,
}; if (cost != null) 'cost': cost,
if (performedBy != null) {
updateData['performedBy'] = performedBy;
}
if (cost != null) {
updateData['cost'] = cost;
}
await updateMaintenance(id, updateData);
// Mettre à jour la date de dernière maintenance des équipements
final maintenance = await getMaintenanceById(id);
if (maintenance != null) {
for (String equipmentId in maintenance.equipmentIds) {
await _equipmentCollection.doc(equipmentId).update({
'lastMaintenanceDate': Timestamp.fromDate(DateTime.now()),
}); });
}
}
} catch (e) { } catch (e) {
print('Error completing maintenance: $e'); print('Error completing maintenance: $e');
rethrow; rethrow;
@@ -161,121 +118,10 @@ class MaintenanceService {
/// Vérifier les maintenances à venir et créer des alertes /// Vérifier les maintenances à venir et créer des alertes
Future<void> checkUpcomingMaintenances() async { Future<void> checkUpcomingMaintenances() async {
try { try {
final sevenDaysFromNow = DateTime.now().add(const Duration(days: 7)); await _apiService.call('checkUpcomingMaintenances', {});
// Récupérer les maintenances planifiées dans les 7 prochains jours
final maintenancesQuery = await _maintenancesCollection
.where('scheduledDate', isLessThanOrEqualTo: Timestamp.fromDate(sevenDaysFromNow))
.where('completedDate', isNull: true)
.get();
for (var doc in maintenancesQuery.docs) {
final maintenance = MaintenanceModel.fromMap(
doc.data() as Map<String, dynamic>,
doc.id,
);
for (String equipmentId in maintenance.equipmentIds) {
await _createMaintenanceAlert(equipmentId, maintenance);
}
}
} catch (e) { } catch (e) {
print('Error checking upcoming maintenances: $e'); print('Error checking upcoming maintenances: $e');
rethrow; rethrow;
} }
} }
/// Créer une alerte de maintenance à venir
Future<void> _createMaintenanceAlert(String equipmentId, MaintenanceModel maintenance) async {
try {
// Vérifier si une alerte existe déjà
final existingAlerts = await _alertsCollection
.where('equipmentId', isEqualTo: equipmentId)
.where('type', isEqualTo: alertTypeToString(AlertType.maintenanceDue))
.where('isRead', isEqualTo: false)
.get();
// Vérifier si l'alerte concerne la même maintenance
bool alertExists = false;
for (var alertDoc in existingAlerts.docs) {
final alertData = alertDoc.data() as Map<String, dynamic>;
if (alertData['message']?.contains(maintenance.name) ?? false) {
alertExists = true;
break;
}
}
if (!alertExists) {
// Récupérer l'équipement pour le nom
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
String equipmentName = equipmentId;
if (equipmentDoc.exists) {
final equipmentData = equipmentDoc.data() as Map<String, dynamic>;
equipmentName = equipmentData['name'] ?? equipmentId;
}
final daysUntil = maintenance.scheduledDate.difference(DateTime.now()).inDays;
final alert = AlertModel(
id: _alertsCollection.doc().id,
type: AlertType.maintenanceDue,
message: 'Maintenance "${maintenance.name}" prévue dans $daysUntil jour(s) pour $equipmentName',
equipmentId: equipmentId,
createdAt: DateTime.now(),
);
await _alertsCollection.doc(alert.id).set(alert.toMap());
}
} catch (e) {
print('Error creating maintenance alert: $e');
rethrow;
}
}
/// Mettre à jour la liste des maintenances d'un équipement
Future<void> _updateEquipmentMaintenanceList(String equipmentId, String maintenanceId) async {
try {
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
if (equipmentDoc.exists) {
final equipment = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
final updatedMaintenanceIds = List<String>.from(equipment.maintenanceIds);
if (!updatedMaintenanceIds.contains(maintenanceId)) {
updatedMaintenanceIds.add(maintenanceId);
await _equipmentCollection.doc(equipmentId).update({
'maintenanceIds': updatedMaintenanceIds,
});
}
}
} catch (e) {
print('Error updating equipment maintenance list: $e');
rethrow;
}
}
/// Retirer une maintenance de la liste d'un équipement
Future<void> _removeMaintenanceFromEquipment(String equipmentId, String maintenanceId) async {
try {
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
if (equipmentDoc.exists) {
final equipment = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
final updatedMaintenanceIds = List<String>.from(equipment.maintenanceIds);
updatedMaintenanceIds.remove(maintenanceId);
await _equipmentCollection.doc(equipmentId).update({
'maintenanceIds': updatedMaintenanceIds,
});
}
} catch (e) {
print('Error removing maintenance from equipment: $e');
rethrow;
}
}
} }

View File

@@ -24,14 +24,16 @@ class PDFGeneratorConfig {
itemsPerPage: 50, itemsPerPage: 50,
); );
// 4 colonnes x 10 lignes = 40 étiquettes
static const medium = PDFGeneratorConfig( static const medium = PDFGeneratorConfig(
qrCodeSize: 250, qrCodeSize: 150, // Réduit légèrement pour entrer dans 25.4mm de haut
itemsPerPage: 20, itemsPerPage: 40,
); );
// 2 colonnes x 5 lignes = 10 étiquettes
static const large = PDFGeneratorConfig( static const large = PDFGeneratorConfig(
qrCodeSize: 300, qrCodeSize: 300,
itemsPerPage: 12, itemsPerPage: 10,
); );
static PDFGeneratorConfig fromFormat(QRLabelFormat format) { static PDFGeneratorConfig fromFormat(QRLabelFormat format) {
@@ -47,7 +49,6 @@ class PDFGeneratorConfig {
} }
/// Service UNIQUE et optimisé pour la génération de PDFs avec QR codes /// Service UNIQUE et optimisé pour la génération de PDFs avec QR codes
/// Remplace PDFGeneratorService, ContainerPDFGeneratorService et UnifiedPDFGeneratorService
class PDFService { class PDFService {
static Uint8List? _cachedLogoBytes; static Uint8List? _cachedLogoBytes;
static bool _logoLoadAttempted = false; static bool _logoLoadAttempted = false;
@@ -71,13 +72,6 @@ class PDFService {
} }
/// Génère un PDF avec QR codes pour n'importe quel type d'objet /// Génère un PDF avec QR codes pour n'importe quel type d'objet
///
/// [items] : Liste des objets à générer
/// [format] : Format des étiquettes (small, medium, large)
/// [getId] : Fonction pour obtenir l'ID unique
/// [getTitle] : Fonction pour obtenir le titre (optionnel)
/// [getDetails] : Fonction pour obtenir les détails (optionnel, seulement pour large)
/// [onProgress] : Callback de progression (optionnel)
static Future<Uint8List> generatePDF<T>({ static Future<Uint8List> generatePDF<T>({
required List<T> items, required List<T> items,
required QRLabelFormat format, required QRLabelFormat format,
@@ -93,8 +87,8 @@ class PDFService {
final config = PDFGeneratorConfig.fromFormat(format); final config = PDFGeneratorConfig.fromFormat(format);
final pdf = pw.Document(); final pdf = pw.Document();
// Pré-charger le logo pour format large // Pré-charger le logo pour formats medium et large
if (format == QRLabelFormat.large) { if (format == QRLabelFormat.medium || format == QRLabelFormat.large) {
await _ensureLogoLoaded(); await _ensureLogoLoaded();
} }
@@ -124,7 +118,7 @@ class PDFService {
} }
// ======================================================================== // ========================================================================
// PETITS LABELS (2x2 cm, 20 par page) // PETITS LABELS (Original: 2x2 cm approx)
// ======================================================================== // ========================================================================
static void _addSmallLabels<T>( static void _addSmallLabels<T>(
pw.Document pdf, pw.Document pdf,
@@ -133,7 +127,7 @@ class PDFService {
List<Uint8List> qrImages, List<Uint8List> qrImages,
PDFGeneratorConfig config, PDFGeneratorConfig config,
) { ) {
const qrSize = 56.69; // 2cm const qrSize = 56.69; // ~2cm
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) { for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList(); final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
@@ -169,9 +163,8 @@ class PDFService {
); );
} }
} }
// ========================================================================
// ======================================================================== // LABELS MOYENS (49 x 26 mm | 4 colonnes, 10 lignes)
// LABELS MOYENS (4x4 cm, 6 par page)
// ======================================================================== // ========================================================================
static void _addMediumLabels<T>( static void _addMediumLabels<T>(
pw.Document pdf, pw.Document pdf,
@@ -181,7 +174,19 @@ class PDFService {
List<Uint8List> qrImages, List<Uint8List> qrImages,
PDFGeneratorConfig config, PDFGeneratorConfig config,
) { ) {
const qrSize = 113.39; // 4cm // 1. Dimensions exactes des étiquettes
const double labelWidth = 50 * PdfPageFormat.mm;
const double labelHeight = 26.0 * PdfPageFormat.mm;
// 2. Calcul du centrage manuel
// Marge théorique = (210mm - (49*4)) / 2 = 7mm
// CORRECTION : On enlève 1.5mm pour réduire la marge de gauche (décalage vers la gauche)
const double horizontalCorrection = PdfPageFormat.mm;
final double leftMargin = ((PdfPageFormat.a4.width - (labelWidth * 4)) / 2) + horizontalCorrection;
// Centrage vertical standard
final double topMargin = (PdfPageFormat.a4.height - (labelHeight * 10)) / 2 -0.75;
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) { for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList(); final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
@@ -190,32 +195,59 @@ class PDFService {
pdf.addPage( pdf.addPage(
pw.Page( pw.Page(
pageFormat: PdfPageFormat.a4, pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(20), // 3. Application des marges calculées (plus de pw.Center)
margin: pw.EdgeInsets.only(
left: leftMargin,
top: topMargin,
right: 0,
bottom: 0
),
build: (_) => pw.Wrap( build: (_) => pw.Wrap(
spacing: 20, spacing: 0,
runSpacing: 20, runSpacing: 0,
children: List.generate(pageItems.length, (i) { children: List.generate(pageItems.length, (i) {
return pw.Container( return pw.Container(
width: qrSize, width: labelWidth,
height: qrSize + 30, height: labelHeight,
padding: const pw.EdgeInsets.all(2),
child: pw.Row(
children: [
// QR Code à gauche
pw.Container(
width: labelHeight - 4,
height: labelHeight - 4,
child: pw.Image(pw.MemoryImage(pageQRs[i])),
),
pw.SizedBox(width: 4),
// Texte à droite
pw.Expanded(
child: pw.Column( child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center, mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Image(pw.MemoryImage(pageQRs[i])), // Logo
pw.SizedBox(height: 4), if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
pw.Container(
height: 12,
alignment: pw.Alignment.centerLeft,
margin: const pw.EdgeInsets.only(bottom: 2),
child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)),
),
pw.Text( pw.Text(
getId(pageItems[i]), getId(pageItems[i]),
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold), style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),
textAlign: pw.TextAlign.center, maxLines: 1,
), ),
if (getTitle != null) ...[ if (getTitle != null)
pw.SizedBox(height: 2),
pw.Text( pw.Text(
_truncate(getTitle(pageItems[i]), 25), _truncate(getTitle(pageItems[i]), 18),
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), style: const pw.TextStyle(fontSize: 6, color: PdfColors.grey700),
textAlign: pw.TextAlign.center, maxLines: 2,
overflow: pw.TextOverflow.clip,
), ),
], ],
),
),
], ],
), ),
); );
@@ -227,7 +259,7 @@ class PDFService {
} }
// ======================================================================== // ========================================================================
// GRANDS LABELS (avec détails, 10 par page) // GRANDS LABELS (105 x 57 mm | 2 colonnes, 5 lignes)
// ======================================================================== // ========================================================================
static void _addLargeLabels<T>( static void _addLargeLabels<T>(
pw.Document pdf, pw.Document pdf,
@@ -238,7 +270,17 @@ class PDFService {
List<Uint8List> qrImages, List<Uint8List> qrImages,
PDFGeneratorConfig config, PDFGeneratorConfig config,
) { ) {
const qrSize = 100.0; // UTILISATION DE LA LARGEUR A4 DIVISÉE PAR 2
// Cela garantit que 2 colonnes rentrent pile poil (210mm / 2 = 105mm)
final double labelWidth = PdfPageFormat.a4.width / 2;
const double labelHeight = 57.0 * PdfPageFormat.mm;
const int cols = 2;
const int rows = 5;
final double totalGridWidth = labelWidth * cols;
final double totalGridHeight = labelHeight * rows;
const double innerQrSize = 45.0 * PdfPageFormat.mm;
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) { for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList(); final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
@@ -247,27 +289,28 @@ class PDFService {
pdf.addPage( pdf.addPage(
pw.Page( pw.Page(
pageFormat: PdfPageFormat.a4, pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(20), margin: pw.EdgeInsets.zero,
build: (_) => pw.Wrap( build: (_) => pw.Center(
spacing: 10, child: pw.Container(
runSpacing: 10, width: totalGridWidth,
height: totalGridHeight,
child: pw.Wrap(
spacing: 0, // Très important : 0 espace entre les colonnes
runSpacing: 0, // 0 espace entre les lignes
children: List.generate(pageItems.length, (i) { children: List.generate(pageItems.length, (i) {
final item = pageItems[i]; final item = pageItems[i];
return pw.Container( return pw.Container(
width: 260, width: labelWidth,
height: 120, height: labelHeight,
decoration: pw.BoxDecoration( padding: const pw.EdgeInsets.all(6),
border: pw.Border.all(color: PdfColors.grey400), // Suppression de la décoration (bordure)
borderRadius: pw.BorderRadius.circular(4),
),
padding: const pw.EdgeInsets.all(8),
child: pw.Row( child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [ children: [
// QR Code // QR Code
pw.Container( pw.Container(
width: qrSize, width: innerQrSize,
height: qrSize, height: innerQrSize,
child: pw.Image(pw.MemoryImage(pageQRs[i])), child: pw.Image(pw.MemoryImage(pageQRs[i])),
), ),
pw.SizedBox(width: 8), pw.SizedBox(width: 8),
@@ -275,22 +318,21 @@ class PDFService {
pw.Expanded( pw.Expanded(
child: pw.Column( child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.start,
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [ children: [
// Logo // Logo
if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty) if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
pw.Center( pw.Container(
child: pw.Container( height: 20,
height: 25, alignment: pw.Alignment.centerLeft,
margin: const pw.EdgeInsets.only(bottom: 6), margin: const pw.EdgeInsets.only(bottom: 4),
child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)), child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)),
), ),
),
// Titre // Titre
if (getTitle != null) ...[ if (getTitle != null) ...[
pw.SizedBox(height: 2),
pw.Text( pw.Text(
_truncate(getTitle(item), 20), _truncate(getTitle(item), 40),
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold), style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
maxLines: 2, maxLines: 2,
), ),
], ],
@@ -298,18 +340,17 @@ class PDFService {
pw.SizedBox(height: 2), pw.SizedBox(height: 2),
pw.Text( pw.Text(
getId(item), getId(item),
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
maxLines: 1,
), ),
// Détails supplémentaires // Détails supplémentaires
if (getDetails != null) ...[ if (getDetails != null) ...[
pw.SizedBox(height: 4), pw.SizedBox(height: 4),
...getDetails(item).take(5).map((line) { ...getDetails(item).take(4).map((line) {
return pw.Padding( return pw.Padding(
padding: const pw.EdgeInsets.only(bottom: 1), padding: const pw.EdgeInsets.only(bottom: 1),
child: pw.Text( child: pw.Text(
_truncate(line, 25), _truncate(line, 35),
style: const pw.TextStyle(fontSize: 6, color: PdfColors.grey800), style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey800),
), ),
); );
}), }),
@@ -323,14 +364,14 @@ class PDFService {
}), }),
), ),
), ),
),
),
); );
} }
} }
/// Nettoie le cache (logo) /// Nettoie le cache (logo)
static void clearCache() { static void clearCache() {
_cachedLogoBytes = null; _cachedLogoBytes = null;
_logoLoadAttempted = false; _logoLoadAttempted = false;
} }
} }

View File

@@ -0,0 +1,117 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:em2rp/config/app_version.dart';
import 'package:url_launcher/url_launcher.dart';
/// Service pour gérer les mises à jour de l'application
class UpdateService {
// URL de votre version.json déployé sur Firebase Hosting
static const String versionUrl = 'https://app.em2events.fr/version.json';
/// Vérifie si une mise à jour est disponible
static Future<UpdateInfo?> checkForUpdate() async {
try {
// Récupérer la version actuelle depuis AppVersion
final currentVersion = AppVersion.version;
if (kDebugMode) {
print('[UpdateService] Current version: $currentVersion');
}
// Récupérer la version depuis le serveur (avec cache-busting)
final timestamp = DateTime.now().millisecondsSinceEpoch;
final response = await http.get(
Uri.parse('$versionUrl?t=$timestamp'),
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final serverVersion = data['version'] as String;
if (kDebugMode) {
print('[UpdateService] Server version: $serverVersion');
}
// Comparer les versions
if (_isNewerVersion(serverVersion, currentVersion)) {
return UpdateInfo(
currentVersion: currentVersion,
newVersion: serverVersion,
updateUrl: data['updateUrl'] as String?,
releaseNotes: data['releaseNotes'] as String?,
forceUpdate: data['forceUpdate'] as bool? ?? false,
);
}
}
return null;
} catch (e) {
if (kDebugMode) {
print('[UpdateService] Error checking for update: $e');
}
return null;
}
}
/// Compare deux versions sémantiques (x.y.z)
/// Retourne true si newVersion > currentVersion
static bool _isNewerVersion(String newVersion, String currentVersion) {
final newParts = newVersion.split('.').map(int.parse).toList();
final currentParts = currentVersion.split('.').map(int.parse).toList();
// Comparer major
if (newParts[0] > currentParts[0]) return true;
if (newParts[0] < currentParts[0]) return false;
// Comparer minor
if (newParts[1] > currentParts[1]) return true;
if (newParts[1] < currentParts[1]) return false;
// Comparer patch
return newParts[2] > currentParts[2];
}
/// Force le rechargement de l'application (vide le cache)
static Future<void> reloadApp() async {
if (kIsWeb) {
// Pour le web, recharger la page en utilisant JavaScript
final url = Uri.base;
await launchUrl(url, webOnlyWindowName: '_self');
}
}
/// Vérification automatique au démarrage
static Future<UpdateInfo?> checkOnStartup() async {
// Attendre un peu avant de vérifier (pour ne pas ralentir le démarrage)
await Future.delayed(const Duration(seconds: 2));
return await checkForUpdate();
}
}
/// Informations sur une mise à jour disponible
class UpdateInfo {
final String currentVersion;
final String newVersion;
final String? updateUrl;
final String? releaseNotes;
final bool forceUpdate;
UpdateInfo({
required this.currentVersion,
required this.newVersion,
this.updateUrl,
this.releaseNotes,
this.forceUpdate = false,
});
String get versionDifference {
return 'Nouvelle version disponible';
}
}

View File

@@ -1,40 +1,49 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import '../models/user_model.dart'; import '../models/user_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
/// @deprecated Ce service est obsolète. Utilisez UsersProvider avec DataService à la place.
/// Ce service reste pour compatibilité mais toutes les opérations passent par l'API.
class UserService { class UserService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final DataService _dataService = DataService(FirebaseFunctionsApiService());
/// @deprecated Utilisez UsersProvider.fetchUsers() à la place
Future<List<UserModel>> fetchUsers() async { Future<List<UserModel>> fetchUsers() async {
try { try {
final snapshot = await _firestore.collection('users').get(); final usersData = await _dataService.getUsers();
return snapshot.docs return usersData.map((data) => UserModel.fromMap(data, data['id'] as String)).toList();
.map((doc) => UserModel.fromMap(doc.data(), doc.id))
.toList();
} catch (e) { } catch (e) {
print("Erreur: $e"); print("Erreur: $e");
return []; return [];
} }
} }
/// @deprecated Utilisez DataService.updateUser() à la place
Future<void> updateUser(UserModel user) async { Future<void> updateUser(UserModel user) async {
try { try {
await _firestore.collection('users').doc(user.uid).update(user.toMap()); await _dataService.updateUser(user.uid, user.toMap());
} catch (e) { } catch (e) {
print("Erreur mise à jour: $e"); print("Erreur mise à jour: $e");
} }
} }
/// @deprecated Utilisez API deleteUser à la place
Future<void> deleteUser(String uid) async { Future<void> deleteUser(String uid) async {
try { try {
await _firestore.collection('users').doc(uid).delete(); final apiService = FirebaseFunctionsApiService();
await apiService.call('deleteUser', {'userId': uid});
} catch (e) { } catch (e) {
print("Erreur suppression: $e"); print("Erreur suppression: $e");
rethrow;
} }
} }
/// Firebase Auth reste OK (pas Firestore)
Future<void> resetPassword(String email) async { Future<void> resetPassword(String email) async {
try { try {
// Firebase Auth est OK, ce n'est pas Firestore
await FirebaseAuth.instance.sendPasswordResetEmail(email: email); await FirebaseAuth.instance.sendPasswordResetEmail(email: email);
print("Email de réinitialisation envoyé à $email"); print("Email de réinitialisation envoyé à $email");
} catch (e) { } catch (e) {

View File

@@ -0,0 +1,274 @@
/// Énumération centralisée de toutes les permissions de l'application
/// Chaque permission contrôle l'accès à une fonctionnalité spécifique
enum AppPermission {
// ============= ÉVÉNEMENTS =============
/// Permet de voir les événements
viewEvents('view_events'),
/// Permet de créer de nouveaux événements
createEvents('create_events'),
/// Permet de modifier les événements existants
editEvents('edit_events'),
/// Permet de supprimer des événements
deleteEvents('delete_events'),
/// Permet de voir tous les événements de tous les utilisateurs
/// (nécessaire pour le filtre par utilisateur dans le calendrier)
viewAllUserEvents('view_all_user_events'),
// ============= ÉQUIPEMENTS =============
/// Permet de voir la liste des équipements
viewEquipment('view_equipment'),
/// Permet de créer, modifier et supprimer des équipements
/// Inclut aussi la gestion des prix d'achat/location
manageEquipment('manage_equipment'),
// ============= CONTENEURS =============
/// Permet de voir les conteneurs
viewContainers('view_containers'),
/// Permet de créer, modifier et supprimer des conteneurs
manageContainers('manage_containers'),
// ============= MAINTENANCE =============
/// Permet de voir les maintenances
viewMaintenance('view_maintenance'),
/// Permet de créer, modifier et supprimer des maintenances
manageMaintenance('manage_maintenance'),
// ============= UTILISATEURS =============
/// Permet de voir la liste de tous les utilisateurs
viewAllUsers('view_all_users'),
/// Permet de créer, modifier et supprimer des utilisateurs
/// Inclut la gestion des rôles
manageUsers('manage_users'),
// ============= ALERTES =============
/// Reçoit les alertes de maintenance
receiveMaintenanceAlerts('receive_maintenance_alerts'),
/// Reçoit les alertes d'événements (création, modification)
receiveEventAlerts('receive_event_alerts'),
/// Reçoit les alertes de stock faible
receiveStockAlerts('receive_stock_alerts'),
// ============= NOTIFICATIONS =============
/// Peut recevoir des notifications par email
receiveEmailNotifications('receive_email_notifications'),
/// Peut recevoir des notifications push dans le navigateur
receivePushNotifications('receive_push_notifications'),
// ============= PRÉPARATION/CHARGEMENT =============
/// Permet d'accéder aux pages de préparation d'événements
accessPreparation('access_preparation'),
/// Permet de valider les étapes de préparation
validatePreparation('validate_preparation'),
// ============= EXPORTS/RAPPORTS =============
/// Permet d'exporter des données (ICS, PDF, etc.)
exportData('export_data'),
/// Permet de générer des rapports
generateReports('generate_reports');
/// L'identifiant de la permission tel qu'il est stocké dans Firestore
final String id;
const AppPermission(this.id);
/// Convertit une string en AppPermission
static AppPermission? fromString(String? value) {
if (value == null) return null;
try {
return AppPermission.values.firstWhere((p) => p.id == value);
} catch (e) {
return null;
}
}
/// Retourne une description lisible de la permission (pour l'UI admin)
String get description {
switch (this) {
// Événements
case AppPermission.viewEvents:
return 'Voir les événements';
case AppPermission.createEvents:
return 'Créer des événements';
case AppPermission.editEvents:
return 'Modifier des événements';
case AppPermission.deleteEvents:
return 'Supprimer des événements';
case AppPermission.viewAllUserEvents:
return 'Voir les événements de tous les utilisateurs';
// Équipements
case AppPermission.viewEquipment:
return 'Voir les équipements';
case AppPermission.manageEquipment:
return 'Gérer les équipements';
// Conteneurs
case AppPermission.viewContainers:
return 'Voir les conteneurs';
case AppPermission.manageContainers:
return 'Gérer les conteneurs';
// Maintenance
case AppPermission.viewMaintenance:
return 'Voir les maintenances';
case AppPermission.manageMaintenance:
return 'Gérer les maintenances';
// Utilisateurs
case AppPermission.viewAllUsers:
return 'Voir tous les utilisateurs';
case AppPermission.manageUsers:
return 'Gérer les utilisateurs';
// Alertes
case AppPermission.receiveMaintenanceAlerts:
return 'Recevoir les alertes de maintenance';
case AppPermission.receiveEventAlerts:
return 'Recevoir les alertes d\'événements';
case AppPermission.receiveStockAlerts:
return 'Recevoir les alertes de stock';
// Notifications
case AppPermission.receiveEmailNotifications:
return 'Recevoir les notifications par email';
case AppPermission.receivePushNotifications:
return 'Recevoir les notifications push';
// Préparation
case AppPermission.accessPreparation:
return 'Accéder aux préparations d\'événements';
case AppPermission.validatePreparation:
return 'Valider les préparations';
// Exports
case AppPermission.exportData:
return 'Exporter des données';
case AppPermission.generateReports:
return 'Générer des rapports';
}
}
/// Retourne la catégorie de la permission (pour l'UI de gestion des rôles)
String get category {
switch (this) {
case AppPermission.viewEvents:
case AppPermission.createEvents:
case AppPermission.editEvents:
case AppPermission.deleteEvents:
case AppPermission.viewAllUserEvents:
return 'Événements';
case AppPermission.viewEquipment:
case AppPermission.manageEquipment:
return 'Équipements';
case AppPermission.viewContainers:
case AppPermission.manageContainers:
return 'Conteneurs';
case AppPermission.viewMaintenance:
case AppPermission.manageMaintenance:
return 'Maintenance';
case AppPermission.viewAllUsers:
case AppPermission.manageUsers:
return 'Utilisateurs';
case AppPermission.receiveMaintenanceAlerts:
case AppPermission.receiveEventAlerts:
case AppPermission.receiveStockAlerts:
return 'Alertes';
case AppPermission.receiveEmailNotifications:
case AppPermission.receivePushNotifications:
return 'Notifications';
case AppPermission.accessPreparation:
case AppPermission.validatePreparation:
return 'Préparation';
case AppPermission.exportData:
case AppPermission.generateReports:
return 'Exports & Rapports';
}
}
}
/// Extension pour faciliter les vérifications de permissions
extension PermissionListExtension on List<String> {
/// Vérifie si la liste contient une permission donnée
bool hasPermission(AppPermission permission) {
return contains(permission.id);
}
/// Vérifie si la liste contient toutes les permissions données
bool hasAllPermissions(List<AppPermission> permissions) {
return permissions.every((p) => contains(p.id));
}
/// Vérifie si la liste contient au moins une des permissions données
bool hasAnyPermission(List<AppPermission> permissions) {
return permissions.any((p) => contains(p.id));
}
}
/// Rôles prédéfinis avec leurs permissions
class PredefinedRoles {
/// Rôle ADMIN : Accès complet à toutes les fonctionnalités
static List<String> get admin => AppPermission.values.map((p) => p.id).toList();
/// Rôle TECHNICIEN : Gestion des équipements et préparation
static List<String> get technician => [
AppPermission.viewEvents.id,
AppPermission.viewEquipment.id,
AppPermission.manageEquipment.id,
AppPermission.viewContainers.id,
AppPermission.manageContainers.id,
AppPermission.viewMaintenance.id,
AppPermission.manageMaintenance.id,
AppPermission.receiveMaintenanceAlerts.id,
AppPermission.receiveStockAlerts.id,
AppPermission.accessPreparation.id,
AppPermission.validatePreparation.id,
AppPermission.exportData.id,
];
/// Rôle MANAGER : Gestion des événements et vue d'ensemble
static List<String> get manager => [
AppPermission.viewEvents.id,
AppPermission.createEvents.id,
AppPermission.editEvents.id,
AppPermission.deleteEvents.id,
AppPermission.viewAllUserEvents.id,
AppPermission.viewEquipment.id,
AppPermission.viewContainers.id,
AppPermission.viewMaintenance.id,
AppPermission.viewAllUsers.id,
AppPermission.receiveEventAlerts.id,
AppPermission.accessPreparation.id,
AppPermission.exportData.id,
AppPermission.generateReports.id,
];
/// Rôle USER : Consultation uniquement
static List<String> get user => [
AppPermission.viewEvents.id,
AppPermission.viewEquipment.id,
AppPermission.viewContainers.id,
AppPermission.receiveEventAlerts.id,
];
}

View File

@@ -17,14 +17,19 @@ class AuthGuard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final localAuthProvider = Provider.of<LocalUserProvider>(context); final localAuthProvider = Provider.of<LocalUserProvider>(context);
// Log pour débug
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
// Si l'utilisateur n'est pas connecté // Si l'utilisateur n'est pas connecté
if (localAuthProvider.currentUser == null) { if (localAuthProvider.currentUser == null) {
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
return const LoginPage(); return const LoginPage();
} }
// Si la page requiert une permission spécifique et que l'utilisateur ne la possède pas // Si la page requiert une permission spécifique et que l'utilisateur ne la possède pas
if (requiredPermission != null && if (requiredPermission != null &&
!localAuthProvider.hasPermission(requiredPermission!)) { !localAuthProvider.hasPermission(requiredPermission!)) {
print('[AuthGuard] Permission "$requiredPermission" refusée');
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text("Accès refusé")), appBar: AppBar(title: const Text("Accès refusé")),
body: const Center( body: const Center(
@@ -34,6 +39,7 @@ class AuthGuard extends StatelessWidget {
} }
// Sinon, afficher la page demandée // Sinon, afficher la page demandée
print('[AuthGuard] Accès autorisé, affichage de la page');
return child; return child;
} }
} }

View File

@@ -0,0 +1,33 @@
import 'package:flutter/foundation.dart';
/// Helper pour gérer les logs de debug
/// Les logs sont automatiquement désactivés en mode release
class DebugLog {
/// Flag pour activer/désactiver les logs manuellement
static const bool _forceEnableLogs = false;
/// Vérifie si les logs doivent être affichés
static bool get _shouldLog => kDebugMode || _forceEnableLogs;
/// Log une information
static void info(String message) {
if (_shouldLog) {
print(message);
}
}
/// Log une erreur (toujours affiché, même en production)
static void error(String message, [Object? error, StackTrace? stackTrace]) {
print('ERROR: $message');
if (error != null) print(' Error: $error');
if (stackTrace != null && kDebugMode) print(' StackTrace: $stackTrace');
}
/// Log un warning
static void warning(String message) {
if (_shouldLog) {
print('WARNING: $message');
}
}
}

View File

@@ -0,0 +1,35 @@
import 'package:em2rp/models/equipment_model.dart';
/// Helpers pour la gestion et l'affichage des équipements
class EquipmentHelpers {
/// Détermine si un équipement devrait avoir une quantité par défaut
/// Retourne true pour câbles, consommables et structures
static bool shouldBeQuantifiableByDefault(EquipmentCategory category) {
return category == EquipmentCategory.cable ||
category == EquipmentCategory.consumable ||
category == EquipmentCategory.structure;
}
/// Calcule la quantité disponible d'un équipement
/// Prend en compte la quantité totale et la quantité déjà assignée
static int calculateAvailableQuantity(
EquipmentModel equipment,
int assignedQuantity,
) {
if (!equipment.hasQuantity) return 0;
final total = equipment.availableQuantity ?? equipment.totalQuantity ?? 0;
return (total - assignedQuantity).clamp(0, total);
}
/// Vérifie si un équipement est en stock faible
/// (quantité disponible en dessous du seuil critique)
static bool isLowStock(EquipmentModel equipment) {
if (!equipment.hasQuantity) return false;
if (equipment.criticalThreshold == null) return false;
final available = equipment.availableQuantity ?? 0;
return available <= equipment.criticalThreshold!;
}
}

View File

@@ -1,18 +1,19 @@
import 'package:flutter/foundation.dart'; // pour kIsWeb import 'package:flutter/foundation.dart'; // pour kIsWeb
import 'package:firebase_storage/firebase_storage.dart'; import 'package:firebase_storage/firebase_storage.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class FirebaseStorageManager { class FirebaseStorageManager {
final FirebaseStorage _storage = FirebaseStorage.instance; final FirebaseStorage _storage = FirebaseStorage.instance;
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final DataService _dataService = DataService(FirebaseFunctionsApiService());
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage. /// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
/// Pour le Web, on fixe l'extension .jpg. /// Pour le Web, on fixe l'extension .jpg.
/// 1. Construit le chemin : "ProfilePictures/UID.jpg" /// 1. Construit le chemin : "ProfilePictures/UID.jpg"
/// 2. Supprime l'ancienne photo (si elle existe). /// 2. Supprime l'ancienne photo (si elle existe).
/// 3. Upload la nouvelle photo. /// 3. Upload la nouvelle photo.
/// 4. Met à jour Firestore avec l'URL de la nouvelle image. /// 4. Met à jour Firestore avec l'URL de la nouvelle image via l'API.
Future<String?> sendProfilePicture( Future<String?> sendProfilePicture(
{required XFile imageFile, required String uid}) async { {required XFile imageFile, required String uid}) async {
try { try {
@@ -57,17 +58,14 @@ class FirebaseStorageManager {
print( print(
"FirebaseStorageManager: Nouvelle photo uploadée pour l'utilisateur $uid. URL: $downloadUrl"); "FirebaseStorageManager: Nouvelle photo uploadée pour l'utilisateur $uid. URL: $downloadUrl");
// 5. Mettre à jour Firestore avec l'URL de la photo de profil // 5. Mettre à jour via l'API (plus sécurisé)
try { try {
await _firestore await _dataService.updateUser(uid, {'profilePhotoUrl': downloadUrl});
.collection('users')
.doc(uid)
.update({'profilePhotoUrl': downloadUrl});
print( print(
"FirebaseStorageManager: Firestore mis à jour pour l'utilisateur $uid."); "FirebaseStorageManager: Profil mis à jour via API pour l'utilisateur $uid.");
} catch (firestoreError) { } catch (apiError) {
print( print(
"FirebaseStorageManager: Erreur Firestore pour l'utilisateur $uid: $firestoreError"); "FirebaseStorageManager: Erreur API pour l'utilisateur $uid: $apiError");
return downloadUrl; // On retourne l'URL même si la mise à jour échoue return downloadUrl; // On retourne l'URL même si la mise à jour échoue
} }
return downloadUrl; return downloadUrl;

View File

@@ -0,0 +1,86 @@
import 'package:em2rp/models/event_model.dart';
/// Helper pour la gestion des prix HT et TTC
class PriceHelpers {
/// Taux de TVA par défaut (20%)
static const double defaultTaxRate = 0.20;
/// Calcule le prix TTC à partir du prix HT
static double calculateTTC(double priceHT, {double taxRate = defaultTaxRate}) {
return priceHT * (1 + taxRate);
}
/// Calcule le prix HT à partir du prix TTC
static double calculateHT(double priceTTC, {double taxRate = defaultTaxRate}) {
return priceTTC / (1 + taxRate);
}
/// Calcule le montant de TVA
static double calculateTax(double priceHT, {double taxRate = defaultTaxRate}) {
return priceHT * taxRate;
}
/// Formate un prix en euros avec deux décimales
static String formatPrice(double price) {
return '${price.toStringAsFixed(2)}';
}
/// Retourne un objet EventPricing avec HT, TVA et TTC calculés
static EventPricing getPricing(EventModel event, {double taxRate = defaultTaxRate}) {
// basePrice dans Firestore est le prix TTC (avec TVA 20% déjà incluse)
final priceTTC = event.basePrice;
final priceHT = calculateHT(priceTTC, taxRate: taxRate);
final taxAmount = calculateTax(priceHT, taxRate: taxRate);
return EventPricing(
priceHT: priceHT,
taxAmount: taxAmount,
priceTTC: priceTTC,
taxRate: taxRate,
);
}
}
/// Classe pour stocker les différentes composantes du prix d'un événement
class EventPricing {
final double priceHT;
final double taxAmount;
final double priceTTC;
final double taxRate;
const EventPricing({
required this.priceHT,
required this.taxAmount,
required this.priceTTC,
required this.taxRate,
});
/// Retourne le taux de TVA en pourcentage (ex: 20.0 pour 20%)
double get taxRatePercentage => taxRate * 100;
/// Formate le prix HT
String get formattedHT => PriceHelpers.formatPrice(priceHT);
/// Formate le montant de TVA
String get formattedTax => PriceHelpers.formatPrice(taxAmount);
/// Formate le prix TTC
String get formattedTTC => PriceHelpers.formatPrice(priceTTC);
/// Retourne un résumé complet du pricing
String get summary => 'HT: $formattedHT | TVA (${taxRatePercentage.toStringAsFixed(0)}%): $formattedTax | TTC: $formattedTTC';
}
/// Widget helper pour afficher les prix
class PriceDisplay {
/// Génère un Map avec les composantes de prix pour affichage
static Map<String, String> getPriceComponents(EventModel event) {
final pricing = PriceHelpers.getPricing(event);
return {
'HT': pricing.formattedHT,
'TVA': '${pricing.formattedTax} (${pricing.taxRatePercentage.toStringAsFixed(0)}%)',
'TTC': pricing.formattedTTC,
};
}
}

View File

@@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/alert_model.dart';
import 'package:em2rp/services/alert_service.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/widgets/alert_item.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:provider/provider.dart';
/// Page listant toutes les alertes de l'utilisateur
class AlertsPage extends StatefulWidget {
const AlertsPage({super.key});
@override
State<AlertsPage> createState() => _AlertsPageState();
}
class _AlertsPageState extends State<AlertsPage> with SingleTickerProviderStateMixin {
late TabController _tabController;
final AlertService _alertService = AlertService();
AlertType? _filter;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_tabController.addListener(() {
setState(() {
_filter = _getFilterForTab(_tabController.index);
});
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
AlertType? _getFilterForTab(int index) {
switch (index) {
case 0:
return null; // Toutes
case 1:
return AlertType.eventCreated; // Événements (on filtrera manuellement)
case 2:
return AlertType.maintenanceDue; // Maintenance
case 3:
return AlertType.lost; // Équipement
default:
return null;
}
}
@override
Widget build(BuildContext context) {
final localUserProvider = context.watch<LocalUserProvider>();
final userId = localUserProvider.currentUser?.uid;
if (userId == null) {
return Scaffold(
appBar: AppBar(
title: const Text('Notifications'),
),
body: const Center(
child: Text('Veuillez vous connecter'),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Notifications'),
backgroundColor: AppColors.rouge,
actions: [
IconButton(
icon: const Icon(Icons.done_all),
onPressed: () => _markAllAsRead(userId),
tooltip: 'Tout marquer comme lu',
),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
tabs: const [
Tab(text: 'Toutes'),
Tab(text: 'Événements'),
Tab(text: 'Maintenance'),
Tab(text: 'Équipement'),
],
),
),
body: _buildAlertsList(userId),
);
}
Widget _buildAlertsList(String userId) {
return StreamBuilder<List<AlertModel>>(
stream: _alertService.alertsStreamForUser(userId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
// Log détaillé de l'erreur
print('[AlertsPage] ERREUR Stream: ${snapshot.error}');
print('[AlertsPage] StackTrace: ${snapshot.stackTrace}');
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('Erreur de chargement des alertes'),
const SizedBox(height: 8),
Text(
snapshot.error.toString(),
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => setState(() {}),
child: const Text('Réessayer'),
),
],
),
);
}
final allAlerts = snapshot.data ?? [];
// Filtrer selon l'onglet sélectionné
final filteredAlerts = _filterAlerts(allAlerts);
if (filteredAlerts.isEmpty) {
return _buildEmptyState();
}
return RefreshIndicator(
onRefresh: () async {
setState(() {});
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: filteredAlerts.length,
itemBuilder: (context, index) {
final alert = filteredAlerts[index];
return AlertItem(
alert: alert,
onTap: () => _handleAlertTap(alert),
onMarkAsRead: () => _markAsRead(alert.id),
onDelete: () => _deleteAlert(alert.id),
);
},
),
);
},
);
}
List<AlertModel> _filterAlerts(List<AlertModel> alerts) {
if (_filter == null) {
return alerts; // Toutes
}
switch (_tabController.index) {
case 1: // Événements
return alerts.where((a) => a.isEventAlert).toList();
case 2: // Maintenance
return alerts.where((a) => a.isMaintenanceAlert).toList();
case 3: // Équipement
return alerts.where((a) => a.isEquipmentAlert).toList();
default:
return alerts;
}
}
Widget _buildEmptyState() {
String message;
IconData icon;
switch (_tabController.index) {
case 1:
message = 'Aucune alerte d\'événement';
icon = Icons.event;
break;
case 2:
message = 'Aucune alerte de maintenance';
icon = Icons.build;
break;
case 3:
message = 'Aucune alerte d\'équipement';
icon = Icons.inventory_2;
break;
default:
message = 'Aucune notification';
icon = Icons.notifications_none;
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
message,
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
);
}
Future<void> _handleAlertTap(AlertModel alert) async {
// Marquer comme lu si pas déjà lu
if (!alert.isRead) {
await _markAsRead(alert.id);
}
// Redirection selon actionUrl (pour l'instant, juste rester sur la page)
// TODO: Implémenter navigation vers événement/équipement si besoin
}
Future<void> _markAsRead(String alertId) async {
try {
await _alertService.markAsRead(alertId);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur : $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _deleteAlert(String alertId) async {
try {
await _alertService.deleteAlert(alertId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Alerte supprimée'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur : $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _markAllAsRead(String userId) async {
try {
final alerts = await _alertService.getAlertsForUser(userId);
for (final alert in alerts.where((a) => !a.isRead)) {
await _alertService.markAsRead(alert.id);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Toutes les alertes ont été marquées comme lues'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur : $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}

View File

@@ -12,6 +12,7 @@ import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart'; import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
import 'package:em2rp/views/event_add_page.dart'; import 'package:em2rp/views/event_add_page.dart';
import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart'; import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart';
import 'package:em2rp/views/widgets/calendar_widgets/user_filter_dropdown.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
class CalendarPage extends StatefulWidget { class CalendarPage extends StatefulWidget {
@@ -28,6 +29,7 @@ class _CalendarPageState extends State<CalendarPage> {
EventModel? _selectedEvent; EventModel? _selectedEvent;
bool _calendarCollapsed = false; bool _calendarCollapsed = false;
int _selectedEventIndex = 0; int _selectedEventIndex = 0;
String? _selectedUserId; // Filtre par utilisateur (null = tous les événements)
@override @override
void initState() { void initState() {
@@ -50,7 +52,6 @@ class _CalendarPageState extends State<CalendarPage> {
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
EventModel? selected; EventModel? selected;
DateTime? selectedDay; DateTime? selectedDay;
int selectedEventIndex = 0;
if (todayEvents.isNotEmpty) { if (todayEvents.isNotEmpty) {
selected = todayEvents[0]; selected = todayEvents[0];
selectedDay = DateTime(now.year, now.month, now.day); selectedDay = DateTime(now.year, now.month, now.day);
@@ -87,9 +88,7 @@ class _CalendarPageState extends State<CalendarPage> {
Provider.of<LocalUserProvider>(context, listen: false); Provider.of<LocalUserProvider>(context, listen: false);
final eventProvider = Provider.of<EventProvider>(context, listen: false); final eventProvider = Provider.of<EventProvider>(context, listen: false);
final userId = localAuthProvider.uid; final userId = localAuthProvider.uid;
print('Permissions utilisateur: ${localAuthProvider.permissions}');
final canViewAllEvents = localAuthProvider.hasPermission('view_all_events'); final canViewAllEvents = localAuthProvider.hasPermission('view_all_events');
print('canViewAllEvents: $canViewAllEvents');
if (userId != null) { if (userId != null) {
await eventProvider.loadUserEvents(userId, await eventProvider.loadUserEvents(userId,
@@ -97,6 +96,26 @@ 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) {
if (_selectedUserId == null) {
return allEvents; // Pas de filtre, retourner tous les événements
}
// Filtrer les événements où l'utilisateur sélectionné fait partie de la workforce
return allEvents.where((event) {
return event.workforce.any((worker) {
if (worker is String) {
return worker == _selectedUserId;
}
// Si c'est une DocumentReference, on ne peut pas facilement comparer
// On suppose que les données sont chargées correctement en String
return false;
});
}).toList();
}
void _changeWeek(int delta) { void _changeWeek(int delta) {
setState(() { setState(() {
_focusedDay = _focusedDay.add(Duration(days: 7 * delta)); _focusedDay = _focusedDay.add(Duration(days: 7 * delta));
@@ -107,9 +126,13 @@ class _CalendarPageState extends State<CalendarPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final eventProvider = Provider.of<EventProvider>(context); final eventProvider = Provider.of<EventProvider>(context);
final localUserProvider = Provider.of<LocalUserProvider>(context); final localUserProvider = Provider.of<LocalUserProvider>(context);
final isAdmin = localUserProvider.hasPermission('view_all_users'); final canCreateEvents = localUserProvider.hasPermission('create_events');
final canViewAllUserEvents = localUserProvider.hasPermission('view_all_user_events');
final isMobile = MediaQuery.of(context).size.width < 600; final isMobile = MediaQuery.of(context).size.width < 600;
// Appliquer le filtre utilisateur si actif
final filteredEvents = _getFilteredEvents(eventProvider.events);
if (eventProvider.isLoading) { if (eventProvider.isLoading) {
return const Scaffold( return const Scaffold(
body: Center( body: Center(
@@ -123,8 +146,42 @@ class _CalendarPageState extends State<CalendarPage> {
title: "Calendrier", title: "Calendrier",
), ),
drawer: const MainDrawer(currentPage: '/calendar'), drawer: const MainDrawer(currentPage: '/calendar'),
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), body: Column(
floatingActionButton: isAdmin 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;
});
},
),
),
],
),
),
// Corps du calendrier
Expanded(
child: isMobile ? _buildMobileLayout(filteredEvents) : _buildDesktopLayout(filteredEvents),
),
],
),
floatingActionButton: canCreateEvents
? FloatingActionButton( ? FloatingActionButton(
backgroundColor: Colors.white, backgroundColor: Colors.white,
onPressed: () { onPressed: () {
@@ -143,14 +200,13 @@ class _CalendarPageState extends State<CalendarPage> {
); );
} }
Widget _buildDesktopLayout() { Widget _buildDesktopLayout(List<EventModel> filteredEvents) {
final eventProvider = Provider.of<EventProvider>(context);
return Row( return Row(
children: [ children: [
// Calendrier (65% de la largeur) // Calendrier (65% de la largeur)
Expanded( Expanded(
flex: 65, flex: 65,
child: _buildCalendar(), child: _buildCalendar(filteredEvents),
), ),
// Détails de l'événement (35% de la largeur) // Détails de l'événement (35% de la largeur)
Expanded( Expanded(
@@ -159,7 +215,7 @@ class _CalendarPageState extends State<CalendarPage> {
? EventDetails( ? EventDetails(
event: _selectedEvent!, event: _selectedEvent!,
selectedDate: _selectedDay, selectedDate: _selectedDay,
events: eventProvider.events, events: filteredEvents,
onSelectEvent: (event, date) { onSelectEvent: (event, date) {
setState(() { setState(() {
_selectedEvent = event; _selectedEvent = event;
@@ -178,11 +234,10 @@ class _CalendarPageState extends State<CalendarPage> {
); );
} }
Widget _buildMobileLayout() { Widget _buildMobileLayout(List<EventModel> filteredEvents) {
final eventProvider = Provider.of<EventProvider>(context);
final eventsForSelectedDay = _selectedDay == null final eventsForSelectedDay = _selectedDay == null
? [] ? []
: eventProvider.events : filteredEvents
.where((e) => .where((e) =>
e.startDateTime.year == _selectedDay!.year && e.startDateTime.year == _selectedDay!.year &&
e.startDateTime.month == _selectedDay!.month && e.startDateTime.month == _selectedDay!.month &&
@@ -267,9 +322,9 @@ class _CalendarPageState extends State<CalendarPage> {
child: MobileCalendarView( child: MobileCalendarView(
focusedDay: _focusedDay, focusedDay: _focusedDay,
selectedDay: _selectedDay, selectedDay: _selectedDay,
events: eventProvider.events, events: filteredEvents,
onDaySelected: (day) { onDaySelected: (day) {
final eventsForDay = eventProvider.events final eventsForDay = filteredEvents
.where((e) => .where((e) =>
e.startDateTime.year == day.year && e.startDateTime.year == day.year &&
e.startDateTime.month == day.month && e.startDateTime.month == day.month &&
@@ -505,13 +560,11 @@ class _CalendarPageState extends State<CalendarPage> {
} }
} }
Widget _buildCalendar() { Widget _buildCalendar(List<EventModel> filteredEvents) {
final eventProvider = Provider.of<EventProvider>(context);
if (_calendarFormat == CalendarFormat.week) { if (_calendarFormat == CalendarFormat.week) {
return WeekView( return WeekView(
focusedDay: _focusedDay, focusedDay: _focusedDay,
events: eventProvider.events, events: filteredEvents,
onWeekChange: _changeWeek, onWeekChange: _changeWeek,
onEventSelected: (event) { onEventSelected: (event) {
setState(() { setState(() {
@@ -525,7 +578,7 @@ class _CalendarPageState extends State<CalendarPage> {
}); });
}, },
onDaySelected: (selectedDay) { onDaySelected: (selectedDay) {
final eventsForDay = eventProvider.events final eventsForDay = filteredEvents
.where((e) => .where((e) =>
e.startDateTime.year == selectedDay.year && e.startDateTime.year == selectedDay.year &&
e.startDateTime.month == selectedDay.month && e.startDateTime.month == selectedDay.month &&
@@ -557,9 +610,9 @@ class _CalendarPageState extends State<CalendarPage> {
focusedDay: _focusedDay, focusedDay: _focusedDay,
selectedDay: _selectedDay, selectedDay: _selectedDay,
calendarFormat: _calendarFormat, calendarFormat: _calendarFormat,
events: eventProvider.events, events: filteredEvents,
onDaySelected: (selectedDay, focusedDay) { onDaySelected: (selectedDay, focusedDay) {
final eventsForDay = eventProvider.events final eventsForDay = filteredEvents
.where((event) => .where((event) =>
event.startDateTime.year == selectedDay.year && event.startDateTime.year == selectedDay.year &&
event.startDateTime.month == selectedDay.month && event.startDateTime.month == selectedDay.month &&

View File

@@ -622,6 +622,10 @@ class _ContainerDetailPageState extends State<ContainerDetailPage> {
return 'Consommable'; return 'Consommable';
case EquipmentCategory.cable: case EquipmentCategory.cable:
return 'Câble'; return 'Câble';
case EquipmentCategory.vehicle:
return 'Véhicule';
case EquipmentCategory.backline:
return 'Régie / Backline';
case EquipmentCategory.other: case EquipmentCategory.other:
return 'Autre'; return 'Autre';
} }

View File

@@ -5,6 +5,7 @@ import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/equipment_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/utils/id_generator.dart';
class ContainerFormPage extends StatefulWidget { class ContainerFormPage extends StatefulWidget {
@@ -534,7 +535,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } catch (e) {
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e'); DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
} }
} }
@@ -580,7 +581,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } catch (e) {
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e'); DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
} }
} }
@@ -593,7 +594,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
equipmentId: equipmentId, equipmentId: equipmentId,
); );
} catch (e) { } catch (e) {
print('Erreur lors du retrait de l\'équipement $equipmentId: $e'); DebugLog.error('Erreur lors du retrait de l\'équipement $equipmentId', e);
} }
} }
@@ -911,6 +912,10 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
return 'Consommable'; return 'Consommable';
case EquipmentCategory.cable: case EquipmentCategory.cable:
return 'Câble'; return 'Câble';
case EquipmentCategory.vehicle:
return 'Véhicule';
case EquipmentCategory.backline:
return 'Régie / Backline';
case EquipmentCategory.other: case EquipmentCategory.other:
return 'Autre'; return 'Autre';
} }
@@ -932,6 +937,10 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
return Icons.inventory; return Icons.inventory;
case EquipmentCategory.cable: case EquipmentCategory.cable:
return Icons.cable; return Icons.cable;
case EquipmentCategory.vehicle:
return Icons.local_shipping;
case EquipmentCategory.backline:
return Icons.piano;
case EquipmentCategory.other: case EquipmentCategory.other:
return Icons.category; return Icons.category;
} }

View File

@@ -5,14 +5,19 @@ import 'package:em2rp/utils/permission_gate.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart'; import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/views/equipment_detail_page.dart';
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
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/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/management/management_search_bar.dart';
import 'package:em2rp/views/widgets/management/management_card.dart'; import 'package:em2rp/views/widgets/management/management_card.dart';
import 'package:em2rp/views/widgets/management/management_list.dart'; import 'package:em2rp/views/widgets/management/management_list.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
class ContainerManagementPage extends StatefulWidget { class ContainerManagementPage extends StatefulWidget {
const ContainerManagementPage({super.key}); const ContainerManagementPage({super.key});
@@ -25,13 +30,61 @@ class ContainerManagementPage extends StatefulWidget {
class _ContainerManagementPageState extends State<ContainerManagementPage> class _ContainerManagementPageState extends State<ContainerManagementPage>
with SelectionModeMixin<ContainerManagementPage> { with SelectionModeMixin<ContainerManagementPage> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
ContainerType? _selectedType; ContainerType? _selectedType;
EquipmentStatus? _selectedStatus; bool _isLoadingMore = false; // Flag pour éviter les appels multiples
List<ContainerModel>? _cachedContainers; // Cache pour éviter le rebuild
@override
void initState() {
super.initState();
// Activer le mode pagination
final provider = context.read<ContainerProvider>();
provider.enablePagination();
// Ajouter le listener de scroll
_scrollController.addListener(_onScroll);
// Charger la première page
WidgetsBinding.instance.addPostFrameCallback((_) {
provider.loadFirstPage();
});
}
void _onScroll() {
// Éviter les appels multiples
if (_isLoadingMore) return;
final provider = context.read<ContainerProvider>();
// Charger la page suivante quand on arrive à 300px du bas
if (_scrollController.hasClients &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
setState(() => _isLoadingMore = true);
provider.loadNextPage().then((_) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
}).catchError((error) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
});
}
}
}
@override @override
void dispose() { void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchController.dispose(); _searchController.dispose();
context.read<ContainerProvider>().disablePagination();
super.dispose(); super.dispose();
} }
@@ -68,6 +121,7 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
actions: [ actions: [
const NotificationBadge(),
if (hasSelection) ...[ if (hasSelection) ...[
IconButton( IconButton(
icon: const Icon(Icons.qr_code, color: Colors.white), icon: const Icon(Icons.qr_code, color: Colors.white),
@@ -82,44 +136,14 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
], ],
], ],
) )
: AppBar( : CustomAppBar(
title: const Text('Gestion des Containers'), title: 'Gestion des Containers',
backgroundColor: AppColors.rouge,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
tooltip: 'Retour à la gestion des équipements', tooltip: 'Retour à la gestion des équipements',
onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'), onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'),
), ),
actions: [ showLogoutButton: true,
IconButton(
icon: const Icon(Icons.logout, color: Colors.white),
onPressed: () async {
final shouldLogout = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Déconnexion'),
content: const Text('Voulez-vous vraiment vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Déconnexion'),
),
],
),
);
if (shouldLogout == true && context.mounted) {
await context.read<LocalUserProvider>().signOut();
if (context.mounted) {
Navigator.pushReplacementNamed(context, '/login');
}
}
},
),
],
), ),
drawer: const MainDrawer(currentPage: '/container_management'), drawer: const MainDrawer(currentPage: '/container_management'),
floatingActionButton: !isSelectionMode floatingActionButton: !isSelectionMode
@@ -169,14 +193,37 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
} }
Widget _buildSearchBar() { Widget _buildSearchBar() {
return ManagementSearchBar( return SearchActionsBar(
controller: _searchController, controller: _searchController,
hintText: 'Rechercher un container...', hintText: 'Rechercher un container...',
onChanged: (value) { onChanged: (value) {
context.read<ContainerProvider>().setSearchQuery(value); context.read<ContainerProvider>().setSearchQuery(value);
}, },
onSelectionModeToggle: isSelectionMode ? null : toggleSelectionMode, onClear: () {
showSelectionModeButton: !isSelectionMode, _searchController.clear();
context.read<ContainerProvider>().setSearchQuery('');
},
actions: [
IconButton.filled(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
),
if (!isSelectionMode)
IconButton.filled(
onPressed: toggleSelectionMode,
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
],
); );
} }
@@ -261,30 +308,12 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
...ContainerType.values.map((type) { ...ContainerType.values.map((type) {
return _buildFilterOption(type, type.label); return _buildFilterOption(type, type.label);
}), }),
const Divider(height: 32),
// Filtre par statut
Text(
'Statut',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.noir,
),
),
const SizedBox(height: 8),
_buildStatusFilter(null, 'Tous les statuts'),
_buildStatusFilter(EquipmentStatus.available, 'Disponible'),
_buildStatusFilter(EquipmentStatus.inUse, 'En prestation'),
_buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'),
_buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'),
], ],
), ),
); );
} }
Widget _buildFilterOption(ContainerType? type, String label) { Widget _buildFilterOption(ContainerType? type, String label) {
final isSelected = _selectedType == type;
return RadioListTile<ContainerType?>( return RadioListTile<ContainerType?>(
title: Text(label), title: Text(label),
value: type, value: type,
@@ -301,36 +330,62 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
); );
} }
Widget _buildStatusFilter(EquipmentStatus? status, String label) {
final isSelected = _selectedStatus == status;
return RadioListTile<EquipmentStatus?>(
title: Text(label),
value: status,
groupValue: _selectedStatus,
activeColor: AppColors.rouge,
dense: true,
contentPadding: EdgeInsets.zero,
onChanged: (value) {
setState(() {
_selectedStatus = value;
context.read<ContainerProvider>().setSelectedStatus(_selectedStatus);
});
},
);
}
Widget _buildContainerList() { Widget _buildContainerList() {
return Consumer<ContainerProvider>( return Consumer<ContainerProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
return ManagementList<ContainerModel>( // Afficher l'indicateur de chargement initial
stream: provider.containersStream, if (provider.isLoading && provider.containers.isEmpty) {
cachedItems: _cachedContainers, return const Center(child: CircularProgressIndicator());
emptyMessage: 'Aucun container trouvé', }
emptyIcon: Icons.inventory_2_outlined,
onDataReceived: (items) { final containers = provider.containers;
_cachedContainers = items;
// Afficher le message vide
if (containers.isEmpty && !provider.isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucun container trouvé',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
],
),
);
}
// Calculer le nombre total d'items
final itemCount = containers.length + (provider.hasMore ? 1 : 0);
return ListView.builder(
controller: _scrollController,
itemCount: itemCount,
itemBuilder: (context, index) {
// Dernier élément = indicateur de chargement
if (index == containers.length) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: provider.isLoadingMore
? const CircularProgressIndicator()
: const SizedBox.shrink(),
),
);
}
return _buildContainerCard(containers[index]);
}, },
itemBuilder: (container) => _buildContainerCard(container),
); );
}, },
); );
@@ -417,7 +472,7 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
_editContainer(container); _editContainer(container);
break; break;
case 'qr': case 'qr':
// Non utilisé - les QR codes multiples sont générés via _generateQRCodesForSelected _showQRCode(container);
break; break;
case 'delete': case 'delete':
_deleteContainer(container); _deleteContainer(container);
@@ -425,6 +480,14 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
} }
} }
/// Afficher le QR code d'un conteneur
void _showQRCode(ContainerModel container) {
showDialog(
context: context,
builder: (context) => QRCodeDialog.forContainer(container),
);
}
void _navigateToForm(BuildContext context) async { void _navigateToForm(BuildContext context) async {
final result = await Navigator.pushNamed(context, '/container_form'); final result = await Navigator.pushNamed(context, '/container_form');
if (result == true) { if (result == true) {
@@ -452,6 +515,16 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
Future<void> _generateQRCodesForSelected() async { Future<void> _generateQRCodesForSelected() async {
if (!hasSelection) return; if (!hasSelection) return;
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(color: AppColors.rouge),
),
);
try {
// Récupérer les containers sélectionnés // Récupérer les containers sélectionnés
final containerProvider = context.read<ContainerProvider>(); final containerProvider = context.read<ContainerProvider>();
final List<ContainerModel> selectedContainers = []; final List<ContainerModel> selectedContainers = [];
@@ -467,6 +540,11 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
} }
} }
// Fermer l'indicateur de chargement
if (mounted) {
Navigator.of(context).pop();
}
if (selectedContainers.isEmpty) { if (selectedContainers.isEmpty) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -496,6 +574,23 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
), ),
); );
} }
} catch (e) {
// Fermer l'indicateur si une erreur survient
if (mounted) {
Navigator.of(context).pop();
}
DebugLog.error('[ContainerManagementPage] Error generating QR codes', e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la génération : ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
} }
Future<void> _deleteContainer(ContainerModel container) async { Future<void> _deleteContainer(ContainerModel container) async {
@@ -583,5 +678,119 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
} }
} }
} }
/// Scanner un QR Code et ouvrir la vue de détail correspondante
Future<void> _scanQRCode() async {
try {
// Ouvrir le scanner
final scannedCode = await showDialog<String>(
context: context,
builder: (context) => const QRCodeScannerDialog(),
);
if (scannedCode == null || scannedCode.isEmpty) {
return; // L'utilisateur a annulé
}
if (!mounted) return;
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(color: AppColors.rouge),
),
);
// Rechercher d'abord dans les conteneurs
final containerProvider = context.read<ContainerProvider>();
if (containerProvider.containers.isEmpty) {
await containerProvider.loadContainers();
}
final container = containerProvider.containers.firstWhere(
(c) => c.id == scannedCode,
orElse: () => ContainerModel(
id: '',
name: '',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (mounted) {
Navigator.of(context).pop(); // Fermer l'indicateur
}
if (container.id.isNotEmpty) {
// Conteneur trouvé
if (mounted) {
Navigator.pushNamed(
context,
'/container_detail',
arguments: container,
);
}
return;
}
// Si pas trouvé dans les conteneurs, chercher dans les équipements
final equipmentProvider = context.read<EquipmentProvider>();
await equipmentProvider.ensureLoaded();
final equipment = equipmentProvider.allEquipment.firstWhere(
(eq) => eq.id == scannedCode,
orElse: () => EquipmentModel(
id: '',
name: '',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (equipment.id.isNotEmpty) {
// Équipement trouvé
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentDetailPage(equipment: equipment),
),
);
}
return;
}
// Rien trouvé
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Aucun conteneur ou équipement trouvé avec l\'ID : $scannedCode'),
backgroundColor: Colors.orange,
),
);
}
} catch (e) {
DebugLog.error('[ContainerManagementPage] Error scanning QR code', e);
if (mounted) {
// Fermer l'indicateur si ouvert
Navigator.of(context).popUntil((route) => route.isFirst);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors du scan : ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
} }

View File

@@ -10,7 +10,6 @@ import 'package:em2rp/services/qr_code_service.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/equipment_form_page.dart'; import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/widgets/equipment/equipment_parent_containers.dart';
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart'; import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
import 'package:em2rp/views/widgets/equipment/equipment_header_section.dart'; import 'package:em2rp/views/widgets/equipment/equipment_header_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_main_info_section.dart'; import 'package:em2rp/views/widgets/equipment/equipment_main_info_section.dart';
@@ -124,15 +123,9 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
const SizedBox(height: 24), const SizedBox(height: 24),
// Containers parents (si applicable) // Containers contenant cet équipement
if (widget.equipment.parentBoxIds.isNotEmpty) ...[ // Note: On utilise EquipmentReferencingContainers qui recherche dynamiquement
EquipmentParentContainers( // les containers au lieu de se baser sur parentBoxIds qui peut être désynchronisé
parentBoxIds: widget.equipment.parentBoxIds,
),
const SizedBox(height: 24),
],
// Containers associés
EquipmentReferencingContainers( EquipmentReferencingContainers(
equipmentId: widget.equipment.id, equipmentId: widget.equipment.id,
), ),
@@ -256,6 +249,17 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(), '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
style: TextStyle(color: Colors.grey[700]), style: TextStyle(color: Colors.grey[700]),
), ),
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'📁 ${widget.equipment.subCategory}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 13,
fontStyle: FontStyle.italic,
),
),
],
], ],
), ),
), ),
@@ -400,27 +404,34 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
// Fermer le dialog
Navigator.pop(context); Navigator.pop(context);
// Capturer le ScaffoldMessenger avant la suppression
final scaffoldMessenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
try { try {
await context await context
.read<EquipmentProvider>() .read<EquipmentProvider>()
.deleteEquipment(widget.equipment.id); .deleteEquipment(widget.equipment.id);
if (mounted) {
Navigator.pop(context); // Revenir à la page précédente
ScaffoldMessenger.of(context).showSnackBar( navigator.pop();
// Afficher le snackbar (même si le widget est démonté)
scaffoldMessenger.showSnackBar(
const SnackBar( const SnackBar(
content: Text('Équipement supprimé avec succès'), content: Text('Équipement supprimé avec succès'),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
}
} catch (e) { } catch (e) {
if (mounted) { // Afficher l'erreur
ScaffoldMessenger.of(context).showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar(content: Text('Erreur: $e')), SnackBar(content: Text('Erreur: $e')),
); );
} }
}
}, },
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'), child: const Text('Supprimer'),

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/equipment_model.dart';
/// Widget de sélection de sous-catégorie avec autocomplétion
/// Similaire au système Brand/Model mais filtré par catégorie
class SubCategorySelector extends StatelessWidget {
final TextEditingController controller;
final EquipmentCategory? selectedCategory;
final List<String> filteredSubCategories;
final ValueChanged<String?>? onChanged;
const SubCategorySelector({
super.key,
required this.controller,
required this.selectedCategory,
required this.filteredSubCategories,
this.onChanged,
});
@override
Widget build(BuildContext context) {
return Autocomplete<String>(
initialValue: TextEditingValue(text: controller.text),
optionsBuilder: (TextEditingValue textEditingValue) {
if (selectedCategory == null) {
return const Iterable<String>.empty();
}
if (textEditingValue.text.isEmpty) {
return filteredSubCategories;
}
return filteredSubCategories.where((String subCategory) {
return subCategory.toLowerCase().contains(
textEditingValue.text.toLowerCase(),
);
});
},
onSelected: (String selection) {
controller.text = selection;
onChanged?.call(selection);
},
fieldViewBuilder: (context, fieldController, focusNode, onEditingComplete) {
if (fieldController.text != controller.text) {
fieldController.text = controller.text;
}
return TextFormField(
controller: fieldController,
focusNode: focusNode,
enabled: selectedCategory != null,
decoration: InputDecoration(
labelText: 'Sous-catégorie',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.category_outlined),
hintText: selectedCategory == null
? 'Catégorie requise'
: 'Saisissez la sous-catégorie',
helperText: 'Optionnel - Permet un classement plus précis',
),
onChanged: (value) {
controller.text = value;
onChanged?.call(value.isNotEmpty ? value : null);
},
);
},
);
}
}

View File

@@ -9,7 +9,9 @@ import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:em2rp/views/equipment_form/brand_model_selector.dart'; 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/id_generator.dart';
import 'package:em2rp/utils/debug_log.dart';
class EquipmentFormPage extends StatefulWidget { class EquipmentFormPage extends StatefulWidget {
final EquipmentModel? equipment; final EquipmentModel? equipment;
@@ -28,6 +30,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
final TextEditingController _identifierController = TextEditingController(); final TextEditingController _identifierController = TextEditingController();
final TextEditingController _brandController = TextEditingController(); final TextEditingController _brandController = TextEditingController();
final TextEditingController _modelController = TextEditingController(); final TextEditingController _modelController = TextEditingController();
final TextEditingController _subCategoryController = TextEditingController();
final TextEditingController _purchasePriceController = TextEditingController(); final TextEditingController _purchasePriceController = TextEditingController();
final TextEditingController _rentalPriceController = TextEditingController(); final TextEditingController _rentalPriceController = TextEditingController();
final TextEditingController _totalQuantityController = TextEditingController(); final TextEditingController _totalQuantityController = TextEditingController();
@@ -41,18 +44,15 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
DateTime? _purchaseDate; DateTime? _purchaseDate;
DateTime? _lastMaintenanceDate; DateTime? _lastMaintenanceDate;
DateTime? _nextMaintenanceDate; DateTime? _nextMaintenanceDate;
List<String> _selectedParentBoxIds = [];
List<EquipmentModel> _availableBoxes = [];
bool _isLoading = false; bool _isLoading = false;
bool _isLoadingBoxes = true;
bool _addMultiple = false; bool _addMultiple = false;
String? _selectedBrand; String? _selectedBrand;
List<String> _filteredModels = []; List<String> _filteredModels = [];
List<String> _filteredSubCategories = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadAvailableBoxes();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = Provider.of<EquipmentProvider>(context, listen: false); final provider = Provider.of<EquipmentProvider>(context, listen: false);
provider.loadBrands(); provider.loadBrands();
@@ -65,10 +65,12 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
void _populateFields() { void _populateFields() {
final equipment = widget.equipment!; final equipment = widget.equipment!;
setState(() {
_identifierController.text = equipment.id; _identifierController.text = equipment.id;
_brandController.text = equipment.brand ?? ''; _brandController.text = equipment.brand ?? '';
_selectedBrand = equipment.brand; _selectedBrand = equipment.brand;
_modelController.text = equipment.model ?? ''; _modelController.text = equipment.model ?? '';
_subCategoryController.text = equipment.subCategory ?? '';
_selectedCategory = equipment.category; _selectedCategory = equipment.category;
_selectedStatus = equipment.status; _selectedStatus = equipment.status;
_purchasePriceController.text = equipment.purchasePrice?.toStringAsFixed(2) ?? ''; _purchasePriceController.text = equipment.purchasePrice?.toStringAsFixed(2) ?? '';
@@ -78,32 +80,20 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
_purchaseDate = equipment.purchaseDate; _purchaseDate = equipment.purchaseDate;
_lastMaintenanceDate = equipment.lastMaintenanceDate; _lastMaintenanceDate = equipment.lastMaintenanceDate;
_nextMaintenanceDate = equipment.nextMaintenanceDate; _nextMaintenanceDate = equipment.nextMaintenanceDate;
_selectedParentBoxIds = List.from(equipment.parentBoxIds);
_notesController.text = equipment.notes ?? ''; _notesController.text = equipment.notes ?? '';
});
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) { if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
_loadFilteredModels(_selectedBrand!); _loadFilteredModels(_selectedBrand!);
} }
// Charger les sous-catégories pour la catégorie sélectionnée
_loadFilteredSubCategories(_selectedCategory);
} }
Future<void> _loadAvailableBoxes() async {
try {
final boxes = await _equipmentService.getBoxes();
setState(() {
_availableBoxes = boxes;
_isLoadingBoxes = false;
});
} catch (e) {
setState(() {
_isLoadingBoxes = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors du chargement des boîtes : $e')),
);
}
}
}
Future<void> _loadFilteredModels(String brand) async { Future<void> _loadFilteredModels(String brand) async {
try { try {
@@ -119,11 +109,26 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
} }
} }
Future<void> _loadFilteredSubCategories(EquipmentCategory category) async {
try {
final equipmentProvider = Provider.of<EquipmentProvider>(context, listen: false);
final subCategories = await equipmentProvider.loadSubCategoriesByCategory(category);
setState(() {
_filteredSubCategories = subCategories;
});
} catch (e) {
setState(() {
_filteredSubCategories = [];
});
}
}
@override @override
void dispose() { void dispose() {
_identifierController.dispose(); _identifierController.dispose();
_brandController.dispose(); _brandController.dispose();
_modelController.dispose(); _modelController.dispose();
_subCategoryController.dispose();
_purchasePriceController.dispose(); _purchasePriceController.dispose();
_rentalPriceController.dispose(); _rentalPriceController.dispose();
_totalQuantityController.dispose(); _totalQuantityController.dispose();
@@ -282,7 +287,9 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
if (value != null) { if (value != null) {
setState(() { setState(() {
_selectedCategory = value; _selectedCategory = value;
_subCategoryController.clear();
}); });
_loadFilteredSubCategories(value);
} }
}, },
), ),
@@ -318,6 +325,19 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Sous-catégorie
SubCategorySelector(
controller: _subCategoryController,
selectedCategory: _selectedCategory,
filteredSubCategories: _filteredSubCategories,
onChanged: (value) {
setState(() {
// La valeur est déjà dans le controller
});
},
),
const SizedBox(height: 16),
// Prix // Prix
if (hasManagePermission) ...[ if (hasManagePermission) ...[
Row( Row(
@@ -389,15 +409,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
// Boîtes parentes
const Divider(),
const Text('Boîtes parentes', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
_isLoadingBoxes
? const Center(child: CircularProgressIndicator())
: _buildParentBoxesSelector(),
const SizedBox(height: 16),
// Dates // Dates
const Divider(), const Divider(),
const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
@@ -448,38 +459,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
); );
} }
Widget _buildParentBoxesSelector() {
if (_availableBoxes.isEmpty) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('Aucune boîte disponible'),
),
);
}
return Card(
child: Column(
children: _availableBoxes.map((box) {
final isSelected = _selectedParentBoxIds.contains(box.id);
return CheckboxListTile(
title: Text(box.name),
subtitle: box.model != null ? Text('Modèle: {box.model}') : null,
value: isSelected,
onChanged: (bool? value) {
setState(() {
if (value == true) {
_selectedParentBoxIds.add(box.id);
} else {
_selectedParentBoxIds.remove(box.id);
}
});
},
);
}).toList(),
),
);
}
Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) { Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) {
return InkWell( return InkWell(
@@ -617,6 +596,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
brand: brand, brand: brand,
model: model, model: model,
category: _selectedCategory, category: _selectedCategory,
subCategory: _subCategoryController.text.trim().isNotEmpty ? _subCategoryController.text.trim() : null,
status: _selectedStatus, status: _selectedStatus,
purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null, purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null,
rentalPrice: _rentalPriceController.text.isNotEmpty ? double.tryParse(_rentalPriceController.text) : null, rentalPrice: _rentalPriceController.text.isNotEmpty ? double.tryParse(_rentalPriceController.text) : null,
@@ -625,17 +605,13 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
purchaseDate: _purchaseDate, purchaseDate: _purchaseDate,
lastMaintenanceDate: _lastMaintenanceDate, lastMaintenanceDate: _lastMaintenanceDate,
nextMaintenanceDate: _nextMaintenanceDate, nextMaintenanceDate: _nextMaintenanceDate,
parentBoxIds: _selectedParentBoxIds,
notes: _notesController.text, notes: _notesController.text,
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now, createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
updatedAt: now, updatedAt: now,
availableQuantity: availableQuantity, availableQuantity: availableQuantity,
); );
if (isEditing) { if (isEditing) {
await equipmentProvider.updateEquipment( await equipmentProvider.updateEquipment(equipment);
equipment.id,
equipment.toMap(),
);
} else { } else {
await equipmentProvider.addEquipment(equipment); await equipmentProvider.addEquipment(equipment);
} }

View File

@@ -5,14 +5,21 @@ import 'package:em2rp/utils/permission_gate.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart'; import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/views/equipment_form_page.dart'; import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/equipment_detail_page.dart'; import 'package:em2rp/views/equipment_detail_page.dart';
import 'package:em2rp/views/container_detail_page.dart';
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart'; import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
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/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart'; import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/management/management_list.dart'; import 'package:em2rp/views/widgets/management/management_list.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
class EquipmentManagementPage extends StatefulWidget { class EquipmentManagementPage extends StatefulWidget {
const EquipmentManagementPage({super.key}); const EquipmentManagementPage({super.key});
@@ -26,12 +33,66 @@ class EquipmentManagementPage extends StatefulWidget {
class _EquipmentManagementPageState extends State<EquipmentManagementPage> class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> { with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
EquipmentCategory? _selectedCategory; EquipmentCategory? _selectedCategory;
List<EquipmentModel>? _cachedEquipment; List<EquipmentModel>? _cachedEquipment;
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
@override
void initState() {
super.initState();
DebugLog.info('[EquipmentManagementPage] initState called');
// Activer le mode pagination
final provider = context.read<EquipmentProvider>();
provider.enablePagination();
// Ajouter le listener de scroll pour le chargement infini
_scrollController.addListener(_onScroll);
// Charger la première page au démarrage
WidgetsBinding.instance.addPostFrameCallback((_) {
DebugLog.info('[EquipmentManagementPage] Loading first page...');
provider.loadFirstPage();
});
}
void _onScroll() {
// Éviter les appels multiples
if (_isLoadingMore) return;
final provider = context.read<EquipmentProvider>();
// Charger la page suivante quand on arrive à 300px du bas
if (_scrollController.hasClients &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
setState(() => _isLoadingMore = true);
provider.loadNextPage().then((_) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
}).catchError((error) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
});
}
}
}
@override @override
void dispose() { void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchController.dispose(); _searchController.dispose();
// Désactiver le mode pagination en quittant
context.read<EquipmentProvider>().disablePagination();
super.dispose(); super.dispose();
} }
@@ -68,6 +129,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
actions: [ actions: [
const NotificationBadge(),
if (hasSelection) ...[ if (hasSelection) ...[
IconButton( IconButton(
icon: const Icon(Icons.qr_code, color: Colors.white), icon: const Icon(Icons.qr_code, color: Colors.white),
@@ -84,13 +146,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
) )
: CustomAppBar( : CustomAppBar(
title: 'Gestion du matériel', title: 'Gestion du matériel',
actions: [
IconButton(
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
onPressed: toggleSelectionMode,
),
],
), ),
drawer: const MainDrawer(currentPage: '/equipment_management'), drawer: const MainDrawer(currentPage: '/equipment_management'),
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
@@ -114,37 +169,27 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
Widget _buildMobileLayout() { Widget _buildMobileLayout() {
return Column( return Column(
children: [ children: [
// Barre de recherche et bouton boîtes // Barre de recherche et boutons d'action
Padding( SearchActionsBar(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par nom, modèle ou ID...', hintText: 'Rechercher par nom, modèle ou ID...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (value) { onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value); context.read<EquipmentProvider>().setSearchQuery(value);
}, },
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
actions: [
IconButton.filled(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
), ),
), ),
const SizedBox(width: 8),
// Bouton Gérer les boîtes
IconButton.filled( IconButton.filled(
onPressed: () { onPressed: () {
Navigator.pushNamed(context, '/container_management'); Navigator.pushNamed(context, '/container_management');
@@ -158,7 +203,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
), ),
], ],
), ),
),
// Menu horizontal de filtres par catégorie // Menu horizontal de filtres par catégorie
SizedBox( SizedBox(
height: 60, height: 60,
@@ -222,29 +266,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
), ),
child: Column( child: Column(
children: [ children: [
// Bouton Gérer les boîtes const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2, color: Colors.white),
label: const Text(
'Gérer les boîtes',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
minimumSize: const Size(double.infinity, 50),
),
),
),
const Divider(),
// En-tête filtres // En-tête filtres
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -265,37 +287,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
], ],
), ),
), ),
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher...',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchController.clear();
context
.read<EquipmentProvider>()
.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
),
),
// Filtres par catégorie // Filtres par catégorie
Padding( Padding(
padding: padding:
@@ -349,8 +340,57 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
), ),
), ),
// Contenu principal // Contenu principal
Expanded(
child: Column(
children: [
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
actions: [
IconButton.filled(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
),
IconButton.filled(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2),
tooltip: 'Gérer les boîtes',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
if (!isSelectionMode)
IconButton.filled(
onPressed: toggleSelectionMode,
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
],
),
Expanded(child: _buildEquipmentList()), Expanded(child: _buildEquipmentList()),
], ],
),
),
],
); );
} }
@@ -420,16 +460,62 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
Widget _buildEquipmentList() { Widget _buildEquipmentList() {
return Consumer<EquipmentProvider>( return Consumer<EquipmentProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
return ManagementList<EquipmentModel>( DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
stream: provider.equipmentStream,
cachedItems: _cachedEquipment, // Afficher l'indicateur de chargement initial uniquement
emptyMessage: 'Aucun équipement trouvé', if (provider.isLoading && provider.equipment.isEmpty) {
emptyIcon: Icons.inventory_2_outlined, DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
onDataReceived: (items) { return const Center(child: CircularProgressIndicator());
_cachedEquipment = items; }
},
itemBuilder: (equipment) { final equipments = provider.equipment;
return _buildEquipmentCard(equipment);
if (equipments.isEmpty && !provider.isLoading) {
DebugLog.info('[EquipmentManagementPage] No equipment found');
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucun équipement trouvé',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
],
),
);
}
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);
return ListView.builder(
controller: _scrollController,
itemCount: itemCount,
itemBuilder: (context, index) {
// Dernier élément = indicateur de chargement
if (index == equipments.length) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: provider.isLoadingMore
? const CircularProgressIndicator()
: const SizedBox.shrink(),
),
);
}
return _buildEquipmentCard(equipments[index]);
}, },
); );
}, },
@@ -484,6 +570,18 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
: 'Marque/Modèle non défini', : 'Marque/Modèle non défini',
style: TextStyle(color: Colors.grey[600], fontSize: 14), style: TextStyle(color: Colors.grey[600], fontSize: 14),
), ),
// Afficher la sous-catégorie si elle existe
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'📁 ${equipment.subCategory}',
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
// Afficher la quantité disponible pour les consommables/câbles // Afficher la quantité disponible pour les consommables/câbles
if (equipment.category == EquipmentCategory.consumable || if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) ...[ equipment.category == EquipmentCategory.cable) ...[
@@ -716,6 +814,16 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
void _generateQRCodesForSelected() async { void _generateQRCodesForSelected() async {
if (!hasSelection) return; if (!hasSelection) return;
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(color: AppColors.rouge),
),
);
try {
// Récupérer les équipements sélectionnés // Récupérer les équipements sélectionnés
final provider = context.read<EquipmentProvider>(); final provider = context.read<EquipmentProvider>();
final List<EquipmentModel> selectedEquipment = []; final List<EquipmentModel> selectedEquipment = [];
@@ -730,16 +838,24 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
break; break;
} }
// Fermer l'indicateur de chargement
if (mounted) {
Navigator.of(context).pop();
}
if (selectedEquipment.isEmpty) return; if (selectedEquipment.isEmpty) return;
if (selectedEquipment.length == 1) { if (selectedEquipment.length == 1) {
// Un seul équipement : afficher le dialogue simple // Un seul équipement : afficher le dialogue simple
if (mounted) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first), builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
); );
}
} else { } else {
// Plusieurs équipements : afficher le sélecteur de format // Plusieurs équipements : afficher le sélecteur de format
if (mounted) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => QRCodeFormatSelectorDialog<EquipmentModel>( builder: (context) => QRCodeFormatSelectorDialog<EquipmentModel>(
@@ -751,6 +867,24 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
); );
} }
} }
} catch (e) {
// Fermer l'indicateur si une erreur survient
if (mounted) {
Navigator.of(context).pop();
}
DebugLog.error('[EquipmentManagementPage] Error generating QR codes', e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la génération : ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
void _showRestockDialog(EquipmentModel equipment) { void _showRestockDialog(EquipmentModel equipment) {
final TextEditingController quantityController = TextEditingController(); final TextEditingController quantityController = TextEditingController();
@@ -903,11 +1037,14 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
'updatedAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(),
}; };
await context.read<EquipmentProvider>().updateEquipment( final updatedEquipment = equipment.copyWith(
equipment.id, availableQuantity: newAvailable,
updatedData, totalQuantity: newTotal,
updatedAt: DateTime.now(),
); );
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -949,4 +1086,119 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
), ),
); );
} }
/// Scanner un QR Code et ouvrir la vue de détail correspondante
Future<void> _scanQRCode() async {
try {
// Ouvrir le scanner
final scannedCode = await showDialog<String>(
context: context,
builder: (context) => const QRCodeScannerDialog(),
);
if (scannedCode == null || scannedCode.isEmpty) {
return; // L'utilisateur a annulé
}
if (!mounted) return;
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(color: AppColors.rouge),
),
);
// Rechercher d'abord dans les équipements
final equipmentProvider = context.read<EquipmentProvider>();
await equipmentProvider.ensureLoaded();
final equipment = equipmentProvider.allEquipment.firstWhere(
(eq) => eq.id == scannedCode,
orElse: () => EquipmentModel(
id: '',
name: '',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (mounted) {
Navigator.of(context).pop(); // Fermer l'indicateur
}
if (equipment.id.isNotEmpty) {
// Équipement trouvé
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentDetailPage(equipment: equipment),
),
);
}
return;
}
// Si pas trouvé dans les équipements, chercher dans les conteneurs
final containerProvider = context.read<ContainerProvider>();
if (containerProvider.containers.isEmpty) {
await containerProvider.loadContainers();
}
final container = containerProvider.containers.firstWhere(
(c) => c.id == scannedCode,
orElse: () => ContainerModel(
id: '',
name: '',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (container.id.isNotEmpty) {
// Conteneur trouvé
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ContainerDetailPage(container: container),
),
);
}
return;
}
// Rien trouvé
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
backgroundColor: Colors.orange,
),
);
}
} catch (e) {
DebugLog.error('[EquipmentManagementPage] Error scanning QR code', e);
if (mounted) {
// Fermer l'indicateur si ouvert
Navigator.of(context).popUntil((route) => route.isFirst);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors du scan : ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
} }

View File

@@ -1,14 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_functions/cloud_functions.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/services/event_preparation_service.dart'; import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/services/event_preparation_service_extended.dart'; import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep; 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/utils/debug_log.dart';
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart'; import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
@@ -34,9 +38,8 @@ class EventPreparationPage extends StatefulWidget {
} }
class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin { class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin {
final EventPreparationService _preparationService = EventPreparationService();
final EventPreparationServiceExtended _extendedService = EventPreparationServiceExtended();
late AnimationController _animationController; late AnimationController _animationController;
late final DataService _dataService;
Map<String, EquipmentModel> _equipmentCache = {}; Map<String, EquipmentModel> _equipmentCache = {};
Map<String, ContainerModel> _containerCache = {}; Map<String, ContainerModel> _containerCache = {};
@@ -45,6 +48,13 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
// État local des validations (non sauvegardé jusqu'à la validation finale) // État local des validations (non sauvegardé jusqu'à la validation finale)
Map<String, bool> _localValidationState = {}; Map<String, bool> _localValidationState = {};
// NOUVEAU : Gestion des quantités par étape
Map<String, int> _quantitiesAtPreparation = {};
Map<String, int> _quantitiesAtLoading = {};
Map<String, int> _quantitiesAtUnloading = {};
Map<String, int> _quantitiesAtReturn = {};
bool _isLoading = true; bool _isLoading = true;
bool _isValidating = false; bool _isValidating = false;
bool _showSuccessAnimation = false; bool _showSuccessAnimation = false;
@@ -89,12 +99,13 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
void initState() { void initState() {
super.initState(); super.initState();
_currentEvent = widget.initialEvent; _currentEvent = widget.initialEvent;
_dataService = DataService(FirebaseFunctionsApiService());
_animationController = AnimationController( _animationController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
); );
// Vérification de sécurité : bloquer l'accès si toutes les étapes sont complétées // Vérification de sécurité et chargement après le premier frame
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isCurrentStepCompleted()) { if (_isCurrentStepCompleted()) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -106,9 +117,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
Navigator.of(context).pop(); Navigator.of(context).pop();
return; return;
} }
});
// Charger les équipements après le premier frame pour éviter setState pendant build
_loadEquipmentAndContainers(); _loadEquipmentAndContainers();
});
} }
/// Vérifie si l'étape actuelle est déjà complétée /// Vérifie si l'étape actuelle est déjà complétée
@@ -131,24 +143,6 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
super.dispose(); super.dispose();
} }
/// Recharger l'événement depuis Firestore
Future<void> _reloadEvent() async {
try {
final doc = await FirebaseFirestore.instance
.collection('events')
.doc(_currentEvent.id)
.get();
if (doc.exists) {
setState(() {
_currentEvent = EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
});
}
} catch (e) {
print('[EventPreparationPage] Error reloading event: $e');
}
}
Future<void> _loadEquipmentAndContainers() async { Future<void> _loadEquipmentAndContainers() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
@@ -156,6 +150,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
final equipmentProvider = context.read<EquipmentProvider>(); final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>(); final containerProvider = context.read<ContainerProvider>();
// S'assurer que les équipements sont chargés
await equipmentProvider.ensureLoaded();
await containerProvider.ensureLoaded();
final equipment = await equipmentProvider.equipmentStream.first; final equipment = await equipmentProvider.equipmentStream.first;
final containers = await containerProvider.containersStream.first; final containers = await containerProvider.containersStream.first;
@@ -167,7 +165,6 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
name: 'Équipement inconnu', name: 'Équipement inconnu',
category: EquipmentCategory.other, category: EquipmentCategory.other,
status: EquipmentStatus.available, status: EquipmentStatus.available,
parentBoxIds: [],
maintenanceIds: [], maintenanceIds: [],
createdAt: DateTime.now(), createdAt: DateTime.now(),
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
@@ -194,7 +191,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
if ((_currentStep == PreparationStep.return_ || if ((_currentStep == PreparationStep.return_ ||
_currentStep == PreparationStep.unloadingReturn) && _currentStep == PreparationStep.unloadingReturn) &&
equipmentItem.hasQuantity) { equipmentItem.hasQuantity) {
_returnedQuantities[eq.equipmentId] = eq.returnedQuantity ?? eq.quantity; _returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity;
} }
} }
@@ -214,52 +211,113 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
_containerCache[containerId] = container; _containerCache[containerId] = container;
} }
} catch (e) { } catch (e) {
print('[EventPreparationPage] Error: $e'); DebugLog.error('[EventPreparationPage] Error', e);
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
} }
/// Basculer l'état de validation d'un équipement (état local uniquement) /// Basculer l'état de validation d'un équipement (état local uniquement)
void _toggleEquipmentValidation(String equipmentId) { Future<void> _toggleEquipmentValidation(String equipmentId) async {
final currentState = _localValidationState[equipmentId] ?? false;
// Si on veut valider (passer de false à true) et que c'était manquant avant
if (!currentState && _wasMissingAtPreviousStep(equipmentId)) {
final confirmed = await _confirmValidationIfWasMissingBefore(equipmentId);
if (!confirmed) {
return; // Annulation, ne rien faire
}
}
setState(() { setState(() {
_localValidationState[equipmentId] = !(_localValidationState[equipmentId] ?? false); _localValidationState[equipmentId] = !currentState;
}); });
} }
Future<void> _validateAll() async { /// Marquer TOUT comme validé et enregistrer (bouton "Tout confirmer")
Future<void> _validateAllAndConfirm() async {
// Marquer tout comme validé localement
setState(() {
for (var eq in _currentEvent.assignedEquipment) {
_localValidationState[eq.equipmentId] = true;
}
});
// Puis enregistrer
await _confirmCurrentState();
}
/// Enregistrer l'état actuel TEL QUEL (cochés = validés, non cochés = manquants)
Future<void> _confirmCurrentState() async {
setState(() => _isValidating = true); setState(() => _isValidating = true);
try { try {
// Si "tout valider" est cliqué, marquer tout comme validé localement // Déterminer les manquants = équipements NON validés
for (var equipmentId in _localValidationState.keys) { final Map<String, bool> missingAtThisStep = {};
_localValidationState[equipmentId] = true; for (var eq in _currentEvent.assignedEquipment) {
final isValidated = _localValidationState[eq.equipmentId] ?? false;
missingAtThisStep[eq.equipmentId] = !isValidated; // Manquant si pas validé
} }
// Préparer la liste des équipements avec leur nouvel état // Préparer la liste des équipements avec leur nouvel état
final updatedEquipment = _currentEvent.assignedEquipment.map((eq) { final updatedEquipment = _currentEvent.assignedEquipment.map((eq) {
final isValidated = _localValidationState[eq.equipmentId] ?? false; final isValidated = _localValidationState[eq.equipmentId] ?? false;
final isMissing = missingAtThisStep[eq.equipmentId] ?? false;
// Récupérer les quantités selon l'étape
final qtyAtPrep = _quantitiesAtPreparation[eq.equipmentId];
final qtyAtLoad = _quantitiesAtLoading[eq.equipmentId];
final qtyAtUnload = _quantitiesAtUnloading[eq.equipmentId];
final qtyAtRet = _quantitiesAtReturn[eq.equipmentId];
switch (_currentStep) { switch (_currentStep) {
case PreparationStep.preparation: case PreparationStep.preparation:
if (_loadSimultaneously) { if (_loadSimultaneously) {
return eq.copyWith(isPrepared: isValidated, isLoaded: isValidated); return eq.copyWith(
isPrepared: isValidated,
isLoaded: isValidated,
isMissingAtPreparation: isMissing,
isMissingAtLoading: isMissing, // Propager
quantityAtPreparation: qtyAtPrep,
quantityAtLoading: qtyAtPrep, // Même quantité
);
} }
return eq.copyWith(isPrepared: isValidated); return eq.copyWith(
isPrepared: isValidated,
isMissingAtPreparation: isMissing,
quantityAtPreparation: qtyAtPrep,
);
case PreparationStep.loadingOutbound: case PreparationStep.loadingOutbound:
return eq.copyWith(isLoaded: isValidated); return eq.copyWith(
isLoaded: isValidated,
isMissingAtLoading: isMissing,
quantityAtLoading: qtyAtLoad,
);
case PreparationStep.unloadingReturn: case PreparationStep.unloadingReturn:
if (_loadSimultaneously) { if (_loadSimultaneously) {
final returnedQty = _returnedQuantities[eq.equipmentId] ?? eq.quantity; return eq.copyWith(
return eq.copyWith(isUnloaded: isValidated, isReturned: isValidated, returnedQuantity: returnedQty); isUnloaded: isValidated,
isReturned: isValidated,
isMissingAtUnloading: isMissing,
isMissingAtReturn: isMissing, // Propager
quantityAtUnloading: qtyAtUnload,
quantityAtReturn: qtyAtRet ?? qtyAtUnload,
);
} }
return eq.copyWith(isUnloaded: isValidated); return eq.copyWith(
isUnloaded: isValidated,
isMissingAtUnloading: isMissing,
quantityAtUnloading: qtyAtUnload,
);
case PreparationStep.return_: case PreparationStep.return_:
final returnedQty = _returnedQuantities[eq.equipmentId] ?? eq.quantity; return eq.copyWith(
return eq.copyWith(isReturned: isValidated, returnedQuantity: returnedQty); isReturned: isValidated,
isMissingAtReturn: isMissing,
quantityAtReturn: qtyAtRet,
);
} }
}).toList(); }).toList();
@@ -269,35 +327,46 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
}; };
// Ajouter les statuts selon l'étape et la checkbox // Ajouter les statuts selon l'étape et la checkbox
String validationType = 'CHECK';
switch (_currentStep) { switch (_currentStep) {
case PreparationStep.preparation: case PreparationStep.preparation:
updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed); updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed);
validationType = 'CHECK_OUT';
if (_loadSimultaneously) { if (_loadSimultaneously) {
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed); updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
validationType = 'LOADING';
} }
break; break;
case PreparationStep.loadingOutbound: case PreparationStep.loadingOutbound:
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed); updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
validationType = 'LOADING';
break; break;
case PreparationStep.unloadingReturn: case PreparationStep.unloadingReturn:
updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed); updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed);
validationType = 'UNLOADING';
if (_loadSimultaneously) { if (_loadSimultaneously) {
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed); updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
validationType = 'CHECK_IN';
} }
break; break;
case PreparationStep.return_: case PreparationStep.return_:
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed); updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
validationType = 'CHECK_IN';
break; break;
} }
// Sauvegarder dans Firestore // Sauvegarder dans Firestore via l'API
await FirebaseFirestore.instance await _dataService.updateEventEquipment(
.collection('events') eventId: _currentEvent.id,
.doc(_currentEvent.id) assignedEquipment: updatedEquipment.map((e) => e.toMap()).toList(),
.update(updateData); preparationStatus: updateData['preparationStatus'],
loadingStatus: updateData['loadingStatus'],
unloadingStatus: updateData['unloadingStatus'],
returnStatus: updateData['returnStatus'],
);
// Mettre à jour les statuts des équipements si nécessaire // Mettre à jour les statuts des équipements si nécessaire
if (_currentStep == PreparationStep.preparation || if (_currentStep == PreparationStep.preparation ||
@@ -305,6 +374,49 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
await _updateEquipmentStatuses(updatedEquipment); await _updateEquipmentStatuses(updatedEquipment);
} }
// NOUVEAU: Appeler la Cloud Function pour traiter la validation
// et créer les alertes automatiquement
try {
DebugLog.info('[EventPreparationPage] Appel processEquipmentValidation');
final equipmentList = updatedEquipment.map((eq) {
final equipment = _equipmentCache[eq.equipmentId];
return {
'equipmentId': eq.equipmentId,
'name': equipment?.name ?? 'Équipement inconnu',
'status': _determineEquipmentStatus(eq),
'quantity': _getQuantityForStep(eq),
'expectedQuantity': eq.quantity,
'isMissingAtPreparation': eq.isMissingAtPreparation,
'isMissingAtReturn': eq.isMissingAtReturn,
};
}).toList();
final result = await FirebaseFunctions.instanceFor(region: 'us-central1')
.httpsCallable('processEquipmentValidation')
.call({
'eventId': _currentEvent.id,
'equipmentList': equipmentList,
'validationType': validationType,
});
final alertsCreated = result.data['alertsCreated'] ?? 0;
if (alertsCreated > 0) {
DebugLog.info('[EventPreparationPage] $alertsCreated alertes créées automatiquement');
}
} catch (e) {
DebugLog.error('[EventPreparationPage] Erreur appel processEquipmentValidation', e);
// Ne pas bloquer la validation si les alertes échouent
}
// Recharger l'événement depuis le provider
final eventProvider = context.read<EventProvider>();
// Recharger la liste des événements pour rafraîchir les données
final userId = context.read<LocalUserProvider>().uid;
if (userId != null) {
await eventProvider.loadUserEvents(userId, canViewAllEvents: true);
}
setState(() => _showSuccessAnimation = true); setState(() => _showSuccessAnimation = true);
_animationController.forward(); _animationController.forward();
@@ -338,16 +450,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async { Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async {
for (var eq in equipment) { for (var eq in equipment) {
try { try {
final doc = await FirebaseFirestore.instance final equipmentData = _equipmentCache[eq.equipmentId];
.collection('equipments') if (equipmentData == null) continue;
.doc(eq.equipmentId)
.get();
if (doc.exists) {
final equipmentData = EquipmentModel.fromMap(
doc.data() as Map<String, dynamic>,
doc.id,
);
// Déterminer le nouveau statut // Déterminer le nouveau statut
EquipmentStatus newStatus; EquipmentStatus newStatus;
@@ -361,29 +465,22 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
// Ne mettre à jour que les équipements non quantifiables // Ne mettre à jour que les équipements non quantifiables
if (!equipmentData.hasQuantity) { if (!equipmentData.hasQuantity) {
await FirebaseFirestore.instance await _dataService.updateEquipmentStatusOnly(
.collection('equipments') equipmentId: eq.equipmentId,
.doc(eq.equipmentId) status: equipmentStatusToString(newStatus),
.update({ );
'status': equipmentStatusToString(newStatus),
'updatedAt': Timestamp.fromDate(DateTime.now()),
});
} }
// Gérer les stocks pour les consommables // Gérer les stocks pour les consommables
if (equipmentData.hasQuantity && eq.isReturned && eq.returnedQuantity != null) { if (equipmentData.hasQuantity && eq.isReturned && eq.quantityAtReturn != null) {
final currentAvailable = equipmentData.availableQuantity ?? 0; final currentAvailable = equipmentData.availableQuantity ?? 0;
await FirebaseFirestore.instance await _dataService.updateEquipmentStatusOnly(
.collection('equipments') equipmentId: eq.equipmentId,
.doc(eq.equipmentId) availableQuantity: currentAvailable + eq.quantityAtReturn!,
.update({ );
'availableQuantity': currentAvailable + eq.returnedQuantity!,
'updatedAt': Timestamp.fromDate(DateTime.now()),
});
}
} }
} catch (e) { } catch (e) {
print('Error updating equipment status for ${eq.equipmentId}: $e'); // Erreur silencieuse pour ne pas bloquer le processus
} }
} }
} }
@@ -476,7 +573,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
if (missingEquipmentIds.isEmpty) { if (missingEquipmentIds.isEmpty) {
// Tout est validé, confirmer directement // Tout est validé, confirmer directement
await _validateAll(); await _confirmCurrentState();
} else { } else {
// Afficher le dialog des manquants // Afficher le dialog des manquants
final missingEquipmentModels = missingEquipmentIds final missingEquipmentModels = missingEquipmentIds
@@ -499,8 +596,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
); );
if (action == 'confirm_anyway') { if (action == 'confirm_anyway') {
// Confirmer malgré les manquants // Confirmer malgré les manquants (enregistrer l'état actuel TEL QUEL)
await _validateAll(); await _confirmCurrentState();
} else if (action == 'mark_as_validated') { } else if (action == 'mark_as_validated') {
// Marquer les manquants comme validés localement // Marquer les manquants comme validés localement
for (var equipmentId in missingEquipmentIds) { for (var equipmentId in missingEquipmentIds) {
@@ -508,12 +605,167 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
} }
setState(() {}); setState(() {});
// Puis confirmer // Puis confirmer
await _validateAll(); await _confirmCurrentState();
} }
// Si 'return_to_list', ne rien faire // Si 'return_to_list', ne rien faire
} }
} }
/// Valider tous les enfants d'un container
void _validateAllContainerChildren(String containerId) {
final container = _containerCache[containerId];
if (container == null) return;
setState(() {
for (final equipmentId in container.equipmentIds) {
if (_equipmentCache.containsKey(equipmentId)) {
_localValidationState[equipmentId] = true;
}
}
});
}
/// 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;
}
});
}
/// Vérifier si un équipement était manquant à l'étape précédente
bool _wasMissingAtPreviousStep(String equipmentId) {
final eq = _currentEvent.assignedEquipment.firstWhere(
(e) => e.equipmentId == equipmentId,
orElse: () => EventEquipment(equipmentId: equipmentId),
);
switch (_currentStep) {
case PreparationStep.preparation:
return false; // Pas d'étape avant
case PreparationStep.loadingOutbound:
return eq.isMissingAtPreparation;
case PreparationStep.unloadingReturn:
return eq.isMissingAtLoading;
case PreparationStep.return_:
return eq.isMissingAtUnloading;
}
}
/// Afficher pop-up de confirmation si l'équipement était manquant avant
Future<bool> _confirmValidationIfWasMissingBefore(String equipmentId) async {
if (!_wasMissingAtPreviousStep(equipmentId)) {
return true; // Pas de problème, continuer
}
final equipment = _equipmentCache[equipmentId];
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange),
SizedBox(width: 8),
Text('Confirmation'),
],
),
content: Text(
'L\'équipement "${equipment?.name ?? equipmentId}" était manquant à l\'étape précédente.\n\n'
'Êtes-vous sûr de le marquer comme présent maintenant ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
child: const Text('Confirmer'),
),
],
),
);
return result ?? false;
}
/// Détermine le statut d'un équipement selon l'étape actuelle
String _determineEquipmentStatus(EventEquipment eq) {
// Vérifier d'abord si l'équipement est perdu (LOST)
if (_shouldMarkAsLost(eq)) {
return 'LOST';
}
// Vérifier si manquant à l'étape actuelle
if (_isMissingAtCurrentStep(eq)) {
return 'MISSING';
}
// Vérifier les quantités
final currentQty = _getQuantityForStep(eq);
if (currentQty != null && currentQty < eq.quantity) {
return 'QUANTITY_MISMATCH';
}
return 'AVAILABLE';
}
/// 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 false;
}
// Si manquant maintenant mais PAS manquant à la préparation = LOST
return eq.isMissingAtReturn && !eq.isMissingAtPreparation;
}
/// Vérifie si un équipement est manquant à l'étape actuelle
bool _isMissingAtCurrentStep(EventEquipment eq) {
switch (_currentStep) {
case PreparationStep.preparation:
return eq.isMissingAtPreparation;
case PreparationStep.loadingOutbound:
return eq.isMissingAtLoading;
case PreparationStep.unloadingReturn:
return eq.isMissingAtUnloading;
case PreparationStep.return_:
return eq.isMissingAtReturn;
}
}
/// Récupère la quantité pour l'étape actuelle
int? _getQuantityForStep(EventEquipment eq) {
switch (_currentStep) {
case PreparationStep.preparation:
return eq.quantityAtPreparation;
case PreparationStep.loadingOutbound:
return eq.quantityAtLoading;
case PreparationStep.unloadingReturn:
return eq.quantityAtUnloading;
case PreparationStep.return_:
return eq.quantityAtReturn;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final allValidated = _isStepCompleted(); final allValidated = _isStepCompleted();
@@ -592,7 +844,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
const SizedBox(height: 8), const SizedBox(height: 8),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: allValidated ? null : _validateAll, onPressed: allValidated ? null : _validateAllAndConfirm,
icon: const Icon(Icons.check_circle_outline), icon: const Icon(Icons.check_circle_outline),
label: Text( label: Text(
allValidated allValidated
@@ -608,32 +860,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
), ),
), ),
Expanded( Expanded(
child: ListView.builder( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: _currentEvent.assignedEquipment.length, children: _buildChecklistItems(),
itemBuilder: (context, index) {
final eventEquipment = _currentEvent.assignedEquipment[index];
final equipment = _equipmentCache[eventEquipment.equipmentId];
if (equipment == null) {
return const SizedBox.shrink();
}
return EquipmentChecklistItem(
equipment: equipment,
eventEquipment: eventEquipment,
step: _getChecklistStep(),
isValidated: _localValidationState[equipment.id] ?? false,
onToggle: () => _toggleEquipmentValidation(equipment.id),
onReturnedQuantityChanged: _currentStep == PreparationStep.return_ && equipment.hasQuantity
? (qty) {
setState(() {
_returnedQuantities[equipment.id] = qty;
});
}
: null,
);
},
), ),
), ),
], ],
@@ -699,5 +928,92 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
), ),
); );
} }
/// Construit la liste des items de checklist en groupant par containers
List<Widget> _buildChecklistItems() {
final List<Widget> items = [];
// Set pour tracker les équipements déjà affichés dans un container
final Set<String> equipmentIdsInContainers = {};
// Map des EventEquipment par ID pour accès rapide
final Map<String, EventEquipment> eventEquipmentsMap = {
for (var eq in _currentEvent.assignedEquipment) eq.equipmentId: eq,
};
// 1. Afficher les containers avec leurs enfants
for (final containerId in _currentEvent.assignedContainers) {
final container = _containerCache[containerId];
if (container == null) continue;
// Récupérer les équipements enfants de ce container
final List<EquipmentModel> childEquipments = [];
for (final equipmentId in container.equipmentIds) {
final equipment = _equipmentCache[equipmentId];
if (equipment != null && eventEquipmentsMap.containsKey(equipmentId)) {
childEquipments.add(equipment);
equipmentIdsInContainers.add(equipmentId);
}
}
if (childEquipments.isEmpty) continue;
// Vérifier si tous les enfants sont validés
final allChildrenValidated = childEquipments.every(
(eq) => _localValidationState[eq.id] ?? false,
);
// Map des états de validation des enfants
final Map<String, bool> childValidationStates = {
for (var eq in childEquipments) eq.id: _localValidationState[eq.id] ?? false,
};
// Map des enfants manquants à l'étape précédente
final Map<String, bool> wasMissingBeforeMap = {
for (var eq in childEquipments) eq.id: _wasMissingAtPreviousStep(eq.id),
};
items.add(
ContainerChecklistItem(
container: container,
childEquipments: childEquipments,
eventEquipmentsMap: eventEquipmentsMap,
step: _getChecklistStep(),
isValidated: allChildrenValidated,
childValidationStates: childValidationStates,
onToggleContainer: () => _validateAllContainerChildren(containerId),
onToggleChild: (equipmentId) => _toggleEquipmentValidation(equipmentId),
onQuantityChanged: (equipmentId, qty) => _updateEquipmentQuantity(equipmentId, qty),
wasMissingBeforeMap: wasMissingBeforeMap,
),
);
}
// 2. Afficher les équipements standalone (pas dans un container)
for (final eventEquipment in _currentEvent.assignedEquipment) {
// Skip si déjà affiché dans un container
if (equipmentIdsInContainers.contains(eventEquipment.equipmentId)) {
continue;
}
final equipment = _equipmentCache[eventEquipment.equipmentId];
if (equipment == null) continue;
items.add(
EquipmentChecklistItem(
equipment: equipment,
eventEquipment: eventEquipment,
step: _getChecklistStep(),
isValidated: _localValidationState[equipment.id] ?? false,
onToggle: () => _toggleEquipmentValidation(equipment.id),
onQuantityChanged: (qty) => _updateEquipmentQuantity(equipment.id, qty),
isChild: false, // Équipement standalone (pas indenté)
wasMissingBefore: _wasMissingAtPreviousStep(equipment.id),
),
);
}
return items;
}
} }

View File

@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import 'package:em2rp/views/widgets/inputs/styled_text_field.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/image/profile_picture_selector.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/widgets/notification_preferences_widget.dart';
class MyAccountPage extends StatelessWidget { class MyAccountPage extends StatelessWidget {
const MyAccountPage({super.key}); const MyAccountPage({super.key});
@@ -86,6 +87,13 @@ class MyAccountPage extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(height: 24),
// Section Préférences de notifications
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: const NotificationPreferencesWidget(),
),
], ],
), ),
), ),

View File

@@ -9,7 +9,8 @@ import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/permission_gate.dart'; import 'package:em2rp/utils/permission_gate.dart';
import 'package:em2rp/models/role_model.dart'; import 'package:em2rp/models/role_model.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class UserManagementPage extends StatefulWidget { class UserManagementPage extends StatefulWidget {
const UserManagementPage({super.key}); const UserManagementPage({super.key});
@@ -90,7 +91,8 @@ class _UserManagementPageState extends State<UserManagementPage> {
onEdit: () => showDialog( onEdit: () => showDialog(
context: context, context: context,
builder: (_) => EditUserDialog(user: user)), builder: (_) => EditUserDialog(user: user)),
onDelete: () => usersProvider.deleteUser(user.uid), onResetPassword: () => _resetPassword(context, user),
onDelete: () => _confirmDeleteUser(context, usersProvider, user),
); );
}, },
), ),
@@ -116,14 +118,18 @@ class _UserManagementPageState extends State<UserManagementPage> {
bool isLoadingRoles = true; bool isLoadingRoles = true;
Future<void> loadRoles() async { Future<void> loadRoles() async {
final snapshot = try {
await FirebaseFirestore.instance.collection('roles').get(); final dataService = DataService(FirebaseFunctionsApiService());
availableRoles = snapshot.docs final rolesData = await dataService.getRoles();
.map((doc) => RoleModel.fromMap(doc.data(), doc.id)) availableRoles = rolesData
.map((data) => RoleModel.fromMap(data, data['id'] as String))
.toList(); .toList();
selectedRoleId = selectedRoleId =
availableRoles.isNotEmpty ? availableRoles.first.id : null; availableRoles.isNotEmpty ? availableRoles.first.id : null;
isLoadingRoles = false; isLoadingRoles = false;
} catch (e) {
isLoadingRoles = false;
}
} }
InputDecoration buildInputDecoration(String label, IconData icon) { InputDecoration buildInputDecoration(String label, IconData icon) {
@@ -254,20 +260,27 @@ class _UserManagementPageState extends State<UserManagementPage> {
return; return;
} }
try { try {
final newUser = UserModel(
uid: '', // Sera généré par Firebase
firstName: firstNameController.text,
lastName: lastNameController.text,
email: emailController.text,
phoneNumber: phoneController.text,
role: selectedRoleId!,
profilePhotoUrl: '',
);
await Provider.of<UsersProvider>(context, await Provider.of<UsersProvider>(context,
listen: false) listen: false)
.createUserWithEmailInvite(context, newUser, .createUserWithEmailInvite(
roleId: selectedRoleId); email: emailController.text,
firstName: firstNameController.text,
lastName: lastNameController.text,
phoneNumber: phoneController.text,
roleId: selectedRoleId!,
);
if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Utilisateur créé avec succès. Email de réinitialisation envoyé à ${emailController.text}',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -303,4 +316,174 @@ class _UserManagementPageState extends State<UserManagementPage> {
), ),
); );
} }
/// Réinitialise le mot de passe d'un utilisateur
Future<void> _resetPassword(BuildContext context, UserModel user) async {
try {
await Provider.of<UsersProvider>(context, listen: false)
.resetPassword(user.email);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Email de réinitialisation envoyé à ${user.email}',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Erreur lors de l\'envoi: ${e.toString()}',
),
backgroundColor: Colors.red,
),
);
}
}
}
/// Affiche une confirmation avant de supprimer un utilisateur
Future<void> _confirmDeleteUser(
BuildContext context,
UsersProvider usersProvider,
UserModel user,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
const Icon(Icons.warning, color: Colors.orange),
const SizedBox(width: 12),
Expanded(
child: Text(
'Confirmer la suppression',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Êtes-vous sûr de vouloir supprimer cet utilisateur ?',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.person, size: 20, color: AppColors.noir),
const SizedBox(width: 8),
Expanded(
child: Text(
'${user.firstName} ${user.lastName}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.email, size: 20, color: AppColors.gris),
const SizedBox(width: 8),
Expanded(
child: Text(
user.email,
style: TextStyle(color: Colors.grey[700]),
),
),
],
),
],
),
),
const SizedBox(height: 16),
Text(
'Cette action est irréversible. L\'utilisateur sera supprimé et désattribué de tous les événements liés',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.red[700],
fontStyle: FontStyle.italic,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
child: const Text(
'Annuler',
style: TextStyle(color: AppColors.gris),
),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Supprimer',
style: TextStyle(color: Colors.white),
),
),
],
),
);
if (confirmed == true && context.mounted) {
try {
await usersProvider.deleteUser(user.uid);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Utilisateur ${user.firstName} ${user.lastName} supprimé avec succès',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Erreur lors de la suppression: ${e.toString()}',
),
backgroundColor: Colors.red,
),
);
}
}
}
}
} }

View File

@@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/alert_model.dart';
// import 'package:timeago/timeago.dart' as timeago; // TODO: Ajouter dépendance dans pubspec.yaml
/// Widget pour afficher une alerte individuelle
class AlertItem extends StatelessWidget {
final AlertModel alert;
final VoidCallback? onTap;
final VoidCallback? onMarkAsRead;
final VoidCallback? onDelete;
const AlertItem({
super.key,
required this.alert,
this.onTap,
this.onMarkAsRead,
this.onDelete,
});
@override
Widget build(BuildContext context) {
return Dismissible(
key: Key(alert.id),
background: _buildSwipeBackground(
Colors.blue,
Icons.check,
Alignment.centerLeft,
),
secondaryBackground: _buildSwipeBackground(
Colors.red,
Icons.delete,
Alignment.centerRight,
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
// Swipe vers la droite = marquer comme lu
onMarkAsRead?.call();
return false; // Ne pas supprimer le widget
} else {
// Swipe vers la gauche = supprimer
return await _confirmDelete(context);
}
},
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: alert.isRead ? Colors.white : Colors.blue.shade50,
elevation: alert.isRead ? 1 : 2,
child: ListTile(
leading: _buildIcon(),
title: Text(
alert.message,
style: TextStyle(
fontWeight: alert.isRead ? FontWeight.normal : FontWeight.bold,
fontSize: 14,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
_formatDate(alert.createdAt),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
if (alert.isResolved) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.check_circle, size: 14, color: Colors.green),
const SizedBox(width: 4),
Text(
'Résolu',
style: TextStyle(
fontSize: 12,
color: Colors.green,
fontWeight: FontWeight.w600,
),
),
],
),
],
],
),
trailing: !alert.isRead
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getSeverityColor(alert.severity),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Nouveau',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
)
: null,
onTap: onTap,
),
),
);
}
Widget _buildSwipeBackground(Color color, IconData icon, Alignment alignment) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Icon(icon, color: Colors.white, size: 28),
);
}
Widget _buildIcon() {
IconData iconData;
Color iconColor;
switch (alert.type) {
case AlertType.eventCreated:
case AlertType.eventModified:
case AlertType.eventAssigned:
iconData = Icons.event;
iconColor = Colors.blue;
break;
case AlertType.workforceAdded:
iconData = Icons.group_add;
iconColor = Colors.green;
break;
case AlertType.eventCancelled:
iconData = Icons.event_busy;
iconColor = Colors.red;
break;
case AlertType.maintenanceDue:
case AlertType.maintenanceReminder:
iconData = Icons.build;
iconColor = Colors.orange;
break;
case AlertType.lost:
iconData = Icons.error;
iconColor = Colors.red;
break;
case AlertType.equipmentMissing:
iconData = Icons.warning;
iconColor = Colors.orange;
break;
case AlertType.lowStock:
iconData = Icons.inventory_2;
iconColor = Colors.orange;
break;
case AlertType.conflict:
iconData = Icons.error_outline;
iconColor = Colors.red;
break;
case AlertType.quantityMismatch:
iconData = Icons.compare_arrows;
iconColor = Colors.orange;
break;
case AlertType.damaged:
iconData = Icons.broken_image;
iconColor = Colors.red;
break;
}
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(iconData, color: iconColor, size: 24),
);
}
Color _getSeverityColor(AlertSeverity severity) {
switch (severity) {
case AlertSeverity.info:
return Colors.blue;
case AlertSeverity.warning:
return Colors.orange;
case AlertSeverity.critical:
return Colors.red;
}
}
String _formatDate(DateTime date) {
// TODO: Utiliser timeago une fois la dépendance ajoutée
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inSeconds < 60) {
return 'À l\'instant';
} else if (difference.inMinutes < 60) {
return 'Il y a ${difference.inMinutes} min';
} else if (difference.inHours < 24) {
return 'Il y a ${difference.inHours}h';
} else if (difference.inDays < 7) {
return 'Il y a ${difference.inDays}j';
} else {
return '${date.day}/${date.month}/${date.year}';
}
}
Future<bool> _confirmDelete(BuildContext context) async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer l\'alerte ?'),
content: const Text('Cette action est irréversible.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
onDelete?.call();
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
) ??
false;
}
}

View File

@@ -1,3 +1,4 @@
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -41,8 +42,6 @@ class _ForgotPasswordDialogState extends State<ForgotPasswordDialogWidget> {
_errorMessage = "Erreur : ${e.message}"; _errorMessage = "Erreur : ${e.message}";
_emailSent = false; _emailSent = false;
}); });
print(
"Erreur de réinitialisation du mot de passe: ${e.code} - ${e.message}");
} }
} }

View File

@@ -77,7 +77,7 @@ class EventDetails extends StatelessWidget {
EventDetailsDescription(event: event), EventDetailsDescription(event: event),
EventDetailsDocuments(documents: event.documents), EventDetailsDocuments(documents: event.documents),
const SizedBox(height: 16), const SizedBox(height: 16),
EventDetailsEquipe(workforce: event.workforce), EventDetailsEquipe(event: event),
], ],
), ),
), ),

View File

@@ -1,19 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart'; import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart';
class EventDetailsEquipe extends StatelessWidget { class EventDetailsEquipe extends StatelessWidget {
final List workforce; final EventModel event;
const EventDetailsEquipe({ const EventDetailsEquipe({
super.key, super.key,
required this.workforce, required this.event,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (workforce.isEmpty) { if (event.workforce.isEmpty) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -33,55 +35,23 @@ class EventDetailsEquipe extends StatelessWidget {
); );
} }
return FutureBuilder<List<UserModel>>( // Récupérer les utilisateurs depuis le cache du provider
future: _fetchUsers(), final eventProvider = Provider.of<EventProvider>(context, listen: false);
builder: (context, snapshot) { final workforceUsers = eventProvider.getWorkforceUsers(event);
if (snapshot.connectionState == ConnectionState.waiting) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Equipe',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
),
],
);
}
if (snapshot.hasError) { // Convertir en UserModel
return Column( final users = workforceUsers.map((userData) {
crossAxisAlignment: CrossAxisAlignment.start, return UserModel(
children: [ uid: userData['uid'] ?? '',
Text( firstName: userData['firstName'] ?? '',
'Equipe', lastName: userData['lastName'] ?? '',
style: Theme.of(context).textTheme.titleLarge?.copyWith( email: userData['email'] ?? '',
color: Colors.black, phoneNumber: userData['phoneNumber'] ?? '',
fontWeight: FontWeight.bold, profilePhotoUrl: userData['profilePhotoUrl'] ?? '',
), role: '', // Pas besoin du rôle pour l'affichage
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
snapshot.error.toString().contains('permission-denied')
? "Vous n'avez pas la permission de voir tous les membres de l'équipe."
: "Erreur lors du chargement de l'équipe : ${snapshot.error}",
style: const TextStyle(color: Colors.red),
),
),
],
); );
} }).toList();
final users = snapshot.data ?? [];
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -95,7 +65,7 @@ class EventDetailsEquipe extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
if (users.isEmpty) if (users.isEmpty)
Text( Text(
'Aucun membre assigné ou erreur de chargement.', 'Aucun membre assigné.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.orange[700], color: Colors.orange[700],
), ),
@@ -107,31 +77,6 @@ class EventDetailsEquipe extends StatelessWidget {
), ),
], ],
); );
},
);
}
Future<List<UserModel>> _fetchUsers() async {
final firestore = FirebaseFirestore.instance;
List<UserModel> users = [];
for (int i = 0; i < workforce.length; i++) {
final ref = workforce[i];
try {
if (ref is DocumentReference) {
final doc = await firestore.doc(ref.path).get();
if (doc.exists) {
final userData = doc.data() as Map<String, dynamic>;
users.add(UserModel.fromMap(userData, doc.id));
}
}
} catch (e) {
// Log silencieux des erreurs individuelles
debugPrint('Error fetching user $i: $e');
}
}
return users;
} }
} }

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/event_add_page.dart'; import 'package:em2rp/views/event_add_page.dart';
import 'package:em2rp/services/ics_export_service.dart'; import 'package:em2rp/services/ics_export_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'dart:html' as html; import 'dart:html' as html;
import 'dart:convert'; import 'dart:convert';
@@ -31,31 +32,43 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
_fetchEventTypeName(); _fetchEventTypeName();
} }
@override
void didUpdateWidget(EventDetailsHeader oldWidget) {
super.didUpdateWidget(oldWidget);
// Recharger le type d'événement si l'événement a changé
if (oldWidget.event.id != widget.event.id ||
oldWidget.event.eventTypeId != widget.event.eventTypeId) {
_fetchEventTypeName();
}
}
Future<void> _fetchEventTypeName() async { Future<void> _fetchEventTypeName() async {
setState(() => _isLoadingEventType = true);
try { try {
if (widget.event.eventTypeId.isEmpty) { if (widget.event.eventTypeId.isEmpty) {
setState(() => _isLoadingEventType = false); setState(() {
_eventTypeName = null;
_isLoadingEventType = false;
});
return; return;
} }
final doc = await FirebaseFirestore.instance // Charger tous les types d'événements via l'API
.collection('eventTypes') final dataService = DataService(FirebaseFunctionsApiService());
.doc(widget.event.eventTypeId) final eventTypes = await dataService.getEventTypes();
.get();
// Trouver le type correspondant
final eventType = eventTypes.firstWhere(
(type) => type['id'] == widget.event.eventTypeId,
orElse: () => <String, dynamic>{},
);
if (doc.exists) {
setState(() { setState(() {
_eventTypeName = doc.data()?['name'] as String? ?? widget.event.eventTypeId; _eventTypeName = eventType['name'] as String? ?? widget.event.eventTypeId;
_isLoadingEventType = false; _isLoadingEventType = false;
}); });
} else {
setState(() {
_eventTypeName = widget.event.eventTypeId;
_isLoadingEventType = false;
});
}
} catch (e) { } catch (e) {
print('Erreur lors du chargement du type d\'événement: $e');
setState(() { setState(() {
_eventTypeName = widget.event.eventTypeId; _eventTypeName = widget.event.eventTypeId;
_isLoadingEventType = false; _isLoadingEventType = false;
@@ -142,8 +155,27 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
), ),
); );
// Générer le contenu ICS // Charger les utilisateurs pour résoudre leurs noms
final icsContent = await IcsExportService.generateIcsContent(widget.event); final dataService = DataService(FirebaseFunctionsApiService());
final users = await dataService.getUsers();
// Créer une Map des IDs utilisateurs vers leurs noms complets
final Map<String, String> userNames = {};
for (final user in users) {
final userId = user['id'] as String?;
final firstName = user['firstName'] as String? ?? '';
final lastName = user['lastName'] as String? ?? '';
if (userId != null && (firstName.isNotEmpty || lastName.isNotEmpty)) {
userNames[userId] = '$firstName $lastName'.trim();
}
}
// Générer le contenu ICS avec le nom du type et les noms des utilisateurs
final icsContent = await IcsExportService.generateIcsContent(
widget.event,
eventTypeName: _eventTypeName ?? 'Non spécifié',
userNames: userNames, // Passer les noms des utilisateurs
);
final fileName = IcsExportService.generateFileName(widget.event); final fileName = IcsExportService.generateFileName(widget.event);
// Créer un blob et télécharger le fichier // Créer un blob et télécharger le fichier

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/price_helpers.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart'; import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart';
@@ -34,13 +35,48 @@ class EventDetailsInfo extends StatelessWidget {
'Horaire de fin', 'Horaire de fin',
dateFormat.format(event.endDateTime), dateFormat.format(event.endDateTime),
), ),
if (canViewPrices) if (canViewPrices) ...[
// Calcul des prix HT/TVA/TTC
Builder(
builder: (context) {
final pricing = PriceHelpers.getPricing(event);
return Column(
children: [
_buildInfoRow( _buildInfoRow(
context, context,
Icons.euro, Icons.euro,
'Prix de base', 'Prix HT',
currencyFormat.format(event.basePrice), pricing.formattedHT,
), ),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
children: [
Icon(Icons.percent, size: 16, color: Colors.grey[600]),
const SizedBox(width: 8),
Text(
'TVA (${pricing.taxRatePercentage.toStringAsFixed(0)}%) : ${pricing.formattedTax}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
),
_buildInfoRow(
context,
Icons.attach_money,
'Prix TTC',
pricing.formattedTTC,
highlighted: true,
),
],
);
},
),
],
if (event.options.isNotEmpty) ...[ if (event.options.isNotEmpty) ...[
EventOptionsDisplayWidget( EventOptionsDisplayWidget(
optionsData: event.options, optionsData: event.options,
@@ -52,37 +88,88 @@ class EventDetailsInfo extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Builder( Builder(
builder: (context) { builder: (context) {
final total = event.basePrice + // Total TTC = basePrice (TTC) + options (TTC)
final totalTTC = event.basePrice +
event.options.fold<num>( event.options.fold<num>(
0, 0,
(sum, opt) { (sum, opt) {
final price = opt['price'] ?? 0.0; final priceTTC = opt['price'] ?? 0.0;
final quantity = opt['quantity'] ?? 1; final quantity = opt['quantity'] ?? 1;
return sum + (price * quantity); return sum + (priceTTC * quantity);
}, },
); );
// Calculer le total HT
final totalHT = PriceHelpers.calculateHT(totalTTC.toDouble());
final totalTVA = PriceHelpers.calculateTax(totalHT);
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon(Icons.attach_money, color: AppColors.rouge), // Séparateur visuel
const Divider(thickness: 1),
const SizedBox(height: 8),
// Prix total HT
Row(
children: [
const Icon(Icons.euro, color: AppColors.rouge, size: 22),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Prix total : ', 'Prix total HT : ',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.noir, color: AppColors.noir,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
Text( Text(
currencyFormat.format(total), currencyFormat.format(totalHT),
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.rouge, color: AppColors.noir,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
], ],
), ),
// TVA en petit
Padding(
padding: const EdgeInsets.only(left: 30.0, top: 4.0, bottom: 4.0),
child: Text(
'TVA (20%) : ${currencyFormat.format(totalTVA)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
),
// Prix total TTC en surbrillance
Row(
children: [
const Icon(Icons.attach_money, color: AppColors.rouge, size: 24),
const SizedBox(width: 8),
Text(
'Prix total TTC : ',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.w900,
),
),
Text(
currencyFormat.format(totalTTC),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.rouge,
fontWeight: FontWeight.w900,
),
),
],
),
],
),
); );
}, },
), ),
@@ -139,24 +226,28 @@ class EventDetailsInfo extends StatelessWidget {
BuildContext context, BuildContext context,
IconData icon, IconData icon,
String label, String label,
String value, String value, {
) { bool highlighted = false,
}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Row( child: Row(
children: [ children: [
Icon(icon, color: AppColors.rouge), Icon(icon, color: highlighted ? AppColors.rouge : AppColors.rouge),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'$label : ', '$label : ',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.noir, color: AppColors.noir,
fontWeight: FontWeight.bold, fontWeight: highlighted ? FontWeight.w900 : FontWeight.bold,
), ),
), ),
Text( Text(
value, value,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: highlighted ? AppColors.rouge : null,
fontWeight: highlighted ? FontWeight.bold : null,
),
), ),
], ],
), ),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/views/event_preparation_page.dart'; import 'package:em2rp/views/event_preparation_page.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
@@ -20,30 +21,19 @@ class EventPreparationButtons extends StatefulWidget {
class _EventPreparationButtonsState extends State<EventPreparationButtons> { class _EventPreparationButtonsState extends State<EventPreparationButtons> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Écouter les changements de l'événement en temps réel // Utiliser le provider pour récupérer l'événement à jour
return StreamBuilder<DocumentSnapshot>( final eventProvider = context.watch<EventProvider>();
stream: FirebaseFirestore.instance
.collection('events') // Chercher l'événement mis à jour dans le provider
.doc(widget.event.id) final EventModel currentEvent = eventProvider.events.firstWhere(
.snapshots(), (e) => e.id == widget.event.id,
initialData: null, orElse: () => widget.event,
builder: (context, snapshot) {
// Utiliser l'événement du stream si disponible, sinon l'événement initial
final EventModel currentEvent;
if (snapshot.hasData && snapshot.data != null && snapshot.data!.exists) {
currentEvent = EventModel.fromMap(
snapshot.data!.data() as Map<String, dynamic>,
snapshot.data!.id,
); );
} else {
currentEvent = widget.event;
}
return _buildButtons(context, currentEvent); return _buildButtons(context, currentEvent);
},
);
} }
Widget _buildButtons(BuildContext context, EventModel event) { Widget _buildButtons(BuildContext context, EventModel event) {
// Vérifier s'il y a du matériel assigné // Vérifier s'il y a du matériel assigné
final hasMaterial = event.assignedEquipment.isNotEmpty || event.assignedContainers.isNotEmpty; final hasMaterial = event.assignedEquipment.isNotEmpty || event.assignedContainers.isNotEmpty;

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class EventStatusButton extends StatefulWidget { class EventStatusButton extends StatefulWidget {
final EventModel event; final EventModel event;
@@ -22,22 +23,28 @@ class EventStatusButton extends StatefulWidget {
class _EventStatusButtonState extends State<EventStatusButton> { class _EventStatusButtonState extends State<EventStatusButton> {
bool _loading = false; bool _loading = false;
final DataService _dataService = DataService(FirebaseFunctionsApiService());
Future<void> _changeStatus(EventStatus newStatus) async { Future<void> _changeStatus(EventStatus newStatus) async {
if (widget.event.status == newStatus) return; if (widget.event.status == newStatus) return;
setState(() => _loading = true); setState(() => _loading = true);
try { try {
await FirebaseFirestore.instance // Mettre à jour via l'API
.collection('events') await _dataService.updateEvent(widget.event.id, {
.doc(widget.event.id) 'status': eventStatusToString(newStatus),
.update({'status': eventStatusToString(newStatus)}); });
final snap = await FirebaseFirestore.instance // Récupérer l'événement mis à jour via l'API
.collection('events') final result = await _dataService.getEvents();
.doc(widget.event.id) final eventsList = result['events'] as List<dynamic>;
.get(); final eventData = eventsList.firstWhere(
final updatedEvent = EventModel.fromMap(snap.data()!, widget.event.id); (e) => e['id'] == widget.event.id,
orElse: () => <String, dynamic>{},
);
if (eventData.isNotEmpty) {
final updatedEvent = EventModel.fromMap(eventData, widget.event.id);
widget.onSelectEvent( widget.onSelectEvent(
updatedEvent, updatedEvent,
@@ -46,6 +53,7 @@ class _EventStatusButtonState extends State<EventStatusButton> {
await Provider.of<EventProvider>(context, listen: false) await Provider.of<EventProvider>(context, listen: false)
.updateEvent(updatedEvent); .updateEvent(updatedEvent);
}
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/providers/users_provider.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget de filtre par utilisateur pour le calendrier
/// Affiche un dropdown permettant de filtrer les événements par utilisateur
class UserFilterDropdown extends StatefulWidget {
final String? selectedUserId;
final ValueChanged<String?> onUserSelected;
const UserFilterDropdown({
super.key,
required this.selectedUserId,
required this.onUserSelected,
});
@override
State<UserFilterDropdown> createState() => _UserFilterDropdownState();
}
class _UserFilterDropdownState extends State<UserFilterDropdown> {
List<UserModel> _users = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
// Charger après le premier frame pour éviter setState pendant build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_loadUsers();
}
});
}
Future<void> _loadUsers() async {
if (!mounted) return;
final usersProvider = Provider.of<UsersProvider>(context, listen: false);
// Ne pas appeler fetchUsers si les utilisateurs sont déjà chargés
if (usersProvider.users.isEmpty) {
await usersProvider.fetchUsers();
}
if (!mounted) return;
setState(() {
_users = usersProvider.users;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SizedBox(
width: 200,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
return Container(
width: 250,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: widget.selectedUserId,
hint: const Row(
children: [
Icon(Icons.filter_list, size: 18, color: Colors.grey),
SizedBox(width: 8),
Text('Tous les utilisateurs', style: TextStyle(fontSize: 14)),
],
),
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down, size: 24),
style: const TextStyle(fontSize: 14, color: Colors.black87),
onChanged: widget.onUserSelected,
items: [
// Option "Tous les utilisateurs"
const DropdownMenuItem<String?>(
value: null,
child: Row(
children: [
Icon(Icons.people, size: 18, color: AppColors.rouge),
SizedBox(width: 8),
Text('Tous les utilisateurs', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
),
// Liste des utilisateurs
..._users.map((user) {
return DropdownMenuItem<String?>(
value: user.uid,
child: Row(
children: [
CircleAvatar(
radius: 12,
backgroundImage: user.profilePhotoUrl.isNotEmpty
? NetworkImage(user.profilePhotoUrl)
: null,
child: user.profilePhotoUrl.isEmpty
? Text(
user.firstName.isNotEmpty
? user.firstName[0].toUpperCase()
: '?',
style: const TextStyle(fontSize: 10),
)
: null,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${user.firstName} ${user.lastName}',
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
],
),
),
);
}
}

View File

@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:em2rp/utils/colors.dart';
/// Dialog pour scanner un QR code et récupérer l'ID
class QRCodeScannerDialog extends StatefulWidget {
const QRCodeScannerDialog({super.key});
@override
State<QRCodeScannerDialog> createState() => _QRCodeScannerDialogState();
}
class _QRCodeScannerDialogState extends State<QRCodeScannerDialog> {
MobileScannerController? _controller;
bool _isProcessing = false;
String? _scannedCode;
@override
void initState() {
super.initState();
_controller = MobileScannerController(
detectionSpeed: DetectionSpeed.normal,
facing: CameraFacing.back,
);
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (_isProcessing) return;
final List<Barcode> barcodes = capture.barcodes;
if (barcodes.isEmpty) return;
final barcode = barcodes.first;
final code = barcode.rawValue;
if (code != null && code.isNotEmpty) {
setState(() {
_isProcessing = true;
_scannedCode = code;
});
// Retourner le code après un court délai pour montrer le feedback visuel
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
Navigator.of(context).pop(code);
}
});
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.black,
insetPadding: const EdgeInsets.all(20),
child: Container(
constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
const Icon(Icons.qr_code_scanner, color: Colors.white, size: 28),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Scanner un QR Code',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Zone de scan
Expanded(
child: Stack(
children: [
// Scanner
if (_controller != null)
ClipRRect(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
child: MobileScanner(
controller: _controller,
onDetect: _onDetect,
),
),
// Overlay avec cadre de scan
Positioned.fill(
child: CustomPaint(
painter: _ScannerOverlayPainter(),
),
),
// Feedback visuel quand un code est détecté
if (_isProcessing && _scannedCode != null)
Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 64,
),
const SizedBox(height: 16),
const Text(
'QR Code détecté !',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_scannedCode!,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontFamily: 'monospace',
),
),
),
],
),
),
),
),
],
),
),
// Instructions
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.grey[400], size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
'Positionnez le QR code dans le cadre',
style: TextStyle(
color: Colors.grey[300],
fontSize: 14,
),
),
),
],
),
),
],
),
),
);
}
}
/// Painter pour dessiner l'overlay du scanner avec un cadre
class _ScannerOverlayPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
const double scanAreaSize = 250.0;
final double left = (size.width - scanAreaSize) / 2;
final double top = (size.height - scanAreaSize) / 2;
// Fond semi-transparent
final backgroundPath = Path()
..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
final holePath = Path()
..addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(left, top, scanAreaSize, scanAreaSize),
const Radius.circular(16),
));
final backgroundPaint = Paint()
..color = Colors.black.withValues(alpha: 0.5)
..style = PaintingStyle.fill;
canvas.drawPath(
Path.combine(PathOperation.difference, backgroundPath, holePath),
backgroundPaint,
);
// Cadre de scan (coins)
final cornerPaint = Paint()
..color = AppColors.rouge
..style = PaintingStyle.stroke
..strokeWidth = 4
..strokeCap = StrokeCap.round;
const double cornerLength = 30.0;
// Coin haut-gauche
canvas.drawLine(
Offset(left, top + cornerLength),
Offset(left, top),
cornerPaint,
);
canvas.drawLine(
Offset(left, top),
Offset(left + cornerLength, top),
cornerPaint,
);
// Coin haut-droit
canvas.drawLine(
Offset(left + scanAreaSize - cornerLength, top),
Offset(left + scanAreaSize, top),
cornerPaint,
);
canvas.drawLine(
Offset(left + scanAreaSize, top),
Offset(left + scanAreaSize, top + cornerLength),
cornerPaint,
);
// Coin bas-gauche
canvas.drawLine(
Offset(left, top + scanAreaSize - cornerLength),
Offset(left, top + scanAreaSize),
cornerPaint,
);
canvas.drawLine(
Offset(left, top + scanAreaSize),
Offset(left + cornerLength, top + scanAreaSize),
cornerPaint,
);
// Coin bas-droit
canvas.drawLine(
Offset(left + scanAreaSize - cornerLength, top + scanAreaSize),
Offset(left + scanAreaSize, top + scanAreaSize),
cornerPaint,
);
canvas.drawLine(
Offset(left + scanAreaSize, top + scanAreaSize - cornerLength),
Offset(left + scanAreaSize, top + scanAreaSize),
cornerPaint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
class SearchActionsBar extends StatelessWidget {
final TextEditingController controller;
final String hintText;
final ValueChanged<String> onChanged;
final VoidCallback onClear;
final List<Widget> actions;
const SearchActionsBar({
super.key,
required this.controller,
required this.hintText,
required this.onChanged,
required this.onClear,
this.actions = const [],
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: onClear,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onChanged: onChanged,
),
),
if (actions.isNotEmpty) ...[
const SizedBox(width: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < actions.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
actions[i],
],
],
),
],
],
),
);
}
}

View File

@@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
import 'package:em2rp/services/update_service.dart';
import 'package:em2rp/utils/colors.dart';
/// Dialog pour informer l'utilisateur d'une mise à jour disponible
class UpdateDialog extends StatelessWidget {
final UpdateInfo updateInfo;
const UpdateDialog({
super.key,
required this.updateInfo,
});
@override
Widget build(BuildContext context) {
return PopScope(
// Empêcher la fermeture si c'est une mise à jour forcée
canPop: !updateInfo.forceUpdate,
child: AlertDialog(
title: Row(
children: [
Icon(
updateInfo.forceUpdate ? Icons.update : Icons.system_update,
color: updateInfo.forceUpdate ? Colors.orange : AppColors.rouge,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
updateInfo.forceUpdate
? 'Mise à jour requise'
: 'Mise à jour disponible',
style: const TextStyle(fontSize: 20),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Versions
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Version actuelle :',
style: TextStyle(fontWeight: FontWeight.w500),
),
Text(
updateInfo.currentVersion,
style: const TextStyle(
fontFamily: 'monospace',
color: Colors.grey,
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Nouvelle version :',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(
updateInfo.newVersion,
style: const TextStyle(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
color: AppColors.rouge,
),
),
],
),
],
),
),
const SizedBox(height: 16),
// Message principal
if (updateInfo.forceUpdate) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange, width: 2),
),
child: const Row(
children: [
Icon(Icons.warning, color: Colors.orange),
SizedBox(width: 12),
Expanded(
child: Text(
'Cette mise à jour est obligatoire pour continuer à utiliser l\'application.',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
),
],
),
),
const SizedBox(height: 16),
],
Text(
updateInfo.forceUpdate
? 'L\'application va se recharger pour appliquer la mise à jour.'
: 'Une nouvelle version de l\'application est disponible. Voulez-vous mettre à jour maintenant ?',
style: const TextStyle(fontSize: 15),
),
// Notes de version
if (updateInfo.releaseNotes != null &&
updateInfo.releaseNotes!.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Nouveautés :',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withValues(alpha: 0.2)),
),
child: Text(
updateInfo.releaseNotes!,
style: const TextStyle(fontSize: 14),
),
),
],
],
),
actions: [
if (!updateInfo.forceUpdate)
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Plus tard'),
),
ElevatedButton.icon(
onPressed: () async {
Navigator.of(context).pop();
// Recharger l'application
await UpdateService.reloadApp();
},
icon: const Icon(Icons.refresh, color: Colors.white),
label: Text(
updateInfo.forceUpdate ? 'Mettre à jour' : 'Mettre à jour maintenant',
style: const TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: updateInfo.forceUpdate ? Colors.orange : AppColors.rouge,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
),
],
),
);
}
}
/// Widget pour vérifier automatiquement les mises à jour
class UpdateChecker extends StatefulWidget {
final Widget child;
const UpdateChecker({
super.key,
required this.child,
});
@override
State<UpdateChecker> createState() => _UpdateCheckerState();
}
class _UpdateCheckerState extends State<UpdateChecker> {
@override
void initState() {
super.initState();
_checkForUpdate();
}
Future<void> _checkForUpdate() async {
final updateInfo = await UpdateService.checkOnStartup();
if (updateInfo != null && mounted) {
// Attendre que l'interface soit complètement chargée
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
showDialog(
context: context,
barrierDismissible: !updateInfo.forceUpdate,
builder: (context) => UpdateDialog(updateInfo: updateInfo),
);
}
});
}
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}

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