Compare commits

...

37 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
ElPoyo
fb6a271f66 feat: add current events section for equipment with dynamic status calculation 2026-01-06 12:13:09 +01:00
ElPoyo
25d395b41a feat: ajout de la gestion de la préparation d'un événement avec page permettant de le gérer 2026-01-06 10:53:23 +01:00
ElPoyo
fa1d6a4295 feat: export ICS 2025-12-20 15:56:57 +01:00
ElPoyo
df9e24d3b3 feat: ajout de champs jauge et contact mail et télphone.
Changement des icons de l'app
2025-12-20 15:38:27 +01:00
ElPoyo
28d9e008af feat: Ajout de la gestion de la quantité pour les options d'événement 2025-12-16 19:23:48 +01:00
ElPoyo
08f046c89c feat: Refactor event equipment management with advanced selection and conflict detection
This commit introduces a complete overhaul of how equipment is assigned to events, focusing on an enhanced user experience, advanced selection capabilities, and robust conflict detection.

**Key Features & Enhancements:**

-   **Advanced Equipment Selection UI (`EquipmentSelectionDialog`):**
    -   New full-screen dialog to select equipment and containers ("boîtes") for an event.
    -   Hierarchical view showing containers and a flat list of all individual equipment.
    -   Real-time search and filtering by equipment category.
    -   Side panel summarizing the current selection and providing recommendations for containers based on selected equipment.
    -   Supports quantity selection for consumables and cables.

-   **Conflict Detection & Management (`EventAvailabilityService`):**
    -   A new service (`EventAvailabilityService`) checks for equipment availability against other events based on the selected date range.
    -   The selection dialog visually highlights equipment and containers with scheduling conflicts (e.g., already used, partially unavailable).
    -   A dedicated conflict resolution dialog (`EquipmentConflictDialog`) appears if conflicting items are selected, allowing the user to either remove them or force the assignment.

-   **Integrated Event Form (`EventAssignedEquipmentSection`):**
    -   The event creation/editing form now includes a new section for managing assigned equipment.
    -   It clearly displays assigned containers and standalone equipment, showing the composition of each container.
    -   Integrates the new selection dialog, ensuring all assignments are checked for conflicts before being saved.

-   **Event Preparation & Return Workflow (`EventPreparationPage`):**
    -   New page (`EventPreparationPage`) for managing the check-out (preparation) and check-in (return) of equipment for an event.
    -   Provides a checklist of all assigned equipment.
    -   Users can validate each item, with options to "validate all" or finalize with missing items.
    -   Includes a dialog (`MissingEquipmentDialog`) to handle discrepancies.
    -   Supports tracking returned quantities for consumables.

**Data Model and Other Changes:**

-   The `EventModel` now includes `assignedContainers` to explicitly link containers to an event.
-   `EquipmentAssociatedEventsSection` on the equipment detail page is now functional, displaying current, upcoming, and past events for that item.
-   Added deployment and versioning scripts (`scripts/deploy.js`, `scripts/increment_version.js`, `scripts/toggle_env.js`) to automate the release process.
-   Introduced an application version display in the main drawer (`AppVersion`).
2025-11-30 20:33:03 +01:00
ElPoyo
e59e3e6316 feat: Enhance container management UI with new management components and improved QR code generation flow 2025-10-30 20:06:13 +01:00
ElPoyo
6abb8f1d14 feat: Introduce PDFService for optimized PDF generation and caching in container and equipment management 2025-10-30 18:45:50 +01:00
ElPoyo
822d4443f9 Refactor de la page de détails de l'équipement et ajouts de widgets communs
Refactor de la page `equipment_detail_page` en la décomposant en plusieurs widgets de section réutilisables pour une meilleure lisibilité et maintenance :
- `EquipmentHeaderSection` : En-tête avec titre et informations principales.
- `EquipmentMainInfoSection` : Informations sur la catégorie, la marque, le modèle et le statut.
- `EquipmentNotesSection` : Affichage des notes.
- `EquipmentDatesSection` : Gestion de l'affichage des dates (achat, maintenance, création, etc.).
- `EquipmentPriceSection` : Section dédiée aux prix.
- `EquipmentMaintenanceHistorySection` : Historique des maintenances.
- `EquipmentAssociatedEventsSection` : Placeholder pour les événements à venir.
- `EquipmentReferencingContainers` : Affiche les boites (containers) qui contiennent cet équipement.

Ajout de plusieurs widgets communs et utilitaires :
- Widgets UI : `SearchBarWidget`, `SelectionAppBar`, `CustomFilterChip`, `EmptyState`, `InfoChip`, `StatusBadge`, `QuantityDisplay`.
- Dialogues : `RestockDialog` pour les consommables et `DialogUtils` pour les confirmations génériques.

Autres modifications :
- Mise à jour de la terminologie "Container" en "Boite" dans l'interface utilisateur.
- Amélioration de la sélection d'équipements dans le formulaire des boites.
- Ajout d'instructions pour Copilot (`copilot-instructions.md`).
- Mise à jour de certaines icônes pour les types de boites.
2025-10-30 17:40:28 +01:00
ElPoyo
df6d54a007 Refactor: Centralisation des labels et icônes pour les enums
Centralise la gestion des libellés, couleurs et icônes pour `EquipmentStatus`, `EquipmentCategory`, et `ContainerType` en utilisant des extensions Dart.

- Ajout de nouvelles icônes SVG pour `flight-case`, `truss` et `tape`.
- Refactorisation des vues pour utiliser les nouvelles extensions, supprimant ainsi la logique d'affichage dupliquée.
- Mise à jour des `ChoiceChip` et des listes de filtres pour afficher les icônes à côté des labels.
2025-10-29 18:43:24 +01:00
ElPoyo
3fab69cb00 feat: Gestion complète des containers et refactorisation du matériel
Ajout de la gestion des containers (création, édition, suppression, affichage des détails).
Introduction d'un système de génération de QR codes unifié et d'un mode de sélection multiple.

**Features:**
- **Gestion des Containers :**
    - Nouvelle page de gestion des containers (`container_management_page.dart`) avec recherche et filtres.
    - Formulaire de création/édition de containers (`container_form_page.dart`) avec génération d'ID automatique.
    - Page de détails d'un container (`container_detail_page.dart`) affichant son contenu et ses caractéristiques.
    - Ajout des routes et du provider (`ContainerProvider`) nécessaires.
- **Modèle de Données :**
    - Ajout du `ContainerModel` pour représenter les boîtes, flight cases, etc.
    - Le modèle `EquipmentModel` a été enrichi avec des caractéristiques physiques (poids, dimensions).
- **QR Codes :**
    - Nouveau service unifié (`UnifiedPDFGeneratorService`) pour générer des PDFs de QR codes pour n'importe quelle entité.
    - Services `PDFGeneratorService` et `ContainerPDFGeneratorService` transformés en wrappers pour maintenir la compatibilité.
    - Amélioration de la performance de la génération de QR codes en masse.
- **Interface Utilisateur (UI/UX) :**
    - Nouvelle page de détails pour le matériel (`equipment_detail_page.dart`).
    - Ajout d'un `SelectionModeMixin` pour gérer la sélection multiple dans les pages de gestion.
    - Dialogues réutilisables pour l'affichage de QR codes (`QRCodeDialog`) et la sélection de format d'impression (`QRCodeFormatSelectorDialog`).
    - Ajout d'un bouton "Gérer les boîtes" sur la page de gestion du matériel.

**Refactorisation :**
- L' `IdGenerator` a été déplacé dans le répertoire `utils` et étendu pour gérer les containers.
- Mise à jour de nombreuses dépendances `pubspec.yaml` vers des versions plus récentes.
- Séparation de la logique d'affichage des containers et du matériel dans des widgets dédiés (`ContainerHeaderCard`, `EquipmentParentContainers`, etc.).
2025-10-29 10:57:42 +01:00
ElPoyo
ae3a1b7227 Add equipment management features (and qr generation support) 2025-10-21 16:32:18 +02:00
ElPoyo
ef638d8c8c Mise en place du permission gate sur les pages de data managment 2025-10-17 15:59:53 +02:00
ElPoyo
5057bf9a77 Refactor event type handling and add data management page (options and event types) 2025-10-15 19:01:09 +02:00
ElPoyo
f10a608801 split et refacto de event_details.dart 2025-10-15 14:09:44 +02:00
ElPoyo
4128ddc34a Modif de l'affichage des données d'un événement et de l'afichage de la création/édition
Options sont maintenant géres dans firebase
2025-10-10 19:20:38 +02:00
ElPoyo
aae68f8ab7 V1 calendrier 2025-10-10 14:58:05 +02:00
080fb7d077 fix erreur firebase 2025-06-03 20:41:45 +02:00
57c59c911a Equipe sur event details OK
Modif evenement OK
2025-06-03 19:59:40 +02:00
277 changed files with 39206 additions and 2546 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

6
em2rp/.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,6 @@
CLEAN CODE très important: Toujours écrire du code propre, lisible et bien structuré. Utiliser des noms de variables et de fonctions explicites, éviter les répétitions inutiles et suivre les meilleures pratiques de codage.
Penser a créer des fonctions réutilisables pour éviter la duplication de code.
Verifier la présence de composant existants ou librairie existante avant de créer du code maison. Reutiliser le plus possible le code. Ne pas héister a analyser fréquemment la codebase existante.
Créer des fichiers séparés pour chaque composant, classe ou module afin de faciliter la maintenance et la réutilisation. Il faut eviter de dépasser 600 lignes par fichier.
Si quelque chose n'est pas clair, poser des questions pour clarifier les exigences avant de commencer à coder.
Ne pas générer de fichier résumant le code généré.

4
em2rp/.gitignore vendored
View File

@@ -41,3 +41,7 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# Environment configuration with credentials
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

