24 Commits

Author SHA1 Message Date
ElPoyo ea1e1335e3 feat: implement comprehensive Firebase Functions backend for equipment management and migrate core repository services 2026-05-26 15:35:48 +02:00
ElPoyo 323df01afe style: add margins to cards, move quantity display to badge column with identical size, and implement long press to edit 2026-05-26 15:01:09 +02:00
ElPoyo 854b0a9bb0 style: redesign equipment list cards to improve spacing and center-align the availability badge 2026-05-26 14:54:38 +02:00
ElPoyo f8f6cfb102 bugfix: resolve runtime issues - encoding accents, ListView dynamic heights, notifyListeners exceptions during build phase, and layout overflows 2026-05-26 14:48:39 +02:00
ElPoyo 9bc4e88e46 fix: ajout des imports manquants pour UserModel et AvailabilityConflict suite au refactoring 2026-05-26 13:59:02 +02:00
ElPoyo f56615451e perf: suppression du blocage d'authentification au démarrage et chargement non bloquant de l'utilisateur 2026-05-26 13:57:14 +02:00
ElPoyo 845b6e91d2 perf: suppression du téléchargement massif d'événements côté client (appel de la CF checkContainerAvailability et lecture synchrone des quantités) 2026-05-26 13:50:35 +02:00
ElPoyo 93c102012b perf: optimization des rebuilds (ValueNotifier pour calendrier/container_form, Selector pour pages de gestion et mon compte) 2026-05-26 13:48:50 +02:00
ElPoyo 6ee63ed29c Merge branch 'perf/cleanup' 2026-05-26 13:43:46 +02:00
ElPoyo c35e633568 Merge branch 'perf/search-debounce' into perf/listview-optimization 2026-05-26 13:43:24 +02:00
ElPoyo 4284142b1e perf: ajout de ListView itemExtent/prototypeItem pour l'optimisation des performances 2026-05-26 13:43:18 +02:00
ElPoyo 32f1718a8c perf: ajout d'un Debouncer 400ms sur toutes les barres de recherche 2026-05-26 13:41:21 +02:00
ElPoyo a59deb19a9 perf: nettoyage code mort, sécurisation clé, et remplacement des prints 2026-05-26 13:40:33 +02:00
ElPoyo 0bbc77ffc8 feat: mise à jour de la version de l'application à 1.2.1 et ajout d'un assistant IA pour la gestion des équipements 2026-05-25 23:55:59 +02:00
ElPoyo 19d3dcef69 fix: correction du merge de equipment_selection_dialog.dart (structure invalide) 2026-05-25 23:42:00 +02:00
ElPoyo 32a279e0ae feat: merge branche IA (beta) - Intégration assistant IA logisticien Gemini 2026-05-25 23:35:40 +02:00
ElPoyo 7258509528 feat: amélioration de l'assistant IA logisticien et de la gestion des containers
- **Backend (Cloud Functions)** :
    - Mise à jour de `firebase-functions` vers la version `7.2.5`.
    - Amélioration de la sécurité et de la flexibilité des clés API Gemini (support des variables d'environnement `.env` et `.env.local`).
    - Optimisation de la recherche d'équipements avec une stratégie multi-passes (exacte, par tokens, puis catégorielle/fuzzy).
    - Ajout de nouveaux outils pour l'IA : `check_container_availability` et `check_container_availability_batch` pour vérifier la disponibilité des flight-cases.
    - Implémentation d'un post-traitement automatique suggérant des containers complets si tous leurs équipements internes sont requis par l'événement.
    - Amélioration de la résilience aux erreurs 429/503 de Gemini avec une stratégie d'exponential backoff.

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

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

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

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

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

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

---

### Proposed Commit Message:

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

- Mise à jour du modèle d'IA vers `gemini-3.1-flash-lite-preview` et augmentation de la limite d'itérations des outils à 20.
- Ajout de l'outil `list_equipment_by_category` pour permettre à l'IA d'explorer les alternatives en cas d'échec de recherche textuelle.
- Enrichissement du prompt système et du contexte envoyé à l'IA (nom, lieu, notes et options de l'événement).
- Implémentation de la pagination dans `ContainerFormPage` pour la sélection d'équipements afin d'améliorer les performances.
- Optimisation de `EquipmentSelectionDialog` avec chargement automatique des pages suivantes si les filtres réduisent trop la liste visible.
- Passage à `withValues` pour la gestion des couleurs et amélioration de la gestion des erreurs et du logging.
2026-03-30 12:32:33 +02:00
ElPoyo cf13b4a986 .env gitignore 2026-03-28 21:38:56 +01:00
ElPoyo 84c882ac0b feat: Intégration d'un assistant IA logisticien basé sur Gemini
- Ajout d'une Cloud Function `aiEquipmentProposal` utilisant le modèle Gemini avec function calling pour suggérer du matériel et des containers.
- Implémentation de plusieurs outils (tools) côté serveur pour permettre à l'IA d'interagir avec Firestore : `search_equipment`, `check_availability_batch`, `get_past_events`, `search_event_reference` et `search_containers`.
- Ajout de la dépendance `@google/generative-ai` dans le backend.
- Création d'un service Flutter `AiEquipmentAssistantService` pour communiquer avec la nouvelle Cloud Function.
- Ajout d'une interface de dialogue `AiEquipmentAssistantDialog` permettant aux utilisateurs de discuter avec l'IA pour affiner les propositions de matériel.
- Intégration de l'assistant IA dans la section de gestion du matériel des événements (`EventAssignedEquipmentSection`).
- Mise à jour de `DataService` avec de nouvelles méthodes de recherche et de vérification de disponibilité optimisées pour l'assistant.
- Activation du mode développement et configuration des identifiants de test dans `env.dart`.
- Optimisation des paramètres de la Cloud Function (timeout de 300s et 1GiB de RAM) pour supporter les traitements IA.
2026-03-24 12:00:30 +01:00
79 changed files with 12009 additions and 7478 deletions
+13 -13
View File
@@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1773324020831,d5cd7334d7c3a990dbff0821b9aaab39129803e306b0d96599b8adc6d4f433a6
index.html,1773324025840,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1773324116910,8582e401e070055f59183c207cf7a7e6a9219a50f5089a24a77d91d3ff77dcbc
flutter_bootstrap.js,1773324025827,74eaa66055c715df232ee96fc4114d5473f67717278fb4effa38d8b1b362e303
assets/FontManifest.json,1773324113335,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/AssetManifest.json,1773324113335,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/AssetManifest.bin.json,1773324113335,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/AssetManifest.bin,1773324113335,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773324115847,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1773324113551,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/fonts/MaterialIcons-Regular.otf,1773324115852,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c
assets/NOTICES,1773324113339,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79
main.dart.js,1773324112059,bfc66ab7e817db63dee4b996af3dea0629c4c4e87ba91070c15b133ab5104848
version.json,1779800968600,00f600f01984c1e371af870e40f78fd44ba53d05596f2f92f9b4fc56a85f52b6
index.html,1779800974065,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1779801065196,879f42a05578f24ea45dc23326fdda6246d38dc59de0824ef8d4edfa4715e571
flutter_bootstrap.js,1779800974054,977a20d5caac8da21af648cae8fa7dba00a5cd959fd2fafa7ef538a012fe87c3
assets/FontManifest.json,1779801061892,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/AssetManifest.json,1779801061891,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/AssetManifest.bin.json,1779801061892,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/AssetManifest.bin,1779801061891,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779801064441,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1779801062097,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/fonts/MaterialIcons-Regular.otf,1779801064446,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
assets/NOTICES,1779801061893,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
main.dart.js,1779801060964,e87fb4dfca93c3384b5cb63d627186e48b0c15d78ed63bc7f5e61544ef292dd9
+3
View File
@@ -45,3 +45,6 @@ app.*.map.json
# Environment configuration with credentials
lib/config/env.dev.dart
functions/.env
.env
env.dart
functions/.env.local
+15
View File
@@ -2,6 +2,21 @@
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
## 26/05/2026
Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.
## 25/05/2026
Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.
## 04/05/2026
Optimisation du lancement de l'application et amélioration de la gestion du cache.
## 22/04/2026
Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement
## 30/03/2026
Patch bug envoi d'alerte equipement perdu, date dans les alertes, recherche par ID d'équipement.
## 24/03/2026
Fix BUG : Problème de cache avec les équipements non affichés dans le dialog de sélection d'équipement. Amélioration de la gestion du cache pour éviter les problèmes d'affichage.
+1
View File
@@ -7,3 +7,4 @@ SMTP_PASS="aL8@Rx8xqFrNij$a"
# URL de l'application
APP_URL="https://app.em2events.fr"
GEMINI_API_KEY="AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc"
+9 -1
View File
@@ -4,7 +4,7 @@ module.exports = {
node: true,
},
parserOptions: {
"ecmaVersion": 2018,
"ecmaVersion": 2020,
},
extends: [
"eslint:recommended",
@@ -14,6 +14,14 @@ module.exports = {
"no-restricted-globals": ["error", "name", "length"],
"prefer-arrow-callback": "error",
"quotes": ["error", "double", {"allowTemplateLiterals": true}],
"max-len": "off",
"valid-jsdoc": "off",
"require-jsdoc": "off",
"guard-for-in": "off",
"no-unused-vars": "warn",
"brace-style": "off",
"object-curly-spacing": "off",
"arrow-parens": "off",
},
overrides: [
{
+1
View File
@@ -2,3 +2,4 @@ node_modules/
*.local
.env
.env.local
serviceAccountKey.json
File diff suppressed because it is too large Load Diff
+33 -33
View File
@@ -1,34 +1,34 @@
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');
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 || '*';
const origin = req.headers.origin || "*";
res.set('Access-Control-Allow-Origin', 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');
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');
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('');
if (req.method === "OPTIONS") {
res.status(204).send("");
return;
}
try {
@@ -48,8 +48,8 @@ const withCors = (handler) => {
*/
exports.createAlert = onRequest({
cors: false,
invoker: 'public',
region: 'europe-west9'
invoker: "public",
region: "europe-west9",
}, withCors(async (req, res) => {
try {
// Vérifier l'authentification
@@ -70,7 +70,7 @@ exports.createAlert = onRequest({
// Validation des données
if (!type || !severity || !message) {
res.status(400).json({error: 'type, severity et message sont requis'});
res.status(400).json({error: "type, severity et message sont requis"});
return;
}
@@ -78,12 +78,12 @@ exports.createAlert = onRequest({
const userIds = await determineTargetUsers(type, severity, eventId);
if (userIds.length === 0) {
res.status(400).json({error: 'Aucun utilisateur à notifier'});
res.status(400).json({error: "Aucun utilisateur à notifier"});
return;
}
// 2. Créer l'alerte dans Firestore
const alertRef = admin.firestore().collection('alerts').doc();
const alertRef = admin.firestore().collection("alerts").doc();
const alertData = {
id: alertRef.id,
type,
@@ -99,14 +99,14 @@ exports.createAlert = onRequest({
createdBy: decodedToken.uid,
isRead: false,
emailSent: false,
status: 'ACTIVE',
status: "ACTIVE",
};
await alertRef.set(alertData);
// 3. Envoyer les emails si alerte critique
let emailResults = {};
if (severity === 'CRITICAL') {
if (severity === "CRITICAL") {
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
// Mettre à jour le statut d'envoi
@@ -124,7 +124,7 @@ exports.createAlert = onRequest({
emailsSent: Object.values(emailResults).filter((v) => v).length,
});
} catch (error) {
logger.error('[createAlert] Erreur:', error);
logger.error("[createAlert] Erreur:", error);
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
}
}));
@@ -137,23 +137,23 @@ async function determineTargetUsers(alertType, severity, eventId) {
const targetUserIds = new Set();
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
const allUsersSnapshot = await db.collection('users').get();
const allUsersSnapshot = await db.collection("users").get();
allUsersSnapshot.forEach((doc) => {
const user = doc.data();
if (user.role) {
// Le rôle peut être une référence Firestore ou une string
let rolePath = '';
if (typeof user.role === 'string') {
let rolePath = "";
if (typeof user.role === "string") {
rolePath = user.role;
} else if (user.role.path) {
rolePath = user.role.path;
} else if (user.role._path && user.role._path.segments) {
rolePath = user.role._path.segments.join('/');
rolePath = user.role._path.segments.join("/");
}
// Vérifier si c'est un admin (path = "roles/ADMIN")
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
if (rolePath === "roles/ADMIN" || rolePath === "ADMIN") {
targetUserIds.add(doc.id);
}
}
@@ -162,7 +162,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
// 2. Si un événement est lié, ajouter tous les membres de la workforce
if (eventId) {
try {
const eventDoc = await db.collection('events').doc(eventId).get();
const eventDoc = await db.collection("events").doc(eventId).get();
if (eventDoc.exists) {
const event = eventDoc.data();
@@ -177,7 +177,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
}
} catch (error) {
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
logger.error("[determineTargetUsers] Erreur récupération événement:", error);
}
}
@@ -222,7 +222,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
const db = admin.firestore();
// Récupérer l'utilisateur
const userDoc = await db.collection('users').doc(userId).get();
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
return false;
@@ -250,7 +250,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
const templateData = await prepareTemplateData(alertData, user);
// Rendre le template
const html = await renderTemplate('alert-individual', templateData);
const html = await renderTemplate("alert-individual", templateData);
// Envoyer l'email
await transporter.sendMail({
+24 -24
View File
@@ -4,9 +4,9 @@
* Avec système de cache dans Firebase Storage
*/
const textToSpeech = require('@google-cloud/text-to-speech');
const crypto = require('crypto');
const logger = require('firebase-functions/logger');
const textToSpeech = require("@google-cloud/text-to-speech");
const crypto = require("crypto");
const logger = require("firebase-functions/logger");
/**
* Génère un hash MD5 pour le texte (utilisé comme clé de cache)
@@ -16,10 +16,10 @@ const logger = require('firebase-functions/logger');
function generateCacheKey(text, voiceConfig = {}) {
const cacheString = JSON.stringify({
text,
lang: voiceConfig.languageCode || 'fr-FR',
voice: voiceConfig.name || 'fr-FR-Standard-B',
lang: voiceConfig.languageCode || "fr-FR",
voice: voiceConfig.name || "fr-FR-Standard-B",
});
return crypto.createHash('md5').update(cacheString).digest('hex');
return crypto.createHash("md5").update(cacheString).digest("hex");
}
/**
@@ -34,21 +34,21 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
try {
// Validation du texte
if (!text || text.trim().length === 0) {
throw new Error('Text cannot be empty');
throw new Error("Text cannot be empty");
}
if (text.length > 5000) {
throw new Error('Text too long (max 5000 characters)');
throw new Error("Text too long (max 5000 characters)");
}
// Configuration par défaut de la voix
const defaultVoiceConfig = {
languageCode: 'fr-FR',
name: 'fr-FR-Standard-B', // Voix masculine française (Standard = gratuit)
ssmlGender: 'MALE',
languageCode: "fr-FR",
name: "fr-FR-Standard-B", // Voix masculine française (Standard = gratuit)
ssmlGender: "MALE",
};
const finalVoiceConfig = { ...defaultVoiceConfig, ...voiceConfig };
const finalVoiceConfig = {...defaultVoiceConfig, ...voiceConfig};
// Générer la clé de cache
const cacheKey = generateCacheKey(text, finalVoiceConfig);
@@ -59,11 +59,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
const [exists] = await file.exists();
if (exists) {
logger.info('[generateTTS] ✓ Cache HIT', { cacheKey, text: text.substring(0, 50) });
logger.info("[generateTTS] ✓ Cache HIT", {cacheKey, text: text.substring(0, 50)});
// Générer une URL signée valide 7 jours
const [url] = await file.getSignedUrl({
action: 'read',
action: "read",
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
});
@@ -74,7 +74,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
};
}
logger.info('[generateTTS] ○ Cache MISS - Generating audio', {
logger.info("[generateTTS] ○ Cache MISS - Generating audio", {
cacheKey,
text: text.substring(0, 50),
voice: finalVoiceConfig.name,
@@ -85,10 +85,10 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
// Configuration de la requête
const request = {
input: { text: text },
input: {text: text},
voice: finalVoiceConfig,
audioConfig: {
audioEncoding: 'MP3',
audioEncoding: "MP3",
speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension
pitch: -2.0, // Voix un peu plus grave
volumeGainDb: 0.0,
@@ -99,17 +99,17 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
const [response] = await client.synthesizeSpeech(request);
if (!response.audioContent) {
throw new Error('No audio content returned from TTS API');
throw new Error("No audio content returned from TTS API");
}
logger.info('[generateTTS] ✓ Audio generated', {
logger.info("[generateTTS] ✓ Audio generated", {
size: response.audioContent.length,
});
// Sauvegarder dans Firebase Storage
await file.save(response.audioContent, {
metadata: {
contentType: 'audio/mpeg',
contentType: "audio/mpeg",
metadata: {
text: text.substring(0, 100), // Premier 100 caractères pour debug
voice: finalVoiceConfig.name,
@@ -118,11 +118,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
},
});
logger.info('[generateTTS] ✓ Audio cached', { fileName });
logger.info("[generateTTS] ✓ Audio cached", {fileName});
// Générer une URL signée
const [url] = await file.getSignedUrl({
action: 'read',
action: "read",
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
});
@@ -132,7 +132,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
cacheKey,
};
} catch (error) {
logger.error('[generateTTS] ✗ Error', {
logger.error("[generateTTS] ✗ Error", {
error: error.message,
code: error.code,
text: text?.substring(0, 50),
@@ -142,5 +142,5 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
}
}
module.exports = { generateTTS, generateCacheKey };
module.exports = {generateTTS, generateCacheKey};
+282 -4030
View File
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -2,17 +2,17 @@
* Script de migration : Active les emails pour tous les utilisateurs existants
* À exécuter une seule fois après le déploiement
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
// AJOUTER CECI : Charger le fichier de clé
const serviceAccount = require('./serviceAccountKey.json');
const serviceAccount = require("./serviceAccountKey.json");
// Initialiser Firebase Admin avec les credentials explicites
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici
projectId: 'em2rp-951dc',
projectId: "em2rp-951dc",
});
}
@@ -22,11 +22,11 @@ const db = admin.firestore();
* Active les notifications par email pour tous les utilisateurs existants
*/
async function migrateEmailPreferences() {
console.log('=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n');
console.log("=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n");
try {
// 1. Récupérer tous les utilisateurs
const usersSnapshot = await db.collection('users').get();
const usersSnapshot = await db.collection("users").get();
console.log(`${usersSnapshot.size} utilisateurs trouvés\n`);
// 2. Préparer les updates
@@ -49,7 +49,7 @@ async function migrateEmailPreferences() {
updates.push({
ref: doc.ref,
data: {
'notificationPreferences.emailEnabled': true,
"notificationPreferences.emailEnabled": true,
},
});
}
@@ -83,7 +83,7 @@ async function migrateEmailPreferences() {
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
}
console.log('=== FIN MIGRATION ===');
console.log("=== FIN MIGRATION ===");
return {
success: true,
total: usersSnapshot.size,
@@ -91,7 +91,7 @@ async function migrateEmailPreferences() {
updated: toUpdate,
};
} catch (error) {
console.error('❌ ERREUR MIGRATION:', error);
console.error("❌ ERREUR MIGRATION:", error);
throw error;
}
}
@@ -99,15 +99,15 @@ async function migrateEmailPreferences() {
// 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);
});
.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 };
module.exports = {migrateEmailPreferences};
+26 -27
View File
@@ -5,28 +5,28 @@
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
*/
const admin = require('firebase-admin');
const serviceAccount = require('./serviceAccountKey.json');
const admin = require("firebase-admin");
const serviceAccount = require("./serviceAccountKey.json");
// Initialiser Firebase Admin
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
credential: admin.credential.cert(serviceAccount),
});
const db = admin.firestore();
async function migrateEquipmentIds() {
console.log('🔧 Migration: Ajout du champ id aux équipements');
console.log('================================================\n');
console.log("🔧 Migration: Ajout du champ id aux équipements");
console.log("================================================\n");
try {
// Récupérer tous les équipements
const equipmentsSnapshot = await db.collection('equipments').get();
const equipmentsSnapshot = await db.collection("equipments").get();
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
let missingIdCount = 0;
let updatedCount = 0;
let errorCount = 0;
const errorCount = 0;
const batch = db.batch();
let batchCount = 0;
@@ -34,12 +34,12 @@ async function migrateEquipmentIds() {
const data = doc.data();
// Vérifier si le champ 'id' est manquant ou vide
if (!data.id || data.id === '') {
if (!data.id || data.id === "") {
missingIdCount++;
console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`);
console.log(`❌ Équipement ${doc.id} (${data.name || "Sans nom"}) : champ 'id' manquant`);
// Ajouter au batch
batch.update(doc.ref, { id: doc.id });
batch.update(doc.ref, {id: doc.id});
batchCount++;
updatedCount++;
@@ -58,36 +58,35 @@ async function migrateEquipmentIds() {
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
}
console.log('\n================================================');
console.log('📊 RÉSUMÉ DE LA MIGRATION');
console.log('================================================');
console.log("\n================================================");
console.log("📊 RÉSUMÉ DE LA MIGRATION");
console.log("================================================");
console.log(`Total d'équipements: ${equipmentsSnapshot.size}`);
console.log(`Équipements avec 'id' manquant: ${missingIdCount}`);
console.log(`Équipements mis à jour: ${updatedCount}`);
console.log(`Erreurs: ${errorCount}`);
console.log('================================================\n');
console.log("================================================\n");
if (missingIdCount === 0) {
console.log('✅ Tous les équipements ont déjà un champ id !');
console.log("✅ Tous les équipements ont déjà un champ id !");
} else if (updatedCount === missingIdCount) {
console.log('✅ Migration terminée avec succès !');
console.log("✅ Migration terminée avec succès !");
} else {
console.log('⚠️ Migration terminée avec des erreurs');
console.log("⚠️ Migration terminée avec des erreurs");
}
} catch (error) {
console.error('❌ Erreur lors de la migration:', error);
console.error("❌ Erreur lors de la migration:", error);
throw error;
}
}
// 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);
});
.then(() => {
console.log("\n✅ Script terminé");
process.exit(0);
})
.catch((error) => {
console.error("\n❌ Script échoué:", error);
process.exit(1);
});
+19 -5
View File
@@ -8,11 +8,12 @@
"dependencies": {
"@google-cloud/storage": "^7.18.0",
"@google-cloud/text-to-speech": "^5.4.0",
"@google/generative-ai": "^0.21.0",
"axios": "^1.13.2",
"dotenv": "^17.2.3",
"envdot": "^0.0.3",
"firebase-admin": "^12.6.0",
"firebase-functions": "^7.0.3",
"firebase-functions": "^7.2.5",
"handlebars": "^4.7.8",
"nodemailer": "^6.10.1"
},
@@ -785,6 +786,15 @@
"node": ">=14.0.0"
}
},
"node_modules/@google/generative-ai": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz",
"integrity": "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
@@ -3354,9 +3364,9 @@
}
},
"node_modules/firebase-functions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz",
"integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==",
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz",
"integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -3375,7 +3385,8 @@
"peerDependencies": {
"@apollo/server": "^5.2.0",
"@as-integrations/express4": "^1.1.2",
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0",
"graphql": "^16.12.0"
},
"peerDependenciesMeta": {
"@apollo/server": {
@@ -3383,6 +3394,9 @@
},
"@as-integrations/express4": {
"optional": true
},
"graphql": {
"optional": true
}
}
},
+2 -1
View File
@@ -16,11 +16,12 @@
"dependencies": {
"@google-cloud/storage": "^7.18.0",
"@google-cloud/text-to-speech": "^5.4.0",
"@google/generative-ai": "^0.21.0",
"axios": "^1.13.2",
"dotenv": "^17.2.3",
"envdot": "^0.0.3",
"firebase-admin": "^12.6.0",
"firebase-functions": "^7.0.3",
"firebase-functions": "^7.2.5",
"handlebars": "^4.7.8",
"nodemailer": "^6.10.1"
},
+134 -89
View File
@@ -1,8 +1,8 @@
const {onCall} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const nodemailer = require('nodemailer');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
const {onCall} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const nodemailer = require("nodemailer");
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
/**
* Traite la validation du matériel d'un événement
* Appelée par le client lors du chargement/déchargement
@@ -10,14 +10,14 @@ const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
*/
exports.processEquipmentValidation = onCall({
cors: true,
region: 'europe-west9'
region: "europe-west9",
}, async (request) => {
try {
// L'authentification est automatique avec onCall
const {auth, data} = request;
if (!auth) {
throw new Error('L\'utilisateur doit être authentifié');
throw new Error("L'utilisateur doit être authentifié");
}
const {
@@ -28,34 +28,39 @@ exports.processEquipmentValidation = onCall({
// Validation
if (!eventId || !equipmentList || !validationType) {
throw new Error('eventId, equipmentList et validationType sont requis');
throw new Error("eventId, equipmentList et validationType sont requis");
}
const db = admin.firestore();
const alerts = [];
// 1. Récupérer les détails de l'événement
const eventRef = db.collection('events').doc(eventId);
const eventRef = db.collection("events").doc(eventId);
const eventDoc = await eventRef.get();
if (!eventDoc.exists) {
throw new Error('Événement introuvable');
throw new Error("Événement introuvable");
}
const event = eventDoc.data();
const eventName = event.Name || event.name || 'Événement inconnu';
const eventName = event.Name || event.name || "Événement inconnu";
const eventDate = formatEventDate(event);
// 2. Analyser les équipements et détecter les problèmes
for (const equipment of equipmentList) {
const {equipmentId, status, quantity, expectedQuantity} = equipment;
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
if (status === "NOT_TAKEN") {
continue;
}
// Cas 1: Équipement PERDU
if (status === 'LOST') {
if (status === "LOST") {
const alertData = await createAlertInFirestore({
type: 'LOST',
severity: 'CRITICAL',
title: 'Équipement perdu',
type: "LOST",
severity: "CRITICAL",
title: "Équipement perdu",
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
@@ -71,11 +76,11 @@ exports.processEquipmentValidation = onCall({
}
// Cas 2: Équipement MANQUANT
if (status === 'MISSING') {
if (status === "MISSING") {
const alertData = await createAlertInFirestore({
type: 'EQUIPMENT_MISSING',
severity: 'WARNING',
title: 'Équipement manquant',
type: "EQUIPMENT_MISSING",
severity: "WARNING",
title: "Équipement manquant",
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
@@ -91,11 +96,13 @@ exports.processEquipmentValidation = onCall({
}
// Cas 3: Quantité incorrecte
if (expectedQuantity && quantity !== expectedQuantity) {
const hasExpectedQuantity = typeof expectedQuantity === "number";
const hasActualQuantity = typeof quantity === "number";
if (hasExpectedQuantity && hasActualQuantity && quantity !== expectedQuantity) {
const alertData = await createAlertInFirestore({
type: 'QUANTITY_MISMATCH',
severity: 'INFO',
title: 'Quantité incorrecte',
type: "QUANTITY_MISMATCH",
severity: "INFO",
title: "Quantité incorrecte",
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
equipmentId,
eventId,
@@ -113,11 +120,11 @@ exports.processEquipmentValidation = onCall({
}
// Cas 4: Équipement endommagé
if (status === 'DAMAGED') {
if (status === "DAMAGED") {
const alertData = await createAlertInFirestore({
type: 'DAMAGED',
severity: 'WARNING',
title: 'Équipement endommagé',
type: "DAMAGED",
severity: "WARNING",
title: "Équipement endommagé",
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
@@ -144,7 +151,7 @@ exports.processEquipmentValidation = onCall({
});
// 4. Envoyer les notifications pour les alertes critiques
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL");
if (criticalAlerts.length > 0) {
for (const alert of criticalAlerts) {
try {
@@ -162,7 +169,7 @@ exports.processEquipmentValidation = onCall({
alertIds: alerts.map((a) => a.id),
};
} catch (error) {
logger.error('[processEquipmentValidation] Erreur:', error);
logger.error("[processEquipmentValidation] Erreur:", error);
throw error;
}
});
@@ -172,14 +179,14 @@ exports.processEquipmentValidation = onCall({
*/
async function createAlertInFirestore(alertData) {
const db = admin.firestore();
const alertRef = db.collection('alerts').doc();
const alertRef = db.collection("alerts").doc();
const fullAlertData = {
id: alertRef.id,
...alertData,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
isRead: false,
status: 'ACTIVE',
status: "ACTIVE",
emailSent: false,
assignedTo: [],
};
@@ -199,7 +206,7 @@ async function sendAlertNotifications(alert, eventId) {
try {
// 1. Récupérer TOUS les utilisateurs et leurs permissions
const allUsersSnapshot = await db.collection('users').get();
const allUsersSnapshot = await db.collection("users").get();
// Créer un map pour stocker les références de rôles à récupérer
const roleRefs = new Map();
@@ -212,17 +219,17 @@ async function sendAlertNotifications(alert, eventId) {
}
// Extraire le chemin du rôle
let rolePath = '';
let roleId = '';
let rolePath = "";
let roleId = "";
if (typeof user.role === 'string') {
if (typeof user.role === "string") {
rolePath = user.role;
roleId = user.role.split('/').pop();
roleId = user.role.split("/").pop();
} else if (user.role.path) {
rolePath = user.role.path;
roleId = user.role.path.split('/').pop();
roleId = user.role.path.split("/").pop();
} else if (user.role._path && user.role._path.segments) {
rolePath = user.role._path.segments.join('/');
rolePath = user.role._path.segments.join("/");
roleId = user.role._path.segments[user.role._path.segments.length - 1];
}
@@ -238,14 +245,14 @@ async function sendAlertNotifications(alert, eventId) {
// 2. Récupérer les permissions de chaque rôle unique
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
try {
const roleDoc = await db.collection('roles').doc(roleId).get();
const roleDoc = await db.collection("roles").doc(roleId).get();
if (roleDoc.exists) {
const roleData = roleDoc.data();
const permissions = roleData.permissions || [];
// Vérifier si le rôle a la permission view_all_events
if (permissions.includes('view_all_events')) {
if (permissions.includes("view_all_events")) {
users.forEach((userId) => {
usersWithPermission.add(userId);
targetUserIds.add(userId);
@@ -259,7 +266,7 @@ async function sendAlertNotifications(alert, eventId) {
// 3. Ajouter la workforce de l'événement
if (eventId) {
const eventDoc = await db.collection('events').doc(eventId).get();
const eventDoc = await db.collection("events").doc(eventId).get();
if (eventDoc.exists) {
const event = eventDoc.data();
@@ -269,14 +276,14 @@ async function sendAlertNotifications(alert, eventId) {
// Extraire l'userId selon différentes structures possibles
let userId = null;
if (typeof member === 'string') {
if (typeof member === "string") {
userId = member;
} else if (member.userId) {
userId = member.userId;
} else if (member.id) {
userId = member.id;
} else if (member.user) {
if (typeof member.user === 'string') {
if (typeof member.user === "string") {
userId = member.user;
} else if (member.user.id) {
userId = member.user.id;
@@ -293,18 +300,18 @@ async function sendAlertNotifications(alert, eventId) {
const userIds = Array.from(targetUserIds);
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
await db.collection('alerts').doc(alert.id).update({
await db.collection("alerts").doc(alert.id).update({
assignedTo: userIds,
});
// 5. Envoyer les emails si alerte critique
if (alert.severity === 'CRITICAL') {
if (alert.severity === "CRITICAL") {
await sendAlertEmails(alert, userIds);
}
return userIds;
} catch (error) {
logger.error('[sendAlertNotifications] Erreur:', error);
logger.error("[sendAlertNotifications] Erreur:", error);
throw error;
}
}
@@ -314,12 +321,12 @@ async function sendAlertNotifications(alert, eventId) {
*/
async function sendAlertEmails(alert, userIds) {
try {
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
const {renderTemplate, getEmailSubject, prepareTemplateData} = require("./utils/emailTemplates");
const db = admin.firestore();
// Vérifier que EMAIL_CONFIG est disponible
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré');
logger.error("[sendAlertEmails] EMAIL_CONFIG non configuré");
return 0;
}
@@ -336,7 +343,7 @@ async function sendAlertEmails(alert, userIds) {
const promises = batch.map(async (userId) => {
try {
// Récupérer l'utilisateur
const userDoc = await db.collection('users').doc(userId).get();
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
return false;
@@ -344,55 +351,55 @@ async function sendAlertEmails(alert, userIds) {
const user = userDoc.data();
// Vérifier les préférences email
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
return false;
}
// Vérifier les préférences email
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
return false;
}
if (!user.email) {
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 = `
// 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>
<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;
}
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({
await db.collection("alerts").doc(alert.id).update({
emailSent: true,
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
emailsSentCount: successCount,
@@ -400,7 +407,7 @@ async function sendAlertEmails(alert, userIds) {
return successCount;
} catch (error) {
logger.error('[sendAlertEmails] Erreur globale:', error);
logger.error("[sendAlertEmails] Erreur globale:", error);
return 0;
}
}
@@ -409,10 +416,48 @@ async function sendAlertEmails(alert, userIds) {
* Formate la date d'un événement
*/
function formatEventDate(event) {
if (event.startDate) {
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate);
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'});
}
return 'Date inconnue';
const rawDate =
event?.StartDateTime ||
event?.startDateTime ||
event?.startDate ||
event?.eventDate;
const parsedDate = parseFirestoreDate(rawDate);
const safeDate = parsedDate || new Date();
return safeDate.toLocaleDateString("fr-FR", {
day: "numeric",
month: "numeric",
year: "numeric",
});
}
function parseFirestoreDate(value) {
if (!value) {
return null;
}
if (typeof value.toDate === "function") {
return value.toDate();
}
if (value instanceof Date) {
return value;
}
if (typeof value === "string" || typeof value === "number") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
if (typeof value === "object" && typeof value.seconds === "number") {
return new Date(value.seconds * 1000);
}
if (typeof value === "object" && typeof value._seconds === "number") {
return new Date(value._seconds * 1000);
}
return null;
}
+60 -60
View File
@@ -1,51 +1,51 @@
const {onCall} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
const fs = require('fs').promises;
const path = require('path');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
const {onCall} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const nodemailer = require("nodemailer");
const handlebars = require("handlebars");
const fs = require("fs").promises;
const path = require("path");
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
/**
* Envoie un email d'alerte à un utilisateur
* Appelé par le client Dart via callable function
*/
exports.sendAlertEmail = onCall({
region: 'europe-west9',
cors: true
region: "europe-west9",
cors: true,
}, async (request) => {
// Vérifier l'authentification
if (!request.auth) {
throw new Error('L\'utilisateur doit être authentifié');
throw new Error("L'utilisateur doit être authentifié");
}
const {alertId, userId, templateType} = request.data;
if (!alertId || !userId) {
throw new Error('alertId et userId sont requis');
throw new Error("alertId et userId sont requis");
}
try {
// Récupérer l'alerte depuis Firestore
const alertDoc = await admin.firestore()
.collection('alerts')
.collection("alerts")
.doc(alertId)
.get();
if (!alertDoc.exists) {
throw new Error('Alerte introuvable');
throw new Error("Alerte introuvable");
}
const alert = alertDoc.data();
// Récupérer l'utilisateur
const userDoc = await admin.firestore()
.collection('users')
.collection("users")
.doc(userId)
.get();
if (!userDoc.exists) {
throw new Error('Utilisateur introuvable');
throw new Error("Utilisateur introuvable");
}
const user = userDoc.data();
@@ -54,7 +54,7 @@ exports.sendAlertEmail = onCall({
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
console.log(`Email désactivé pour l'utilisateur ${userId}`);
return {success: true, skipped: true, reason: 'email_disabled'};
return {success: true, skipped: true, reason: "email_disabled"};
}
// Vérifier la préférence pour ce type d'alerte
@@ -62,7 +62,7 @@ exports.sendAlertEmail = onCall({
const shouldSend = checkAlertPreference(alertType, prefs);
if (!shouldSend) {
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
return {success: true, skipped: true, reason: 'alert_type_disabled'};
return {success: true, skipped: true, reason: "alert_type_disabled"};
}
// Préparer les données pour le template
@@ -70,7 +70,7 @@ exports.sendAlertEmail = onCall({
// Rendre le template HTML
const html = await renderTemplate(
templateType || 'alert-individual',
templateType || "alert-individual",
templateData,
);
@@ -88,7 +88,7 @@ exports.sendAlertEmail = onCall({
text: alert.message,
});
console.log('Email envoyé:', info.messageId);
console.log("Email envoyé:", info.messageId);
// Marquer l'email comme envoyé dans l'alerte
await alertDoc.ref.update({
@@ -102,7 +102,7 @@ exports.sendAlertEmail = onCall({
skipped: false,
};
} catch (error) {
console.error('Erreur envoi email:', error);
console.error("Erreur envoi email:", error);
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
}
});
@@ -112,13 +112,13 @@ exports.sendAlertEmail = onCall({
*/
function checkAlertPreference(alertType, preferences) {
const typeMapping = {
'EVENT_CREATED': 'eventsNotifications',
'EVENT_MODIFIED': 'eventsNotifications',
'EVENT_CANCELLED': 'eventsNotifications',
'LOST': 'equipmentNotifications',
'EQUIPMENT_MISSING': 'equipmentNotifications',
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
'STOCK_LOW': 'stockNotifications',
"EVENT_CREATED": "eventsNotifications",
"EVENT_MODIFIED": "eventsNotifications",
"EVENT_CANCELLED": "eventsNotifications",
"LOST": "equipmentNotifications",
"EQUIPMENT_MISSING": "equipmentNotifications",
"MAINTENANCE_REMINDER": "maintenanceNotifications",
"STOCK_LOW": "stockNotifications",
};
const prefKey = typeMapping[alertType];
@@ -130,12 +130,12 @@ function checkAlertPreference(alertType, preferences) {
*/
async function prepareTemplateData(alert, user) {
const data = {
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'Utilisateur',
userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() ||
"Utilisateur",
alertTitle: getAlertTitle(alert.type),
alertMessage: alert.message,
isCritical: alert.severity === 'CRITICAL',
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
isCritical: alert.severity === "CRITICAL",
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`,
appUrl: EMAIL_CONFIG.appUrl,
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
year: new Date().getFullYear(),
@@ -146,7 +146,7 @@ async function prepareTemplateData(alert, user) {
if (alert.eventId) {
try {
const eventDoc = await admin.firestore()
.collection('events')
.collection("events")
.doc(alert.eventId)
.get();
@@ -155,22 +155,22 @@ async function prepareTemplateData(alert, user) {
data.eventName = event.Name;
if (event.StartDateTime) {
const date = event.StartDateTime.toDate();
data.eventDate = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
data.eventDate = date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
}
} catch (error) {
console.error('Erreur récupération événement:', error);
console.error("Erreur récupération événement:", error);
}
}
if (alert.equipmentId) {
try {
const eqDoc = await admin.firestore()
.collection('equipments')
.collection("equipments")
.doc(alert.equipmentId)
.get();
@@ -178,7 +178,7 @@ async function prepareTemplateData(alert, user) {
data.equipmentName = eqDoc.data().name;
}
} catch (error) {
console.error('Erreur récupération équipement:', error);
console.error("Erreur récupération équipement:", error);
}
}
@@ -190,16 +190,16 @@ async function prepareTemplateData(alert, user) {
*/
function getEmailSubject(alert) {
const subjects = {
'EVENT_CREATED': '📅 Nouvel événement créé',
'EVENT_MODIFIED': '📝 Événement modifié',
'EVENT_CANCELLED': '❌ Événement annulé',
'LOST': '🔴 Alerte critique : Équipement perdu',
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
'STOCK_LOW': '📦 Stock faible',
"EVENT_CREATED": "📅 Nouvel événement créé",
"EVENT_MODIFIED": "📝 Événement modifié",
"EVENT_CANCELLED": "❌ Événement annulé",
"LOST": "🔴 Alerte critique : Équipement perdu",
"EQUIPMENT_MISSING": "⚠️ Équipement manquant",
"MAINTENANCE_REMINDER": "🔧 Rappel de maintenance",
"STOCK_LOW": "📦 Stock faible",
};
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events";
}
/**
@@ -207,16 +207,16 @@ function getEmailSubject(alert) {
*/
function getAlertTitle(type) {
const titles = {
'EVENT_CREATED': 'Nouvel événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'EVENT_CANCELLED': 'Événement annulé',
'LOST': 'Équipement perdu',
'EQUIPMENT_MISSING': 'Équipement manquant',
'MAINTENANCE_REMINDER': 'Maintenance requise',
'STOCK_LOW': 'Stock faible',
"EVENT_CREATED": "Nouvel événement créé",
"EVENT_MODIFIED": "Événement modifié",
"EVENT_CANCELLED": "Événement annulé",
"LOST": "Équipement perdu",
"EQUIPMENT_MISSING": "Équipement manquant",
"MAINTENANCE_REMINDER": "Maintenance requise",
"STOCK_LOW": "Stock faible",
};
return titles[type] || 'Nouvelle alerte';
return titles[type] || "Nouvelle alerte";
}
/**
@@ -225,16 +225,16 @@ function getAlertTitle(type) {
async function renderTemplate(templateName, data) {
try {
// Lire le template de base
const basePath = path.join(__dirname, 'templates', 'base-template.html');
const baseTemplate = await fs.readFile(basePath, 'utf8');
const basePath = path.join(__dirname, "templates", "base-template.html");
const baseTemplate = await fs.readFile(basePath, "utf8");
// Lire le template de contenu
const contentPath = path.join(
__dirname,
'templates',
"templates",
`${templateName}.html`,
);
const contentTemplate = await fs.readFile(contentPath, 'utf8');
const contentTemplate = await fs.readFile(contentPath, "utf8");
// Compiler les templates
const compileContent = handlebars.compile(contentTemplate);
@@ -249,7 +249,7 @@ async function renderTemplate(templateName, data) {
content: renderedContent,
});
} catch (error) {
console.error('Erreur rendu template:', error);
console.error("Erreur rendu template:", error);
// Fallback vers un template simple
return `
<html>
+45 -45
View File
@@ -3,10 +3,10 @@
* S'exécute tous les jours à 8h00 (Europe/Paris)
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const nodemailer = require('nodemailer');
const { getSmtpConfig } = require('./utils/emailConfig');
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const nodemailer = require("nodemailer");
const {getSmtpConfig} = require("./utils/emailConfig");
/**
* Fonction principale : envoie le digest quotidien
@@ -14,11 +14,11 @@ const { getSmtpConfig } = require('./utils/emailConfig');
async function sendDailyDigest() {
const db = admin.firestore();
logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====');
logger.info("[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====");
try {
// 1. Récupérer tous les utilisateurs avec email activé
const usersSnapshot = await db.collection('users').get();
const usersSnapshot = await db.collection("users").get();
const eligibleUsers = [];
usersSnapshot.forEach((doc) => {
@@ -30,8 +30,8 @@ async function sendDailyDigest() {
eligibleUsers.push({
uid: doc.id,
email: user.email,
firstName: user.firstName || 'Utilisateur',
lastName: user.lastName || '',
firstName: user.firstName || "Utilisateur",
lastName: user.lastName || "",
});
}
});
@@ -48,12 +48,12 @@ async function sendDailyDigest() {
for (const user of eligibleUsers) {
try {
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
const alertsSnapshot = await db.collection('alerts')
.where('assignedTo', 'array-contains', user.uid)
.where('isRead', '==', false)
.where('createdAt', '>=', yesterday)
.orderBy('createdAt', 'desc')
.get();
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
@@ -61,7 +61,7 @@ async function sendDailyDigest() {
const alerts = [];
alertsSnapshot.forEach((doc) => {
alerts.push({ id: doc.id, ...doc.data() });
alerts.push({id: doc.id, ...doc.data()});
});
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
@@ -77,11 +77,11 @@ async function sendDailyDigest() {
}
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
logger.info("[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====");
return { success: true, emailsSent };
return {success: true, emailsSent};
} catch (error) {
logger.error('[sendDailyDigest] Erreur globale:', error);
logger.error("[sendDailyDigest] Erreur globale:", error);
throw error;
}
}
@@ -92,9 +92,9 @@ async function sendDailyDigest() {
async function sendDigestEmail(transporter, user, alerts) {
try {
// Grouper les alertes par sévérité
const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL');
const warningAlerts = alerts.filter(a => a.severity === 'WARNING');
const infoAlerts = alerts.filter(a => a.severity === 'INFO');
const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL");
const warningAlerts = alerts.filter((a) => a.severity === "WARNING");
const infoAlerts = alerts.filter((a) => a.severity === "INFO");
// Construire le HTML
const html = buildDigestHtml(user, {
@@ -125,7 +125,7 @@ async function sendDigestEmail(transporter, user, alerts) {
function buildDigestHtml(user, alertsByType) {
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
let alertsHtml = '';
let alertsHtml = "";
// Alertes critiques
if (alertsByType.critical.length > 0) {
@@ -134,7 +134,7 @@ function buildDigestHtml(user, alertsByType) {
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
🔴 Alertes critiques (${alertsByType.critical.length})
</h3>
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
${alertsByType.critical.map((alert) => formatAlertItem(alert)).join("")}
</div>
`;
}
@@ -146,7 +146,7 @@ function buildDigestHtml(user, alertsByType) {
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
Avertissements (${alertsByType.warning.length})
</h3>
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
${alertsByType.warning.map((alert) => formatAlertItem(alert)).join("")}
</div>
`;
}
@@ -158,7 +158,7 @@ function buildDigestHtml(user, alertsByType) {
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
Informations (${alertsByType.info.length})
</h3>
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
${alertsByType.info.map((alert) => formatAlertItem(alert)).join("")}
</div>
`;
}
@@ -216,24 +216,24 @@ function buildDigestHtml(user, alertsByType) {
*/
function formatAlertItem(alert) {
const date = alert.createdAt?.toDate ?
new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
new Date(alert.createdAt.toDate()).toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}) :
'Date inconnue';
"Date inconnue";
// Type d'alerte en français
const typeLabels = {
'EQUIPMENT_MISSING': 'Équipement manquant',
'LOST': 'Équipement perdu',
'DAMAGED': 'Équipement endommagé',
'QUANTITY_MISMATCH': 'Écart de quantité',
'EVENT_CREATED': 'Événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'WORKFORCE_ADDED': 'Ajout à la workforce',
"EQUIPMENT_MISSING": "Équipement manquant",
"LOST": "Équipement perdu",
"DAMAGED": "Équipement endommagé",
"QUANTITY_MISMATCH": "Écart de quantité",
"EVENT_CREATED": "Événement créé",
"EVENT_MODIFIED": "Événement modifié",
"WORKFORCE_ADDED": "Ajout à la workforce",
};
const typeLabel = typeLabels[alert.type] || alert.type;
@@ -245,7 +245,7 @@ function formatAlertItem(alert) {
<span style="color: #6b7280; font-size: 13px;">${date}</span>
</div>
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
${alert.message || 'Aucun message'}
${alert.message || "Aucun message"}
</p>
</div>
`;
@@ -256,12 +256,12 @@ function formatAlertItem(alert) {
*/
function getSeverityColor(severity) {
switch (severity) {
case 'CRITICAL': return '#dc2626';
case 'WARNING': return '#f59e0b';
case 'INFO': return '#3b82f6';
default: return '#6b7280';
case "CRITICAL": return "#dc2626";
case "WARNING": return "#f59e0b";
case "INFO": return "#3b82f6";
default: return "#6b7280";
}
}
module.exports = { sendDailyDigest };
module.exports = {sendDailyDigest};
+72
View File
@@ -0,0 +1,72 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Récupère toutes les alertes (filtrées et limitées)
exports.getAlerts = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("alerts")
.orderBy("createdAt", "desc")
.limit(100)
.get();
const alerts = snapshot.docs.map((doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ["createdAt"]),
};
});
res.status(200).json({alerts});
} catch (error) {
logger.error("Error fetching alerts:", error);
res.status(500).json({error: error.message});
}
};
// Marquer une alerte comme lue
exports.markAlertAsRead = async (req, res) => {
try {
await auth.authenticateUser(req);
const alertId = req.body.data?.alertId;
if (!alertId) {
res.status(400).json({error: "alertId is required"});
return;
}
await db.collection("alerts").doc(alertId).update({
isRead: true,
});
res.status(200).json({success: true});
} catch (error) {
logger.error("Error marking alert as read:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer une alerte
exports.deleteAlert = async (req, res) => {
try {
await auth.authenticateUser(req);
const alertId = req.body.data?.alertId;
if (!alertId) {
res.status(400).json({error: "alertId is required"});
return;
}
await db.collection("alerts").doc(alertId).delete();
res.status(200).json({success: true});
} catch (error) {
logger.error("Error deleting alert:", error);
res.status(500).json({error: error.message});
}
};
+628
View File
@@ -0,0 +1,628 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Vérifie si un équipement est disponible pour une plage de dates
exports.checkEquipmentAvailability = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentId, startDate, endDate, excludeEventId} = req.body.data;
if (!equipmentId || !startDate || !endDate) {
res.status(400).json({error: "equipmentId, startDate, and endDate are required"});
return;
}
logger.info(`Checking availability for equipment ${equipmentId} from ${startDate} to ${endDate}, excluding event: ${excludeEventId}`);
const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate));
const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate));
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
logger.info(`Found ${eventsSnapshot.docs.length} events to check`);
const conflicts = [];
for (const eventDoc of eventsSnapshot.docs) {
const event = eventDoc.data();
if (excludeEventId && eventDoc.id === excludeEventId) {
continue;
}
let eventStart; let eventEnd;
if (event.StartDateTime) {
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
}
if (event.EndDateTime) {
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
}
if (!eventStart || !eventEnd) {
continue;
}
const assignedEquipment = event.assignedEquipment || [];
const assignedContainers = event.assignedContainers || [];
const isEquipmentDirectlyAssigned = assignedEquipment.some((eq) => eq.equipmentId === equipmentId);
let isEquipmentInAssignedContainer = false;
if (assignedContainers.length > 0) {
logger.info(`Event ${eventDoc.id} has ${assignedContainers.length} assigned containers`);
for (const containerId of assignedContainers) {
const containerDoc = await db.collection("containers").doc(containerId).get();
if (containerDoc.exists) {
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
logger.info(`Container ${containerId} contains equipment IDs: ${equipmentIds.join(", ")}`);
if (equipmentIds.includes(equipmentId)) {
isEquipmentInAssignedContainer = true;
logger.info(`Equipment ${equipmentId} found in container ${containerId} for event ${eventDoc.id}`);
break;
}
}
}
}
if (isEquipmentDirectlyAssigned) {
logger.info(`Equipment ${equipmentId} is directly assigned to event ${eventDoc.id}`);
}
if (!isEquipmentDirectlyAssigned && !isEquipmentInAssignedContainer) {
continue;
}
const requestStart = startTimestamp.toDate();
const requestEnd = endTimestamp.toDate();
const installationTime = event.InstallationTime || 0;
const disassemblyTime = event.DisassemblyTime || 0;
const eventStartWithSetup = new Date(eventStart);
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime);
const eventEndWithTeardown = new Date(eventEnd);
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime);
const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup;
if (hasOverlap) {
const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup));
const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown));
const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24));
logger.info(`Conflict detected: Equipment ${equipmentId} conflicts with event ${eventDoc.id} (${event.Name})`);
const eventData = helpers.serializeTimestamps(event);
conflicts.push({
eventId: eventDoc.id,
eventName: event.Name,
eventData: eventData,
startDate: eventStart.toISOString(),
endDate: eventEnd.toISOString(),
overlapDays: overlapDays,
});
}
}
logger.info(`Total conflicts found: ${conflicts.length}`);
res.status(200).json({conflicts, available: conflicts.length === 0});
} catch (error) {
logger.error("Error checking equipment availability:", error);
res.status(500).json({error: error.message || "Failed to check equipment availability"});
}
};
// Vérifie la disponibilité d'un container et de son contenu
exports.checkContainerAvailability = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {containerId, startDate, endDate, excludeEventId} = req.body.data;
if (!containerId || !startDate || !endDate) {
res.status(400).json({error: "containerId, startDate, and endDate are required"});
return;
}
const containerDoc = await db.collection("containers").doc(containerId).get();
if (!containerDoc.exists) {
throw new Error("Container not found");
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate));
const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate));
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const containerConflicts = [];
const equipmentConflicts = {};
for (const eventDoc of eventsSnapshot.docs) {
const event = eventDoc.data();
if (excludeEventId && eventDoc.id === excludeEventId) {
continue;
}
let eventStart; let eventEnd;
if (event.StartDateTime) {
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
}
if (event.EndDateTime) {
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
}
if (!eventStart || !eventEnd) {
continue;
}
const assignedContainers = event.assignedContainers || [];
const isContainerAssigned = assignedContainers.includes(containerId);
const assignedEquipment = event.assignedEquipment || [];
const conflictingEquipmentIds = equipmentIds.filter((eqId) =>
assignedEquipment.some((eq) => eq.equipmentId === eqId),
);
if (!isContainerAssigned && conflictingEquipmentIds.length === 0) {
continue;
}
const requestStart = startTimestamp.toDate();
const requestEnd = endTimestamp.toDate();
const installationTime = event.InstallationTime || 0;
const disassemblyTime = event.DisassemblyTime || 0;
const eventStartWithSetup = new Date(eventStart);
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime);
const eventEndWithTeardown = new Date(eventEnd);
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime);
const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup;
if (hasOverlap) {
const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup));
const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown));
const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24));
const conflictInfo = {
eventId: eventDoc.id,
eventName: event.Name,
startDate: eventStart.toISOString(),
endDate: eventEnd.toISOString(),
overlapDays: overlapDays,
};
if (isContainerAssigned) {
containerConflicts.push(conflictInfo);
}
conflictingEquipmentIds.forEach((eqId) => {
if (!equipmentConflicts[eqId]) {
equipmentConflicts[eqId] = [];
}
equipmentConflicts[eqId].push(conflictInfo);
});
}
}
const hasContainerConflict = containerConflicts.length > 0;
const hasPartialConflict = Object.keys(equipmentConflicts).length > 0 && !hasContainerConflict;
const conflictType = hasContainerConflict ? "complete" : (hasPartialConflict ? "partial" : "none");
res.status(200).json({
conflictType,
containerConflicts,
equipmentConflicts,
isAvailable: conflictType === "none",
});
} catch (error) {
logger.error("Error checking container availability:", error);
res.status(500).json({error: error.message || "Failed to check container availability"});
}
};
// Récupère tous les équipements et conteneurs en conflit pour une période donnée
exports.getConflictingEquipmentIds = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {startDate, endDate, excludeEventId, installationTime = 0, disassemblyTime = 0} = req.body.data;
if (!startDate || !endDate) {
res.status(400).json({error: "startDate and endDate are required"});
return;
}
logger.info(`Getting conflicting equipment IDs for period ${startDate} to ${endDate}`);
const requestStartDate = new Date(startDate);
requestStartDate.setHours(requestStartDate.getHours() - installationTime);
const requestEndDate = new Date(endDate);
requestEndDate.setHours(requestEndDate.getHours() + disassemblyTime);
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
logger.info(`Found ${eventsSnapshot.docs.length} events to check`);
const equipmentsSnapshot = await db.collection("equipments").get();
const equipmentsInfo = {};
equipmentsSnapshot.docs.forEach((doc) => {
const data = doc.data();
equipmentsInfo[doc.id] = {
category: data.category,
totalQuantity: data.totalQuantity || 0,
hasQuantity: data.category === "CABLE" || data.category === "CONSUMABLE",
};
});
const conflictingEquipmentIds = new Set();
const conflictingContainerIds = new Set();
const conflictDetails = {};
const equipmentQuantities = {};
for (const eventDoc of eventsSnapshot.docs) {
if (excludeEventId && eventDoc.id === excludeEventId) {
continue;
}
const event = eventDoc.data();
let eventStart; let eventEnd;
if (event.StartDateTime) {
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
}
if (event.EndDateTime) {
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
}
if (!eventStart || !eventEnd) {
continue;
}
const eventInstallTime = event.InstallationTime || 0;
const eventDisassemblyTime = event.DisassemblyTime || 0;
const eventStartWithSetup = new Date(eventStart);
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - eventInstallTime);
const eventEndWithTeardown = new Date(eventEnd);
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + eventDisassemblyTime);
const hasOverlap = requestStartDate < eventEndWithTeardown && requestEndDate > eventStartWithSetup;
if (!hasOverlap) {
continue;
}
const assignedEquipment = event.assignedEquipment || [];
const assignedContainers = event.assignedContainers || [];
const conflictInfo = {
eventId: eventDoc.id,
eventName: event.Name,
startDate: eventStart.toISOString(),
endDate: eventEnd.toISOString(),
};
for (const eq of assignedEquipment) {
const equipmentId = eq.equipmentId;
const quantity = eq.quantity || 1;
const equipInfo = equipmentsInfo[equipmentId];
if (equipInfo && equipInfo.hasQuantity) {
if (!equipmentQuantities[equipmentId]) {
equipmentQuantities[equipmentId] = {
totalQuantity: equipInfo.totalQuantity,
reservedQuantity: 0,
availableQuantity: equipInfo.totalQuantity,
reservations: [],
};
}
equipmentQuantities[equipmentId].reservedQuantity += quantity;
equipmentQuantities[equipmentId].availableQuantity = equipInfo.totalQuantity - equipmentQuantities[equipmentId].reservedQuantity;
equipmentQuantities[equipmentId].reservations.push({
...conflictInfo,
quantity: quantity,
});
if (equipmentQuantities[equipmentId].availableQuantity <= 0) {
conflictingEquipmentIds.add(equipmentId);
}
} else {
conflictingEquipmentIds.add(equipmentId);
}
if (!conflictDetails[equipmentId]) {
conflictDetails[equipmentId] = [];
}
conflictDetails[equipmentId].push({
...conflictInfo,
quantity: quantity,
});
}
for (const containerId of assignedContainers) {
conflictingContainerIds.add(containerId);
if (!conflictDetails[containerId]) {
conflictDetails[containerId] = [];
}
conflictDetails[containerId].push(conflictInfo);
const containerDoc = await db.collection("containers").doc(containerId).get();
if (containerDoc.exists) {
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
for (const equipmentId of equipmentIds) {
conflictingEquipmentIds.add(equipmentId);
if (!conflictDetails[equipmentId]) {
conflictDetails[equipmentId] = [];
}
conflictDetails[equipmentId].push({
...conflictInfo,
viaContainer: containerId,
viaContainerName: containerData.name || "Conteneur inconnu",
});
}
}
}
}
logger.info(`Found ${conflictingEquipmentIds.size} conflicting equipment(s) and ${conflictingContainerIds.size} conflicting container(s)`);
res.status(200).json({
conflictingEquipmentIds: Array.from(conflictingEquipmentIds),
conflictingContainerIds: Array.from(conflictingContainerIds),
conflictDetails: conflictDetails,
equipmentQuantities: equipmentQuantities,
});
} catch (error) {
logger.error("Error getting conflicting equipment IDs:", error);
res.status(500).json({error: error.message || "Failed to get conflicting equipment IDs"});
}
};
/**
* Trouver des alternatives (même modèle) disponibles pour une période donnée
*/
exports.findAlternativeEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {model, startDate, endDate} = req.body.data;
if (!model || !startDate || !endDate) {
res.status(400).json({error: "model, startDate and endDate are required"});
return;
}
const start = admin.firestore.Timestamp.fromDate(new Date(startDate));
const end = admin.firestore.Timestamp.fromDate(new Date(endDate));
// Récupérer tous les équipements du même modèle
const equipmentsSnapshot = await db.collection("equipments")
.where("model", "==", model)
.get();
// Récupérer tous les événements qui chevauchent la période
const eventsSnapshot = await db.collection("events")
.where("StartDateTime", "<=", end)
.where("EndDateTime", ">=", start)
.where("status", "!=", "CANCELLED")
.get();
// Créer un set des équipements en conflit
const conflictingEquipmentIds = new Set();
eventsSnapshot.docs.forEach((doc) => {
const eventData = doc.data();
const assignedEquipment = eventData.assignedEquipment || [];
assignedEquipment.forEach((eq) => conflictingEquipmentIds.add(eq.equipmentId));
});
// Filtrer les équipements disponibles
const alternatives = [];
equipmentsSnapshot.docs.forEach((doc) => {
const data = doc.data();
if (!conflictingEquipmentIds.has(doc.id) && data.status === "available") {
alternatives.push({
id: doc.id,
...helpers.serializeTimestamps(data, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
});
}
});
res.status(200).json({alternatives});
} catch (error) {
logger.error("Error finding alternative equipment:", error);
res.status(500).json({error: error.message});
}
};
/**
* Calculer le statut réel d'un ou plusieurs équipements basé sur les événements en cours
*/
exports.calculateEquipmentStatuses = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentIds} = req.body.data;
if (!equipmentIds || !Array.isArray(equipmentIds)) {
res.status(400).json({error: "equipmentIds array is required"});
return;
}
// Récupérer tous les événements en cours (préparation complétée mais pas encore retournés)
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const equipmentIdsInUse = new Set();
const containerIdsInUse = new Set();
eventsSnapshot.docs.forEach((doc) => {
const event = doc.data();
const isPrepared = event.preparationStatus === "completed" ||
event.preparationStatus === "completedWithMissing";
const isReturned = event.returnStatus === "completed" ||
event.returnStatus === "completedWithMissing";
if (isPrepared && !isReturned) {
// Ajouter les équipements directs
const assignedEquipment = event.assignedEquipment || [];
assignedEquipment.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId));
// Ajouter les conteneurs
const assignedContainers = event.assignedContainers || [];
assignedContainers.forEach((containerId) => containerIdsInUse.add(containerId));
}
});
// Récupérer les équipements dans les conteneurs en cours d'utilisation
if (containerIdsInUse.size > 0) {
const containersSnapshot = await db.collection("containers")
.where(admin.firestore.FieldPath.documentId(), "in", Array.from(containerIdsInUse))
.get();
containersSnapshot.docs.forEach((doc) => {
const containerData = doc.data();
const equipmentList = containerData.equipment || [];
equipmentList.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId));
});
}
// Récupérer les données des équipements demandés
const statuses = {};
for (const equipmentId of equipmentIds) {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (!equipmentDoc.exists) {
statuses[equipmentId] = null;
continue;
}
const equipmentData = equipmentDoc.data();
let calculatedStatus = equipmentData.status;
// Si l'équipement est perdu ou HS, garder ce statut
if (equipmentData.status === "lost" || equipmentData.status === "outOfService") {
calculatedStatus = equipmentData.status;
} else if (equipmentIdsInUse.has(equipmentId)) {
calculatedStatus = "inUse";
} else if (equipmentData.status === "maintenance" ||
equipmentData.status === "rented") {
calculatedStatus = equipmentData.status;
} else {
calculatedStatus = "available";
}
statuses[equipmentId] = calculatedStatus;
}
res.status(200).json({statuses});
} catch (error) {
logger.error("Error calculating equipment statuses:", error);
res.status(500).json({error: error.message});
}
};
/**
* Récupérer tous les événements en cours (pour le calcul de statuts)
*/
exports.getActiveEvents = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_events");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_events permission"});
return;
}
// Récupérer les événements en cours (préparation complétée mais pas encore retournés)
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const activeEvents = [];
eventsSnapshot.docs.forEach((doc) => {
const event = doc.data();
const isPrepared = event.preparationStatus === "completed" ||
event.preparationStatus === "completedWithMissing";
const isReturned = event.returnStatus === "completed" ||
event.returnStatus === "completedWithMissing";
if (isPrepared && !isReturned) {
activeEvents.push({
id: doc.id,
assignedEquipment: event.assignedEquipment || [],
assignedContainers: event.assignedContainers || [],
preparationStatus: event.preparationStatus,
returnStatus: event.returnStatus,
});
}
});
res.status(200).json({events: activeEvents});
} catch (error) {
logger.error("Error fetching active events:", error);
res.status(500).json({error: error.message});
}
};
+504
View File
@@ -0,0 +1,504 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer un container
exports.createContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const containerData = req.body.data;
const containerId = containerData.id;
if (!containerId) {
res.status(400).json({error: "Container ID is required"});
return;
}
const existingDoc = await db.collection("containers").doc(containerId).get();
if (existingDoc.exists) {
res.status(409).json({error: "Container ID already exists"});
return;
}
const dataToSave = helpers.deserializeTimestamps(containerData, ["createdAt", "updatedAt"]);
await db.collection("containers").doc(containerId).set(dataToSave);
res.status(201).json({id: containerId, message: "Container created successfully"});
} catch (error) {
logger.error("Error creating container:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un container
exports.updateContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId, data} = req.body.data;
if (!containerId) {
res.status(400).json({error: "Container ID is required"});
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
await db.collection("containers").doc(containerId).update(data);
res.status(200).json({message: "Container updated successfully"});
} catch (error) {
logger.error("Error updating container:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un container
exports.deleteContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId} = req.body.data;
if (!containerId) {
res.status(400).json({error: "Container ID is required"});
return;
}
// Récupérer le container pour obtenir les équipements
const containerDoc = await db.collection("containers").doc(containerId).get();
if (containerDoc.exists) {
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
// Retirer le container des parentBoxIds de chaque équipement
for (const equipmentId of equipmentIds) {
try {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const parentBoxIds = (equipmentData.parentBoxIds || []).filter((boxId) => boxId !== containerId);
await db.collection("equipments").doc(equipmentId).update({
parentBoxIds: parentBoxIds,
updatedAt: admin.firestore.Timestamp.now(),
});
}
} catch (err) {
logger.error(`Error updating equipment ${equipmentId} when deleting container:`, err);
}
}
}
await db.collection("containers").doc(containerId).delete();
res.status(200).json({message: "Container deleted successfully"});
} catch (error) {
logger.error("Error deleting container:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer les containers contenant un équipement
exports.getContainersByEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentId} = req.body.data || {};
if (!equipmentId) {
res.status(400).json({error: "equipmentId is required"});
return;
}
const snapshot = await db.collection("containers")
.where("equipmentIds", "array-contains", equipmentId)
.get();
const containers = [];
snapshot.forEach((doc) => {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
containers.push(data);
});
res.status(200).json({containers});
} catch (error) {
logger.error("Error getting containers by equipment:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer plusieurs containers par leurs IDs
exports.getContainersByIds = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {containerIds} = req.body.data || {};
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
res.status(400).json({error: "containerIds array is required and must not be empty"});
return;
}
if (containerIds.length > 100) {
res.status(400).json({error: "Maximum 100 container IDs per request"});
return;
}
const promises = containerIds.map((id) => db.collection("containers").doc(id).get());
const docs = await Promise.all(promises);
const containers = [];
for (const doc of docs) {
if (doc.exists) {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
containers.push(data);
}
}
res.status(200).json({containers});
} catch (error) {
logger.error("Error getting containers by IDs:", error);
res.status(500).json({error: error.message});
}
};
// Ajouter un équipement à un container
exports.addEquipmentToContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId, equipmentId, userId} = req.body.data;
if (!containerId || !equipmentId) {
res.status(400).json({error: "containerId and equipmentId are required"});
return;
}
const containerDoc = await db.collection("containers").doc(containerId).get();
if (!containerDoc.exists) {
res.status(404).json({success: false, message: "Container non trouvé"});
return;
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
if (equipmentIds.includes(equipmentId)) {
res.status(400).json({success: false, message: "Cet équipement est déjà dans ce container"});
return;
}
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (!equipmentDoc.exists) {
res.status(404).json({success: false, message: "Équipement non trouvé"});
return;
}
const equipmentData = equipmentDoc.data();
const parentBoxIds = equipmentData.parentBoxIds || [];
const warnings = [];
if (parentBoxIds.length > 0) {
const otherContainersPromises = parentBoxIds.map((boxId) =>
db.collection("containers").doc(boxId).get(),
);
const otherContainersDocs = await Promise.all(otherContainersPromises);
const otherNames = otherContainersDocs
.filter((doc) => doc.exists)
.map((doc) => doc.data().name);
if (otherNames.length > 0) {
warnings.push(`Attention : cet équipement est également dans les boites suivants : ${otherNames.join(", ")}`);
}
}
await db.collection("containers").doc(containerId).update({
equipmentIds: [...equipmentIds, equipmentId],
updatedAt: admin.firestore.Timestamp.now(),
});
await db.collection("equipments").doc(equipmentId).update({
parentBoxIds: [...parentBoxIds, containerId],
updatedAt: admin.firestore.Timestamp.now(),
});
const history = containerData.history || [];
const historyEntry = {
timestamp: admin.firestore.Timestamp.now(),
action: "equipment_added",
equipmentId: equipmentId,
newValue: equipmentId,
userId: userId || decodedToken.uid,
};
const updatedHistory = [...history, historyEntry].slice(-100);
await db.collection("containers").doc(containerId).update({
history: updatedHistory,
});
res.status(200).json({
success: true,
message: "Équipement ajouté avec succès",
warnings: warnings.length > 0 ? warnings[0] : null,
});
} catch (error) {
logger.error("Error adding equipment to container:", error);
res.status(500).json({success: false, message: error.message});
}
};
// Retirer un équipement d'un container
exports.removeEquipmentFromContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId, equipmentId, userId} = req.body.data;
if (!containerId || !equipmentId) {
res.status(400).json({error: "containerId and equipmentId are required"});
return;
}
const containerDoc = await db.collection("containers").doc(containerId).get();
if (!containerDoc.exists) {
res.status(404).json({error: "Container non trouvé"});
return;
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
const updatedEquipmentIds = equipmentIds.filter((id) => id !== equipmentId);
await db.collection("containers").doc(containerId).update({
equipmentIds: updatedEquipmentIds,
updatedAt: admin.firestore.Timestamp.now(),
});
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const parentBoxIds = equipmentData.parentBoxIds || [];
const updatedParentBoxIds = parentBoxIds.filter((id) => id !== containerId);
await db.collection("equipments").doc(equipmentId).update({
parentBoxIds: updatedParentBoxIds,
updatedAt: admin.firestore.Timestamp.now(),
});
}
const history = containerData.history || [];
const historyEntry = {
timestamp: admin.firestore.Timestamp.now(),
action: "equipment_removed",
equipmentId: equipmentId,
previousValue: equipmentId,
userId: userId || decodedToken.uid,
};
const updatedHistory = [...history, historyEntry].slice(-100);
await db.collection("containers").doc(containerId).update({
history: updatedHistory,
});
res.status(200).json({success: true});
} catch (error) {
logger.error("Error removing equipment from container:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer les containers avec pagination et filtrage côté serveur
exports.getContainersPaginated = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canView) {
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
return;
}
// Récupérer les paramètres de la requête
const params = req.method === "GET" ? req.query : (req.body?.data || {});
const limit = Math.min(parseInt(params.limit) || 20, 100);
const startAfterId = params.startAfter || null;
const type = params.type ? params.type.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null;
const searchQuery = params.searchQuery?.toLowerCase() || null;
const category = params.category ? params.category.toUpperCase() : null;
const sortBy = params.sortBy || "id";
const sortOrder = params.sortOrder === "desc" ? "desc" : "asc";
logger.info(`[getContainersPaginated] Params: limit=${limit}, startAfter=${startAfterId}, type=${type}, status=${status}, category=${category}, search=${searchQuery}`);
let query = db.collection("containers");
const queryLimit = (searchQuery || category) ? Math.min(limit * 10, 200) : limit;
if (type) {
query = query.where("type", "==", type);
}
if (status) {
query = query.where("status", "==", status);
}
if (sortBy === "id") {
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
} else {
query = query.orderBy(sortBy, sortOrder);
}
if (startAfterId) {
const startAfterDoc = await db.collection("containers").doc(startAfterId).get();
if (startAfterDoc.exists) {
query = query.startAfter(startAfterDoc);
}
}
query = query.limit(queryLimit + 1);
const snapshot = await query.get();
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > queryLimit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
let containers = docsToProcess.map((doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ["createdAt", "updatedAt"]),
};
});
const allEquipmentIds = new Set();
containers.forEach((c) => {
if (c.equipmentIds && Array.isArray(c.equipmentIds)) {
c.equipmentIds.forEach((id) => allEquipmentIds.add(id));
}
});
const equipmentMap = new Map();
if (allEquipmentIds.size > 0) {
const equipmentIdArray = Array.from(allEquipmentIds);
const batchSize = 30;
for (let i = 0; i < equipmentIdArray.length; i += batchSize) {
const batch = equipmentIdArray.slice(i, i + batchSize);
const equipmentSnapshot = await db.collection("equipments")
.where(admin.firestore.FieldPath.documentId(), "in", batch)
.get();
equipmentSnapshot.docs.forEach((doc) => {
const equipmentData = doc.data();
equipmentMap.set(doc.id, {
id: doc.id,
...helpers.serializeTimestamps(equipmentData),
});
});
}
}
containers = containers.map((container) => ({
...container,
equipment: (container.equipmentIds || [])
.map((eqId) => equipmentMap.get(eqId))
.filter((eq) => eq !== undefined),
}));
if (category) {
containers = containers.filter((c) => {
return c.equipment.some((eq) => eq.category === category);
});
}
if (searchQuery) {
containers = containers.filter((c) => {
const searchableText = [
c.name || "",
c.id || "",
...(c.equipment || []).map((eq) => eq.name || ""),
].join(" ").toLowerCase();
return searchableText.includes(searchQuery);
});
}
const limitedContainers = containers.slice(0, limit);
const lastVisible = limitedContainers.length > 0 ? limitedContainers[limitedContainers.length - 1].id : null;
const totalEquipmentCount = limitedContainers.reduce((sum, c) => sum + (c.equipment?.length || 0), 0);
logger.info(`[getContainersPaginated] Returning ${limitedContainers.length} containers with ${totalEquipmentCount} total equipment(s)`);
limitedContainers.forEach((c) => {
logger.info(` - Container ${c.id}: ${c.equipment?.length || 0} equipment(s)`);
});
res.status(200).json({
containers: limitedContainers,
hasMore: containers.length > limit || hasMoreDocs,
lastVisible,
total: limitedContainers.length,
});
} catch (error) {
logger.error("Error fetching paginated containers:", error);
res.status(500).json({error: error.message});
}
};
+668
View File
@@ -0,0 +1,668 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer un équipement (admin ou manage_equipment)
exports.createEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const equipmentData = req.body.data;
const equipmentId = equipmentData.id;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
// Vérifier unicité de l'ID
const existingDoc = await db.collection("equipments").doc(equipmentId).get();
if (existingDoc.exists) {
res.status(409).json({error: "Equipment ID already exists"});
return;
}
// Convertir les timestamps
const dataToSave = helpers.deserializeTimestamps(equipmentData, [
"createdAt", "updatedAt", "purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate",
]);
await db.collection("equipments").doc(equipmentId).set(dataToSave);
res.status(201).json({id: equipmentId, message: "Equipment created successfully"});
} catch (error) {
logger.error("Error creating equipment:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un équipement
exports.updateEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {equipmentId, data} = req.body.data;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
if (!data || typeof data !== "object" || Object.keys(data).length === 0) {
res.status(400).json({error: "Update data is required and must be a non-empty object"});
return;
}
// Empêcher la modification de l'ID
delete data.id;
// Ajouter updatedAt
data.updatedAt = admin.firestore.Timestamp.now();
const dataToSave = helpers.deserializeTimestamps(data, [
"purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate",
]);
await db.collection("equipments").doc(equipmentId).update(dataToSave);
res.status(200).json({message: "Equipment updated successfully"});
} catch (error) {
logger.error("Error updating equipment:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un équipement
exports.deleteEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {equipmentId, forceDelete = false} = req.body.data;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
// Vérifier si l'équipement est utilisé dans des événements à venir
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const now = new Date();
const upcomingEvents = [];
for (const eventDoc of eventsSnapshot.docs) {
const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || [];
if (!assignedEquipment.some((eq) => eq.equipmentId === equipmentId)) {
continue;
}
let eventStart = null;
if (eventData.StartDateTime) {
eventStart = eventData.StartDateTime.toDate ?
eventData.StartDateTime.toDate() :
new Date(eventData.StartDateTime);
}
if (eventStart && eventStart > now) {
upcomingEvents.push({
eventId: eventDoc.id,
eventName: eventData.Name || "",
startDate: eventStart.toISOString(),
});
}
}
if (upcomingEvents.length > 0 && !forceDelete) {
res.status(409).json({
error: "FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events",
upcomingEvents,
});
return;
}
await db.collection("equipments").doc(equipmentId).delete();
res.status(200).json({message: "Equipment deleted successfully"});
} catch (error) {
logger.error("Error deleting equipment:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer un équipement par ID
exports.getEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentId} = req.body.data || req.query;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
const doc = await db.collection("equipments").doc(equipmentId).get();
if (!doc.exists) {
res.status(404).json({error: "Equipment not found"});
return;
}
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
// Masquer les prix si pas de permission manage_equipment
data = helpers.maskSensitiveFields(data, hasManageAccess);
res.status(200).json({equipment: data});
} catch (error) {
logger.error("Error getting equipment:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer plusieurs équipements par leurs IDs
exports.getEquipmentsByIds = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentIds} = req.body.data || {};
if (!equipmentIds || !Array.isArray(equipmentIds) || equipmentIds.length === 0) {
res.status(400).json({error: "equipmentIds array is required and must not be empty"});
return;
}
// Limiter à 100 équipements max par requête
if (equipmentIds.length > 100) {
res.status(400).json({error: "Maximum 100 equipment IDs per request"});
return;
}
// Récupérer tous les documents en parallèle
const promises = equipmentIds.map((id) => db.collection("equipments").doc(id).get());
const docs = await Promise.all(promises);
const equipments = [];
for (const doc of docs) {
if (doc.exists) {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
// Masquer les prix si pas de permission manage_equipment
data = helpers.maskSensitiveFields(data, hasManageAccess);
equipments.push(data);
}
}
res.status(200).json({equipments});
} catch (error) {
logger.error("Error getting equipments by IDs:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour uniquement le statut d'un équipement
exports.updateEquipmentStatusOnly = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {equipmentId, status, availableQuantity} = req.body.data;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
// Vérifier les permissions
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const updateData = {updatedAt: admin.firestore.Timestamp.now()};
if (status) updateData.status = status;
if (availableQuantity !== undefined) updateData.availableQuantity = availableQuantity;
await db.collection("equipments").doc(equipmentId).update(updateData);
res.status(200).json({message: "Equipment status updated successfully"});
} catch (error) {
logger.error("Error updating equipment status:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour le statut de plusieurs équipements (pour préparation/retour)
exports.updateEquipmentStatus = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {eventId, updates} = req.body.data;
if (!eventId || !updates || !Array.isArray(updates)) {
res.status(400).json({error: "Event ID and updates array are required"});
return;
}
// Vérifier que l'utilisateur est assigné à l'événement ou est admin
const isAssigned = await auth.isAssignedToEvent(decodedToken.uid, eventId);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAssigned && !isAdminUser) {
res.status(403).json({error: "Forbidden: Not assigned to this event"});
return;
}
// Batch update
const batch = db.batch();
for (const update of updates) {
const {equipmentId, status} = update;
if (equipmentId && status) {
const equipmentRef = db.collection("equipments").doc(equipmentId);
batch.update(equipmentRef, {status});
}
}
await batch.commit();
res.status(200).json({message: "Equipment statuses updated successfully"});
} catch (error) {
logger.error("Error updating equipment statuses:", error);
res.status(500).json({error: error.message});
}
};
// Récupère les équipements avec pagination et filtrage côté serveur
exports.getEquipmentsPaginated = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment");
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canManage && !canView) {
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
return;
}
// Récupérer les paramètres de la requête
const params = req.method === "GET" ? req.query : (req.body?.data || {});
const limit = Math.min(parseInt(params.limit) || 20, 100);
const startAfterId = params.startAfter || null;
const category = params.category ? params.category.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null;
const rawSearchQuery = typeof params.searchQuery === "string" ? params.searchQuery.trim() : "";
const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null;
const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, "") : null;
const sortBy = params.sortBy || "id";
const sortOrder = params.sortOrder === "desc" ? "desc" : "asc";
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`);
// Fast-path pour une recherche d'ID exact
if (searchQuery && !startAfterId) {
const exactIdCandidates = Array.from(new Set([
rawSearchQuery,
rawSearchQuery.toUpperCase(),
rawSearchQuery.toLowerCase(),
].filter(Boolean)));
for (const candidateId of exactIdCandidates) {
const exactDoc = await db.collection("equipments").doc(candidateId).get();
if (!exactDoc.exists) {
continue;
}
const exactData = exactDoc.data() || {};
const matchesCategory = !category || exactData.category === category;
const matchesStatus = !status || exactData.status === status;
if (!matchesCategory || !matchesStatus) {
continue;
}
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
id: exactDoc.id,
};
logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1,
});
return;
}
// Compatibilité legacy
for (const legacyId of exactIdCandidates) {
let legacyIdQuery = db.collection("equipments").where("id", "==", legacyId);
if (category) {
legacyIdQuery = legacyIdQuery.where("category", "==", category);
}
if (status) {
legacyIdQuery = legacyIdQuery.where("status", "==", status);
}
const legacySnapshot = await legacyIdQuery.limit(1).get();
if (legacySnapshot.empty) {
continue;
}
const exactDoc = legacySnapshot.docs[0];
const exactData = exactDoc.data() || {};
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
id: exactDoc.id,
};
logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1,
});
return;
}
}
// Construire la requête Firestore
let query = db.collection("equipments");
if (category) {
query = query.where("category", "==", category);
}
if (status) {
query = query.where("status", "==", status);
}
if (sortBy === "id") {
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
} else {
query = query.orderBy(sortBy, sortOrder);
}
if (startAfterId) {
const startAfterDoc = await db.collection("equipments").doc(startAfterId).get();
if (startAfterDoc.exists) {
query = query.startAfter(startAfterDoc);
}
}
const timestampFields = ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"];
const mapEquipmentDoc = (doc) => {
const data = {...(doc.data() || {})};
if (!canManage) {
delete data.purchasePrice;
delete data.rentalPrice;
}
const legacyId = typeof data.id === "string" ? data.id : "";
return {
...helpers.serializeTimestamps(data, timestampFields),
id: doc.id,
_legacyId: legacyId,
};
};
const matchesSearchQuery = (equipment) => {
const searchableText = [
equipment.name || "",
equipment.id || "",
equipment._legacyId || "",
equipment.model || "",
equipment.brand || "",
equipment.subCategory || "",
].join(" ").toLowerCase();
if (searchableText.includes(searchQuery)) {
return true;
}
if (!compactSearchQuery) {
return false;
}
const compactSearchableText = searchableText.replace(/[\s_-]+/g, "");
return compactSearchableText.includes(compactSearchQuery);
};
if (!searchQuery) {
const snapshot = await query.limit(limit + 1).get();
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > limit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, limit) : snapshot.docs;
const limitedEquipments = docsToProcess
.map(mapEquipmentDoc)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreDocs,
lastVisible,
total: limitedEquipments.length,
});
return;
}
const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200);
const matchedEquipments = [];
let scannedDocuments = 0;
let searchQueryRef = query;
let hasMoreMatches = false;
let hasMoreDocsToScan = true;
while (hasMoreDocsToScan && !hasMoreMatches) {
const snapshot = await searchQueryRef.limit(searchBatchSize).get();
if (snapshot.empty) {
hasMoreDocsToScan = false;
break;
}
scannedDocuments += snapshot.docs.length;
for (const doc of snapshot.docs) {
const equipment = mapEquipmentDoc(doc);
if (!matchesSearchQuery(equipment)) {
continue;
}
matchedEquipments.push(equipment);
if (matchedEquipments.length > limit) {
hasMoreMatches = true;
break;
}
}
if (hasMoreMatches) {
break;
}
if (snapshot.docs.length < searchBatchSize) {
hasMoreDocsToScan = false;
break;
}
const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1];
searchQueryRef = query.startAfter(lastDocInBatch);
}
const limitedEquipments = matchedEquipments
.slice(0, limit)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreMatches,
lastVisible,
total: limitedEquipments.length,
});
} catch (error) {
logger.error("Error fetching paginated equipments:", error);
res.status(500).json({error: error.message});
}
};
// Recherche rapide d'équipements et containers pour l'autocomplétion
exports.quickSearch = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canView) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const params = req.method === "GET" ? req.query : (req.body?.data || {});
const searchQuery = params.query?.toLowerCase() || "";
const limit = Math.min(parseInt(params.limit) || 10, 50);
const includeEquipments = params.includeEquipments !== "false";
const includeContainers = params.includeContainers !== "false";
if (!searchQuery || searchQuery.length < 2) {
res.status(200).json({results: []});
return;
}
const results = [];
// Rechercher dans les équipements
if (includeEquipments) {
const equipmentSnapshot = await db.collection("equipments")
.orderBy("id")
.limit(limit * 2)
.get();
equipmentSnapshot.docs.forEach((doc) => {
const data = doc.data();
const searchableText = [
data.name || "",
doc.id || "",
data.model || "",
data.brand || "",
].join(" ").toLowerCase();
if (searchableText.includes(searchQuery)) {
results.push({
type: "equipment",
id: doc.id,
name: data.name,
category: data.category,
model: data.model,
brand: data.brand,
});
}
});
}
// Rechercher dans les containers
if (includeContainers) {
const containerSnapshot = await db.collection("containers")
.orderBy("id")
.limit(limit * 2)
.get();
containerSnapshot.docs.forEach((doc) => {
const data = doc.data();
const searchableText = [
data.name || "",
doc.id || "",
].join(" ").toLowerCase();
if (searchableText.includes(searchQuery)) {
results.push({
type: "container",
id: doc.id,
name: data.name,
containerType: data.type,
});
}
});
}
const limitedResults = results
.sort((a, b) => {
const aStarts = a.id.toLowerCase().startsWith(searchQuery);
const bStarts = b.id.toLowerCase().startsWith(searchQuery);
if (aStarts && !bStarts) return -1;
if (!aStarts && bStarts) return 1;
return 0;
})
.slice(0, limit);
res.status(200).json({results: limitedResults});
} catch (error) {
logger.error("Error in quick search:", error);
res.status(500).json({error: error.message});
}
};
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer une maintenance
exports.createMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"});
return;
}
const maintenanceData = req.body.data;
const dataToSave = helpers.deserializeTimestamps(maintenanceData, [
"scheduledDate", "completedDate", "createdAt", "updatedAt",
]);
const docRef = await db.collection("maintenances").add(dataToSave);
const maintenanceId = docRef.id;
if (maintenanceData.equipmentIds && Array.isArray(maintenanceData.equipmentIds)) {
for (const equipmentId of maintenanceData.equipmentIds) {
try {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const maintenanceIds = equipmentData.maintenanceIds || [];
if (!maintenanceIds.includes(maintenanceId)) {
maintenanceIds.push(maintenanceId);
await db.collection("equipments").doc(equipmentId).update({
maintenanceIds: maintenanceIds,
});
}
}
if (maintenanceData.scheduledDate) {
const scheduledDate = maintenanceData.scheduledDate.toDate ?
maintenanceData.scheduledDate.toDate() :
new Date(maintenanceData.scheduledDate);
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
if (scheduledDate <= sevenDaysFromNow) {
const existingAlerts = await db.collection("alerts")
.where("equipmentId", "==", equipmentId)
.where("type", "==", "maintenanceDue")
.where("isRead", "==", false)
.get();
let alertExists = false;
for (const alertDoc of existingAlerts.docs) {
const alertData = alertDoc.data();
if (alertData.message && alertData.message.includes(maintenanceData.name || "")) {
alertExists = true;
break;
}
}
if (!alertExists) {
const equipmentName = equipmentDoc.exists ?
(equipmentDoc.data().name || equipmentId) :
equipmentId;
const daysUntil = Math.ceil((scheduledDate - new Date()) / (1000 * 60 * 60 * 24));
await db.collection("alerts").add({
type: "maintenanceDue",
message: `Maintenance "${maintenanceData.name || "Sans nom"}" prévue dans ${daysUntil} jour(s) pour ${equipmentName}`,
equipmentId: equipmentId,
createdAt: admin.firestore.Timestamp.now(),
isRead: false,
});
}
}
}
} catch (err) {
logger.error(`Error updating equipment ${equipmentId} for maintenance:`, err);
}
}
}
res.status(201).json({id: maintenanceId, message: "Maintenance created successfully"});
} catch (error) {
logger.error("Error creating maintenance:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour une maintenance
exports.updateMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"});
return;
}
const {maintenanceId, data} = req.body.data;
if (!maintenanceId) {
res.status(400).json({error: "Maintenance ID is required"});
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
const dataToSave = helpers.deserializeTimestamps(data, [
"scheduledDate", "completedDate",
]);
await db.collection("maintenances").doc(maintenanceId).update(dataToSave);
res.status(200).json({message: "Maintenance updated successfully"});
} catch (error) {
logger.error("Error updating maintenance:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer toutes les maintenances
exports.getMaintenances = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {equipmentId} = req.body.data || {};
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canView) {
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
return;
}
let query = db.collection("maintenances");
if (equipmentId) {
query = query.where("equipmentIds", "array-contains", equipmentId);
}
const snapshot = await query.get();
const maintenances = snapshot.docs.map((doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ["scheduledDate", "completedDate", "createdAt", "updatedAt"]),
};
});
res.status(200).json({maintenances});
} catch (error) {
logger.error("Error fetching maintenances:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer une maintenance
exports.deleteMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!canManage) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const maintenanceId = req.body.data?.maintenanceId;
if (!maintenanceId) {
res.status(400).json({error: "maintenanceId is required"});
return;
}
const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get();
if (maintenanceDoc.exists) {
const maintenance = maintenanceDoc.data();
if (maintenance.equipmentIds) {
for (const equipmentId of maintenance.equipmentIds) {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const maintenanceIds = (equipmentData.maintenanceIds || []).filter((id) => id !== maintenanceId);
await db.collection("equipments").doc(equipmentId).update({maintenanceIds});
}
}
}
}
await db.collection("maintenances").doc(maintenanceId).delete();
res.status(200).json({success: true});
} catch (error) {
logger.error("Error deleting maintenance:", error);
res.status(500).json({error: error.message});
}
};
/**
* Vérifier les maintenances à venir et créer des alertes
*/
exports.checkUpcomingMaintenances = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
const now = admin.firestore.Timestamp.now();
const sevenDaysTimestamp = admin.firestore.Timestamp.fromDate(sevenDaysFromNow);
// Récupérer les maintenances planifiées dans les 7 prochains jours
const maintenancesSnapshot = await db.collection("maintenances")
.where("scheduledDate", "<=", sevenDaysTimestamp)
.where("scheduledDate", ">=", now)
.get();
const alertsCreated = [];
for (const doc of maintenancesSnapshot.docs) {
const maintenance = doc.data();
// Vérifier si une alerte existe déjà pour cette maintenance
const existingAlertSnapshot = await db.collection("alerts")
.where("type", "==", "MAINTENANCE_DUE")
.where("relatedMaintenanceId", "==", doc.id)
.get();
if (existingAlertSnapshot.empty) {
// Créer une nouvelle alerte
const alertData = {
type: "MAINTENANCE_DUE",
title: `Maintenance à venir`,
message: `Une maintenance est prévue pour ${maintenance.equipmentIds?.length || 0} équipement(s)`,
severity: "MEDIUM",
isRead: false,
relatedMaintenanceId: doc.id,
createdAt: admin.firestore.Timestamp.now(),
};
const alertRef = await db.collection("alerts").add(alertData);
alertsCreated.push({id: alertRef.id, ...alertData});
}
}
res.status(200).json({
success: true,
alertsCreated: alertsCreated.length,
alerts: alertsCreated,
});
} catch (error) {
logger.error("Error checking upcoming maintenances:", error);
res.status(500).json({error: error.message});
}
};
/**
* Compléter une maintenance
*/
exports.completeMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {maintenanceId, performedBy, cost} = req.body.data;
if (!maintenanceId) {
res.status(400).json({error: "maintenanceId is required"});
return;
}
const now = admin.firestore.Timestamp.now();
const updateData = {
completedDate: now,
updatedAt: now,
};
if (performedBy) {
updateData.performedBy = performedBy;
}
if (cost !== undefined && cost !== null) {
updateData.cost = cost;
}
// Mettre à jour la maintenance
await db.collection("maintenances").doc(maintenanceId).update(updateData);
// Récupérer la maintenance pour mettre à jour les équipements
const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get();
const maintenanceData = maintenanceDoc.data();
// Mettre à jour la date de dernière maintenance des équipements
if (maintenanceData && maintenanceData.equipmentIds) {
const updatePromises = maintenanceData.equipmentIds.map((equipmentId) =>
db.collection("equipments").doc(equipmentId).update({
lastMaintenanceDate: now,
updatedAt: now,
}),
);
await Promise.all(updatePromises);
}
res.status(200).json({success: true});
} catch (error) {
logger.error("Error completing maintenance:", error);
res.status(500).json({error: error.message});
}
};
+263
View File
@@ -0,0 +1,263 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Récupérer toutes les options (public pour utilisateurs authentifiés)
exports.getOptions = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("options").get();
const options = snapshot.docs.map((doc) => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data()),
}));
res.status(200).json({options});
} catch (error) {
logger.error("Error fetching options:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer tous les types d'événements (public pour utilisateurs authentifiés)
exports.getEventTypes = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("eventTypes").get();
const eventTypes = snapshot.docs.map((doc) => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data()),
}));
res.status(200).json({eventTypes});
} catch (error) {
logger.error("Error fetching event types:", error);
res.status(500).json({error: error.message});
}
};
// Créer une option
exports.createOption = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const optionData = req.body.data;
const optionId = optionData.id;
if (!optionId) {
res.status(400).json({error: "Option ID is required"});
return;
}
await db.collection("options").doc(optionId).set(optionData);
res.status(201).json({id: optionId, message: "Option created successfully"});
} catch (error) {
logger.error("Error creating option:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour une option
exports.updateOption = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {optionId, data} = req.body.data;
if (!optionId) {
res.status(400).json({error: "Option ID is required"});
return;
}
delete data.id;
await db.collection("options").doc(optionId).update(data);
res.status(200).json({message: "Option updated successfully"});
} catch (error) {
logger.error("Error updating option:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer une option
exports.deleteOption = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {optionId} = req.body.data;
if (!optionId) {
res.status(400).json({error: "Option ID is required"});
return;
}
await db.collection("options").doc(optionId).delete();
res.status(200).json({message: "Option deleted successfully"});
} catch (error) {
logger.error("Error deleting option:", error);
res.status(500).json({error: error.message});
}
};
// Créer un type d'événement
exports.createEventType = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
if (!isAdmin) {
res.status(403).json({error: "Forbidden: Admin permission required"});
return;
}
const {name, defaultPrice} = req.body.data;
if (!name || defaultPrice === undefined) {
res.status(400).json({error: "Name and defaultPrice are required"});
return;
}
const existingSnapshot = await db.collection("eventTypes")
.where("name", "==", name)
.get();
if (!existingSnapshot.empty) {
res.status(409).json({error: "Event type name already exists"});
return;
}
const eventTypeData = {
name,
defaultPrice,
createdAt: admin.firestore.Timestamp.now(),
};
const docRef = await db.collection("eventTypes").add(eventTypeData);
res.status(201).json({id: docRef.id, message: "Event type created successfully"});
} catch (error) {
logger.error("Error creating event type:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un type d'événement
exports.updateEventType = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
if (!isAdmin) {
res.status(403).json({error: "Forbidden: Admin permission required"});
return;
}
const {eventTypeId, name, defaultPrice} = req.body.data;
if (!eventTypeId) {
res.status(400).json({error: "Event type ID is required"});
return;
}
const docRef = db.collection("eventTypes").doc(eventTypeId);
const doc = await docRef.get();
if (!doc.exists) {
res.status(404).json({error: "Event type not found"});
return;
}
if (name) {
const existingSnapshot = await db.collection("eventTypes")
.where("name", "==", name)
.get();
const hasDuplicate = existingSnapshot.docs.some((d) => d.id !== eventTypeId);
if (hasDuplicate) {
res.status(409).json({error: "Event type name already exists"});
return;
}
}
const updateData = {};
if (name) updateData.name = name;
if (defaultPrice !== undefined) updateData.defaultPrice = defaultPrice;
await docRef.update(updateData);
res.status(200).json({message: "Event type updated successfully"});
} catch (error) {
logger.error("Error updating event type:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un type d'événement
exports.deleteEventType = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
if (!isAdmin) {
res.status(403).json({error: "Forbidden: Admin permission required"});
return;
}
const {eventTypeId} = req.body.data;
if (!eventTypeId) {
res.status(400).json({error: "Event type ID is required"});
return;
}
const eventsSnapshot = await db.collection("events")
.where("eventTypeId", "==", eventTypeId)
.get();
const now = admin.firestore.Timestamp.now();
const futureEvents = eventsSnapshot.docs.filter((doc) => {
const startDate = doc.data().StartDateTime;
return startDate && startDate > now;
});
if (futureEvents.length > 0) {
res.status(409).json({
error: "Cannot delete event type with future events",
futureEventsCount: futureEvents.length,
});
return;
}
await db.collection("eventTypes").doc(eventTypeId).delete();
res.status(200).json({message: "Event type deleted successfully"});
} catch (error) {
logger.error("Error deleting event type:", error);
res.status(500).json({error: error.message});
}
};
+33
View File
@@ -0,0 +1,33 @@
const admin = require("firebase-admin");
const {Storage} = require("@google-cloud/storage");
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const storage = new Storage();
exports.moveEventFileV2 = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {sourcePath, destinationPath} = req.body.data || {};
if (!sourcePath || !destinationPath) {
res.status(400).json({error: "Source and destination paths are required."});
return;
}
const bucketName = admin.storage().bucket().name;
const bucket = storage.bucket(bucketName);
await bucket.file(sourcePath).copy(bucket.file(destinationPath));
await bucket.file(sourcePath).delete();
const [url] = await bucket.file(destinationPath).getSignedUrl({
action: "read",
expires: "03-01-2500",
});
res.status(200).json({url});
} catch (error) {
logger.error("Error moving file:", error);
res.status(500).json({error: error.message});
}
};
+58
View File
@@ -0,0 +1,58 @@
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
exports.generateTTSV2 = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
logger.info("[generateTTSV2] Request from user:", {
uid: decodedToken.uid,
email: decodedToken.email,
});
const {text, voiceConfig} = req.body.data || {};
if (!text) {
res.status(400).json({error: "Text parameter is required"});
return;
}
if (text.length > 5000) {
res.status(400).json({error: "Text too long (max 5000 characters)"});
return;
}
const {Storage} = require("@google-cloud/storage");
const storage = new Storage();
const bucketName = admin.storage().bucket().name;
const bucket = storage.bucket(bucketName);
const {generateTTS} = require("../generateTTS");
const result = await generateTTS(text, storage, bucket, voiceConfig);
logger.info("[generateTTSV2] ✓ Success", {
cached: result.cached,
cacheKey: result.cacheKey,
});
res.status(200).json({
audioUrl: result.audioUrl,
cached: result.cached,
cacheKey: result.cacheKey,
});
} catch (error) {
logger.error("[generateTTSV2] ✗ Error:", {
error: error.message,
code: error.code,
});
if (error.code === "PERMISSION_DENIED") {
res.status(403).json({error: "Permission denied. Check Google Cloud TTS API is enabled."});
} else if (error.code === "QUOTA_EXCEEDED") {
res.status(429).json({error: "TTS quota exceeded. Try again later."});
} else {
res.status(500).json({error: error.message});
}
}
};
+336
View File
@@ -0,0 +1,336 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer un utilisateur
exports.createUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const userData = req.body.data;
const userId = userData.uid;
if (!userId) {
res.status(400).json({error: "User ID is required"});
return;
}
await db.collection("users").doc(userId).set(userData);
res.status(201).json({id: userId, message: "User created successfully"});
} catch (error) {
logger.error("Error creating user:", error);
res.status(500).json({error: error.message});
}
};
// Créer un utilisateur avec invitation par email
exports.createUserWithInvite = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {email, firstName, lastName, phoneNumber, roleId} = req.body.data;
if (!email || !firstName || !lastName || !roleId) {
res.status(400).json({error: "email, firstName, lastName, and roleId are required"});
return;
}
const tempPassword = Math.random().toString(36).slice(-12) + "Aa1!";
let userRecord;
try {
userRecord = await admin.auth().createUser({
email: email,
password: tempPassword,
emailVerified: false,
displayName: `${firstName} ${lastName}`,
});
} catch (authError) {
logger.error("Error creating user in Auth:", authError);
res.status(500).json({error: `Failed to create user in Auth: ${authError.message}`});
return;
}
try {
await db.collection("users").doc(userRecord.uid).set({
firstName: firstName,
lastName: lastName,
email: email,
phoneNumber: phoneNumber || "",
profilePhotoUrl: "",
role: db.collection("roles").doc(roleId),
createdAt: admin.firestore.FieldValue.serverTimestamp(),
createdBy: decodedToken.uid,
});
} catch (firestoreError) {
logger.error("Error creating user in Firestore:", firestoreError);
try {
await admin.auth().deleteUser(userRecord.uid);
} catch (cleanupError) {
logger.error("Error cleaning up Auth user:", cleanupError);
}
res.status(500).json({error: `Failed to create user in Firestore: ${firestoreError.message}`});
return;
}
try {
const axios = require("axios");
const firebaseApiKey = "AIzaSyARQL4P-t5l-cNjQNP9cMokQrLJ8BorF0U";
await axios.post(
`https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=${firebaseApiKey}`,
{
requestType: "PASSWORD_RESET",
email: email,
},
);
logger.info(`Password reset email sent to ${email}`);
} catch (emailError) {
logger.warn(`Could not send password reset email to ${email}: ${emailError.message}`);
}
logger.info(`User ${userRecord.uid} created by ${decodedToken.uid}`);
res.status(201).json({
id: userRecord.uid,
message: "User created successfully. Password reset email sent.",
});
} catch (error) {
logger.error("Error in createUserWithInvite:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un utilisateur
exports.updateUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {userId, data} = req.body.data;
if (!userId) {
res.status(400).json({error: "User ID is required"});
return;
}
// Un utilisateur ne peut modifier que son propre compte, sauf s'il est admin
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (decodedToken.uid !== userId && !isAdminUser) {
res.status(403).json({error: "Forbidden: Cannot update other user accounts"});
return;
}
// Empêcher les non-admins de modifier le rôle
if (!isAdminUser && data.role) {
delete data.role;
}
// Si le rôle est fourni et est un string, le convertir en DocumentReference
if (data.role && typeof data.role === "string") {
data.role = db.collection("roles").doc(data.role);
}
await db.collection("users").doc(userId).update(data);
res.status(200).json({message: "User updated successfully"});
} catch (error) {
logger.error("Error updating user:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un utilisateur
exports.deleteUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {userId} = req.body.data;
if (!userId) {
res.status(400).json({error: "User ID is required"});
return;
}
if (decodedToken.uid === userId) {
res.status(400).json({error: "Cannot delete your own account"});
return;
}
await db.collection("users").doc(userId).delete();
try {
await admin.auth().deleteUser(userId);
} catch (authError) {
logger.warn(`Could not delete user from Auth: ${authError.message}`);
}
res.status(200).json({message: "User deleted successfully"});
} catch (error) {
logger.error("Error deleting user:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer tous les utilisateurs (selon permissions)
exports.getUsers = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_users");
if (!canViewAll) {
const userDoc = await db.collection("users").doc(decodedToken.uid).get();
if (!userDoc.exists) {
res.status(404).json({error: "User not found"});
return;
}
let userData = userDoc.data();
userData = helpers.serializeTimestamps(userData);
userData = helpers.serializeReferences(userData);
res.status(200).json({
users: [{
id: userDoc.id,
...userData,
}],
});
return;
}
const snapshot = await db.collection("users").get();
const users = snapshot.docs.map((doc) => {
let data = doc.data();
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
return {
id: doc.id,
...data,
};
});
res.status(200).json({users});
} catch (error) {
logger.error("Error fetching users:", error);
res.status(500).json({error: error.message});
}
};
// Récupère un utilisateur spécifique
exports.getUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {userId} = req.body.data || req.body || {};
if (!userId) {
res.status(400).json({error: "userId is required"});
return;
}
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
res.status(404).json({error: "User not found"});
return;
}
const user = userDoc.data();
const userData = {
id: userDoc.id,
uid: user.uid || userDoc.id,
email: user.email || "",
firstName: user.firstName || "",
lastName: user.lastName || "",
phoneNumber: user.phoneNumber || "",
profilePhotoUrl: user.profilePhotoUrl || "",
};
if (user.role) {
const roleDoc = await user.role.get();
if (roleDoc.exists) {
userData.role = {
id: roleDoc.id,
...roleDoc.data(),
};
}
}
res.status(200).json({user: userData});
} catch (error) {
logger.error("Error fetching user:", error);
res.status(500).json({error: error.message});
}
};
// Récupère l'utilisateur actuellement authentifié avec son rôle
exports.getCurrentUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const userId = decodedToken.uid;
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
res.status(404).json({error: "User not found"});
return;
}
const userData = userDoc.data();
let roleData = null;
if (userData.role) {
const roleDoc = await userData.role.get();
if (roleDoc.exists) {
roleData = {id: roleDoc.id, ...roleDoc.data()};
}
}
res.status(200).json({
user: {
uid: userId,
...helpers.serializeTimestamps(userData),
role: roleData,
},
});
} catch (error) {
logger.error("Error getting current user:", error);
res.status(500).json({error: error.message});
}
};
// Récupère tous les rôles
exports.getRoles = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("roles").get();
const roles = snapshot.docs.map((doc) => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data()),
}));
res.status(200).json({roles});
} catch (error) {
logger.error("Error fetching roles:", error);
res.status(500).json({error: error.message});
}
};
+20 -20
View File
@@ -1,24 +1,24 @@
/**
* Utilitaires d'authentification et d'autorisation
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
/**
* Vérifie le token Firebase et retourne l'utilisateur
*/
async function authenticateUser(req) {
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) {
throw new Error('Unauthorized: No token provided');
if (!req.headers.authorization || !req.headers.authorization.startsWith("Bearer ")) {
throw new Error("Unauthorized: No token provided");
}
const idToken = req.headers.authorization.split('Bearer ')[1];
const idToken = req.headers.authorization.split("Bearer ")[1];
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
return decodedToken;
} catch (e) {
logger.error("Error verifying Firebase ID token:", e);
throw new Error('Unauthorized: Invalid token');
throw new Error("Unauthorized: Invalid token");
}
}
@@ -26,11 +26,11 @@ async function authenticateUser(req) {
* Récupère les données utilisateur depuis Firestore
*/
async function getUserData(uid) {
const userDoc = await admin.firestore().collection('users').doc(uid).get();
const userDoc = await admin.firestore().collection("users").doc(uid).get();
if (!userDoc.exists) {
return null;
}
return { uid, ...userDoc.data() };
return {uid, ...userDoc.data()};
}
/**
@@ -40,7 +40,7 @@ async function getRolePermissions(roleRef) {
if (!roleRef) return [];
let roleId;
if (typeof roleRef === 'string') {
if (typeof roleRef === "string") {
roleId = roleRef;
} else if (roleRef.id) {
roleId = roleRef.id;
@@ -48,7 +48,7 @@ async function getRolePermissions(roleRef) {
return [];
}
const roleDoc = await admin.firestore().collection('roles').doc(roleId).get();
const roleDoc = await admin.firestore().collection("roles").doc(roleId).get();
if (!roleDoc.exists) return [];
return roleDoc.data().permissions || [];
@@ -74,7 +74,7 @@ async function isAdmin(uid) {
let roleId;
const roleField = userData.role;
if (typeof roleField === 'string') {
if (typeof roleField === "string") {
roleId = roleField;
} else if (roleField && roleField.id) {
roleId = roleField.id;
@@ -82,22 +82,22 @@ async function isAdmin(uid) {
return false;
}
return roleId === 'ADMIN';
return roleId === "ADMIN";
}
/**
* Vérifie si l'utilisateur est assigné à un événement
*/
async function isAssignedToEvent(uid, eventId) {
const eventDoc = await admin.firestore().collection('events').doc(eventId).get();
const eventDoc = await admin.firestore().collection("events").doc(eventId).get();
if (!eventDoc.exists) return false;
const eventData = eventDoc.data();
const workforce = eventData.workforce || [];
// workforce contient des références DocumentReference
return workforce.some(ref => {
if (typeof ref === 'string') return ref === uid;
return workforce.some((ref) => {
if (typeof ref === "string") return ref === uid;
if (ref && ref.id) return ref.id === uid;
return false;
});
@@ -113,7 +113,7 @@ async function authMiddleware(req, res, next) {
req.uid = decodedToken.uid;
next();
} catch (error) {
res.status(401).json({ error: error.message });
res.status(401).json({error: error.message});
}
}
@@ -125,12 +125,12 @@ function requirePermission(permission) {
try {
const hasAccess = await hasPermission(req.uid, permission);
if (!hasAccess) {
res.status(403).json({ error: `Forbidden: Requires permission '${permission}'` });
res.status(403).json({error: `Forbidden: Requires permission '${permission}'`});
return;
}
next();
} catch (error) {
res.status(403).json({ error: error.message });
res.status(403).json({error: error.message});
}
};
}
@@ -142,12 +142,12 @@ async function requireAdmin(req, res, next) {
try {
const adminAccess = await isAdmin(req.uid);
if (!adminAccess) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
next();
} catch (error) {
res.status(403).json({ error: error.message });
res.status(403).json({error: error.message});
}
}
+8 -8
View File
@@ -7,12 +7,12 @@
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
const getSmtpConfig = () => {
return {
host: process.env.SMTP_HOST || 'mail.em2events.fr',
port: parseInt(process.env.SMTP_PORT || '465'),
host: process.env.SMTP_HOST || "mail.em2events.fr",
port: parseInt(process.env.SMTP_PORT || "465"),
secure: true, // true pour port 465, false pour autres ports
auth: {
user: process.env.SMTP_USER || 'notify@em2events.fr',
pass: process.env.SMTP_PASS || '',
user: process.env.SMTP_USER || "notify@em2events.fr",
pass: process.env.SMTP_PASS || "",
},
tls: {
// Ne pas échouer sur certificats invalides
@@ -24,12 +24,12 @@ const getSmtpConfig = () => {
// Configuration email par défaut
const EMAIL_CONFIG = {
from: {
name: 'EM2 Events',
address: 'notify@em2events.fr',
name: "EM2 Events",
address: "notify@em2events.fr",
},
replyTo: 'contact@em2events.fr',
replyTo: "contact@em2events.fr",
// URL de l'application pour les liens
appUrl: process.env.APP_URL || 'https://app.em2events.fr',
appUrl: process.env.APP_URL || "https://app.em2events.fr",
};
module.exports = {
+50 -50
View File
@@ -1,23 +1,23 @@
const admin = require('firebase-admin');
const handlebars = require('handlebars');
const fs = require('fs').promises;
const path = require('path');
const {EMAIL_CONFIG} = require('./emailConfig');
const admin = require("firebase-admin");
const handlebars = require("handlebars");
const fs = require("fs").promises;
const path = require("path");
const {EMAIL_CONFIG} = require("./emailConfig");
/**
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
*/
function checkAlertPreference(alertType, preferences) {
const typeMapping = {
'EVENT_CREATED': 'eventsNotifications',
'EVENT_MODIFIED': 'eventsNotifications',
'EVENT_CANCELLED': 'eventsNotifications',
'LOST': 'equipmentNotifications',
'EQUIPMENT_MISSING': 'equipmentNotifications',
'DAMAGED': 'equipmentNotifications',
'QUANTITY_MISMATCH': 'equipmentNotifications',
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
'STOCK_LOW': 'stockNotifications',
"EVENT_CREATED": "eventsNotifications",
"EVENT_MODIFIED": "eventsNotifications",
"EVENT_CANCELLED": "eventsNotifications",
"LOST": "equipmentNotifications",
"EQUIPMENT_MISSING": "equipmentNotifications",
"DAMAGED": "equipmentNotifications",
"QUANTITY_MISMATCH": "equipmentNotifications",
"MAINTENANCE_REMINDER": "maintenanceNotifications",
"STOCK_LOW": "stockNotifications",
};
const prefKey = typeMapping[alertType];
@@ -29,12 +29,12 @@ function checkAlertPreference(alertType, preferences) {
*/
async function prepareTemplateData(alert, user) {
const data = {
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'Utilisateur',
userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() ||
"Utilisateur",
alertTitle: getAlertTitle(alert.type),
alertMessage: alert.message,
isCritical: alert.severity === 'CRITICAL',
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
isCritical: alert.severity === "CRITICAL",
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`,
appUrl: EMAIL_CONFIG.appUrl,
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
year: new Date().getFullYear(),
@@ -45,20 +45,20 @@ async function prepareTemplateData(alert, user) {
if (alert.eventId) {
try {
const eventDoc = await admin.firestore()
.collection('events')
.collection("events")
.doc(alert.eventId)
.get();
if (eventDoc.exists) {
const event = eventDoc.data();
data.eventName = event.Name || event.name || 'Événement';
data.eventName = event.Name || event.name || "Événement";
if (event.StartDateTime || event.startDate) {
const dateField = event.StartDateTime || event.startDate;
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
data.eventDate = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
data.eventDate = date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
}
@@ -70,7 +70,7 @@ async function prepareTemplateData(alert, user) {
if (alert.equipmentId) {
try {
const eqDoc = await admin.firestore()
.collection('equipments')
.collection("equipments")
.doc(alert.equipmentId)
.get();
@@ -90,18 +90,18 @@ async function prepareTemplateData(alert, user) {
*/
function getEmailSubject(alert) {
const subjects = {
'EVENT_CREATED': '📅 Nouvel événement créé',
'EVENT_MODIFIED': '📝 Événement modifié',
'EVENT_CANCELLED': '❌ Événement annulé',
'LOST': '🔴 Alerte critique : Équipement perdu',
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
'DAMAGED': '⚠️ Équipement endommagé',
'QUANTITY_MISMATCH': '️ Quantité incorrecte',
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
'STOCK_LOW': '📦 Stock faible',
"EVENT_CREATED": "📅 Nouvel événement créé",
"EVENT_MODIFIED": "📝 Événement modifié",
"EVENT_CANCELLED": "❌ Événement annulé",
"LOST": "🔴 Alerte critique : Équipement perdu",
"EQUIPMENT_MISSING": "⚠️ Équipement manquant",
"DAMAGED": "⚠️ Équipement endommagé",
"QUANTITY_MISMATCH": "️ Quantité incorrecte",
"MAINTENANCE_REMINDER": "🔧 Rappel de maintenance",
"STOCK_LOW": "📦 Stock faible",
};
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events";
}
/**
@@ -109,18 +109,18 @@ function getEmailSubject(alert) {
*/
function getAlertTitle(type) {
const titles = {
'EVENT_CREATED': 'Nouvel événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'EVENT_CANCELLED': 'Événement annulé',
'LOST': 'Équipement perdu',
'EQUIPMENT_MISSING': 'Équipement manquant',
'DAMAGED': 'Équipement endommagé',
'QUANTITY_MISMATCH': 'Quantité incorrecte',
'MAINTENANCE_REMINDER': 'Maintenance requise',
'STOCK_LOW': 'Stock faible',
"EVENT_CREATED": "Nouvel événement créé",
"EVENT_MODIFIED": "Événement modifié",
"EVENT_CANCELLED": "Événement annulé",
"LOST": "Équipement perdu",
"EQUIPMENT_MISSING": "Équipement manquant",
"DAMAGED": "Équipement endommagé",
"QUANTITY_MISMATCH": "Quantité incorrecte",
"MAINTENANCE_REMINDER": "Maintenance requise",
"STOCK_LOW": "Stock faible",
};
return titles[type] || 'Nouvelle alerte';
return titles[type] || "Nouvelle alerte";
}
/**
@@ -129,17 +129,17 @@ function getAlertTitle(type) {
async function renderTemplate(templateName, data) {
try {
// Lire le template de base
const basePath = path.join(__dirname, '..', 'templates', 'base-template.html');
const baseTemplate = await fs.readFile(basePath, 'utf8');
const basePath = path.join(__dirname, "..", "templates", "base-template.html");
const baseTemplate = await fs.readFile(basePath, "utf8");
// Lire le template de contenu
const contentPath = path.join(
__dirname,
'..',
'templates',
"..",
"templates",
`${templateName}.html`,
);
const contentTemplate = await fs.readFile(contentPath, 'utf8');
const contentTemplate = await fs.readFile(contentPath, "utf8");
// Compiler les templates
const compileContent = handlebars.compile(contentTemplate);
+26 -26
View File
@@ -1,7 +1,7 @@
/**
* Helpers pour la manipulation de données Firestore
*/
const admin = require('firebase-admin');
const admin = require("firebase-admin");
/**
* Convertit les Timestamps Firestore en ISO strings pour JSON
@@ -19,7 +19,7 @@ function serializeTimestamps(data) {
return null;
}
const result = { ...data };
const result = {...data};
for (const key in result) {
const value = result[key];
@@ -29,31 +29,31 @@ function serializeTimestamps(data) {
}
// Gérer les Timestamps Firestore
if (value.toDate && typeof value.toDate === 'function') {
if (value.toDate && typeof value.toDate === "function") {
result[key] = value.toDate().toISOString();
}
// Gérer les DocumentReference
else if (value.path && value.id && typeof value.path === 'string') {
else if (value.path && value.id && typeof value.path === "string") {
result[key] = value.path;
}
// Gérer les GeoPoint
else if (value.latitude !== undefined && value.longitude !== undefined) {
result[key] = {
latitude: value.latitude,
longitude: value.longitude
longitude: value.longitude,
};
}
// Gérer les tableaux
else if (Array.isArray(value)) {
result[key] = value.map(item => {
if (!item || typeof item !== 'object') return item;
result[key] = value.map((item) => {
if (!item || typeof item !== "object") return item;
// DocumentReference dans un tableau
if (item.path && item.id) {
return item.path;
}
// Timestamp dans un tableau
if (item.toDate && typeof item.toDate === 'function') {
if (item.toDate && typeof item.toDate === "function") {
return item.toDate().toISOString();
}
// Objet normal
@@ -61,7 +61,7 @@ function serializeTimestamps(data) {
});
}
// Gérer les objets imbriqués (mais pas les objets Firestore)
else if (typeof value === 'object' && !value._firestore && !value._path) {
else if (typeof value === "object" && !value._firestore && !value._path) {
result[key] = serializeTimestamps(value);
}
}
@@ -75,10 +75,10 @@ function serializeTimestamps(data) {
function deserializeTimestamps(data, timestampFields = []) {
if (!data) return data;
const result = { ...data };
const result = {...data};
for (const field of timestampFields) {
if (result[field] && typeof result[field] === 'string') {
if (result[field] && typeof result[field] === "string") {
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
}
}
@@ -92,15 +92,15 @@ function deserializeTimestamps(data, timestampFields = []) {
function serializeReferences(data) {
if (!data) return data;
const result = { ...data };
const result = {...data};
for (const key in result) {
if (result[key] && result[key].path && typeof result[key].path === 'string') {
if (result[key] && result[key].path && typeof result[key].path === "string") {
// C'est une DocumentReference
result[key] = result[key].id;
} else if (Array.isArray(result[key])) {
result[key] = result[key].map(item => {
if (item && item.path && typeof item.path === 'string') {
result[key] = result[key].map((item) => {
if (item && item.path && typeof item.path === "string") {
return item.id;
}
return item;
@@ -117,7 +117,7 @@ function serializeReferences(data) {
function maskSensitiveFields(data, canViewSensitive) {
if (canViewSensitive) return data;
const masked = { ...data };
const masked = {...data};
// Masquer les prix si pas de permission manage_equipment
delete masked.purchasePrice;
@@ -143,34 +143,34 @@ function paginate(query, limit = 50, startAfter = null) {
* Filtre les événements annulés
*/
function filterCancelledEvents(events) {
return events.filter(event => event.status !== 'CANCELLED');
return events.filter((event) => event.status !== "CANCELLED");
}
/**
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
* @param {Object} data - Données de l'événement
* @returns {Object} - Données avec DocumentReference
* @return {Object} - Données avec DocumentReference
*/
function convertIdsToReferences(data) {
if (!data) return data;
const result = { ...data };
const result = {...data};
// Convertir EventType (ID → DocumentReference)
if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) {
result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType);
if (result.EventType && typeof result.EventType === "string" && !result.EventType.includes("/")) {
result.EventType = admin.firestore().collection("eventTypes").doc(result.EventType);
}
// Convertir customer (ID → DocumentReference)
if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) {
result.customer = admin.firestore().collection('customers').doc(result.customer);
if (result.customer && typeof result.customer === "string" && !result.customer.includes("/")) {
result.customer = admin.firestore().collection("customers").doc(result.customer);
}
// Convertir workforce (IDs → DocumentReference)
if (Array.isArray(result.workforce)) {
result.workforce = result.workforce.map(item => {
if (typeof item === 'string' && !item.includes('/')) {
return admin.firestore().collection('users').doc(item);
result.workforce = result.workforce.map((item) => {
if (typeof item === "string" && !item.includes("/")) {
return admin.firestore().collection("users").doc(item);
}
return item;
});
+1 -1
View File
@@ -1,6 +1,6 @@
/// Configuration de la version de l'application
class AppVersion {
static const String version = '1.1.19';
static const String version = '1.2.3';
/// Retourne la version complète de l'application
static String get fullVersion => 'v$version';
+152 -255
View File
@@ -1,3 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:em2rp/providers/users_provider.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart';
@@ -5,7 +9,6 @@ import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/maintenance_provider.dart';
import 'package:em2rp/providers/alert_provider.dart';
import 'package:em2rp/utils/auth_guard_widget.dart';
import 'package:em2rp/utils/performance_monitor.dart';
import 'package:em2rp/views/alerts_page.dart';
import 'package:em2rp/views/calendar_page.dart';
import 'package:em2rp/views/login_page.dart';
@@ -19,10 +22,8 @@ import 'package:em2rp/views/event_statistics_page.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'package:em2rp/services/app_initializer.dart';
import 'utils/colors.dart';
import 'views/my_account_page.dart';
import 'views/user_management_page.dart';
@@ -30,35 +31,21 @@ import 'package:provider/provider.dart';
import 'providers/local_user_provider.dart';
import 'views/reset_password_page.dart';
import 'config/env.dart';
import 'services/update_service.dart';
import 'views/widgets/common/update_dialog.dart';
import 'config/api_config.dart';
import 'utils/app_start_gate.dart';
import 'views/widgets/common/startup_splash_screen.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
void main() async {
void main() {
// Ne pas effectuer d'initialisations asynchrones lourdes ici.
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Configuration des émulateurs en mode développement
if (ApiConfig.isDevelopment) {
print('🔧 Mode développement activé - Utilisation des émulateurs');
// Configurer l'émulateur Auth
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
print('✓ Auth émulateur configuré: localhost:9199');
// Configurer l'émulateur Firestore
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
print('✓ Firestore émulateur configuré: localhost:8088');
}
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
runApp(
MultiProvider(
providers: [
// Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond)
ChangeNotifierProvider<AppInitializer>(
create: (_) => AppInitializer(),
),
// LocalUserProvider pour la gestion de l'authentification
ChangeNotifierProvider<LocalUserProvider>(
create: (context) => LocalUserProvider()),
@@ -96,241 +83,151 @@ void main() async {
);
}
class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final Future<void> _startupFuture;
@override
void initState() {
super.initState();
_startupFuture = _bootstrapApp();
}
Future<void> _bootstrapApp() async {
final initializer = context.read<AppInitializer>();
final localAuthProvider = context.read<LocalUserProvider>();
await initializer.initialize();
// Lancer la connexion automatique en dev sans bloquer le démarrage initial
if (Env.isDevelopment && FirebaseAuth.instance.currentUser == null) {
unawaited(
localAuthProvider.signInWithEmailAndPassword(
Env.devAdminEmail,
Env.devAdminPassword,
).then((_) {
return localAuthProvider.loadUserData();
}).catchError((e) {
if (kDebugMode) debugPrint('Dev auto-login failed: $e');
}),
);
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'EM2 Hub',
theme: ThemeData(
primarySwatch: Colors.red,
primaryColor: AppColors.noir,
colorScheme:
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
textTheme: const TextTheme(
bodyMedium: TextStyle(color: AppColors.noir),
),
inputDecorationTheme: const InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.noir),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.gris),
),
labelStyle: TextStyle(color: AppColors.noir),
hintStyle: TextStyle(color: AppColors.gris),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
foregroundColor: AppColors.blanc,
backgroundColor: AppColors.noir,
return FutureBuilder<void>(
future: _startupFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: StartupSplashScreen(),
);
}
return MaterialApp(
title: 'EM2 Hub',
theme: ThemeData(
primarySwatch: Colors.red,
primaryColor: AppColors.noir,
colorScheme:
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
textTheme: const TextTheme(
bodyMedium: TextStyle(color: AppColors.noir),
),
inputDecorationTheme: const InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.noir),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppColors.gris),
),
labelStyle: TextStyle(color: AppColors.noir),
hintStyle: TextStyle(color: AppColors.gris),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
foregroundColor: AppColors.blanc,
backgroundColor: AppColors.noir,
),
),
),
),
),
locale: const Locale('fr', 'FR'),
supportedLocales: const [
Locale('fr', 'FR'),
],
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
initialRoute: '/',
routes: {
'/': (context) => const AutoLoginWrapper(),
'/login': (context) => const LoginPage(),
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
'/user_management': (context) => const AuthGuard(
requiredPermission: "view_all_users", child: UserManagementPage()),
'/reset_password': (context) {
final args = ModalRoute.of(context)!.settings.arguments
as Map<String, dynamic>;
return ResetPasswordPage(
email: args['email'] 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()),
'/maintenance_management': (context) => const AuthGuard(
requiredPermission: "manage_maintenances",
child: MaintenanceManagementPage()),
'/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,
),
);
},
'/event_statistics': (context) => const AuthGuard(
requiredPermission: 'generate_reports', child: EventStatisticsPage()),
locale: const Locale('fr', 'FR'),
supportedLocales: const [
Locale('fr', 'FR'),
],
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
routes: {
'/login': (context) => const LoginPage(),
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
'/calendar': (context) => const AuthGuard(
allowWhileLoading: true, child: CalendarPage()),
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
'/user_management': (context) => const AuthGuard(
requiredPermission: "view_all_users",
child: UserManagementPage()),
'/reset_password': (context) {
final args = ModalRoute.of(context)!.settings.arguments
as Map<String, dynamic>;
return ResetPasswordPage(
email: args['email'] 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()),
'/maintenance_management': (context) => const AuthGuard(
requiredPermission: "manage_maintenances",
child: MaintenanceManagementPage()),
'/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,
),
);
},
'/event_statistics': (context) => const AuthGuard(
requiredPermission: 'generate_reports',
child: EventStatisticsPage()),
},
home: const AppStartGate(),
);
},
);
}
}
class AutoLoginWrapper extends StatefulWidget {
const AutoLoginWrapper({super.key});
@override
State<AutoLoginWrapper> createState() => _AutoLoginWrapperState();
}
class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
@override
void initState() {
super.initState();
// Attendre la fin du premier build avant de naviguer
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoLogin();
// Vérifier les mises à jour après un délai pour ne pas interférer avec l'autologin
_checkForUpdateDelayed();
});
}
/// Vérifie les mises à jour après un délai
Future<void> _checkForUpdateDelayed() async {
try {
// Attendre que l'app soit complètement chargée (navigation effectuée, etc.)
await Future.delayed(const Duration(seconds: 3));
if (!mounted) return;
final updateInfo = await UpdateService.checkForUpdate();
if (updateInfo != null && mounted) {
// Attendre encore un peu pour être sûr que le bon contexte est disponible
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
showDialog(
context: context,
barrierDismissible: !updateInfo.forceUpdate,
builder: (context) => UpdateDialog(updateInfo: updateInfo),
);
}
}
} catch (e) {
print('[AutoLoginWrapper] Error checking for update: $e');
}
}
Future<void> _autoLogin() async {
PerformanceMonitor.start('App.autoLogin');
try {
final localAuthProvider =
Provider.of<LocalUserProvider>(context, listen: false);
// Vérifier si l'utilisateur est déjà connecté
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
PerformanceMonitor.start('App.signIn');
// Connexion automatique en mode développement
await localAuthProvider.signInWithEmailAndPassword(
Env.devAdminEmail,
Env.devAdminPassword,
);
PerformanceMonitor.end('App.signIn');
}
if (mounted) {
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
// En Flutter Web, on peut vérifier window.location.hash
final currentUri = Uri.base;
final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts
print('[AutoLoginWrapper] Fragment URL: $fragment');
// Navigation immédiate sans attendre le chargement des données
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
print('[AutoLoginWrapper] Redirection vers: $fragment');
Navigator.of(context).pushReplacementNamed(fragment);
} else {
// Route par défaut : calendrier
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
Navigator.of(context).pushReplacementNamed('/calendar');
}
PerformanceMonitor.end('App.autoLogin');
PerformanceMonitor.printSummary();
// Charger les données utilisateur en arrière-plan
localAuthProvider.loadUserData().catchError((e) {
print('Error loading user data: $e');
});
}
} catch (e) {
print('Auto login failed: $e');
PerformanceMonitor.end('App.autoLogin');
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo de l'application
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 200,
height: 200,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 80,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 40),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
),
const SizedBox(height: 20),
const Text(
'Chargement...',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
fontWeight: FontWeight.w400,
),
),
],
),
),
);
}
}
+6
View File
@@ -174,6 +174,7 @@ ReturnStatus returnStatusFromString(String? status) {
class EventEquipment {
final String equipmentId; // ID de l'équipement
final int quantity; // Quantité initiale assignée
final String? rationale; // Explication/Justification (ex: IA alternative)
final bool isPrepared; // Validé en préparation
final bool isLoaded; // Validé au chargement
final bool isUnloaded; // Validé au déchargement
@@ -194,6 +195,7 @@ class EventEquipment {
EventEquipment({
required this.equipmentId,
this.quantity = 1,
this.rationale,
this.isPrepared = false,
this.isLoaded = false,
this.isUnloaded = false,
@@ -212,6 +214,7 @@ class EventEquipment {
return EventEquipment(
equipmentId: map['equipmentId'] ?? '',
quantity: map['quantity'] ?? 1,
rationale: map['rationale'],
isPrepared: map['isPrepared'] ?? false,
isLoaded: map['isLoaded'] ?? false,
isUnloaded: map['isUnloaded'] ?? false,
@@ -231,6 +234,7 @@ class EventEquipment {
return {
'equipmentId': equipmentId,
'quantity': quantity,
'rationale': rationale,
'isPrepared': isPrepared,
'isLoaded': isLoaded,
'isUnloaded': isUnloaded,
@@ -249,6 +253,7 @@ class EventEquipment {
EventEquipment copyWith({
String? equipmentId,
int? quantity,
String? rationale,
bool? isPrepared,
bool? isLoaded,
bool? isUnloaded,
@@ -265,6 +270,7 @@ class EventEquipment {
return EventEquipment(
equipmentId: equipmentId ?? this.equipmentId,
quantity: quantity ?? this.quantity,
rationale: rationale ?? this.rationale,
isPrepared: isPrepared ?? this.isPrepared,
isLoaded: isLoaded ?? this.isLoaded,
isUnloaded: isUnloaded ?? this.isUnloaded,
+5 -5
View File
@@ -40,7 +40,7 @@ class AlertProvider extends ChangeNotifier {
return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
}).toList();
} catch (e) {
print('Error loading alerts: $e');
if (kDebugMode) debugPrint('Error loading alerts: $e');
_alerts = [];
} finally {
_isLoading = false;
@@ -67,7 +67,7 @@ class AlertProvider extends ChangeNotifier {
notifyListeners();
}
} catch (e) {
print('Error marking alert as read: $e');
if (kDebugMode) debugPrint('Error marking alert as read: $e');
rethrow;
}
}
@@ -81,7 +81,7 @@ class AlertProvider extends ChangeNotifier {
_alerts.removeWhere((a) => a.id == alertId);
notifyListeners();
} catch (e) {
print('Error deleting alert: $e');
if (kDebugMode) debugPrint('Error deleting alert: $e');
rethrow;
}
}
@@ -95,7 +95,7 @@ class AlertProvider extends ChangeNotifier {
await markAsRead(alertId);
}
} catch (e) {
print('Error marking all alerts as read: $e');
if (kDebugMode) debugPrint('Error marking all alerts as read: $e');
rethrow;
}
}
@@ -109,7 +109,7 @@ class AlertProvider extends ChangeNotifier {
await deleteAlert(alertId);
}
} catch (e) {
print('Error deleting read alerts: $e');
if (kDebugMode) debugPrint('Error deleting read alerts: $e');
rethrow;
}
}
@@ -1,62 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/alert_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class AlertProvider extends ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
List<AlertModel> _alerts = [];
bool _isLoading = false;
List<AlertModel> get alerts => _alerts;
bool get isLoading => _isLoading;
/// Nombre d'alertes non lues
int get unreadCount => _alerts.where((a) => !a.isRead).length;
/// Charger toutes les alertes via l'API
Future<void> loadAlerts() async {
_isLoading = true;
notifyListeners();
try {
final alertsData = await _dataService.getAlerts();
_alerts = alertsData.map((data) {
return AlertModel.fromMap(data, data['id'] as String);
}).toList();
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error loading alerts: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharger les alertes
Future<void> refresh() async {
await loadAlerts();
}
/// Obtenir les alertes non lues
List<AlertModel> get unreadAlerts {
return _alerts.where((a) => !a.isRead).toList();
}
/// Obtenir les alertes par type
List<AlertModel> getByType(AlertType type) {
return _alerts.where((a) => a.type == type).toList();
}
/// Obtenir les alertes critiques (stock bas, équipement perdu)
List<AlertModel> get criticalAlerts {
return _alerts.where((a) =>
a.type == AlertType.lowStock || a.type == AlertType.lost
).toList();
}
}
+7 -7
View File
@@ -66,7 +66,7 @@ class ContainerProvider with ChangeNotifier {
// Charger toutes les pages en boucle
while (hasMore) {
pageCount++;
print('[ContainerProvider] Loading page $pageCount...');
DebugLog.info('[ContainerProvider] Loading page $pageCount...');
final result = await _dataService.getContainersPaginated(
limit: 100, // Charger 100 par page pour aller plus vite
@@ -86,14 +86,14 @@ class ContainerProvider with ChangeNotifier {
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
DebugLog.info('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
}
_isLoading = false;
_isInitialized = true;
notifyListeners();
} catch (e) {
print('Error loading containers: $e');
DebugLog.error('[ContainerProvider] Error loading containers', e);
_isLoading = false;
notifyListeners();
}
@@ -292,7 +292,7 @@ class ContainerProvider with ChangeNotifier {
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
if (containerIds.isEmpty) return [];
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
DebugLog.info('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
try {
// Vérifier d'abord le cache local
@@ -320,7 +320,7 @@ class ContainerProvider with ChangeNotifier {
}
}
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
DebugLog.info('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
// Si tous sont en cache, retourner directement
if (missingIds.isEmpty) {
@@ -341,12 +341,12 @@ class ContainerProvider with ChangeNotifier {
}
}
print('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
DebugLog.info('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
// Retourner tous les conteneurs (cache + chargés)
return [...cachedContainers, ...loadedContainers];
} catch (e) {
print('[ContainerProvider] Error loading containers by IDs: $e');
DebugLog.error('[ContainerProvider] Error loading containers by IDs', e);
rethrow;
}
}
+5 -5
View File
@@ -80,7 +80,7 @@ class EquipmentProvider extends ChangeNotifier {
Future<void> loadEquipments() async {
print('[EquipmentProvider] Starting to load ALL equipments...');
_isLoading = true;
notifyListeners();
scheduleMicrotask(notifyListeners);
try {
_equipment.clear();
@@ -272,7 +272,7 @@ class EquipmentProvider extends ChangeNotifier {
_lastVisible = null;
_hasMore = true;
_isLoading = true;
notifyListeners();
scheduleMicrotask(notifyListeners);
try {
await loadNextPage();
@@ -296,7 +296,7 @@ class EquipmentProvider extends ChangeNotifier {
_isLoadingMore = true;
_isLoading = true;
notifyListeners();
scheduleMicrotask(notifyListeners);
try {
final result = await _dataService.getEquipmentsPaginated(
@@ -433,9 +433,9 @@ class EquipmentProvider extends ChangeNotifier {
}
/// Supprimer un équipement
Future<void> deleteEquipment(String equipmentId) async {
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) async {
try {
await _dataService.deleteEquipment(equipmentId);
await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete);
if (_usePagination) {
await reload();
} else {
+75 -29
View File
@@ -19,7 +19,8 @@ class EventProvider with ChangeNotifier {
bool _lastCanViewAll = false;
// Nouveau: Cache par mois pour le lazy loading
final Map<String, List<EventModel>> _eventsByMonth = {}; // "2026-02" => [events]
final Map<String, List<EventModel>> _eventsByMonth =
{}; // "2026-02" => [events]
String? _currentMonth; // Mois actuellement affiché
List<EventModel> get events => _events;
@@ -28,7 +29,8 @@ class EventProvider with ChangeNotifier {
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
bool _shouldReload(String userId, bool canViewAllEvents) {
if (_lastLoadTime == null) return true;
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true;
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents)
return true;
final now = DateTime.now();
final difference = now.difference(_lastLoadTime!);
@@ -36,12 +38,14 @@ class EventProvider with ChangeNotifier {
}
/// Charger les événements d'un utilisateur via l'API
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async {
Future<void> loadUserEvents(String userId,
{bool canViewAllEvents = false, bool forceReload = false}) async {
PerformanceMonitor.start('EventProvider.loadUserEvents');
// Éviter les rechargements inutiles
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
print(
'Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
PerformanceMonitor.end('EventProvider.loadUserEvents');
return;
}
@@ -50,7 +54,8 @@ class EventProvider with ChangeNotifier {
notifyListeners();
try {
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
print(
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
PerformanceMonitor.start('EventProvider.getEvents_API');
// Charger via l'API - les permissions sont vérifiées côté serveur
@@ -61,9 +66,8 @@ class EventProvider with ChangeNotifier {
final usersData = result['users'] as Map<String, dynamic>;
// Stocker les utilisateurs dans le cache
_usersCache = usersData.map((key, value) =>
MapEntry(key, value as Map<String, dynamic>)
);
_usersCache = usersData
.map((key, value) => MapEntry(key, value as Map<String, dynamic>));
print('Found ${eventsData.length} events from API');
@@ -74,7 +78,8 @@ class EventProvider with ChangeNotifier {
// Parser chaque événement
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(eventData, eventData['id'] as String);
final event =
EventModel.fromMap(eventData, eventData['id'] as String);
allEvents.add(event);
} catch (e) {
print('Failed to parse event ${eventData['id']}: $e');
@@ -88,7 +93,8 @@ class EventProvider with ChangeNotifier {
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
print('Successfully loaded ${_events.length} events ($failedCount failed)');
print(
'Successfully loaded ${_events.length} events ($failedCount failed)');
_isLoading = false;
notifyListeners();
@@ -104,8 +110,9 @@ class EventProvider with ChangeNotifier {
/// Charger les événements d'un mois spécifique (lazy loading optimisé)
Future<void> loadMonthEvents(String userId, int year, int month,
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async {
{bool canViewAllEvents = false,
bool forceReload = false,
bool silent = false}) async {
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
// Vérifier le cache
@@ -130,19 +137,15 @@ class EventProvider with ChangeNotifier {
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
final result = await _dataService.getEventsByMonth(
userId: userId,
year: year,
month: month
);
userId: userId, year: year, month: month);
PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
final eventsData = result['events'] as List<Map<String, dynamic>>;
final usersData = result['users'] as Map<String, dynamic>;
// Mettre à jour le cache utilisateurs (addAll pour cumuler)
_usersCache.addAll(
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>))
);
_usersCache.addAll(usersData
.map((key, value) => MapEntry(key, value as Map<String, dynamic>)));
print('[EventProvider] Found ${eventsData.length} events for $monthKey');
@@ -153,7 +156,8 @@ class EventProvider with ChangeNotifier {
// Parser les événements
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(eventData, eventData['id'] as String);
final event =
EventModel.fromMap(eventData, eventData['id'] as String);
monthEvents.add(event);
} catch (e) {
print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
@@ -176,7 +180,8 @@ class EventProvider with ChangeNotifier {
_lastUserId = userId;
_lastCanViewAll = canViewAllEvents;
print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
print(
'[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
if (!silent) {
_isLoading = false;
@@ -195,7 +200,6 @@ class EventProvider with ChangeNotifier {
/// Précharger les mois adjacents en arrière-plan
void preloadAdjacentMonths(String userId, int year, int month,
{bool canViewAllEvents = false}) {
// Mois précédent
final prevMonth = month == 1 ? 12 : month - 1;
final prevYear = month == 1 ? year - 1 : year;
@@ -230,8 +234,10 @@ class EventProvider with ChangeNotifier {
}
/// Recharger les événements (utilise le dernier userId)
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
Future<void> refreshEvents(String userId,
{bool canViewAllEvents = false}) async {
await loadUserEvents(userId,
canViewAllEvents: canViewAllEvents, forceReload: true);
}
/// Récupérer un événement spécifique par ID
@@ -243,6 +249,41 @@ class EventProvider with ChangeNotifier {
}
}
/// Recherche des événements accessibles à l'utilisateur.
Future<List<EventModel>> searchEvents({
required String userId,
required String query,
int limit = 20,
}) async {
final trimmedQuery = query.trim();
if (trimmedQuery.isEmpty) {
return [];
}
final result = await _dataService.searchEvents(
userId: userId,
query: trimmedQuery,
limit: limit,
);
final events = <EventModel>[];
for (final eventData in result) {
try {
final eventId = eventData['id'] as String?;
if (eventId == null || eventId.isEmpty) {
continue;
}
events.add(EventModel.fromMap(eventData, eventId));
} catch (e) {
print('Failed to parse searched event ${eventData['id']}: $e');
}
}
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
return events;
}
/// Ajouter un nouvel événement
Future<void> addEvent(EventModel event) async {
try {
@@ -250,7 +291,8 @@ class EventProvider with ChangeNotifier {
_events.add(event);
// Ajouter dans le cache par mois
final monthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
final monthKey =
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
if (_eventsByMonth.containsKey(monthKey)) {
_eventsByMonth[monthKey]!.add(event);
}
@@ -272,8 +314,10 @@ class EventProvider with ChangeNotifier {
_events[index] = event;
// Mettre à jour dans le cache par mois
final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
final oldMonthKey =
'${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
final newMonthKey =
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
// Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
if (oldMonthKey != newMonthKey) {
@@ -286,7 +330,8 @@ class EventProvider with ChangeNotifier {
} else {
// Même mois, juste mettre à jour
if (_eventsByMonth.containsKey(newMonthKey)) {
final monthIndex = _eventsByMonth[newMonthKey]!.indexWhere((e) => e.id == event.id);
final monthIndex = _eventsByMonth[newMonthKey]!
.indexWhere((e) => e.id == event.id);
if (monthIndex != -1) {
_eventsByMonth[newMonthKey]![monthIndex] = event;
}
@@ -308,7 +353,8 @@ class EventProvider with ChangeNotifier {
// Trouver l'événement pour obtenir sa date avant de le supprimer
final eventToDelete = _events.firstWhere((e) => e.id == eventId);
final monthKey = '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
final monthKey =
'${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
// Supprimer de _events
_events.removeWhere((event) => event.id == eventId);
+51 -5
View File
@@ -12,7 +12,7 @@ import '../utils/performance_monitor.dart';
class LocalUserProvider with ChangeNotifier {
UserModel? _currentUser;
RoleModel? _currentRole;
final FirebaseAuth _auth = FirebaseAuth.instance;
FirebaseAuth? _auth;
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
final DataService _dataService = DataService(apiService);
@@ -43,11 +43,41 @@ class LocalUserProvider with ChangeNotifier {
/// Charge les données de l'utilisateur actuel via Cloud Function
Future<void> loadUserData({bool forceReload = false}) async {
if (_auth.currentUser == null) {
// Si FirebaseAuth n'est pas encore disponible
final FirebaseAuth auth;
try {
auth = _getAuthInstance();
} catch (e) {
print('Auth instance not ready in loadUserData: $e');
return;
}
if (auth.currentUser == null) {
print('No current user in Auth');
return;
}
// Bootstrap léger : rendre l'UID disponible tout de suite pour les écrans
// qui en ont besoin, même si le profil complet n'est pas encore chargé.
if (_currentUser == null) {
final firebaseUser = auth.currentUser!;
_currentUser = UserModel(
uid: firebaseUser.uid,
email: firebaseUser.email ?? '',
firstName: '',
lastName: '',
role: 'USER',
phoneNumber: '',
profilePhotoUrl: firebaseUser.photoURL ?? '',
);
_currentRole = RoleModel(
id: 'USER',
name: '',
permissions: const [],
);
notifyListeners();
}
// Éviter les rechargements inutiles
if (!forceReload && !_shouldReloadUserData()) {
print('Using cached user data');
@@ -62,7 +92,7 @@ class LocalUserProvider with ChangeNotifier {
_isLoadingUserData = true;
PerformanceMonitor.start('LocalUserProvider.loadUserData');
print('Loading user data for: ${_auth.currentUser!.uid}');
print('Loading user data for: ${_auth!.currentUser!.uid}');
try {
// Utiliser la Cloud Function getCurrentUser
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
@@ -194,7 +224,8 @@ class LocalUserProvider with ChangeNotifier {
Future<UserCredential> signInWithEmailAndPassword(
String email, String password) async {
try {
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
final auth = _getAuthInstance();
UserCredential userCredential = await auth.signInWithEmailAndPassword(
email: email, password: password);
// Note: loadUserData() sera appelé en arrière-plan dans main.dart
// pour ne pas bloquer la navigation
@@ -206,10 +237,25 @@ class LocalUserProvider with ChangeNotifier {
/// Déconnexion
Future<void> signOut() async {
await _auth.signOut();
try {
final auth = _getAuthInstance();
await auth.signOut();
} catch (e) {
debugPrint('Error during signOut: $e');
}
clearUser();
}
FirebaseAuth _getAuthInstance() {
try {
_auth ??= FirebaseAuth.instance;
return _auth!;
} catch (e, st) {
debugPrint('[LocalUserProvider] FirebaseAuth.instance access error: $e\n$st');
throw Exception('FirebaseAuth not available');
}
}
/// Vérifie si l'utilisateur a une permission spécifique
bool hasPermission(String permission) {
return _currentRole?.permissions.contains(permission) ?? false;
@@ -1,51 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/maintenance_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class MaintenanceProvider extends ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
List<MaintenanceModel> _maintenances = [];
bool _isLoading = false;
List<MaintenanceModel> get maintenances => _maintenances;
bool get isLoading => _isLoading;
/// Charger toutes les maintenances via l'API
Future<void> loadMaintenances({String? equipmentId}) async {
_isLoading = true;
notifyListeners();
try {
final maintenancesData = await _dataService.getMaintenances(
equipmentId: equipmentId,
);
_maintenances = maintenancesData.map((data) {
return MaintenanceModel.fromMap(data, data['id'] as String);
}).toList();
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error loading maintenances: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharger les maintenances
Future<void> refresh({String? equipmentId}) async {
await loadMaintenances(equipmentId: equipmentId);
}
/// Obtenir les maintenances pour un équipement spécifique
List<MaintenanceModel> getForEquipment(String equipmentId) {
return _maintenances.where((m) =>
m.equipmentIds.contains(equipmentId)
).toList();
}
}
@@ -0,0 +1,38 @@
import 'package:em2rp/services/api_service.dart';
/// Repository pour gérer toutes les opérations sur les alertes.
class AlertRepository {
final ApiService _apiService;
AlertRepository(this._apiService);
/// Récupère toutes les alertes
Future<List<Map<String, dynamic>>> getAlerts() async {
try {
final result = await _apiService.call('getAlerts', {});
final alerts = result['alerts'] as List<dynamic>?;
if (alerts == null) return [];
return alerts.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des alertes: $e');
}
}
/// Marque une alerte comme lue
Future<void> markAlertAsRead(String alertId) async {
try {
await _apiService.call('markAlertAsRead', {'alertId': alertId});
} catch (e) {
throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e');
}
}
/// Supprime une alerte
Future<void> deleteAlert(String alertId) async {
try {
await _apiService.call('deleteAlert', {'alertId': alertId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'alerte: $e');
}
}
}
@@ -0,0 +1,128 @@
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Repository pour gérer toutes les opérations sur les conteneurs.
class ContainerRepository {
final ApiService _apiService;
ContainerRepository(this._apiService);
/// Récupère tous les conteneurs
Future<List<Map<String, dynamic>>> getContainers() async {
try {
final result = await _apiService.call('getContainers', {});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des conteneurs: $e');
}
}
/// Récupère plusieurs containers par leurs IDs
Future<List<Map<String, dynamic>>> getContainersByIds(
List<String> containerIds) async {
try {
if (containerIds.isEmpty) return [];
print(
'[ContainerRepository] Getting containers by IDs: ${containerIds.length} items');
final result = await _apiService.call('getContainersByIds', {
'containerIds': containerIds,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) {
print('[ContainerRepository] No containers in result');
return [];
}
print('[ContainerRepository] Found ${containers.length} containers by IDs');
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[ContainerRepository] Error getting containers by IDs: $e');
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
/// Récupère les containers avec pagination et filtrage
Future<Map<String, dynamic>> getContainersPaginated({
int limit = 20,
String? startAfter,
String? type,
String? status,
String? searchQuery,
String? category,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (type != null) params['type'] = type;
if (status != null) params['status'] = status;
if (category != null) params['category'] = category;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result =
await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getContainersPaginated',
params,
);
return {
'containers': (result['containers'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ??
[],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) {
DebugLog.error('[ContainerRepository] Error in getContainersPaginated', e);
throw Exception(
'Erreur lors de la récupération paginée des containers: $e');
}
}
/// Récupère les containers contenant un équipement
Future<List<Map<String, dynamic>>> getContainersByEquipment(
String equipmentId) async {
try {
final result = await _apiService.call('getContainersByEquipment', {
'equipmentId': equipmentId,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
/// Vérifie la disponibilité d'un container
Future<Map<String, dynamic>> checkContainerAvailability({
required String containerId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
try {
final result = await _apiService.call('checkContainerAvailability', {
'containerId': containerId,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
if (excludeEventId != null) 'excludeEventId': excludeEventId,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la vérification de disponibilité du container: $e');
}
}
}
@@ -0,0 +1,350 @@
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Repository pour gérer toutes les opérations sur les équipements.
class EquipmentRepository {
final ApiService _apiService;
EquipmentRepository(this._apiService);
/// Récupère tous les équipements (avec masquage des prix selon permissions)
Future<List<Map<String, dynamic>>> getEquipments() async {
try {
print('[EquipmentRepository] Calling getEquipments API...');
final result = await _apiService.call('getEquipments', {});
print('[EquipmentRepository] API call successful, parsing result...');
final equipments = result['equipments'] as List<dynamic>?;
if (equipments == null) {
print('[EquipmentRepository] No equipments in result');
return [];
}
print('[EquipmentRepository] Found ${equipments.length} equipments');
return equipments.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[EquipmentRepository] Error getting equipments: $e');
throw Exception('Erreur lors de la récupération des équipements: $e');
}
}
/// Récupère plusieurs équipements par leurs IDs
Future<List<Map<String, dynamic>>> getEquipmentsByIds(
List<String> equipmentIds) async {
try {
if (equipmentIds.isEmpty) return [];
print(
'[EquipmentRepository] Getting equipments by IDs: ${equipmentIds.length} items');
final result = await _apiService.call('getEquipmentsByIds', {
'equipmentIds': equipmentIds,
});
final equipments = result['equipments'] as List<dynamic>?;
if (equipments == null) {
print('[EquipmentRepository] No equipments in result');
return [];
}
print('[EquipmentRepository] Found ${equipments.length} equipments by IDs');
return equipments.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[EquipmentRepository] Error getting equipments by IDs: $e');
throw Exception('Erreur lors de la récupération des équipements: $e');
}
}
/// Récupère les équipements avec pagination et filtrage
Future<Map<String, dynamic>> getEquipmentsPaginated({
int limit = 20,
String? startAfter,
String? category,
String? status,
String? searchQuery,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (category != null) params['category'] = category;
if (status != null) params['status'] = status;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result =
await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getEquipmentsPaginated',
params,
);
return {
'equipments': (result['equipments'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ??
[],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) {
DebugLog.error('[EquipmentRepository] Error in getEquipmentsPaginated', e);
throw Exception(
'Erreur lors de la récupération paginée des équipements: $e');
}
}
/// Crée un équipement
Future<void> createEquipment(
String equipmentId, Map<String, dynamic> data) async {
try {
final equipmentData = Map<String, dynamic>.from(data);
equipmentData['id'] = equipmentId;
await _apiService.call('createEquipment', equipmentData);
} catch (e) {
throw Exception('Erreur lors de la création de l\'équipement: $e');
}
}
/// Met à jour un équipement
Future<void> updateEquipment(
String equipmentId, Map<String, dynamic> data) async {
try {
await _apiService.call('updateEquipment', {
'equipmentId': equipmentId,
'data': data,
});
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
}
}
/// Supprime un équipement
Future<void> deleteEquipment(String equipmentId,
{bool forceDelete = false}) async {
try {
await _apiService.call('deleteEquipment', {
'equipmentId': equipmentId,
'forceDelete': forceDelete,
});
} on ApiException {
rethrow;
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
}
}
/// Met à jour uniquement le statut d'un équipement
Future<void> updateEquipmentStatusOnly({
required String equipmentId,
String? status,
int? availableQuantity,
}) async {
try {
final data = <String, dynamic>{'equipmentId': equipmentId};
if (status != null) data['status'] = status;
if (availableQuantity != null) {
data['availableQuantity'] = availableQuantity;
}
await _apiService.call('updateEquipmentStatusOnly', data);
} catch (e) {
throw Exception(
'Erreur lors de la mise à jour du statut de l\'équipement: $e');
}
}
/// Recherche rapide (autocomplétion)
Future<List<Map<String, dynamic>>> quickSearch(
String query, {
int limit = 10,
bool includeEquipments = true,
bool includeContainers = true,
}) async {
try {
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
query,
limit: limit,
includeEquipments: includeEquipments,
includeContainers: includeContainers,
);
} catch (e) {
DebugLog.error('[EquipmentRepository] Error in quickSearch', e);
return [];
}
}
/// Recherche des équipements pour l'assistant IA avec fallback paginé.
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({
required String query,
int limit = 12,
}) async {
final normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
return [];
}
try {
final quickResults = await quickSearch(
normalizedQuery,
limit: limit,
includeEquipments: true,
includeContainers: false,
);
final equipmentResults = quickResults
.where((item) =>
(item['type']?.toString().toLowerCase() ?? '') == 'equipment')
.map(_normalizeAssistantEquipment)
.toList();
if (equipmentResults.isNotEmpty) {
return equipmentResults;
}
final paginated = await getEquipmentsPaginated(
limit: limit,
searchQuery: normalizedQuery,
sortBy: 'id',
sortOrder: 'asc',
);
final equipments =
paginated['equipments'] as List<Map<String, dynamic>>? ?? [];
return equipments.map(_normalizeAssistantEquipment).toList();
} catch (e) {
DebugLog.error('[EquipmentRepository] Error in searchEquipmentsForAssistant', e);
throw Exception('Erreur lors de la recherche de matériel: $e');
}
}
/// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA.
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
required String equipmentId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
try {
final result = await checkEquipmentAvailability(
equipmentId: equipmentId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
final available = result['available'] as bool? ?? true;
final conflicts = (result['conflicts'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map((conflict) {
final eventData =
conflict['eventData'] as Map<String, dynamic>? ?? const {};
final eventName =
(eventData['Name'] ?? conflict['eventName'] ?? '').toString();
return {
'eventId': conflict['eventId']?.toString() ?? '',
'eventName': eventName,
'overlapDays': conflict['overlapDays'] as int? ?? 0,
};
}).toList();
return {
'equipmentId': equipmentId,
'available': available,
'conflictCount': conflicts.length,
'conflicts': conflicts,
};
} catch (e) {
DebugLog.error(
'[EquipmentRepository] Error in checkEquipmentAvailabilityForAssistant', e);
throw Exception('Erreur lors de la vérification de disponibilité: $e');
}
}
/// Vérifie la disponibilité d'un équipement
Future<Map<String, dynamic>> checkEquipmentAvailability({
required String equipmentId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
try {
final result = await _apiService.call('checkEquipmentAvailability', {
'equipmentId': equipmentId,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
if (excludeEventId != null) 'excludeEventId': excludeEventId,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la vérification de disponibilité: $e');
}
}
/// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période
/// Optimisé : une seule requête au lieu d'une par équipement
Future<Map<String, dynamic>> getConflictingEquipmentIds({
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
int installationTime = 0,
int disassemblyTime = 0,
}) async {
try {
final result = await _apiService.call('getConflictingEquipmentIds', {
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
if (excludeEventId != null) 'excludeEventId': excludeEventId,
'installationTime': installationTime,
'disassemblyTime': disassemblyTime,
});
return result;
} catch (e) {
throw Exception(
'Erreur lors de la récupération des équipements en conflit: $e');
}
}
Map<String, dynamic> _normalizeAssistantEquipment(Map<String, dynamic> item) {
return {
'id': (item['id'] ?? '').toString(),
'name': (item['name'] ?? item['id'] ?? '').toString(),
'category': (item['category'] ?? '').toString(),
'status': (item['status'] ?? '').toString(),
'brand': item['brand']?.toString(),
'model': item['model']?.toString(),
'availableQuantity': item['availableQuantity'],
'totalQuantity': item['totalQuantity'],
};
}
/// Récupère toutes les maintenances
Future<List<Map<String, dynamic>>> getMaintenances(
{String? equipmentId}) async {
try {
final data = <String, dynamic>{};
if (equipmentId != null) data['equipmentId'] = equipmentId;
final result = await _apiService.call('getMaintenances', data);
final maintenances = result['maintenances'] as List<dynamic>?;
if (maintenances == null) return [];
return maintenances.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des maintenances: $e');
}
}
/// Supprime une maintenance
Future<void> deleteMaintenance(String maintenanceId) async {
try {
await _apiService
.call('deleteMaintenance', {'maintenanceId': maintenanceId});
} catch (e) {
throw Exception('Erreur lors de la suppression de la maintenance: $e');
}
}
}
@@ -0,0 +1,179 @@
import 'package:em2rp/services/api_service.dart';
/// Repository pour gérer toutes les opérations sur les événements.
class EventRepository {
final ApiService _apiService;
EventRepository(this._apiService);
/// Met à jour les équipements d'un événement
Future<void> updateEventEquipment({
required String eventId,
List<Map<String, dynamic>>? assignedEquipment,
String? preparationStatus,
String? loadingStatus,
String? unloadingStatus,
String? returnStatus,
}) async {
try {
final data = <String, dynamic>{'eventId': eventId};
if (assignedEquipment != null) {
data['assignedEquipment'] = assignedEquipment;
}
if (preparationStatus != null) {
data['preparationStatus'] = preparationStatus;
}
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
if (returnStatus != null) data['returnStatus'] = returnStatus;
await _apiService.call('updateEventEquipment', data);
} catch (e) {
throw Exception(
'Erreur lors de la mise à jour des équipements de l\'événement: $e');
}
}
/// Met à jour un événement
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
try {
final requestData = {'eventId': eventId, ...data};
await _apiService.call('updateEvent', requestData);
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
}
}
/// Supprime un événement
Future<void> deleteEvent(String eventId) async {
try {
await _apiService.call('deleteEvent', {'eventId': eventId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'événement: $e');
}
}
/// Récupère les événements utilisant un type d'événement donné
Future<List<Map<String, dynamic>>> getEventsByEventType(
String eventTypeId) async {
try {
final result = await _apiService
.call('getEventsByEventType', {'eventTypeId': eventTypeId});
final events = result['events'] as List<dynamic>?;
if (events == null) return [];
return events.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des événements: $e');
}
}
/// Récupère tous les événements (filtrés selon permissions)
/// Retourne { events: List<Map>, users: Map<String, Map> }
Future<Map<String, dynamic>> getEvents({String? userId}) async {
try {
final data = <String, dynamic>{};
if (userId != null) data['userId'] = userId;
final result = await _apiService.call('getEvents', data);
// Extraire events et users
final events = result['events'] as List<dynamic>? ?? [];
final users = result['users'] as Map<String, dynamic>? ?? {};
return {
'events': events.map((e) => e as Map<String, dynamic>).toList(),
'users': users,
};
} catch (e) {
throw Exception('Erreur lors de la récupération des événements: $e');
}
}
/// Récupère les événements d'un mois spécifique (lazy loading optimisé)
Future<Map<String, dynamic>> getEventsByMonth({
required String userId,
required int year,
required int month,
}) async {
try {
print('[EventRepository] Calling getEventsByMonth for $year-$month');
final result = await _apiService.call('getEventsByMonth', {
'userId': userId,
'year': year,
'month': month,
});
// Extraire events et users
final events = result['events'] as List<dynamic>? ?? [];
final users = result['users'] as Map<String, dynamic>? ?? {};
print(
'[EventRepository] Events loaded for $year-$month: ${events.length} events');
return {
'events': events.map((e) => e as Map<String, dynamic>).toList(),
'users': users,
};
} catch (e) {
print('[EventRepository] Error getting events by month: $e');
throw Exception(
'Erreur lors de la récupération des événements du mois: $e');
}
}
/// Recherche des événements accessibles à l'utilisateur.
Future<List<Map<String, dynamic>>> searchEvents({
required String userId,
required String query,
int limit = 20,
}) async {
try {
final result = await _apiService.call('searchEvents', {
'userId': userId,
'query': query,
'limit': limit,
});
final events = result['events'] as List<dynamic>?;
if (events == null) {
return [];
}
return events.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la recherche d\'événements: $e');
}
}
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
try {
print('[EventRepository] Getting event with details: $eventId');
final result = await _apiService.call('getEventWithDetails', {
'eventId': eventId,
});
final event = result['event'] as Map<String, dynamic>?;
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
final containers = result['containers'] as Map<String, dynamic>? ?? {};
if (event == null) {
throw Exception('Event not found');
}
print(
'[EventRepository] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
return {
'event': event,
'equipments': equipments,
'containers': containers,
};
} catch (e) {
print('[EventRepository] Error getting event with details: $e');
throw Exception(
'Erreur lors de la récupération de l\'événement avec détails: $e');
}
}
}
@@ -0,0 +1,109 @@
import 'package:em2rp/services/api_service.dart';
/// Repository pour gérer toutes les opérations sur les options et types d'événements.
class OptionRepository {
final ApiService _apiService;
OptionRepository(this._apiService);
/// Récupère toutes les options
Future<List<Map<String, dynamic>>> getOptions() async {
try {
final result = await _apiService.call('getOptions', {});
final options = result['options'] as List<dynamic>?;
if (options == null) return [];
return options.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des options: $e');
}
}
/// Récupère tous les types d'événements
Future<List<Map<String, dynamic>>> getEventTypes() async {
try {
final result = await _apiService.call('getEventTypes', {});
final eventTypes = result['eventTypes'] as List<dynamic>?;
if (eventTypes == null) return [];
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception(
'Erreur lors de la récupération des types d\'événements: $e');
}
}
/// Crée un type d'événement
Future<String> createEventType({
required String name,
required double defaultPrice,
}) async {
try {
final result = await _apiService.call('createEventType', {
'name': name,
'defaultPrice': defaultPrice,
});
return result['id'] as String;
} catch (e) {
throw Exception('Erreur lors de la création du type d\'événement: $e');
}
}
/// Met à jour un type d'événement
Future<void> updateEventType({
required String eventTypeId,
String? name,
double? defaultPrice,
}) async {
try {
final data = <String, dynamic>{'eventTypeId': eventTypeId};
if (name != null) data['name'] = name;
if (defaultPrice != null) data['defaultPrice'] = defaultPrice;
await _apiService.call('updateEventType', data);
} catch (e) {
throw Exception('Erreur lors de la mise à jour du type d\'événement: $e');
}
}
/// Supprime un type d'événement
Future<void> deleteEventType(String eventTypeId) async {
try {
await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId});
} catch (e) {
throw Exception('Erreur lors de la suppression du type d\'événement: $e');
}
}
/// Crée une option
Future<String> createOption(String code, Map<String, dynamic> data) async {
try {
final requestData = {
'id': code,
'code': code,
...data
};
final result = await _apiService.call('createOption', requestData);
return result['id'] as String? ?? code;
} catch (e) {
throw Exception('Erreur lors de la création de l\'option: $e');
}
}
/// Met à jour une option
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
try {
final requestData = {'optionId': optionId, 'data': data};
await _apiService.call('updateOption', requestData);
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
}
}
/// Supprime une option
Future<void> deleteOption(String optionId) async {
try {
await _apiService.call('deleteOption', {'optionId': optionId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'option: $e');
}
}
}
@@ -0,0 +1,99 @@
import 'package:em2rp/services/api_service.dart';
/// Repository pour gérer toutes les opérations sur les utilisateurs et les rôles.
class UserRepository {
final ApiService _apiService;
UserRepository(this._apiService);
/// Récupère l'utilisateur actuellement authentifié avec son rôle
Future<Map<String, dynamic>> getCurrentUser() async {
try {
print('[UserRepository] Calling getCurrentUser API...');
final result = await _apiService.call('getCurrentUser', {});
print('[UserRepository] Current user loaded successfully');
return result['user'] as Map<String, dynamic>;
} catch (e) {
print('[UserRepository] Error getting current user: $e');
throw Exception(
'Erreur lors de la récupération de l\'utilisateur actuel: $e');
}
}
/// Récupère tous les utilisateurs (selon permissions)
Future<List<Map<String, dynamic>>> getUsers() async {
try {
final result = await _apiService.call('getUsers', {});
final users = result['users'] as List<dynamic>?;
if (users == null) return [];
return users.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des utilisateurs: $e');
}
}
/// Récupère un utilisateur spécifique
Future<Map<String, dynamic>> getUser(String userId) async {
try {
final result = await _apiService.call('getUser', {'userId': userId});
return result['user'] as Map<String, dynamic>;
} catch (e) {
throw Exception('Erreur lors de la récupération de l\'utilisateur: $e');
}
}
/// Supprime un utilisateur (Auth + Firestore)
Future<void> deleteUser(String userId) async {
try {
await _apiService.call('deleteUser', {'userId': userId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'utilisateur: $e');
}
}
/// Met à jour un utilisateur
Future<void> updateUser(String userId, Map<String, dynamic> data) async {
try {
await _apiService.call('updateUser', {
'userId': userId,
'data': data,
});
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e');
}
}
/// Crée un utilisateur avec invitation par email
Future<Map<String, dynamic>> createUserWithInvite({
required String email,
required String firstName,
required String lastName,
String? phoneNumber,
required String roleId,
}) async {
try {
final result = await _apiService.call('createUserWithInvite', {
'email': email,
'firstName': firstName,
'lastName': lastName,
'phoneNumber': phoneNumber ?? '',
'roleId': roleId,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la création de l\'utilisateur: $e');
}
}
/// Récupère tous les rôles
Future<List<Map<String, dynamic>>> getRoles() async {
try {
final result = await _apiService.call('getRoles', {});
final roles = result['roles'] as List<dynamic>?;
if (roles == null) return [];
return roles.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des rôles: $e');
}
}
}
@@ -0,0 +1,280 @@
import 'dart:async';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Représente un tour de conversation dans le chat.
class AiAssistantChatTurn {
final bool isUser;
final String text;
const AiAssistantChatTurn({required this.isUser, required this.text});
}
/// Document à attacher pour demander à l'IA d'analyser un devis, etc.
class AiEquipmentDocument {
final String base64Data;
final String mimeType;
final String? fileName;
const AiEquipmentDocument({
required this.base64Data,
required this.mimeType,
this.fileName,
});
}
/// Un item proposé par l'IA dans la liste de matériel.
class AiEquipmentProposalItem {
final String equipmentId;
final int quantity;
final String rationale;
const AiEquipmentProposalItem({
required this.equipmentId,
required this.quantity,
required this.rationale,
});
}
/// Métadonnées pour un container proposé par l'IA.
class AiEquipmentProposalContainer {
final String containerId;
final String rationale;
final List<String> equipmentIds;
final List<String> matchingEquipmentIds;
final List<String> missingEquipmentIds;
final bool partial;
final bool? available;
final dynamic availabilityDetail;
const AiEquipmentProposalContainer({
required this.containerId,
required this.rationale,
this.equipmentIds = const [],
this.matchingEquipmentIds = const [],
this.missingEquipmentIds = const [],
this.partial = false,
this.available,
this.availabilityDetail,
});
}
/// Proposition complète retournée par l'IA.
class AiEquipmentProposal {
final String summary;
final List<AiEquipmentProposalItem> items;
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
final List<EventEquipment> asEventEquipment;
/// Containers (métadonnées) proposés par l'IA.
final List<AiEquipmentProposalContainer> containers;
List<String> get containerIds => containers.map((c) => c.containerId).toList();
const AiEquipmentProposal({
required this.summary,
required this.items,
required this.asEventEquipment,
required this.containers,
});
}
/// Réponse complète de l'assistant IA (message + proposition optionnelle).
class AiEquipmentAssistantResponse {
final String assistantMessage;
final AiEquipmentProposal? proposal;
final List<String> debugLogs;
const AiEquipmentAssistantResponse({
required this.assistantMessage,
this.proposal,
this.debugLogs = const [],
});
}
/// Service assistant IA logisticien.
/// Délègue tous les appels Gemini à la Cloud Function [aiEquipmentProposal].
/// L'authentification Firebase (token Bearer) suffit — aucune clé API côté client.
class AiEquipmentAssistantService {
final ApiService _apiService;
AiEquipmentAssistantService({ApiService? apiService})
: _apiService = apiService ?? FirebaseFunctionsApiService();
/// Envoie un message et retourne la réponse de l'assistant IA.
Future<AiEquipmentAssistantResponse> generateProposal({
required DateTime startDate,
required DateTime endDate,
required List<AiAssistantChatTurn> history,
required String userMessage,
String? eventTypeId,
String? excludeEventId,
List<EventEquipment> currentAssignedEquipment = const [],
List<EventEquipment> workingProposalEquipment = const [],
AiEquipmentDocument? document,
}) async {
final payload = <String, dynamic>{
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
'userMessage': userMessage.trim(),
'history': history
.where((turn) => turn.text.trim().isNotEmpty)
.map((turn) => {'isUser': turn.isUser, 'text': turn.text.trim()})
.toList(),
'currentEquipment': currentAssignedEquipment
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
.toList(),
'workingProposal': workingProposalEquipment
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
.toList(),
};
if (eventTypeId != null) payload['eventTypeId'] = eventTypeId;
if (excludeEventId != null) payload['excludeEventId'] = excludeEventId;
if (document != null) {
payload['document'] = {
'mimeType': document.mimeType,
'data': document.base64Data,
if (document.fileName != null) 'fileName': document.fileName,
};
}
try {
DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function');
final result = await _apiService.call('aiEquipmentProposal', payload);
final assistantMessage = result['assistantMessage']?.toString().trim() ?? '';
final proposal = _parseProposal(result['proposal']);
final rawLogs = result['debugLogs'];
final debugLogs = (rawLogs is List) ? rawLogs.map((e) => e.toString()).toList() : <String>[];
DebugLog.info(
'[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}',
);
return AiEquipmentAssistantResponse(
assistantMessage: assistantMessage.isNotEmpty
? assistantMessage
: 'Je n\'ai pas pu générer de réponse.',
proposal: proposal,
debugLogs: debugLogs,
);
} on ApiException catch (e) {
DebugLog.error('[AiEquipmentAssistantService] API error', e);
if (e.isUnauthorized) {
throw Exception('Vous n\'êtes pas authentifié. Reconnectez-vous et réessayez.');
}
throw Exception('Erreur du service IA (${e.statusCode}): ${e.message}');
} catch (e) {
DebugLog.error('[AiEquipmentAssistantService] Error', e);
rethrow;
}
}
AiEquipmentProposal? _parseProposal(dynamic rawProposal) {
if (rawProposal == null || rawProposal is! Map<String, dynamic>) return null;
final proposalItems = <AiEquipmentProposalItem>[];
final eventEquipmentList = <EventEquipment>[];
// legacy containerIds variable removed (we now use containersMeta)
final rawItems = rawProposal['items'];
if (rawItems is List) {
for (final rawItem in rawItems) {
if (rawItem is! Map) continue;
final item = Map<String, dynamic>.from(rawItem);
final equipmentId = item['equipmentId']?.toString().trim() ?? '';
final quantity = int.tryParse(item['quantity']?.toString() ?? '1') ?? 1;
if (equipmentId.isEmpty || quantity <= 0) continue;
final rationale = item['rationale']?.toString().trim() ?? 'Proposition IA';
proposalItems.add(AiEquipmentProposalItem(
equipmentId: equipmentId,
quantity: quantity,
rationale: rationale,
));
eventEquipmentList.add(EventEquipment(
equipmentId: equipmentId,
quantity: quantity,
rationale: rationale,
));
}
}
final containersMeta = <AiEquipmentProposalContainer>[];
final rawContainers = rawProposal['containers'];
if (rawContainers is List) {
for (final rawContainer in rawContainers) {
if (rawContainer is String) {
final cid = rawContainer.toString().trim();
if (cid.isNotEmpty) {
containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA'));
}
continue;
}
if (rawContainer is! Map) continue;
final container = Map<String, dynamic>.from(rawContainer);
final containerId = container['containerId']?.toString().trim() ?? '';
if (containerId.isEmpty) continue;
final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA';
final equipmentIds = <String>[];
final matching = <String>[];
final missing = <String>[];
if (container['equipmentIds'] is List) {
for (final v in container['equipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) equipmentIds.add(s);
}
}
if (container['matchingEquipmentIds'] is List) {
for (final v in container['matchingEquipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) matching.add(s);
}
}
if (container['missingEquipmentIds'] is List) {
for (final v in container['missingEquipmentIds']) {
final s = v == null ? null : v.toString().trim();
if (s != null && s.isNotEmpty) missing.add(s);
}
}
final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty);
final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null;
final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null;
containersMeta.add(AiEquipmentProposalContainer(
containerId: containerId,
rationale: rationale,
equipmentIds: equipmentIds,
matchingEquipmentIds: matching,
missingEquipmentIds: missing,
partial: partial,
available: available,
availabilityDetail: availabilityDetail,
));
}
}
if (proposalItems.isEmpty && containersMeta.isEmpty) return null;
return AiEquipmentProposal(
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
? rawProposal['summary'].toString().trim()
: 'Proposition matériel générée automatiquement.',
items: proposalItems,
asEventEquipment: eventEquipmentList,
containers: containersMeta,
);
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import 'api_service.dart' show FirebaseFunctionsApiService;
/// Architecture simplifiée : le client appelle uniquement les Cloud Functions
/// Toute la logique métier est gérée côté backend
class AlertService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseAuth _auth = FirebaseAuth.instance;
FirebaseFirestore get _firestore => FirebaseFirestore.instance;
FirebaseAuth get _auth => FirebaseAuth.instance;
/// Stream des alertes pour l'utilisateur connecté
Stream<List<AlertModel>> getAlertsStream() {
+2
View File
@@ -173,6 +173,8 @@ class FirebaseFunctionsApiService implements ApiService {
statusCode: response.statusCode,
);
}
} on ApiException {
rethrow;
} catch (e) {
DebugLog.error('[API] Error during request: $functionName', e);
throw ApiException(
+79
View File
@@ -0,0 +1,79 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../firebase_options.dart';
import '../config/api_config.dart';
import 'cache_service.dart';
/// Service responsable des initialisations lourdes en tâche de fond.
///
/// Objectif : réduire au maximum le travail synchrone dans main(),
/// afficher immédiatement une UI minimale, puis effectuer l'init asynchrone.
class AppInitializer with ChangeNotifier {
bool _isInitialized = false;
bool _isInitializing = false;
bool get isInitialized => _isInitialized;
bool get isInitializing => _isInitializing;
final CacheService cacheService = CacheService();
/// Démarre l'initialisation asynchrone. Idempotent.
Future<void> initialize() async {
if (_isInitialized || _isInitializing) return;
_isInitializing = true;
scheduleMicrotask(() => notifyListeners());
try {
// Initialiser Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Configurer les émulateurs en dev si demandé
if (ApiConfig.isDevelopment) {
try {
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
} catch (e) {
// Ignorer si non supporté
if (kDebugMode) print('Emulator setup failed: $e');
}
}
// Initialiser le cache local sans bloquer l'écran de démarrage.
unawaited(cacheService.init());
// Précharger des assets critiques de façon asynchrone
unawaited(_preloadAssets());
// TODO: lancer ici d'autres initialisations non bloquantes
_isInitialized = true;
_isInitializing = false;
notifyListeners();
} catch (e, st) {
if (kDebugMode) print('AppInitializer failed: $e\n$st');
_isInitializing = false;
// Ne rethrow pas pour éviter de planter l'app; laisser l'UI gérer les erreurs.
notifyListeners();
}
}
Future<void> _preloadAssets() async {
try {
// Charger quelques assets en mémoire pour rendre l'affichage initial fluide
await rootBundle.load('assets/logos/RectangleLogoBlack.png');
await rootBundle.load('assets/logos/SquareLogoWhite.png');
} catch (e) {
if (kDebugMode) print('Preload assets failed: $e');
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Service simple de cache local basé sur SharedPreferences.
///
/// Fonctionne sur mobile et sur Flutter Web pour conserver des données
/// locales légères quand cela apporte une vraie valeur.
class CacheService {
SharedPreferences? _prefs;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
bool ready() => _prefs != null;
Future<void> setJson(String key, Map<String, dynamic> value) async {
if (_prefs == null) return;
await _prefs!.setString(key, jsonEncode(value));
}
Map<String, dynamic>? getJson(String key) {
if (_prefs == null) return null;
final s = _prefs!.getString(key);
if (s == null) return null;
try {
return jsonDecode(s) as Map<String, dynamic>;
} catch (e) {
if (kDebugMode) print('CacheService getJson error: $e');
return null;
}
}
Future<void> setString(String key, String value) async {
if (_prefs == null) return;
await _prefs!.setString(key, value);
}
String? getString(String key) => _prefs?.getString(key);
}
+15 -38
View File
@@ -141,7 +141,6 @@ class ContainerService {
}
}
/// Vérifier la disponibilité d'un container et de son contenu pour un événement
Future<Map<String, dynamic>> checkContainerAvailability({
required String containerId,
required DateTime startDate,
@@ -149,43 +148,21 @@ class ContainerService {
String? excludeEventId,
}) async {
try {
final container = await getContainerById(containerId);
if (container == null) {
return {'available': false, 'message': 'Container non trouvé'};
}
// Vérifier le statut du container
if (container.status != EquipmentStatus.available) {
return {
'available': false,
'message': 'Container ${container.name} n\'est pas disponible (statut: ${container.status})',
};
}
// Vérifier la disponibilité de chaque équipement dans le container
List<String> unavailableEquipment = [];
if (container.equipmentIds.isNotEmpty) {
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
for (var data in equipmentsData) {
final id = data['id'] as String;
final equipment = EquipmentModel.fromMap(data, id);
if (equipment.status != EquipmentStatus.available) {
unavailableEquipment.add('${equipment.name} (${equipment.status})');
}
}
}
if (unavailableEquipment.isNotEmpty) {
return {
'available': false,
'message': 'Certains équipements ne sont pas disponibles',
'unavailableItems': unavailableEquipment,
};
}
return {'available': true, 'message': 'Container et tout son contenu disponibles'};
final result = await _dataService.checkContainerAvailability(
containerId: containerId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
return {
'available': result['isAvailable'] ?? false,
'message': result['isAvailable'] == true
? 'Container et tout son contenu disponibles'
: 'Container non disponible ou en conflit',
'conflictType': result['conflictType'],
'containerConflicts': result['containerConflicts'],
'equipmentConflicts': result['equipmentConflicts'],
};
} catch (e) {
print('Error checking container availability: $e');
return {'available': false, 'message': 'Erreur: $e'};
+200 -641
View File
@@ -1,49 +1,55 @@
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/repositories/event_repository.dart';
import 'package:em2rp/repositories/equipment_repository.dart';
import 'package:em2rp/repositories/container_repository.dart';
import 'package:em2rp/repositories/alert_repository.dart';
import 'package:em2rp/repositories/user_repository.dart';
import 'package:em2rp/repositories/option_repository.dart';
/// Service générique pour les opérations de lecture de données via Cloud Functions
/// Service façade pour rétrocompatibilité.
/// Délègue les opérations aux Repositories de domaine respectifs.
class DataService {
final ApiService _apiService;
final EventRepository eventRepository;
final EquipmentRepository equipmentRepository;
final ContainerRepository containerRepository;
final AlertRepository alertRepository;
final UserRepository userRepository;
final OptionRepository optionRepository;
DataService(this._apiService);
DataService(ApiService apiService)
: eventRepository = EventRepository(apiService),
equipmentRepository = EquipmentRepository(apiService),
containerRepository = ContainerRepository(apiService),
alertRepository = AlertRepository(apiService),
userRepository = UserRepository(apiService),
optionRepository = OptionRepository(apiService);
/// Récupère toutes les options
Future<List<Map<String, dynamic>>> getOptions() async {
try {
final result = await _apiService.call('getOptions', {});
final options = result['options'] as List<dynamic>?;
if (options == null) return [];
return options.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des options: $e');
}
}
// ============================================================================
// OPTIONS & METADATA (delegated to OptionRepository)
// ============================================================================
Future<List<Map<String, dynamic>>> getOptions() => optionRepository.getOptions();
/// Récupère tous les types d'événements
Future<List<Map<String, dynamic>>> getEventTypes() async {
try {
final result = await _apiService.call('getEventTypes', {});
final eventTypes = result['eventTypes'] as List<dynamic>?;
if (eventTypes == null) return [];
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des types d\'événements: $e');
}
}
Future<List<Map<String, dynamic>>> getEventTypes() => optionRepository.getEventTypes();
/// Récupère tous les rôles
Future<List<Map<String, dynamic>>> getRoles() async {
try {
final result = await _apiService.call('getRoles', {});
final roles = result['roles'] as List<dynamic>?;
if (roles == null) return [];
return roles.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des rôles: $e');
}
}
Future<String> createEventType({required String name, required double defaultPrice}) =>
optionRepository.createEventType(name: name, defaultPrice: defaultPrice);
/// Met à jour les équipements d'un événement
Future<void> updateEventType({required String eventTypeId, String? name, double? defaultPrice}) =>
optionRepository.updateEventType(eventTypeId: eventTypeId, name: name, defaultPrice: defaultPrice);
Future<void> deleteEventType(String eventTypeId) => optionRepository.deleteEventType(eventTypeId);
Future<String> createOption(String code, Map<String, dynamic> data) =>
optionRepository.createOption(code, data);
Future<void> updateOption(String optionId, Map<String, dynamic> data) =>
optionRepository.updateOption(optionId, data);
Future<void> deleteOption(String optionId) => optionRepository.deleteOption(optionId);
// ============================================================================
// EVENTS (delegated to EventRepository)
// ============================================================================
Future<void> updateEventEquipment({
required String eventId,
List<Map<String, dynamic>>? assignedEquipment,
@@ -51,347 +57,45 @@ class DataService {
String? loadingStatus,
String? unloadingStatus,
String? returnStatus,
}) async {
try {
final data = <String, dynamic>{'eventId': eventId};
}) =>
eventRepository.updateEventEquipment(
eventId: eventId,
assignedEquipment: assignedEquipment,
preparationStatus: preparationStatus,
loadingStatus: loadingStatus,
unloadingStatus: unloadingStatus,
returnStatus: returnStatus,
);
if (assignedEquipment != null) data['assignedEquipment'] = assignedEquipment;
if (preparationStatus != null) data['preparationStatus'] = preparationStatus;
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
if (returnStatus != null) data['returnStatus'] = returnStatus;
Future<void> updateEvent(String eventId, Map<String, dynamic> data) =>
eventRepository.updateEvent(eventId, data);
await _apiService.call('updateEventEquipment', data);
} catch (e) {
throw Exception('Erreur lors de la mise à jour des équipements de l\'événement: $e');
}
}
Future<void> deleteEvent(String eventId) => eventRepository.deleteEvent(eventId);
/// Met à jour uniquement le statut d'un équipement
Future<void> updateEquipmentStatusOnly({
required String equipmentId,
String? status,
int? availableQuantity,
}) async {
try {
final data = <String, dynamic>{'equipmentId': equipmentId};
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) =>
eventRepository.getEventsByEventType(eventTypeId);
if (status != null) data['status'] = status;
if (availableQuantity != null) data['availableQuantity'] = availableQuantity;
Future<Map<String, dynamic>> getEvents({String? userId}) =>
eventRepository.getEvents(userId: userId);
await _apiService.call('updateEquipmentStatusOnly', data);
} catch (e) {
throw Exception('Erreur lors de la mise à jour du statut de l\'équipement: $e');
}
}
Future<Map<String, dynamic>> getEventsByMonth({required String userId, required int year, required int month}) =>
eventRepository.getEventsByMonth(userId: userId, year: year, month: month);
/// Met à jour un événement
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
try {
// Correction : fusionner eventId et les champs de data à la racine
final requestData = {'eventId': eventId, ...data};
await _apiService.call('updateEvent', requestData);
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
}
}
Future<List<Map<String, dynamic>>> searchEvents({required String userId, required String query, int limit = 20}) =>
eventRepository.searchEvents(userId: userId, query: query, limit: limit);
/// Supprime un événement
Future<void> deleteEvent(String eventId) async {
try {
await _apiService.call('deleteEvent', {'eventId': eventId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'événement: $e');
}
}
/// Crée un équipement
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) async {
try {
// S'assurer que l'ID est dans les données
final equipmentData = Map<String, dynamic>.from(data);
equipmentData['id'] = equipmentId;
await _apiService.call('createEquipment', equipmentData);
} catch (e) {
throw Exception('Erreur lors de la création de l\'équipement: $e');
}
}
/// Met à jour un équipement
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) async {
try {
await _apiService.call('updateEquipment', {
'equipmentId': equipmentId,
'data': data,
});
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
}
}
/// Supprime un équipement
Future<void> deleteEquipment(String equipmentId) async {
try {
await _apiService.call('deleteEquipment', {'equipmentId': equipmentId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
}
}
/// Récupère les événements utilisant un type d'événement donné
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) async {
try {
final result = await _apiService.call('getEventsByEventType', {'eventTypeId': eventTypeId});
final events = result['events'] as List<dynamic>?;
if (events == null) return [];
return events.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des événements: $e');
}
}
/// Crée un type d'événement
Future<String> createEventType({
required String name,
required double defaultPrice,
}) async {
try {
final result = await _apiService.call('createEventType', {
'name': name,
'defaultPrice': defaultPrice,
});
return result['id'] as String;
} catch (e) {
throw Exception('Erreur lors de la création du type d\'événement: $e');
}
}
/// Met à jour un type d'événement
Future<void> updateEventType({
required String eventTypeId,
String? name,
double? defaultPrice,
}) async {
try {
final data = <String, dynamic>{'eventTypeId': eventTypeId};
if (name != null) data['name'] = name;
if (defaultPrice != null) data['defaultPrice'] = defaultPrice;
await _apiService.call('updateEventType', data);
} catch (e) {
throw Exception('Erreur lors de la mise à jour du type d\'événement: $e');
}
}
/// Supprime un type d'événement
Future<void> deleteEventType(String eventTypeId) async {
try {
await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId});
} catch (e) {
throw Exception('Erreur lors de la suppression du type d\'événement: $e');
}
}
/// Crée une option
Future<String> createOption(String code, Map<String, dynamic> data) async {
try {
final requestData = {
'id': code, // Ajouter l'ID en utilisant le code comme identifiant
'code': code,
...data
};
final result = await _apiService.call('createOption', requestData);
return result['id'] as String? ?? code;
} catch (e) {
throw Exception('Erreur lors de la création de l\'option: $e');
}
}
/// Met à jour une option
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
try {
final requestData = {'optionId': optionId, 'data': data};
await _apiService.call('updateOption', requestData);
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
}
}
/// Supprime une option
Future<void> deleteOption(String optionId) async {
try {
await _apiService.call('deleteOption', {'optionId': optionId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'option: $e');
}
}
Future<Map<String, dynamic>> getEventWithDetails(String eventId) =>
eventRepository.getEventWithDetails(eventId);
// ============================================================================
// LECTURE DES DONNÉES (avec permissions côté serveur)
// EQUIPMENTS & AVAILABILITY (delegated to EquipmentRepository)
// ============================================================================
Future<List<Map<String, dynamic>>> getEquipments() =>
equipmentRepository.getEquipments();
/// Récupère tous les événements (filtrés selon permissions)
/// Retourne { events: List<Map>, users: Map<String, Map> }
Future<Map<String, dynamic>> getEvents({String? userId}) async {
try {
final data = <String, dynamic>{};
if (userId != null) data['userId'] = userId;
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) =>
equipmentRepository.getEquipmentsByIds(equipmentIds);
final result = await _apiService.call('getEvents', data);
// Extraire events et users
final events = result['events'] as List<dynamic>? ?? [];
final users = result['users'] as Map<String, dynamic>? ?? {};
return {
'events': events.map((e) => e as Map<String, dynamic>).toList(),
'users': users,
};
} catch (e) {
throw Exception('Erreur lors de la récupération des événements: $e');
}
}
/// Récupère les événements d'un mois spécifique (lazy loading optimisé)
Future<Map<String, dynamic>> getEventsByMonth({
required String userId,
required int year,
required int month,
}) async {
try {
print('[DataService] Calling getEventsByMonth for $year-$month');
final result = await _apiService.call('getEventsByMonth', {
'userId': userId,
'year': year,
'month': month,
});
// Extraire events et users
final events = result['events'] as List<dynamic>? ?? [];
final users = result['users'] as Map<String, dynamic>? ?? {};
print('[DataService] Events loaded for $year-$month: ${events.length} events');
return {
'events': events.map((e) => e as Map<String, dynamic>).toList(),
'users': users,
};
} catch (e) {
print('[DataService] Error getting events by month: $e');
throw Exception('Erreur lors de la récupération des événements du mois: $e');
}
}
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
try {
print('[DataService] Getting event with details: $eventId');
final result = await _apiService.call('getEventWithDetails', {
'eventId': eventId,
});
final event = result['event'] as Map<String, dynamic>?;
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
final containers = result['containers'] as Map<String, dynamic>? ?? {};
if (event == null) {
throw Exception('Event not found');
}
print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
return {
'event': event,
'equipments': equipments,
'containers': containers,
};
} catch (e) {
print('[DataService] Error getting event with details: $e');
throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e');
}
}
/// Récupère tous les équipements (avec masquage des prix selon permissions)
Future<List<Map<String, dynamic>>> getEquipments() async {
try {
print('[DataService] Calling getEquipments API...');
final result = await _apiService.call('getEquipments', {});
print('[DataService] API call successful, parsing result...');
final equipments = result['equipments'] as List<dynamic>?;
if (equipments == null) {
print('[DataService] No equipments in result');
return [];
}
print('[DataService] Found ${equipments.length} equipments');
return equipments.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[DataService] Error getting equipments: $e');
throw Exception('Erreur lors de la récupération des équipements: $e');
}
}
/// Récupère plusieurs équipements par leurs IDs
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) async {
try {
if (equipmentIds.isEmpty) return [];
print('[DataService] Getting equipments by IDs: ${equipmentIds.length} items');
final result = await _apiService.call('getEquipmentsByIds', {
'equipmentIds': equipmentIds,
});
final equipments = result['equipments'] as List<dynamic>?;
if (equipments == null) {
print('[DataService] No equipments in result');
return [];
}
print('[DataService] Found ${equipments.length} equipments by IDs');
return equipments.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[DataService] Error getting equipments by IDs: $e');
throw Exception('Erreur lors de la récupération des équipements: $e');
}
}
/// Récupère tous les conteneurs
Future<List<Map<String, dynamic>>> getContainers() async {
try {
final result = await _apiService.call('getContainers', {});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des conteneurs: $e');
}
}
/// Récupère plusieurs containers par leurs IDs
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async {
try {
if (containerIds.isEmpty) return [];
print('[DataService] Getting containers by IDs: ${containerIds.length} items');
final result = await _apiService.call('getContainersByIds', {
'containerIds': containerIds,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) {
print('[DataService] No containers in result');
return [];
}
print('[DataService] Found ${containers.length} containers by IDs');
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
print('[DataService] Error getting containers by IDs: $e');
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
// ============================================================================
// EQUIPMENTS & CONTAINERS - Pagination
// ============================================================================
/// Récupère les équipements avec pagination et filtrage
Future<Map<String, dynamic>> getEquipmentsPaginated({
int limit = 20,
String? startAfter,
@@ -400,41 +104,92 @@ class DataService {
String? searchQuery,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (category != null) params['category'] = category;
if (status != null) params['status'] = status;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getEquipmentsPaginated',
params,
}) =>
equipmentRepository.getEquipmentsPaginated(
limit: limit,
startAfter: startAfter,
category: category,
status: status,
searchQuery: searchQuery,
sortBy: sortBy,
sortOrder: sortOrder,
);
return {
'equipments': (result['equipments'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ?? [],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) {
DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
throw Exception('Erreur lors de la récupération paginée des équipements: $e');
}
}
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) =>
equipmentRepository.createEquipment(equipmentId, data);
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) =>
equipmentRepository.updateEquipment(equipmentId, data);
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) =>
equipmentRepository.deleteEquipment(equipmentId, forceDelete: forceDelete);
Future<void> updateEquipmentStatusOnly({required String equipmentId, String? status, int? availableQuantity}) =>
equipmentRepository.updateEquipmentStatusOnly(
equipmentId: equipmentId,
status: status,
availableQuantity: availableQuantity,
);
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({required String query, int limit = 12}) =>
equipmentRepository.searchEquipmentsForAssistant(query: query, limit: limit);
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
required String equipmentId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) =>
equipmentRepository.checkEquipmentAvailabilityForAssistant(
equipmentId: equipmentId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
Future<Map<String, dynamic>> checkEquipmentAvailability({
required String equipmentId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) =>
equipmentRepository.checkEquipmentAvailability(
equipmentId: equipmentId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
Future<Map<String, dynamic>> getConflictingEquipmentIds({
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
int installationTime = 0,
int disassemblyTime = 0,
}) =>
equipmentRepository.getConflictingEquipmentIds(
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
installationTime: installationTime,
disassemblyTime: disassemblyTime,
);
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) =>
equipmentRepository.getMaintenances(equipmentId: equipmentId);
Future<void> deleteMaintenance(String maintenanceId) =>
equipmentRepository.deleteMaintenance(maintenanceId);
// ============================================================================
// CONTAINERS (delegated to ContainerRepository)
// ============================================================================
Future<List<Map<String, dynamic>>> getContainers() =>
containerRepository.getContainers();
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) =>
containerRepository.getContainersByIds(containerIds);
/// Récupère les containers avec pagination et filtrage
Future<Map<String, dynamic>> getContainersPaginated({
int limit = 20,
String? startAfter,
@@ -444,267 +199,71 @@ class DataService {
String? category,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (type != null) params['type'] = type;
if (status != null) params['status'] = status;
if (category != null) params['category'] = category;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getContainersPaginated',
params,
);
return {
'containers': (result['containers'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ?? [],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) {
DebugLog.error('[DataService] Error in getContainersPaginated', e);
throw Exception('Erreur lors de la récupération paginée des containers: $e');
}
}
/// Recherche rapide (autocomplétion)
Future<List<Map<String, dynamic>>> quickSearch(
String query, {
int limit = 10,
bool includeEquipments = true,
bool includeContainers = true,
}) async {
try {
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
query,
}) =>
containerRepository.getContainersPaginated(
limit: limit,
includeEquipments: includeEquipments,
includeContainers: includeContainers,
startAfter: startAfter,
type: type,
status: status,
searchQuery: searchQuery,
category: category,
sortBy: sortBy,
sortOrder: sortOrder,
);
} catch (e) {
DebugLog.error('[DataService] Error in quickSearch', e);
return [];
}
}
// ============================================================================
// USER - Current User
// ============================================================================
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) =>
containerRepository.getContainersByEquipment(equipmentId);
/// Récupère l'utilisateur actuellement authentifié avec son rôle
Future<Map<String, dynamic>> getCurrentUser() async {
try {
print('[DataService] Calling getCurrentUser API...');
final result = await _apiService.call('getCurrentUser', {});
print('[DataService] Current user loaded successfully');
return result['user'] as Map<String, dynamic>;
} catch (e) {
print('[DataService] Error getting current user: $e');
throw Exception('Erreur lors de la récupération de l\'utilisateur actuel: $e');
}
}
// ============================================================================
// ALERTS
// ============================================================================
/// Récupère toutes les alertes
Future<List<Map<String, dynamic>>> getAlerts() async {
try {
final result = await _apiService.call('getAlerts', {});
final alerts = result['alerts'] as List<dynamic>?;
if (alerts == null) return [];
return alerts.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des alertes: $e');
}
}
/// Marque une alerte comme lue
Future<void> markAlertAsRead(String alertId) async {
try {
await _apiService.call('markAlertAsRead', {'alertId': alertId});
} catch (e) {
throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e');
}
}
/// Supprime une alerte
Future<void> deleteAlert(String alertId) async {
try {
await _apiService.call('deleteAlert', {'alertId': alertId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'alerte: $e');
}
}
// ============================================================================
// EQUIPMENT AVAILABILITY
// ============================================================================
/// Vérifie la disponibilité d'un équipement
Future<Map<String, dynamic>> checkEquipmentAvailability({
required String equipmentId,
Future<Map<String, dynamic>> checkContainerAvailability({
required String containerId,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
try {
final result = await _apiService.call('checkEquipmentAvailability', {
'equipmentId': equipmentId,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
if (excludeEventId != null) 'excludeEventId': excludeEventId,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la vérification de disponibilité: $e');
}
}
/// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période
/// Optimisé : une seule requête au lieu d'une par équipement
Future<Map<String, dynamic>> getConflictingEquipmentIds({
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
int installationTime = 0,
int disassemblyTime = 0,
}) async {
try {
final result = await _apiService.call('getConflictingEquipmentIds', {
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
if (excludeEventId != null) 'excludeEventId': excludeEventId,
'installationTime': installationTime,
'disassemblyTime': disassemblyTime,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la récupération des équipements en conflit: $e');
}
}
}) =>
containerRepository.checkContainerAvailability(
containerId: containerId,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
// ============================================================================
// MAINTENANCES
// USERS (delegated to UserRepository)
// ============================================================================
Future<Map<String, dynamic>> getCurrentUser() => userRepository.getCurrentUser();
/// Récupère toutes les maintenances
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async {
try {
final data = <String, dynamic>{};
if (equipmentId != null) data['equipmentId'] = equipmentId;
Future<List<Map<String, dynamic>>> getUsers() => userRepository.getUsers();
final result = await _apiService.call('getMaintenances', data);
final maintenances = result['maintenances'] as List<dynamic>?;
if (maintenances == null) return [];
return maintenances.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des maintenances: $e');
}
}
Future<Map<String, dynamic>> getUser(String userId) => userRepository.getUser(userId);
/// Supprime une maintenance
Future<void> deleteMaintenance(String maintenanceId) async {
try {
await _apiService.call('deleteMaintenance', {'maintenanceId': maintenanceId});
} catch (e) {
throw Exception('Erreur lors de la suppression de la maintenance: $e');
}
}
Future<void> deleteUser(String userId) => userRepository.deleteUser(userId);
/// Récupère les containers contenant un équipement
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async {
try {
final result = await _apiService.call('getContainersByEquipment', {
'equipmentId': equipmentId,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
Future<void> updateUser(String userId, Map<String, dynamic> data) =>
userRepository.updateUser(userId, data);
// ============================================================================
// USERS
// ============================================================================
/// Récupère tous les utilisateurs (selon permissions)
Future<List<Map<String, dynamic>>> getUsers() async {
try {
final result = await _apiService.call('getUsers', {});
final users = result['users'] as List<dynamic>?;
if (users == null) return [];
return users.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des utilisateurs: $e');
}
}
/// Récupère un utilisateur spécifique
Future<Map<String, dynamic>> getUser(String userId) async {
try {
final result = await _apiService.call('getUser', {'userId': userId});
return result['user'] as Map<String, dynamic>;
} catch (e) {
throw Exception('Erreur lors de la récupération de l\'utilisateur: $e');
}
}
/// Supprime un utilisateur (Auth + Firestore)
Future<void> deleteUser(String userId) async {
try {
await _apiService.call('deleteUser', {'userId': userId});
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'utilisateur: $e');
}
}
/// Met à jour un utilisateur
Future<void> updateUser(String userId, Map<String, dynamic> data) async {
try {
await _apiService.call('updateUser', {
'userId': userId,
'data': data,
});
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e');
}
}
/// Crée un utilisateur avec invitation par email
Future<Map<String, dynamic>> createUserWithInvite({
required String email,
required String firstName,
required String lastName,
String? phoneNumber,
required String roleId,
}) async {
try {
final result = await _apiService.call('createUserWithInvite', {
'email': email,
'firstName': firstName,
'lastName': lastName,
'phoneNumber': phoneNumber ?? '',
'roleId': roleId,
});
return result;
} catch (e) {
throw Exception('Erreur lors de la création de l\'utilisateur: $e');
}
}
}) =>
userRepository.createUserWithInvite(
email: email,
firstName: firstName,
lastName: lastName,
phoneNumber: phoneNumber,
roleId: roleId,
);
Future<List<Map<String, dynamic>>> getRoles() => userRepository.getRoles();
// ============================================================================
// ALERTS (delegated to AlertRepository)
// ============================================================================
Future<List<Map<String, dynamic>>> getAlerts() => alertRepository.getAlerts();
Future<void> markAlertAsRead(String alertId) => alertRepository.markAlertAsRead(alertId);
Future<void> deleteAlert(String alertId) => alertRepository.deleteAlert(alertId);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
/// Service d'envoi d'emails via Cloud Functions
class EmailService {
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9');
FirebaseFunctions get _functions => FirebaseFunctions.instanceFor(region: 'europe-west9');
/// Envoie un email d'alerte à un utilisateur
///
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
@@ -66,27 +67,19 @@ class AvailabilityConflict {
class EventAvailabilityService {
final DataService _dataService = DataService(apiService);
/// Helper pour récupérer uniquement la liste d'événements
Future<List<Map<String, dynamic>>> _getEventsList() async {
final result = await _dataService.getEvents();
final events = result['events'] as List<dynamic>? ?? [];
return events.map((e) => e as Map<String, dynamic>).toList();
}
/// Vérifie si un équipement est disponible pour une plage de dates via Cloud Function
Future<List<AvailabilityConflict>> checkEquipmentAvailability({
required String equipmentId,
required String equipmentName,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId, // Pour exclure l'événement en cours d'édition
String? excludeEventId,
}) async {
final conflicts = <AvailabilityConflict>[];
try {
print('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
if (kDebugMode) debugPrint('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
// Utiliser la Cloud Function pour vérifier la disponibilité
final result = await _dataService.checkEquipmentAvailability(
equipmentId: equipmentId,
startDate: startDate,
@@ -94,20 +87,12 @@ class EventAvailabilityService {
excludeEventId: excludeEventId,
);
print('[EventAvailabilityService] Result for $equipmentId: $result');
final available = result['available'] as bool? ?? true;
print('[EventAvailabilityService] Equipment $equipmentId available: $available');
if (!available) {
final conflictsData = result['conflicts'] as List<dynamic>? ?? [];
print('[EventAvailabilityService] Found ${conflictsData.length} conflicts for equipment $equipmentId');
for (final conflictData in conflictsData) {
final conflict = conflictData as Map<String, dynamic>;
final eventId = conflict['eventId'] as String;
// Le backend retourne déjà eventData
final eventData = conflict['eventData'] as Map<String, dynamic>?;
if (eventData != null && eventData.isNotEmpty) {
@@ -119,19 +104,16 @@ class EventAvailabilityService {
conflictingEvent: event,
overlapDays: conflict['overlapDays'] as int? ?? 0,
));
print('[EventAvailabilityService] Added conflict with event ${event.name}');
} catch (e) {
print('[EventAvailabilityService] Error creating EventModel: $e');
print('[EventAvailabilityService] EventData: $eventData');
if (kDebugMode) debugPrint('[EventAvailabilityService] Error creating EventModel: $e');
}
}
}
}
} catch (e) {
print('[EventAvailabilityService] Error checking availability: $e');
if (kDebugMode) debugPrint('[EventAvailabilityService] Error checking availability: $e');
}
print('[EventAvailabilityService] Returning ${conflicts.length} conflicts for equipment $equipmentId');
return conflicts;
}
@@ -159,164 +141,10 @@ class EventAvailabilityService {
}
}
return allConflicts;
}
/// Vérifie si deux plages de dates se chevauchent
bool _datesOverlap(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
// Deux plages se chevauchent si elles ne sont PAS complètement séparées
// Elles sont séparées si : end1 < start2 OU end2 < start1
// Donc elles se chevauchent si : NOT (end1 < start2 OU end2 < start1)
// Équivalent à : end1 >= start2 ET end2 >= start1
return !end1.isBefore(start2) && !end2.isBefore(start1);
}
/// Calcule le nombre de jours de chevauchement
int _calculateOverlapDays(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
final overlapStart = start1.isAfter(start2) ? start1 : start2;
final overlapEnd = end1.isBefore(end2) ? end1 : end2;
return overlapEnd.difference(overlapStart).inDays + 1;
}
/// Récupère la quantité disponible pour un consommable/câble
Future<int> getAvailableQuantity({
required EquipmentModel equipment,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
if (!equipment.hasQuantity) {
return 1; // Équipement non consommable
}
final totalQuantity = equipment.totalQuantity ?? 0;
int reservedQuantity = 0;
try {
// Récupérer tous les événements via Cloud Function
final eventsData = await _getEventsList();
for (var eventData in eventsData) {
final eventId = eventData['id'] as String;
if (excludeEventId != null && eventId == excludeEventId) {
continue;
}
try {
final event = EventModel.fromMap(eventData, eventId);
// Ignorer les événements annulés
if (event.status == EventStatus.canceled) {
continue;
}
// Calculer les dates réelles avec temps d'installation et démontage
final eventRealStartDate = event.startDateTime.subtract(
Duration(hours: event.installationTime),
);
final eventRealEndDate = event.endDateTime.add(
Duration(hours: event.disassemblyTime),
);
// Vérifier le chevauchement des dates
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
final assignedEquipment = event.assignedEquipment.firstWhere(
(eq) => eq.equipmentId == equipment.id,
orElse: () => EventEquipment(equipmentId: ''),
);
// Si l'équipement est assigné, réserver la quantité
// (peu importe le statut de préparation/retour)
if (assignedEquipment.equipmentId.isNotEmpty) {
reservedQuantity += assignedEquipment.quantity;
}
}
} catch (e) {
print('[EventAvailabilityService] Error processing event $eventId for quantity: $e');
}
}
} catch (e) {
print('[EventAvailabilityService] Error getting available quantity: $e');
}
return totalQuantity - reservedQuantity;
}
/// Vérifie la disponibilité d'un équipement avec gestion des quantités
Future<List<AvailabilityConflict>> checkEquipmentAvailabilityWithQuantity({
required EquipmentModel equipment,
required int requestedQuantity,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
final conflicts = <AvailabilityConflict>[];
// Si équipement quantifiable (consommable/câble)
if (equipment.hasQuantity) {
final totalQuantity = equipment.totalQuantity ?? 0;
final availableQty = await getAvailableQuantity(
equipment: equipment,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
final reservedQty = totalQuantity - availableQty;
// Ne créer un conflit que si la quantité est VRAIMENT insuffisante
if (availableQty < requestedQuantity) {
// Trouver les événements qui réservent cette quantité
final eventsData = await _getEventsList();
for (var eventData in eventsData) {
final eventId = eventData['id'] as String;
if (excludeEventId != null && eventId == excludeEventId) continue;
try {
final event = EventModel.fromMap(eventData, eventId);
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
final assignedEquipment = event.assignedEquipment.firstWhere(
(eq) => eq.equipmentId == equipment.id,
orElse: () => EventEquipment(equipmentId: ''),
);
if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) {
conflicts.add(AvailabilityConflict(
equipmentId: equipment.id,
equipmentName: equipment.name,
conflictingEvent: event,
overlapDays: _calculateOverlapDays(startDate, endDate, event.startDateTime, event.endDateTime),
type: ConflictType.insufficientQuantity,
totalQuantity: totalQuantity,
availableQuantity: availableQty,
requestedQuantity: requestedQuantity,
reservedQuantity: reservedQty,
));
}
}
} catch (e) {
print('[EventAvailabilityService] Error processing event $eventId: $e');
}
}
}
} else {
// Équipement non quantifiable : vérification classique
return await checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
}
return conflicts;
}
/// Vérifie la disponibilité d'une boîte et de son contenu
/// Vérifie la disponibilité d'une boîte et de son contenu via le backend
Future<List<AvailabilityConflict>> checkContainerAvailability({
required ContainerModel container,
required List<EquipmentModel> containerEquipment,
@@ -325,99 +153,62 @@ class EventAvailabilityService {
String? excludeEventId,
}) async {
final conflicts = <AvailabilityConflict>[];
final conflictingChildrenIds = <String>[];
// Vérifier d'abord si la boîte complète est utilisée
final eventsData = await _getEventsList();
bool isContainerFullyUsed = false;
EventModel? containerConflictingEvent;
try {
final result = await _dataService.checkContainerAvailability(
containerId: container.id,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
for (var eventData in eventsData) {
final eventId = eventData['id'] as String;
if (excludeEventId != null && eventId == excludeEventId) continue;
try {
final event = EventModel.fromMap(eventData, eventId);
// Ignorer les événements annulés
if (event.status == EventStatus.canceled) {
continue;
}
// Calculer les dates réelles avec temps d'installation et démontage
final eventRealStartDate = event.startDateTime.subtract(
Duration(hours: event.installationTime),
);
final eventRealEndDate = event.endDateTime.add(
Duration(hours: event.disassemblyTime),
);
// Vérifier si cette boîte est assignée
if (event.assignedContainers.contains(container.id)) {
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
isContainerFullyUsed = true;
containerConflictingEvent = event;
break;
final isAvailable = result['isAvailable'] as bool? ?? true;
if (!isAvailable) {
final conflictType = result['conflictType'] as String?;
if (conflictType == 'complete') {
final containerConflicts = result['containerConflicts'] as List<dynamic>? ?? [];
for (var conflictData in containerConflicts) {
final conflict = conflictData as Map<String, dynamic>;
final eventId = conflict['eventId'] as String;
final eventName = conflict['eventName'] as String? ?? '';
final startDateStr = conflict['startDate'] as String?;
final endDateStr = conflict['endDate'] as String?;
final event = EventModel(
id: eventId,
name: eventName,
description: '',
startDateTime: startDateStr != null ? DateTime.tryParse(startDateStr) ?? DateTime.now() : DateTime.now(),
endDateTime: endDateStr != null ? DateTime.tryParse(endDateStr) ?? DateTime.now() : DateTime.now(),
basePrice: 0.0,
installationTime: 0,
disassemblyTime: 0,
eventTypeId: '',
customerId: '',
address: '',
latitude: 0.0,
longitude: 0.0,
workforce: const [],
documents: const [],
);
conflicts.add(AvailabilityConflict(
equipmentId: container.id,
equipmentName: container.name,
conflictingEvent: event,
overlapDays: conflict['overlapDays'] as int? ?? 0,
type: ConflictType.containerFullyUsed,
containerId: container.id,
containerName: container.name,
));
}
}
} catch (e) {
print('[EventAvailabilityService] Error processing event $eventId: $e');
}
}
if (isContainerFullyUsed && containerConflictingEvent != null) {
// Boîte complète utilisée
conflicts.add(AvailabilityConflict(
equipmentId: container.id,
equipmentName: container.name,
conflictingEvent: containerConflictingEvent,
overlapDays: _calculateOverlapDays(
startDate,
endDate,
containerConflictingEvent.startDateTime,
containerConflictingEvent.endDateTime,
),
type: ConflictType.containerFullyUsed,
containerId: container.id,
containerName: container.name,
));
} else {
// Vérifier chaque équipement enfant individuellement
for (var equipment in containerEquipment) {
final equipmentConflicts = await checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
if (equipmentConflicts.isNotEmpty) {
conflictingChildrenIds.add(equipment.id);
conflicts.addAll(equipmentConflicts);
}
}
// Si au moins un enfant en conflit, ajouter un conflit pour la boîte
if (conflictingChildrenIds.isNotEmpty && conflicts.isNotEmpty) {
conflicts.insert(
0,
AvailabilityConflict(
equipmentId: container.id,
equipmentName: container.name,
conflictingEvent: conflicts.first.conflictingEvent,
overlapDays: conflicts.first.overlapDays,
type: ConflictType.containerPartiallyUsed,
containerId: container.id,
containerName: container.name,
conflictingChildrenIds: conflictingChildrenIds,
),
);
}
} catch (e) {
if (kDebugMode) debugPrint('[EventAvailabilityService] Error checking container availability: $e');
}
return conflicts;
}
}
@@ -4,6 +4,44 @@ import 'package:em2rp/services/api_service.dart';
class EventPreparationService {
final ApiService _apiService = apiService;
/// Retourne true si l'équipement était absent du flux événementiel.
///
/// Cas typique: matériel jamais emporté au départ, donc absent au retour,
/// mais qui ne doit jamais être classé en [LOST].
static bool isEquipmentNotTakenToEvent({
required bool isMissingAtReturn,
required bool isLoaded,
required bool isMissingAtLoading,
int? quantityAtLoading,
}) {
if (!isMissingAtReturn) {
return false;
}
final loadedQuantity = quantityAtLoading ?? 0;
return !isLoaded || isMissingAtLoading || loadedQuantity <= 0;
}
/// Retourne true uniquement si l'équipement doit être classé perdu.
static bool shouldMarkEquipmentAsLost({
required bool isReturnValidationStep,
required bool isMissingAtReturn,
required bool isLoaded,
required bool isMissingAtLoading,
int? quantityAtLoading,
}) {
if (!isReturnValidationStep || !isMissingAtReturn) {
return false;
}
return !isEquipmentNotTakenToEvent(
isMissingAtReturn: isMissingAtReturn,
isLoaded: isLoaded,
isMissingAtLoading: isMissingAtLoading,
quantityAtLoading: quantityAtLoading,
);
}
// === PRÉPARATION ===
/// Valider un équipement individuel en préparation
+144
View File
@@ -0,0 +1,144 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:async';
import '../views/login_page.dart';
import '../utils/colors.dart';
import '../providers/local_user_provider.dart';
/// Gate de démarrage qui attend la restauration Firebase Auth avant
/// d'afficher soit le contenu connecté, soit la page de connexion.
class AppStartGate extends StatelessWidget {
const AppStartGate({super.key});
@override
Widget build(BuildContext context) {
// Sur le web, certaines erreurs natives (ex: cookies tiers bloqués)
// peuvent faire remonter une FirebaseException sur le stream d'auth.
// Pour éviter que StreamBuilder reçoive une erreur qui casse le build
// (TypeError JS interop), on "handleError" et on transforme l'erreur
// en une valeur nulle (pas d'utilisateur) afin de garder l'app stable.
// Accès protégé à `FirebaseAuth.instance` sur le web certaines erreurs
// d'interop JS peuvent produire des TypeError non compatibles. Nous
// attrapons toute exception lors de l'accès et fournissons un stream
// neutre (pas d'utilisateur) afin de garder l'UI stable.
late final Stream<User?> safeAuthStream;
try {
safeAuthStream = FirebaseAuth.instance
.authStateChanges()
.handleError((error, stack) {
// Log pour debug ; ne rethrow pas
debugPrint('[AppStartGate] authStateChanges error: $error');
});
} catch (e, st) {
// Sur certaines configurations web l'accès à FirebaseAuth.instance
// peut échouer au niveau JS interop. On log puis on fournit un stream
// qui émet une seule valeur nulle pour indiquer "pas d'utilisateur".
debugPrint('[AppStartGate] FirebaseAuth.instance access error: $e\n$st');
safeAuthStream = Stream<User?>.value(null);
}
return StreamBuilder<User?>(
stream: safeAuthStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _StartupSplashScreen();
}
if (snapshot.hasError) {
// En théorie handleError évite d'arriver ici, mais on garde
// une protection supplémentaire.
debugPrint('[AppStartGate] snapshot error: ${snapshot.error}');
return const _StartupSplashScreen(message: 'Erreur de connexion');
}
if (snapshot.data != null) {
return const _AuthenticatedBootstrap();
}
return const LoginPage();
},
);
}
}
class _AuthenticatedBootstrap extends StatefulWidget {
const _AuthenticatedBootstrap();
@override
State<_AuthenticatedBootstrap> createState() =>
_AuthenticatedBootstrapState();
}
class _AuthenticatedBootstrapState extends State<_AuthenticatedBootstrap> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_redirectAfterAuth();
});
}
Future<void> _redirectAfterAuth() async {
final fragment = Uri.base.fragment;
if (!mounted) return;
// Charger les données utilisateur de façon non bloquante
unawaited(
context.read<LocalUserProvider>().loadUserData().catchError((e) {
debugPrint('[AppStartGate] User data bootstrap failed: $e');
}),
);
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
Navigator.of(context).pushReplacementNamed(fragment);
} else {
Navigator.of(context).pushReplacementNamed('/calendar');
}
}
@override
Widget build(BuildContext context) {
return const _StartupSplashScreen();
}
}
class _StartupSplashScreen extends StatelessWidget {
final String message;
const _StartupSplashScreen({this.message = 'Démarrage...'});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 160,
height: 160,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 72,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 24),
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(message),
],
),
),
);
}
}
+23 -2
View File
@@ -1,27 +1,48 @@
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/login_page.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/views/login_page.dart';
class AuthGuard extends StatelessWidget {
final Widget child;
final String? requiredPermission;
final bool allowWhileLoading;
const AuthGuard({
super.key,
required this.child,
this.requiredPermission,
this.allowWhileLoading = false,
});
@override
Widget build(BuildContext context) {
final localAuthProvider = Provider.of<LocalUserProvider>(context);
final firebaseUser = FirebaseAuth.instance.currentUser;
// Log pour débug
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
// Si Firebase n'a pas encore restauré la session ou si le profil charge,
// afficher un écran neutre plutôt que la page de connexion.
if (firebaseUser != null &&
(localAuthProvider.currentUser == null ||
localAuthProvider.isLoadingUserData)) {
if (allowWhileLoading) {
return child;
}
return const Scaffold(
backgroundColor: Colors.white,
body: Center(
child: CircularProgressIndicator(),
),
);
}
// Si l'utilisateur n'est pas connecté
if (localAuthProvider.currentUser == null) {
if (firebaseUser == null || localAuthProvider.currentUser == null) {
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
return const LoginPage();
}
+21
View File
@@ -0,0 +1,21 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Utilitaire pour différer l'exécution d'une action après un délai.
/// Utilisé principalement pour les champs de recherche afin d'éviter
/// des requêtes à chaque frappe clavier.
class Debouncer {
final Duration delay;
Timer? _timer;
Debouncer({this.delay = const Duration(milliseconds: 400)});
void call(VoidCallback action) {
_timer?.cancel();
_timer = Timer(delay, action);
}
void dispose() {
_timer?.cancel();
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:em2rp/services/api_service.dart';
import 'package:flutter/material.dart';
/// Utilitaires partages pour la suppression d'equipement avec forcage.
class EquipmentDeleteUtils {
static const String _legacyConflictToken = 'future_event_assignment';
static const List<String> _conflictMessageTokens = [
'cannot delete equipment because it is assigned to upcoming events',
'cannot delete equipment because it is assigned to future events',
'assigned to upcoming events',
'assigned to future events',
];
static const String deleteDialogTitle = 'Confirmer la suppression';
static const String deleteDialogCancelLabel = 'Annuler';
static const String deleteDialogConfirmLabel = 'Supprimer';
static const String deleteSuccessMessage = 'Équipement supprimé avec succès';
/// Retourne [name] si renseigne, sinon [id].
static String resolveEquipmentLabel({required String id, String? name}) {
final trimmedName = name?.trim();
if (trimmedName == null || trimmedName.isEmpty) {
return id;
}
return trimmedName;
}
/// Construit le message de confirmation de suppression d'un equipement.
static String buildSingleDeleteConfirmationMessage(String equipmentLabel) {
return 'Voulez-vous vraiment supprimer "$equipmentLabel" ?\n\n'
'Cette action est irréversible.';
}
/// Construit le message de confirmation de suppression multiple.
static String buildBulkDeleteConfirmationMessage(int selectedCount) {
return 'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?\n\n'
'Cette action est irréversible.';
}
/// Construit le message de succes de suppression multiple.
static String buildBulkDeleteSuccessMessage(int deletedCount) {
return '$deletedCount équipement(s) supprimé(s) avec succès';
}
/// Construit un message d'erreur de suppression homogene.
static String buildDeleteErrorMessage(Object error) {
return 'Erreur lors de la suppression : $error';
}
/// Indique si l'erreur correspond a un conflit de suppression 409.
static bool isFutureAssignmentDeleteConflict(Object error) {
if (error is ApiException && !error.isConflict) {
return false;
}
final normalizedMessage = _normalizeErrorMessage(error);
if (normalizedMessage.contains(_legacyConflictToken)) {
return true;
}
return _conflictMessageTokens.any(normalizedMessage.contains);
}
/// Affiche la confirmation de suppression forcee.
static Future<bool> showForceDeleteDialog(
BuildContext context, {
required String equipmentLabel,
}) async {
final shouldForceDelete = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Équipement utilisé dans un événement à venir'),
content: Text(
'"$equipmentLabel" est assigné à au moins un événement à venir.\n\n'
'Voulez-vous forcer la suppression ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Forcer la suppression'),
),
],
),
);
return shouldForceDelete == true;
}
/// Execute une suppression, puis propose un forcage en cas de conflit 409.
static Future<bool> deleteWithFutureAssignmentCheck({
required BuildContext context,
required String equipmentLabel,
required Future<void> Function({bool forceDelete}) deleteEquipment,
}) async {
try {
await deleteEquipment(forceDelete: false);
return true;
} catch (error) {
if (!isFutureAssignmentDeleteConflict(error)) {
rethrow;
}
if (!context.mounted) {
return false;
}
final shouldForceDelete = await showForceDeleteDialog(
context,
equipmentLabel: equipmentLabel,
);
if (!shouldForceDelete) {
return false;
}
await deleteEquipment(forceDelete: true);
return true;
}
}
static String _normalizeErrorMessage(Object error) {
if (error is ApiException) {
return error.message.toLowerCase();
}
return error.toString().toLowerCase();
}
}
@@ -5,8 +5,8 @@ import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class FirebaseStorageManager {
final FirebaseStorage _storage = FirebaseStorage.instance;
final DataService _dataService = DataService(FirebaseFunctionsApiService());
FirebaseStorage get _storage => FirebaseStorage.instance;
final DataService _dataService = DataService(apiService);
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
/// Pour le Web, on fixe l'extension .jpg.
+10 -13
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../providers/local_user_provider.dart';
@@ -33,22 +35,17 @@ class LoginViewModel extends ChangeNotifier {
passwordController.text,
);
// --- Étape 2: Charger les données utilisateur depuis Firestore ---
await localAuthProvider.loadUserData();
// --- Étape 2: Charger les données utilisateur en arrière-plan ---
unawaited(
localAuthProvider.loadUserData().catchError((e) {
debugPrint('Erreur chargement profil après connexion : $e');
}),
);
// Vérifier si le contexte est toujours valide
if (context.mounted) {
// Vérifier si l'utilisateur a bien été chargé dans le provider
if (localAuthProvider.currentUser != null) {
// Utiliser pushReplacementNamed pour une transition propre
Navigator.of(context, rootNavigator: true)
.pushReplacementNamed('/calendar');
} else {
errorMessage =
'Erreur inattendue après connexion: Données utilisateur non chargées.';
isLoading = false;
notifyListeners();
}
Navigator.of(context, rootNavigator: true)
.pushReplacementNamed('/calendar');
}
} on FirebaseAuthException catch (e) {
// Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.)
File diff suppressed because it is too large Load Diff
+178 -132
View File
@@ -7,6 +7,9 @@ import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/id_generator.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debouncer.dart';
class ContainerFormPage extends StatefulWidget {
final ContainerModel? container;
@@ -32,7 +35,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
// Form fields
ContainerType _selectedType = ContainerType.flightCase;
EquipmentStatus _selectedStatus = EquipmentStatus.available;
bool _autoGenerateId = true;
final ValueNotifier<bool> _autoGenerateIdNotifier = ValueNotifier<bool>(true);
final Set<String> _selectedEquipmentIds = {};
bool _isEditing = false;
@@ -58,11 +61,11 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
_heightController.text = container.height?.toString() ?? '';
_notesController.text = container.notes ?? '';
_selectedEquipmentIds.addAll(container.equipmentIds);
_autoGenerateId = false;
_autoGenerateIdNotifier.value = false;
}
void _updateIdFromName() {
if (_autoGenerateId && !_isEditing) {
if (_autoGenerateIdNotifier.value && !_isEditing) {
final name = _nameController.text;
if (name.isNotEmpty) {
final baseId = IdGenerator.generateContainerId(
@@ -75,7 +78,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
}
void _updateIdFromType() {
if (_autoGenerateId && !_isEditing) {
if (_autoGenerateIdNotifier.value && !_isEditing) {
final name = _nameController.text;
if (name.isNotEmpty) {
final baseId = IdGenerator.generateContainerId(
@@ -120,49 +123,52 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
const SizedBox(height: 16),
// ID
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextFormField(
controller: _idController,
decoration: const InputDecoration(
labelText: 'Identifiant *',
hintText: 'ex: FLIGHTCASE_BEAM',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.qr_code),
ValueListenableBuilder<bool>(
valueListenable: _autoGenerateIdNotifier,
builder: (context, autoGenerateId, child) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextFormField(
controller: _idController,
decoration: const InputDecoration(
labelText: 'Identifiant *',
hintText: 'ex: FLIGHTCASE_BEAM',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.qr_code),
),
enabled: !autoGenerateId || _isEditing,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un identifiant';
}
final validation = IdGenerator.validateContainerId(value);
return validation;
},
),
),
enabled: !_autoGenerateId || _isEditing,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un identifiant';
}
final validation = IdGenerator.validateContainerId(value);
return validation;
},
),
),
if (!_isEditing) ...[
const SizedBox(width: 8),
IconButton(
icon: Icon(
_autoGenerateId ? Icons.lock : Icons.lock_open,
color: _autoGenerateId ? AppColors.rouge : Colors.grey,
),
tooltip: _autoGenerateId
? 'Génération automatique'
: 'Saisie manuelle',
onPressed: () {
setState(() {
_autoGenerateId = !_autoGenerateId;
if (_autoGenerateId) {
_updateIdFromName();
}
});
},
),
],
],
if (!_isEditing) ...[
const SizedBox(width: 8),
IconButton(
icon: Icon(
autoGenerateId ? Icons.lock : Icons.lock_open,
color: autoGenerateId ? AppColors.rouge : Colors.grey,
),
tooltip: autoGenerateId
? 'Génération automatique'
: 'Saisie manuelle',
onPressed: () {
_autoGenerateIdNotifier.value = !autoGenerateId;
if (_autoGenerateIdNotifier.value) {
_updateIdFromName();
}
},
),
],
],
);
},
),
const SizedBox(height: 16),
@@ -629,6 +635,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
_widthController.dispose();
_heightController.dispose();
_notesController.dispose();
_autoGenerateIdNotifier.dispose();
super.dispose();
}
}
@@ -650,25 +657,88 @@ class _EquipmentSelectorDialog extends StatefulWidget {
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final DataService _dataService = DataService(FirebaseFunctionsApiService());
EquipmentCategory? _filterCategory;
String _searchQuery = '';
late Set<String> _tempSelectedIds;
late final Future<void> _loadingFuture;
final _searchDebouncer = Debouncer();
final List<EquipmentModel> _paginatedEquipments = [];
bool _isLoadingMore = false;
bool _hasMoreEquipments = true;
String? _lastEquipmentId;
@override
void initState() {
super.initState();
// Créer une copie temporaire des IDs sélectionnés
_tempSelectedIds = Set<String>.from(widget.selectedIds);
_loadingFuture = widget.equipmentProvider.loadEquipments();
_scrollController.addListener(_onScroll);
_loadNextPage();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.dispose();
_searchDebouncer.dispose();
super.dispose();
}
void _onScroll() {
if (_isLoadingMore) return;
if (_scrollController.hasClients &&
_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) {
if (_hasMoreEquipments) {
_loadNextPage();
}
}
}
Future<void> _loadNextPage() async {
if (_isLoadingMore || !_hasMoreEquipments) return;
setState(() => _isLoadingMore = true);
try {
final result = await _dataService.getEquipmentsPaginated(
limit: 50,
startAfter: _lastEquipmentId,
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
category: _filterCategory != null ? equipmentCategoryToString(_filterCategory!) : null,
sortBy: 'id',
sortOrder: 'asc',
);
final newEquipments = (result['equipments'] as List<dynamic>)
.map((data) => EquipmentModel.fromMap(data as Map<String, dynamic>, data['id'] as String))
.toList();
if (mounted) {
setState(() {
_paginatedEquipments.addAll(newEquipments);
_hasMoreEquipments = result['hasMore'] as bool? ?? false;
_lastEquipmentId = result['lastVisible'] as String?;
_isLoadingMore = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
}
}
Future<void> _reloadData() async {
setState(() {
_paginatedEquipments.clear();
_lastEquipmentId = null;
_hasMoreEquipments = true;
});
await _loadNextPage();
}
@override
Widget build(BuildContext context) {
return Dialog(
@@ -718,6 +788,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() {
_searchQuery = '';
});
_reloadData();
},
)
: null,
@@ -726,6 +797,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() {
_searchQuery = value;
});
_searchDebouncer(_reloadData);
},
),
const SizedBox(height: 16),
@@ -743,6 +815,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() {
_filterCategory = null;
});
_reloadData();
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
@@ -761,6 +834,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
setState(() {
_filterCategory = selected ? category : null;
});
_reloadData();
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
@@ -780,7 +854,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.rouge.withOpacity(0.1),
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
@@ -798,90 +872,62 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
// Liste des équipements
Expanded(
child: FutureBuilder<void>(
future: _loadingFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Erreur: ${snapshot.error}'));
}
var equipment = List<EquipmentModel>.from(
widget.equipmentProvider.allEquipment,
);
// Filtrer par catégorie
if (_filterCategory != null) {
equipment = equipment
.where((e) => e.category == _filterCategory)
.toList();
}
// Filtrer par recherche
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
equipment = equipment.where((e) {
return e.id.toLowerCase().contains(query) ||
(e.brand?.toLowerCase().contains(query) ?? false) ||
(e.model?.toLowerCase().contains(query) ?? false);
}).toList();
}
if (equipment.isEmpty) {
return const Center(
child: Text('Aucun équipement trouvé'),
);
}
return ListView.builder(
itemCount: equipment.length,
itemBuilder: (context, index) {
final item = equipment[index];
final isSelected = _tempSelectedIds.contains(item.id);
return CheckboxListTile(
value: isSelected,
onChanged: (selected) {
setState(() {
if (selected == true) {
_tempSelectedIds.add(item.id);
} else {
_tempSelectedIds.remove(item.id);
}
});
},
title: Text(
item.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.brand != null || item.model != null)
Text('${item.brand ?? ''} ${item.model ?? ''}'),
const SizedBox(height: 4),
Text(
_getCategoryLabel(item.category),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
child: _paginatedEquipments.isEmpty && !_isLoadingMore
? const Center(child: Text('Aucun équipement trouvé'))
: ListView.builder(
controller: _scrollController,
itemCount: _paginatedEquipments.length + (_isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == _paginatedEquipments.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(),
),
],
),
secondary: Icon(
_getCategoryIcon(item.category),
color: AppColors.rouge,
),
activeColor: AppColors.rouge,
);
},
);
},
),
);
}
final item = _paginatedEquipments[index];
final isSelected = _tempSelectedIds.contains(item.id);
return CheckboxListTile(
value: isSelected,
onChanged: (selected) {
setState(() {
if (selected == true) {
_tempSelectedIds.add(item.id);
} else {
_tempSelectedIds.remove(item.id);
}
});
},
title: Text(
item.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.brand != null || item.model != null)
Text('${item.brand ?? ''} ${item.model ?? ''}'),
const SizedBox(height: 4),
Text(
_getCategoryLabel(item.category),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
secondary: Icon(
_getCategoryIcon(item.category),
color: AppColors.rouge,
),
activeColor: AppColors.rouge,
);
},
),
),
// Boutons d'action
+50 -24
View File
@@ -8,6 +8,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/qr_code_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/equipment_delete_utils.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
@@ -45,7 +46,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
Future<void> _loadMaintenances() async {
try {
final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id);
final maintenances = await _equipmentService
.getMaintenancesForEquipment(widget.equipment.id);
setState(() {
_maintenances = maintenances;
_isLoadingMaintenances = false;
@@ -57,8 +59,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
}
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
@@ -103,7 +103,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
const SizedBox(height: 24),
// 3. Notes
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[
if (widget.equipment.notes != null &&
widget.equipment.notes!.isNotEmpty) ...[
EquipmentNotesSection(notes: widget.equipment.notes!),
const SizedBox(height: 24),
],
@@ -185,7 +186,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
);
}
void _showQRCode() {
showDialog(
context: context,
@@ -249,10 +249,12 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
),
const SizedBox(height: 4),
Text(
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'
.trim(),
style: TextStyle(color: Colors.grey[700]),
),
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[
if (widget.equipment.subCategory != null &&
widget.equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'📁 ${widget.equipment.subCategory}',
@@ -389,7 +391,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
if (!hasPermission) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vous n\'avez pas la permission de gérer les maintenances'),
content:
Text('Vous n\'avez pas la permission de gérer les maintenances'),
backgroundColor: Colors.orange,
),
);
@@ -423,31 +426,50 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
}
void _deleteEquipment() {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: widget.equipment.id,
name: widget.equipment.name,
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.',
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
// Fermer le dialog
Navigator.pop(context);
Navigator.pop(dialogContext);
// Capturer le ScaffoldMessenger avant la suppression
final scaffoldMessenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final navigator = Navigator.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
await context
.read<EquipmentProvider>()
.deleteEquipment(widget.equipment.id);
final deleted =
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: equipmentLabel,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
widget.equipment.id,
forceDelete: forceDelete,
);
},
);
if (!deleted) {
return;
}
// Revenir à la page précédente
navigator.pop();
@@ -455,22 +477,26 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
// Afficher le snackbar (même si le widget est démonté)
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text('Équipement supprimé avec succès'),
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green,
),
);
} catch (e) {
// Afficher l'erreur
scaffoldMessenger.showSnackBar(
SnackBar(content: Text('Erreur: $e')),
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
);
}
}
}
+9 -11
View File
@@ -57,6 +57,12 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
final provider = Provider.of<EquipmentProvider>(context, listen: false);
provider.loadBrands();
provider.loadModels();
if (widget.equipment != null) {
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
_loadFilteredModels(_selectedBrand!);
}
_loadFilteredSubCategories(_selectedCategory);
}
});
if (widget.equipment != null) {
_populateFields();
@@ -84,14 +90,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
});
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
_loadFilteredModels(_selectedBrand!);
}
// Charger les sous-catégories pour la catégorie sélectionnée
_loadFilteredSubCategories(_selectedCategory);
}
@@ -163,11 +161,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
TextFormField(
controller: _identifierController,
decoration: InputDecoration(
labelText: 'Identifiant *',
labelText: 'Identifiant (Laissez vide pour auto-génération) *',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.tag),
hintText: isEditing ? null : 'Laissez vide pour générer automatiquement',
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}',
hintText: isEditing ? null : 'Auto-attribué par défaut',
helperText: isEditing ? 'Non modifiable' : 'Génération auto recommandée basée sur Marque/Modèle',
),
enabled: !isEditing,
validator: (value) {
+314 -216
View File
@@ -6,6 +6,7 @@ import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/views/equipment_form_page.dart';
@@ -16,9 +17,11 @@ import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/utils/equipment_delete_utils.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
import 'package:em2rp/utils/debouncer.dart';
class EquipmentManagementPage extends StatefulWidget {
const EquipmentManagementPage({super.key});
@@ -28,11 +31,11 @@ class EquipmentManagementPage extends StatefulWidget {
_EquipmentManagementPageState();
}
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final _searchDebouncer = Debouncer();
EquipmentCategory? _selectedCategory;
List<EquipmentModel>? _cachedEquipment;
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
@@ -66,7 +69,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (_scrollController.hasClients &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
// Pas de setState ici pour éviter les rebuilds pendant le scroll
@@ -76,7 +78,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_isLoadingMore = false;
}).catchError((error) {
_isLoadingMore = false;
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
DebugLog.error(
'[EquipmentManagementPage] Error loading next page', error);
});
}
}
@@ -87,6 +90,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchController.dispose();
_searchDebouncer.dispose();
// Désactiver le mode pagination en quittant
context.read<EquipmentProvider>().disablePagination();
super.dispose();
@@ -140,7 +144,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
],
],
)
: CustomAppBar(
: const CustomAppBar(
title: 'Gestion du matériel',
),
drawer: const MainDrawer(currentPage: '/equipment_management'),
@@ -169,9 +173,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
@@ -342,9 +344,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
@@ -456,11 +456,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
Widget _buildEquipmentList() {
return Consumer<EquipmentProvider>(
builder: (context, provider, child) {
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
DebugLog.info(
'[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
// Afficher l'indicateur de chargement initial uniquement
if (provider.isLoading && provider.equipment.isEmpty) {
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
DebugLog.info(
'[EquipmentManagementPage] Showing initial loading indicator');
return const Center(child: CircularProgressIndicator());
}
@@ -490,7 +492,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
);
}
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
DebugLog.info(
'[EquipmentManagementPage] Building list with ${equipments.length} items');
// Calculer le nombre total d'items (équipements + indicateur de chargement)
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
@@ -498,10 +501,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
return ListView.builder(
controller: _scrollController,
itemCount: itemCount,
// Ajouter une estimation de la hauteur pour améliorer le scroll
// Note : À ajuster selon la hauteur réelle de vos cartes
// itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur
// Augmenter le cache pour un scroll plus fluide
// Augmenter le cache pour un scroll plus fluide (prototypeItem retiré car les hauteurs dynamiques varient selon le type d'équipement)
cacheExtent: 500, // Précharger 500px en plus
itemBuilder: (context, index) {
// Dernier élément = indicateur de chargement
@@ -528,121 +528,192 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
return RepaintBoundary(
key: ValueKey(equipment.id),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
margin: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isSelectionMode && isSelected
? AppColors.rouge
: Colors.grey.shade200,
width: isSelectionMode && isSelected ? 2 : 1,
),
),
color: isSelectionMode && isSelected
? AppColors.rouge.withValues(alpha: 0.1)
: null,
child: ListTile(
leading: isSelectionMode
? Checkbox(
value: isSelected,
onChanged: (value) => toggleItemSelection(equipment.id),
activeColor: AppColors.rouge,
)
: CircleAvatar(
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
child: equipment.category.getIcon(
size: 20,
color: equipment.category.color,
),
),
title: Row(
children: [
Expanded(
child: Text(
equipment.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// Afficher le badge de statut calculé dynamiquement
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
EquipmentStatusBadge(equipment: equipment),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
.trim()
.isNotEmpty
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
: 'Marque/Modèle non défini',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
// Afficher la sous-catégorie si elle existe
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'📁 ${equipment.subCategory}',
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
// Afficher la quantité disponible pour les consommables/câbles
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) ...[
const SizedBox(height: 4),
_buildQuantityDisplay(equipment),
],
],
),
trailing: isSelectionMode
? AppColors.rouge.withValues(alpha: 0.05)
: Colors.white,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: isSelectionMode
? () => toggleItemSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
onLongPress: isSelectionMode
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton Restock (uniquement pour consommables/câbles avec permission)
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.add_shopping_cart,
color: AppColors.rouge),
tooltip: 'Restock',
onPressed: () => _showRestockDialog(equipment),
: () {
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
if (localUserProvider.hasPermission('manage_equipment')) {
_editEquipment(equipment);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Vous n'avez pas la permission de modifier cet équipement"),
),
);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 1. leading selection or icon
if (isSelectionMode)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Checkbox(
value: isSelected,
onChanged: (value) => toggleItemSelection(equipment.id),
activeColor: AppColors.rouge,
),
)
else
Padding(
padding: const EdgeInsets.only(right: 16),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: equipment.category.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: equipment.category.getIcon(
size: 22,
color: equipment.category.color,
),
),
),
// Bouton QR Code
IconButton(
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
tooltip: 'QR Code',
onPressed: () => showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(equipment),
),
),
// Bouton Modifier (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge),
tooltip: 'Modifier',
onPressed: () => _editEquipment(equipment),
),
// 2. Info details (ID, Brand/Model, Subcategory)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
equipment.id,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: AppColors.noir,
),
),
const SizedBox(height: 4),
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
.trim()
.isNotEmpty
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
: 'Marque/Modèle non défini',
style: TextStyle(
color: Colors.grey[700],
fontSize: 13,
),
),
// Sous-catégorie
if (equipment.subCategory != null &&
equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'📁 ${equipment.subCategory}',
style: TextStyle(
color: Colors.grey[500],
fontSize: 11,
fontStyle: FontStyle.italic,
),
),
],
],
),
// Bouton Supprimer (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _deleteEquipment(equipment),
),
),
const SizedBox(width: 16),
// 3. Status Badge OR Quantity Display (centered vertically in the row!)
Padding(
padding: const EdgeInsets.only(right: 16),
child: (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable)
? _buildQuantityDisplay(equipment)
: EquipmentStatusBadge(equipment: equipment),
),
// 4. Trailing Action Buttons
if (!isSelectionMode)
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton Restock (uniquement pour consommables/câbles avec permission)
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.add_shopping_cart,
color: AppColors.rouge, size: 20),
tooltip: 'Restock',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _showRestockDialog(equipment),
),
),
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable)
const SizedBox(width: 8),
// Bouton QR Code
IconButton(
icon: const Icon(Icons.qr_code, color: AppColors.rouge, size: 20),
tooltip: 'QR Code',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => showDialog(
context: context,
builder: (context) =>
QRCodeDialog.forEquipment(equipment),
),
),
const SizedBox(width: 8),
// Bouton Modifier (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge, size: 20),
tooltip: 'Modifier',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _editEquipment(equipment),
),
),
const SizedBox(width: 8),
// Bouton Supprimer (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.delete, color: Colors.red, size: 20),
tooltip: 'Supprimer',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _deleteEquipment(equipment),
),
),
],
),
],
),
onTap: isSelectionMode
? () => toggleItemSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
],
),
),
),
),
)
);
}
@@ -652,60 +723,26 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
final criticalThreshold = equipment.criticalThreshold ?? 0;
final isCritical =
criticalThreshold > 0 && availableQty <= criticalThreshold;
final color = isCritical ? Colors.red : Colors.grey.shade600;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isCritical
? Colors.red.withOpacity(0.15)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isCritical ? Colors.red : Colors.grey.shade400,
width: isCritical ? 2 : 1,
),
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isCritical ? Icons.warning : Icons.inventory,
size: 16,
color: isCritical ? Colors.red : Colors.grey[700],
),
const SizedBox(width: 6),
Text(
'Disponible: $availableQty / $totalQty',
style: TextStyle(
fontSize: 13,
fontWeight: isCritical ? FontWeight.bold : FontWeight.normal,
color: isCritical ? Colors.red : Colors.grey[700],
),
),
if (isCritical) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'CRITIQUE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
],
child: Text(
'$availableQty / $totalQty',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: color,
),
),
);
}
// Actions
void _createNewEquipment() {
Navigator.push(
@@ -726,39 +763,64 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
}
void _deleteEquipment(EquipmentModel equipment) {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: equipment.id,
name: equipment.name,
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
await context
.read<EquipmentProvider>()
.deleteEquipment(equipment.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Équipement supprimé avec succès')),
);
final deleted =
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: equipmentLabel,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
equipment.id,
forceDelete: forceDelete,
);
},
);
if (!deleted) {
return;
}
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green,
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
@@ -768,46 +830,78 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
void _deleteSelectedEquipment() async {
if (!hasSelection) return;
final pageContext = context;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?',
EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage(
selectedCount,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
final provider = context.read<EquipmentProvider>();
final equipmentById = {
for (final equipment
in provider.equipment)
equipment.id: equipment,
};
var deletedCount = 0;
for (final id in selectedIds) {
await provider.deleteEquipment(id);
final label = EquipmentDeleteUtils.resolveEquipmentLabel(
id: id,
name: equipmentById[id]?.name,
);
final deleted = await EquipmentDeleteUtils
.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: label,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
id,
forceDelete: forceDelete,
);
},
);
if (deleted) {
deletedCount++;
}
}
disableSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'$selectedCount équipement(s) supprimé(s) avec succès'),
backgroundColor: Colors.green,
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
EquipmentDeleteUtils.buildBulkDeleteSuccessMessage(
deletedCount,
),
),
);
}
backgroundColor: Colors.green,
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
@@ -853,7 +947,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) {
showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
builder: (context) =>
QRCodeDialog.forEquipment(selectedEquipment.first),
);
}
} else {
@@ -1046,7 +1141,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
updatedAt: DateTime.now(),
);
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
await context
.read<EquipmentProvider>()
.updateEquipment(updatedEquipment);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1184,7 +1281,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
content: Text(
'Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
backgroundColor: Colors.orange,
),
);
+25 -22
View File
@@ -77,7 +77,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
return;
}
final success = await _controller.submitForm(context, existingEvent: widget.event);
final success =
await _controller.submitForm(context, existingEvent: widget.event);
if (success && mounted) {
Navigator.of(context).pop();
}
@@ -158,21 +159,25 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
},
child: Scaffold(
appBar: AppBar(
title: Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'),
title: Text(
isEditMode ? 'Modifier un événement' : 'Créer un événement'),
),
body: Center(
child: SingleChildScrollView(
child: (isMobile
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
child: _buildFormContent(isMobile),
)
: Card(
elevation: 6,
margin: const EdgeInsets.all(24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
padding: const EdgeInsets.symmetric(
horizontal: 32, vertical: 32),
child: _buildFormContent(isMobile),
),
)),
@@ -186,15 +191,6 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
Widget _buildFormContent(bool isMobile) {
return Consumer<EventFormController>(
builder: (context, controller, child) {
// Trouver le nom du type d'événement pour le passer au sélecteur d'options
final selectedEventTypeIndex = controller.selectedEventTypeId != null
? controller.eventTypes.indexWhere((et) => et.id == controller.selectedEventTypeId)
: -1;
final selectedEventType = selectedEventTypeIndex != -1
? controller.eventTypes[selectedEventTypeIndex]
: null;
final selectedEventTypeName = selectedEventType?.name;
return Form(
key: _formKey,
child: Column(
@@ -209,18 +205,22 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
selectedEventTypeId: controller.selectedEventTypeId,
startDateTime: controller.startDateTime,
endDateTime: controller.endDateTime,
onEventTypeChanged: (typeId) => controller.onEventTypeChanged(typeId, context),
onEventTypeChanged: (typeId) =>
controller.onEventTypeChanged(typeId, context),
onStartDateTimeChanged: controller.setStartDateTime,
onEndDateTimeChanged: controller.setEndDateTime,
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
onAnyFieldChanged:
() {}, // Géré automatiquement par le contrôleur
),
const SizedBox(height: 16),
OptionSelectorWidget(
eventType: controller.selectedEventTypeId, // Utilise l'ID au lieu du nom
eventType: controller
.selectedEventTypeId, // Utilise l'ID au lieu du nom
selectedOptions: controller.selectedOptions,
onChanged: controller.setSelectedOptions,
onRemove: (optionId) {
final newOptions = List<Map<String, dynamic>>.from(controller.selectedOptions);
final newOptions = List<Map<String, dynamic>>.from(
controller.selectedOptions);
newOptions.removeWhere((o) => o['id'] == optionId);
controller.setSelectedOptions(newOptions);
},
@@ -236,6 +236,7 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
endDate: controller.endDateTime,
onChanged: controller.setAssignedEquipment,
eventId: widget.event?.id,
eventTypeId: controller.selectedEventTypeId,
),
const SizedBox(height: 16),
EventDetailsSection(
@@ -247,7 +248,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
contactEmailController: controller.contactEmailController,
contactPhoneController: controller.contactPhoneController,
isMobile: isMobile,
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
onAnyFieldChanged:
() {}, // Géré automatiquement par le contrôleur
),
EventStaffAndDocumentsSection(
allUsers: controller.allUsers,
@@ -290,9 +292,10 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
}
},
onSubmit: _submit,
onSetConfirmed: !isEditMode ? () {
} : null,
onDelete: isEditMode ? _deleteEvent : null, // Ajout du callback de suppression
onSetConfirmed: !isEditMode ? () {} : null,
onDelete: isEditMode
? _deleteEvent
: null, // Ajout du callback de suppression
),
],
),
+27 -5
View File
@@ -13,6 +13,7 @@ import 'package:em2rp/services/qr_code_processing_service.dart';
import 'package:em2rp/services/audio_feedback_service.dart';
import 'package:em2rp/services/smart_text_to_speech_service.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/event_preparation_service.dart';
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
@@ -1097,6 +1098,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Détermine le statut d'un équipement selon l'étape actuelle
String _determineEquipmentStatus(EventEquipment eq) {
if (_isNotTakenToEventAtReturn(eq)) {
return 'NOT_TAKEN';
}
// Vérifier d'abord si l'équipement est perdu (LOST)
if (_shouldMarkAsLost(eq)) {
return 'LOST';
@@ -1118,14 +1123,31 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Vérifie si un équipement doit être marqué comme LOST
bool _shouldMarkAsLost(EventEquipment eq) {
// Seulement aux étapes de retour
if (_currentStep != PreparationStep.return_ &&
!(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) {
return EventPreparationService.shouldMarkEquipmentAsLost(
isReturnValidationStep: _isReturnValidationStep,
isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool _isNotTakenToEventAtReturn(EventEquipment eq) {
if (!_isReturnValidationStep) {
return false;
}
// Si manquant maintenant mais PAS manquant à la préparation = LOST
return eq.isMissingAtReturn && !eq.isMissingAtPreparation;
return EventPreparationService.isEquipmentNotTakenToEvent(
isMissingAtReturn: eq.isMissingAtReturn,
isLoaded: eq.isLoaded,
isMissingAtLoading: eq.isMissingAtLoading,
quantityAtLoading: eq.quantityAtLoading,
);
}
bool get _isReturnValidationStep {
return _currentStep == PreparationStep.return_ ||
(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously);
}
/// Vérifie si un équipement est manquant à l'étape actuelle
@@ -75,14 +75,15 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
title: 'Gestion des maintenances',
),
drawer: const MainDrawer(currentPage: '/maintenance_management'),
body: Consumer<MaintenanceProvider>(
builder: (context, maintenanceProvider, _) {
if (maintenanceProvider.isLoading) {
body: Selector<MaintenanceProvider, ({bool isLoading, List<MaintenanceModel> maintenances})>(
selector: (context, provider) => (isLoading: provider.isLoading, maintenances: provider.maintenances),
builder: (context, data, _) {
if (data.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final filteredMaintenances = _getFilteredMaintenances(
maintenanceProvider.maintenances,
data.maintenances,
);
return Column(
@@ -91,7 +92,7 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
_buildFilterChips(),
// Statistiques
_buildStatsCards(maintenanceProvider),
_buildStatsCards(data.maintenances),
// Liste des maintenances
Expanded(
@@ -148,10 +149,10 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
);
}
Widget _buildStatsCards(MaintenanceProvider provider) {
final upcoming = provider.maintenances.where((m) => !m.isCompleted && !m.isOverdue).length;
final overdue = provider.maintenances.where((m) => m.isOverdue).length;
final completed = provider.maintenances.where((m) => m.isCompleted).length;
Widget _buildStatsCards(List<MaintenanceModel> maintenances) {
final upcoming = maintenances.where((m) => !m.isCompleted && !m.isOverdue).length;
final overdue = maintenances.where((m) => m.isOverdue).length;
final completed = maintenances.where((m) => m.isCompleted).length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
+5 -5
View File
@@ -2,6 +2,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/views/widgets/inputs/styled_text_field.dart';
import 'package:em2rp/views/widgets/image/profile_picture_selector.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
@@ -17,10 +18,9 @@ class MyAccountPage extends StatelessWidget {
title: 'Mon compte',
),
drawer: const MainDrawer(currentPage: '/my_account'),
body: Consumer<LocalUserProvider>(
builder: (context, userProvider, child) {
final user = userProvider.currentUser;
body: Selector<LocalUserProvider, UserModel?>(
selector: (context, provider) => provider.currentUser,
builder: (context, user, child) {
if (user == null) {
return const Center(child: CircularProgressIndicator());
}
@@ -73,7 +73,7 @@ class MyAccountPage extends StatelessWidget {
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
userProvider.updateUserData(
context.read<LocalUserProvider>().updateUserData(
firstName: firstNameController.text,
lastName: lastNameController.text,
phoneNumber: phoneController.text,
+6 -5
View File
@@ -51,12 +51,13 @@ class _UserManagementPageState extends State<UserManagementPage> {
title: 'Gestion des utilisateurs',
),
drawer: const MainDrawer(currentPage: '/account_management'),
body: Consumer<UsersProvider>(
builder: (context, usersProvider, child) {
if (usersProvider.isLoading) {
body: Selector<UsersProvider, ({bool isLoading, List<UserModel> users})>(
selector: (context, provider) => (isLoading: provider.isLoading, users: provider.users),
builder: (context, data, child) {
if (data.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final users = usersProvider.users;
final users = data.users;
if (users.isEmpty) {
return const Center(child: Text("Aucun utilisateur trouvé"));
}
@@ -92,7 +93,7 @@ class _UserManagementPageState extends State<UserManagementPage> {
context: context,
builder: (_) => EditUserDialog(user: user)),
onResetPassword: () => _resetPassword(context, user),
onDelete: () => _confirmDeleteUser(context, usersProvider, user),
onDelete: () => _confirmDeleteUser(context, context.read<UsersProvider>(), user),
);
},
),
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
class StartupSplashScreen extends StatelessWidget {
final String message;
const StartupSplashScreen({super.key, this.message = 'Démarrage...'});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/logos/RectangleLogoBlack.png',
width: 160,
height: 160,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.event_available,
size: 72,
color: AppColors.rouge,
);
},
),
const SizedBox(height: 24),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
color: AppColors.noir,
fontSize: 16,
),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,755 @@
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/services/ai_equipment_assistant_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:convert';
/// Résultat retourné par le dialog après confirmation de la proposition IA.
class AiProposalResult {
final List<EventEquipment> equipment;
final List<String> containerIds;
const AiProposalResult({
required this.equipment,
required this.containerIds,
});
}
class AiEquipmentAssistantDialog extends StatefulWidget {
final DateTime startDate;
final DateTime endDate;
final String? eventTypeId;
final String? excludeEventId;
final List<EventEquipment> currentAssignedEquipment;
const AiEquipmentAssistantDialog({
super.key,
required this.startDate,
required this.endDate,
required this.currentAssignedEquipment,
this.eventTypeId,
this.excludeEventId,
});
@override
State<AiEquipmentAssistantDialog> createState() =>
_AiEquipmentAssistantDialogState();
}
class _AiEquipmentAssistantDialogState
extends State<AiEquipmentAssistantDialog> {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final ScrollController _proposalScrollController = ScrollController();
final List<_AssistantChatMessage> _messages = [];
late final AiEquipmentAssistantService _assistantService;
bool _isLoading = false;
String? _errorMessage;
AiEquipmentProposal? _latestProposal;
late List<EventEquipment> _workingEquipment;
AiEquipmentDocument? _selectedDocument;
List<String> _sessionLogs = [];
Set<String> _selectedContainerIds = {};
@override
void initState() {
super.initState();
_assistantService = AiEquipmentAssistantService();
_workingEquipment = List<EventEquipment>.from(widget.currentAssignedEquipment);
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
_proposalScrollController.dispose();
super.dispose();
}
bool get _isChatEmpty => _messages.isEmpty;
String get _actionButtonLabel {
return _isChatEmpty ? 'Generer la liste automatiquement' : 'Envoyer';
}
Future<void> _sendMessage() async {
if (_isLoading) {
return;
}
final rawInput = _messageController.text.trim();
final isAutoMode = _isChatEmpty;
final userMessage = isAutoMode
? (rawInput.isNotEmpty
? rawInput
: 'Genere automatiquement une proposition de materiel pour cet evenement.')
: rawInput;
if (userMessage.isEmpty) {
return;
}
_messageController.clear();
setState(() {
_errorMessage = null;
_messages.add(_AssistantChatMessage.user(userMessage));
if (_selectedDocument != null) {
_messages.add(_AssistantChatMessage.user('[Document joint : ${_selectedDocument!.fileName ?? "Document"}]'));
}
_isLoading = true;
});
_scrollToBottom();
try {
final documentToSend = _selectedDocument;
_selectedDocument = null; // Clear after sending
final response = await _assistantService
.generateProposal(
startDate: widget.startDate,
endDate: widget.endDate,
eventTypeId: widget.eventTypeId,
excludeEventId: widget.excludeEventId,
currentAssignedEquipment: widget.currentAssignedEquipment,
workingProposalEquipment: _workingEquipment,
userMessage: userMessage,
document: documentToSend,
history: _messages
.map((message) => AiAssistantChatTurn(
isUser: message.isUser, text: message.text))
.toList(),
);
if (!mounted) {
return;
}
setState(() {
_messages
.add(_AssistantChatMessage.assistant(response.assistantMessage));
_latestProposal = response.proposal;
if (response.proposal != null) {
_workingEquipment = List<EventEquipment>.from(
response.proposal!.asEventEquipment,
);
// Préselectionner les containers non partiels
_selectedContainerIds = {
for (final c in response.proposal!.containers)
if (!c.partial) c.containerId
};
}
_sessionLogs.addAll(response.debugLogs);
_isLoading = false;
});
_scrollToBottom();
} on FormatException catch (error) {
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = 'Reponse IA invalide: ${error.message}';
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = 'Erreur IA: $error';
});
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) {
return;
}
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
});
}
Future<void> _pickDocument() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'txt', 'jpg', 'jpeg', 'png'],
withData: true,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
if (file.bytes != null) {
final base64String = base64Encode(file.bytes!);
String mimeType = 'application/octet-stream';
if (file.extension == 'pdf') mimeType = 'application/pdf';
else if (file.extension == 'txt') mimeType = 'text/plain';
else if (file.extension == 'jpg' || file.extension == 'jpeg') mimeType = 'image/jpeg';
else if (file.extension == 'png') mimeType = 'image/png';
setState(() {
_selectedDocument = AiEquipmentDocument(
base64Data: base64String,
mimeType: mimeType,
fileName: file.name,
);
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Erreur lors de la selection du document : $e';
});
}
}
}
void _showLogsDialog() {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Logs de l\'IA'),
content: SizedBox(
width: 800,
height: 600,
child: ListView.builder(
itemCount: _sessionLogs.length,
itemBuilder: (context, index) {
final log = _sessionLogs[index];
final isError = log.startsWith('[ERROR]');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
log,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: isError ? Colors.red : Colors.black87,
),
),
);
},
),
),
actions: [
TextButton(
onPressed: () {
final fullLogs = _sessionLogs.join('\n');
Clipboard.setData(ClipboardData(text: fullLogs));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logs copiés dans le presse-papiers')),
);
},
child: const Text('Copier tout'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: SizedBox(
width: 760,
height: 640,
child: Column(
children: [
AppBar(
automaticallyImplyLeading: false,
title: const Text('(BETA) Assistant IA Logisticien'),
actions: [
if (_sessionLogs.isNotEmpty)
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: 'Voir les logs',
onPressed: _showLogsDialog,
),
IconButton(
icon: const Icon(Icons.close),
onPressed:
_isLoading ? null : () => Navigator.of(context).pop(),
),
],
),
Expanded(
child: Column(
children: [
Expanded(
child: Container(
color: Colors.grey.shade50,
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return _buildMessageBubble(message);
},
),
),
),
if (_isLoading)
const Padding(
padding:
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Expanded(
child: const Text(
'Generation en cours... verification du materiel et disponibilites. (Cela peut prendre jusqu\'a une minute en cas de forte affluence)',
textAlign: TextAlign.left,
),
),
],
),
),
if (_errorMessage != null)
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade800),
),
),
if (_latestProposal != null)
_buildProposalSummary(_latestProposal!),
if (_selectedDocument != null)
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 8),
child: Row(
children: [
const Icon(Icons.attach_file, color: Colors.blue, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedDocument!.fileName ?? 'Document joint',
style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () {
setState(() {
_selectedDocument = null;
});
},
tooltip: 'Retirer le document',
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.attach_file),
onPressed: _isLoading ? null : _pickDocument,
tooltip: 'Joindre un devis ou document',
),
Expanded(
child: TextField(
controller: _messageController,
enabled: !_isLoading,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
hintText:
'Precisez votre besoin (style, jauge, contraintes...)',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _isLoading ? null : _sendMessage,
child: Text(_actionButtonLabel),
),
],
),
),
],
),
),
],
),
),
);
}
Widget _buildMessageBubble(_AssistantChatMessage message) {
final bubbleColor = message.isUser ? Colors.blue.shade600 : Colors.white;
final textColor = message.isUser ? Colors.white : Colors.black87;
return Align(
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 10),
constraints: const BoxConstraints(maxWidth: 560),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(message.isUser ? 16 : 4),
bottomRight: Radius.circular(message.isUser ? 4 : 16),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
border:
message.isUser ? null : Border.all(color: Colors.grey.shade200),
),
child: message.isUser
? Text(message.text, style: TextStyle(color: textColor))
: _buildAssistantMessageContent(message.text),
),
);
}
Widget _buildAssistantMessageContent(String text) {
// Si le message semble structuré par l'IA avec nos nouvelles règles
if (text.contains('Matériel ajouté :') || text.contains('Matériel non trouvé')) {
final sections = text.split('\n\n');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sections.map((section) {
final isAdded = section.contains('Matériel ajouté :');
final isMissing = section.contains('Matériel non trouvé');
if (isAdded) {
return _buildStatusSection(
title: section.split('\n').first,
content: section.split('\n').skip(1).join('\n'),
icon: Icons.check_circle_outline,
color: Colors.green.shade700,
bgColor: Colors.green.shade50,
);
} else if (isMissing) {
return _buildStatusSection(
title: section.split('\n').first,
content: section.split('\n').skip(1).join('\n'),
icon: Icons.warning_amber_rounded,
color: Colors.orange.shade800,
bgColor: Colors.orange.shade50,
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(section),
);
}).toList(),
);
}
return Text(text);
}
Widget _buildStatusSection({
required String title,
required String content,
required IconData icon,
required Color color,
required Color bgColor,
}) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
title.replaceAll(':', '').trim(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
),
),
),
],
),
if (content.trim().isNotEmpty) ...[
const SizedBox(height: 8),
Text(
content.trim(),
style: TextStyle(fontSize: 13, color: Colors.grey.shade800),
),
],
],
),
);
}
void _confirmProposal({bool excludeAlternatives = false}) {
if (_latestProposal == null) return;
List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment);
// Ne renvoyer que les containerIds sélectionnés (par défaut les containers complets)
final List<String> containerIds = _selectedContainerIds.isNotEmpty
? _selectedContainerIds.toList()
: List.from(_latestProposal!.containerIds);
if (excludeAlternatives) {
// On utilise la liste des items d'origine pour savoir lesquels exclure
// car ils contiennent le champ rationale (avant conversion en EventEquipment)
final idsToExclude = _latestProposal!.items
.where((item) {
final rationale = item.rationale.toLowerCase();
return rationale.contains('alternative') ||
rationale.contains('remplacement') ||
rationale.contains('indisponible');
})
.map((item) => item.equipmentId)
.toSet();
equipment = equipment.where((eq) => !idsToExclude.contains(eq.equipmentId)).toList();
}
Navigator.of(context).pop(
AiProposalResult(
equipment: equipment,
containerIds: containerIds,
),
);
}
Widget _buildProposalSummary(AiEquipmentProposal proposal) {
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(maxHeight: 280),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.assignment_turned_in, color: Colors.indigo),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Récapitulatif de la proposition IA',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.indigo,
),
),
),
],
),
const SizedBox(height: 12),
Flexible(
child: Scrollbar(
controller: _proposalScrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _proposalScrollController,
padding: const EdgeInsets.only(right: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
proposal.summary,
style: const TextStyle(fontStyle: FontStyle.italic),
),
if (proposal.items.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'Matériel individuel :',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(height: 4),
...proposal.items.map((item) {
final isAlt = item.rationale.toLowerCase().contains('alternative') || item.rationale.toLowerCase().contains('remplacement');
return Padding(
padding: const EdgeInsets.only(bottom: 6, left: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isAlt ? Icons.swap_horiz : Icons.add_circle_outline,
size: 14,
color: isAlt ? Colors.orange : Colors.indigo,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${item.equipmentId} x${item.quantity}',
style: const TextStyle(fontWeight: FontWeight.w500)
),
),
],
),
);
}),
],
if (proposal.containers.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'Fly-cases & Boîtes :',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(height: 4),
...proposal.containers.map((c) {
final isPartial = c.partial;
final isSelected = _selectedContainerIds.contains(c.containerId);
return Padding(
padding: const EdgeInsets.only(bottom: 6, left: 4),
child: Row(
children: [
Icon(
Icons.inventory_2_outlined,
size: 14,
color: c.available == false ? Colors.red : Colors.indigo,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text('${c.containerId} ${c.rationale.isNotEmpty ? "- ${c.rationale}" : ""}', style: const TextStyle(fontWeight: FontWeight.w500))),
if (c.available == false)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(Icons.block, color: Colors.red.shade700, size: 14),
),
],
),
if (isPartial) ...[
const SizedBox(height: 4),
Text('Contenu partiel : ${c.matchingEquipmentIds.length}/${c.equipmentIds.length} items utilisés.', style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
],
],
),
),
const SizedBox(width: 8),
if (isPartial)
Checkbox(
value: isSelected,
onChanged: (v) {
setState(() {
if (v == true) _selectedContainerIds.add(c.containerId);
else _selectedContainerIds.remove(c.containerId);
});
},
),
],
),
);
}),
],
],
),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _isLoading ? null : () => _confirmProposal(),
icon: const Icon(Icons.check),
label: const Text('Tout ajouter'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
),
OutlinedButton.icon(
onPressed: _isLoading ? null : () => _confirmProposal(excludeAlternatives: true),
icon: const Icon(Icons.filter_list_off),
label: const Text('Ajouter sans alternatives'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.indigo,
side: const BorderSide(color: Colors.indigo),
),
),
],
),
],
),
);
}
}
class _AssistantChatMessage {
final bool isUser;
final String text;
const _AssistantChatMessage._({required this.isUser, required this.text});
factory _AssistantChatMessage.user(String text) {
return _AssistantChatMessage._(isUser: true, text: text);
}
factory _AssistantChatMessage.assistant(String text) {
return _AssistantChatMessage._(isUser: false, text: text);
}
}
@@ -8,6 +8,7 @@ import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
import 'package:em2rp/views/widgets/event_form/ai_equipment_assistant_dialog.dart';
/// Section pour afficher et gérer le matériel assigné à un événement
class EventAssignedEquipmentSection extends StatefulWidget {
@@ -17,6 +18,7 @@ class EventAssignedEquipmentSection extends StatefulWidget {
final DateTime? endDate;
final Function(List<EventEquipment>, List<String>) onChanged;
final String? eventId; // Pour exclure l'événement actuel de la vérification
final String? eventTypeId;
const EventAssignedEquipmentSection({
super.key,
@@ -26,14 +28,18 @@ class EventAssignedEquipmentSection extends StatefulWidget {
required this.endDate,
required this.onChanged,
this.eventId,
this.eventTypeId,
});
@override
State<EventAssignedEquipmentSection> createState() => _EventAssignedEquipmentSectionState();
State<EventAssignedEquipmentSection> createState() =>
_EventAssignedEquipmentSectionState();
}
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
class _EventAssignedEquipmentSectionState
extends State<EventAssignedEquipmentSection> {
bool get _canAddMaterial =>
widget.startDate != null && widget.endDate != null;
final Map<String, EquipmentModel> _equipmentCache = {};
final Map<String, ContainerModel> _containerCache = {};
bool _isLoading = true;
@@ -61,19 +67,24 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>();
DebugLog.info('[EventAssignedEquipmentSection] Loading caches from assigned lists');
DebugLog.info(
'[EventAssignedEquipmentSection] Loading caches from assigned lists');
// Toujours partir des données locales du formulaire pour éviter les décalages visuels.
final equipmentIds = widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
final equipmentIds =
widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
final containers =
await containerProvider.getContainersByIds(widget.assignedContainers);
final childEquipmentIds = <String>[];
for (final container in containers) {
childEquipmentIds.addAll(container.equipmentIds);
}
final allEquipmentIds = <String>{...equipmentIds, ...childEquipmentIds}.toList();
final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
final allEquipmentIds =
<String>{...equipmentIds, ...childEquipmentIds}.toList();
final equipment =
await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
_equipmentCache.clear();
_containerCache.clear();
@@ -110,7 +121,9 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
_containerCache[containerId] = container;
}
} catch (e) {
DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e);
DebugLog.error(
'[EventAssignedEquipmentSection] Error loading equipment and containers',
e);
} finally {
setState(() => _isLoading = false);
}
@@ -138,7 +151,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
}
Future<void> _processSelection(Map<String, SelectedItem> selection) async {
DebugLog.info('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
DebugLog.info(
'[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
// Séparer équipements et conteneurs
final newEquipment = <EventEquipment>[];
@@ -155,23 +169,27 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
}
}
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
DebugLog.info(
'[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
// 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants
if (newContainers.isNotEmpty) {
final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.getContainersByIds(newContainers);
final containers =
await containerProvider.getContainersByIds(newContainers);
for (var container in containers) {
for (var childEquipmentId in container.equipmentIds) {
// Vérifier si l'équipement enfant n'est pas déjà dans la liste
final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
final existsInNew =
newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
if (!existsInNew) {
newEquipment.add(EventEquipment(
equipmentId: childEquipmentId,
quantity: 1,
));
DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
DebugLog.info(
'[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
}
}
}
@@ -183,11 +201,12 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
// Fusionner avec l'existant
final updatedEquipment = [...widget.assignedEquipment];
final updatedContainers = [...widget.assignedContainers];
// Pour chaque nouvel équipement
for (var eq in newEquipment) {
final existingIndex = updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
final existingIndex =
updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
if (existingIndex != -1) {
// L'équipement existe déjà : mettre à jour la quantité
updatedEquipment[existingIndex] = EventEquipment(
@@ -204,17 +223,85 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
updatedEquipment.add(eq);
}
}
for (var containerId in newContainers) {
if (!updatedContainers.contains(containerId)) {
updatedContainers.add(containerId);
}
}
// Notifier le changement
widget.onChanged(updatedEquipment, updatedContainers);
}
Future<void> _openAiAssistantDialog() async {
if (widget.startDate == null || widget.endDate == null) {
return;
}
final result = await showDialog<AiProposalResult>(
context: context,
builder: (context) => AiEquipmentAssistantDialog(
startDate: widget.startDate!,
endDate: widget.endDate!,
eventTypeId: widget.eventTypeId,
excludeEventId: widget.eventId,
currentAssignedEquipment: widget.assignedEquipment,
),
);
if (result == null) {
return;
}
_applyAiProposal(result);
}
void _applyAiProposal(AiProposalResult result) async {
final existingById = {
for (final equipment in widget.assignedEquipment)
equipment.equipmentId: equipment,
};
final updatedEquipment = result.equipment.map((proposed) {
final existing = existingById[proposed.equipmentId];
if (existing == null) {
return proposed;
}
return existing.copyWith(quantity: proposed.quantity, rationale: proposed.rationale);
}).toList();
// 🔧 FIX: Pour chaque container ajouté par l'IA, ajouter aussi ses équipements enfants
if (result.containerIds.isNotEmpty) {
final containerProvider = context.read<ContainerProvider>();
final containers = await containerProvider.getContainersByIds(result.containerIds);
for (var container in containers) {
for (var childEquipmentId in container.equipmentIds) {
// Vérifier si l'équipement enfant n'est pas déjà dans la liste (ou déjà ajouté par la proposition)
final exists = updatedEquipment.any((eq) => eq.equipmentId == childEquipmentId);
if (!exists) {
updatedEquipment.add(EventEquipment(
equipmentId: childEquipmentId,
quantity: 1,
rationale: 'Inclus dans ${container.id}',
));
DebugLog.info('[EventAssignedEquipmentSection] AI adding child equipment $childEquipmentId from container ${container.id}');
}
}
}
}
final updatedContainers = [...widget.assignedContainers];
for (final containerId in result.containerIds) {
if (!updatedContainers.contains(containerId)) {
updatedContainers.add(containerId);
}
}
widget.onChanged(updatedEquipment, updatedContainers);
}
void _removeEquipment(String equipmentId) {
final updated = widget.assignedEquipment
.where((eq) => eq.equipmentId != equipmentId)
@@ -231,9 +318,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
final container = _containerCache[containerId];
// Retirer le conteneur de la liste
final updatedContainers = widget.assignedContainers
.where((id) => id != containerId)
.toList();
final updatedContainers =
widget.assignedContainers.where((id) => id != containerId).toList();
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
final updatedEquipment = <EventEquipment>[];
@@ -252,8 +338,10 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
// 1. Ne sont PAS dans le container supprimé OU
// 2. Sont dans le container supprimé MAIS aussi dans un autre container
for (var eq in widget.assignedEquipment) {
final isInRemovedContainer = container.equipmentIds.contains(eq.equipmentId);
final isInOtherContainer = equipmentIdsInOtherContainers.contains(eq.equipmentId);
final isInRemovedContainer =
container.equipmentIds.contains(eq.equipmentId);
final isInOtherContainer =
equipmentIdsInOtherContainers.contains(eq.equipmentId);
if (!isInRemovedContainer || isInOtherContainer) {
updatedEquipment.add(eq);
@@ -271,7 +359,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
_containerCache.remove(containerId);
// Nettoyer le cache uniquement pour les équipements effectivement supprimés
if (container != null) {
final remainingEquipmentIds = updatedEquipment.map((eq) => eq.equipmentId).toSet();
final remainingEquipmentIds =
updatedEquipment.map((eq) => eq.equipmentId).toSet();
for (var equipmentId in container.equipmentIds) {
if (!remainingEquipmentIds.contains(equipmentId)) {
_equipmentCache.remove(equipmentId);
@@ -301,7 +390,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
@override
Widget build(BuildContext context) {
final totalItems = widget.assignedEquipment.length + widget.assignedContainers.length;
final totalItems =
widget.assignedEquipment.length + widget.assignedContainers.length;
return Card(
elevation: 2,
@@ -350,15 +440,25 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
],
),
),
ActionChip(
onPressed: _canAddMaterial ? _openAiAssistantDialog : null,
avatar: const Icon(Icons.auto_fix_high, size: 18),
label: const Text('Assistant IA'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _canAddMaterial ? _openSelectionDialog : null,
icon: Icon(Icons.add, color: _canAddMaterial ? Colors.white : Colors.grey),
icon: Icon(Icons.add,
color: _canAddMaterial ? Colors.white : Colors.grey),
label: Text(
'Ajouter',
style: TextStyle(color: _canAddMaterial ? Colors.white : Colors.grey),
style: TextStyle(
color: _canAddMaterial ? Colors.white : Colors.grey),
),
style: ElevatedButton.styleFrom(
backgroundColor: _canAddMaterial ? AppColors.rouge : Colors.grey.shade300,
backgroundColor: _canAddMaterial
? AppColors.rouge
: Colors.grey.shade300,
),
),
],
@@ -512,7 +612,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -537,7 +638,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
color: Colors.grey.shade600,
),
const SizedBox(width: 8),
eq.category.getIcon(size: 16, color: eq.category.color),
eq.category
.getIcon(size: 16, color: eq.category.color),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -562,7 +664,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
);
}
Widget _buildEquipmentItem(EquipmentModel? equipment, EventEquipment eventEq) {
Widget _buildEquipmentItem(
EquipmentModel? equipment, EventEquipment eventEq) {
if (equipment == null) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
@@ -585,17 +688,15 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
}
final isConsumable = equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable;
equipment.category == EquipmentCategory.cable;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
child: equipment.category.getIconForAvatar(
size: 24,
color: equipment.category.color
),
child: equipment.category
.getIconForAvatar(size: 24, color: equipment.category.color),
),
title: Text(
equipment.id,
@@ -634,4 +735,3 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
);
}
}
+1
View File
@@ -17,6 +17,7 @@ dependencies:
cloud_functions: ^6.0.4
google_sign_in: ^7.2.0
firebase_storage: ^13.0.3
shared_preferences: ^2.0.15
# State Management
provider: ^6.1.2
+3 -3
View File
@@ -1,7 +1,7 @@
{
"version": "1.1.19",
"version": "1.2.3",
"updateUrl": "https://app.em2events.fr",
"forceUpdate": true,
"releaseNotes": "Fix BUG : Problème de cache avec les équipements non affichés dans le dialog de sélection d'équipement. Amélioration de la gestion du cache pour éviter les problèmes d'affichage.",
"timestamp": "2026-03-24T11:14:01.828Z"
"releaseNotes": "Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.",
"timestamp": "2026-05-26T13:34:16.390Z"
}