@@ -1,3 +0,0 @@
# em2rp
A new Flutter project.

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é.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1793 5032 c-17 -9 -82 -75 -144 -147 l-112 -130 511 -3 c282 -1 742
-1 1024 0 l511 3 -112 130 c-62 72 -127 138 -144 147 -29 16 -93 17 -767 17
-674 0 -738 -1 -767 -17z"/>
<path d="M76 4426 c-75 -45 -75 -46 -76 -443 l0 -353 410 0 410 0 0 34 c0 43
36 96 80 118 31 16 71 18 363 18 356 0 374 -2 416 -56 12 -15 26 -47 32 -71
l11 -43 842 0 842 0 2 32 c3 41 29 85 65 112 27 20 41 21 360 24 217 2 345 -1
369 -8 46 -13 85 -59 99 -116 l11 -44 404 0 404 0 0 351 c0 345 0 352 -22 391
-14 24 -38 48 -60 59 -36 19 -101 19 -2480 19 l-2443 0 -39 -24z"/>
<path d="M1120 3295 l0 -205 150 0 150 0 0 205 0 205 -150 0 -150 0 0 -205z"/>
<path d="M3710 3295 l0 -205 150 0 150 0 0 205 0 205 -150 0 -150 0 0 -205z"/>
<path d="M0 3088 l0 -243 240 240 c132 132 240 241 240 242 0 2 -108 3 -240 3
l-240 0 0 -242z"/>
<path d="M1718 3118 c-3 -234 -8 -255 -71 -302 -27 -20 -41 -21 -370 -24 -382
-3 -388 -2 -434 67 -22 32 -23 44 -23 205 l0 171 -410 -410 -410 -410 0 -460
0 -460 601 -608 601 -607 1350 0 1350 0 609 602 609 603 0 470 1 470 -403 397
-403 397 -5 -157 c-6 -172 -15 -202 -73 -246 -27 -20 -41 -21 -370 -24 -382
-3 -388 -2 -434 67 -22 33 -23 42 -23 252 l0 219 -844 0 -845 0 -3 -212z"/>
<path d="M4875 3090 c132 -132 241 -240 242 -240 2 0 3 108 3 240 l0 240 -242
0 -243 0 240 -240z"/>
<path d="M0 731 c0 -321 1 -335 21 -371 40 -71 71 -80 283 -80 l186 0 0 -45
c0 -100 56 -160 147 -160 85 0 143 57 150 147 l4 53 -395 395 -396 395 0 -334z"/>
<path d="M4723 668 c-384 -384 -393 -394 -393 -432 0 -63 34 -121 84 -145 54
-27 81 -26 136 2 54 27 80 72 80 140 l0 47 185 0 c214 0 243 8 283 78 22 40
22 44 20 371 l-3 331 -392 -392z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1979 5105 c-272 -46 -519 -179 -746 -403 -407 -401 -673 -1072 -701
-1767 -26 -644 82 -1147 345 -1610 131 -232 154 -409 69 -544 -39 -61 -145
-159 -201 -185 -104 -49 -330 -113 -585 -167 -58 -12 -106 -23 -108 -24 -4 -4
20 -99 27 -107 3 -4 59 0 124 7 160 19 167 16 231 -112 l36 -72 122 2 c140 2
157 -3 239 -80 l47 -44 116 26 c373 84 861 246 1020 339 94 55 141 166 139
331 -1 171 -47 317 -160 512 -254 440 -373 934 -373 1553 0 259 15 440 51 639
131 720 509 1334 1049 1703 24 16 8 17 -320 16 -231 0 -370 -4 -421 -13z"/>
<path d="M3276 5109 c-322 -47 -636 -237 -884 -534 -759 -909 -791 -2555 -68
-3530 86 -116 295 -319 401 -392 457 -309 952 -312 1410 -7 103 68 271 226
362 338 288 357 477 830 550 1381 24 182 24 628 0 810 -151 1141 -829 1953
-1622 1944 -49 -1 -116 -5 -149 -10z m399 -1047 c227 -79 439 -286 570 -557
282 -586 185 -1369 -222 -1796 -284 -297 -634 -357 -971 -167 -102 57 -260
212 -337 331 -337 521 -335 1288 7 1802 59 89 198 235 269 283 68 46 175 98
243 117 135 39 304 34 441 -13z"/>
<path d="M3307 3905 c-61 -15 -158 -60 -210 -97 -43 -30 -145 -127 -168 -159
l-19 -27 55 -113 c65 -138 107 -261 137 -411 19 -97 23 -144 23 -328 0 -184
-3 -231 -23 -328 -31 -152 -81 -302 -142 -423 -38 -76 -48 -105 -40 -118 5
-10 48 -54 95 -99 131 -125 263 -182 419 -182 350 0 651 328 756 825 27 130
37 373 21 514 -44 372 -212 694 -449 855 -132 90 -314 127 -455 91z"/>
<path d="M2327 628 c-3 -74 -11 -140 -20 -165 l-16 -43 226 0 226 0 -79 51
c-94 61 -164 118 -260 212 l-71 69 -6 -124z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M410 3418 c-70 -35 -90 -85 -90 -218 1 -152 35 -214 132 -235 l28 -7
0 -398 0 -398 -28 -7 c-97 -21 -131 -83 -132 -235 0 -133 20 -183 90 -217 l44
-23 2106 0 2106 0 44 23 c70 34 90 84 90 217 -1 152 -35 214 -132 235 l-28 7
0 398 0 398 28 7 c97 21 131 83 132 235 0 133 -20 183 -90 218 l-44 22 -2106
0 -2106 0 -44 -22z m4230 -218 l0 -80 -2080 0 -2080 0 0 80 0 80 2080 0 2080
0 0 -80z m-3770 -640 c-130 -260 -205 -400 -215 -400 -13 0 -15 51 -15 400 l0
400 215 0 215 0 -200 -400z m640 0 l200 -400 -430 0 -430 0 200 400 c197 393
201 400 230 400 29 0 33 -7 230 -400z m640 0 c-197 -393 -201 -400 -230 -400
-29 0 -33 7 -230 400 l-200 400 430 0 430 0 -200 -400z m640 0 l200 -400 -430
0 -430 0 200 400 c197 393 201 400 230 400 29 0 33 -7 230 -400z m640 0 c-197
-393 -201 -400 -230 -400 -29 0 -33 7 -230 400 l-200 400 430 0 430 0 -200
-400z m640 0 l200 -400 -430 0 -430 0 200 400 c197 393 201 400 230 400 29 0
33 -7 230 -400z m410 0 c0 -349 -2 -400 -15 -400 -10 0 -85 140 -215 400
l-200 400 215 0 215 0 0 -400z m160 -640 l0 -80 -2080 0 -2080 0 0 80 0 80
2080 0 2080 0 0 -80z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

70
em2rp/deploy.bat Normal file
View File

@@ -0,0 +1,70 @@
@echo off
REM Script Windows pour incrémenter la version et déployer sur Firebase
echo ================================================
echo Déploiement Firebase Hosting avec EM2RP
echo ================================================
echo.
echo [0/4] Basculement en mode PRODUCTION...
node scripts\toggle_env.js prod
if %ERRORLEVEL% NEQ 0 (
echo Erreur lors du basculement en mode production
pause
exit /b 1
)
echo.
echo [1/4] Incrémentation de la version...
node scripts\increment_version.js
if %ERRORLEVEL% NEQ 0 (
echo Erreur lors de l'incrémentation de la version
node scripts\toggle_env.js dev
pause
exit /b 1
)
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...
call flutter build web --release
if %ERRORLEVEL% NEQ 0 (
echo Erreur lors du build Flutter
node scripts\toggle_env.js dev
pause
exit /b 1
)
echo.
echo [3/4] Déploiement Firebase Hosting...
call firebase deploy --only hosting
if %ERRORLEVEL% NEQ 0 (
echo Erreur lors du déploiement Firebase
node scripts\toggle_env.js dev
pause
exit /b 1
)
echo.
echo [4/4] Retour en mode DÉVELOPPEMENT...
node scripts\toggle_env.js dev
if %ERRORLEVEL% NEQ 0 (
echo ATTENTION: Impossible de rebascule en mode dev
echo Exécutez manuellement: node scripts\toggle_env.js dev
)
echo.
echo ================================================
echo Déploiement terminé avec succès!
echo ================================================
pause

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,3 +1,4 @@
description: This file stores settings for Dart & Flutter DevTools. description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions: extensions:
- provider: true

15
em2rp/env_dev.bat Normal file
View File

@@ -0,0 +1,15 @@
@echo off
REM Script Windows pour basculer en mode DÉVELOPPEMENT
echo Basculement en mode DÉVELOPPEMENT...
node scripts\toggle_env.js dev
if %ERRORLEVEL% EQU 0 (
echo ✅ Mode DÉVELOPPEMENT activé
echo - isDevelopment = true
echo - Auto-login activé
) else (
echo ❌ Erreur lors du basculement
echo Vérifiez que le fichier env.dev.dart existe
)

16
em2rp/env_prod.bat Normal file
View File

@@ -0,0 +1,16 @@
@echo off
REM Script Windows pour basculer en mode PRODUCTION
echo Basculement en mode PRODUCTION...
node scripts\toggle_env.js prod
if %ERRORLEVEL% EQU 0 (
echo ✅ Mode PRODUCTION activé
echo - isDevelopment = false
echo - Credentials masqués
) else (
echo ❌ Erreur lors du basculement
)
pause

View File

@@ -34,5 +34,39 @@
"*.local" "*.local"
] ]
} }
] ],
"hosting": {
"public": "build/web",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"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

@@ -0,0 +1,20 @@
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/EM2_NsurB.jpg"
web:
generate: true
image_path: "assets/EM2_NsurB.jpg"
background_color: "#ffffff"
theme_color: "#0175C2"
windows:
generate: true
image_path: "assets/EM2_NsurB.jpg"
icon_size: 48
macos:
generate: true
image_path: "assets/EM2_NsurB.jpg"
linux:
generate: true
image_path: "assets/EM2_NsurB.jpg"

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,14 @@
@echo off
REM Script Windows pour incrémenter uniquement la version
echo Incrémentation de la version...
node scripts\increment_version.js
if %ERRORLEVEL% EQU 0 (
echo Version incrémentée avec succès!
) else (
echo Erreur lors de l'incrémentation
)
pause

View File

@@ -427,7 +427,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";

View File

@@ -1,122 +1 @@
{ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

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

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

View File

@@ -3,8 +3,7 @@ class Env {
// Configuration de l'auto-login en développement // Configuration de l'auto-login en développement
static const String devAdminEmail = 'paul.fournel@em2events.fr'; static const String devAdminEmail = 'paul.fournel@em2events.fr';
static const String devAdminPassword = static const String devAdminPassword = 'Pastis51!';
"Azerty\$1!"; // À remplacer par le vrai mot de passe
// URLs et endpoints // URLs et endpoints
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com'; static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
@@ -15,3 +14,4 @@ class Env {
// Autres configurations // Autres configurations
static const int apiTimeout = 30000; // 30 secondes static const int apiTimeout = 30000; // 30 secondes
} }

View File

@@ -0,0 +1,17 @@
class Env {
static const bool isDevelopment = true;
// Configuration de l'auto-login en développement
static const String devAdminEmail = 'paul.fournel@em2events.fr';
static const String devAdminPassword = 'Pastis51!';
// URLs et endpoints
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
// Configuration Firebase
static const String firebaseProjectId = 'em2rp-951dc';
// Autres configurations
static const int apiTimeout = 30000; // 30 secondes
}

View File

@@ -0,0 +1,488 @@
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:file_picker/file_picker.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/event_type_model.dart';
import 'package:em2rp/models/user_model.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:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
class EventFormController extends ChangeNotifier {
// Controllers
final TextEditingController nameController = TextEditingController();
final TextEditingController descriptionController = TextEditingController();
final TextEditingController basePriceController = TextEditingController();
final TextEditingController installationController = TextEditingController();
final TextEditingController disassemblyController = TextEditingController();
final TextEditingController addressController = TextEditingController();
final TextEditingController jaugeController = TextEditingController();
final TextEditingController contactEmailController = TextEditingController();
final TextEditingController contactPhoneController = TextEditingController();
// State variables
DateTime? _startDateTime;
DateTime? _endDateTime;
bool _isLoading = false;
String? _error;
String? _success;
String? _selectedEventTypeId;
List<EventTypeModel> _eventTypes = [];
bool _isLoadingEventTypes = true;
List<String> _selectedUserIds = [];
List<UserModel> _allUsers = [];
bool _isLoadingUsers = true;
List<Map<String, String>> _uploadedFiles = [];
List<Map<String, dynamic>> _selectedOptions = [];
bool _formChanged = false;
EventStatus _selectedStatus = EventStatus.waitingForApproval;
List<EventEquipment> _assignedEquipment = [];
List<String> _assignedContainers = [];
// Getters
DateTime? get startDateTime => _startDateTime;
DateTime? get endDateTime => _endDateTime;
bool get isLoading => _isLoading;
String? get error => _error;
String? get success => _success;
String? get selectedEventTypeId => _selectedEventTypeId;
List<EventTypeModel> get eventTypes => _eventTypes;
bool get isLoadingEventTypes => _isLoadingEventTypes;
List<String> get selectedUserIds => _selectedUserIds;
List<UserModel> get allUsers => _allUsers;
bool get isLoadingUsers => _isLoadingUsers;
List<Map<String, String>> get uploadedFiles => _uploadedFiles;
List<Map<String, dynamic>> get selectedOptions => _selectedOptions;
List<EventEquipment> get assignedEquipment => _assignedEquipment;
List<String> get assignedContainers => _assignedContainers;
bool get formChanged => _formChanged;
EventStatus get selectedStatus => _selectedStatus;
EventFormController() {
_setupListeners();
}
void _setupListeners() {
nameController.addListener(_onAnyFieldChanged);
basePriceController.addListener(_onAnyFieldChanged);
installationController.addListener(_onAnyFieldChanged);
disassemblyController.addListener(_onAnyFieldChanged);
addressController.addListener(_onAnyFieldChanged);
descriptionController.addListener(_onAnyFieldChanged);
jaugeController.addListener(_onAnyFieldChanged);
contactEmailController.addListener(_onAnyFieldChanged);
contactPhoneController.addListener(_onAnyFieldChanged);
}
void _onAnyFieldChanged() {
if (!_formChanged) {
_formChanged = true;
notifyListeners();
}
}
Future<void> initialize({EventModel? existingEvent, DateTime? selectedDate}) async {
await Future.wait([
_fetchUsers(),
_fetchEventTypes(),
]);
if (existingEvent != null) {
_populateFromEvent(existingEvent);
} else {
_selectedStatus = EventStatus.waitingForApproval;
// Préremplir les dates si une date est sélectionnée dans le calendrier
if (selectedDate != null) {
// Date de début : selectedDate à 20h00
_startDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
20,
0,
);
// Date de fin : selectedDate + 4 heures
_endDateTime = _startDateTime!.add(const Duration(hours: 4));
}
}
notifyListeners();
}
void _populateFromEvent(EventModel event) {
nameController.text = event.name;
descriptionController.text = event.description;
basePriceController.text = event.basePrice.toStringAsFixed(2);
installationController.text = event.installationTime.toString();
disassemblyController.text = event.disassemblyTime.toString();
addressController.text = event.address;
jaugeController.text = event.jauge?.toString() ?? '';
contactEmailController.text = event.contactEmail ?? '';
contactPhoneController.text = event.contactPhone ?? '';
_startDateTime = event.startDateTime;
_endDateTime = event.endDateTime;
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
_assignedContainers = List<String>.from(event.assignedContainers);
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
// 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);
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
_selectedStatus = event.status;
}
Future<void> _fetchUsers() async {
try {
_allUsers = await EventFormService.fetchUsers();
_isLoadingUsers = false;
} catch (e) {
_error = e.toString();
_isLoadingUsers = false;
}
notifyListeners();
}
Future<void> _fetchEventTypes() async {
try {
_eventTypes = await EventFormService.fetchEventTypes();
_isLoadingEventTypes = false;
} catch (e) {
_error = e.toString();
_isLoadingEventTypes = false;
}
notifyListeners();
}
void setStartDateTime(DateTime? dateTime) {
_startDateTime = dateTime;
if (_endDateTime != null &&
dateTime != null &&
(_endDateTime!.isBefore(dateTime) || _endDateTime!.isAtSameMomentAs(dateTime))) {
_endDateTime = null;
}
_onAnyFieldChanged();
notifyListeners();
}
void setEndDateTime(DateTime? dateTime) {
_endDateTime = dateTime;
_onAnyFieldChanged();
notifyListeners();
}
void onEventTypeChanged(String? newTypeId, BuildContext context) {
if (newTypeId == _selectedEventTypeId) return;
final oldEventTypeIndex = _selectedEventTypeId != null
? _eventTypes.indexWhere((et) => et.id == _selectedEventTypeId)
: -1;
final EventTypeModel? oldEventType = oldEventTypeIndex != -1 ? _eventTypes[oldEventTypeIndex] : null;
_selectedEventTypeId = newTypeId;
if (newTypeId != null) {
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
// Utiliser le prix par défaut du type d'événement (prix TTC stocké dans basePrice)
final defaultPriceTTC = selectedType.defaultPrice;
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
final oldDefaultPrice = oldEventType?.defaultPrice;
// Mettre à jour le prix TTC si le champ est vide ou si c'était l'ancien prix par défaut
if (basePriceController.text.isEmpty ||
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
basePriceController.text = defaultPriceTTC.toStringAsFixed(2);
}
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
final before = _selectedOptions.length;
_selectedOptions.removeWhere((opt) {
// Vérifier si cette option est compatible avec le type d'événement sélectionné
final optionEventTypes = opt['eventTypes'] as List<dynamic>? ?? [];
return !optionEventTypes.contains(selectedType.id);
});
if (_selectedOptions.length < before) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Certaines options ont été retirées car non compatibles avec "${selectedType.name}".')),
);
}
} else {
_selectedOptions.clear();
}
_onAnyFieldChanged();
notifyListeners();
}
void setSelectedUserIds(List<String> userIds) {
_selectedUserIds = userIds;
_onAnyFieldChanged();
notifyListeners();
}
void setUploadedFiles(List<Map<String, String>> files) {
_uploadedFiles = files;
_onAnyFieldChanged();
notifyListeners();
}
void setSelectedOptions(List<Map<String, dynamic>> options) {
_selectedOptions = options;
_onAnyFieldChanged();
notifyListeners();
}
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
_assignedEquipment = equipment;
_assignedContainers = containers;
_onAnyFieldChanged();
notifyListeners();
}
Future<void> pickAndUploadFiles() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true, withData: true);
if (result != null && result.files.isNotEmpty) {
_isLoading = true;
_error = null;
notifyListeners();
try {
final files = await EventFormService.uploadFiles(result.files);
_uploadedFiles.addAll(files);
_onAnyFieldChanged();
} catch (e) {
_error = 'Erreur lors de l\'upload : $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
}
bool validateForm() {
return nameController.text.isNotEmpty &&
_startDateTime != null &&
_endDateTime != null &&
_selectedEventTypeId != null &&
addressController.text.isNotEmpty &&
(_endDateTime!.isAfter(_startDateTime!));
}
Future<bool> submitForm(BuildContext context, {EventModel? existingEvent}) async {
if (!validateForm()) {
_error = "Veuillez remplir tous les champs obligatoires.";
notifyListeners();
return false;
}
_isLoading = true;
_error = null;
_success = null;
notifyListeners();
try {
final eventTypeRef = _selectedEventTypeId != null
? null // Les références Firestore ne sont plus nécessaires, l'ID suffit
: null;
if (existingEvent != null) {
// Mode édition
// Gérer les nouveaux fichiers uploadés s'il y en a
List<Map<String, String>> finalDocuments = List<Map<String, String>>.from(_uploadedFiles);
// Identifier les nouveaux fichiers (ceux qui ont une URL temp)
final newFiles = _uploadedFiles.where((file) =>
file['url']?.contains('events/temp/') ?? false).toList();
if (newFiles.isNotEmpty) {
// Déplacer les nouveaux fichiers vers le dossier de l'événement
final movedFiles = await EventFormService.moveFilesToEvent(newFiles, existingEvent.id);
// Remplacer les URLs temporaires par les nouvelles URLs
for (int i = 0; i < finalDocuments.length; i++) {
final tempFile = finalDocuments[i];
final movedFile = movedFiles.firstWhere(
(moved) => moved['name'] == tempFile['name'],
orElse: () => tempFile,
);
finalDocuments[i] = movedFile;
}
}
final updatedEvent = EventModel(
id: existingEvent.id,
name: nameController.text.trim(),
description: descriptionController.text.trim(),
startDateTime: _startDateTime!,
endDateTime: _endDateTime!,
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
installationTime: int.tryParse(installationController.text) ?? 0,
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
eventTypeId: _selectedEventTypeId!,
eventTypeRef: eventTypeRef,
customerId: existingEvent.customerId,
address: addressController.text.trim(),
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
workforce: _selectedUserIds,
latitude: existingEvent.latitude,
longitude: existingEvent.longitude,
documents: finalDocuments,
options: _selectedOptions,
status: _selectedStatus,
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
assignedEquipment: _assignedEquipment,
assignedContainers: _assignedContainers,
preparationStatus: existingEvent.preparationStatus,
returnStatus: existingEvent.returnStatus,
);
await EventFormService.updateEvent(updatedEvent);
// Recharger les événements après modification
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
final eventProvider = Provider.of<EventProvider>(context, listen: false);
final userId = localUserProvider.uid;
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
if (userId != null) {
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
}
_success = "Événement modifié avec succès !";
} else {
// Mode création
final newEvent = EventModel(
id: '',
name: nameController.text.trim(),
description: descriptionController.text.trim(),
startDateTime: _startDateTime!,
endDateTime: _endDateTime!,
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
installationTime: int.tryParse(installationController.text) ?? 0,
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
eventTypeId: _selectedEventTypeId!,
eventTypeRef: eventTypeRef,
customerId: '',
address: addressController.text.trim(),
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
workforce: _selectedUserIds,
latitude: 0.0,
longitude: 0.0,
documents: _uploadedFiles,
options: _selectedOptions,
status: _selectedStatus,
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
assignedContainers: _assignedContainers,
assignedEquipment: _assignedEquipment,
);
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);
if (newFiles.isNotEmpty) {
await EventFormService.updateEventDocuments(eventId, newFiles);
}
}
// Reload events
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
final eventProvider = Provider.of<EventProvider>(context, listen: false);
final userId = localUserProvider.uid;
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
if (userId != null) {
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
}
_success = "Événement créé avec succès !";
}
_formChanged = false;
notifyListeners();
return true;
} catch (e) {
_error = "Erreur lors de la sauvegarde : $e";
notifyListeners();
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<bool> deleteEvent(BuildContext context, String eventId) async {
_isLoading = true;
_error = null;
_success = null;
notifyListeners();
try {
// Supprimer l'événement via l'API
final dataService = DataService(FirebaseFunctionsApiService());
await dataService.deleteEvent(eventId);
// Recharger la liste des événements
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
final eventProvider = Provider.of<EventProvider>(context, listen: false);
final userId = localUserProvider.uid;
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
if (userId != null) {
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
}
_success = "Événement supprimé avec succès !";
notifyListeners();
return true;
} catch (e) {
_error = "Erreur lors de la suppression : $e";
notifyListeners();
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
void clearError() {
_error = null;
notifyListeners();
}
void clearSuccess() {
_success = null;
notifyListeners();
}
@override
void dispose() {
nameController.dispose();
descriptionController.dispose();
basePriceController.dispose();
installationController.dispose();
disassemblyController.dispose();
addressController.dispose();
jaugeController.dispose();
contactEmailController.dispose();
contactPhoneController.dispose();
super.dispose();
}
}

View File

@@ -1,9 +1,22 @@
import 'package:em2rp/providers/users_provider.dart'; import 'package:em2rp/providers/users_provider.dart';
import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/maintenance_provider.dart';
import 'package:em2rp/providers/alert_provider.dart';
import 'package:em2rp/utils/auth_guard_widget.dart'; import 'package:em2rp/utils/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/container_management_page.dart';
import 'package:em2rp/views/container_form_page.dart';
import 'package:em2rp/views/container_detail_page.dart';
import 'package:em2rp/views/event_preparation_page.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package: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';
@@ -12,37 +25,67 @@ 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 'pages/auth/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(),
), ),
// EquipmentProvider migré vers l'API
ChangeNotifierProvider<EquipmentProvider>(
create: (context) => EquipmentProvider(),
),
// ContainerProvider migré vers l'API
ChangeNotifierProvider<ContainerProvider>(
create: (context) => ContainerProvider(),
),
// MaintenanceProvider migré vers l'API
ChangeNotifierProvider<MaintenanceProvider>(
create: (context) => MaintenanceProvider(),
),
ChangeNotifierProvider<AlertProvider>(
create: (context) => AlertProvider(),
),
], ],
child: const MyApp(), child: const MyApp(),
), ),
@@ -54,9 +97,9 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
print("test"); return UpdateChecker(
return MaterialApp( child: MaterialApp(
title: 'EM2 ERP', title: 'EM2 Hub',
theme: ThemeData( theme: ThemeData(
primarySwatch: Colors.red, primarySwatch: Colors.red,
primaryColor: AppColors.noir, primaryColor: AppColors.noir,
@@ -91,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(
@@ -106,7 +151,39 @@ class MyApp extends StatelessWidget {
actionCode: args['actionCode'] as String, actionCode: args['actionCode'] as String,
); );
}, },
'/equipment_management': (context) => const AuthGuard(
requiredPermission: "view_equipment",
child: EquipmentManagementPage()),
'/container_management': (context) => const AuthGuard(
requiredPermission: "view_equipment",
child: ContainerManagementPage()),
'/container_form': (context) {
final args = ModalRoute.of(context)?.settings.arguments;
return AuthGuard(
requiredPermission: "manage_equipment",
child: ContainerFormPage(
container: args as ContainerModel?,
),
);
}, },
'/container_detail': (context) {
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel;
return AuthGuard(
requiredPermission: "view_equipment",
child: ContainerDetailPage(container: container),
);
},
'/event_preparation': (context) {
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
final event = args['event'] as EventModel;
return AuthGuard(
child: EventPreparationPage(
initialEvent: event,
),
);
},
},
),
); );
} }
} }
@@ -143,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

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
/// Mixin réutilisable pour gérer le mode sélection multiple
/// Utilisable dans equipment_management_page, container_management_page, etc.
mixin SelectionModeMixin<T extends StatefulWidget> on State<T> {
// État du mode sélection
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
// Getters
bool get isSelectionMode => _isSelectionMode;
Set<String> get selectedIds => _selectedIds;
int get selectedCount => _selectedIds.length;
bool get hasSelection => _selectedIds.isNotEmpty;
/// Active/désactive le mode sélection
void toggleSelectionMode() {
setState(() {
_isSelectionMode = !_isSelectionMode;
if (!_isSelectionMode) {
_selectedIds.clear();
}
});
}
/// Active le mode sélection
void enableSelectionMode() {
if (!_isSelectionMode) {
setState(() {
_isSelectionMode = true;
});
}
}
/// Désactive le mode sélection et efface la sélection
void disableSelectionMode() {
if (_isSelectionMode) {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
}
/// Toggle la sélection d'un item
void toggleItemSelection(String id) {
setState(() {
if (_selectedIds.contains(id)) {
_selectedIds.remove(id);
} else {
_selectedIds.add(id);
}
});
}
/// Sélectionne un item
void selectItem(String id) {
setState(() {
_selectedIds.add(id);
});
}
/// Désélectionne un item
void deselectItem(String id) {
setState(() {
_selectedIds.remove(id);
});
}
/// Vérifie si un item est sélectionné
bool isItemSelected(String id) {
return _selectedIds.contains(id);
}
/// Sélectionne tous les items
void selectAll(List<String> ids) {
setState(() {
_selectedIds.addAll(ids);
});
}
/// Efface la sélection
void clearSelection() {
setState(() {
_selectedIds.clear();
});
}
/// Sélectionne/désélectionne tous les items
void toggleSelectAll(List<String> ids) {
setState(() {
if (_selectedIds.length == ids.length) {
// Tout est sélectionné, on désélectionne tout
_selectedIds.clear();
} else {
// Sélectionner tout
_selectedIds.addAll(ids);
}
});
}
/// Widget pour afficher le nombre d'éléments sélectionnés
Widget buildSelectionCounter({
required Color backgroundColor,
required Color textColor,
String? customText,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(16),
),
child: Text(
customText ?? '$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}',
style: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
),
),
);
}
/// AppBar pour le mode sélection
PreferredSizeWidget buildSelectionAppBar({
required String title,
required List<Widget> actions,
Color? backgroundColor,
}) {
return AppBar(
backgroundColor: backgroundColor,
leading: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: disableSelectionMode,
),
title: Text(
'$selectedCount $title sélectionné${selectedCount > 1 ? 's' : ''}',
style: const TextStyle(color: Colors.white),
),
actions: actions,
);
}
}

View File

@@ -0,0 +1,273 @@
import 'package:cloud_firestore/cloud_firestore.dart';
/// Type d'alerte
enum AlertType {
lowStock, // Stock faible
maintenanceDue, // Maintenance à venir
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) {
switch (type) {
case AlertType.lowStock:
return 'LOW_STOCK';
case AlertType.maintenanceDue:
return 'MAINTENANCE_DUE';
case AlertType.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';
}
}
AlertType alertTypeFromString(String? type) {
switch (type) {
case 'LOW_STOCK':
return AlertType.lowStock;
case 'MAINTENANCE_DUE':
return AlertType.maintenanceDue;
case '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:
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 {
final String id; // ID généré automatiquement
final AlertType type; // Type d'alerte
final AlertSeverity severity; // Gravité 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? createdByUserId; // Qui a déclenché l'alerte
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 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({
required this.id,
required this.type,
this.severity = AlertSeverity.info,
required this.message,
this.assignedToUserIds = const [],
this.eventId,
this.equipmentId,
this.createdByUserId,
required this.createdAt,
this.dueDate,
this.actionUrl,
this.isRead = false,
this.isResolved = false,
this.resolution,
this.resolvedAt,
this.resolvedByUserId,
});
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(
id: id,
type: alertTypeFromString(map['type']),
severity: alertSeverityFromString(map['severity']),
message: map['message'] ?? '',
assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']),
eventId: map['eventId'],
equipmentId: map['equipmentId'],
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
createdAt: _parseDate(map['createdAt']),
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
actionUrl: map['actionUrl'],
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() {
return {
'type': alertTypeToString(type),
'severity': alertSeverityToString(severity),
'message': message,
'assignedToUserIds': assignedToUserIds,
if (eventId != null) 'eventId': eventId,
if (equipmentId != null) 'equipmentId': equipmentId,
if (createdByUserId != null) 'createdByUserId': createdByUserId,
'createdAt': Timestamp.fromDate(createdAt),
if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!),
if (actionUrl != null) 'actionUrl': actionUrl,
'isRead': isRead,
'isResolved': isResolved,
if (resolution != null) 'resolution': resolution,
if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!),
if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId,
};
}
AlertModel copyWith({
String? id,
AlertType? type,
AlertSeverity? severity,
String? message,
List<String>? assignedToUserIds,
String? eventId,
String? equipmentId,
String? createdByUserId,
DateTime? createdAt,
DateTime? dueDate,
String? actionUrl,
bool? isRead,
bool? isResolved,
String? resolution,
DateTime? resolvedAt,
String? resolvedByUserId,
}) {
return AlertModel(
id: id ?? this.id,
type: type ?? this.type,
severity: severity ?? this.severity,
message: message ?? this.message,
assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds,
eventId: eventId ?? this.eventId,
equipmentId: equipmentId ?? this.equipmentId,
createdByUserId: createdByUserId ?? this.createdByUserId,
createdAt: createdAt ?? this.createdAt,
dueDate: dueDate ?? this.dueDate,
actionUrl: actionUrl ?? this.actionUrl,
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

@@ -0,0 +1,382 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:em2rp/models/equipment_model.dart';
/// Type de container
enum ContainerType {
flightCase, // Flight case
pelicase, // Pelicase
bag, // Sac
openCrate, // Caisse ouverte
toolbox, // Boîte à outils
}
String containerTypeToString(ContainerType type) {
switch (type) {
case ContainerType.flightCase:
return 'FLIGHT_CASE';
case ContainerType.pelicase:
return 'PELICASE';
case ContainerType.bag:
return 'BAG';
case ContainerType.openCrate:
return 'OPEN_CRATE';
case ContainerType.toolbox:
return 'TOOLBOX';
}
}
ContainerType containerTypeFromString(String? type) {
switch (type) {
case 'FLIGHT_CASE':
return ContainerType.flightCase;
case 'PELICASE':
return ContainerType.pelicase;
case 'BAG':
return ContainerType.bag;
case 'OPEN_CRATE':
return ContainerType.openCrate;
case 'TOOLBOX':
return ContainerType.toolbox;
default:
return ContainerType.flightCase;
}
}
String containerTypeLabel(ContainerType type) {
switch (type) {
case ContainerType.flightCase:
return 'Flight Case';
case ContainerType.pelicase:
return 'Pelicase';
case ContainerType.bag:
return 'Sac';
case ContainerType.openCrate:
return 'Caisse Ouverte';
case ContainerType.toolbox:
return 'Boîte à Outils';
}
}
// Extensions pour centraliser les informations d'affichage
extension ContainerTypeExtension on ContainerType {
/// Retourne le label français du type de container
String get label {
switch (this) {
case ContainerType.flightCase:
return 'Flight Case';
case ContainerType.pelicase:
return 'Pelicase';
case ContainerType.bag:
return 'Sac';
case ContainerType.openCrate:
return 'Caisse Ouverte';
case ContainerType.toolbox:
return 'Boîte à Outils';
}
}
/// Retourne l'icône Material du type de container
IconData get iconData {
switch (this) {
case ContainerType.flightCase:
return Icons.work;
case ContainerType.pelicase:
return Icons.work_outline;
case ContainerType.bag:
return Icons.shopping_bag;
case ContainerType.openCrate:
return Icons.inventory_2;
case ContainerType.toolbox:
return Icons.home_repair_service;
}
}
/// Retourne le chemin de l'icône personnalisée (si disponible)
String? get customIconPath {
switch (this) {
case ContainerType.flightCase:
return 'assets/icons/flight-case.svg';
default:
return null;
}
}
/// Vérifie si une icône personnalisée est disponible
bool get hasCustomIcon => customIconPath != null;
/// Retourne l'icône Widget à afficher (unifié pour Material et personnalisé)
Widget getIcon({double size = 24, Color? color}) {
final customPath = customIconPath;
if (customPath != null) {
// Détection automatique du format (SVG ou PNG)
final isSvg = customPath.toLowerCase().endsWith('.svg');
if (isSvg) {
// SVG : on peut appliquer la couleur sans dégrader la qualité
return SvgPicture.asset(
customPath,
width: size,
height: size,
colorFilter: color != null
? ColorFilter.mode(color, BlendMode.srcIn)
: null,
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
);
} else {
// PNG : on n'applique PAS le color filter pour préserver la qualité
return Image.asset(
customPath,
width: size,
height: size,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
return Icon(iconData, size: size, color: color);
},
);
}
}
return Icon(iconData, size: size, color: color);
}
/// Version pour CircleAvatar et contextes similaires
Widget getIconForAvatar({double size = 24, Color? color}) {
final customPath = customIconPath;
if (customPath != null) {
final isSvg = customPath.toLowerCase().endsWith('.svg');
if (isSvg) {
return SvgPicture.asset(
customPath,
width: size,
height: size,
colorFilter: color != null
? ColorFilter.mode(color, BlendMode.srcIn)
: null,
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
);
} else {
return Image.asset(
customPath,
width: size,
height: size,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
return Icon(iconData, size: size, color: color);
},
);
}
}
return Icon(iconData, size: size, color: color);
}
}
/// Modèle de container/boîte pour le matériel
class ContainerModel {
final String id; // Identifiant unique (généré comme pour équipement)
final String name; // Nom du container
final ContainerType type; // Type de container
final EquipmentStatus status; // Statut actuel (même que équipement)
// Caractéristiques physiques
final double? weight; // Poids à vide (kg)
final double? length; // Longueur (cm)
final double? width; // Largeur (cm)
final double? height; // Hauteur (cm)
// Contenu
final List<String> equipmentIds; // IDs des équipements contenus
// Événement
final String? eventId; // ID de l'événement actuel (si en prestation)
// Métadonnées
final String? notes; // Notes additionnelles
final DateTime createdAt; // Date de création
final DateTime updatedAt; // Date de mise à jour
// Historique simple (optionnel)
final List<ContainerHistoryEntry> history; // Historique des modifications
ContainerModel({
required this.id,
required this.name,
required this.type,
this.status = EquipmentStatus.available,
this.weight,
this.length,
this.width,
this.height,
this.equipmentIds = const [],
this.eventId,
this.notes,
required this.createdAt,
required this.updatedAt,
this.history = const [],
});
/// Vérifier si le container est vide
bool get isEmpty => equipmentIds.isEmpty;
/// Nombre d'équipements dans le container
int get itemCount => equipmentIds.length;
/// Calculer le volume (m³)
double? get volume {
if (length == null || width == null || height == null) return null;
return (length! * width! * height!) / 1000000; // cm³ to m³
}
/// Calculer le poids total (poids vide + équipements)
/// Nécessite la liste des équipements
double calculateTotalWeight(List<EquipmentModel> equipment) {
double total = weight ?? 0.0;
for (final eq in equipment) {
if (equipmentIds.contains(eq.id) && eq.weight != null) {
total += eq.weight!;
}
}
return total;
}
/// Factory depuis Firestore
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<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
final List<dynamic> historyRaw = map['history'] ?? [];
final List<ContainerHistoryEntry> history = historyRaw
.map((e) => ContainerHistoryEntry.fromMap(e as Map<String, dynamic>))
.toList();
return ContainerModel(
id: id,
name: map['name'] ?? '',
type: containerTypeFromString(map['type']),
status: equipmentStatusFromString(map['status']),
weight: map['weight']?.toDouble(),
length: map['length']?.toDouble(),
width: map['width']?.toDouble(),
height: map['height']?.toDouble(),
equipmentIds: equipmentIds,
eventId: map['eventId'],
notes: map['notes'],
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
history: history,
);
}
/// Convertir en Map pour Firestore
Map<String, dynamic> toMap() {
return {
'name': name,
'type': containerTypeToString(type),
'status': equipmentStatusToString(status),
'weight': weight,
'length': length,
'width': width,
'height': height,
'equipmentIds': equipmentIds,
'eventId': eventId,
'notes': notes,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
'history': history.map((e) => e.toMap()).toList(),
};
}
/// Copier avec modifications
ContainerModel copyWith({
String? id,
String? name,
ContainerType? type,
EquipmentStatus? status,
double? weight,
double? length,
double? width,
double? height,
List<String>? equipmentIds,
String? eventId,
String? notes,
DateTime? createdAt,
DateTime? updatedAt,
List<ContainerHistoryEntry>? history,
}) {
return ContainerModel(
id: id ?? this.id,
name: name ?? this.name,
type: type ?? this.type,
status: status ?? this.status,
weight: weight ?? this.weight,
length: length ?? this.length,
width: width ?? this.width,
height: height ?? this.height,
equipmentIds: equipmentIds ?? this.equipmentIds,
eventId: eventId ?? this.eventId,
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
history: history ?? this.history,
);
}
}
/// Entrée d'historique pour un container
class ContainerHistoryEntry {
final DateTime timestamp;
final String action; // 'added', 'removed', 'status_change', etc.
final String? equipmentId; // ID de l'équipement concerné (si applicable)
final String? previousValue; // Valeur précédente
final String? newValue; // Nouvelle valeur
final String? userId; // ID de l'utilisateur ayant fait la modification
ContainerHistoryEntry({
required this.timestamp,
required this.action,
this.equipmentId,
this.previousValue,
this.newValue,
this.userId,
});
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(
timestamp: _parseDate(map['timestamp']),
action: map['action'] ?? '',
equipmentId: map['equipmentId'],
previousValue: map['previousValue'],
newValue: map['newValue'],
userId: map['userId'],
);
}
Map<String, dynamic> toMap() {
return {
'timestamp': Timestamp.fromDate(timestamp),
'action': action,
'equipmentId': equipmentId,
'previousValue': previousValue,
'newValue': newValue,
'userId': userId,
};
}
}

View File

@@ -0,0 +1,528 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
enum EquipmentStatus {
available, // Disponible
inUse, // En prestation
rented, // Loué
lost, // Perdu
outOfService, // HS
maintenance, // En maintenance
}
String equipmentStatusToString(EquipmentStatus status) {
switch (status) {
case EquipmentStatus.available:
return 'AVAILABLE';
case EquipmentStatus.inUse:
return 'IN_USE';
case EquipmentStatus.rented:
return 'RENTED';
case EquipmentStatus.lost:
return 'LOST';
case EquipmentStatus.outOfService:
return 'OUT_OF_SERVICE';
case EquipmentStatus.maintenance:
return 'MAINTENANCE';
}
}
EquipmentStatus equipmentStatusFromString(String? status) {
switch (status) {
case 'AVAILABLE':
return EquipmentStatus.available;
case 'IN_USE':
return EquipmentStatus.inUse;
case 'RENTED':
return EquipmentStatus.rented;
case 'LOST':
return EquipmentStatus.lost;
case 'OUT_OF_SERVICE':
return EquipmentStatus.outOfService;
case 'MAINTENANCE':
return EquipmentStatus.maintenance;
default:
return EquipmentStatus.available;
}
}
enum EquipmentCategory {
lighting, // Lumière
sound, // Son
video, // Vidéo
effect, // Effets spéciaux
structure, // Structure
consumable, // Consommable
cable, // Câble
vehicle, // Véhicule
backline, // Régie / Backline
other // Autre
}
String equipmentCategoryToString(EquipmentCategory category) {
switch (category) {
case EquipmentCategory.lighting:
return 'LIGHTING';
case EquipmentCategory.sound:
return 'SOUND';
case EquipmentCategory.video:
return 'VIDEO';
case EquipmentCategory.structure:
return 'STRUCTURE';
case EquipmentCategory.consumable:
return 'CONSUMABLE';
case EquipmentCategory.cable:
return 'CABLE';
case EquipmentCategory.vehicle:
return 'VEHICLE';
case EquipmentCategory.backline:
return 'BACKLINE';
case EquipmentCategory.other:
return 'OTHER';
case EquipmentCategory.effect:
return 'EFFECT';
}
}
EquipmentCategory equipmentCategoryFromString(String? category) {
switch (category) {
case 'LIGHTING':
return EquipmentCategory.lighting;
case 'SOUND':
return EquipmentCategory.sound;
case 'VIDEO':
return EquipmentCategory.video;
case 'STRUCTURE':
return EquipmentCategory.structure;
case 'CONSUMABLE':
return EquipmentCategory.consumable;
case 'CABLE':
return EquipmentCategory.cable;
case 'VEHICLE':
return EquipmentCategory.vehicle;
case 'BACKLINE':
return EquipmentCategory.backline;
case 'EFFECT':
return EquipmentCategory.effect;
case 'OTHER':
default:
return EquipmentCategory.other;
}
}
// Extensions pour centraliser les informations d'affichage
extension EquipmentCategoryExtension on EquipmentCategory {
/// Retourne le label français de la catégorie
String get label {
switch (this) {
case EquipmentCategory.lighting:
return 'Lumière';
case EquipmentCategory.sound:
return 'Son';
case EquipmentCategory.video:
return 'Vidéo';
case EquipmentCategory.effect:
return 'Effets';
case EquipmentCategory.structure:
return 'Structure';
case EquipmentCategory.consumable:
return 'Consommable';
case EquipmentCategory.cable:
return 'Câble';
case EquipmentCategory.vehicle:
return 'Véhicule';
case EquipmentCategory.backline:
return 'Régie / Backline';
case EquipmentCategory.other:
return 'Autre';
}
}
/// Retourne l'icône Material de la catégorie
IconData get iconData {
switch (this) {
case EquipmentCategory.lighting:
return Icons.light_mode;
case EquipmentCategory.sound:
return Icons.volume_up;
case EquipmentCategory.video:
return Icons.videocam;
case EquipmentCategory.effect:
return Icons.auto_awesome;
case EquipmentCategory.structure:
return Icons.construction;
case EquipmentCategory.consumable:
return Icons.inventory_2;
case EquipmentCategory.cable:
return Icons.cable;
case EquipmentCategory.vehicle:
return Icons.local_shipping;
case EquipmentCategory.backline:
return Icons.piano;
case EquipmentCategory.other:
return Icons.more_horiz;
}
}
/// Retourne la couleur associée à la catégorie
Color get color {
switch (this) {
case EquipmentCategory.lighting:
return Colors.yellow.shade700;
case EquipmentCategory.sound:
return Colors.purple;
case EquipmentCategory.video:
return Colors.blue;
case EquipmentCategory.effect:
return Colors.pink;
case EquipmentCategory.structure:
return Colors.brown;
case EquipmentCategory.consumable:
return Colors.orange;
case EquipmentCategory.cable:
return Colors.grey;
case EquipmentCategory.vehicle:
return Colors.teal;
case EquipmentCategory.backline:
return Colors.indigo;
case EquipmentCategory.other:
return Colors.blueGrey;
}
}
/// Retourne le chemin de l'icône personnalisée (si disponible)
String? get customIconPath {
switch (this) {
case EquipmentCategory.structure:
return 'assets/icons/truss.svg';
case EquipmentCategory.consumable:
return 'assets/icons/tape.svg';
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;
}
}
/// Vérifie si une icône personnalisée est disponible
bool get hasCustomIcon => customIconPath != null;
/// Retourne l'icône Widget à afficher (unifié pour Material et personnalisé)
Widget getIcon({double size = 24, Color? color}) {
final customPath = customIconPath;
if (customPath != null) {
// Détection automatique du format (SVG ou PNG)
final isSvg = customPath.toLowerCase().endsWith('.svg');
if (isSvg) {
// SVG : on peut appliquer la couleur sans dégrader la qualité
return SvgPicture.asset(
customPath,
width: size,
height: size,
colorFilter: color != null
? ColorFilter.mode(color, BlendMode.srcIn)
: null,
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
);
} else {
// PNG : on n'applique PAS le color filter pour préserver la qualité
return Image.asset(
customPath,
width: size,
height: size,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
return Icon(iconData, size: size, color: color);
},
);
}
}
return Icon(iconData, size: size, color: color);
}
/// Version pour CircleAvatar et contextes similaires (sans ColorFilter si Material Icon)
Widget getIconForAvatar({double size = 24, Color? color}) {
final customPath = customIconPath;
if (customPath != null) {
final isSvg = customPath.toLowerCase().endsWith('.svg');
if (isSvg) {
return SvgPicture.asset(
customPath,
width: size,
height: size,
colorFilter: color != null
? ColorFilter.mode(color, BlendMode.srcIn)
: null,
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
);
} else {
return Image.asset(
customPath,
width: size,
height: size,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
return Icon(iconData, size: size, color: color);
},
);
}
}
return Icon(iconData, size: size, color: color);
}
}
extension EquipmentStatusExtension on EquipmentStatus {
/// Retourne le label français du statut
String get label {
switch (this) {
case EquipmentStatus.available:
return 'Disponible';
case EquipmentStatus.inUse:
return 'En prestation';
case EquipmentStatus.rented:
return 'Loué';
case EquipmentStatus.lost:
return 'Perdu';
case EquipmentStatus.outOfService:
return 'HS';
case EquipmentStatus.maintenance:
return 'Maintenance';
}
}
/// Retourne la couleur associée au statut
Color get color {
switch (this) {
case EquipmentStatus.available:
return Colors.green;
case EquipmentStatus.inUse:
return Colors.blue;
case EquipmentStatus.rented:
return Colors.orange;
case EquipmentStatus.lost:
return Colors.red;
case EquipmentStatus.outOfService:
return Colors.red.shade900;
case EquipmentStatus.maintenance:
return Colors.amber;
}
}
}
class EquipmentModel {
final String id; // Identifiant unique (clé)
final String name; // Nom de l'équipement
final String? brand; // Marque (indexé)
final String? model; // Modèle (indexé)
final EquipmentCategory category; // Catégorie
final String? subCategory; // Sous-catégorie (indexé par catégorie)
final EquipmentStatus status; // Statut actuel
// Prix (visible uniquement avec manage_equipment)
final double? purchasePrice; // Prix d'achat
final double? rentalPrice; // Prix de location
// Quantité (pour consommables/câbles)
final int? totalQuantity; // Quantité totale
final int? availableQuantity; // Quantité disponible
final int? criticalThreshold; // Seuil critique pour alerte
// Caractéristiques physiques
final double? weight; // Poids (kg)
final double? length; // Longueur (cm)
final double? width; // Largeur (cm)
final double? height; // Hauteur (cm)
// Dates & maintenance
final DateTime? purchaseDate; // Date d'achat
final DateTime? lastMaintenanceDate; // Dernière maintenance
final DateTime? nextMaintenanceDate; // Prochaine maintenance prévue
// Maintenances (références)
final List<String> maintenanceIds; // IDs des opérations de maintenance
// Image
final String? imageUrl; // URL de l'image (Storage /materiel)
// Métadonnées
final String? notes; // Notes additionnelles
final DateTime createdAt; // Date de création
final DateTime updatedAt; // Date de mise à jour
EquipmentModel({
required this.id,
required this.name,
this.brand,
this.model,
required this.category,
this.subCategory,
this.status = EquipmentStatus.available,
this.purchasePrice,
this.rentalPrice,
this.totalQuantity,
this.availableQuantity,
this.criticalThreshold,
this.weight,
this.length,
this.width,
this.height,
this.purchaseDate,
this.lastMaintenanceDate,
this.nextMaintenanceDate,
this.maintenanceIds = const [],
this.imageUrl,
this.notes,
required this.createdAt,
required this.updatedAt,
});
factory EquipmentModel.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 des listes
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
return EquipmentModel(
id: id,
name: map['name'] ?? '',
brand: map['brand'],
model: map['model'],
category: equipmentCategoryFromString(map['category']),
subCategory: map['subCategory'],
status: equipmentStatusFromString(map['status']),
purchasePrice: map['purchasePrice']?.toDouble(),
rentalPrice: map['rentalPrice']?.toDouble(),
totalQuantity: map['totalQuantity']?.toInt(),
availableQuantity: map['availableQuantity']?.toInt(),
criticalThreshold: map['criticalThreshold']?.toInt(),
weight: map['weight']?.toDouble(),
length: map['length']?.toDouble(),
width: map['width']?.toDouble(),
height: map['height']?.toDouble(),
purchaseDate: _parseDate(map['purchaseDate']),
nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']),
maintenanceIds: maintenanceIds,
imageUrl: map['imageUrl'],
notes: map['notes'],
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
);
}
Map<String, dynamic> toMap() {
return {
'name': name,
'brand': brand,
'model': model,
'category': equipmentCategoryToString(category),
'subCategory': subCategory,
'status': equipmentStatusToString(status),
'purchasePrice': purchasePrice,
'rentalPrice': rentalPrice,
'totalQuantity': totalQuantity,
'availableQuantity': availableQuantity,
'criticalThreshold': criticalThreshold,
'weight': weight,
'length': length,
'width': width,
'height': height,
'lastMaintenanceDate': lastMaintenanceDate != null ? Timestamp.fromDate(lastMaintenanceDate!) : null,
'purchaseDate': purchaseDate != null ? Timestamp.fromDate(purchaseDate!) : null,
'nextMaintenanceDate': nextMaintenanceDate != null ? Timestamp.fromDate(nextMaintenanceDate!) : null,
'maintenanceIds': maintenanceIds,
'imageUrl': imageUrl,
'notes': notes,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
};
}
EquipmentModel copyWith({
String? id,
String? brand,
String? name,
String? model,
EquipmentCategory? category,
String? subCategory,
EquipmentStatus? status,
double? purchasePrice,
double? rentalPrice,
int? totalQuantity,
int? availableQuantity,
int? criticalThreshold,
double? weight,
double? length,
double? width,
double? height,
DateTime? purchaseDate,
DateTime? lastMaintenanceDate,
DateTime? nextMaintenanceDate,
List<String>? maintenanceIds,
String? imageUrl,
String? notes,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return EquipmentModel(
id: id ?? this.id,
brand: brand ?? this.brand,
name: name ?? this.name,
model: model ?? this.model,
category: category ?? this.category,
subCategory: subCategory ?? this.subCategory,
status: status ?? this.status,
purchasePrice: purchasePrice ?? this.purchasePrice,
rentalPrice: rentalPrice ?? this.rentalPrice,
totalQuantity: totalQuantity ?? this.totalQuantity,
availableQuantity: availableQuantity ?? this.availableQuantity,
criticalThreshold: criticalThreshold ?? this.criticalThreshold,
weight: weight ?? this.weight,
length: length ?? this.length,
width: width ?? this.width,
height: height ?? this.height,
lastMaintenanceDate: lastMaintenanceDate ?? this.lastMaintenanceDate,
purchaseDate: purchaseDate ?? this.purchaseDate,
nextMaintenanceDate: nextMaintenanceDate ?? this.nextMaintenanceDate,
maintenanceIds: maintenanceIds ?? this.maintenanceIds,
imageUrl: imageUrl ?? this.imageUrl,
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Helper pour vérifier si c'est un consommable/câble avec quantité
bool get hasQuantity => category == EquipmentCategory.consumable || category == EquipmentCategory.cable;
// Helper pour vérifier si le stock est critique
bool get isCriticalStock {
if (!hasQuantity || criticalThreshold == null || availableQuantity == null) {
return false;
}
return availableQuantity! <= criticalThreshold!;
}
// Helper pour vérifier si la maintenance est à venir
bool get isMaintenanceDue {
if (nextMaintenanceDate == null) return false;
return nextMaintenanceDate!.isBefore(DateTime.now().add(const Duration(days: 7)));
}
}

View File

@@ -1,5 +1,4 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:latlong2/latlong.dart';
enum EventStatus { enum EventStatus {
confirmed, confirmed,
@@ -14,7 +13,6 @@ String eventStatusToString(EventStatus status) {
case EventStatus.canceled: case EventStatus.canceled:
return 'CANCELED'; return 'CANCELED';
case EventStatus.waitingForApproval: case EventStatus.waitingForApproval:
default:
return 'WAITING_FOR_APPROVAL'; return 'WAITING_FOR_APPROVAL';
} }
} }
@@ -31,6 +29,258 @@ EventStatus eventStatusFromString(String? status) {
} }
} }
enum PreparationStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String preparationStatusToString(PreparationStatus status) {
switch (status) {
case PreparationStatus.notStarted:
return 'NOT_STARTED';
case PreparationStatus.inProgress:
return 'IN_PROGRESS';
case PreparationStatus.completed:
return 'COMPLETED';
case PreparationStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
PreparationStatus preparationStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return PreparationStatus.notStarted;
case 'IN_PROGRESS':
return PreparationStatus.inProgress;
case 'COMPLETED':
return PreparationStatus.completed;
case 'COMPLETED_WITH_MISSING':
return PreparationStatus.completedWithMissing;
default:
return PreparationStatus.notStarted;
}
}
// Statut de chargement (loading)
enum LoadingStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String loadingStatusToString(LoadingStatus status) {
switch (status) {
case LoadingStatus.notStarted:
return 'NOT_STARTED';
case LoadingStatus.inProgress:
return 'IN_PROGRESS';
case LoadingStatus.completed:
return 'COMPLETED';
case LoadingStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
LoadingStatus loadingStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return LoadingStatus.notStarted;
case 'IN_PROGRESS':
return LoadingStatus.inProgress;
case 'COMPLETED':
return LoadingStatus.completed;
case 'COMPLETED_WITH_MISSING':
return LoadingStatus.completedWithMissing;
default:
return LoadingStatus.notStarted;
}
}
// Statut de déchargement (unloading)
enum UnloadingStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String unloadingStatusToString(UnloadingStatus status) {
switch (status) {
case UnloadingStatus.notStarted:
return 'NOT_STARTED';
case UnloadingStatus.inProgress:
return 'IN_PROGRESS';
case UnloadingStatus.completed:
return 'COMPLETED';
case UnloadingStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
UnloadingStatus unloadingStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return UnloadingStatus.notStarted;
case 'IN_PROGRESS':
return UnloadingStatus.inProgress;
case 'COMPLETED':
return UnloadingStatus.completed;
case 'COMPLETED_WITH_MISSING':
return UnloadingStatus.completedWithMissing;
default:
return UnloadingStatus.notStarted;
}
}
enum ReturnStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String returnStatusToString(ReturnStatus status) {
switch (status) {
case ReturnStatus.notStarted:
return 'NOT_STARTED';
case ReturnStatus.inProgress:
return 'IN_PROGRESS';
case ReturnStatus.completed:
return 'COMPLETED';
case ReturnStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
ReturnStatus returnStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return ReturnStatus.notStarted;
case 'IN_PROGRESS':
return ReturnStatus.inProgress;
case 'COMPLETED':
return ReturnStatus.completed;
case 'COMPLETED_WITH_MISSING':
return ReturnStatus.completedWithMissing;
default:
return ReturnStatus.notStarted;
}
}
class EventEquipment {
final String equipmentId; // ID de l'équipement
final int quantity; // Quantité initiale assignée
final bool isPrepared; // Validé en préparation
final bool isLoaded; // Validé au chargement
final bool isUnloaded; // Validé au déchargement
final bool isReturned; // Validé au retour
// 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({
required this.equipmentId,
this.quantity = 1,
this.isPrepared = false,
this.isLoaded = false,
this.isUnloaded = false,
this.isReturned = false,
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) {
return EventEquipment(
equipmentId: map['equipmentId'] ?? '',
quantity: map['quantity'] ?? 1,
isPrepared: map['isPrepared'] ?? false,
isLoaded: map['isLoaded'] ?? false,
isUnloaded: map['isUnloaded'] ?? false,
isReturned: map['isReturned'] ?? false,
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'],
);
}
Map<String, dynamic> toMap() {
return {
'equipmentId': equipmentId,
'quantity': quantity,
'isPrepared': isPrepared,
'isLoaded': isLoaded,
'isUnloaded': isUnloaded,
'isReturned': isReturned,
'isMissingAtPreparation': isMissingAtPreparation,
'isMissingAtLoading': isMissingAtLoading,
'isMissingAtUnloading': isMissingAtUnloading,
'isMissingAtReturn': isMissingAtReturn,
'quantityAtPreparation': quantityAtPreparation,
'quantityAtLoading': quantityAtLoading,
'quantityAtUnloading': quantityAtUnloading,
'quantityAtReturn': quantityAtReturn,
};
}
EventEquipment copyWith({
String? equipmentId,
int? quantity,
bool? isPrepared,
bool? isLoaded,
bool? isUnloaded,
bool? isReturned,
bool? isMissingAtPreparation,
bool? isMissingAtLoading,
bool? isMissingAtUnloading,
bool? isMissingAtReturn,
int? quantityAtPreparation,
int? quantityAtLoading,
int? quantityAtUnloading,
int? quantityAtReturn,
}) {
return EventEquipment(
equipmentId: equipmentId ?? this.equipmentId,
quantity: quantity ?? this.quantity,
isPrepared: isPrepared ?? this.isPrepared,
isLoaded: isLoaded ?? this.isLoaded,
isUnloaded: isUnloaded ?? this.isUnloaded,
isReturned: isReturned ?? this.isReturned,
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,
);
}
}
class EventModel { class EventModel {
final String id; final String id;
final String name; final String name;
@@ -41,15 +291,29 @@ class EventModel {
final int installationTime; final int installationTime;
final int disassemblyTime; final int disassemblyTime;
final String eventTypeId; final String eventTypeId;
final DocumentReference? eventTypeRef;
final String customerId; final String customerId;
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;
// Champs de contact
final int? jauge;
final String? contactEmail;
final String? contactPhone;
// Nouveaux champs pour la gestion du matériel
final List<EventEquipment> assignedEquipment;
final List<String> assignedContainers; // IDs des conteneurs assignés
final PreparationStatus? preparationStatus;
final LoadingStatus? loadingStatus;
final UnloadingStatus? unloadingStatus;
final ReturnStatus? returnStatus;
EventModel({ EventModel({
required this.id, required this.id,
required this.name, required this.name,
@@ -60,6 +324,7 @@ class EventModel {
required this.installationTime, required this.installationTime,
required this.disassemblyTime, required this.disassemblyTime,
required this.eventTypeId, required this.eventTypeId,
this.eventTypeRef,
required this.customerId, required this.customerId,
required this.address, required this.address,
required this.latitude, required this.latitude,
@@ -68,62 +333,197 @@ class EventModel {
required this.documents, required this.documents,
this.options = const [], this.options = const [],
this.status = EventStatus.waitingForApproval, this.status = EventStatus.waitingForApproval,
this.jauge,
this.contactEmail,
this.contactPhone,
this.assignedEquipment = const [],
this.assignedContainers = const [],
this.preparationStatus,
this.loadingStatus,
this.unloadingStatus,
this.returnStatus,
}); });
factory EventModel.fromMap(Map<String, dynamic> map, String id) { factory EventModel.fromMap(Map<String, dynamic> map, String id) {
final List<dynamic> workforceRefs = map['workforce'] ?? []; try {
final Timestamp? startTimestamp = map['StartDateTime'] as Timestamp?; // Fonction helper pour convertir Timestamp ou String ISO en DateTime
final Timestamp? endTimestamp = map['EndDateTime'] as Timestamp?; 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
final List<dynamic> workforceRefs = map['workforce'] ?? [];
final List<dynamic> safeWorkforce = [];
for (var ref in workforceRefs) {
if (ref is DocumentReference) {
safeWorkforce.add(ref);
} else if (ref is String) {
// Accepter directement les UIDs (envoyés par le backend)
safeWorkforce.add(ref);
} else {
print('Warning: Invalid workforce reference in event $id: $ref');
}
}
// Gestion sécurisée des timestamps avec support ISO string
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
// Gestion sécurisée des documents
final docsRaw = map['documents'] ?? []; final docsRaw = map['documents'] ?? [];
final docs = docsRaw is List final List<Map<String, String>> docs = [];
? docsRaw.map<Map<String, String>>((e) {
if (docsRaw is List) {
for (var e in docsRaw) {
try {
if (e is Map) { if (e is Map) {
return Map<String, String>.from(e as Map); docs.add(Map<String, String>.from(e));
} else if (e is String) { } else if (e is String) {
final fileName = Uri.decodeComponent( final fileName = Uri.decodeComponent(
e.split('/').last.split('?').first, e.split('/').last.split('?').first,
); );
return {'name': fileName, 'url': e}; docs.add({'name': fileName, 'url': e});
} else {
return {};
} }
}).toList() } catch (docError) {
: <Map<String, String>>[]; print('Warning: Failed to parse document in event $id: $docError');
}
}
}
// Gestion sécurisée des options
final optionsRaw = map['options'] ?? []; final optionsRaw = map['options'] ?? [];
final options = optionsRaw is List final List<Map<String, dynamic>> options = [];
? optionsRaw.map<Map<String, dynamic>>((e) {
if (optionsRaw is List) {
for (var e in optionsRaw) {
try {
if (e is Map) { if (e is Map) {
return Map<String, dynamic>.from(e as Map); options.add(Map<String, dynamic>.from(e));
} else {
return {};
} }
}).toList() } catch (optionError) {
: <Map<String, dynamic>>[]; print('Warning: Failed to parse option in event $id: $optionError');
}
}
}
// Gestion sécurisée de l'EventType
String eventTypeId = '';
DocumentReference? eventTypeRef;
if (map['EventType'] is DocumentReference) {
eventTypeRef = map['EventType'] as DocumentReference;
eventTypeId = eventTypeRef.id;
} else if (map['EventType'] is 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
String customerId = '';
if (map['customer'] is DocumentReference) {
customerId = (map['customer'] as DocumentReference).id;
} else if (map['customer'] is 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
final assignedEquipmentRaw = map['assignedEquipment'] ?? [];
final List<EventEquipment> assignedEquipment = [];
if (assignedEquipmentRaw is List) {
for (var e in assignedEquipmentRaw) {
try {
if (e is Map) {
assignedEquipment.add(EventEquipment.fromMap(Map<String, dynamic>.from(e)));
}
} catch (equipmentError) {
print('Warning: Failed to parse equipment in event $id: $equipmentError');
}
}
}
// Gestion des conteneurs assignés
final assignedContainersRaw = map['assignedContainers'] ?? [];
final List<String> assignedContainers = [];
if (assignedContainersRaw is List) {
for (var e in assignedContainersRaw) {
if (e is String) {
assignedContainers.add(e);
}
}
}
return EventModel( return EventModel(
id: id, id: id,
name: map['Name'] ?? '', name: (map['Name'] ?? '').toString().trim(),
description: map['Description'] ?? '', description: (map['Description'] ?? '').toString(),
startDateTime: startTimestamp?.toDate() ?? DateTime.now(), startDateTime: startDate,
endDateTime: endTimestamp?.toDate() ?? endDateTime: endDate,
DateTime.now().add(const Duration(hours: 1)), basePrice: _parseDouble(map['BasePrice'] ?? map['Price'] ?? 0.0),
basePrice: (map['BasePrice'] ?? map['Price'] ?? 0.0).toDouble(), installationTime: _parseInt(map['InstallationTime'] ?? 0),
installationTime: map['InstallationTime'] ?? 0, assignedContainers: assignedContainers,
disassemblyTime: map['DisassemblyTime'] ?? 0, disassemblyTime: _parseInt(map['DisassemblyTime'] ?? 0),
eventTypeId: map['EventType'] is DocumentReference eventTypeId: eventTypeId,
? (map['EventType'] as DocumentReference).id eventTypeRef: eventTypeRef,
: '', customerId: customerId,
customerId: map['customer'] is DocumentReference address: (map['Address'] ?? '').toString(),
? (map['customer'] as DocumentReference).id latitude: _parseDouble(map['Latitude'] ?? 0.0),
: '', longitude: _parseDouble(map['Longitude'] ?? 0.0),
address: map['Address'] ?? '', workforce: safeWorkforce,
latitude: (map['Latitude'] ?? 0.0).toDouble(),
longitude: (map['Longitude'] ?? 0.0).toDouble(),
workforce: workforceRefs.whereType<DocumentReference>().toList(),
documents: docs, documents: docs,
options: options, options: options,
status: eventStatusFromString(map['status'] as String?), status: eventStatusFromString(map['status'] as String?),
jauge: map['jauge'] != null ? _parseInt(map['jauge']) : null,
contactEmail: map['contactEmail']?.toString(),
contactPhone: map['contactPhone']?.toString(),
assignedEquipment: assignedEquipment,
preparationStatus: preparationStatusFromString(map['preparationStatus'] as String?),
loadingStatus: loadingStatusFromString(map['loadingStatus'] as String?),
unloadingStatus: unloadingStatusFromString(map['unloadingStatus'] as String?),
returnStatus: returnStatusFromString(map['returnStatus'] as String?),
); );
} catch (e) {
print('Error parsing event $id: $e');
print('Event data: $map');
rethrow;
}
}
// Méthodes utilitaires pour le parsing sécurisé
static double _parseDouble(dynamic value) {
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is String) {
final parsed = double.tryParse(value);
if (parsed != null) return parsed;
}
return 0.0;
}
static int _parseInt(dynamic value) {
if (value is int) return value;
if (value is double) return value.toInt();
if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) return parsed;
}
return 0;
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
@@ -135,8 +535,10 @@ class EventModel {
'BasePrice': basePrice, 'BasePrice': basePrice,
'InstallationTime': installationTime, 'InstallationTime': installationTime,
'DisassemblyTime': disassemblyTime, 'DisassemblyTime': disassemblyTime,
'EventType': eventTypeId, // Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
'customer': customerId, 'EventType': eventTypeId.isNotEmpty ? eventTypeId : null,
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
'customer': customerId.isNotEmpty ? customerId : null,
'Address': address, 'Address': address,
'Position': GeoPoint(latitude, longitude), 'Position': GeoPoint(latitude, longitude),
'Latitude': latitude, 'Latitude': latitude,
@@ -145,6 +547,75 @@ class EventModel {
'documents': documents, 'documents': documents,
'options': options, 'options': options,
'status': eventStatusToString(status), 'status': eventStatusToString(status),
'jauge': jauge,
'contactEmail': contactEmail,
'contactPhone': contactPhone,
'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(),
'assignedContainers': assignedContainers,
'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null,
'loadingStatus': loadingStatus != null ? loadingStatusToString(loadingStatus!) : null,
'unloadingStatus': unloadingStatus != null ? unloadingStatusToString(unloadingStatus!) : 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

@@ -0,0 +1,40 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class EventTypeModel {
final String id;
final String name;
final double defaultPrice;
final DateTime createdAt;
EventTypeModel({
required this.id,
required this.name,
required this.defaultPrice,
required this.createdAt,
});
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(
id: id,
name: map['name'] ?? '',
defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(),
createdAt: parseCreatedAt(map['createdAt']),
);
}
Map<String, dynamic> toMap() {
return {
'name': name,
'defaultPrice': defaultPrice,
'createdAt': createdAt,
};
}
}

View File

@@ -0,0 +1,146 @@
import 'package:cloud_firestore/cloud_firestore.dart';
enum MaintenanceType {
preventive, // Préventive
corrective, // Corrective
inspection // Inspection
}
String maintenanceTypeToString(MaintenanceType type) {
switch (type) {
case MaintenanceType.preventive:
return 'PREVENTIVE';
case MaintenanceType.corrective:
return 'CORRECTIVE';
case MaintenanceType.inspection:
return 'INSPECTION';
}
}
MaintenanceType maintenanceTypeFromString(String? type) {
switch (type) {
case 'PREVENTIVE':
return MaintenanceType.preventive;
case 'CORRECTIVE':
return MaintenanceType.corrective;
case 'INSPECTION':
return MaintenanceType.inspection;
default:
return MaintenanceType.preventive;
}
}
class MaintenanceModel {
final String id; // ID aléatoire
final List<String> equipmentIds; // IDs des équipements concernés (peut être multiple)
final MaintenanceType type; // Type de maintenance
final DateTime scheduledDate; // Date planifiée
final DateTime? completedDate; // Date de réalisation (null si pas encore effectuée)
final String name; // Nom de l'opération
final String description; // Description détaillée
final String? performedBy; // ID de l'utilisateur qui a effectué la maintenance
final double? cost; // Coût de la maintenance
final String? notes; // Notes additionnelles
final DateTime createdAt; // Date de création
final DateTime updatedAt; // Date de mise à jour
MaintenanceModel({
required this.id,
required this.equipmentIds,
required this.type,
required this.scheduledDate,
this.completedDate,
required this.name,
required this.description,
this.performedBy,
this.cost,
this.notes,
required this.createdAt,
required this.updatedAt,
});
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
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
return MaintenanceModel(
id: id,
equipmentIds: equipmentIds,
type: maintenanceTypeFromString(map['type']),
scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(),
completedDate: _parseDate(map['completedDate']),
name: map['name'] ?? '',
description: map['description'] ?? '',
performedBy: map['performedBy'],
cost: map['cost']?.toDouble(),
notes: map['notes'],
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
);
}
Map<String, dynamic> toMap() {
return {
'equipmentIds': equipmentIds,
'type': maintenanceTypeToString(type),
'scheduledDate': Timestamp.fromDate(scheduledDate),
'completedDate': completedDate != null ? Timestamp.fromDate(completedDate!) : null,
'name': name,
'description': description,
'performedBy': performedBy,
'cost': cost,
'notes': notes,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
};
}
MaintenanceModel copyWith({
String? id,
List<String>? equipmentIds,
MaintenanceType? type,
DateTime? scheduledDate,
DateTime? completedDate,
String? name,
String? description,
String? performedBy,
double? cost,
String? notes,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return MaintenanceModel(
id: id ?? this.id,
equipmentIds: equipmentIds ?? this.equipmentIds,
type: type ?? this.type,
scheduledDate: scheduledDate ?? this.scheduledDate,
completedDate: completedDate ?? this.completedDate,
name: name ?? this.name,
description: description ?? this.description,
performedBy: performedBy ?? this.performedBy,
cost: cost ?? this.cost,
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Helper pour vérifier si la maintenance est complétée
bool get isCompleted => completedDate != null;
// Helper pour vérifier si la maintenance est en retard
bool get isOverdue {
if (isCompleted) return false;
return scheduledDate.isBefore(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,42 +1,48 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class EventOption { class EventOption {
final String id; final String id;
final String code; // Nouveau champ code
final String name; final String name;
final String details; final String details;
final double valMin; final double valMin;
final double valMax; final double valMax;
final List<DocumentReference> eventTypes; final List<String> eventTypes; // Changé de List<DocumentReference> à List<String>
final bool isQuantitative; // Indique si l'option peut avoir une quantité
EventOption({ EventOption({
required this.id, required this.id,
required this.code,
required this.name, required this.name,
required this.details, required this.details,
required this.valMin, required this.valMin,
required this.valMax, required this.valMax,
required this.eventTypes, required this.eventTypes,
this.isQuantitative = false,
}); });
factory EventOption.fromMap(Map<String, dynamic> map, String id) { factory EventOption.fromMap(Map<String, dynamic> map, String id) {
return EventOption( return EventOption(
id: id, id: id,
code: map['code'] ?? id, // Utilise le code ou l'ID en fallback
name: map['name'] ?? '', name: map['name'] ?? '',
details: map['details'] ?? '', details: map['details'] ?? '',
valMin: (map['valMin'] ?? 0.0).toDouble(), valMin: (map['valMin'] ?? 0.0).toDouble(),
valMax: (map['valMax'] ?? 0.0).toDouble(), valMax: (map['valMax'] ?? 0.0).toDouble(),
eventTypes: (map['eventTypes'] as List<dynamic>? ?? []) eventTypes: (map['eventTypes'] as List<dynamic>? ?? [])
.whereType<DocumentReference>() .map((e) => e.toString()) // Convertit en String (supporte IDs et références)
.toList(), .toList(),
isQuantitative: map['isQuantitative'] ?? false,
); );
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'code': code,
'name': name, 'name': name,
'details': details, 'details': details,
'valMin': valMin, 'valMin': valMin,
'valMax': valMax, 'valMax': valMax,
'eventTypes': eventTypes, 'eventTypes': eventTypes,
'isQuantitative': isQuantitative,
}; };
} }
} }

View File

@@ -1,4 +1,3 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class RoleModel { class RoleModel {
final String id; final String id;

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

@@ -0,0 +1,117 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/alert_model.dart';
import 'package:em2rp/services/api_service.dart';
class AlertProvider extends ChangeNotifier {
final ApiService _apiService = apiService;
List<AlertModel> _alerts = [];
bool _isLoading = false;
// Getters
List<AlertModel> get alerts => _alerts;
bool get isLoading => _isLoading;
/// Nombre d'alertes non lues
int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
/// Alertes non lues uniquement
List<AlertModel> get unreadAlerts => _alerts.where((alert) => !alert.isRead).toList();
/// Alertes de stock critique
List<AlertModel> get lowStockAlerts => _alerts.where((alert) => alert.type == AlertType.lowStock).toList();
/// Alertes de maintenance
List<AlertModel> get maintenanceAlerts => _alerts.where((alert) => alert.type == AlertType.maintenanceDue).toList();
/// Alertes de conflit
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
/// Charger toutes les alertes via Cloud Function
Future<void> loadAlerts() async {
_isLoading = true;
notifyListeners();
try {
final result = await _apiService.call('getAlerts', {});
final alertsData = result['alerts'] as List<dynamic>;
_alerts = alertsData.map((data) {
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 via Cloud Function
Future<void> markAsRead(String alertId) async {
try {
await _apiService.call('markAlertAsRead', {'alertId': alertId});
// 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();
}
} catch (e) {
print('Error marking alert as read: $e');
rethrow;
}
}
/// Supprimer une alerte via Cloud Function
Future<void> deleteAlert(String alertId) async {
try {
await _apiService.call('deleteAlert', {'alertId': alertId});
// Supprimer localement
_alerts.removeWhere((a) => a.id == alertId);
notifyListeners();
} catch (e) {
print('Error deleting alert: $e');
rethrow;
}
}
/// Marquer toutes les alertes comme lues
Future<void> markAllAsRead() async {
try {
final unreadAlertIds = _alerts.where((a) => !a.isRead).map((a) => a.id).toList();
for (final alertId in unreadAlertIds) {
await markAsRead(alertId);
}
} catch (e) {
print('Error marking all alerts as read: $e');
rethrow;
}
}
/// Supprimer toutes les alertes lues via Cloud Function
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) {
print('Error deleting read alerts: $e');
rethrow;
}
}
}

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

@@ -0,0 +1,452 @@
import 'package:flutter/foundation.dart';
import 'dart:async';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.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 {
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;
EquipmentStatus? _selectedStatus;
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;
EquipmentStatus? get selectedStatus => _selectedStatus;
String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
bool get isInitialized => _isInitialized;
bool get usePagination => _usePagination;
/// S'assure que les conteneurs sont chargés (charge si nécessaire)
Future<void> ensureLoaded() async {
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,
status: _selectedStatus,
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é
void setSelectedType(ContainerType? type) async {
if (_selectedType == type) return;
_selectedType = type;
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
}
/// Définir le statut sélectionné
void setSelectedStatus(EquipmentStatus? status) async {
if (_selectedStatus == status) return;
_selectedStatus = status;
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();
}
// ============================================================================
// 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
Future<void> createContainer(ContainerModel container) async {
await _containerService.createContainer(container);
notifyListeners();
}
/// Mettre à jour un container
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
await _containerService.updateContainer(id, data);
notifyListeners();
}
/// Supprimer un container
Future<void> deleteContainer(String id) async {
await _containerService.deleteContainer(id);
notifyListeners();
}
/// Récupérer un container par ID
Future<ContainerModel?> getContainerById(String id) async {
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
Future<Map<String, dynamic>> addEquipmentToContainer({
required String containerId,
required String equipmentId,
String? userId,
}) async {
final result = await _containerService.addEquipmentToContainer(
containerId: containerId,
equipmentId: equipmentId,
userId: userId,
);
notifyListeners();
return result;
}
/// Retirer un équipement d'un container
Future<void> removeEquipmentFromContainer({
required String containerId,
required String equipmentId,
String? userId,
}) async {
await _containerService.removeEquipmentFromContainer(
containerId: containerId,
equipmentId: equipmentId,
userId: userId,
);
notifyListeners();
}
/// Vérifier la disponibilité d'un container
Future<Map<String, dynamic>> checkContainerAvailability({
required String containerId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
return await _containerService.checkContainerAvailability(
containerId: containerId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
}
/// Récupérer les équipements d'un container
Future<List<EquipmentModel>> getContainerEquipment(String containerId) async {
return await _containerService.getContainerEquipment(containerId);
}
/// Trouver tous les containers contenant un équipement
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
return await _containerService.findContainersWithEquipment(equipmentId);
}
/// Vérifier si un ID existe
Future<bool> checkContainerIdExists(String id) async {
return await _containerService.checkContainerIdExists(id);
}
/// Générer un ID unique pour un container
/// Format: BOX_{TYPE}_{NAME}_{NUMBER}
static String generateContainerId({
required ContainerType type,
required String name,
int? number,
}) {
// Obtenir le type en majuscules
final typeStr = containerTypeToString(type);
// Nettoyer le nom (enlever espaces, caractères spéciaux)
final cleanName = name
.replaceAll(' ', '_')
.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '')
.toUpperCase();
if (number != null) {
return 'BOX_${typeStr}_${cleanName}_#$number';
}
return 'BOX_${typeStr}_$cleanName';
}
/// Assurer l'unicité d'un ID de container
static Future<String> ensureUniqueContainerId(
String baseId,
ContainerService service,
) async {
String uniqueId = baseId;
int counter = 1;
while (await service.checkContainerIdExists(uniqueId)) {
uniqueId = '${baseId}_$counter';
counter++;
}
return uniqueId;
}
}

View File

@@ -0,0 +1,533 @@
import 'package:flutter/foundation.dart';
import 'dart:async';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
class EquipmentProvider extends ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
// 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<String> _models = [];
List<String> _brands = [];
// Filtres et recherche
EquipmentCategory? _selectedCategory;
EquipmentStatus? _selectedStatus;
String? _selectedModel;
String _searchQuery = '';
bool _isLoading = false;
bool _isInitialized = false;
// Mode de chargement (pagination vs full)
bool _usePagination = false;
EquipmentProvider();
// Getters
List<EquipmentModel> get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment;
List<EquipmentModel> get allEquipment => _equipment;
List<String> get models => _models;
List<String> get brands => _brands;
EquipmentCategory? get selectedCategory => _selectedCategory;
EquipmentStatus? get selectedStatus => _selectedStatus;
String? get selectedModel => _selectedModel;
String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
bool get isInitialized => _isInitialized;
bool get usePagination => _usePagination;
/// S'assure que les équipements sont chargés (charge si nécessaire)
Future<void> ensureLoaded() async {
// Si déjà en train de charger, attendre
if (_isLoading) {
print('[EquipmentProvider] Equipment loading in progress, waiting...');
return;
}
// Si initialisé MAIS _equipment est vide, forcer le rechargement
if (_isInitialized && _equipment.isEmpty) {
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
_isInitialized = false; // Réinitialiser le flag
await loadEquipments();
return;
}
// Si déjà initialisé avec des données, ne rien faire
if (_isInitialized) {
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');
}
}
// Extraire les modèles et marques uniques
_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 {
// 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();
} catch (e) {
DebugLog.error('[EquipmentProvider] Error loading next page', e);
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharge en changeant de filtre ou recherche
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 {
await _dataService.deleteEquipment(equipmentId);
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) {
DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
rethrow;
}
}
/// Ajouter un équipement
Future<void> addEquipment(EquipmentModel equipment) async {
try {
await _dataService.createEquipment(equipment.id, equipment.toMap());
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) {
DebugLog.error('[EquipmentProvider] Error adding equipment', e);
rethrow;
}
}
/// Mettre à jour un équipement
Future<void> updateEquipment(EquipmentModel equipment) async {
try {
await _dataService.updateEquipment(equipment.id, equipment.toMap());
if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) {
DebugLog.error('[EquipmentProvider] Error updating equipment', e);
rethrow;
}
}
/// Charger les marques
Future<void> loadBrands() async {
await ensureLoaded();
_extractUniqueValues();
}
/// 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
}
}
// Sinon retourner le statut de base
return equipment.status;
}
}

View File

@@ -1,43 +1,59 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/event_model.dart'; import 'package:cloud_firestore/cloud_firestore.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();
// Sauvegarder les paramètres
_saveLastLoadParams(userId, canViewAllEvents);
try { try {
print( print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
QuerySnapshot eventsSnapshot; // Charger via l'API - les permissions sont vérifiées côté serveur
if (canViewAllEvents) { final result = await _dataService.getEvents(userId: userId);
eventsSnapshot = await _firestore.collection('events').get(); final eventsData = result['events'] as List<Map<String, dynamic>>;
} else { final usersData = result['users'] as Map<String, dynamic>;
eventsSnapshot = await _firestore
.collection('events') // Stocker les utilisateurs dans le cache
.where('workforce', _usersCache = usersData.map((key, value) =>
arrayContains: _firestore.collection('users').doc(userId)) MapEntry(key, value as Map<String, dynamic>)
.get(); );
print('Found ${eventsData.length} events from API');
List<EventModel> allEvents = [];
int failedCount = 0;
// Parser chaque événement
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(eventData, eventData['id'] as String);
allEvents.add(event);
} catch (e) {
print('Failed to parse event ${eventData['id']}: $e');
failedCount++;
}
} }
print('Found ${eventsSnapshot.docs.length} events for user'); _events = allEvents;
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
_events = eventsSnapshot.docs.map((doc) {
print('Event data: ${doc.data()}');
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
}).toList();
print('Parsed ${_events.length} events');
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
@@ -49,37 +65,35 @@ class EventProvider with ChangeNotifier {
} }
} }
// 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;
@@ -91,10 +105,10 @@ 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);
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
@@ -103,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

@@ -0,0 +1,106 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/maintenance_model.dart';
import 'package:em2rp/services/maintenance_service.dart';
class MaintenanceProvider extends ChangeNotifier {
final MaintenanceService _service = MaintenanceService();
List<MaintenanceModel> _maintenances = [];
// Getters
List<MaintenanceModel> get maintenances => _maintenances;
/// Récupérer les maintenances pour un équipement spécifique
Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async {
return await _service.getMaintenancesByEquipment(equipmentId);
}
/// Récupérer toutes les maintenances
Future<List<MaintenanceModel>> getAllMaintenances() async {
return await _service.getAllMaintenances();
}
/// Créer une nouvelle maintenance
Future<void> createMaintenance(MaintenanceModel maintenance) async {
try {
await _service.createMaintenance(maintenance);
notifyListeners();
} catch (e) {
print('Error creating maintenance: $e');
rethrow;
}
}
/// Mettre à jour une maintenance
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
try {
await _service.updateMaintenance(id, data);
notifyListeners();
} catch (e) {
print('Error updating maintenance: $e');
rethrow;
}
}
/// Supprimer une maintenance
Future<void> deleteMaintenance(String id) async {
try {
await _service.deleteMaintenance(id);
notifyListeners();
} catch (e) {
print('Error deleting maintenance: $e');
rethrow;
}
}
/// Récupérer une maintenance par ID
Future<MaintenanceModel?> getMaintenanceById(String id) async {
try {
return await _service.getMaintenanceById(id);
} catch (e) {
print('Error getting maintenance: $e');
rethrow;
}
}
/// Marquer une maintenance comme complétée
Future<void> completeMaintenance(
String id, {
String? performedBy,
double? cost,
}) async {
try {
await _service.completeMaintenance(id, performedBy: performedBy, cost: cost);
notifyListeners();
} catch (e) {
print('Error completing maintenance: $e');
rethrow;
}
}
/// Vérifier les maintenances à venir
Future<void> checkUpcomingMaintenances() async {
try {
await _service.checkUpcomingMaintenances();
} catch (e) {
print('Error checking upcoming maintenances: $e');
rethrow;
}
}
/// Récupérer les maintenances en retard
List<MaintenanceModel> get overdueMaintances {
return _maintenances.where((m) => m.isOverdue).toList();
}
/// Récupérer les maintenances complétées
List<MaintenanceModel> get completedMaintenances {
return _maintenances.where((m) => m.isCompleted).toList();
}
/// Récupérer les maintenances à venir
List<MaintenanceModel> get upcomingMaintenances {
return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList();
}
}

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,55 +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:flutter/foundation.dart'; import 'package:em2rp/models/user_model.dart';
import 'package:provider/provider.dart'; import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/providers/local_user_provider.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) {
@@ -62,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) {
@@ -74,97 +73,44 @@ class UsersProvider with ChangeNotifier {
} }
} }
/// Créer un utilisateur avec invitation par email
Future<void> createUserWithEmailInvite({
required String email,
required String firstName,
required String lastName,
String? phoneNumber,
required String roleId,
}) async {
try {
print('Creating user with email invite: $email');
// Appeler la Cloud Function pour créer l'utilisateur
await _dataService.createUserWithInvite(
email: email,
firstName: firstName,
lastName: lastName,
phoneNumber: phoneNumber,
roleId: roleId,
);
// Recharger la liste des utilisateurs
await fetchUsers();
print('User created successfully: $email');
} catch (e) {
print('Error creating user with email invite: $e');
rethrow;
}
}
/// Réinitialisation du mot de passe /// Réinitialisation du mot de passe
Future<void> resetPassword(String email) async { Future<void> resetPassword(String email) async {
await _userService.resetPassword(email);
}
Future<void> createUserWithEmailInvite(BuildContext context, UserModel user,
{String? roleId}) async {
String? authUid;
try { try {
// Vérifier l'état de l'authentification // Firebase Auth reste OK (ce n'est pas Firestore)
final currentUser = _auth.currentUser; await FirebaseAuth.instance.sendPasswordResetEmail(email: email);
print('Current user: ${currentUser?.email}'); print('Email de réinitialisation envoyé à $email');
if (currentUser == null) {
throw Exception('Aucun utilisateur connecté');
}
// Vérifier la permission via le provider
final localUserProvider =
Provider.of<LocalUserProvider>(context, listen: false);
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;
print('User created in Auth with UID: $authUid');
// Créer le document dans Firestore avec l'UID de Auth comme ID
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://localhost:63337/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 reset password: $e');
if (authUid != null) {
try {
await _auth.currentUser?.delete();
} catch (deleteError) {
print('Warning: Could not delete Auth user: $deleteError');
}
}
rethrow;
}
} catch (e) {
print('Error creating user: $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}';
}
}

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