Compare commits
39 Commits
004d442e67
...
mise-en-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a182f1b922 | ||
|
|
b79791ff7a | ||
|
|
7e111ec041 | ||
|
|
4e7af9119a | ||
|
|
1ea5cea6fc | ||
|
|
06f394b728 | ||
|
|
67b85d323c | ||
|
|
beaabceda4 | ||
|
|
60d0e1c6c4 | ||
|
|
b30ae0f10a | ||
|
|
fb3f41df4d | ||
|
|
4e4573f57b | ||
|
|
4545bdba81 | ||
|
|
272b4bc9c9 | ||
|
|
0f7a886cf7 | ||
|
|
2bcd1ca4c3 | ||
|
|
f38d75362c | ||
|
|
13a890606d | ||
|
|
fb6a271f66 | ||
|
|
25d395b41a | ||
|
|
fa1d6a4295 | ||
|
|
df9e24d3b3 | ||
|
|
28d9e008af | ||
|
|
08f046c89c | ||
|
|
e59e3e6316 | ||
|
|
6abb8f1d14 | ||
|
|
822d4443f9 | ||
|
|
df6d54a007 | ||
|
|
3fab69cb00 | ||
|
|
ae3a1b7227 | ||
|
|
ef638d8c8c | ||
|
|
5057bf9a77 | ||
|
|
f10a608801 | ||
|
|
4128ddc34a | ||
|
|
aae68f8ab7 | ||
| 080fb7d077 | |||
| 57c59c911a | |||
| acab16e101 | |||
| 9a9c932262 |
47
em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache
Normal file
@@ -0,0 +1,47 @@
|
||||
manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2
|
||||
flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4
|
||||
favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0
|
||||
favicon.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
|
||||
icons/Icon-maskable-512.png,1766235851206,adeda24772174dad916236f9385d1deaa05da836521af74912a11d217a3e18de
|
||||
icons/Icon-maskable-192.png,1766235851135,fedfe0abc624a28f241f7f8e06ceab04c6c88a500290078410e1a7d12089952a
|
||||
icons/Icon-512.png,1766235851087,adeda24772174dad916236f9385d1deaa05da836521af74912a11d217a3e18de
|
||||
icons/Icon-192.png,1766235851013,fedfe0abc624a28f241f7f8e06ceab04c6c88a500290078410e1a7d12089952a
|
||||
canvaskit/skwasm_heavy.wasm,1759914809247,509ac05ee7c60aaee61d52bad4527f40e1ce79511ca29908237472a1cd476180
|
||||
canvaskit/skwasm_heavy.js.symbols,1759914809219,612ffa6a568de0500758c132cd0ea7d7c4f389157d618fe2b4255e73f3068e8f
|
||||
canvaskit/skwasm_heavy.js,1759914809214,5552644d0313045f87d52097dd1e86a75f64b9e048a450ce2c885e313ed1b4c5
|
||||
canvaskit/skwasm.wasm,1759914809212,85c6ff573c3f76f2d84f5553fab09bf0d0f715519c679f7626722ac0fb501640
|
||||
canvaskit/skwasm.js.symbols,1759914809190,83718024df2bd4902e4c0fdfa47ea7e9ca401dcf7f31f4061c6da8478f12987f
|
||||
canvaskit/skwasm.js,1759914809185,2e251855d712f083d8c6aa79bf49f6d2a8e15311f161115eb8a39bcf0688c878
|
||||
canvaskit/canvaskit.wasm,1759914809134,52dedf2cd2d6bf150262bf145ffde2fc80e296d98a9d3764961eb6f84c8ce988
|
||||
canvaskit/canvaskit.js.symbols,1759914809092,a3577bf24071e07f599ac61535dbee4ae4d37c5cc6ee6289379576773f9c336b
|
||||
canvaskit/canvaskit.js,1759914809082,bb9141a62dec1f0a41e311b845569915df9ebb5f074dd2afc181f26b323d2dd1
|
||||
canvaskit/chromium/canvaskit.wasm,1759914809184,4a868d7961a9740ae6694f62fc15b2b0ed76df50598e8311d61e8ee814d78229
|
||||
canvaskit/chromium/canvaskit.js.symbols,1759914809141,f395278c466a0eaed0201edd6b14a3aa8fee0a16bfedee2d239835cd7e865472
|
||||
canvaskit/chromium/canvaskit.js,1759914809136,ce5184f74e2501d849490df34d0506167a0708b9120be088039b785343335664
|
||||
assets/packages/flutter_map/lib/assets/flutter_map_logo.png,1759916249804,26fe50c9203ccf93512b80d4ee1a7578184a910457b36a6a5b7d41b799efb966
|
||||
assets/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d640313cd6a02692249cd41e4643c2771b4202cc84e0f07f5f65cdc77a36826f
|
||||
assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f
|
||||
assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee
|
||||
assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd
|
||||
assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703
|
||||
assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1
|
||||
assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c
|
||||
assets/assets/logos/RectangleLogoBlack.png,1760462340000,536ebd370e55736b3622a673c684a150e23f5d3b82c71283d7a3f4a93564c02c
|
||||
assets/assets/logos/LowQRectangleLogoBlack.png,1761139425319,ae4f8e428dd3634a14b45421a3c9b30fea8592ff33ff21f6962ed548e7db242b
|
||||
assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb6399444016c67afe9e223fddf4ecdac1dad822198
|
||||
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
||||
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
||||
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
||||
version.json,1768586208886,5a25871ae727f23c4b7258c34108085b8711aa94f6fcab512e0c3ca00a429a64
|
||||
index.html,1768586225248,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||
flutter_service_worker.js,1768586307073,4ea31c373e15f13c2916a12d9d799905af2a79ff7ed0bcceb4334707910c7721
|
||||
flutter_bootstrap.js,1768586225225,e95b1b0bd493a475c8eed0e630e413d898f2ceff11cd9b24c6c564bbc2c5f5e9
|
||||
assets/FontManifest.json,1768586302952,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||
assets/AssetManifest.json,1768586302952,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067
|
||||
assets/AssetManifest.bin.json,1768586302952,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a
|
||||
assets/AssetManifest.bin,1768586302952,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3
|
||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1768586306083,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||
assets/shaders/ink_sparkle.frag,1768586303187,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||
assets/fonts/MaterialIcons-Regular.otf,1768586306096,33efc485968dd28630ace587c22d6df359c195821b1114aaa85383e4d5394eac
|
||||
assets/NOTICES,1768586302954,fc20c3c3c998057eb7e58ad2e009c7268bf748bfde685e95130431f4c54bd51c
|
||||
main.dart.js,1768586301774,9b399ba21ab3247d46cf7dbcd5873aa248636bcd7864a1a0cedf1aae08608f9a
|
||||
6
em2rp/.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
CLEAN CODE très important: Toujours écrire du code propre, lisible et bien structuré. Utiliser des noms de variables et de fonctions explicites, éviter les répétitions inutiles et suivre les meilleures pratiques de codage.
|
||||
Penser a créer des fonctions réutilisables pour éviter la duplication de code.
|
||||
Verifier la présence de composant existants ou librairie existante avant de créer du code maison. Reutiliser le plus possible le code. Ne pas héister a analyser fréquemment la codebase existante.
|
||||
Créer des fichiers séparés pour chaque composant, classe ou module afin de faciliter la maintenance et la réutilisation. Il faut eviter de dépasser 600 lignes par fichier.
|
||||
Si quelque chose n'est pas clair, poser des questions pour clarifier les exigences avant de commencer à coder.
|
||||
Ne pas générer de fichier résumant le code généré.
|
||||
4
em2rp/.gitignore
vendored
@@ -41,3 +41,7 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Environment configuration with credentials
|
||||
lib/config/env.dev.dart
|
||||
functions/.env
|
||||
|
||||
37
em2rp/CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Changelog - EM2RP
|
||||
|
||||
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
||||
|
||||
## 🚀 Nouveautés de la mise à jour
|
||||
|
||||
Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :
|
||||
|
||||
* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.
|
||||
* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.
|
||||
* **Checklist de Préparation 2.0 :** L'interface de préparation a été repensée. Elle regroupe désormais les objets par conteneurs et permet de suivre visuellement les équipements manquants ou perdus à chaque étape (chargement, retour, etc.).
|
||||
* **Sélecteur de Matériel Optimisé :** La recherche de matériel pour un événement est beaucoup plus rapide. Vous pouvez désormais masquer automatiquement les équipements déjà utilisés sur d'autres événements aux mêmes dates.
|
||||
* **Gestion & Administration :** Affichage clair des prix HT/TTC partout dans l'application. Pour les administrateurs, l'ajout d'utilisateurs et la réinitialisation de mot de passe sont simplifiés via l'envoi d'emails automatiques.
|
||||
### Ajouté
|
||||
- Système de vérification automatique des mises à jour
|
||||
- Dialog de notification de mise à jour avec notes de version
|
||||
- Rechargement automatique du cache après mise à jour
|
||||
|
||||
## [1.0.0] - 2026-01-16
|
||||
|
||||
### Ajouté
|
||||
- Scanner QR Code pour équipements et conteneurs
|
||||
- Génération de QR codes pour conteneurs
|
||||
- Indicateur de chargement pour génération QR
|
||||
- Sections repliables dans le dialog de sélection d'équipement
|
||||
- Filtrage des équipements en conflit
|
||||
- Filtrage des boîtes par catégorie
|
||||
|
||||
### Amélioré
|
||||
- Performance du dialog de sélection d'équipement
|
||||
- Gestion du cache des équipements
|
||||
- Interface utilisateur générale
|
||||
|
||||
### Corrigé
|
||||
- Problème de cache avec les équipements non affichés
|
||||
- Bouton de validation désactivé dans certains cas
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# em2rp
|
||||
|
||||
A new Flutter project.
|
||||
337
em2rp/SYSTEME_MISE_A_JOUR.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Système de Gestion des Mises à Jour - EM2RP
|
||||
|
||||
## 📋 Vue d'ensemble
|
||||
|
||||
Ce système permet de gérer automatiquement les mises à jour de l'application web Flutter, en notifiant les utilisateurs et en forçant le rechargement du cache si nécessaire.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Architecture
|
||||
|
||||
### Fichiers impliqués
|
||||
|
||||
#### Configuration
|
||||
- **`lib/config/app_version.dart`** : Fichier source de vérité pour la version
|
||||
- **`web/version.json`** : Fichier déployé avec l'app pour vérification côté serveur
|
||||
|
||||
#### Services
|
||||
- **`lib/services/update_service.dart`** : Service de vérification des mises à jour
|
||||
- **`lib/views/widgets/common/update_dialog.dart`** : Widget d'affichage du dialog de mise à jour
|
||||
|
||||
#### Scripts
|
||||
- **`scripts/increment_version.js`** : Incrémente automatiquement la version
|
||||
- **`scripts/update_version_json.js`** : Génère version.json depuis app_version.dart
|
||||
- **`deploy.bat`** : Script de déploiement complet
|
||||
|
||||
#### Documentation
|
||||
- **`CHANGELOG.md`** : Notes de version (utilisées dans le dialog)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Workflow de déploiement
|
||||
|
||||
### 1. Développement normal
|
||||
Travaillez normalement sur votre code en mode développement.
|
||||
|
||||
### 2. Déploiement d'une nouvelle version
|
||||
```bash
|
||||
deploy.bat
|
||||
```
|
||||
|
||||
Ce script exécute automatiquement :
|
||||
1. ✅ Bascule en mode PRODUCTION
|
||||
2. ✅ **Incrémente la version** (0.3.8 → 0.3.9)
|
||||
3. ✅ **Incrémente le buildNumber** (1 → 2)
|
||||
4. ✅ **Génère version.json** depuis app_version.dart
|
||||
5. ✅ Build Flutter Web
|
||||
6. ✅ Déploie sur Firebase Hosting
|
||||
7. ✅ Retour en mode DÉVELOPPEMENT
|
||||
|
||||
### 3. Mise à jour côté utilisateur
|
||||
Au prochain chargement de l'app (ou après 2 secondes) :
|
||||
- L'app vérifie `https://em2rp.web.app/version.json`
|
||||
- Compare avec la version locale dans `app_version.dart`
|
||||
- Si `buildNumber serveur > buildNumber local` → Affiche le dialog
|
||||
|
||||
---
|
||||
|
||||
## 📝 Format de version
|
||||
|
||||
### app_version.dart
|
||||
```dart
|
||||
class AppVersion {
|
||||
static const String version = '0.3.8'; // Version sémantique
|
||||
static const int buildNumber = 1; // Numéro de build (incrémenté automatiquement)
|
||||
|
||||
static String get fullVersion => 'v$version';
|
||||
static String get fullVersionWithBuild => 'v$version+$buildNumber';
|
||||
}
|
||||
```
|
||||
|
||||
### version.json (déployé)
|
||||
```json
|
||||
{
|
||||
"version": "0.3.8",
|
||||
"buildNumber": 1,
|
||||
"updateUrl": "https://em2rp.web.app",
|
||||
"forceUpdate": false,
|
||||
"releaseNotes": "• Scanner QR Code\n• Génération QR conteneurs\n• Performance améliorée"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Comparaison des versions
|
||||
|
||||
Le système compare uniquement le **buildNumber** :
|
||||
- `buildNumber serveur > buildNumber local` → Mise à jour disponible
|
||||
- Ignore les versions identiques même si la version sémantique change
|
||||
|
||||
**Exemple** :
|
||||
- Local : `0.3.8+1`
|
||||
- Serveur : `0.3.9+2`
|
||||
- Résultat : Mise à jour proposée (2 > 1) ✅
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Expérience utilisateur
|
||||
|
||||
### Mise à jour normale (forceUpdate: false)
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ 🔄 Mise à jour disponible │
|
||||
├────────────────────────────────────┤
|
||||
│ Version actuelle : 0.3.8 (1) │
|
||||
│ Nouvelle version : 0.3.9 (2) │
|
||||
│ │
|
||||
│ Nouveautés : │
|
||||
│ • Scanner QR Code │
|
||||
│ • Performance améliorée │
|
||||
│ │
|
||||
│ [Plus tard] [Mettre à jour] 🔄 │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Mise à jour forcée (forceUpdate: true)
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ ⚠️ Mise à jour requise │
|
||||
├────────────────────────────────────┤
|
||||
│ Version actuelle : 0.3.8 (1) │
|
||||
│ Nouvelle version : 0.3.9 (2) │
|
||||
│ │
|
||||
│ ⚠️ Cette mise à jour est │
|
||||
│ obligatoire pour continuer │
|
||||
│ │
|
||||
│ [Mettre à jour] 🔄 │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Utilisation avancée
|
||||
|
||||
### Forcer une mise à jour critique
|
||||
Si vous déployez un correctif critique :
|
||||
|
||||
1. Modifiez `web/version.json` **après le déploiement** :
|
||||
```json
|
||||
{
|
||||
"version": "0.3.9",
|
||||
"buildNumber": 2,
|
||||
"forceUpdate": true, // ← Changer à true
|
||||
"releaseNotes": "🔴 Correctif de sécurité important"
|
||||
}
|
||||
```
|
||||
|
||||
2. Les utilisateurs ne pourront plus fermer le dialog jusqu'à la mise à jour
|
||||
|
||||
### Personnaliser les notes de version
|
||||
Éditez `CHANGELOG.md` avant le déploiement :
|
||||
|
||||
```markdown
|
||||
## [0.3.9] - 2026-01-16
|
||||
|
||||
### Ajouté
|
||||
- Scanner QR Code pour équipements
|
||||
- Génération QR pour conteneurs
|
||||
|
||||
### Amélioré
|
||||
- Performance du dialog de sélection
|
||||
- Gestion du cache
|
||||
|
||||
### Corrigé
|
||||
- Bug de cache des équipements
|
||||
```
|
||||
|
||||
Les 5 premières lignes de la section seront utilisées dans le dialog.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
### Test 1 : Vérification de version locale
|
||||
```dart
|
||||
// Dans n'importe quel fichier
|
||||
import 'package:em2rp/config/app_version.dart';
|
||||
|
||||
print('Version: ${AppVersion.version}');
|
||||
print('Build: ${AppVersion.buildNumber}');
|
||||
print('Full: ${AppVersion.fullVersionWithBuild}');
|
||||
```
|
||||
|
||||
### Test 2 : Forcer l'affichage du dialog
|
||||
Modifiez temporairement `web/version.json` :
|
||||
```json
|
||||
{
|
||||
"buildNumber": 999 // Très grand nombre
|
||||
}
|
||||
```
|
||||
|
||||
Rechargez l'app → Le dialog s'affiche immédiatement
|
||||
|
||||
### Test 3 : Tester le rechargement
|
||||
1. Cliquez sur "Mettre à jour"
|
||||
2. Vérifiez que la page se recharge
|
||||
3. Vérifiez que le cache est vidé (nouvelles ressources chargées)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Logs de debug
|
||||
|
||||
En mode debug, des logs sont affichés dans la console :
|
||||
|
||||
```
|
||||
[UpdateService] Current version: 0.3.8+1
|
||||
[UpdateService] Server version: 0.3.9+2
|
||||
```
|
||||
|
||||
Si pas de mise à jour disponible, rien ne s'affiche.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Sécurité
|
||||
|
||||
### Headers HTTP pour forcer le non-cache
|
||||
Le fichier `web/index.html` contient :
|
||||
```html
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
```
|
||||
|
||||
### Cache-busting sur version.json
|
||||
Chaque requête ajoute un timestamp :
|
||||
```dart
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
Uri.parse('$versionUrl?t=$timestamp')
|
||||
```
|
||||
|
||||
Garantit que la version la plus récente est toujours récupérée.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Résolution de problèmes
|
||||
|
||||
### Problème : Le dialog ne s'affiche pas
|
||||
**Causes possibles :**
|
||||
1. Le `buildNumber` serveur n'est pas supérieur au local
|
||||
2. Erreur réseau (timeout 10s)
|
||||
3. Le fichier `version.json` n'existe pas sur le serveur
|
||||
|
||||
**Solution :**
|
||||
```bash
|
||||
# Vérifier la version déployée
|
||||
curl https://em2rp.web.app/version.json
|
||||
|
||||
# Forcer un nouveau déploiement
|
||||
deploy.bat
|
||||
```
|
||||
|
||||
### Problème : Le cache ne se vide pas
|
||||
**Causes possibles :**
|
||||
1. Service Worker actif (ancienne version)
|
||||
2. Cache navigateur très persistant
|
||||
|
||||
**Solution :**
|
||||
```javascript
|
||||
// Dans les DevTools du navigateur
|
||||
navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||
registrations.forEach(r => r.unregister());
|
||||
});
|
||||
|
||||
// Puis CTRL+SHIFT+R (rechargement forcé)
|
||||
```
|
||||
|
||||
### Problème : Le script increment_version.js échoue
|
||||
**Solution :**
|
||||
```bash
|
||||
# Vérifier la syntaxe du fichier app_version.dart
|
||||
# Doit contenir exactement :
|
||||
static const String version = '0.3.8';
|
||||
static const int buildNumber = 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Évolution future
|
||||
|
||||
### Fonctionnalités possibles
|
||||
- [ ] Afficher un changelog complet dans le dialog
|
||||
- [ ] Permettre de sauter une version (skip this version)
|
||||
- [ ] Notifications push pour les mises à jour critiques
|
||||
- [ ] Analytics sur le taux d'adoption des mises à jour
|
||||
- [ ] Support des mises à jour en arrière-plan
|
||||
|
||||
### Améliorations techniques
|
||||
- [ ] Utiliser un CDN pour version.json
|
||||
- [ ] Implémenter un rollback automatique si erreur
|
||||
- [ ] Ajouter une vérification de santé post-déploiement
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Commandes rapides
|
||||
|
||||
```bash
|
||||
# Déployer une nouvelle version
|
||||
deploy.bat
|
||||
|
||||
# Incrémenter manuellement la version
|
||||
node scripts\increment_version.js
|
||||
|
||||
# Générer version.json manuellement
|
||||
node scripts\update_version_json.js
|
||||
|
||||
# Vérifier la version actuelle
|
||||
type lib\config\app_version.dart
|
||||
|
||||
# Vérifier la version déployée
|
||||
curl https://em2rp.web.app/version.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist de déploiement
|
||||
|
||||
Avant chaque déploiement :
|
||||
|
||||
- [ ] Tester l'application en local
|
||||
- [ ] Mettre à jour `CHANGELOG.md` avec les nouveautés
|
||||
- [ ] Vérifier que tous les tests passent
|
||||
- [ ] Exécuter `deploy.bat`
|
||||
- [ ] Vérifier le déploiement sur https://em2rp.web.app
|
||||
- [ ] Tester la mise à jour sur un navigateur propre
|
||||
- [ ] Informer l'équipe de la nouvelle version
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
En cas de problème avec le système de mise à jour, vérifier :
|
||||
1. Les logs dans la console du navigateur
|
||||
2. Le fichier `version.json` déployé
|
||||
3. Le fichier `app_version.dart` local
|
||||
4. La connexion réseau de l'utilisateur
|
||||
|
||||
**Le système est conçu pour échouer silencieusement** : Si une erreur se produit, l'utilisateur peut continuer à utiliser l'app normalement sans être bloqué.
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.9 KiB |
35
em2rp/assets/icons/flight-case.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1793 5032 c-17 -9 -82 -75 -144 -147 l-112 -130 511 -3 c282 -1 742
|
||||
-1 1024 0 l511 3 -112 130 c-62 72 -127 138 -144 147 -29 16 -93 17 -767 17
|
||||
-674 0 -738 -1 -767 -17z"/>
|
||||
<path d="M76 4426 c-75 -45 -75 -46 -76 -443 l0 -353 410 0 410 0 0 34 c0 43
|
||||
36 96 80 118 31 16 71 18 363 18 356 0 374 -2 416 -56 12 -15 26 -47 32 -71
|
||||
l11 -43 842 0 842 0 2 32 c3 41 29 85 65 112 27 20 41 21 360 24 217 2 345 -1
|
||||
369 -8 46 -13 85 -59 99 -116 l11 -44 404 0 404 0 0 351 c0 345 0 352 -22 391
|
||||
-14 24 -38 48 -60 59 -36 19 -101 19 -2480 19 l-2443 0 -39 -24z"/>
|
||||
<path d="M1120 3295 l0 -205 150 0 150 0 0 205 0 205 -150 0 -150 0 0 -205z"/>
|
||||
<path d="M3710 3295 l0 -205 150 0 150 0 0 205 0 205 -150 0 -150 0 0 -205z"/>
|
||||
<path d="M0 3088 l0 -243 240 240 c132 132 240 241 240 242 0 2 -108 3 -240 3
|
||||
l-240 0 0 -242z"/>
|
||||
<path d="M1718 3118 c-3 -234 -8 -255 -71 -302 -27 -20 -41 -21 -370 -24 -382
|
||||
-3 -388 -2 -434 67 -22 32 -23 44 -23 205 l0 171 -410 -410 -410 -410 0 -460
|
||||
0 -460 601 -608 601 -607 1350 0 1350 0 609 602 609 603 0 470 1 470 -403 397
|
||||
-403 397 -5 -157 c-6 -172 -15 -202 -73 -246 -27 -20 -41 -21 -370 -24 -382
|
||||
-3 -388 -2 -434 67 -22 33 -23 42 -23 252 l0 219 -844 0 -845 0 -3 -212z"/>
|
||||
<path d="M4875 3090 c132 -132 241 -240 242 -240 2 0 3 108 3 240 l0 240 -242
|
||||
0 -243 0 240 -240z"/>
|
||||
<path d="M0 731 c0 -321 1 -335 21 -371 40 -71 71 -80 283 -80 l186 0 0 -45
|
||||
c0 -100 56 -160 147 -160 85 0 143 57 150 147 l4 53 -395 395 -396 395 0 -334z"/>
|
||||
<path d="M4723 668 c-384 -384 -393 -394 -393 -432 0 -63 34 -121 84 -145 54
|
||||
-27 81 -26 136 2 54 27 80 72 80 140 l0 47 185 0 c214 0 243 8 283 78 22 40
|
||||
22 44 20 371 l-3 331 -392 -392z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
32
em2rp/assets/icons/tape.svg
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1979 5105 c-272 -46 -519 -179 -746 -403 -407 -401 -673 -1072 -701
|
||||
-1767 -26 -644 82 -1147 345 -1610 131 -232 154 -409 69 -544 -39 -61 -145
|
||||
-159 -201 -185 -104 -49 -330 -113 -585 -167 -58 -12 -106 -23 -108 -24 -4 -4
|
||||
20 -99 27 -107 3 -4 59 0 124 7 160 19 167 16 231 -112 l36 -72 122 2 c140 2
|
||||
157 -3 239 -80 l47 -44 116 26 c373 84 861 246 1020 339 94 55 141 166 139
|
||||
331 -1 171 -47 317 -160 512 -254 440 -373 934 -373 1553 0 259 15 440 51 639
|
||||
131 720 509 1334 1049 1703 24 16 8 17 -320 16 -231 0 -370 -4 -421 -13z"/>
|
||||
<path d="M3276 5109 c-322 -47 -636 -237 -884 -534 -759 -909 -791 -2555 -68
|
||||
-3530 86 -116 295 -319 401 -392 457 -309 952 -312 1410 -7 103 68 271 226
|
||||
362 338 288 357 477 830 550 1381 24 182 24 628 0 810 -151 1141 -829 1953
|
||||
-1622 1944 -49 -1 -116 -5 -149 -10z m399 -1047 c227 -79 439 -286 570 -557
|
||||
282 -586 185 -1369 -222 -1796 -284 -297 -634 -357 -971 -167 -102 57 -260
|
||||
212 -337 331 -337 521 -335 1288 7 1802 59 89 198 235 269 283 68 46 175 98
|
||||
243 117 135 39 304 34 441 -13z"/>
|
||||
<path d="M3307 3905 c-61 -15 -158 -60 -210 -97 -43 -30 -145 -127 -168 -159
|
||||
l-19 -27 55 -113 c65 -138 107 -261 137 -411 19 -97 23 -144 23 -328 0 -184
|
||||
-3 -231 -23 -328 -31 -152 -81 -302 -142 -423 -38 -76 -48 -105 -40 -118 5
|
||||
-10 48 -54 95 -99 131 -125 263 -182 419 -182 350 0 651 328 756 825 27 130
|
||||
37 373 21 514 -44 372 -212 694 -449 855 -132 90 -314 127 -455 91z"/>
|
||||
<path d="M2327 628 c-3 -74 -11 -140 -20 -165 l-16 -43 226 0 226 0 -79 51
|
||||
c-94 61 -164 118 -260 212 l-71 69 -6 -124z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
26
em2rp/assets/icons/truss.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M410 3418 c-70 -35 -90 -85 -90 -218 1 -152 35 -214 132 -235 l28 -7
|
||||
0 -398 0 -398 -28 -7 c-97 -21 -131 -83 -132 -235 0 -133 20 -183 90 -217 l44
|
||||
-23 2106 0 2106 0 44 23 c70 34 90 84 90 217 -1 152 -35 214 -132 235 l-28 7
|
||||
0 398 0 398 28 7 c97 21 131 83 132 235 0 133 -20 183 -90 218 l-44 22 -2106
|
||||
0 -2106 0 -44 -22z m4230 -218 l0 -80 -2080 0 -2080 0 0 80 0 80 2080 0 2080
|
||||
0 0 -80z m-3770 -640 c-130 -260 -205 -400 -215 -400 -13 0 -15 51 -15 400 l0
|
||||
400 215 0 215 0 -200 -400z m640 0 l200 -400 -430 0 -430 0 200 400 c197 393
|
||||
201 400 230 400 29 0 33 -7 230 -400z m640 0 c-197 -393 -201 -400 -230 -400
|
||||
-29 0 -33 7 -230 400 l-200 400 430 0 430 0 -200 -400z m640 0 l200 -400 -430
|
||||
0 -430 0 200 400 c197 393 201 400 230 400 29 0 33 -7 230 -400z m640 0 c-197
|
||||
-393 -201 -400 -230 -400 -29 0 -33 7 -230 400 l-200 400 430 0 430 0 -200
|
||||
-400z m640 0 l200 -400 -430 0 -430 0 200 400 c197 393 201 400 230 400 29 0
|
||||
33 -7 230 -400z m410 0 c0 -349 -2 -400 -15 -400 -10 0 -85 140 -215 400
|
||||
l-200 400 215 0 215 0 0 -400z m160 -640 l0 -80 -2080 0 -2080 0 0 80 0 80
|
||||
2080 0 2080 0 0 -80z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
em2rp/assets/logos/LowQRectangleLogoBlack.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
em2rp/assets/logos/RectangleLogoBlack.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
em2rp/assets/logos/RectangleLogoWhite.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
em2rp/assets/logos/SquareLogoBlack.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
em2rp/assets/logos/SquareLogoWhite.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
70
em2rp/deploy.bat
Normal file
@@ -0,0 +1,70 @@
|
||||
@echo off
|
||||
REM Script Windows pour incrémenter la version et déployer sur Firebase
|
||||
|
||||
echo ================================================
|
||||
echo Déploiement Firebase Hosting avec EM2RP
|
||||
echo ================================================
|
||||
echo.
|
||||
|
||||
echo [0/4] Basculement en mode PRODUCTION...
|
||||
node scripts\toggle_env.js prod
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo Erreur lors du basculement en mode production
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
echo [1/4] Incrémentation de la version...
|
||||
node scripts\increment_version.js
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo Erreur lors de l'incrémentation de la version
|
||||
node scripts\toggle_env.js dev
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
echo [1.5/4] Mise à jour du fichier version.json...
|
||||
node scripts\update_version_json.js
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo Erreur lors de la mise à jour de version.json
|
||||
node scripts\toggle_env.js dev
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
echo [2/4] Build Flutter Web...
|
||||
call flutter build web --release
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo Erreur lors du build Flutter
|
||||
node scripts\toggle_env.js dev
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
echo [3/4] Déploiement Firebase Hosting...
|
||||
call firebase deploy --only hosting
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo Erreur lors du déploiement Firebase
|
||||
node scripts\toggle_env.js dev
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [4/4] Retour en mode DÉVELOPPEMENT...
|
||||
node scripts\toggle_env.js dev
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo ATTENTION: Impossible de rebascule en mode dev
|
||||
echo Exécutez manuellement: node scripts\toggle_env.js dev
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================
|
||||
echo Déploiement terminé avec succès!
|
||||
echo ================================================
|
||||
pause
|
||||
|
||||
32
em2rp/deploy_alert_corrections.ps1
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Script de déploiement rapide - Corrections Alertes
|
||||
|
||||
Write-Host "=== DÉPLOIEMENT CORRECTIONS ALERTES ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 1. Hot restart Flutter (si app en cours)
|
||||
Write-Host "1. Hot restart recommandé (R dans le terminal Flutter)" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
# 2. Pub get
|
||||
Write-Host "2. Installation des dépendances..." -ForegroundColor Yellow
|
||||
flutter pub get
|
||||
|
||||
# 3. Optionnel : Redéployer les fonctions si besoin
|
||||
# Décommentez si vous avez modifié les Cloud Functions
|
||||
# Write-Host "3. Déploiement Cloud Functions..." -ForegroundColor Yellow
|
||||
# firebase deploy --only functions:sendAlertEmail
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== DÉPLOIEMENT TERMINÉ ===" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "PROCHAINES ÉTAPES:" -ForegroundColor Cyan
|
||||
Write-Host "1. Hot restart de l'application (R dans terminal Flutter)"
|
||||
Write-Host "2. Vérifier que vous êtes connecté"
|
||||
Write-Host "3. Créer un événement de test avec workforce"
|
||||
Write-Host "4. Créer une alerte LOST (équipement perdu)"
|
||||
Write-Host "5. Vérifier les logs (F12 → Console)"
|
||||
Write-Host "6. Vérifier Firestore (Firebase Console)"
|
||||
Write-Host ""
|
||||
Write-Host "Voir CORRECTIONS_ALERTES_CIBLAGE.md pour détails" -ForegroundColor Yellow
|
||||
|
||||
25
em2rp/deploy_alert_trigger.ps1
Normal file
@@ -0,0 +1,25 @@
|
||||
# Script de déploiement de la fonction onAlertCreated
|
||||
Write-Host "=== Déploiement de onAlertCreated ===" -ForegroundColor Cyan
|
||||
|
||||
# Vérifier que nous sommes dans le bon répertoire
|
||||
$currentPath = Get-Location
|
||||
if ($currentPath.Path -notlike "*\em2rp") {
|
||||
Write-Host "ERREUR: Ce script doit être exécuté depuis le répertoire em2rp" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# S'assurer qu'on utilise le bon projet
|
||||
Write-Host "`nVérification du projet Firebase..." -ForegroundColor Yellow
|
||||
firebase use em2rp-951dc
|
||||
|
||||
# Déployer la fonction
|
||||
Write-Host "`nDéploiement de la fonction..." -ForegroundColor Yellow
|
||||
firebase deploy --only functions:onAlertCreated
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "`nDéploiement réussi!" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "`nÉchec du déploiement" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
109
em2rp/deploy_backend.ps1
Normal file
@@ -0,0 +1,109 @@
|
||||
# Script de déploiement backend sécurisé
|
||||
# Usage: .\deploy_backend.ps1 [test|prod]
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[ValidateSet("test", "prod")]
|
||||
[string]$mode
|
||||
)
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Migration Backend - Déploiement" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Mode TEST : Lancer les émulateurs
|
||||
if ($mode -eq "test") {
|
||||
Write-Host "Mode: TEST (émulateurs)" -ForegroundColor Yellow
|
||||
Write-Host "Lancement des émulateurs Firebase..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
firebase emulators:start
|
||||
exit
|
||||
}
|
||||
|
||||
# Mode PROD : Déploiement en production
|
||||
Write-Host "Mode: PRODUCTION" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Confirmation
|
||||
Write-Host "ATTENTION: Vous allez déployer en PRODUCTION !" -ForegroundColor Red
|
||||
$confirmation = Read-Host "Tapez 'OUI' pour confirmer"
|
||||
|
||||
if ($confirmation -ne "OUI") {
|
||||
Write-Host "Déploiement annulé." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "Étape 1/4 : Vérification du code" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
# Vérifier que ApiConfig est en mode production
|
||||
$apiConfigPath = "lib\config\api_config.dart"
|
||||
$apiConfigContent = Get-Content $apiConfigPath -Raw
|
||||
|
||||
if ($apiConfigContent -match "isDevelopment = true") {
|
||||
Write-Host "ERREUR: ApiConfig est en mode développement !" -ForegroundColor Red
|
||||
Write-Host "Veuillez mettre 'isDevelopment = false' dans $apiConfigPath" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✓ ApiConfig en mode production" -ForegroundColor Green
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "Étape 2/4 : Installation dépendances" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
Push-Location functions
|
||||
npm install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "ERREUR: Installation des dépendances échouée" -ForegroundColor Red
|
||||
Pop-Location
|
||||
exit 1
|
||||
}
|
||||
Pop-Location
|
||||
|
||||
Write-Host "✓ Dépendances installées" -ForegroundColor Green
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "Étape 3/4 : Déploiement Cloud Functions" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
firebase deploy --only functions
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "ERREUR: Déploiement des functions échoué" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✓ Cloud Functions déployées" -ForegroundColor Green
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "Étape 4/4 : Déploiement Firestore Rules" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
firebase deploy --only firestore:rules
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "ERREUR: Déploiement des règles échoué" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✓ Firestore Rules déployées" -ForegroundColor Green
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host " DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Prochaines étapes :" -ForegroundColor Yellow
|
||||
Write-Host "1. Tester les opérations CRUD (voir TESTING_PLAN.md)" -ForegroundColor Gray
|
||||
Write-Host "2. Surveiller les logs: firebase functions:log" -ForegroundColor Gray
|
||||
Write-Host "3. Vérifier les permissions utilisateurs" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host "Console Firebase:" -ForegroundColor Cyan
|
||||
Write-Host "https://console.firebase.google.com/project/em2rp-951dc/functions" -ForegroundColor Blue
|
||||
Write-Host ""
|
||||
|
||||
85
em2rp/deploy_firestore_rules.ps1
Normal file
@@ -0,0 +1,85 @@
|
||||
# Script de déploiement des règles Firestore
|
||||
# Date : 15/01/2026
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " DÉPLOIEMENT RÈGLES FIRESTORE" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Vérifier que Firebase CLI est installé
|
||||
Write-Host "Vérification Firebase CLI..." -ForegroundColor Yellow
|
||||
$firebaseCmd = Get-Command firebase -ErrorAction SilentlyContinue
|
||||
if ($null -eq $firebaseCmd) {
|
||||
Write-Host "❌ Firebase CLI n'est pas installé !" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Installation requise :" -ForegroundColor Yellow
|
||||
Write-Host " npm install -g firebase-tools" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "OU copier-coller manuellement dans Console Firebase" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✓ Firebase CLI trouvé" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Vérifier que le fichier firestore.rules existe
|
||||
if (-Not (Test-Path "firestore.rules")) {
|
||||
Write-Host "❌ Fichier firestore.rules introuvable !" -ForegroundColor Red
|
||||
Write-Host "Vérifiez que vous êtes dans le bon répertoire" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✓ Fichier firestore.rules trouvé" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Afficher un aperçu des règles pour les alertes
|
||||
Write-Host "Règles à déployer (extrait) :" -ForegroundColor Yellow
|
||||
Write-Host "------------------------------" -ForegroundColor Gray
|
||||
Get-Content "firestore.rules" | Select-String -Pattern "alerts" -Context 3 | Select-Object -First 10
|
||||
Write-Host "------------------------------" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
# Demander confirmation
|
||||
Write-Host "Déployer les règles Firestore ? (O/N)" -ForegroundColor Yellow -NoNewline
|
||||
Write-Host " " -NoNewline
|
||||
$confirmation = Read-Host
|
||||
|
||||
if ($confirmation -ne "O" -and $confirmation -ne "o") {
|
||||
Write-Host "Déploiement annulé" -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Déploiement en cours..." -ForegroundColor Cyan
|
||||
|
||||
# Déployer les règles
|
||||
try {
|
||||
firebase deploy --only firestore:rules
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host " ✅ DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Les règles Firestore ont été déployées avec succès." -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "Prochaines étapes :" -ForegroundColor Yellow
|
||||
Write-Host " 1. Rafraîchir l'application (Ctrl+R)" -ForegroundColor White
|
||||
Write-Host " 2. Créer un événement pour tester" -ForegroundColor White
|
||||
Write-Host " 3. Vérifier qu'aucune erreur permission n'apparaît" -ForegroundColor White
|
||||
Write-Host ""
|
||||
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Red
|
||||
Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red
|
||||
Write-Host "========================================" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Erreur : $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Solutions :" -ForegroundColor Yellow
|
||||
Write-Host " 1. Vérifier connexion : firebase login" -ForegroundColor White
|
||||
Write-Host " 2. Vérifier projet : firebase use" -ForegroundColor White
|
||||
Write-Host " 3. OU déployer via Console Firebase" -ForegroundColor White
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
66
em2rp/deploy_functions.ps1
Normal file
@@ -0,0 +1,66 @@
|
||||
# EM2RP - Déploiement automatique du système d'alertes
|
||||
# Ce script déploie les Cloud Functions et vérifie le déploiement
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " EM2RP - Déploiement Cloud Functions " -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Vérifier qu'on est dans le bon répertoire
|
||||
if (-not (Test-Path ".\firebase.json")) {
|
||||
Write-Host "❌ ERREUR: Vous devez lancer ce script depuis C:\src\EM2RP\em2rp\" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Vérifier que le fichier .env existe
|
||||
if (-not (Test-Path ".\functions\.env")) {
|
||||
Write-Host "❌ ERREUR: Le fichier functions\.env est manquant" -ForegroundColor Red
|
||||
Write-Host " Créez ce fichier avec les identifiants SMTP" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✅ Vérifications préliminaires OK" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Déployer les fonctions
|
||||
Write-Host "🚀 Déploiement des Cloud Functions en cours..." -ForegroundColor Cyan
|
||||
Write-Host " (Cela peut prendre 3-5 minutes)" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
$deployResult = firebase deploy --only functions 2>&1
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host " ✅ DÉPLOIEMENT RÉUSSI" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Lister les fonctions déployées
|
||||
Write-Host "📋 Fonctions déployées:" -ForegroundColor Cyan
|
||||
firebase functions:list
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "🎯 Prochaines étapes:" -ForegroundColor Yellow
|
||||
Write-Host " 1. Migrer les préférences utilisateurs: cd functions; node migrate_email_prefs.js" -ForegroundColor White
|
||||
Write-Host " 2. Tester la création d'un événement avec workforce" -ForegroundColor White
|
||||
Write-Host " 3. Vérifier les logs: firebase functions:log --limit 20" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "📚 Voir DEPLOY_NOW.md pour plus de détails" -ForegroundColor Gray
|
||||
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Red
|
||||
Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red
|
||||
Write-Host "========================================" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Erreur rencontrée:" -ForegroundColor Yellow
|
||||
Write-Host $deployResult -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "💡 Solutions possibles:" -ForegroundColor Yellow
|
||||
Write-Host " - Si 'Quota exceeded': Attendez 2 minutes et relancez" -ForegroundColor White
|
||||
Write-Host " - Vérifiez que Firebase CLI est à jour: firebase --version" -ForegroundColor White
|
||||
Write-Host " - Consultez les logs: firebase functions:log" -ForegroundColor White
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
- provider: true
|
||||
15
em2rp/env_dev.bat
Normal file
@@ -0,0 +1,15 @@
|
||||
@echo off
|
||||
REM Script Windows pour basculer en mode DÉVELOPPEMENT
|
||||
|
||||
echo Basculement en mode DÉVELOPPEMENT...
|
||||
node scripts\toggle_env.js dev
|
||||
|
||||
if %ERRORLEVEL% EQU 0 (
|
||||
echo ✅ Mode DÉVELOPPEMENT activé
|
||||
echo - isDevelopment = true
|
||||
echo - Auto-login activé
|
||||
) else (
|
||||
echo ❌ Erreur lors du basculement
|
||||
echo Vérifiez que le fichier env.dev.dart existe
|
||||
)
|
||||
|
||||
16
em2rp/env_prod.bat
Normal file
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
REM Script Windows pour basculer en mode PRODUCTION
|
||||
|
||||
echo Basculement en mode PRODUCTION...
|
||||
node scripts\toggle_env.js prod
|
||||
|
||||
if %ERRORLEVEL% EQU 0 (
|
||||
echo ✅ Mode PRODUCTION activé
|
||||
echo - isDevelopment = false
|
||||
echo - Credentials masqués
|
||||
) else (
|
||||
echo ❌ Erreur lors du basculement
|
||||
)
|
||||
|
||||
pause
|
||||
|
||||
@@ -34,5 +34,39 @@
|
||||
"*.local"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"hosting": {
|
||||
"public": "build/web",
|
||||
"ignore": [
|
||||
"firebase.json",
|
||||
"**/.*",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "**",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"emulators": {
|
||||
"functions": {
|
||||
"port": 5051
|
||||
},
|
||||
"firestore": {
|
||||
"port": 8088
|
||||
},
|
||||
"auth": {
|
||||
"port": 9199
|
||||
},
|
||||
"ui": {
|
||||
"enabled": true,
|
||||
"port": 4040
|
||||
},
|
||||
"singleProjectMode": true
|
||||
}
|
||||
}
|
||||
|
||||
46
em2rp/firestore.indexes.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"indexes": [
|
||||
{
|
||||
"collectionGroup": "events",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "EndDateTime",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "StartDateTime",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "__name__",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "events",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "StartDateTime",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "EndDateTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": []
|
||||
}
|
||||
|
||||
184
em2rp/firestore.rules
Normal file
@@ -0,0 +1,184 @@
|
||||
rules_version = '2';
|
||||
|
||||
// ============================================================================
|
||||
// RÈGLES FIRESTORE SÉCURISÉES - VERSION PRODUCTION
|
||||
// ============================================================================
|
||||
// Date de création : 14 janvier 2026
|
||||
// Objectif : Bloquer tous les accès directs à Firestore depuis les clients
|
||||
// Seules les Cloud Functions (côté serveur) peuvent lire/écrire les données
|
||||
// ============================================================================
|
||||
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
|
||||
// ========================================================================
|
||||
// RÈGLE GLOBALE PAR DÉFAUT : TOUT BLOQUER
|
||||
// ========================================================================
|
||||
// Cette règle empêche tout accès direct depuis les clients (web/mobile)
|
||||
// Les Cloud Functions ont un accès admin et ne sont pas affectées
|
||||
|
||||
match /{document=**} {
|
||||
// ❌ REFUSER TOUS LES ACCÈS directs depuis les clients
|
||||
allow read, write: if false;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// EXCEPTIONS OPTIONNELLES pour les listeners temps réel
|
||||
// ========================================================================
|
||||
// Si vous avez besoin de listeners en temps réel pour certaines collections,
|
||||
// décommentez les règles ci-dessous.
|
||||
//
|
||||
// ⚠️ IMPORTANT : Ces règles permettent UNIQUEMENT la LECTURE.
|
||||
// Toutes les ÉCRITURES doivent passer par les Cloud Functions.
|
||||
// ========================================================================
|
||||
|
||||
/*
|
||||
// Événements : Lecture seule pour utilisateurs authentifiés
|
||||
match /events/{eventId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
|
||||
// Équipements : Lecture seule pour utilisateurs authentifiés
|
||||
match /equipments/{equipmentId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
|
||||
// Conteneurs : Lecture seule pour utilisateurs authentifiés
|
||||
match /containers/{containerId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
|
||||
// Maintenances : Lecture seule pour utilisateurs authentifiés
|
||||
match /maintenances/{maintenanceId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
*/
|
||||
|
||||
// Alertes : Lecture et création pour utilisateurs authentifiés
|
||||
// Le trigger backend (onAlertCreated) s'occupe d'assigner les bonnes personnes
|
||||
match /alerts/{alertId} {
|
||||
allow read: if request.auth != null;
|
||||
allow create: if request.auth != null
|
||||
&& request.resource.data.createdBy == request.auth.uid; // Vérifier que l'utilisateur crée l'alerte en son nom
|
||||
allow update: if request.auth != null
|
||||
&& (
|
||||
// L'utilisateur peut marquer comme lue uniquement s'il est assigné
|
||||
(request.auth.uid in resource.data.assignedTo && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isRead', 'readAt']))
|
||||
// Ou le backend peut tout modifier (processed, assignedTo, etc.)
|
||||
|| !('createdBy' in resource.data) // Le trigger backend n'a pas de createdBy
|
||||
);
|
||||
allow delete: if request.auth != null && request.auth.uid in resource.data.assignedTo;
|
||||
}
|
||||
|
||||
/*
|
||||
// Utilisateurs : Lecture de son propre profil uniquement
|
||||
match /users/{userId} {
|
||||
allow read: if request.auth != null && request.auth.uid == userId;
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
|
||||
// Types d'événements : Lecture seule
|
||||
match /eventTypes/{typeId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
|
||||
// Options : Lecture seule
|
||||
match /options/{optionId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
|
||||
// Clients : Lecture seule
|
||||
match /customers/{customerId} {
|
||||
allow read: if request.auth != null;
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
*/
|
||||
|
||||
// ========================================================================
|
||||
// RÈGLES AVANCÉES avec vérification des permissions (OPTIONNEL)
|
||||
// ========================================================================
|
||||
// Décommentez ces règles si vous voulez des permissions basées sur les rôles
|
||||
// pour la lecture en temps réel
|
||||
//
|
||||
// ⚠️ ATTENTION : Ces règles nécessitent une lecture supplémentaire dans
|
||||
// la collection users, ce qui peut impacter les performances et les coûts.
|
||||
// ========================================================================
|
||||
|
||||
/*
|
||||
// Fonction helper : Récupérer les permissions de l'utilisateur
|
||||
function getUserPermissions() {
|
||||
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.permissions;
|
||||
}
|
||||
|
||||
// Fonction helper : Vérifier si l'utilisateur a une permission
|
||||
function hasPermission(permission) {
|
||||
return request.auth != null && permission in getUserPermissions();
|
||||
}
|
||||
|
||||
// Équipements : Lecture uniquement si permission view_equipment
|
||||
match /equipments/{equipmentId} {
|
||||
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
|
||||
// Événements : Lecture selon permissions
|
||||
match /events/{eventId} {
|
||||
allow read: if hasPermission('view_events') || hasPermission('edit_event');
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
|
||||
// Conteneurs : Lecture uniquement si permission view_equipment
|
||||
match /containers/{containerId} {
|
||||
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
|
||||
// Maintenances : Lecture uniquement si permission view_equipment
|
||||
match /maintenances/{maintenanceId} {
|
||||
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||
allow write: if false; // ❌ Écriture interdite
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NOTES DE SÉCURITÉ
|
||||
// ============================================================================
|
||||
//
|
||||
// 1. RÈGLE PAR DÉFAUT (allow read, write: if false)
|
||||
// - Bloque TOUS les accès directs depuis les clients
|
||||
// - Les Cloud Functions ne sont PAS affectées (elles ont un accès admin)
|
||||
// - C'est la configuration la PLUS SÉCURISÉE
|
||||
//
|
||||
// 2. EXCEPTIONS DE LECTURE (commentées par défaut)
|
||||
// - Permettent les listeners en temps réel pour certaines collections
|
||||
// - UNIQUEMENT la LECTURE est autorisée
|
||||
// - Les ÉCRITURES restent bloquées (doivent passer par Cloud Functions)
|
||||
//
|
||||
// 3. RÈGLES BASÉES SUR LES RÔLES (commentées par défaut)
|
||||
// - Permettent un contrôle plus fin basé sur les permissions utilisateur
|
||||
// - ⚠️ Impact sur les performances (lecture supplémentaire de la collection users)
|
||||
// - À utiliser uniquement si nécessaire
|
||||
//
|
||||
// 4. TESTS APRÈS DÉPLOIEMENT
|
||||
// - Vérifier que les Cloud Functions fonctionnent toujours
|
||||
// - Tester qu'un accès direct depuis la console échoue
|
||||
// - Surveiller les logs : firebase functions:log
|
||||
//
|
||||
// 5. ROLLBACK EN CAS DE PROBLÈME
|
||||
// - Remplacer temporairement par :
|
||||
// match /{document=**} {
|
||||
// allow read, write: if request.auth != null;
|
||||
// }
|
||||
// - Déployer rapidement : firebase deploy --only firestore:rules
|
||||
//
|
||||
// ============================================================================
|
||||
|
||||
20
em2rp/flutter_launcher_icons.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: "assets/EM2_NsurB.jpg"
|
||||
web:
|
||||
generate: true
|
||||
image_path: "assets/EM2_NsurB.jpg"
|
||||
background_color: "#ffffff"
|
||||
theme_color: "#0175C2"
|
||||
windows:
|
||||
generate: true
|
||||
image_path: "assets/EM2_NsurB.jpg"
|
||||
icon_size: 48
|
||||
macos:
|
||||
generate: true
|
||||
image_path: "assets/EM2_NsurB.jpg"
|
||||
linux:
|
||||
generate: true
|
||||
image_path: "assets/EM2_NsurB.jpg"
|
||||
|
||||
2
em2rp/functions/.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
node_modules/
|
||||
*.local
|
||||
.env
|
||||
.env.local
|
||||
|
||||
267
em2rp/functions/createAlert.js
Normal file
@@ -0,0 +1,267 @@
|
||||
const {onRequest} = require('firebase-functions/v2/https');
|
||||
const admin = require('firebase-admin');
|
||||
const nodemailer = require('nodemailer');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require('./utils/emailTemplates');
|
||||
const auth = require('./utils/auth');
|
||||
|
||||
// Configuration CORS
|
||||
const setCorsHeaders = (res, req) => {
|
||||
// Utiliser l'origin de la requête pour permettre les credentials
|
||||
const origin = req.headers.origin || '*';
|
||||
|
||||
res.set('Access-Control-Allow-Origin', origin);
|
||||
|
||||
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
|
||||
if (origin !== '*') {
|
||||
res.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
|
||||
res.set('Access-Control-Max-Age', '3600');
|
||||
};
|
||||
|
||||
const withCors = (handler) => {
|
||||
return async (req, res) => {
|
||||
setCorsHeaders(res, req);
|
||||
// Gérer les requêtes preflight OPTIONS immédiatement
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).send('');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await handler(req, res);
|
||||
} catch (error) {
|
||||
logger.error("Unhandled error:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Crée une alerte et envoie les notifications
|
||||
* Gère tout le processus côté backend de A à Z
|
||||
*/
|
||||
exports.createAlert = onRequest({cors: false, invoker: 'public'}, withCors(async (req, res) => {
|
||||
try {
|
||||
// Vérifier l'authentification
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const data = req.body.data || req.body;
|
||||
|
||||
|
||||
const {
|
||||
type,
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
equipmentId,
|
||||
eventId,
|
||||
actionUrl,
|
||||
metadata,
|
||||
} = data;
|
||||
|
||||
// Validation des données
|
||||
if (!type || !severity || !message) {
|
||||
res.status(400).json({error: 'type, severity et message sont requis'});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Déterminer les utilisateurs à notifier
|
||||
const userIds = await determineTargetUsers(type, severity, eventId);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
res.status(400).json({error: 'Aucun utilisateur à notifier'});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Créer l'alerte dans Firestore
|
||||
const alertRef = admin.firestore().collection('alerts').doc();
|
||||
const alertData = {
|
||||
id: alertRef.id,
|
||||
type,
|
||||
severity,
|
||||
title: title || getAlertTitle(type),
|
||||
message,
|
||||
equipmentId: equipmentId || null,
|
||||
eventId: eventId || null,
|
||||
actionUrl: actionUrl || null,
|
||||
metadata: metadata || {},
|
||||
assignedTo: userIds,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
createdBy: decodedToken.uid,
|
||||
isRead: false,
|
||||
emailSent: false,
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
|
||||
await alertRef.set(alertData);
|
||||
|
||||
// 3. Envoyer les emails si alerte critique
|
||||
let emailResults = {};
|
||||
if (severity === 'CRITICAL') {
|
||||
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
|
||||
|
||||
// Mettre à jour le statut d'envoi
|
||||
await alertRef.update({
|
||||
emailSent: true,
|
||||
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
emailResults,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
alertId: alertRef.id,
|
||||
usersNotified: userIds.length,
|
||||
emailsSent: Object.values(emailResults).filter((v) => v).length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[createAlert] Erreur:', error);
|
||||
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Détermine les utilisateurs à notifier selon le type d'alerte
|
||||
*/
|
||||
async function determineTargetUsers(alertType, severity, eventId) {
|
||||
const db = admin.firestore();
|
||||
const targetUserIds = new Set();
|
||||
|
||||
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
|
||||
const allUsersSnapshot = await db.collection('users').get();
|
||||
|
||||
allUsersSnapshot.forEach((doc) => {
|
||||
const user = doc.data();
|
||||
if (user.role) {
|
||||
// Le rôle peut être une référence Firestore ou une string
|
||||
let rolePath = '';
|
||||
if (typeof user.role === 'string') {
|
||||
rolePath = user.role;
|
||||
} else if (user.role.path) {
|
||||
rolePath = user.role.path;
|
||||
} else if (user.role._path && user.role._path.segments) {
|
||||
rolePath = user.role._path.segments.join('/');
|
||||
}
|
||||
|
||||
// Vérifier si c'est un admin (path = "roles/ADMIN")
|
||||
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
|
||||
targetUserIds.add(doc.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Si un événement est lié, ajouter tous les membres de la workforce
|
||||
if (eventId) {
|
||||
try {
|
||||
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||
|
||||
if (eventDoc.exists) {
|
||||
const event = eventDoc.data();
|
||||
const workforce = event.workforce || [];
|
||||
|
||||
workforce.forEach((member) => {
|
||||
if (member.userId) {
|
||||
targetUserIds.add(member.userId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(targetUserIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie les emails d'alerte à tous les utilisateurs
|
||||
*/
|
||||
async function sendAlertEmails(alertId, alertData, userIds) {
|
||||
const results = {};
|
||||
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
||||
|
||||
// Envoyer les emails en parallèle (batch de 5)
|
||||
const batches = [];
|
||||
for (let i = 0; i < userIds.length; i += 5) {
|
||||
batches.push(userIds.slice(i, i + 5));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const promises = batch.map(async (userId) => {
|
||||
try {
|
||||
const sent = await sendSingleEmail(transporter, alertId, alertData, userId);
|
||||
results[userId] = sent;
|
||||
} catch (error) {
|
||||
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
|
||||
results[userId] = false;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email à un utilisateur spécifique
|
||||
*/
|
||||
async function sendSingleEmail(transporter, alertId, alertData, userId) {
|
||||
const db = admin.firestore();
|
||||
|
||||
// Récupérer l'utilisateur
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = userDoc.data();
|
||||
|
||||
// Vérifier les préférences email
|
||||
const prefs = user.notificationPreferences || {};
|
||||
if (!prefs.emailEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier la préférence pour ce type d'alerte
|
||||
if (!checkAlertPreference(alertData.type, prefs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Préparer les données du template
|
||||
const templateData = await prepareTemplateData(alertData, user);
|
||||
|
||||
// Rendre le template
|
||||
const html = await renderTemplate('alert-individual', templateData);
|
||||
|
||||
// Envoyer l'email
|
||||
await transporter.sendMail({
|
||||
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||
to: user.email,
|
||||
replyTo: EMAIL_CONFIG.replyTo,
|
||||
subject: getEmailSubject(alertData),
|
||||
html: html,
|
||||
text: alertData.message,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[sendSingleEmail] Erreur envoi à ${userId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
113
em2rp/functions/migrate_email_prefs.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Script de migration : Active les emails pour tous les utilisateurs existants
|
||||
* À exécuter une seule fois après le déploiement
|
||||
*/
|
||||
const admin = require('firebase-admin');
|
||||
const logger = require('firebase-functions/logger');
|
||||
|
||||
// AJOUTER CECI : Charger le fichier de clé
|
||||
const serviceAccount = require('./serviceAccountKey.json');
|
||||
|
||||
// Initialiser Firebase Admin avec les credentials explicites
|
||||
if (!admin.apps.length) {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici
|
||||
projectId: 'em2rp-951dc',
|
||||
});
|
||||
}
|
||||
|
||||
const db = admin.firestore();
|
||||
|
||||
/**
|
||||
* Active les notifications par email pour tous les utilisateurs existants
|
||||
*/
|
||||
async function migrateEmailPreferences() {
|
||||
console.log('=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n');
|
||||
|
||||
try {
|
||||
// 1. Récupérer tous les utilisateurs
|
||||
const usersSnapshot = await db.collection('users').get();
|
||||
console.log(`✓ ${usersSnapshot.size} utilisateurs trouvés\n`);
|
||||
|
||||
// 2. Préparer les updates
|
||||
const updates = [];
|
||||
let alreadyEnabled = 0;
|
||||
let toUpdate = 0;
|
||||
|
||||
usersSnapshot.forEach((doc) => {
|
||||
const user = doc.data();
|
||||
const prefs = user.notificationPreferences || {};
|
||||
|
||||
// Vérifier si déjà activé
|
||||
if (prefs.emailEnabled === true) {
|
||||
alreadyEnabled++;
|
||||
console.log(` ○ ${user.email || doc.id}: emails déjà activés`);
|
||||
} else {
|
||||
toUpdate++;
|
||||
console.log(` ✓ ${user.email || doc.id}: activation des emails`);
|
||||
|
||||
updates.push({
|
||||
ref: doc.ref,
|
||||
data: {
|
||||
'notificationPreferences.emailEnabled': true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n--- RÉSUMÉ ---`);
|
||||
console.log(` Total utilisateurs: ${usersSnapshot.size}`);
|
||||
console.log(` Déjà activés: ${alreadyEnabled}`);
|
||||
console.log(` À mettre à jour: ${toUpdate}`);
|
||||
|
||||
// 3. Appliquer les mises à jour par batches de 500 (limite Firestore)
|
||||
if (updates.length > 0) {
|
||||
console.log(`\nApplication des mises à jour...`);
|
||||
|
||||
const batchSize = 500;
|
||||
for (let i = 0; i < updates.length; i += batchSize) {
|
||||
const batch = db.batch();
|
||||
const currentBatch = updates.slice(i, i + batchSize);
|
||||
|
||||
currentBatch.forEach((update) => {
|
||||
batch.update(update.ref, update.data);
|
||||
});
|
||||
|
||||
await batch.commit();
|
||||
console.log(` ✓ Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(updates.length / batchSize)} appliqué`);
|
||||
}
|
||||
|
||||
console.log(`\n✓ Migration terminée avec succès !`);
|
||||
console.log(` ${toUpdate} utilisateurs mis à jour\n`);
|
||||
} else {
|
||||
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
|
||||
}
|
||||
|
||||
console.log('=== FIN MIGRATION ===');
|
||||
return {
|
||||
success: true,
|
||||
total: usersSnapshot.size,
|
||||
alreadyEnabled,
|
||||
updated: toUpdate,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ ERREUR MIGRATION:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Exécuter la migration si appelé directement
|
||||
if (require.main === module) {
|
||||
migrateEmailPreferences()
|
||||
.then((result) => {
|
||||
console.log('\n✓ Migration réussie:', result);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Migration échouée:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { migrateEmailPreferences };
|
||||
|
||||
93
em2rp/functions/migrate_equipment_ids.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Script de migration pour ajouter le champ 'id' aux équipements qui n'en ont pas
|
||||
*
|
||||
* Ce script parcourt tous les documents de la collection 'equipments' et ajoute
|
||||
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
|
||||
*/
|
||||
|
||||
const admin = require('firebase-admin');
|
||||
const serviceAccount = require('./serviceAccountKey.json');
|
||||
|
||||
// Initialiser Firebase Admin
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount)
|
||||
});
|
||||
|
||||
const db = admin.firestore();
|
||||
|
||||
async function migrateEquipmentIds() {
|
||||
console.log('🔧 Migration: Ajout du champ id aux équipements');
|
||||
console.log('================================================\n');
|
||||
|
||||
try {
|
||||
// Récupérer tous les équipements
|
||||
const equipmentsSnapshot = await db.collection('equipments').get();
|
||||
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
|
||||
|
||||
let missingIdCount = 0;
|
||||
let updatedCount = 0;
|
||||
let errorCount = 0;
|
||||
const batch = db.batch();
|
||||
let batchCount = 0;
|
||||
|
||||
for (const doc of equipmentsSnapshot.docs) {
|
||||
const data = doc.data();
|
||||
|
||||
// Vérifier si le champ 'id' est manquant ou vide
|
||||
if (!data.id || data.id === '') {
|
||||
missingIdCount++;
|
||||
console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`);
|
||||
|
||||
// Ajouter au batch
|
||||
batch.update(doc.ref, { id: doc.id });
|
||||
batchCount++;
|
||||
updatedCount++;
|
||||
|
||||
// Exécuter le batch tous les 500 documents (limite Firestore)
|
||||
if (batchCount === 500) {
|
||||
await batch.commit();
|
||||
console.log(`✅ Batch de ${batchCount} documents mis à jour`);
|
||||
batchCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exécuter le dernier batch s'il reste des documents
|
||||
if (batchCount > 0) {
|
||||
await batch.commit();
|
||||
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
|
||||
}
|
||||
|
||||
console.log('\n================================================');
|
||||
console.log('📊 RÉSUMÉ DE LA MIGRATION');
|
||||
console.log('================================================');
|
||||
console.log(`Total d'équipements: ${equipmentsSnapshot.size}`);
|
||||
console.log(`Équipements avec 'id' manquant: ${missingIdCount}`);
|
||||
console.log(`Équipements mis à jour: ${updatedCount}`);
|
||||
console.log(`Erreurs: ${errorCount}`);
|
||||
console.log('================================================\n');
|
||||
|
||||
if (missingIdCount === 0) {
|
||||
console.log('✅ Tous les équipements ont déjà un champ id !');
|
||||
} else if (updatedCount === missingIdCount) {
|
||||
console.log('✅ Migration terminée avec succès !');
|
||||
} else {
|
||||
console.log('⚠️ Migration terminée avec des erreurs');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la migration:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Exécuter la migration
|
||||
migrateEquipmentIds()
|
||||
.then(() => {
|
||||
console.log('\n✅ Script terminé');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('\n❌ Script échoué:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
735
em2rp/functions/package-lock.json
generated
@@ -14,8 +14,14 @@
|
||||
},
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"axios": "^1.13.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"envdot": "^0.0.3",
|
||||
"firebase-admin": "^12.6.0",
|
||||
"firebase-functions": "^6.0.1"
|
||||
"firebase-functions": "^7.0.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.15.0",
|
||||
|
||||
415
em2rp/functions/processEquipmentValidation.js
Normal file
@@ -0,0 +1,415 @@
|
||||
const {onCall} = require('firebase-functions/v2/https');
|
||||
const admin = require('firebase-admin');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const nodemailer = require('nodemailer');
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||
/**
|
||||
* Traite la validation du matériel d'un événement
|
||||
* Appelée par le client lors du chargement/déchargement
|
||||
* Crée automatiquement les alertes nécessaires
|
||||
*/
|
||||
exports.processEquipmentValidation = onCall({cors: true}, async (request) => {
|
||||
try {
|
||||
// L'authentification est automatique avec onCall
|
||||
const {auth, data} = request;
|
||||
|
||||
if (!auth) {
|
||||
throw new Error('L\'utilisateur doit être authentifié');
|
||||
}
|
||||
|
||||
const {
|
||||
eventId,
|
||||
equipmentList, // [{equipmentId, status, quantity, etc.}]
|
||||
validationType, // 'LOADING', 'UNLOADING', 'CHECK_OUT', 'CHECK_IN'
|
||||
} = data;
|
||||
|
||||
// Validation
|
||||
if (!eventId || !equipmentList || !validationType) {
|
||||
throw new Error('eventId, equipmentList et validationType sont requis');
|
||||
}
|
||||
|
||||
const db = admin.firestore();
|
||||
const alerts = [];
|
||||
|
||||
// 1. Récupérer les détails de l'événement
|
||||
const eventRef = db.collection('events').doc(eventId);
|
||||
const eventDoc = await eventRef.get();
|
||||
|
||||
if (!eventDoc.exists) {
|
||||
throw new Error('Événement introuvable');
|
||||
}
|
||||
|
||||
const event = eventDoc.data();
|
||||
const eventName = event.Name || event.name || 'Événement inconnu';
|
||||
const eventDate = formatEventDate(event);
|
||||
|
||||
// 2. Analyser les équipements et détecter les problèmes
|
||||
for (const equipment of equipmentList) {
|
||||
const {equipmentId, status, quantity, expectedQuantity} = equipment;
|
||||
|
||||
// Cas 1: Équipement PERDU
|
||||
if (status === 'LOST') {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'LOST',
|
||||
severity: 'CRITICAL',
|
||||
title: 'Équipement perdu',
|
||||
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
eventName,
|
||||
eventDate,
|
||||
createdBy: auth.uid,
|
||||
metadata: {
|
||||
validationType,
|
||||
equipment,
|
||||
},
|
||||
});
|
||||
alerts.push(alertData);
|
||||
}
|
||||
|
||||
// Cas 2: Équipement MANQUANT
|
||||
if (status === 'MISSING') {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'EQUIPMENT_MISSING',
|
||||
severity: 'WARNING',
|
||||
title: 'Équipement manquant',
|
||||
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
eventName,
|
||||
eventDate,
|
||||
createdBy: auth.uid,
|
||||
metadata: {
|
||||
validationType,
|
||||
equipment,
|
||||
},
|
||||
});
|
||||
alerts.push(alertData);
|
||||
}
|
||||
|
||||
// Cas 3: Quantité incorrecte
|
||||
if (expectedQuantity && quantity !== expectedQuantity) {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'QUANTITY_MISMATCH',
|
||||
severity: 'INFO',
|
||||
title: 'Quantité incorrecte',
|
||||
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
eventName,
|
||||
eventDate,
|
||||
createdBy: auth.uid,
|
||||
metadata: {
|
||||
validationType,
|
||||
equipment,
|
||||
expected: expectedQuantity,
|
||||
actual: quantity,
|
||||
},
|
||||
});
|
||||
alerts.push(alertData);
|
||||
}
|
||||
|
||||
// Cas 4: Équipement endommagé
|
||||
if (status === 'DAMAGED') {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'DAMAGED',
|
||||
severity: 'WARNING',
|
||||
title: 'Équipement endommagé',
|
||||
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
eventName,
|
||||
eventDate,
|
||||
createdBy: auth.uid,
|
||||
metadata: {
|
||||
validationType,
|
||||
equipment,
|
||||
},
|
||||
});
|
||||
alerts.push(alertData);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mettre à jour les équipements de l'événement
|
||||
await eventRef.update({
|
||||
equipment: equipmentList,
|
||||
lastValidation: {
|
||||
type: validationType,
|
||||
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||||
by: auth.uid,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Envoyer les notifications pour les alertes critiques
|
||||
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
|
||||
if (criticalAlerts.length > 0) {
|
||||
for (const alert of criticalAlerts) {
|
||||
try {
|
||||
await sendAlertNotifications(alert, eventId);
|
||||
} catch (notificationError) {
|
||||
logger.error(`[processEquipmentValidation] Erreur notification alerte ${alert.id}:`, notificationError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alertsCreated: alerts.length,
|
||||
criticalAlertsCount: criticalAlerts.length,
|
||||
alertIds: alerts.map((a) => a.id),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[processEquipmentValidation] Erreur:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Crée une alerte dans Firestore
|
||||
*/
|
||||
async function createAlertInFirestore(alertData) {
|
||||
const db = admin.firestore();
|
||||
const alertRef = db.collection('alerts').doc();
|
||||
|
||||
const fullAlertData = {
|
||||
id: alertRef.id,
|
||||
...alertData,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
isRead: false,
|
||||
status: 'ACTIVE',
|
||||
emailSent: false,
|
||||
assignedTo: [],
|
||||
};
|
||||
|
||||
await alertRef.set(fullAlertData);
|
||||
|
||||
return {...fullAlertData, id: alertRef.id};
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine les utilisateurs à notifier et envoie les notifications
|
||||
*/
|
||||
async function sendAlertNotifications(alert, eventId) {
|
||||
const db = admin.firestore();
|
||||
const targetUserIds = new Set();
|
||||
const usersWithPermission = new Set();
|
||||
|
||||
try {
|
||||
// 1. Récupérer TOUS les utilisateurs et leurs permissions
|
||||
const allUsersSnapshot = await db.collection('users').get();
|
||||
|
||||
// Créer un map pour stocker les références de rôles à récupérer
|
||||
const roleRefs = new Map();
|
||||
|
||||
for (const doc of allUsersSnapshot.docs) {
|
||||
const user = doc.data();
|
||||
|
||||
if (!user.role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extraire le chemin du rôle
|
||||
let rolePath = '';
|
||||
let roleId = '';
|
||||
|
||||
if (typeof user.role === 'string') {
|
||||
rolePath = user.role;
|
||||
roleId = user.role.split('/').pop();
|
||||
} else if (user.role.path) {
|
||||
rolePath = user.role.path;
|
||||
roleId = user.role.path.split('/').pop();
|
||||
} else if (user.role._path && user.role._path.segments) {
|
||||
rolePath = user.role._path.segments.join('/');
|
||||
roleId = user.role._path.segments[user.role._path.segments.length - 1];
|
||||
}
|
||||
|
||||
if (roleId && !roleRefs.has(roleId)) {
|
||||
roleRefs.set(roleId, {users: [], rolePath});
|
||||
}
|
||||
|
||||
if (roleId) {
|
||||
roleRefs.get(roleId).users.push(doc.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Récupérer les permissions de chaque rôle unique
|
||||
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
|
||||
try {
|
||||
const roleDoc = await db.collection('roles').doc(roleId).get();
|
||||
|
||||
if (roleDoc.exists) {
|
||||
const roleData = roleDoc.data();
|
||||
const permissions = roleData.permissions || [];
|
||||
|
||||
// Vérifier si le rôle a la permission view_all_events
|
||||
if (permissions.includes('view_all_events')) {
|
||||
users.forEach((userId) => {
|
||||
usersWithPermission.add(userId);
|
||||
targetUserIds.add(userId);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[sendAlertNotifications] Erreur récupération rôle ${roleId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Ajouter la workforce de l'événement
|
||||
if (eventId) {
|
||||
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||
|
||||
if (eventDoc.exists) {
|
||||
const event = eventDoc.data();
|
||||
const workforce = event.workforce || [];
|
||||
|
||||
workforce.forEach((member) => {
|
||||
// Extraire l'userId selon différentes structures possibles
|
||||
let userId = null;
|
||||
|
||||
if (typeof member === 'string') {
|
||||
userId = member;
|
||||
} else if (member.userId) {
|
||||
userId = member.userId;
|
||||
} else if (member.id) {
|
||||
userId = member.id;
|
||||
} else if (member.user) {
|
||||
if (typeof member.user === 'string') {
|
||||
userId = member.user;
|
||||
} else if (member.user.id) {
|
||||
userId = member.user.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
targetUserIds.add(userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const userIds = Array.from(targetUserIds);
|
||||
|
||||
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
|
||||
await db.collection('alerts').doc(alert.id).update({
|
||||
assignedTo: userIds,
|
||||
});
|
||||
|
||||
// 5. Envoyer les emails si alerte critique
|
||||
if (alert.severity === 'CRITICAL') {
|
||||
await sendAlertEmails(alert, userIds);
|
||||
}
|
||||
|
||||
return userIds;
|
||||
} catch (error) {
|
||||
logger.error('[sendAlertNotifications] Erreur:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie les emails d'alerte
|
||||
*/
|
||||
async function sendAlertEmails(alert, userIds) {
|
||||
try {
|
||||
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
|
||||
const db = admin.firestore();
|
||||
|
||||
// Vérifier que EMAIL_CONFIG est disponible
|
||||
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
|
||||
logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||
let successCount = 0;
|
||||
|
||||
// Envoyer les emails par lots de 5
|
||||
const batches = [];
|
||||
for (let i = 0; i < userIds.length; i += 5) {
|
||||
batches.push(userIds.slice(i, i + 5));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const promises = batch.map(async (userId) => {
|
||||
try {
|
||||
// Récupérer l'utilisateur
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = userDoc.data();
|
||||
|
||||
// Vérifier les préférences email
|
||||
const prefs = user.notificationPreferences || {};
|
||||
if (!prefs.emailEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Préparer et envoyer l'email
|
||||
let html;
|
||||
try {
|
||||
const templateData = await prepareTemplateData(alert, user);
|
||||
html = await renderTemplate('alert-individual', templateData);
|
||||
} catch (templateError) {
|
||||
logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError);
|
||||
html = `
|
||||
<html>
|
||||
<body>
|
||||
<h2>${alert.title || 'Nouvelle alerte'}</h2>
|
||||
<p>${alert.message}</p>
|
||||
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||
to: user.email,
|
||||
replyTo: EMAIL_CONFIG.replyTo,
|
||||
subject: getEmailSubject(alert),
|
||||
html: html,
|
||||
text: alert.message,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
successCount += results.filter((r) => r).length;
|
||||
}
|
||||
|
||||
// Mettre à jour l'alerte
|
||||
await db.collection('alerts').doc(alert.id).update({
|
||||
emailSent: true,
|
||||
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
emailsSentCount: successCount,
|
||||
});
|
||||
|
||||
return successCount;
|
||||
} catch (error) {
|
||||
logger.error('[sendAlertEmails] Erreur globale:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la date d'un événement
|
||||
*/
|
||||
function formatEventDate(event) {
|
||||
if (event.startDate) {
|
||||
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate);
|
||||
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'});
|
||||
}
|
||||
return 'Date inconnue';
|
||||
}
|
||||
|
||||
277
em2rp/functions/sendAlertEmail.js
Normal file
@@ -0,0 +1,277 @@
|
||||
const functions = require('firebase-functions');
|
||||
const admin = require('firebase-admin');
|
||||
const nodemailer = require('nodemailer');
|
||||
const handlebars = require('handlebars');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||
|
||||
/**
|
||||
* Envoie un email d'alerte à un utilisateur
|
||||
* Appelé par le client Dart via callable function
|
||||
*/
|
||||
exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
|
||||
// Vérifier l'authentification
|
||||
if (!context.auth) {
|
||||
throw new functions.https.HttpsError(
|
||||
'unauthenticated',
|
||||
'L\'utilisateur doit être authentifié',
|
||||
);
|
||||
}
|
||||
|
||||
const {alertId, userId, templateType} = data;
|
||||
|
||||
if (!alertId || !userId) {
|
||||
throw new functions.https.HttpsError(
|
||||
'invalid-argument',
|
||||
'alertId et userId sont requis',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer l'alerte depuis Firestore
|
||||
const alertDoc = await admin.firestore()
|
||||
.collection('alerts')
|
||||
.doc(alertId)
|
||||
.get();
|
||||
|
||||
if (!alertDoc.exists) {
|
||||
throw new functions.https.HttpsError(
|
||||
'not-found',
|
||||
'Alerte introuvable',
|
||||
);
|
||||
}
|
||||
|
||||
const alert = alertDoc.data();
|
||||
|
||||
// Récupérer l'utilisateur
|
||||
const userDoc = await admin.firestore()
|
||||
.collection('users')
|
||||
.doc(userId)
|
||||
.get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
throw new functions.https.HttpsError(
|
||||
'not-found',
|
||||
'Utilisateur introuvable',
|
||||
);
|
||||
}
|
||||
|
||||
const user = userDoc.data();
|
||||
|
||||
// Vérifier les préférences email de l'utilisateur
|
||||
const prefs = user.notificationPreferences || {};
|
||||
if (!prefs.emailEnabled) {
|
||||
console.log(`Email désactivé pour l'utilisateur ${userId}`);
|
||||
return {success: true, skipped: true, reason: 'email_disabled'};
|
||||
}
|
||||
|
||||
// Vérifier la préférence pour ce type d'alerte
|
||||
const alertType = alert.type;
|
||||
const shouldSend = checkAlertPreference(alertType, prefs);
|
||||
if (!shouldSend) {
|
||||
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
|
||||
return {success: true, skipped: true, reason: 'alert_type_disabled'};
|
||||
}
|
||||
|
||||
// Préparer les données pour le template
|
||||
const templateData = await prepareTemplateData(alert, user);
|
||||
|
||||
// Rendre le template HTML
|
||||
const html = await renderTemplate(
|
||||
templateType || 'alert-individual',
|
||||
templateData,
|
||||
);
|
||||
|
||||
// Configurer le transporteur SMTP
|
||||
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
||||
|
||||
// Envoyer l'email
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||
to: user.email,
|
||||
replyTo: EMAIL_CONFIG.replyTo,
|
||||
subject: getEmailSubject(alert),
|
||||
html: html,
|
||||
// Fallback texte brut
|
||||
text: alert.message,
|
||||
});
|
||||
|
||||
console.log('Email envoyé:', info.messageId);
|
||||
|
||||
// Marquer l'email comme envoyé dans l'alerte
|
||||
await alertDoc.ref.update({
|
||||
emailSent: true,
|
||||
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
skipped: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur envoi email:', error);
|
||||
throw new functions.https.HttpsError(
|
||||
'internal',
|
||||
`Erreur lors de l'envoi de l'email: ${error.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
|
||||
*/
|
||||
function checkAlertPreference(alertType, preferences) {
|
||||
const typeMapping = {
|
||||
'EVENT_CREATED': 'eventsNotifications',
|
||||
'EVENT_MODIFIED': 'eventsNotifications',
|
||||
'EVENT_CANCELLED': 'eventsNotifications',
|
||||
'LOST': 'equipmentNotifications',
|
||||
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
||||
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
||||
'STOCK_LOW': 'stockNotifications',
|
||||
};
|
||||
|
||||
const prefKey = typeMapping[alertType];
|
||||
return prefKey ? (preferences[prefKey] !== false) : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare les données pour le template
|
||||
*/
|
||||
async function prepareTemplateData(alert, user) {
|
||||
const data = {
|
||||
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||
'Utilisateur',
|
||||
alertTitle: getAlertTitle(alert.type),
|
||||
alertMessage: alert.message,
|
||||
isCritical: alert.severity === 'CRITICAL',
|
||||
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
||||
appUrl: EMAIL_CONFIG.appUrl,
|
||||
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||
year: new Date().getFullYear(),
|
||||
subject: getEmailSubject(alert),
|
||||
};
|
||||
|
||||
// Ajouter des détails selon le type d'alerte
|
||||
if (alert.eventId) {
|
||||
try {
|
||||
const eventDoc = await admin.firestore()
|
||||
.collection('events')
|
||||
.doc(alert.eventId)
|
||||
.get();
|
||||
|
||||
if (eventDoc.exists) {
|
||||
const event = eventDoc.data();
|
||||
data.eventName = event.Name;
|
||||
if (event.StartDateTime) {
|
||||
const date = event.StartDateTime.toDate();
|
||||
data.eventDate = date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération événement:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (alert.equipmentId) {
|
||||
try {
|
||||
const eqDoc = await admin.firestore()
|
||||
.collection('equipments')
|
||||
.doc(alert.equipmentId)
|
||||
.get();
|
||||
|
||||
if (eqDoc.exists) {
|
||||
data.equipmentName = eqDoc.data().name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération équipement:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le titre de l'email selon le type d'alerte
|
||||
*/
|
||||
function getEmailSubject(alert) {
|
||||
const subjects = {
|
||||
'EVENT_CREATED': '📅 Nouvel événement créé',
|
||||
'EVENT_MODIFIED': '📝 Événement modifié',
|
||||
'EVENT_CANCELLED': '❌ Événement annulé',
|
||||
'LOST': '🔴 Alerte critique : Équipement perdu',
|
||||
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
||||
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
||||
'STOCK_LOW': '📦 Stock faible',
|
||||
};
|
||||
|
||||
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le titre pour le corps de l'email
|
||||
*/
|
||||
function getAlertTitle(type) {
|
||||
const titles = {
|
||||
'EVENT_CREATED': 'Nouvel événement créé',
|
||||
'EVENT_MODIFIED': 'Événement modifié',
|
||||
'EVENT_CANCELLED': 'Événement annulé',
|
||||
'LOST': 'Équipement perdu',
|
||||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
||||
'STOCK_LOW': 'Stock faible',
|
||||
};
|
||||
|
||||
return titles[type] || 'Nouvelle alerte';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rend un template HTML avec Handlebars
|
||||
*/
|
||||
async function renderTemplate(templateName, data) {
|
||||
try {
|
||||
// Lire le template de base
|
||||
const basePath = path.join(__dirname, 'templates', 'base-template.html');
|
||||
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
||||
|
||||
// Lire le template de contenu
|
||||
const contentPath = path.join(
|
||||
__dirname,
|
||||
'templates',
|
||||
`${templateName}.html`,
|
||||
);
|
||||
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
||||
|
||||
// Compiler les templates
|
||||
const compileContent = handlebars.compile(contentTemplate);
|
||||
const compileBase = handlebars.compile(baseTemplate);
|
||||
|
||||
// Rendre le contenu
|
||||
const renderedContent = compileContent(data);
|
||||
|
||||
// Rendre le template de base avec le contenu
|
||||
return compileBase({
|
||||
...data,
|
||||
content: renderedContent,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur rendu template:', error);
|
||||
// Fallback vers un template simple
|
||||
return `
|
||||
<html>
|
||||
<body>
|
||||
<h2>${data.alertTitle}</h2>
|
||||
<p>${data.alertMessage}</p>
|
||||
<a href="${data.actionUrl}">Voir l'alerte</a>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
267
em2rp/functions/sendDailyDigest.js
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Fonction schedulée : Envoie quotidienne d'un résumé des alertes non lues
|
||||
* S'exécute tous les jours à 8h00 (Europe/Paris)
|
||||
*/
|
||||
|
||||
const admin = require('firebase-admin');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const nodemailer = require('nodemailer');
|
||||
const { getSmtpConfig } = require('./utils/emailConfig');
|
||||
|
||||
/**
|
||||
* Fonction principale : envoie le digest quotidien
|
||||
*/
|
||||
async function sendDailyDigest() {
|
||||
const db = admin.firestore();
|
||||
|
||||
logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====');
|
||||
|
||||
try {
|
||||
// 1. Récupérer tous les utilisateurs avec email activé
|
||||
const usersSnapshot = await db.collection('users').get();
|
||||
const eligibleUsers = [];
|
||||
|
||||
usersSnapshot.forEach((doc) => {
|
||||
const user = doc.data();
|
||||
const prefs = user.notificationPreferences || {};
|
||||
|
||||
// Vérifier si l'utilisateur a activé les emails
|
||||
if (prefs.emailEnabled !== false && user.email) {
|
||||
eligibleUsers.push({
|
||||
uid: doc.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName || 'Utilisateur',
|
||||
lastName: user.lastName || '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[sendDailyDigest] ${eligibleUsers.length} utilisateurs éligibles`);
|
||||
|
||||
// 2. Pour chaque utilisateur, récupérer ses alertes non lues des dernières 24h
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
const yesterday = admin.firestore.Timestamp.fromMillis(now.toMillis() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||
let emailsSent = 0;
|
||||
|
||||
for (const user of eligibleUsers) {
|
||||
try {
|
||||
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
|
||||
const alertsSnapshot = await db.collection('alerts')
|
||||
.where('assignedTo', 'array-contains', user.uid)
|
||||
.where('isRead', '==', false)
|
||||
.where('createdAt', '>=', yesterday)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.get();
|
||||
|
||||
if (alertsSnapshot.empty) {
|
||||
continue; // Pas d'alertes non lues pour cet utilisateur
|
||||
}
|
||||
|
||||
const alerts = [];
|
||||
alertsSnapshot.forEach((doc) => {
|
||||
alerts.push({ id: doc.id, ...doc.data() });
|
||||
});
|
||||
|
||||
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
|
||||
|
||||
// 3. Envoyer l'email de digest
|
||||
const sent = await sendDigestEmail(transporter, user, alerts);
|
||||
if (sent) {
|
||||
emailsSent++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[sendDailyDigest] Erreur pour ${user.email}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
|
||||
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
|
||||
|
||||
return { success: true, emailsSent };
|
||||
} catch (error) {
|
||||
logger.error('[sendDailyDigest] Erreur globale:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie l'email de digest à un utilisateur
|
||||
*/
|
||||
async function sendDigestEmail(transporter, user, alerts) {
|
||||
try {
|
||||
// Grouper les alertes par sévérité
|
||||
const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL');
|
||||
const warningAlerts = alerts.filter(a => a.severity === 'WARNING');
|
||||
const infoAlerts = alerts.filter(a => a.severity === 'INFO');
|
||||
|
||||
// Construire le HTML
|
||||
const html = buildDigestHtml(user, {
|
||||
critical: criticalAlerts,
|
||||
warning: warningAlerts,
|
||||
info: infoAlerts,
|
||||
});
|
||||
|
||||
// Envoyer l'email
|
||||
await transporter.sendMail({
|
||||
from: `"EM2RP Notifications" <${process.env.SMTP_USER}>`,
|
||||
to: user.email,
|
||||
subject: `📬 ${alerts.length} nouvelle(s) alerte(s) EM2RP`,
|
||||
html,
|
||||
});
|
||||
|
||||
logger.info(`[sendDigestEmail] ✓ Email envoyé à ${user.email}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[sendDigestEmail] Erreur pour ${user.email}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le HTML du digest
|
||||
*/
|
||||
function buildDigestHtml(user, alertsByType) {
|
||||
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
|
||||
|
||||
let alertsHtml = '';
|
||||
|
||||
// Alertes critiques
|
||||
if (alertsByType.critical.length > 0) {
|
||||
alertsHtml += `
|
||||
<div style="margin-bottom: 24px;">
|
||||
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
|
||||
🔴 Alertes critiques (${alertsByType.critical.length})
|
||||
</h3>
|
||||
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Alertes warning
|
||||
if (alertsByType.warning.length > 0) {
|
||||
alertsHtml += `
|
||||
<div style="margin-bottom: 24px;">
|
||||
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
|
||||
⚠️ Avertissements (${alertsByType.warning.length})
|
||||
</h3>
|
||||
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Alertes info
|
||||
if (alertsByType.info.length > 0) {
|
||||
alertsHtml += `
|
||||
<div style="margin-bottom: 24px;">
|
||||
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
|
||||
ℹ️ Informations (${alertsByType.info.length})
|
||||
</h3>
|
||||
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
|
||||
<!-- En-tête -->
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 32px; border-radius: 12px 12px 0 0; text-align: center;">
|
||||
<h1 style="color: white; margin: 0; font-size: 28px;">📬 Résumé quotidien</h1>
|
||||
<p style="color: rgba(255,255,255,0.9); margin: 8px 0 0 0; font-size: 16px;">
|
||||
Bonjour ${user.firstName},
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div style="background-color: white; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px 0;">
|
||||
Vous avez <strong>${totalAlerts} nouvelle(s) alerte(s)</strong> dans les dernières 24 heures.
|
||||
</p>
|
||||
|
||||
${alertsHtml}
|
||||
|
||||
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb; text-align: center;">
|
||||
<a href="https://app.em2event.fr/#/alerts"
|
||||
style="display: inline-block; background-color: #667eea; color: white; padding: 12px 32px; text-decoration: none; border-radius: 8px; font-weight: 600;">
|
||||
Voir toutes les alertes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pied de page -->
|
||||
<div style="text-align: center; padding: 24px; color: #6b7280; font-size: 14px;">
|
||||
<p style="margin: 0 0 8px 0;">EM2RP - Gestion d'événements</p>
|
||||
<p style="margin: 0;">
|
||||
<a href="https://app.em2event.fr/#/settings" style="color: #667eea; text-decoration: none;">
|
||||
Gérer mes préférences de notification
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate un item d'alerte pour l'email
|
||||
*/
|
||||
function formatAlertItem(alert) {
|
||||
const date = alert.createdAt?.toDate ?
|
||||
new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}) :
|
||||
'Date inconnue';
|
||||
|
||||
// Type d'alerte en français
|
||||
const typeLabels = {
|
||||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||
'LOST': 'Équipement perdu',
|
||||
'DAMAGED': 'Équipement endommagé',
|
||||
'QUANTITY_MISMATCH': 'Écart de quantité',
|
||||
'EVENT_CREATED': 'Événement créé',
|
||||
'EVENT_MODIFIED': 'Événement modifié',
|
||||
'WORKFORCE_ADDED': 'Ajout à la workforce',
|
||||
};
|
||||
|
||||
const typeLabel = typeLabels[alert.type] || alert.type;
|
||||
|
||||
return `
|
||||
<div style="background-color: #f9fafb; padding: 16px; border-radius: 8px; margin-bottom: 12px; border-left: 4px solid ${getSeverityColor(alert.severity)};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
|
||||
<strong style="color: #111827; font-size: 15px;">${typeLabel}</strong>
|
||||
<span style="color: #6b7280; font-size: 13px;">${date}</span>
|
||||
</div>
|
||||
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
|
||||
${alert.message || 'Aucun message'}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la couleur selon la sévérité
|
||||
*/
|
||||
function getSeverityColor(severity) {
|
||||
switch (severity) {
|
||||
case 'CRITICAL': return '#dc2626';
|
||||
case 'WARNING': return '#f59e0b';
|
||||
case 'INFO': return '#3b82f6';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendDailyDigest };
|
||||
|
||||
107
em2rp/functions/templates/alert-digest.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<div style="margin-bottom: 30px;">
|
||||
<!-- En-tête du digest -->
|
||||
<div style="margin-bottom: 25px;">
|
||||
<h2 style="color: #111827; margin: 0 0 10px 0; font-size: 24px; font-weight: 600;">
|
||||
📬 Votre résumé quotidien
|
||||
</h2>
|
||||
<p style="color: #6b7280; margin: 0; font-size: 14px;">
|
||||
{{digestDate}} • {{alertCount}} nouvelle(s) alerte(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Message d'introduction -->
|
||||
<p style="color: #374151; margin: 0 0 30px 0; font-size: 16px; line-height: 1.6;">
|
||||
Bonjour <strong>{{userName}}</strong>,<br>
|
||||
Voici le récapitulatif de vos alertes des dernières 24 heures.
|
||||
</p>
|
||||
|
||||
<!-- Liste des alertes -->
|
||||
{{#each alerts}}
|
||||
<div style="background-color: #f9fafb; border-left: 4px solid {{#if this.isCritical}}#DC2626{{else}}#3B82F6{{/if}}; padding: 20px; margin-bottom: 15px; border-radius: 4px;">
|
||||
<!-- Badge type -->
|
||||
<div style="display: inline-block; padding: 4px 12px; border-radius: 12px; margin-bottom: 10px; background-color: {{#if this.isCritical}}#FEE2E2{{else}}#DBEAFE{{/if}}; color: {{#if this.isCritical}}#991B1B{{else}}#1E40AF{{/if}}; font-size: 11px; font-weight: 600; text-transform: uppercase;">
|
||||
{{this.typeLabel}}
|
||||
</div>
|
||||
|
||||
<!-- Titre de l'alerte -->
|
||||
<h3 style="color: #111827; margin: 0 0 8px 0; font-size: 16px; font-weight: 600;">
|
||||
{{this.title}}
|
||||
</h3>
|
||||
|
||||
<!-- Message -->
|
||||
<p style="color: #4b5563; margin: 0 0 12px 0; font-size: 14px; line-height: 1.5;">
|
||||
{{this.message}}
|
||||
</p>
|
||||
|
||||
<!-- Contexte -->
|
||||
{{#if this.context}}
|
||||
<p style="color: #6b7280; margin: 0; font-size: 13px;">
|
||||
<strong>Contexte :</strong> {{this.context}}
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<!-- Timestamp -->
|
||||
<p style="color: #9ca3af; margin: 8px 0 0 0; font-size: 12px;">
|
||||
🕐 {{this.timestamp}}
|
||||
</p>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
<!-- Aucune alerte -->
|
||||
{{#unless alerts}}
|
||||
<div style="background-color: #f0fdf4; border: 1px solid #86efac; padding: 20px; margin-bottom: 20px; border-radius: 8px; text-align: center;">
|
||||
<p style="color: #166534; margin: 0; font-size: 16px;">
|
||||
✅ <strong>Aucune alerte aujourd'hui</strong><br>
|
||||
<span style="font-size: 14px; color: #15803d;">Tout est en ordre !</span>
|
||||
</p>
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
<!-- Bouton d'action principal -->
|
||||
<div style="text-align: center; margin-top: 30px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 0 auto;">
|
||||
<tr>
|
||||
<td style="border-radius: 6px; background: #3B82F6;">
|
||||
<a href="{{appUrl}}/alerts" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
|
||||
Voir toutes mes alertes
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistiques -->
|
||||
{{#if stats}}
|
||||
<div style="margin-top: 30px; padding: 20px; background-color: #fef3c7; border-radius: 8px;">
|
||||
<h3 style="color: #92400e; margin: 0 0 15px 0; font-size: 16px; font-weight: 600;">
|
||||
📊 Vos statistiques
|
||||
</h3>
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
|
||||
<strong>Alertes non lues :</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
|
||||
{{stats.unreadCount}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
|
||||
<strong>Événements en cours :</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
|
||||
{{stats.activeEvents}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Note de bas de page -->
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
|
||||
💡 Ce résumé est envoyé quotidiennement à 8h. Vous pouvez modifier cette préférence dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
81
em2rp/functions/templates/alert-individual.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<div style="margin-bottom: 30px;">
|
||||
<!-- Badge de sévérité -->
|
||||
<div style="display: inline-block; padding: 8px 16px; border-radius: 20px; margin-bottom: 20px; {{#if isCritical}}background-color: #FEE2E2; color: #991B1B;{{else}}background-color: #FEF3C7; color: #92400E;{{/if}}">
|
||||
<strong style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
{{#if isCritical}}🔴 Alerte Critique{{else}}⚠️ Attention{{/if}}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<!-- Titre -->
|
||||
<h2 style="color: #111827; margin: 0 0 20px 0; font-size: 24px; font-weight: 600;">
|
||||
{{alertTitle}}
|
||||
</h2>
|
||||
|
||||
<!-- Message -->
|
||||
<p style="color: #374151; margin: 0 0 25px 0; font-size: 16px; line-height: 1.6;">
|
||||
{{alertMessage}}
|
||||
</p>
|
||||
|
||||
<!-- Détails de l'alerte -->
|
||||
{{#if alertDetails}}
|
||||
<div style="background-color: #f9fafb; border-left: 4px solid #3B82F6; padding: 16px; margin-bottom: 25px; border-radius: 4px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
<strong style="color: #374151;">Détails :</strong><br>
|
||||
{{alertDetails}}
|
||||
</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Informations contextuelles -->
|
||||
{{#if eventName}}
|
||||
<table style="width: 100%; margin-bottom: 25px; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||
<strong style="color: #374151;">Événement :</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||
{{eventName}}
|
||||
</td>
|
||||
</tr>
|
||||
{{#if eventDate}}
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||
<strong style="color: #374151;">Date :</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||
{{eventDate}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{#if equipmentName}}
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||
<strong style="color: #374151;">Équipement :</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||
{{equipmentName}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</table>
|
||||
{{/if}}
|
||||
|
||||
<!-- Bouton d'action -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="border-radius: 6px; {{#if isCritical}}background: #DC2626;{{else}}background: #3B82F6;{{/if}}">
|
||||
<a href="{{actionUrl}}" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
|
||||
{{#if isCritical}}Voir l'alerte immédiatement{{else}}Consulter les détails{{/if}}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Note de bas de page -->
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
|
||||
💡 <strong>Astuce :</strong> Vous pouvez gérer vos préférences de notifications dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
65
em2rp/functions/templates/base-template.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>{{subject}}</title>
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
|
||||
body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.container { width: 100% !important; }
|
||||
.content { padding: 20px !important; }
|
||||
.button { width: 100% !important; display: block !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f3f4f6;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f3f4f6;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 0;">
|
||||
<!-- Container -->
|
||||
<table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="background: linear-gradient(135deg, #1E3A8A 0%, #3B82F6 100%); padding: 30px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: bold;">
|
||||
EM2 Events
|
||||
</h1>
|
||||
<p style="color: #E0E7FF; margin: 8px 0 0 0; font-size: 14px;">
|
||||
Gestion d'événements professionnelle
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td class="content" style="padding: 40px 30px;">
|
||||
{{{content}}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0 0 15px 0; font-size: 13px; color: #6b7280; text-align: center;">
|
||||
Cet email a été envoyé automatiquement par EM2 Events
|
||||
</p>
|
||||
<p style="margin: 15px 0 0 0; font-size: 11px; color: #9ca3af; text-align: center;">
|
||||
© {{year}} EM2 Events. Tous droits réservés.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
165
em2rp/functions/utils/auth.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Utilitaires d'authentification et d'autorisation
|
||||
*/
|
||||
const admin = require('firebase-admin');
|
||||
const logger = require('firebase-functions/logger');
|
||||
|
||||
/**
|
||||
* Vérifie le token Firebase et retourne l'utilisateur
|
||||
*/
|
||||
async function authenticateUser(req) {
|
||||
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) {
|
||||
throw new Error('Unauthorized: No token provided');
|
||||
}
|
||||
|
||||
const idToken = req.headers.authorization.split('Bearer ')[1];
|
||||
try {
|
||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||
return decodedToken;
|
||||
} catch (e) {
|
||||
logger.error("Error verifying Firebase ID token:", e);
|
||||
throw new Error('Unauthorized: Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les données utilisateur depuis Firestore
|
||||
*/
|
||||
async function getUserData(uid) {
|
||||
const userDoc = await admin.firestore().collection('users').doc(uid).get();
|
||||
if (!userDoc.exists) {
|
||||
return null;
|
||||
}
|
||||
return { uid, ...userDoc.data() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les permissions d'un rôle
|
||||
*/
|
||||
async function getRolePermissions(roleRef) {
|
||||
if (!roleRef) return [];
|
||||
|
||||
let roleId;
|
||||
if (typeof roleRef === 'string') {
|
||||
roleId = roleRef;
|
||||
} else if (roleRef.id) {
|
||||
roleId = roleRef.id;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
const roleDoc = await admin.firestore().collection('roles').doc(roleId).get();
|
||||
if (!roleDoc.exists) return [];
|
||||
|
||||
return roleDoc.data().permissions || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur a une permission spécifique
|
||||
*/
|
||||
async function hasPermission(uid, requiredPermission) {
|
||||
const userData = await getUserData(uid);
|
||||
if (!userData) return false;
|
||||
|
||||
const permissions = await getRolePermissions(userData.role);
|
||||
return permissions.includes(requiredPermission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur est admin
|
||||
*/
|
||||
async function isAdmin(uid) {
|
||||
const userData = await getUserData(uid);
|
||||
if (!userData) return false;
|
||||
|
||||
let roleId;
|
||||
const roleField = userData.role;
|
||||
if (typeof roleField === 'string') {
|
||||
roleId = roleField;
|
||||
} else if (roleField && roleField.id) {
|
||||
roleId = roleField.id;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return roleId === 'ADMIN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur est assigné à un événement
|
||||
*/
|
||||
async function isAssignedToEvent(uid, eventId) {
|
||||
const eventDoc = await admin.firestore().collection('events').doc(eventId).get();
|
||||
if (!eventDoc.exists) return false;
|
||||
|
||||
const eventData = eventDoc.data();
|
||||
const workforce = eventData.workforce || [];
|
||||
|
||||
// workforce contient des références DocumentReference
|
||||
return workforce.some(ref => {
|
||||
if (typeof ref === 'string') return ref === uid;
|
||||
if (ref && ref.id) return ref.id === uid;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware d'authentification pour les Cloud Functions HTTP
|
||||
*/
|
||||
async function authMiddleware(req, res, next) {
|
||||
try {
|
||||
const decodedToken = await authenticateUser(req);
|
||||
req.user = decodedToken;
|
||||
req.uid = decodedToken.uid;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware de vérification de permission
|
||||
*/
|
||||
function requirePermission(permission) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const hasAccess = await hasPermission(req.uid, permission);
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({ error: `Forbidden: Requires permission '${permission}'` });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(403).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware admin uniquement
|
||||
*/
|
||||
async function requireAdmin(req, res, next) {
|
||||
try {
|
||||
const adminAccess = await isAdmin(req.uid);
|
||||
if (!adminAccess) {
|
||||
res.status(403).json({ error: 'Forbidden: Admin access required' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(403).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateUser,
|
||||
getUserData,
|
||||
getRolePermissions,
|
||||
hasPermission,
|
||||
isAdmin,
|
||||
isAssignedToEvent,
|
||||
authMiddleware,
|
||||
requirePermission,
|
||||
requireAdmin,
|
||||
};
|
||||
|
||||
39
em2rp/functions/utils/emailConfig.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Configuration SMTP pour l'envoi d'emails
|
||||
* Les credentials sont stockés dans les variables d'environnement
|
||||
*/
|
||||
|
||||
// Configuration SMTP depuis les variables d'environnement
|
||||
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
|
||||
const getSmtpConfig = () => {
|
||||
return {
|
||||
host: process.env.SMTP_HOST || 'mail.em2events.fr',
|
||||
port: parseInt(process.env.SMTP_PORT || '465'),
|
||||
secure: true, // true pour port 465, false pour autres ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'notify@em2events.fr',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
},
|
||||
tls: {
|
||||
// Ne pas échouer sur certificats invalides
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Configuration email par défaut
|
||||
const EMAIL_CONFIG = {
|
||||
from: {
|
||||
name: 'EM2 Events',
|
||||
address: 'notify@em2events.fr',
|
||||
},
|
||||
replyTo: 'contact@em2events.fr',
|
||||
// URL de l'application pour les liens
|
||||
appUrl: process.env.APP_URL || 'https://em2rp-951dc.web.app',
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getSmtpConfig,
|
||||
EMAIL_CONFIG,
|
||||
};
|
||||
|
||||
177
em2rp/functions/utils/emailTemplates.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const admin = require('firebase-admin');
|
||||
const handlebars = require('handlebars');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const {EMAIL_CONFIG} = require('./emailConfig');
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
|
||||
*/
|
||||
function checkAlertPreference(alertType, preferences) {
|
||||
const typeMapping = {
|
||||
'EVENT_CREATED': 'eventsNotifications',
|
||||
'EVENT_MODIFIED': 'eventsNotifications',
|
||||
'EVENT_CANCELLED': 'eventsNotifications',
|
||||
'LOST': 'equipmentNotifications',
|
||||
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
||||
'DAMAGED': 'equipmentNotifications',
|
||||
'QUANTITY_MISMATCH': 'equipmentNotifications',
|
||||
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
||||
'STOCK_LOW': 'stockNotifications',
|
||||
};
|
||||
|
||||
const prefKey = typeMapping[alertType];
|
||||
return prefKey ? (preferences[prefKey] !== false) : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare les données pour le template
|
||||
*/
|
||||
async function prepareTemplateData(alert, user) {
|
||||
const data = {
|
||||
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||
'Utilisateur',
|
||||
alertTitle: getAlertTitle(alert.type),
|
||||
alertMessage: alert.message,
|
||||
isCritical: alert.severity === 'CRITICAL',
|
||||
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
||||
appUrl: EMAIL_CONFIG.appUrl,
|
||||
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||
year: new Date().getFullYear(),
|
||||
subject: getEmailSubject(alert),
|
||||
};
|
||||
|
||||
// Ajouter des détails selon le type d'alerte
|
||||
if (alert.eventId) {
|
||||
try {
|
||||
const eventDoc = await admin.firestore()
|
||||
.collection('events')
|
||||
.doc(alert.eventId)
|
||||
.get();
|
||||
|
||||
if (eventDoc.exists) {
|
||||
const event = eventDoc.data();
|
||||
data.eventName = event.Name || event.name || 'Événement';
|
||||
if (event.StartDateTime || event.startDate) {
|
||||
const dateField = event.StartDateTime || event.startDate;
|
||||
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
|
||||
data.eventDate = date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignorer silencieusement
|
||||
}
|
||||
}
|
||||
|
||||
if (alert.equipmentId) {
|
||||
try {
|
||||
const eqDoc = await admin.firestore()
|
||||
.collection('equipments')
|
||||
.doc(alert.equipmentId)
|
||||
.get();
|
||||
|
||||
if (eqDoc.exists) {
|
||||
data.equipmentName = eqDoc.data().name;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignorer silencieusement
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le titre de l'email selon le type d'alerte
|
||||
*/
|
||||
function getEmailSubject(alert) {
|
||||
const subjects = {
|
||||
'EVENT_CREATED': '📅 Nouvel événement créé',
|
||||
'EVENT_MODIFIED': '📝 Événement modifié',
|
||||
'EVENT_CANCELLED': '❌ Événement annulé',
|
||||
'LOST': '🔴 Alerte critique : Équipement perdu',
|
||||
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
||||
'DAMAGED': '⚠️ Équipement endommagé',
|
||||
'QUANTITY_MISMATCH': 'ℹ️ Quantité incorrecte',
|
||||
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
||||
'STOCK_LOW': '📦 Stock faible',
|
||||
};
|
||||
|
||||
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le titre pour le corps de l'email
|
||||
*/
|
||||
function getAlertTitle(type) {
|
||||
const titles = {
|
||||
'EVENT_CREATED': 'Nouvel événement créé',
|
||||
'EVENT_MODIFIED': 'Événement modifié',
|
||||
'EVENT_CANCELLED': 'Événement annulé',
|
||||
'LOST': 'Équipement perdu',
|
||||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||
'DAMAGED': 'Équipement endommagé',
|
||||
'QUANTITY_MISMATCH': 'Quantité incorrecte',
|
||||
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
||||
'STOCK_LOW': 'Stock faible',
|
||||
};
|
||||
|
||||
return titles[type] || 'Nouvelle alerte';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rend un template HTML avec Handlebars
|
||||
*/
|
||||
async function renderTemplate(templateName, data) {
|
||||
try {
|
||||
// Lire le template de base
|
||||
const basePath = path.join(__dirname, '..', 'templates', 'base-template.html');
|
||||
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
||||
|
||||
// Lire le template de contenu
|
||||
const contentPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'templates',
|
||||
`${templateName}.html`,
|
||||
);
|
||||
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
||||
|
||||
// Compiler les templates
|
||||
const compileContent = handlebars.compile(contentTemplate);
|
||||
const compileBase = handlebars.compile(baseTemplate);
|
||||
|
||||
// Rendre le contenu
|
||||
const renderedContent = compileContent(data);
|
||||
|
||||
// Rendre le template de base avec le contenu
|
||||
return compileBase({
|
||||
...data,
|
||||
content: renderedContent,
|
||||
});
|
||||
} catch (error) {
|
||||
// Fallback vers un template simple
|
||||
return `
|
||||
<html>
|
||||
<body>
|
||||
<h2>${data.alertTitle}</h2>
|
||||
<p>${data.alertMessage}</p>
|
||||
<a href="${data.actionUrl}">Voir l'alerte</a>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkAlertPreference,
|
||||
prepareTemplateData,
|
||||
getEmailSubject,
|
||||
getAlertTitle,
|
||||
renderTemplate,
|
||||
};
|
||||
|
||||
191
em2rp/functions/utils/helpers.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Helpers pour la manipulation de données Firestore
|
||||
*/
|
||||
const admin = require('firebase-admin');
|
||||
|
||||
/**
|
||||
* Convertit les Timestamps Firestore en ISO strings pour JSON
|
||||
*/
|
||||
function serializeTimestamps(data) {
|
||||
if (!data) return data;
|
||||
|
||||
// Éviter la récursion sur les types Firestore spéciaux
|
||||
if (data._firestore || data._path || data._converter) {
|
||||
// C'est un objet Firestore interne, ne pas le traiter
|
||||
if (data.id && data.path) {
|
||||
// C'est une DocumentReference
|
||||
return data.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = { ...data };
|
||||
|
||||
for (const key in result) {
|
||||
const value = result[key];
|
||||
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gérer les Timestamps Firestore
|
||||
if (value.toDate && typeof value.toDate === 'function') {
|
||||
result[key] = value.toDate().toISOString();
|
||||
}
|
||||
// Gérer les DocumentReference
|
||||
else if (value.path && value.id && typeof value.path === 'string') {
|
||||
result[key] = value.path;
|
||||
}
|
||||
// Gérer les GeoPoint
|
||||
else if (value.latitude !== undefined && value.longitude !== undefined) {
|
||||
result[key] = {
|
||||
latitude: value.latitude,
|
||||
longitude: value.longitude
|
||||
};
|
||||
}
|
||||
// Gérer les tableaux
|
||||
else if (Array.isArray(value)) {
|
||||
result[key] = value.map(item => {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
|
||||
// DocumentReference dans un tableau
|
||||
if (item.path && item.id) {
|
||||
return item.path;
|
||||
}
|
||||
// Timestamp dans un tableau
|
||||
if (item.toDate && typeof item.toDate === 'function') {
|
||||
return item.toDate().toISOString();
|
||||
}
|
||||
// Objet normal
|
||||
return serializeTimestamps(item);
|
||||
});
|
||||
}
|
||||
// Gérer les objets imbriqués (mais pas les objets Firestore)
|
||||
else if (typeof value === 'object' && !value._firestore && !value._path) {
|
||||
result[key] = serializeTimestamps(value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit les ISO strings en Timestamps Firestore
|
||||
*/
|
||||
function deserializeTimestamps(data, timestampFields = []) {
|
||||
if (!data) return data;
|
||||
|
||||
const result = { ...data };
|
||||
|
||||
for (const field of timestampFields) {
|
||||
if (result[field] && typeof result[field] === 'string') {
|
||||
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit les références DocumentReference en IDs
|
||||
*/
|
||||
function serializeReferences(data) {
|
||||
if (!data) return data;
|
||||
|
||||
const result = { ...data };
|
||||
|
||||
for (const key in result) {
|
||||
if (result[key] && result[key].path && typeof result[key].path === 'string') {
|
||||
// C'est une DocumentReference
|
||||
result[key] = result[key].id;
|
||||
} else if (Array.isArray(result[key])) {
|
||||
result[key] = result[key].map(item => {
|
||||
if (item && item.path && typeof item.path === 'string') {
|
||||
return item.id;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masque les champs sensibles selon les permissions
|
||||
*/
|
||||
function maskSensitiveFields(data, canViewSensitive) {
|
||||
if (canViewSensitive) return data;
|
||||
|
||||
const masked = { ...data };
|
||||
|
||||
// Masquer les prix si pas de permission manage_equipment
|
||||
delete masked.purchasePrice;
|
||||
delete masked.rentalPrice;
|
||||
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination helper
|
||||
*/
|
||||
function paginate(query, limit = 50, startAfter = null) {
|
||||
let paginatedQuery = query.limit(limit);
|
||||
|
||||
if (startAfter) {
|
||||
paginatedQuery = paginatedQuery.startAfter(startAfter);
|
||||
}
|
||||
|
||||
return paginatedQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre les événements annulés
|
||||
*/
|
||||
function filterCancelledEvents(events) {
|
||||
return events.filter(event => event.status !== 'CANCELLED');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
|
||||
* @param {Object} data - Données de l'événement
|
||||
* @returns {Object} - Données avec DocumentReference
|
||||
*/
|
||||
function convertIdsToReferences(data) {
|
||||
if (!data) return data;
|
||||
|
||||
const result = { ...data };
|
||||
|
||||
// Convertir EventType (ID → DocumentReference)
|
||||
if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) {
|
||||
result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType);
|
||||
}
|
||||
|
||||
// Convertir customer (ID → DocumentReference)
|
||||
if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) {
|
||||
result.customer = admin.firestore().collection('customers').doc(result.customer);
|
||||
}
|
||||
|
||||
// Convertir workforce (IDs → DocumentReference)
|
||||
if (Array.isArray(result.workforce)) {
|
||||
result.workforce = result.workforce.map(item => {
|
||||
if (typeof item === 'string' && !item.includes('/')) {
|
||||
return admin.firestore().collection('users').doc(item);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeTimestamps,
|
||||
deserializeTimestamps,
|
||||
serializeReferences,
|
||||
maskSensitiveFields,
|
||||
paginate,
|
||||
filterCancelledEvents,
|
||||
convertIdsToReferences,
|
||||
};
|
||||
|
||||
14
em2rp/increment_version.bat
Normal file
@@ -0,0 +1,14 @@
|
||||
@echo off
|
||||
REM Script Windows pour incrémenter uniquement la version
|
||||
|
||||
echo Incrémentation de la version...
|
||||
node scripts\increment_version.js
|
||||
|
||||
if %ERRORLEVEL% EQU 0 (
|
||||
echo Version incrémentée avec succès!
|
||||
) else (
|
||||
echo Erreur lors de l'incrémentation
|
||||
)
|
||||
|
||||
pause
|
||||
|
||||
@@ -427,7 +427,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -484,7 +484,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
||||
@@ -1,122 +1 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 703 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.0 KiB |
19
em2rp/lib/config/api_config.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
/// Configuration de l'API backend
|
||||
class ApiConfig {
|
||||
// Mode développement : utilise les émulateurs locaux
|
||||
static const bool isDevelopment = false; // false = utilise Cloud Functions prod
|
||||
|
||||
// URL de base pour les Cloud Functions
|
||||
static const String productionUrl = 'https://us-central1-em2rp-951dc.cloudfunctions.net';
|
||||
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/us-central1';
|
||||
|
||||
/// Retourne l'URL de base selon l'environnement
|
||||
static String get baseUrl => isDevelopment ? developmentUrl : productionUrl;
|
||||
|
||||
/// Configuration du timeout
|
||||
static const Duration requestTimeout = Duration(seconds: 30);
|
||||
|
||||
/// Nombre de tentatives en cas d'échec
|
||||
static const int maxRetries = 3;
|
||||
}
|
||||
|
||||
12
em2rp/lib/config/app_version.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
/// Configuration de la version de l'application
|
||||
class AppVersion {
|
||||
static const String version = '1.0.4';
|
||||
|
||||
/// Retourne la version complète de l'application
|
||||
static String get fullVersion => 'v$version';
|
||||
|
||||
|
||||
/// Retourne la version avec un préfixe personnalisé
|
||||
static String getVersionWithPrefix(String prefix) => '$prefix $version';
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ class Env {
|
||||
|
||||
// Configuration de l'auto-login en développement
|
||||
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
||||
static const String devAdminPassword =
|
||||
"Azerty\$1!"; // À remplacer par le vrai mot de passe
|
||||
static const String devAdminPassword = 'Pastis51!';
|
||||
|
||||
// URLs et endpoints
|
||||
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||
@@ -15,3 +14,4 @@ class Env {
|
||||
// Autres configurations
|
||||
static const int apiTimeout = 30000; // 30 secondes
|
||||
}
|
||||
|
||||
|
||||
17
em2rp/lib/config/env.dev.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
class Env {
|
||||
static const bool isDevelopment = true;
|
||||
|
||||
// Configuration de l'auto-login en développement
|
||||
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
||||
static const String devAdminPassword = 'Pastis51!';
|
||||
|
||||
// URLs et endpoints
|
||||
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||
|
||||
// Configuration Firebase
|
||||
static const String firebaseProjectId = 'em2rp-951dc';
|
||||
|
||||
// Autres configurations
|
||||
static const int apiTimeout = 30000; // 30 secondes
|
||||
}
|
||||
|
||||
488
em2rp/lib/controllers/event_form_controller.dart
Normal file
@@ -0,0 +1,488 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/models/event_type_model.dart';
|
||||
import 'package:em2rp/models/user_model.dart';
|
||||
import 'package:em2rp/services/event_form_service.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/providers/event_provider.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
|
||||
class EventFormController extends ChangeNotifier {
|
||||
// Controllers
|
||||
final TextEditingController nameController = TextEditingController();
|
||||
final TextEditingController descriptionController = TextEditingController();
|
||||
final TextEditingController basePriceController = TextEditingController();
|
||||
final TextEditingController installationController = TextEditingController();
|
||||
final TextEditingController disassemblyController = TextEditingController();
|
||||
final TextEditingController addressController = TextEditingController();
|
||||
final TextEditingController jaugeController = TextEditingController();
|
||||
final TextEditingController contactEmailController = TextEditingController();
|
||||
final TextEditingController contactPhoneController = TextEditingController();
|
||||
|
||||
// State variables
|
||||
DateTime? _startDateTime;
|
||||
DateTime? _endDateTime;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
String? _success;
|
||||
String? _selectedEventTypeId;
|
||||
List<EventTypeModel> _eventTypes = [];
|
||||
bool _isLoadingEventTypes = true;
|
||||
List<String> _selectedUserIds = [];
|
||||
List<UserModel> _allUsers = [];
|
||||
bool _isLoadingUsers = true;
|
||||
List<Map<String, String>> _uploadedFiles = [];
|
||||
List<Map<String, dynamic>> _selectedOptions = [];
|
||||
bool _formChanged = false;
|
||||
EventStatus _selectedStatus = EventStatus.waitingForApproval;
|
||||
List<EventEquipment> _assignedEquipment = [];
|
||||
List<String> _assignedContainers = [];
|
||||
|
||||
// Getters
|
||||
DateTime? get startDateTime => _startDateTime;
|
||||
DateTime? get endDateTime => _endDateTime;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
String? get success => _success;
|
||||
String? get selectedEventTypeId => _selectedEventTypeId;
|
||||
List<EventTypeModel> get eventTypes => _eventTypes;
|
||||
bool get isLoadingEventTypes => _isLoadingEventTypes;
|
||||
List<String> get selectedUserIds => _selectedUserIds;
|
||||
List<UserModel> get allUsers => _allUsers;
|
||||
bool get isLoadingUsers => _isLoadingUsers;
|
||||
List<Map<String, String>> get uploadedFiles => _uploadedFiles;
|
||||
List<Map<String, dynamic>> get selectedOptions => _selectedOptions;
|
||||
List<EventEquipment> get assignedEquipment => _assignedEquipment;
|
||||
List<String> get assignedContainers => _assignedContainers;
|
||||
bool get formChanged => _formChanged;
|
||||
EventStatus get selectedStatus => _selectedStatus;
|
||||
|
||||
EventFormController() {
|
||||
_setupListeners();
|
||||
}
|
||||
|
||||
void _setupListeners() {
|
||||
nameController.addListener(_onAnyFieldChanged);
|
||||
basePriceController.addListener(_onAnyFieldChanged);
|
||||
installationController.addListener(_onAnyFieldChanged);
|
||||
disassemblyController.addListener(_onAnyFieldChanged);
|
||||
addressController.addListener(_onAnyFieldChanged);
|
||||
descriptionController.addListener(_onAnyFieldChanged);
|
||||
jaugeController.addListener(_onAnyFieldChanged);
|
||||
contactEmailController.addListener(_onAnyFieldChanged);
|
||||
contactPhoneController.addListener(_onAnyFieldChanged);
|
||||
}
|
||||
|
||||
void _onAnyFieldChanged() {
|
||||
if (!_formChanged) {
|
||||
_formChanged = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initialize({EventModel? existingEvent, DateTime? selectedDate}) async {
|
||||
await Future.wait([
|
||||
_fetchUsers(),
|
||||
_fetchEventTypes(),
|
||||
]);
|
||||
|
||||
if (existingEvent != null) {
|
||||
_populateFromEvent(existingEvent);
|
||||
} else {
|
||||
_selectedStatus = EventStatus.waitingForApproval;
|
||||
|
||||
// Préremplir les dates si une date est sélectionnée dans le calendrier
|
||||
if (selectedDate != null) {
|
||||
// Date de début : selectedDate à 20h00
|
||||
_startDateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
20,
|
||||
0,
|
||||
);
|
||||
// Date de fin : selectedDate + 4 heures
|
||||
_endDateTime = _startDateTime!.add(const Duration(hours: 4));
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _populateFromEvent(EventModel event) {
|
||||
nameController.text = event.name;
|
||||
descriptionController.text = event.description;
|
||||
basePriceController.text = event.basePrice.toStringAsFixed(2);
|
||||
installationController.text = event.installationTime.toString();
|
||||
disassemblyController.text = event.disassemblyTime.toString();
|
||||
addressController.text = event.address;
|
||||
jaugeController.text = event.jauge?.toString() ?? '';
|
||||
contactEmailController.text = event.contactEmail ?? '';
|
||||
contactPhoneController.text = event.contactPhone ?? '';
|
||||
_startDateTime = event.startDateTime;
|
||||
_endDateTime = event.endDateTime;
|
||||
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
|
||||
_assignedContainers = List<String>.from(event.assignedContainers);
|
||||
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
||||
|
||||
// Gérer workforce qui peut contenir String ou DocumentReference
|
||||
_selectedUserIds = event.workforce.map((ref) {
|
||||
if (ref is String) return ref;
|
||||
if (ref is DocumentReference) return ref.id;
|
||||
return '';
|
||||
}).where((id) => id.isNotEmpty).toList();
|
||||
|
||||
_uploadedFiles = List<Map<String, String>>.from(event.documents);
|
||||
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
|
||||
_selectedStatus = event.status;
|
||||
}
|
||||
|
||||
Future<void> _fetchUsers() async {
|
||||
try {
|
||||
_allUsers = await EventFormService.fetchUsers();
|
||||
_isLoadingUsers = false;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_isLoadingUsers = false;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _fetchEventTypes() async {
|
||||
try {
|
||||
_eventTypes = await EventFormService.fetchEventTypes();
|
||||
_isLoadingEventTypes = false;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_isLoadingEventTypes = false;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setStartDateTime(DateTime? dateTime) {
|
||||
_startDateTime = dateTime;
|
||||
if (_endDateTime != null &&
|
||||
dateTime != null &&
|
||||
(_endDateTime!.isBefore(dateTime) || _endDateTime!.isAtSameMomentAs(dateTime))) {
|
||||
_endDateTime = null;
|
||||
}
|
||||
_onAnyFieldChanged();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setEndDateTime(DateTime? dateTime) {
|
||||
_endDateTime = dateTime;
|
||||
_onAnyFieldChanged();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void onEventTypeChanged(String? newTypeId, BuildContext context) {
|
||||
if (newTypeId == _selectedEventTypeId) return;
|
||||
|
||||
final oldEventTypeIndex = _selectedEventTypeId != null
|
||||
? _eventTypes.indexWhere((et) => et.id == _selectedEventTypeId)
|
||||
: -1;
|
||||
final EventTypeModel? oldEventType = oldEventTypeIndex != -1 ? _eventTypes[oldEventTypeIndex] : null;
|
||||
|
||||
_selectedEventTypeId = newTypeId;
|
||||
|
||||
if (newTypeId != null) {
|
||||
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
|
||||
|
||||
// Utiliser le prix par défaut du type d'événement (prix TTC stocké dans basePrice)
|
||||
final defaultPriceTTC = selectedType.defaultPrice;
|
||||
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
||||
final oldDefaultPrice = oldEventType?.defaultPrice;
|
||||
|
||||
// Mettre à jour le prix TTC si le champ est vide ou si c'était l'ancien prix par défaut
|
||||
if (basePriceController.text.isEmpty ||
|
||||
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
|
||||
basePriceController.text = defaultPriceTTC.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
||||
final before = _selectedOptions.length;
|
||||
_selectedOptions.removeWhere((opt) {
|
||||
// Vérifier si cette option est compatible avec le type d'événement sélectionné
|
||||
final optionEventTypes = opt['eventTypes'] as List<dynamic>? ?? [];
|
||||
return !optionEventTypes.contains(selectedType.id);
|
||||
});
|
||||
|
||||
if (_selectedOptions.length < before) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Certaines options ont été retirées car non compatibles avec "${selectedType.name}".')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
_selectedOptions.clear();
|
||||
}
|
||||
|
||||
_onAnyFieldChanged();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSelectedUserIds(List<String> userIds) {
|
||||
_selectedUserIds = userIds;
|
||||
_onAnyFieldChanged();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setUploadedFiles(List<Map<String, String>> files) {
|
||||
_uploadedFiles = files;
|
||||
_onAnyFieldChanged();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSelectedOptions(List<Map<String, dynamic>> options) {
|
||||
_selectedOptions = options;
|
||||
_onAnyFieldChanged();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
||||
_assignedEquipment = equipment;
|
||||
_assignedContainers = containers;
|
||||
_onAnyFieldChanged();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> pickAndUploadFiles() async {
|
||||
final result = await FilePicker.platform.pickFiles(allowMultiple: true, withData: true);
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final files = await EventFormService.uploadFiles(result.files);
|
||||
_uploadedFiles.addAll(files);
|
||||
_onAnyFieldChanged();
|
||||
} catch (e) {
|
||||
_error = 'Erreur lors de l\'upload : $e';
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool validateForm() {
|
||||
return nameController.text.isNotEmpty &&
|
||||
_startDateTime != null &&
|
||||
_endDateTime != null &&
|
||||
_selectedEventTypeId != null &&
|
||||
addressController.text.isNotEmpty &&
|
||||
(_endDateTime!.isAfter(_startDateTime!));
|
||||
}
|
||||
|
||||
Future<bool> submitForm(BuildContext context, {EventModel? existingEvent}) async {
|
||||
if (!validateForm()) {
|
||||
_error = "Veuillez remplir tous les champs obligatoires.";
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
_success = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final eventTypeRef = _selectedEventTypeId != null
|
||||
? null // Les références Firestore ne sont plus nécessaires, l'ID suffit
|
||||
: null;
|
||||
|
||||
if (existingEvent != null) {
|
||||
// Mode édition
|
||||
// Gérer les nouveaux fichiers uploadés s'il y en a
|
||||
List<Map<String, String>> finalDocuments = List<Map<String, String>>.from(_uploadedFiles);
|
||||
|
||||
// Identifier les nouveaux fichiers (ceux qui ont une URL temp)
|
||||
final newFiles = _uploadedFiles.where((file) =>
|
||||
file['url']?.contains('events/temp/') ?? false).toList();
|
||||
|
||||
if (newFiles.isNotEmpty) {
|
||||
// Déplacer les nouveaux fichiers vers le dossier de l'événement
|
||||
final movedFiles = await EventFormService.moveFilesToEvent(newFiles, existingEvent.id);
|
||||
|
||||
// Remplacer les URLs temporaires par les nouvelles URLs
|
||||
for (int i = 0; i < finalDocuments.length; i++) {
|
||||
final tempFile = finalDocuments[i];
|
||||
final movedFile = movedFiles.firstWhere(
|
||||
(moved) => moved['name'] == tempFile['name'],
|
||||
orElse: () => tempFile,
|
||||
);
|
||||
finalDocuments[i] = movedFile;
|
||||
}
|
||||
}
|
||||
|
||||
final updatedEvent = EventModel(
|
||||
id: existingEvent.id,
|
||||
name: nameController.text.trim(),
|
||||
description: descriptionController.text.trim(),
|
||||
startDateTime: _startDateTime!,
|
||||
endDateTime: _endDateTime!,
|
||||
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
|
||||
installationTime: int.tryParse(installationController.text) ?? 0,
|
||||
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
|
||||
eventTypeId: _selectedEventTypeId!,
|
||||
eventTypeRef: eventTypeRef,
|
||||
customerId: existingEvent.customerId,
|
||||
address: addressController.text.trim(),
|
||||
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||
workforce: _selectedUserIds,
|
||||
latitude: existingEvent.latitude,
|
||||
longitude: existingEvent.longitude,
|
||||
documents: finalDocuments,
|
||||
options: _selectedOptions,
|
||||
status: _selectedStatus,
|
||||
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
|
||||
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
|
||||
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
|
||||
assignedEquipment: _assignedEquipment,
|
||||
assignedContainers: _assignedContainers,
|
||||
preparationStatus: existingEvent.preparationStatus,
|
||||
returnStatus: existingEvent.returnStatus,
|
||||
);
|
||||
|
||||
await EventFormService.updateEvent(updatedEvent);
|
||||
|
||||
// Recharger les événements après modification
|
||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||
final userId = localUserProvider.uid;
|
||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
||||
|
||||
if (userId != null) {
|
||||
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
||||
}
|
||||
|
||||
_success = "Événement modifié avec succès !";
|
||||
} else {
|
||||
// Mode création
|
||||
final newEvent = EventModel(
|
||||
id: '',
|
||||
name: nameController.text.trim(),
|
||||
description: descriptionController.text.trim(),
|
||||
startDateTime: _startDateTime!,
|
||||
endDateTime: _endDateTime!,
|
||||
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
|
||||
installationTime: int.tryParse(installationController.text) ?? 0,
|
||||
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
|
||||
eventTypeId: _selectedEventTypeId!,
|
||||
eventTypeRef: eventTypeRef,
|
||||
customerId: '',
|
||||
address: addressController.text.trim(),
|
||||
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||
workforce: _selectedUserIds,
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
documents: _uploadedFiles,
|
||||
options: _selectedOptions,
|
||||
status: _selectedStatus,
|
||||
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
|
||||
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
|
||||
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
|
||||
assignedContainers: _assignedContainers,
|
||||
assignedEquipment: _assignedEquipment,
|
||||
);
|
||||
|
||||
final eventId = await EventFormService.createEvent(newEvent);
|
||||
|
||||
// Déplacer et mettre à jour les fichiers uniquement s'il y en a
|
||||
if (_uploadedFiles.isNotEmpty) {
|
||||
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
|
||||
if (newFiles.isNotEmpty) {
|
||||
await EventFormService.updateEventDocuments(eventId, newFiles);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload events
|
||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||
final userId = localUserProvider.uid;
|
||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
||||
|
||||
if (userId != null) {
|
||||
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
||||
}
|
||||
|
||||
_success = "Événement créé avec succès !";
|
||||
}
|
||||
|
||||
_formChanged = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_error = "Erreur lors de la sauvegarde : $e";
|
||||
notifyListeners();
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> deleteEvent(BuildContext context, String eventId) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
_success = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Supprimer l'événement via l'API
|
||||
final dataService = DataService(FirebaseFunctionsApiService());
|
||||
await dataService.deleteEvent(eventId);
|
||||
|
||||
// Recharger la liste des événements
|
||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||
final userId = localUserProvider.uid;
|
||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
||||
|
||||
if (userId != null) {
|
||||
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
||||
}
|
||||
|
||||
_success = "Événement supprimé avec succès !";
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_error = "Erreur lors de la suppression : $e";
|
||||
notifyListeners();
|
||||
return false;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearSuccess() {
|
||||
_success = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
nameController.dispose();
|
||||
descriptionController.dispose();
|
||||
basePriceController.dispose();
|
||||
installationController.dispose();
|
||||
disassemblyController.dispose();
|
||||
addressController.dispose();
|
||||
jaugeController.dispose();
|
||||
contactEmailController.dispose();
|
||||
contactPhoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
import 'package:em2rp/providers/users_provider.dart';
|
||||
import 'package:em2rp/providers/event_provider.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||
import 'package:em2rp/providers/alert_provider.dart';
|
||||
import 'package:em2rp/utils/auth_guard_widget.dart';
|
||||
import 'package:em2rp/views/alerts_page.dart';
|
||||
import 'package:em2rp/views/calendar_page.dart';
|
||||
import 'package:em2rp/views/login_page.dart';
|
||||
import 'package:em2rp/views/equipment_management_page.dart';
|
||||
import 'package:em2rp/views/container_management_page.dart';
|
||||
import 'package:em2rp/views/container_form_page.dart';
|
||||
import 'package:em2rp/views/container_detail_page.dart';
|
||||
import 'package:em2rp/views/event_preparation_page.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'firebase_options.dart';
|
||||
@@ -12,37 +25,67 @@ import 'views/my_account_page.dart';
|
||||
import 'views/user_management_page.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'providers/local_user_provider.dart';
|
||||
import 'services/user_service.dart';
|
||||
import 'pages/auth/reset_password_page.dart';
|
||||
import 'views/reset_password_page.dart';
|
||||
import 'config/env.dart';
|
||||
import 'config/api_config.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'views/widgets/common/update_dialog.dart';
|
||||
|
||||
void main() async {
|
||||
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: [
|
||||
// Injection du service UserService
|
||||
Provider<UserService>(create: (_) => UserService()),
|
||||
|
||||
// LocalUserProvider pour la gestion de l'authentification
|
||||
ChangeNotifierProvider<LocalUserProvider>(
|
||||
create: (context) => LocalUserProvider()),
|
||||
|
||||
// Injection des Providers en utilisant UserService
|
||||
// UsersProvider migré vers l'API
|
||||
ChangeNotifierProvider<UsersProvider>(
|
||||
create: (context) => UsersProvider(context.read<UserService>()),
|
||||
create: (context) => UsersProvider(),
|
||||
),
|
||||
|
||||
// EventProvider pour la gestion des événements
|
||||
// EventProvider migré vers l'API
|
||||
ChangeNotifierProvider<EventProvider>(
|
||||
create: (context) => EventProvider(),
|
||||
),
|
||||
|
||||
// EquipmentProvider migré vers l'API
|
||||
ChangeNotifierProvider<EquipmentProvider>(
|
||||
create: (context) => EquipmentProvider(),
|
||||
),
|
||||
|
||||
// ContainerProvider migré vers l'API
|
||||
ChangeNotifierProvider<ContainerProvider>(
|
||||
create: (context) => ContainerProvider(),
|
||||
),
|
||||
|
||||
// MaintenanceProvider migré vers l'API
|
||||
ChangeNotifierProvider<MaintenanceProvider>(
|
||||
create: (context) => MaintenanceProvider(),
|
||||
),
|
||||
ChangeNotifierProvider<AlertProvider>(
|
||||
create: (context) => AlertProvider(),
|
||||
),
|
||||
],
|
||||
child: const MyApp(),
|
||||
),
|
||||
@@ -54,9 +97,9 @@ class MyApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print("test");
|
||||
return MaterialApp(
|
||||
title: 'EM2 ERP',
|
||||
return UpdateChecker(
|
||||
child: MaterialApp(
|
||||
title: 'EM2 Hub',
|
||||
theme: ThemeData(
|
||||
primarySwatch: Colors.red,
|
||||
primaryColor: AppColors.noir,
|
||||
@@ -91,9 +134,11 @@ class MyApp extends StatelessWidget {
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
home: const AutoLoginWrapper(),
|
||||
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(
|
||||
@@ -106,7 +151,39 @@ class MyApp extends StatelessWidget {
|
||||
actionCode: args['actionCode'] as String,
|
||||
);
|
||||
},
|
||||
'/equipment_management': (context) => const AuthGuard(
|
||||
requiredPermission: "view_equipment",
|
||||
child: EquipmentManagementPage()),
|
||||
'/container_management': (context) => const AuthGuard(
|
||||
requiredPermission: "view_equipment",
|
||||
child: ContainerManagementPage()),
|
||||
'/container_form': (context) {
|
||||
final args = ModalRoute.of(context)?.settings.arguments;
|
||||
return AuthGuard(
|
||||
requiredPermission: "manage_equipment",
|
||||
child: ContainerFormPage(
|
||||
container: args as ContainerModel?,
|
||||
),
|
||||
);
|
||||
},
|
||||
'/container_detail': (context) {
|
||||
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel;
|
||||
return AuthGuard(
|
||||
requiredPermission: "view_equipment",
|
||||
child: ContainerDetailPage(container: container),
|
||||
);
|
||||
},
|
||||
'/event_preparation': (context) {
|
||||
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
|
||||
final event = args['event'] as EventModel;
|
||||
return AuthGuard(
|
||||
child: EventPreparationPage(
|
||||
initialEvent: event,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -143,8 +220,23 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
||||
await localAuthProvider.loadUserData();
|
||||
|
||||
if (mounted) {
|
||||
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
|
||||
// En Flutter Web, on peut vérifier window.location.hash
|
||||
final currentUri = Uri.base;
|
||||
final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts
|
||||
|
||||
print('[AutoLoginWrapper] Fragment URL: $fragment');
|
||||
|
||||
// Si une route spécifique est demandée (autre que / ou vide)
|
||||
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
||||
print('[AutoLoginWrapper] Redirection vers: $fragment');
|
||||
Navigator.of(context).pushReplacementNamed(fragment);
|
||||
} else {
|
||||
// Route par défaut : calendrier
|
||||
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
|
||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Auto login failed: $e');
|
||||
if (mounted) {
|
||||
|
||||
144
em2rp/lib/mixins/selection_mode_mixin.dart
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Mixin réutilisable pour gérer le mode sélection multiple
|
||||
/// Utilisable dans equipment_management_page, container_management_page, etc.
|
||||
mixin SelectionModeMixin<T extends StatefulWidget> on State<T> {
|
||||
// État du mode sélection
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
|
||||
// Getters
|
||||
bool get isSelectionMode => _isSelectionMode;
|
||||
Set<String> get selectedIds => _selectedIds;
|
||||
int get selectedCount => _selectedIds.length;
|
||||
bool get hasSelection => _selectedIds.isNotEmpty;
|
||||
|
||||
/// Active/désactive le mode sélection
|
||||
void toggleSelectionMode() {
|
||||
setState(() {
|
||||
_isSelectionMode = !_isSelectionMode;
|
||||
if (!_isSelectionMode) {
|
||||
_selectedIds.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Active le mode sélection
|
||||
void enableSelectionMode() {
|
||||
if (!_isSelectionMode) {
|
||||
setState(() {
|
||||
_isSelectionMode = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Désactive le mode sélection et efface la sélection
|
||||
void disableSelectionMode() {
|
||||
if (_isSelectionMode) {
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedIds.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle la sélection d'un item
|
||||
void toggleItemSelection(String id) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(id)) {
|
||||
_selectedIds.remove(id);
|
||||
} else {
|
||||
_selectedIds.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Sélectionne un item
|
||||
void selectItem(String id) {
|
||||
setState(() {
|
||||
_selectedIds.add(id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Désélectionne un item
|
||||
void deselectItem(String id) {
|
||||
setState(() {
|
||||
_selectedIds.remove(id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Vérifie si un item est sélectionné
|
||||
bool isItemSelected(String id) {
|
||||
return _selectedIds.contains(id);
|
||||
}
|
||||
|
||||
/// Sélectionne tous les items
|
||||
void selectAll(List<String> ids) {
|
||||
setState(() {
|
||||
_selectedIds.addAll(ids);
|
||||
});
|
||||
}
|
||||
|
||||
/// Efface la sélection
|
||||
void clearSelection() {
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/// Sélectionne/désélectionne tous les items
|
||||
void toggleSelectAll(List<String> ids) {
|
||||
setState(() {
|
||||
if (_selectedIds.length == ids.length) {
|
||||
// Tout est sélectionné, on désélectionne tout
|
||||
_selectedIds.clear();
|
||||
} else {
|
||||
// Sélectionner tout
|
||||
_selectedIds.addAll(ids);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget pour afficher le nombre d'éléments sélectionnés
|
||||
Widget buildSelectionCounter({
|
||||
required Color backgroundColor,
|
||||
required Color textColor,
|
||||
String? customText,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
customText ?? '$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// AppBar pour le mode sélection
|
||||
PreferredSizeWidget buildSelectionAppBar({
|
||||
required String title,
|
||||
required List<Widget> actions,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
return AppBar(
|
||||
backgroundColor: backgroundColor,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: disableSelectionMode,
|
||||
),
|
||||
title: Text(
|
||||
'$selectedCount $title sélectionné${selectedCount > 1 ? 's' : ''}',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
273
em2rp/lib/models/alert_model.dart
Normal file
@@ -0,0 +1,273 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
/// Type d'alerte
|
||||
enum AlertType {
|
||||
lowStock, // Stock faible
|
||||
maintenanceDue, // Maintenance à venir
|
||||
conflict, // Conflit disponibilité
|
||||
lost, // Équipement perdu
|
||||
eventCreated, // Événement créé
|
||||
eventModified, // Événement modifié
|
||||
eventCancelled, // Événement annulé
|
||||
eventAssigned, // Assigné à un événement
|
||||
maintenanceReminder, // Rappel maintenance périodique
|
||||
equipmentMissing, // Équipement manquant à une étape
|
||||
quantityMismatch, // Quantité incorrecte
|
||||
damaged, // Équipement endommagé
|
||||
workforceAdded, // Ajouté à la workforce d'un événement
|
||||
}
|
||||
|
||||
/// Gravité de l'alerte
|
||||
enum AlertSeverity {
|
||||
info, // Information (bleu)
|
||||
warning, // Avertissement (orange)
|
||||
critical, // Critique (rouge)
|
||||
}
|
||||
|
||||
String alertTypeToString(AlertType type) {
|
||||
switch (type) {
|
||||
case AlertType.lowStock:
|
||||
return 'LOW_STOCK';
|
||||
case AlertType.maintenanceDue:
|
||||
return 'MAINTENANCE_DUE';
|
||||
case AlertType.conflict:
|
||||
return 'CONFLICT';
|
||||
case AlertType.lost:
|
||||
return 'LOST';
|
||||
case AlertType.eventCreated:
|
||||
return 'EVENT_CREATED';
|
||||
case AlertType.eventModified:
|
||||
return 'EVENT_MODIFIED';
|
||||
case AlertType.eventCancelled:
|
||||
return 'EVENT_CANCELLED';
|
||||
case AlertType.eventAssigned:
|
||||
return 'EVENT_ASSIGNED';
|
||||
case AlertType.maintenanceReminder:
|
||||
return 'MAINTENANCE_REMINDER';
|
||||
case AlertType.equipmentMissing:
|
||||
return 'EQUIPMENT_MISSING';
|
||||
case AlertType.quantityMismatch:
|
||||
return 'QUANTITY_MISMATCH';
|
||||
case AlertType.damaged:
|
||||
return 'DAMAGED';
|
||||
case AlertType.workforceAdded:
|
||||
return 'WORKFORCE_ADDED';
|
||||
}
|
||||
}
|
||||
|
||||
AlertType alertTypeFromString(String? type) {
|
||||
switch (type) {
|
||||
case 'LOW_STOCK':
|
||||
return AlertType.lowStock;
|
||||
case 'MAINTENANCE_DUE':
|
||||
return AlertType.maintenanceDue;
|
||||
case 'CONFLICT':
|
||||
return AlertType.conflict;
|
||||
case 'LOST':
|
||||
return AlertType.lost;
|
||||
case 'EVENT_CREATED':
|
||||
return AlertType.eventCreated;
|
||||
case 'EVENT_MODIFIED':
|
||||
return AlertType.eventModified;
|
||||
case 'EVENT_CANCELLED':
|
||||
return AlertType.eventCancelled;
|
||||
case 'EVENT_ASSIGNED':
|
||||
return AlertType.eventAssigned;
|
||||
case 'MAINTENANCE_REMINDER':
|
||||
return AlertType.maintenanceReminder;
|
||||
case 'EQUIPMENT_MISSING':
|
||||
return AlertType.equipmentMissing;
|
||||
case 'QUANTITY_MISMATCH':
|
||||
return AlertType.quantityMismatch;
|
||||
case 'DAMAGED':
|
||||
return AlertType.damaged;
|
||||
case 'WORKFORCE_ADDED':
|
||||
return AlertType.workforceAdded;
|
||||
default:
|
||||
return AlertType.conflict;
|
||||
}
|
||||
}
|
||||
|
||||
String alertSeverityToString(AlertSeverity severity) {
|
||||
switch (severity) {
|
||||
case AlertSeverity.info:
|
||||
return 'INFO';
|
||||
case AlertSeverity.warning:
|
||||
return 'WARNING';
|
||||
case AlertSeverity.critical:
|
||||
return 'CRITICAL';
|
||||
}
|
||||
}
|
||||
|
||||
AlertSeverity alertSeverityFromString(String? severity) {
|
||||
switch (severity) {
|
||||
case 'INFO':
|
||||
return AlertSeverity.info;
|
||||
case 'WARNING':
|
||||
return AlertSeverity.warning;
|
||||
case 'CRITICAL':
|
||||
return AlertSeverity.critical;
|
||||
default:
|
||||
return AlertSeverity.info;
|
||||
}
|
||||
}
|
||||
|
||||
class AlertModel {
|
||||
final String id; // ID généré automatiquement
|
||||
final AlertType type; // Type d'alerte
|
||||
final AlertSeverity severity; // Gravité de l'alerte
|
||||
final String message; // Message de l'alerte
|
||||
final List<String> assignedToUserIds; // Utilisateurs concernés
|
||||
final String? eventId; // ID de l'événement concerné (optionnel)
|
||||
final String? equipmentId; // ID de l'équipement concerné (optionnel)
|
||||
final String? createdByUserId; // Qui a déclenché l'alerte
|
||||
final DateTime createdAt; // Date de création
|
||||
final DateTime? dueDate; // Date d'échéance (pour maintenance)
|
||||
final String? actionUrl; // URL de redirection (deep link)
|
||||
final bool isRead; // Statut lu/non lu
|
||||
final bool isResolved; // Résolue ou non
|
||||
final String? resolution; // Message de résolution
|
||||
final DateTime? resolvedAt; // Date de résolution
|
||||
final String? resolvedByUserId; // Qui a résolu
|
||||
|
||||
AlertModel({
|
||||
required this.id,
|
||||
required this.type,
|
||||
this.severity = AlertSeverity.info,
|
||||
required this.message,
|
||||
this.assignedToUserIds = const [],
|
||||
this.eventId,
|
||||
this.equipmentId,
|
||||
this.createdByUserId,
|
||||
required this.createdAt,
|
||||
this.dueDate,
|
||||
this.actionUrl,
|
||||
this.isRead = false,
|
||||
this.isResolved = false,
|
||||
this.resolution,
|
||||
this.resolvedAt,
|
||||
this.resolvedByUserId,
|
||||
});
|
||||
|
||||
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||
DateTime _parseDate(dynamic value) {
|
||||
if (value == null) return DateTime.now();
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
// Parser les assignedToUserIds (peut être List ou null)
|
||||
List<String> parseUserIds(dynamic value) {
|
||||
if (value == null) return [];
|
||||
if (value is List) return value.map((e) => e.toString()).toList();
|
||||
return [];
|
||||
}
|
||||
|
||||
return AlertModel(
|
||||
id: id,
|
||||
type: alertTypeFromString(map['type']),
|
||||
severity: alertSeverityFromString(map['severity']),
|
||||
message: map['message'] ?? '',
|
||||
assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']),
|
||||
eventId: map['eventId'],
|
||||
equipmentId: map['equipmentId'],
|
||||
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
||||
createdAt: _parseDate(map['createdAt']),
|
||||
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
|
||||
actionUrl: map['actionUrl'],
|
||||
isRead: map['isRead'] ?? false,
|
||||
isResolved: map['isResolved'] ?? false,
|
||||
resolution: map['resolution'],
|
||||
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null,
|
||||
resolvedByUserId: map['resolvedByUserId'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Factory depuis un document Firestore
|
||||
factory AlertModel.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Document vide: ${doc.id}');
|
||||
}
|
||||
return AlertModel.fromMap(data, doc.id);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'type': alertTypeToString(type),
|
||||
'severity': alertSeverityToString(severity),
|
||||
'message': message,
|
||||
'assignedToUserIds': assignedToUserIds,
|
||||
if (eventId != null) 'eventId': eventId,
|
||||
if (equipmentId != null) 'equipmentId': equipmentId,
|
||||
if (createdByUserId != null) 'createdByUserId': createdByUserId,
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!),
|
||||
if (actionUrl != null) 'actionUrl': actionUrl,
|
||||
'isRead': isRead,
|
||||
'isResolved': isResolved,
|
||||
if (resolution != null) 'resolution': resolution,
|
||||
if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!),
|
||||
if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId,
|
||||
};
|
||||
}
|
||||
|
||||
AlertModel copyWith({
|
||||
String? id,
|
||||
AlertType? type,
|
||||
AlertSeverity? severity,
|
||||
String? message,
|
||||
List<String>? assignedToUserIds,
|
||||
String? eventId,
|
||||
String? equipmentId,
|
||||
String? createdByUserId,
|
||||
DateTime? createdAt,
|
||||
DateTime? dueDate,
|
||||
String? actionUrl,
|
||||
bool? isRead,
|
||||
bool? isResolved,
|
||||
String? resolution,
|
||||
DateTime? resolvedAt,
|
||||
String? resolvedByUserId,
|
||||
}) {
|
||||
return AlertModel(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
severity: severity ?? this.severity,
|
||||
message: message ?? this.message,
|
||||
assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds,
|
||||
eventId: eventId ?? this.eventId,
|
||||
equipmentId: equipmentId ?? this.equipmentId,
|
||||
createdByUserId: createdByUserId ?? this.createdByUserId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
dueDate: dueDate ?? this.dueDate,
|
||||
actionUrl: actionUrl ?? this.actionUrl,
|
||||
isRead: isRead ?? this.isRead,
|
||||
isResolved: isResolved ?? this.isResolved,
|
||||
resolution: resolution ?? this.resolution,
|
||||
resolvedAt: resolvedAt ?? this.resolvedAt,
|
||||
resolvedByUserId: resolvedByUserId ?? this.resolvedByUserId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper : Retourne true si l'alerte est pour un événement
|
||||
bool get isEventAlert =>
|
||||
type == AlertType.eventCreated ||
|
||||
type == AlertType.eventModified ||
|
||||
type == AlertType.eventCancelled ||
|
||||
type == AlertType.eventAssigned;
|
||||
|
||||
/// Helper : Retourne true si l'alerte est pour la maintenance
|
||||
bool get isMaintenanceAlert =>
|
||||
type == AlertType.maintenanceDue ||
|
||||
type == AlertType.maintenanceReminder;
|
||||
|
||||
/// Helper : Retourne true si l'alerte est pour un équipement
|
||||
bool get isEquipmentAlert =>
|
||||
type == AlertType.lost ||
|
||||
type == AlertType.equipmentMissing ||
|
||||
type == AlertType.lowStock;
|
||||
}
|
||||
|
||||
382
em2rp/lib/models/container_model.dart
Normal file
@@ -0,0 +1,382 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
|
||||
/// Type de container
|
||||
enum ContainerType {
|
||||
flightCase, // Flight case
|
||||
pelicase, // Pelicase
|
||||
bag, // Sac
|
||||
openCrate, // Caisse ouverte
|
||||
toolbox, // Boîte à outils
|
||||
}
|
||||
|
||||
String containerTypeToString(ContainerType type) {
|
||||
switch (type) {
|
||||
case ContainerType.flightCase:
|
||||
return 'FLIGHT_CASE';
|
||||
case ContainerType.pelicase:
|
||||
return 'PELICASE';
|
||||
case ContainerType.bag:
|
||||
return 'BAG';
|
||||
case ContainerType.openCrate:
|
||||
return 'OPEN_CRATE';
|
||||
case ContainerType.toolbox:
|
||||
return 'TOOLBOX';
|
||||
}
|
||||
}
|
||||
|
||||
ContainerType containerTypeFromString(String? type) {
|
||||
switch (type) {
|
||||
case 'FLIGHT_CASE':
|
||||
return ContainerType.flightCase;
|
||||
case 'PELICASE':
|
||||
return ContainerType.pelicase;
|
||||
case 'BAG':
|
||||
return ContainerType.bag;
|
||||
case 'OPEN_CRATE':
|
||||
return ContainerType.openCrate;
|
||||
case 'TOOLBOX':
|
||||
return ContainerType.toolbox;
|
||||
default:
|
||||
return ContainerType.flightCase;
|
||||
}
|
||||
}
|
||||
|
||||
String containerTypeLabel(ContainerType type) {
|
||||
switch (type) {
|
||||
case ContainerType.flightCase:
|
||||
return 'Flight Case';
|
||||
case ContainerType.pelicase:
|
||||
return 'Pelicase';
|
||||
case ContainerType.bag:
|
||||
return 'Sac';
|
||||
case ContainerType.openCrate:
|
||||
return 'Caisse Ouverte';
|
||||
case ContainerType.toolbox:
|
||||
return 'Boîte à Outils';
|
||||
}
|
||||
}
|
||||
|
||||
// Extensions pour centraliser les informations d'affichage
|
||||
extension ContainerTypeExtension on ContainerType {
|
||||
/// Retourne le label français du type de container
|
||||
String get label {
|
||||
switch (this) {
|
||||
case ContainerType.flightCase:
|
||||
return 'Flight Case';
|
||||
case ContainerType.pelicase:
|
||||
return 'Pelicase';
|
||||
case ContainerType.bag:
|
||||
return 'Sac';
|
||||
case ContainerType.openCrate:
|
||||
return 'Caisse Ouverte';
|
||||
case ContainerType.toolbox:
|
||||
return 'Boîte à Outils';
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne l'icône Material du type de container
|
||||
IconData get iconData {
|
||||
switch (this) {
|
||||
case ContainerType.flightCase:
|
||||
return Icons.work;
|
||||
case ContainerType.pelicase:
|
||||
return Icons.work_outline;
|
||||
case ContainerType.bag:
|
||||
return Icons.shopping_bag;
|
||||
case ContainerType.openCrate:
|
||||
return Icons.inventory_2;
|
||||
case ContainerType.toolbox:
|
||||
return Icons.home_repair_service;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le chemin de l'icône personnalisée (si disponible)
|
||||
String? get customIconPath {
|
||||
switch (this) {
|
||||
case ContainerType.flightCase:
|
||||
return 'assets/icons/flight-case.svg';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si une icône personnalisée est disponible
|
||||
bool get hasCustomIcon => customIconPath != null;
|
||||
|
||||
/// Retourne l'icône Widget à afficher (unifié pour Material et personnalisé)
|
||||
Widget getIcon({double size = 24, Color? color}) {
|
||||
final customPath = customIconPath;
|
||||
if (customPath != null) {
|
||||
// Détection automatique du format (SVG ou PNG)
|
||||
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||
|
||||
if (isSvg) {
|
||||
// SVG : on peut appliquer la couleur sans dégrader la qualité
|
||||
return SvgPicture.asset(
|
||||
customPath,
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: color != null
|
||||
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||
: null,
|
||||
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||
);
|
||||
} else {
|
||||
// PNG : on n'applique PAS le color filter pour préserver la qualité
|
||||
return Image.asset(
|
||||
customPath,
|
||||
width: size,
|
||||
height: size,
|
||||
filterQuality: FilterQuality.high,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Icon(iconData, size: size, color: color);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return Icon(iconData, size: size, color: color);
|
||||
}
|
||||
|
||||
/// Version pour CircleAvatar et contextes similaires
|
||||
Widget getIconForAvatar({double size = 24, Color? color}) {
|
||||
final customPath = customIconPath;
|
||||
if (customPath != null) {
|
||||
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||
|
||||
if (isSvg) {
|
||||
return SvgPicture.asset(
|
||||
customPath,
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: color != null
|
||||
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||
: null,
|
||||
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||
);
|
||||
} else {
|
||||
return Image.asset(
|
||||
customPath,
|
||||
width: size,
|
||||
height: size,
|
||||
filterQuality: FilterQuality.high,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Icon(iconData, size: size, color: color);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return Icon(iconData, size: size, color: color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle de container/boîte pour le matériel
|
||||
class ContainerModel {
|
||||
final String id; // Identifiant unique (généré comme pour équipement)
|
||||
final String name; // Nom du container
|
||||
final ContainerType type; // Type de container
|
||||
final EquipmentStatus status; // Statut actuel (même que équipement)
|
||||
|
||||
// Caractéristiques physiques
|
||||
final double? weight; // Poids à vide (kg)
|
||||
final double? length; // Longueur (cm)
|
||||
final double? width; // Largeur (cm)
|
||||
final double? height; // Hauteur (cm)
|
||||
|
||||
// Contenu
|
||||
final List<String> equipmentIds; // IDs des équipements contenus
|
||||
|
||||
// Événement
|
||||
final String? eventId; // ID de l'événement actuel (si en prestation)
|
||||
|
||||
// Métadonnées
|
||||
final String? notes; // Notes additionnelles
|
||||
final DateTime createdAt; // Date de création
|
||||
final DateTime updatedAt; // Date de mise à jour
|
||||
|
||||
// Historique simple (optionnel)
|
||||
final List<ContainerHistoryEntry> history; // Historique des modifications
|
||||
|
||||
ContainerModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.status = EquipmentStatus.available,
|
||||
this.weight,
|
||||
this.length,
|
||||
this.width,
|
||||
this.height,
|
||||
this.equipmentIds = const [],
|
||||
this.eventId,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.history = const [],
|
||||
});
|
||||
|
||||
/// Vérifier si le container est vide
|
||||
bool get isEmpty => equipmentIds.isEmpty;
|
||||
|
||||
/// Nombre d'équipements dans le container
|
||||
int get itemCount => equipmentIds.length;
|
||||
|
||||
/// Calculer le volume (m³)
|
||||
double? get volume {
|
||||
if (length == null || width == null || height == null) return null;
|
||||
return (length! * width! * height!) / 1000000; // cm³ to m³
|
||||
}
|
||||
|
||||
/// Calculer le poids total (poids vide + équipements)
|
||||
/// Nécessite la liste des équipements
|
||||
double calculateTotalWeight(List<EquipmentModel> equipment) {
|
||||
double total = weight ?? 0.0;
|
||||
for (final eq in equipment) {
|
||||
if (equipmentIds.contains(eq.id) && eq.weight != null) {
|
||||
total += eq.weight!;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Factory depuis Firestore
|
||||
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||
DateTime? _parseDate(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||
|
||||
final List<dynamic> historyRaw = map['history'] ?? [];
|
||||
final List<ContainerHistoryEntry> history = historyRaw
|
||||
.map((e) => ContainerHistoryEntry.fromMap(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
return ContainerModel(
|
||||
id: id,
|
||||
name: map['name'] ?? '',
|
||||
type: containerTypeFromString(map['type']),
|
||||
status: equipmentStatusFromString(map['status']),
|
||||
weight: map['weight']?.toDouble(),
|
||||
length: map['length']?.toDouble(),
|
||||
width: map['width']?.toDouble(),
|
||||
height: map['height']?.toDouble(),
|
||||
equipmentIds: equipmentIds,
|
||||
eventId: map['eventId'],
|
||||
notes: map['notes'],
|
||||
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||
history: history,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convertir en Map pour Firestore
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'type': containerTypeToString(type),
|
||||
'status': equipmentStatusToString(status),
|
||||
'weight': weight,
|
||||
'length': length,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'equipmentIds': equipmentIds,
|
||||
'eventId': eventId,
|
||||
'notes': notes,
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||
'history': history.map((e) => e.toMap()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Copier avec modifications
|
||||
ContainerModel copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
ContainerType? type,
|
||||
EquipmentStatus? status,
|
||||
double? weight,
|
||||
double? length,
|
||||
double? width,
|
||||
double? height,
|
||||
List<String>? equipmentIds,
|
||||
String? eventId,
|
||||
String? notes,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
List<ContainerHistoryEntry>? history,
|
||||
}) {
|
||||
return ContainerModel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
status: status ?? this.status,
|
||||
weight: weight ?? this.weight,
|
||||
length: length ?? this.length,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
equipmentIds: equipmentIds ?? this.equipmentIds,
|
||||
eventId: eventId ?? this.eventId,
|
||||
notes: notes ?? this.notes,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
history: history ?? this.history,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Entrée d'historique pour un container
|
||||
class ContainerHistoryEntry {
|
||||
final DateTime timestamp;
|
||||
final String action; // 'added', 'removed', 'status_change', etc.
|
||||
final String? equipmentId; // ID de l'équipement concerné (si applicable)
|
||||
final String? previousValue; // Valeur précédente
|
||||
final String? newValue; // Nouvelle valeur
|
||||
final String? userId; // ID de l'utilisateur ayant fait la modification
|
||||
|
||||
ContainerHistoryEntry({
|
||||
required this.timestamp,
|
||||
required this.action,
|
||||
this.equipmentId,
|
||||
this.previousValue,
|
||||
this.newValue,
|
||||
this.userId,
|
||||
});
|
||||
|
||||
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
||||
// Helper pour parser la date
|
||||
DateTime _parseDate(dynamic value) {
|
||||
if (value == null) return DateTime.now();
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
return ContainerHistoryEntry(
|
||||
timestamp: _parseDate(map['timestamp']),
|
||||
action: map['action'] ?? '',
|
||||
equipmentId: map['equipmentId'],
|
||||
previousValue: map['previousValue'],
|
||||
newValue: map['newValue'],
|
||||
userId: map['userId'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'timestamp': Timestamp.fromDate(timestamp),
|
||||
'action': action,
|
||||
'equipmentId': equipmentId,
|
||||
'previousValue': previousValue,
|
||||
'newValue': newValue,
|
||||
'userId': userId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
528
em2rp/lib/models/equipment_model.dart
Normal file
@@ -0,0 +1,528 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
enum EquipmentStatus {
|
||||
available, // Disponible
|
||||
inUse, // En prestation
|
||||
rented, // Loué
|
||||
lost, // Perdu
|
||||
outOfService, // HS
|
||||
maintenance, // En maintenance
|
||||
}
|
||||
|
||||
String equipmentStatusToString(EquipmentStatus status) {
|
||||
switch (status) {
|
||||
case EquipmentStatus.available:
|
||||
return 'AVAILABLE';
|
||||
case EquipmentStatus.inUse:
|
||||
return 'IN_USE';
|
||||
case EquipmentStatus.rented:
|
||||
return 'RENTED';
|
||||
case EquipmentStatus.lost:
|
||||
return 'LOST';
|
||||
case EquipmentStatus.outOfService:
|
||||
return 'OUT_OF_SERVICE';
|
||||
case EquipmentStatus.maintenance:
|
||||
return 'MAINTENANCE';
|
||||
}
|
||||
}
|
||||
|
||||
EquipmentStatus equipmentStatusFromString(String? status) {
|
||||
switch (status) {
|
||||
case 'AVAILABLE':
|
||||
return EquipmentStatus.available;
|
||||
case 'IN_USE':
|
||||
return EquipmentStatus.inUse;
|
||||
case 'RENTED':
|
||||
return EquipmentStatus.rented;
|
||||
case 'LOST':
|
||||
return EquipmentStatus.lost;
|
||||
case 'OUT_OF_SERVICE':
|
||||
return EquipmentStatus.outOfService;
|
||||
case 'MAINTENANCE':
|
||||
return EquipmentStatus.maintenance;
|
||||
default:
|
||||
return EquipmentStatus.available;
|
||||
}
|
||||
}
|
||||
|
||||
enum EquipmentCategory {
|
||||
lighting, // Lumière
|
||||
sound, // Son
|
||||
video, // Vidéo
|
||||
effect, // Effets spéciaux
|
||||
structure, // Structure
|
||||
consumable, // Consommable
|
||||
cable, // Câble
|
||||
vehicle, // Véhicule
|
||||
backline, // Régie / Backline
|
||||
other // Autre
|
||||
}
|
||||
|
||||
String equipmentCategoryToString(EquipmentCategory category) {
|
||||
switch (category) {
|
||||
case EquipmentCategory.lighting:
|
||||
return 'LIGHTING';
|
||||
case EquipmentCategory.sound:
|
||||
return 'SOUND';
|
||||
case EquipmentCategory.video:
|
||||
return 'VIDEO';
|
||||
case EquipmentCategory.structure:
|
||||
return 'STRUCTURE';
|
||||
case EquipmentCategory.consumable:
|
||||
return 'CONSUMABLE';
|
||||
case EquipmentCategory.cable:
|
||||
return 'CABLE';
|
||||
case EquipmentCategory.vehicle:
|
||||
return 'VEHICLE';
|
||||
case EquipmentCategory.backline:
|
||||
return 'BACKLINE';
|
||||
case EquipmentCategory.other:
|
||||
return 'OTHER';
|
||||
case EquipmentCategory.effect:
|
||||
return 'EFFECT';
|
||||
}
|
||||
}
|
||||
|
||||
EquipmentCategory equipmentCategoryFromString(String? category) {
|
||||
switch (category) {
|
||||
case 'LIGHTING':
|
||||
return EquipmentCategory.lighting;
|
||||
case 'SOUND':
|
||||
return EquipmentCategory.sound;
|
||||
case 'VIDEO':
|
||||
return EquipmentCategory.video;
|
||||
case 'STRUCTURE':
|
||||
return EquipmentCategory.structure;
|
||||
case 'CONSUMABLE':
|
||||
return EquipmentCategory.consumable;
|
||||
case 'CABLE':
|
||||
return EquipmentCategory.cable;
|
||||
case 'VEHICLE':
|
||||
return EquipmentCategory.vehicle;
|
||||
case 'BACKLINE':
|
||||
return EquipmentCategory.backline;
|
||||
case 'EFFECT':
|
||||
return EquipmentCategory.effect;
|
||||
case 'OTHER':
|
||||
default:
|
||||
return EquipmentCategory.other;
|
||||
}
|
||||
}
|
||||
|
||||
// Extensions pour centraliser les informations d'affichage
|
||||
extension EquipmentCategoryExtension on EquipmentCategory {
|
||||
/// Retourne le label français de la catégorie
|
||||
String get label {
|
||||
switch (this) {
|
||||
case EquipmentCategory.lighting:
|
||||
return 'Lumière';
|
||||
case EquipmentCategory.sound:
|
||||
return 'Son';
|
||||
case EquipmentCategory.video:
|
||||
return 'Vidéo';
|
||||
case EquipmentCategory.effect:
|
||||
return 'Effets';
|
||||
case EquipmentCategory.structure:
|
||||
return 'Structure';
|
||||
case EquipmentCategory.consumable:
|
||||
return 'Consommable';
|
||||
case EquipmentCategory.cable:
|
||||
return 'Câble';
|
||||
case EquipmentCategory.vehicle:
|
||||
return 'Véhicule';
|
||||
case EquipmentCategory.backline:
|
||||
return 'Régie / Backline';
|
||||
case EquipmentCategory.other:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne l'icône Material de la catégorie
|
||||
IconData get iconData {
|
||||
switch (this) {
|
||||
case EquipmentCategory.lighting:
|
||||
return Icons.light_mode;
|
||||
case EquipmentCategory.sound:
|
||||
return Icons.volume_up;
|
||||
case EquipmentCategory.video:
|
||||
return Icons.videocam;
|
||||
case EquipmentCategory.effect:
|
||||
return Icons.auto_awesome;
|
||||
case EquipmentCategory.structure:
|
||||
return Icons.construction;
|
||||
case EquipmentCategory.consumable:
|
||||
return Icons.inventory_2;
|
||||
case EquipmentCategory.cable:
|
||||
return Icons.cable;
|
||||
case EquipmentCategory.vehicle:
|
||||
return Icons.local_shipping;
|
||||
case EquipmentCategory.backline:
|
||||
return Icons.piano;
|
||||
case EquipmentCategory.other:
|
||||
return Icons.more_horiz;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne la couleur associée à la catégorie
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case EquipmentCategory.lighting:
|
||||
return Colors.yellow.shade700;
|
||||
case EquipmentCategory.sound:
|
||||
return Colors.purple;
|
||||
case EquipmentCategory.video:
|
||||
return Colors.blue;
|
||||
case EquipmentCategory.effect:
|
||||
return Colors.pink;
|
||||
case EquipmentCategory.structure:
|
||||
return Colors.brown;
|
||||
case EquipmentCategory.consumable:
|
||||
return Colors.orange;
|
||||
case EquipmentCategory.cable:
|
||||
return Colors.grey;
|
||||
case EquipmentCategory.vehicle:
|
||||
return Colors.teal;
|
||||
case EquipmentCategory.backline:
|
||||
return Colors.indigo;
|
||||
case EquipmentCategory.other:
|
||||
return Colors.blueGrey;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le chemin de l'icône personnalisée (si disponible)
|
||||
String? get customIconPath {
|
||||
switch (this) {
|
||||
case EquipmentCategory.structure:
|
||||
return 'assets/icons/truss.svg';
|
||||
case EquipmentCategory.consumable:
|
||||
return 'assets/icons/tape.svg';
|
||||
case EquipmentCategory.lighting:
|
||||
case EquipmentCategory.sound:
|
||||
case EquipmentCategory.video:
|
||||
case EquipmentCategory.effect:
|
||||
case EquipmentCategory.cable:
|
||||
case EquipmentCategory.vehicle:
|
||||
case EquipmentCategory.backline:
|
||||
case EquipmentCategory.other:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si une icône personnalisée est disponible
|
||||
bool get hasCustomIcon => customIconPath != null;
|
||||
|
||||
/// Retourne l'icône Widget à afficher (unifié pour Material et personnalisé)
|
||||
Widget getIcon({double size = 24, Color? color}) {
|
||||
final customPath = customIconPath;
|
||||
if (customPath != null) {
|
||||
// Détection automatique du format (SVG ou PNG)
|
||||
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||
|
||||
if (isSvg) {
|
||||
// SVG : on peut appliquer la couleur sans dégrader la qualité
|
||||
return SvgPicture.asset(
|
||||
customPath,
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: color != null
|
||||
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||
: null,
|
||||
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||
);
|
||||
} else {
|
||||
// PNG : on n'applique PAS le color filter pour préserver la qualité
|
||||
return Image.asset(
|
||||
customPath,
|
||||
width: size,
|
||||
height: size,
|
||||
filterQuality: FilterQuality.high,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Icon(iconData, size: size, color: color);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return Icon(iconData, size: size, color: color);
|
||||
}
|
||||
|
||||
/// Version pour CircleAvatar et contextes similaires (sans ColorFilter si Material Icon)
|
||||
Widget getIconForAvatar({double size = 24, Color? color}) {
|
||||
final customPath = customIconPath;
|
||||
if (customPath != null) {
|
||||
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||
|
||||
if (isSvg) {
|
||||
return SvgPicture.asset(
|
||||
customPath,
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: color != null
|
||||
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||
: null,
|
||||
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||
);
|
||||
} else {
|
||||
return Image.asset(
|
||||
customPath,
|
||||
width: size,
|
||||
height: size,
|
||||
filterQuality: FilterQuality.high,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Icon(iconData, size: size, color: color);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return Icon(iconData, size: size, color: color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension EquipmentStatusExtension on EquipmentStatus {
|
||||
/// Retourne le label français du statut
|
||||
String get label {
|
||||
switch (this) {
|
||||
case EquipmentStatus.available:
|
||||
return 'Disponible';
|
||||
case EquipmentStatus.inUse:
|
||||
return 'En prestation';
|
||||
case EquipmentStatus.rented:
|
||||
return 'Loué';
|
||||
case EquipmentStatus.lost:
|
||||
return 'Perdu';
|
||||
case EquipmentStatus.outOfService:
|
||||
return 'HS';
|
||||
case EquipmentStatus.maintenance:
|
||||
return 'Maintenance';
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne la couleur associée au statut
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case EquipmentStatus.available:
|
||||
return Colors.green;
|
||||
case EquipmentStatus.inUse:
|
||||
return Colors.blue;
|
||||
case EquipmentStatus.rented:
|
||||
return Colors.orange;
|
||||
case EquipmentStatus.lost:
|
||||
return Colors.red;
|
||||
case EquipmentStatus.outOfService:
|
||||
return Colors.red.shade900;
|
||||
case EquipmentStatus.maintenance:
|
||||
return Colors.amber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EquipmentModel {
|
||||
final String id; // Identifiant unique (clé)
|
||||
final String name; // Nom de l'équipement
|
||||
final String? brand; // Marque (indexé)
|
||||
final String? model; // Modèle (indexé)
|
||||
final EquipmentCategory category; // Catégorie
|
||||
final String? subCategory; // Sous-catégorie (indexé par catégorie)
|
||||
final EquipmentStatus status; // Statut actuel
|
||||
|
||||
// Prix (visible uniquement avec manage_equipment)
|
||||
final double? purchasePrice; // Prix d'achat
|
||||
final double? rentalPrice; // Prix de location
|
||||
|
||||
// Quantité (pour consommables/câbles)
|
||||
final int? totalQuantity; // Quantité totale
|
||||
final int? availableQuantity; // Quantité disponible
|
||||
final int? criticalThreshold; // Seuil critique pour alerte
|
||||
|
||||
|
||||
// Caractéristiques physiques
|
||||
final double? weight; // Poids (kg)
|
||||
final double? length; // Longueur (cm)
|
||||
final double? width; // Largeur (cm)
|
||||
final double? height; // Hauteur (cm)
|
||||
|
||||
// Dates & maintenance
|
||||
final DateTime? purchaseDate; // Date d'achat
|
||||
final DateTime? lastMaintenanceDate; // Dernière maintenance
|
||||
final DateTime? nextMaintenanceDate; // Prochaine maintenance prévue
|
||||
|
||||
// Maintenances (références)
|
||||
final List<String> maintenanceIds; // IDs des opérations de maintenance
|
||||
|
||||
// Image
|
||||
final String? imageUrl; // URL de l'image (Storage /materiel)
|
||||
|
||||
// Métadonnées
|
||||
final String? notes; // Notes additionnelles
|
||||
final DateTime createdAt; // Date de création
|
||||
final DateTime updatedAt; // Date de mise à jour
|
||||
|
||||
EquipmentModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.brand,
|
||||
this.model,
|
||||
required this.category,
|
||||
this.subCategory,
|
||||
this.status = EquipmentStatus.available,
|
||||
this.purchasePrice,
|
||||
this.rentalPrice,
|
||||
this.totalQuantity,
|
||||
this.availableQuantity,
|
||||
this.criticalThreshold,
|
||||
this.weight,
|
||||
this.length,
|
||||
this.width,
|
||||
this.height,
|
||||
this.purchaseDate,
|
||||
this.lastMaintenanceDate,
|
||||
this.nextMaintenanceDate,
|
||||
this.maintenanceIds = const [],
|
||||
this.imageUrl,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||
DateTime? _parseDate(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gestion des listes
|
||||
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
|
||||
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
|
||||
|
||||
return EquipmentModel(
|
||||
id: id,
|
||||
name: map['name'] ?? '',
|
||||
brand: map['brand'],
|
||||
model: map['model'],
|
||||
category: equipmentCategoryFromString(map['category']),
|
||||
subCategory: map['subCategory'],
|
||||
status: equipmentStatusFromString(map['status']),
|
||||
purchasePrice: map['purchasePrice']?.toDouble(),
|
||||
rentalPrice: map['rentalPrice']?.toDouble(),
|
||||
totalQuantity: map['totalQuantity']?.toInt(),
|
||||
availableQuantity: map['availableQuantity']?.toInt(),
|
||||
criticalThreshold: map['criticalThreshold']?.toInt(),
|
||||
weight: map['weight']?.toDouble(),
|
||||
length: map['length']?.toDouble(),
|
||||
width: map['width']?.toDouble(),
|
||||
height: map['height']?.toDouble(),
|
||||
purchaseDate: _parseDate(map['purchaseDate']),
|
||||
nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']),
|
||||
maintenanceIds: maintenanceIds,
|
||||
imageUrl: map['imageUrl'],
|
||||
notes: map['notes'],
|
||||
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'brand': brand,
|
||||
'model': model,
|
||||
'category': equipmentCategoryToString(category),
|
||||
'subCategory': subCategory,
|
||||
'status': equipmentStatusToString(status),
|
||||
'purchasePrice': purchasePrice,
|
||||
'rentalPrice': rentalPrice,
|
||||
'totalQuantity': totalQuantity,
|
||||
'availableQuantity': availableQuantity,
|
||||
'criticalThreshold': criticalThreshold,
|
||||
'weight': weight,
|
||||
'length': length,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'lastMaintenanceDate': lastMaintenanceDate != null ? Timestamp.fromDate(lastMaintenanceDate!) : null,
|
||||
'purchaseDate': purchaseDate != null ? Timestamp.fromDate(purchaseDate!) : null,
|
||||
'nextMaintenanceDate': nextMaintenanceDate != null ? Timestamp.fromDate(nextMaintenanceDate!) : null,
|
||||
'maintenanceIds': maintenanceIds,
|
||||
'imageUrl': imageUrl,
|
||||
'notes': notes,
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
EquipmentModel copyWith({
|
||||
String? id,
|
||||
String? brand,
|
||||
String? name,
|
||||
String? model,
|
||||
EquipmentCategory? category,
|
||||
String? subCategory,
|
||||
EquipmentStatus? status,
|
||||
double? purchasePrice,
|
||||
double? rentalPrice,
|
||||
int? totalQuantity,
|
||||
int? availableQuantity,
|
||||
int? criticalThreshold,
|
||||
double? weight,
|
||||
double? length,
|
||||
double? width,
|
||||
double? height,
|
||||
DateTime? purchaseDate,
|
||||
DateTime? lastMaintenanceDate,
|
||||
DateTime? nextMaintenanceDate,
|
||||
List<String>? maintenanceIds,
|
||||
String? imageUrl,
|
||||
String? notes,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return EquipmentModel(
|
||||
id: id ?? this.id,
|
||||
brand: brand ?? this.brand,
|
||||
name: name ?? this.name,
|
||||
model: model ?? this.model,
|
||||
category: category ?? this.category,
|
||||
subCategory: subCategory ?? this.subCategory,
|
||||
status: status ?? this.status,
|
||||
purchasePrice: purchasePrice ?? this.purchasePrice,
|
||||
rentalPrice: rentalPrice ?? this.rentalPrice,
|
||||
totalQuantity: totalQuantity ?? this.totalQuantity,
|
||||
availableQuantity: availableQuantity ?? this.availableQuantity,
|
||||
criticalThreshold: criticalThreshold ?? this.criticalThreshold,
|
||||
weight: weight ?? this.weight,
|
||||
length: length ?? this.length,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
lastMaintenanceDate: lastMaintenanceDate ?? this.lastMaintenanceDate,
|
||||
purchaseDate: purchaseDate ?? this.purchaseDate,
|
||||
nextMaintenanceDate: nextMaintenanceDate ?? this.nextMaintenanceDate,
|
||||
maintenanceIds: maintenanceIds ?? this.maintenanceIds,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
notes: notes ?? this.notes,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper pour vérifier si c'est un consommable/câble avec quantité
|
||||
bool get hasQuantity => category == EquipmentCategory.consumable || category == EquipmentCategory.cable;
|
||||
|
||||
// Helper pour vérifier si le stock est critique
|
||||
bool get isCriticalStock {
|
||||
if (!hasQuantity || criticalThreshold == null || availableQuantity == null) {
|
||||
return false;
|
||||
}
|
||||
return availableQuantity! <= criticalThreshold!;
|
||||
}
|
||||
|
||||
// Helper pour vérifier si la maintenance est à venir
|
||||
bool get isMaintenanceDue {
|
||||
if (nextMaintenanceDate == null) return false;
|
||||
return nextMaintenanceDate!.isBefore(DateTime.now().add(const Duration(days: 7)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
enum EventStatus {
|
||||
confirmed,
|
||||
@@ -14,7 +13,6 @@ String eventStatusToString(EventStatus status) {
|
||||
case EventStatus.canceled:
|
||||
return 'CANCELED';
|
||||
case EventStatus.waitingForApproval:
|
||||
default:
|
||||
return 'WAITING_FOR_APPROVAL';
|
||||
}
|
||||
}
|
||||
@@ -31,6 +29,258 @@ EventStatus eventStatusFromString(String? status) {
|
||||
}
|
||||
}
|
||||
|
||||
enum PreparationStatus {
|
||||
notStarted,
|
||||
inProgress,
|
||||
completed,
|
||||
completedWithMissing
|
||||
}
|
||||
|
||||
String preparationStatusToString(PreparationStatus status) {
|
||||
switch (status) {
|
||||
case PreparationStatus.notStarted:
|
||||
return 'NOT_STARTED';
|
||||
case PreparationStatus.inProgress:
|
||||
return 'IN_PROGRESS';
|
||||
case PreparationStatus.completed:
|
||||
return 'COMPLETED';
|
||||
case PreparationStatus.completedWithMissing:
|
||||
return 'COMPLETED_WITH_MISSING';
|
||||
}
|
||||
}
|
||||
|
||||
PreparationStatus preparationStatusFromString(String? status) {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return PreparationStatus.notStarted;
|
||||
case 'IN_PROGRESS':
|
||||
return PreparationStatus.inProgress;
|
||||
case 'COMPLETED':
|
||||
return PreparationStatus.completed;
|
||||
case 'COMPLETED_WITH_MISSING':
|
||||
return PreparationStatus.completedWithMissing;
|
||||
default:
|
||||
return PreparationStatus.notStarted;
|
||||
}
|
||||
}
|
||||
|
||||
// Statut de chargement (loading)
|
||||
enum LoadingStatus {
|
||||
notStarted,
|
||||
inProgress,
|
||||
completed,
|
||||
completedWithMissing
|
||||
}
|
||||
|
||||
String loadingStatusToString(LoadingStatus status) {
|
||||
switch (status) {
|
||||
case LoadingStatus.notStarted:
|
||||
return 'NOT_STARTED';
|
||||
case LoadingStatus.inProgress:
|
||||
return 'IN_PROGRESS';
|
||||
case LoadingStatus.completed:
|
||||
return 'COMPLETED';
|
||||
case LoadingStatus.completedWithMissing:
|
||||
return 'COMPLETED_WITH_MISSING';
|
||||
}
|
||||
}
|
||||
|
||||
LoadingStatus loadingStatusFromString(String? status) {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return LoadingStatus.notStarted;
|
||||
case 'IN_PROGRESS':
|
||||
return LoadingStatus.inProgress;
|
||||
case 'COMPLETED':
|
||||
return LoadingStatus.completed;
|
||||
case 'COMPLETED_WITH_MISSING':
|
||||
return LoadingStatus.completedWithMissing;
|
||||
default:
|
||||
return LoadingStatus.notStarted;
|
||||
}
|
||||
}
|
||||
|
||||
// Statut de déchargement (unloading)
|
||||
enum UnloadingStatus {
|
||||
notStarted,
|
||||
inProgress,
|
||||
completed,
|
||||
completedWithMissing
|
||||
}
|
||||
|
||||
String unloadingStatusToString(UnloadingStatus status) {
|
||||
switch (status) {
|
||||
case UnloadingStatus.notStarted:
|
||||
return 'NOT_STARTED';
|
||||
case UnloadingStatus.inProgress:
|
||||
return 'IN_PROGRESS';
|
||||
case UnloadingStatus.completed:
|
||||
return 'COMPLETED';
|
||||
case UnloadingStatus.completedWithMissing:
|
||||
return 'COMPLETED_WITH_MISSING';
|
||||
}
|
||||
}
|
||||
|
||||
UnloadingStatus unloadingStatusFromString(String? status) {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return UnloadingStatus.notStarted;
|
||||
case 'IN_PROGRESS':
|
||||
return UnloadingStatus.inProgress;
|
||||
case 'COMPLETED':
|
||||
return UnloadingStatus.completed;
|
||||
case 'COMPLETED_WITH_MISSING':
|
||||
return UnloadingStatus.completedWithMissing;
|
||||
default:
|
||||
return UnloadingStatus.notStarted;
|
||||
}
|
||||
}
|
||||
|
||||
enum ReturnStatus {
|
||||
notStarted,
|
||||
inProgress,
|
||||
completed,
|
||||
completedWithMissing
|
||||
}
|
||||
|
||||
String returnStatusToString(ReturnStatus status) {
|
||||
switch (status) {
|
||||
case ReturnStatus.notStarted:
|
||||
return 'NOT_STARTED';
|
||||
case ReturnStatus.inProgress:
|
||||
return 'IN_PROGRESS';
|
||||
case ReturnStatus.completed:
|
||||
return 'COMPLETED';
|
||||
case ReturnStatus.completedWithMissing:
|
||||
return 'COMPLETED_WITH_MISSING';
|
||||
}
|
||||
}
|
||||
|
||||
ReturnStatus returnStatusFromString(String? status) {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return ReturnStatus.notStarted;
|
||||
case 'IN_PROGRESS':
|
||||
return ReturnStatus.inProgress;
|
||||
case 'COMPLETED':
|
||||
return ReturnStatus.completed;
|
||||
case 'COMPLETED_WITH_MISSING':
|
||||
return ReturnStatus.completedWithMissing;
|
||||
default:
|
||||
return ReturnStatus.notStarted;
|
||||
}
|
||||
}
|
||||
|
||||
class EventEquipment {
|
||||
final String equipmentId; // ID de l'équipement
|
||||
final int quantity; // Quantité initiale assignée
|
||||
final bool isPrepared; // Validé en préparation
|
||||
final bool isLoaded; // Validé au chargement
|
||||
final bool isUnloaded; // Validé au déchargement
|
||||
final bool isReturned; // Validé au retour
|
||||
|
||||
// Tracking des manquants à chaque étape
|
||||
final bool isMissingAtPreparation; // Manquant à la préparation
|
||||
final bool isMissingAtLoading; // Manquant au chargement
|
||||
final bool isMissingAtUnloading; // Manquant au déchargement
|
||||
final bool isMissingAtReturn; // Manquant au retour
|
||||
|
||||
// Quantités réelles à chaque étape (pour les quantifiables)
|
||||
final int? quantityAtPreparation; // Quantité comptée en préparation
|
||||
final int? quantityAtLoading; // Quantité comptée au chargement
|
||||
final int? quantityAtUnloading; // Quantité comptée au déchargement
|
||||
final int? quantityAtReturn; // Quantité retournée
|
||||
|
||||
EventEquipment({
|
||||
required this.equipmentId,
|
||||
this.quantity = 1,
|
||||
this.isPrepared = false,
|
||||
this.isLoaded = false,
|
||||
this.isUnloaded = false,
|
||||
this.isReturned = false,
|
||||
this.isMissingAtPreparation = false,
|
||||
this.isMissingAtLoading = false,
|
||||
this.isMissingAtUnloading = false,
|
||||
this.isMissingAtReturn = false,
|
||||
this.quantityAtPreparation,
|
||||
this.quantityAtLoading,
|
||||
this.quantityAtUnloading,
|
||||
this.quantityAtReturn,
|
||||
});
|
||||
|
||||
factory EventEquipment.fromMap(Map<String, dynamic> map) {
|
||||
return EventEquipment(
|
||||
equipmentId: map['equipmentId'] ?? '',
|
||||
quantity: map['quantity'] ?? 1,
|
||||
isPrepared: map['isPrepared'] ?? false,
|
||||
isLoaded: map['isLoaded'] ?? false,
|
||||
isUnloaded: map['isUnloaded'] ?? false,
|
||||
isReturned: map['isReturned'] ?? false,
|
||||
isMissingAtPreparation: map['isMissingAtPreparation'] ?? false,
|
||||
isMissingAtLoading: map['isMissingAtLoading'] ?? false,
|
||||
isMissingAtUnloading: map['isMissingAtUnloading'] ?? false,
|
||||
isMissingAtReturn: map['isMissingAtReturn'] ?? false,
|
||||
quantityAtPreparation: map['quantityAtPreparation'],
|
||||
quantityAtLoading: map['quantityAtLoading'],
|
||||
quantityAtUnloading: map['quantityAtUnloading'],
|
||||
quantityAtReturn: map['quantityAtReturn'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'equipmentId': equipmentId,
|
||||
'quantity': quantity,
|
||||
'isPrepared': isPrepared,
|
||||
'isLoaded': isLoaded,
|
||||
'isUnloaded': isUnloaded,
|
||||
'isReturned': isReturned,
|
||||
'isMissingAtPreparation': isMissingAtPreparation,
|
||||
'isMissingAtLoading': isMissingAtLoading,
|
||||
'isMissingAtUnloading': isMissingAtUnloading,
|
||||
'isMissingAtReturn': isMissingAtReturn,
|
||||
'quantityAtPreparation': quantityAtPreparation,
|
||||
'quantityAtLoading': quantityAtLoading,
|
||||
'quantityAtUnloading': quantityAtUnloading,
|
||||
'quantityAtReturn': quantityAtReturn,
|
||||
};
|
||||
}
|
||||
|
||||
EventEquipment copyWith({
|
||||
String? equipmentId,
|
||||
int? quantity,
|
||||
bool? isPrepared,
|
||||
bool? isLoaded,
|
||||
bool? isUnloaded,
|
||||
bool? isReturned,
|
||||
bool? isMissingAtPreparation,
|
||||
bool? isMissingAtLoading,
|
||||
bool? isMissingAtUnloading,
|
||||
bool? isMissingAtReturn,
|
||||
int? quantityAtPreparation,
|
||||
int? quantityAtLoading,
|
||||
int? quantityAtUnloading,
|
||||
int? quantityAtReturn,
|
||||
}) {
|
||||
return EventEquipment(
|
||||
equipmentId: equipmentId ?? this.equipmentId,
|
||||
quantity: quantity ?? this.quantity,
|
||||
isPrepared: isPrepared ?? this.isPrepared,
|
||||
isLoaded: isLoaded ?? this.isLoaded,
|
||||
isUnloaded: isUnloaded ?? this.isUnloaded,
|
||||
isReturned: isReturned ?? this.isReturned,
|
||||
isMissingAtPreparation: isMissingAtPreparation ?? this.isMissingAtPreparation,
|
||||
isMissingAtLoading: isMissingAtLoading ?? this.isMissingAtLoading,
|
||||
isMissingAtUnloading: isMissingAtUnloading ?? this.isMissingAtUnloading,
|
||||
isMissingAtReturn: isMissingAtReturn ?? this.isMissingAtReturn,
|
||||
quantityAtPreparation: quantityAtPreparation ?? this.quantityAtPreparation,
|
||||
quantityAtLoading: quantityAtLoading ?? this.quantityAtLoading,
|
||||
quantityAtUnloading: quantityAtUnloading ?? this.quantityAtUnloading,
|
||||
quantityAtReturn: quantityAtReturn ?? this.quantityAtReturn,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EventModel {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -41,15 +291,29 @@ class EventModel {
|
||||
final int installationTime;
|
||||
final int disassemblyTime;
|
||||
final String eventTypeId;
|
||||
final DocumentReference? eventTypeRef;
|
||||
final String customerId;
|
||||
final String address;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final List<DocumentReference> workforce;
|
||||
final List<dynamic> workforce; // Peut contenir DocumentReference OU String (UIDs)
|
||||
final List<Map<String, String>> documents;
|
||||
final List<Map<String, dynamic>> options;
|
||||
final EventStatus status;
|
||||
|
||||
// Champs de contact
|
||||
final int? jauge;
|
||||
final String? contactEmail;
|
||||
final String? contactPhone;
|
||||
|
||||
// Nouveaux champs pour la gestion du matériel
|
||||
final List<EventEquipment> assignedEquipment;
|
||||
final List<String> assignedContainers; // IDs des conteneurs assignés
|
||||
final PreparationStatus? preparationStatus;
|
||||
final LoadingStatus? loadingStatus;
|
||||
final UnloadingStatus? unloadingStatus;
|
||||
final ReturnStatus? returnStatus;
|
||||
|
||||
EventModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -60,6 +324,7 @@ class EventModel {
|
||||
required this.installationTime,
|
||||
required this.disassemblyTime,
|
||||
required this.eventTypeId,
|
||||
this.eventTypeRef,
|
||||
required this.customerId,
|
||||
required this.address,
|
||||
required this.latitude,
|
||||
@@ -68,62 +333,197 @@ class EventModel {
|
||||
required this.documents,
|
||||
this.options = const [],
|
||||
this.status = EventStatus.waitingForApproval,
|
||||
this.jauge,
|
||||
this.contactEmail,
|
||||
this.contactPhone,
|
||||
this.assignedEquipment = const [],
|
||||
this.assignedContainers = const [],
|
||||
this.preparationStatus,
|
||||
this.loadingStatus,
|
||||
this.unloadingStatus,
|
||||
this.returnStatus,
|
||||
});
|
||||
|
||||
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
final List<dynamic> workforceRefs = map['workforce'] ?? [];
|
||||
final Timestamp? startTimestamp = map['StartDateTime'] as Timestamp?;
|
||||
final Timestamp? endTimestamp = map['EndDateTime'] as Timestamp?;
|
||||
try {
|
||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||
DateTime _parseDate(dynamic value, DateTime defaultValue) {
|
||||
if (value == null) return defaultValue;
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Gestion sécurisée des références workforce
|
||||
final List<dynamic> workforceRefs = map['workforce'] ?? [];
|
||||
final List<dynamic> safeWorkforce = [];
|
||||
|
||||
for (var ref in workforceRefs) {
|
||||
if (ref is DocumentReference) {
|
||||
safeWorkforce.add(ref);
|
||||
} else if (ref is String) {
|
||||
// Accepter directement les UIDs (envoyés par le backend)
|
||||
safeWorkforce.add(ref);
|
||||
} else {
|
||||
print('Warning: Invalid workforce reference in event $id: $ref');
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion sécurisée des timestamps avec support ISO string
|
||||
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
|
||||
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
|
||||
|
||||
// Gestion sécurisée des documents
|
||||
final docsRaw = map['documents'] ?? [];
|
||||
final docs = docsRaw is List
|
||||
? docsRaw.map<Map<String, String>>((e) {
|
||||
final List<Map<String, String>> docs = [];
|
||||
|
||||
if (docsRaw is List) {
|
||||
for (var e in docsRaw) {
|
||||
try {
|
||||
if (e is Map) {
|
||||
return Map<String, String>.from(e as Map);
|
||||
docs.add(Map<String, String>.from(e));
|
||||
} else if (e is String) {
|
||||
final fileName = Uri.decodeComponent(
|
||||
e.split('/').last.split('?').first,
|
||||
);
|
||||
return {'name': fileName, 'url': e};
|
||||
} else {
|
||||
return {};
|
||||
docs.add({'name': fileName, 'url': e});
|
||||
}
|
||||
}).toList()
|
||||
: <Map<String, String>>[];
|
||||
} catch (docError) {
|
||||
print('Warning: Failed to parse document in event $id: $docError');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion sécurisée des options
|
||||
final optionsRaw = map['options'] ?? [];
|
||||
final options = optionsRaw is List
|
||||
? optionsRaw.map<Map<String, dynamic>>((e) {
|
||||
final List<Map<String, dynamic>> options = [];
|
||||
|
||||
if (optionsRaw is List) {
|
||||
for (var e in optionsRaw) {
|
||||
try {
|
||||
if (e is Map) {
|
||||
return Map<String, dynamic>.from(e as Map);
|
||||
} else {
|
||||
return {};
|
||||
options.add(Map<String, dynamic>.from(e));
|
||||
}
|
||||
}).toList()
|
||||
: <Map<String, dynamic>>[];
|
||||
} catch (optionError) {
|
||||
print('Warning: Failed to parse option in event $id: $optionError');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion sécurisée de l'EventType
|
||||
String eventTypeId = '';
|
||||
DocumentReference? eventTypeRef;
|
||||
|
||||
if (map['EventType'] is DocumentReference) {
|
||||
eventTypeRef = map['EventType'] as DocumentReference;
|
||||
eventTypeId = eventTypeRef.id;
|
||||
} else if (map['EventType'] is String) {
|
||||
final eventTypeString = map['EventType'] as String;
|
||||
// Si c'est un path (ex: "eventTypes/Mariage"), extraire juste l'ID
|
||||
if (eventTypeString.contains('/')) {
|
||||
eventTypeId = eventTypeString.split('/').last;
|
||||
} else {
|
||||
eventTypeId = eventTypeString;
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion sécurisée du customer
|
||||
String customerId = '';
|
||||
if (map['customer'] is DocumentReference) {
|
||||
customerId = (map['customer'] as DocumentReference).id;
|
||||
} else if (map['customer'] is String) {
|
||||
final customerString = map['customer'] as String;
|
||||
// Si c'est un path (ex: "clients/abc123"), extraire juste l'ID
|
||||
if (customerString.contains('/')) {
|
||||
customerId = customerString.split('/').last;
|
||||
} else {
|
||||
customerId = customerString;
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion des équipements assignés
|
||||
final assignedEquipmentRaw = map['assignedEquipment'] ?? [];
|
||||
final List<EventEquipment> assignedEquipment = [];
|
||||
|
||||
if (assignedEquipmentRaw is List) {
|
||||
for (var e in assignedEquipmentRaw) {
|
||||
try {
|
||||
if (e is Map) {
|
||||
assignedEquipment.add(EventEquipment.fromMap(Map<String, dynamic>.from(e)));
|
||||
}
|
||||
} catch (equipmentError) {
|
||||
print('Warning: Failed to parse equipment in event $id: $equipmentError');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion des conteneurs assignés
|
||||
final assignedContainersRaw = map['assignedContainers'] ?? [];
|
||||
final List<String> assignedContainers = [];
|
||||
|
||||
if (assignedContainersRaw is List) {
|
||||
for (var e in assignedContainersRaw) {
|
||||
if (e is String) {
|
||||
assignedContainers.add(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return EventModel(
|
||||
id: id,
|
||||
name: map['Name'] ?? '',
|
||||
description: map['Description'] ?? '',
|
||||
startDateTime: startTimestamp?.toDate() ?? DateTime.now(),
|
||||
endDateTime: endTimestamp?.toDate() ??
|
||||
DateTime.now().add(const Duration(hours: 1)),
|
||||
basePrice: (map['BasePrice'] ?? map['Price'] ?? 0.0).toDouble(),
|
||||
installationTime: map['InstallationTime'] ?? 0,
|
||||
disassemblyTime: map['DisassemblyTime'] ?? 0,
|
||||
eventTypeId: map['EventType'] is DocumentReference
|
||||
? (map['EventType'] as DocumentReference).id
|
||||
: '',
|
||||
customerId: map['customer'] is DocumentReference
|
||||
? (map['customer'] as DocumentReference).id
|
||||
: '',
|
||||
address: map['Address'] ?? '',
|
||||
latitude: (map['Latitude'] ?? 0.0).toDouble(),
|
||||
longitude: (map['Longitude'] ?? 0.0).toDouble(),
|
||||
workforce: workforceRefs.whereType<DocumentReference>().toList(),
|
||||
name: (map['Name'] ?? '').toString().trim(),
|
||||
description: (map['Description'] ?? '').toString(),
|
||||
startDateTime: startDate,
|
||||
endDateTime: endDate,
|
||||
basePrice: _parseDouble(map['BasePrice'] ?? map['Price'] ?? 0.0),
|
||||
installationTime: _parseInt(map['InstallationTime'] ?? 0),
|
||||
assignedContainers: assignedContainers,
|
||||
disassemblyTime: _parseInt(map['DisassemblyTime'] ?? 0),
|
||||
eventTypeId: eventTypeId,
|
||||
eventTypeRef: eventTypeRef,
|
||||
customerId: customerId,
|
||||
address: (map['Address'] ?? '').toString(),
|
||||
latitude: _parseDouble(map['Latitude'] ?? 0.0),
|
||||
longitude: _parseDouble(map['Longitude'] ?? 0.0),
|
||||
workforce: safeWorkforce,
|
||||
documents: docs,
|
||||
options: options,
|
||||
status: eventStatusFromString(map['status'] as String?),
|
||||
jauge: map['jauge'] != null ? _parseInt(map['jauge']) : null,
|
||||
contactEmail: map['contactEmail']?.toString(),
|
||||
contactPhone: map['contactPhone']?.toString(),
|
||||
assignedEquipment: assignedEquipment,
|
||||
preparationStatus: preparationStatusFromString(map['preparationStatus'] as String?),
|
||||
loadingStatus: loadingStatusFromString(map['loadingStatus'] as String?),
|
||||
unloadingStatus: unloadingStatusFromString(map['unloadingStatus'] as String?),
|
||||
returnStatus: returnStatusFromString(map['returnStatus'] as String?),
|
||||
);
|
||||
} catch (e) {
|
||||
print('Error parsing event $id: $e');
|
||||
print('Event data: $map');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Méthodes utilitaires pour le parsing sécurisé
|
||||
static double _parseDouble(dynamic value) {
|
||||
if (value is double) return value;
|
||||
if (value is int) return value.toDouble();
|
||||
if (value is String) {
|
||||
final parsed = double.tryParse(value);
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
static int _parseInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is double) return value.toInt();
|
||||
if (value is String) {
|
||||
final parsed = int.tryParse(value);
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
@@ -135,8 +535,10 @@ class EventModel {
|
||||
'BasePrice': basePrice,
|
||||
'InstallationTime': installationTime,
|
||||
'DisassemblyTime': disassemblyTime,
|
||||
'EventType': eventTypeId,
|
||||
'customer': customerId,
|
||||
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||
'EventType': eventTypeId.isNotEmpty ? eventTypeId : null,
|
||||
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||
'customer': customerId.isNotEmpty ? customerId : null,
|
||||
'Address': address,
|
||||
'Position': GeoPoint(latitude, longitude),
|
||||
'Latitude': latitude,
|
||||
@@ -145,6 +547,75 @@ class EventModel {
|
||||
'documents': documents,
|
||||
'options': options,
|
||||
'status': eventStatusToString(status),
|
||||
'jauge': jauge,
|
||||
'contactEmail': contactEmail,
|
||||
'contactPhone': contactPhone,
|
||||
'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(),
|
||||
'assignedContainers': assignedContainers,
|
||||
'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null,
|
||||
'loadingStatus': loadingStatus != null ? loadingStatusToString(loadingStatus!) : null,
|
||||
'unloadingStatus': unloadingStatus != null ? unloadingStatusToString(unloadingStatus!) : null,
|
||||
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
|
||||
};
|
||||
}
|
||||
|
||||
EventModel copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? description,
|
||||
DateTime? startDateTime,
|
||||
DateTime? endDateTime,
|
||||
double? basePrice,
|
||||
int? installationTime,
|
||||
int? disassemblyTime,
|
||||
String? eventTypeId,
|
||||
DocumentReference? eventTypeRef,
|
||||
String? customerId,
|
||||
String? address,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
List<dynamic>? workforce,
|
||||
List<Map<String, String>>? documents,
|
||||
List<Map<String, dynamic>>? options,
|
||||
EventStatus? status,
|
||||
int? jauge,
|
||||
String? contactEmail,
|
||||
String? contactPhone,
|
||||
List<EventEquipment>? assignedEquipment,
|
||||
List<String>? assignedContainers,
|
||||
PreparationStatus? preparationStatus,
|
||||
LoadingStatus? loadingStatus,
|
||||
UnloadingStatus? unloadingStatus,
|
||||
ReturnStatus? returnStatus,
|
||||
}) {
|
||||
return EventModel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
startDateTime: startDateTime ?? this.startDateTime,
|
||||
endDateTime: endDateTime ?? this.endDateTime,
|
||||
basePrice: basePrice ?? this.basePrice,
|
||||
installationTime: installationTime ?? this.installationTime,
|
||||
disassemblyTime: disassemblyTime ?? this.disassemblyTime,
|
||||
eventTypeId: eventTypeId ?? this.eventTypeId,
|
||||
eventTypeRef: eventTypeRef ?? this.eventTypeRef,
|
||||
customerId: customerId ?? this.customerId,
|
||||
address: address ?? this.address,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
workforce: workforce ?? this.workforce,
|
||||
documents: documents ?? this.documents,
|
||||
options: options ?? this.options,
|
||||
status: status ?? this.status,
|
||||
jauge: jauge ?? this.jauge,
|
||||
contactEmail: contactEmail ?? this.contactEmail,
|
||||
contactPhone: contactPhone ?? this.contactPhone,
|
||||
assignedEquipment: assignedEquipment ?? this.assignedEquipment,
|
||||
assignedContainers: assignedContainers ?? this.assignedContainers,
|
||||
preparationStatus: preparationStatus ?? this.preparationStatus,
|
||||
loadingStatus: loadingStatus ?? this.loadingStatus,
|
||||
unloadingStatus: unloadingStatus ?? this.unloadingStatus,
|
||||
returnStatus: returnStatus ?? this.returnStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
40
em2rp/lib/models/event_type_model.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class EventTypeModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final double defaultPrice;
|
||||
final DateTime createdAt;
|
||||
|
||||
EventTypeModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.defaultPrice,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory EventTypeModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
// Gérer createdAt qui peut être Timestamp (Firestore) ou String ISO (API)
|
||||
DateTime parseCreatedAt(dynamic value) {
|
||||
if (value == null) return DateTime.now();
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
return EventTypeModel(
|
||||
id: id,
|
||||
name: map['name'] ?? '',
|
||||
defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(),
|
||||
createdAt: parseCreatedAt(map['createdAt']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'defaultPrice': defaultPrice,
|
||||
'createdAt': createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
146
em2rp/lib/models/maintenance_model.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
enum MaintenanceType {
|
||||
preventive, // Préventive
|
||||
corrective, // Corrective
|
||||
inspection // Inspection
|
||||
}
|
||||
|
||||
String maintenanceTypeToString(MaintenanceType type) {
|
||||
switch (type) {
|
||||
case MaintenanceType.preventive:
|
||||
return 'PREVENTIVE';
|
||||
case MaintenanceType.corrective:
|
||||
return 'CORRECTIVE';
|
||||
case MaintenanceType.inspection:
|
||||
return 'INSPECTION';
|
||||
}
|
||||
}
|
||||
|
||||
MaintenanceType maintenanceTypeFromString(String? type) {
|
||||
switch (type) {
|
||||
case 'PREVENTIVE':
|
||||
return MaintenanceType.preventive;
|
||||
case 'CORRECTIVE':
|
||||
return MaintenanceType.corrective;
|
||||
case 'INSPECTION':
|
||||
return MaintenanceType.inspection;
|
||||
default:
|
||||
return MaintenanceType.preventive;
|
||||
}
|
||||
}
|
||||
|
||||
class MaintenanceModel {
|
||||
final String id; // ID aléatoire
|
||||
final List<String> equipmentIds; // IDs des équipements concernés (peut être multiple)
|
||||
final MaintenanceType type; // Type de maintenance
|
||||
final DateTime scheduledDate; // Date planifiée
|
||||
final DateTime? completedDate; // Date de réalisation (null si pas encore effectuée)
|
||||
final String name; // Nom de l'opération
|
||||
final String description; // Description détaillée
|
||||
final String? performedBy; // ID de l'utilisateur qui a effectué la maintenance
|
||||
final double? cost; // Coût de la maintenance
|
||||
final String? notes; // Notes additionnelles
|
||||
final DateTime createdAt; // Date de création
|
||||
final DateTime updatedAt; // Date de mise à jour
|
||||
|
||||
MaintenanceModel({
|
||||
required this.id,
|
||||
required this.equipmentIds,
|
||||
required this.type,
|
||||
required this.scheduledDate,
|
||||
this.completedDate,
|
||||
required this.name,
|
||||
required this.description,
|
||||
this.performedBy,
|
||||
this.cost,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||
DateTime? _parseDate(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gestion de la liste des équipements
|
||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||
|
||||
return MaintenanceModel(
|
||||
id: id,
|
||||
equipmentIds: equipmentIds,
|
||||
type: maintenanceTypeFromString(map['type']),
|
||||
scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(),
|
||||
completedDate: _parseDate(map['completedDate']),
|
||||
name: map['name'] ?? '',
|
||||
description: map['description'] ?? '',
|
||||
performedBy: map['performedBy'],
|
||||
cost: map['cost']?.toDouble(),
|
||||
notes: map['notes'],
|
||||
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'equipmentIds': equipmentIds,
|
||||
'type': maintenanceTypeToString(type),
|
||||
'scheduledDate': Timestamp.fromDate(scheduledDate),
|
||||
'completedDate': completedDate != null ? Timestamp.fromDate(completedDate!) : null,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'performedBy': performedBy,
|
||||
'cost': cost,
|
||||
'notes': notes,
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
MaintenanceModel copyWith({
|
||||
String? id,
|
||||
List<String>? equipmentIds,
|
||||
MaintenanceType? type,
|
||||
DateTime? scheduledDate,
|
||||
DateTime? completedDate,
|
||||
String? name,
|
||||
String? description,
|
||||
String? performedBy,
|
||||
double? cost,
|
||||
String? notes,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return MaintenanceModel(
|
||||
id: id ?? this.id,
|
||||
equipmentIds: equipmentIds ?? this.equipmentIds,
|
||||
type: type ?? this.type,
|
||||
scheduledDate: scheduledDate ?? this.scheduledDate,
|
||||
completedDate: completedDate ?? this.completedDate,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
performedBy: performedBy ?? this.performedBy,
|
||||
cost: cost ?? this.cost,
|
||||
notes: notes ?? this.notes,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper pour vérifier si la maintenance est complétée
|
||||
bool get isCompleted => completedDate != null;
|
||||
|
||||
// Helper pour vérifier si la maintenance est en retard
|
||||
bool get isOverdue {
|
||||
if (isCompleted) return false;
|
||||
return scheduledDate.isBefore(DateTime.now());
|
||||
}
|
||||
}
|
||||
|
||||
88
em2rp/lib/models/notification_preferences_model.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
/// Préférences de notifications pour un utilisateur
|
||||
class NotificationPreferences {
|
||||
final bool emailEnabled; // Recevoir emails
|
||||
final bool pushEnabled; // Recevoir notifications push
|
||||
final bool inAppEnabled; // Recevoir alertes in-app
|
||||
|
||||
// Préférences par type d'alerte
|
||||
final bool eventsNotifications; // Alertes événements
|
||||
final bool maintenanceNotifications; // Alertes maintenance
|
||||
final bool stockNotifications; // Alertes stock
|
||||
final bool equipmentNotifications; // Alertes équipement
|
||||
|
||||
// Token FCM (pour push)
|
||||
final String? fcmToken;
|
||||
|
||||
const NotificationPreferences({
|
||||
this.emailEnabled = true, // ✓ Activé par défaut
|
||||
this.pushEnabled = false,
|
||||
this.inAppEnabled = true,
|
||||
this.eventsNotifications = true,
|
||||
this.maintenanceNotifications = true,
|
||||
this.stockNotifications = true,
|
||||
this.equipmentNotifications = true,
|
||||
this.fcmToken,
|
||||
});
|
||||
|
||||
/// Valeurs par défaut pour un nouvel utilisateur
|
||||
factory NotificationPreferences.defaults() {
|
||||
return const NotificationPreferences(
|
||||
emailEnabled: true, // ✓ Activé par défaut
|
||||
pushEnabled: false,
|
||||
inAppEnabled: true,
|
||||
eventsNotifications: true,
|
||||
maintenanceNotifications: true,
|
||||
stockNotifications: true,
|
||||
equipmentNotifications: true,
|
||||
);
|
||||
}
|
||||
|
||||
factory NotificationPreferences.fromMap(Map<String, dynamic> map) {
|
||||
return NotificationPreferences(
|
||||
emailEnabled: map['emailEnabled'] ?? true, // ✓ true par défaut
|
||||
pushEnabled: map['pushEnabled'] ?? false,
|
||||
inAppEnabled: map['inAppEnabled'] ?? true,
|
||||
eventsNotifications: map['eventsNotifications'] ?? true,
|
||||
maintenanceNotifications: map['maintenanceNotifications'] ?? true,
|
||||
stockNotifications: map['stockNotifications'] ?? true,
|
||||
equipmentNotifications: map['equipmentNotifications'] ?? true,
|
||||
fcmToken: map['fcmToken'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'emailEnabled': emailEnabled,
|
||||
'pushEnabled': pushEnabled,
|
||||
'inAppEnabled': inAppEnabled,
|
||||
'eventsNotifications': eventsNotifications,
|
||||
'maintenanceNotifications': maintenanceNotifications,
|
||||
'stockNotifications': stockNotifications,
|
||||
'equipmentNotifications': equipmentNotifications,
|
||||
if (fcmToken != null) 'fcmToken': fcmToken,
|
||||
};
|
||||
}
|
||||
|
||||
NotificationPreferences copyWith({
|
||||
bool? emailEnabled,
|
||||
bool? pushEnabled,
|
||||
bool? inAppEnabled,
|
||||
bool? eventsNotifications,
|
||||
bool? maintenanceNotifications,
|
||||
bool? stockNotifications,
|
||||
bool? equipmentNotifications,
|
||||
String? fcmToken,
|
||||
}) {
|
||||
return NotificationPreferences(
|
||||
emailEnabled: emailEnabled ?? this.emailEnabled,
|
||||
pushEnabled: pushEnabled ?? this.pushEnabled,
|
||||
inAppEnabled: inAppEnabled ?? this.inAppEnabled,
|
||||
eventsNotifications: eventsNotifications ?? this.eventsNotifications,
|
||||
maintenanceNotifications: maintenanceNotifications ?? this.maintenanceNotifications,
|
||||
stockNotifications: stockNotifications ?? this.stockNotifications,
|
||||
equipmentNotifications: equipmentNotifications ?? this.equipmentNotifications,
|
||||
fcmToken: fcmToken ?? this.fcmToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,48 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class EventOption {
|
||||
final String id;
|
||||
final String code; // Nouveau champ code
|
||||
final String name;
|
||||
final String details;
|
||||
final double valMin;
|
||||
final double valMax;
|
||||
final List<DocumentReference> eventTypes;
|
||||
final List<String> eventTypes; // Changé de List<DocumentReference> à List<String>
|
||||
final bool isQuantitative; // Indique si l'option peut avoir une quantité
|
||||
|
||||
EventOption({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.details,
|
||||
required this.valMin,
|
||||
required this.valMax,
|
||||
required this.eventTypes,
|
||||
this.isQuantitative = false,
|
||||
});
|
||||
|
||||
factory EventOption.fromMap(Map<String, dynamic> map, String id) {
|
||||
return EventOption(
|
||||
id: id,
|
||||
code: map['code'] ?? id, // Utilise le code ou l'ID en fallback
|
||||
name: map['name'] ?? '',
|
||||
details: map['details'] ?? '',
|
||||
valMin: (map['valMin'] ?? 0.0).toDouble(),
|
||||
valMax: (map['valMax'] ?? 0.0).toDouble(),
|
||||
eventTypes: (map['eventTypes'] as List<dynamic>? ?? [])
|
||||
.whereType<DocumentReference>()
|
||||
.map((e) => e.toString()) // Convertit en String (supporte IDs et références)
|
||||
.toList(),
|
||||
isQuantitative: map['isQuantitative'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'code': code,
|
||||
'name': name,
|
||||
'details': details,
|
||||
'valMin': valMin,
|
||||
'valMax': valMax,
|
||||
'eventTypes': eventTypes,
|
||||
'isQuantitative': isQuantitative,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class RoleModel {
|
||||
final String id;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/notification_preferences_model.dart';
|
||||
|
||||
class UserModel {
|
||||
final String uid;
|
||||
@@ -8,6 +9,7 @@ class UserModel {
|
||||
final String profilePhotoUrl;
|
||||
final String email;
|
||||
final String phoneNumber;
|
||||
final NotificationPreferences? notificationPreferences;
|
||||
|
||||
UserModel({
|
||||
required this.uid,
|
||||
@@ -17,19 +19,39 @@ class UserModel {
|
||||
required this.profilePhotoUrl,
|
||||
required this.email,
|
||||
required this.phoneNumber,
|
||||
this.notificationPreferences,
|
||||
});
|
||||
|
||||
// Convertit une Map (Firestore) en UserModel
|
||||
factory UserModel.fromMap(Map<String, dynamic> data, String uid) {
|
||||
String roleString;
|
||||
final roleField = data['role'];
|
||||
|
||||
if (roleField is String) {
|
||||
// Cas 1 : role est déjà un String (ex: "roles/ADMIN")
|
||||
roleString = roleField;
|
||||
} else if (roleField is DocumentReference) {
|
||||
// Cas 2 : role est une DocumentReference
|
||||
roleString = roleField.id;
|
||||
} else if (roleField is Map) {
|
||||
// Cas 3 : role est un Map sérialisé (ex: {"_path": {"segments": ["roles", "ADMIN"]}})
|
||||
// On extrait le path
|
||||
final pathData = roleField['_path'];
|
||||
if (pathData is Map && pathData['segments'] is List) {
|
||||
final segments = pathData['segments'] as List;
|
||||
if (segments.length >= 2) {
|
||||
roleString = segments[1].toString(); // Ex: "ADMIN"
|
||||
} else {
|
||||
roleString = 'USER';
|
||||
}
|
||||
} else {
|
||||
roleString = 'USER';
|
||||
}
|
||||
} else {
|
||||
// Cas par défaut
|
||||
roleString = 'USER';
|
||||
}
|
||||
|
||||
return UserModel(
|
||||
uid: uid,
|
||||
firstName: data['firstName'] ?? '',
|
||||
@@ -38,6 +60,9 @@ class UserModel {
|
||||
profilePhotoUrl: data['profilePhotoUrl'] ?? '',
|
||||
email: data['email'] ?? '',
|
||||
phoneNumber: data['phoneNumber'] ?? '',
|
||||
notificationPreferences: data['notificationPreferences'] != null
|
||||
? NotificationPreferences.fromMap(data['notificationPreferences'] as Map<String, dynamic>)
|
||||
: NotificationPreferences.defaults(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,10 +71,12 @@ class UserModel {
|
||||
return {
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'role': FirebaseFirestore.instance.collection('roles').doc(role),
|
||||
'role': role, // Envoyer directement le string roleId au lieu de créer une DocumentReference
|
||||
'profilePhotoUrl': profilePhotoUrl,
|
||||
'email': email,
|
||||
'phoneNumber': phoneNumber,
|
||||
if (notificationPreferences != null)
|
||||
'notificationPreferences': notificationPreferences!.toMap(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +87,7 @@ class UserModel {
|
||||
String? profilePhotoUrl,
|
||||
String? email,
|
||||
String? phoneNumber,
|
||||
NotificationPreferences? notificationPreferences,
|
||||
}) {
|
||||
return UserModel(
|
||||
uid: uid, // L'UID ne change pas
|
||||
@@ -69,6 +97,7 @@ class UserModel {
|
||||
profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
|
||||
email: email ?? this.email,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
notificationPreferences: notificationPreferences ?? this.notificationPreferences,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
117
em2rp/lib/providers/alert_provider.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:em2rp/models/alert_model.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
class AlertProvider extends ChangeNotifier {
|
||||
final ApiService _apiService = apiService;
|
||||
|
||||
List<AlertModel> _alerts = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
// Getters
|
||||
List<AlertModel> get alerts => _alerts;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// Nombre d'alertes non lues
|
||||
int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
|
||||
|
||||
/// Alertes non lues uniquement
|
||||
List<AlertModel> get unreadAlerts => _alerts.where((alert) => !alert.isRead).toList();
|
||||
|
||||
/// Alertes de stock critique
|
||||
List<AlertModel> get lowStockAlerts => _alerts.where((alert) => alert.type == AlertType.lowStock).toList();
|
||||
|
||||
/// Alertes de maintenance
|
||||
List<AlertModel> get maintenanceAlerts => _alerts.where((alert) => alert.type == AlertType.maintenanceDue).toList();
|
||||
|
||||
/// Alertes de conflit
|
||||
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
|
||||
|
||||
/// Charger toutes les alertes via Cloud Function
|
||||
Future<void> loadAlerts() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await _apiService.call('getAlerts', {});
|
||||
final alertsData = result['alerts'] as List<dynamic>;
|
||||
|
||||
_alerts = alertsData.map((data) {
|
||||
return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
print('Error loading alerts: $e');
|
||||
_alerts = [];
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Marquer une alerte comme lue via Cloud Function
|
||||
Future<void> markAsRead(String alertId) async {
|
||||
try {
|
||||
await _apiService.call('markAlertAsRead', {'alertId': alertId});
|
||||
|
||||
// Mettre à jour localement
|
||||
final index = _alerts.indexWhere((a) => a.id == alertId);
|
||||
if (index != -1) {
|
||||
_alerts[index] = AlertModel(
|
||||
id: _alerts[index].id,
|
||||
type: _alerts[index].type,
|
||||
message: _alerts[index].message,
|
||||
equipmentId: _alerts[index].equipmentId,
|
||||
isRead: true,
|
||||
createdAt: _alerts[index].createdAt,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error marking alert as read: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprimer une alerte via Cloud Function
|
||||
Future<void> deleteAlert(String alertId) async {
|
||||
try {
|
||||
await _apiService.call('deleteAlert', {'alertId': alertId});
|
||||
|
||||
// Supprimer localement
|
||||
_alerts.removeWhere((a) => a.id == alertId);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error deleting alert: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marquer toutes les alertes comme lues
|
||||
Future<void> markAllAsRead() async {
|
||||
try {
|
||||
final unreadAlertIds = _alerts.where((a) => !a.isRead).map((a) => a.id).toList();
|
||||
|
||||
for (final alertId in unreadAlertIds) {
|
||||
await markAsRead(alertId);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error marking all alerts as read: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprimer toutes les alertes lues via Cloud Function
|
||||
Future<void> deleteReadAlerts() async {
|
||||
try {
|
||||
final readAlertIds = _alerts.where((a) => a.isRead).map((a) => a.id).toList();
|
||||
|
||||
for (final alertId in readAlertIds) {
|
||||
await deleteAlert(alertId);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error deleting read alerts: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
62
em2rp/lib/providers/alert_provider_new.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:em2rp/models/alert_model.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
class AlertProvider extends ChangeNotifier {
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
|
||||
List<AlertModel> _alerts = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
List<AlertModel> get alerts => _alerts;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// Nombre d'alertes non lues
|
||||
int get unreadCount => _alerts.where((a) => !a.isRead).length;
|
||||
|
||||
/// Charger toutes les alertes via l'API
|
||||
Future<void> loadAlerts() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final alertsData = await _dataService.getAlerts();
|
||||
|
||||
_alerts = alertsData.map((data) {
|
||||
return AlertModel.fromMap(data, data['id'] as String);
|
||||
}).toList();
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error loading alerts: $e');
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recharger les alertes
|
||||
Future<void> refresh() async {
|
||||
await loadAlerts();
|
||||
}
|
||||
|
||||
/// Obtenir les alertes non lues
|
||||
List<AlertModel> get unreadAlerts {
|
||||
return _alerts.where((a) => !a.isRead).toList();
|
||||
}
|
||||
|
||||
/// Obtenir les alertes par type
|
||||
List<AlertModel> getByType(AlertType type) {
|
||||
return _alerts.where((a) => a.type == type).toList();
|
||||
}
|
||||
|
||||
/// Obtenir les alertes critiques (stock bas, équipement perdu)
|
||||
List<AlertModel> get criticalAlerts {
|
||||
return _alerts.where((a) =>
|
||||
a.type == AlertType.lowStock || a.type == AlertType.lost
|
||||
).toList();
|
||||
}
|
||||
}
|
||||
|
||||
452
em2rp/lib/providers/container_provider.dart
Normal file
@@ -0,0 +1,452 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:async';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/services/container_service.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
|
||||
class ContainerProvider with ChangeNotifier {
|
||||
final ContainerService _containerService = ContainerService();
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
|
||||
// Timer pour le debouncing de la recherche
|
||||
Timer? _searchDebounceTimer;
|
||||
|
||||
// Liste paginée pour la page de gestion
|
||||
List<ContainerModel> _paginatedContainers = [];
|
||||
bool _hasMore = true;
|
||||
bool _isLoadingMore = false;
|
||||
String? _lastVisible;
|
||||
|
||||
// Cache complet pour compatibilité
|
||||
List<ContainerModel> _containers = [];
|
||||
|
||||
// Filtres et recherche
|
||||
ContainerType? _selectedType;
|
||||
EquipmentStatus? _selectedStatus;
|
||||
String _searchQuery = '';
|
||||
bool _isLoading = false;
|
||||
bool _isInitialized = false;
|
||||
|
||||
// Mode de chargement (pagination vs full)
|
||||
bool _usePagination = false;
|
||||
|
||||
// Getters
|
||||
List<ContainerModel> get containers => _usePagination ? _paginatedContainers : _containers;
|
||||
ContainerType? get selectedType => _selectedType;
|
||||
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||
String get searchQuery => _searchQuery;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoadingMore => _isLoadingMore;
|
||||
bool get hasMore => _hasMore;
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get usePagination => _usePagination;
|
||||
|
||||
/// S'assure que les conteneurs sont chargés (charge si nécessaire)
|
||||
Future<void> ensureLoaded() async {
|
||||
if (_isInitialized || _isLoading) {
|
||||
return;
|
||||
}
|
||||
await loadContainers();
|
||||
}
|
||||
|
||||
/// Charger tous les containers via l'API (avec pagination automatique)
|
||||
Future<void> loadContainers() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_containers.clear();
|
||||
String? lastVisible;
|
||||
bool hasMore = true;
|
||||
int pageCount = 0;
|
||||
|
||||
// Charger toutes les pages en boucle
|
||||
while (hasMore) {
|
||||
pageCount++;
|
||||
print('[ContainerProvider] Loading page $pageCount...');
|
||||
|
||||
final result = await _dataService.getContainersPaginated(
|
||||
limit: 100, // Charger 100 par page pour aller plus vite
|
||||
startAfter: lastVisible,
|
||||
sortBy: 'id',
|
||||
sortOrder: 'asc',
|
||||
type: _selectedType?.name,
|
||||
status: _selectedStatus?.name,
|
||||
searchQuery: _searchQuery,
|
||||
);
|
||||
|
||||
final containers = (result['containers'] as List<dynamic>)
|
||||
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
|
||||
.toList();
|
||||
|
||||
_containers.addAll(containers);
|
||||
hasMore = result['hasMore'] as bool? ?? false;
|
||||
lastVisible = result['lastVisible'] as String?;
|
||||
|
||||
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error loading containers: $e');
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer les containers avec filtres appliqués
|
||||
Future<List<ContainerModel>> getContainers() async {
|
||||
return await _containerService.getContainers(
|
||||
type: _selectedType,
|
||||
status: _selectedStatus,
|
||||
searchQuery: _searchQuery,
|
||||
);
|
||||
}
|
||||
|
||||
/// Stream des containers - retourne un stream depuis les données en cache
|
||||
/// Pour compatibilité avec les widgets existants qui utilisent StreamBuilder
|
||||
Stream<List<ContainerModel>> get containersStream async* {
|
||||
// Si les données ne sont pas chargées, charger d'abord
|
||||
if (!_isInitialized) {
|
||||
await loadContainers();
|
||||
}
|
||||
|
||||
// Émettre les données actuelles
|
||||
yield _containers;
|
||||
|
||||
// Continuer à émettre les mises à jour du cache
|
||||
// Note: Pour un vrai temps réel, il faudrait implémenter un StreamController
|
||||
// et notifier quand les données changent
|
||||
}
|
||||
|
||||
/// Définir le type sélectionné
|
||||
void setSelectedType(ContainerType? type) async {
|
||||
if (_selectedType == type) return;
|
||||
_selectedType = type;
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Définir le statut sélectionné
|
||||
void setSelectedStatus(EquipmentStatus? status) async {
|
||||
if (_selectedStatus == status) return;
|
||||
_selectedStatus = status;
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Définir la requête de recherche (avec debouncing)
|
||||
void setSearchQuery(String query) {
|
||||
if (_searchQuery == query) return;
|
||||
_searchQuery = query;
|
||||
|
||||
// Annuler le timer précédent
|
||||
_searchDebounceTimer?.cancel();
|
||||
|
||||
if (_usePagination) {
|
||||
// Attendre 500ms avant de recharger (debouncing)
|
||||
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||
reload();
|
||||
});
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PAGINATION - Nouvelles méthodes
|
||||
// ============================================================================
|
||||
|
||||
/// Active le mode pagination (pour la page de gestion)
|
||||
void enablePagination() {
|
||||
if (!_usePagination) {
|
||||
_usePagination = true;
|
||||
DebugLog.info('[ContainerProvider] Pagination mode enabled');
|
||||
}
|
||||
}
|
||||
|
||||
/// Désactive le mode pagination (pour les autres pages)
|
||||
void disablePagination() {
|
||||
if (_usePagination) {
|
||||
_usePagination = false;
|
||||
DebugLog.info('[ContainerProvider] Pagination mode disabled');
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge la première page (réinitialise tout)
|
||||
Future<void> loadFirstPage() async {
|
||||
DebugLog.info('[ContainerProvider] Loading first page...');
|
||||
|
||||
_paginatedContainers.clear();
|
||||
_lastVisible = null;
|
||||
_hasMore = true;
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await loadNextPage();
|
||||
_isInitialized = true;
|
||||
} catch (e) {
|
||||
DebugLog.error('[ContainerProvider] Error loading first page', e);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge la page suivante (scroll infini)
|
||||
Future<void> loadNextPage() async {
|
||||
if (_isLoadingMore || !_hasMore) {
|
||||
DebugLog.info('[ContainerProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
|
||||
return;
|
||||
}
|
||||
|
||||
DebugLog.info('[ContainerProvider] Loading next page... (current: ${_paginatedContainers.length})');
|
||||
|
||||
_isLoadingMore = true;
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await _dataService.getContainersPaginated(
|
||||
limit: 20,
|
||||
startAfter: _lastVisible,
|
||||
type: _selectedType != null ? containerTypeToString(_selectedType!) : null,
|
||||
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||
sortBy: 'id',
|
||||
sortOrder: 'asc',
|
||||
);
|
||||
|
||||
final newContainers = (result['containers'] as List<dynamic>)
|
||||
.map((data) {
|
||||
final map = data as Map<String, dynamic>;
|
||||
return ContainerModel.fromMap(map, map['id'] as String);
|
||||
})
|
||||
.toList();
|
||||
|
||||
_paginatedContainers.addAll(newContainers);
|
||||
_hasMore = result['hasMore'] as bool? ?? false;
|
||||
_lastVisible = result['lastVisible'] as String?;
|
||||
|
||||
DebugLog.info('[ContainerProvider] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMore');
|
||||
|
||||
_isLoadingMore = false;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
DebugLog.error('[ContainerProvider] Error loading next page', e);
|
||||
_isLoadingMore = false;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recharge en changeant de filtre ou recherche
|
||||
Future<void> reload() async {
|
||||
DebugLog.info('[ContainerProvider] Reloading with new filters...');
|
||||
await loadFirstPage();
|
||||
}
|
||||
|
||||
/// Créer un nouveau container
|
||||
Future<void> createContainer(ContainerModel container) async {
|
||||
await _containerService.createContainer(container);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Mettre à jour un container
|
||||
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
|
||||
await _containerService.updateContainer(id, data);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Supprimer un container
|
||||
Future<void> deleteContainer(String id) async {
|
||||
await _containerService.deleteContainer(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Récupérer un container par ID
|
||||
Future<ContainerModel?> getContainerById(String id) async {
|
||||
return await _containerService.getContainerById(id);
|
||||
}
|
||||
|
||||
/// Charge plusieurs conteneurs par leurs IDs (optimisé pour les détails d'événement)
|
||||
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
|
||||
if (containerIds.isEmpty) return [];
|
||||
|
||||
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
|
||||
|
||||
try {
|
||||
// Vérifier d'abord le cache local
|
||||
final cachedContainers = <ContainerModel>[];
|
||||
final missingIds = <String>[];
|
||||
|
||||
for (final id in containerIds) {
|
||||
final cached = _containers.firstWhere(
|
||||
(c) => c.id == id,
|
||||
orElse: () => ContainerModel(
|
||||
id: '',
|
||||
name: '',
|
||||
type: ContainerType.flightCase,
|
||||
status: EquipmentStatus.available,
|
||||
equipmentIds: [],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (cached.id.isNotEmpty) {
|
||||
cachedContainers.add(cached);
|
||||
} else {
|
||||
missingIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
|
||||
|
||||
// Si tous sont en cache, retourner directement
|
||||
if (missingIds.isEmpty) {
|
||||
return cachedContainers;
|
||||
}
|
||||
|
||||
// Charger les manquants depuis l'API
|
||||
final containersData = await _dataService.getContainersByIds(missingIds);
|
||||
|
||||
final loadedContainers = containersData.map((data) {
|
||||
return ContainerModel.fromMap(data, data['id'] as String);
|
||||
}).toList();
|
||||
|
||||
// Ajouter au cache
|
||||
for (final container in loadedContainers) {
|
||||
if (!_containers.any((c) => c.id == container.id)) {
|
||||
_containers.add(container);
|
||||
}
|
||||
}
|
||||
|
||||
print('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
|
||||
|
||||
// Retourner tous les conteneurs (cache + chargés)
|
||||
return [...cachedContainers, ...loadedContainers];
|
||||
} catch (e) {
|
||||
print('[ContainerProvider] Error loading containers by IDs: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajouter un équipement à un container
|
||||
Future<Map<String, dynamic>> addEquipmentToContainer({
|
||||
required String containerId,
|
||||
required String equipmentId,
|
||||
String? userId,
|
||||
}) async {
|
||||
final result = await _containerService.addEquipmentToContainer(
|
||||
containerId: containerId,
|
||||
equipmentId: equipmentId,
|
||||
userId: userId,
|
||||
);
|
||||
notifyListeners();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Retirer un équipement d'un container
|
||||
Future<void> removeEquipmentFromContainer({
|
||||
required String containerId,
|
||||
required String equipmentId,
|
||||
String? userId,
|
||||
}) async {
|
||||
await _containerService.removeEquipmentFromContainer(
|
||||
containerId: containerId,
|
||||
equipmentId: equipmentId,
|
||||
userId: userId,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Vérifier la disponibilité d'un container
|
||||
Future<Map<String, dynamic>> checkContainerAvailability({
|
||||
required String containerId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
return await _containerService.checkContainerAvailability(
|
||||
containerId: containerId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Récupérer les équipements d'un container
|
||||
Future<List<EquipmentModel>> getContainerEquipment(String containerId) async {
|
||||
return await _containerService.getContainerEquipment(containerId);
|
||||
}
|
||||
|
||||
/// Trouver tous les containers contenant un équipement
|
||||
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
|
||||
return await _containerService.findContainersWithEquipment(equipmentId);
|
||||
}
|
||||
|
||||
/// Vérifier si un ID existe
|
||||
Future<bool> checkContainerIdExists(String id) async {
|
||||
return await _containerService.checkContainerIdExists(id);
|
||||
}
|
||||
|
||||
/// Générer un ID unique pour un container
|
||||
/// Format: BOX_{TYPE}_{NAME}_{NUMBER}
|
||||
static String generateContainerId({
|
||||
required ContainerType type,
|
||||
required String name,
|
||||
int? number,
|
||||
}) {
|
||||
// Obtenir le type en majuscules
|
||||
final typeStr = containerTypeToString(type);
|
||||
|
||||
// Nettoyer le nom (enlever espaces, caractères spéciaux)
|
||||
final cleanName = name
|
||||
.replaceAll(' ', '_')
|
||||
.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '')
|
||||
.toUpperCase();
|
||||
|
||||
if (number != null) {
|
||||
return 'BOX_${typeStr}_${cleanName}_#$number';
|
||||
}
|
||||
|
||||
return 'BOX_${typeStr}_$cleanName';
|
||||
}
|
||||
|
||||
/// Assurer l'unicité d'un ID de container
|
||||
static Future<String> ensureUniqueContainerId(
|
||||
String baseId,
|
||||
ContainerService service,
|
||||
) async {
|
||||
String uniqueId = baseId;
|
||||
int counter = 1;
|
||||
|
||||
while (await service.checkContainerIdExists(uniqueId)) {
|
||||
uniqueId = '${baseId}_$counter';
|
||||
counter++;
|
||||
}
|
||||
|
||||
return uniqueId;
|
||||
}
|
||||
}
|
||||
|
||||
533
em2rp/lib/providers/equipment_provider.dart
Normal file
@@ -0,0 +1,533 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:async';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
|
||||
class EquipmentProvider extends ChangeNotifier {
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
|
||||
// Timer pour le debouncing de la recherche
|
||||
Timer? _searchDebounceTimer;
|
||||
|
||||
// Liste paginée pour la page de gestion
|
||||
List<EquipmentModel> _paginatedEquipment = [];
|
||||
bool _hasMore = true;
|
||||
bool _isLoadingMore = false;
|
||||
String? _lastVisible;
|
||||
|
||||
// Cache complet pour getEquipmentsByIds et compatibilité
|
||||
List<EquipmentModel> _equipment = [];
|
||||
List<String> _models = [];
|
||||
List<String> _brands = [];
|
||||
|
||||
// Filtres et recherche
|
||||
EquipmentCategory? _selectedCategory;
|
||||
EquipmentStatus? _selectedStatus;
|
||||
String? _selectedModel;
|
||||
String _searchQuery = '';
|
||||
bool _isLoading = false;
|
||||
bool _isInitialized = false;
|
||||
|
||||
// Mode de chargement (pagination vs full)
|
||||
bool _usePagination = false;
|
||||
|
||||
EquipmentProvider();
|
||||
|
||||
// Getters
|
||||
List<EquipmentModel> get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment;
|
||||
List<EquipmentModel> get allEquipment => _equipment;
|
||||
List<String> get models => _models;
|
||||
List<String> get brands => _brands;
|
||||
EquipmentCategory? get selectedCategory => _selectedCategory;
|
||||
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||
String? get selectedModel => _selectedModel;
|
||||
String get searchQuery => _searchQuery;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoadingMore => _isLoadingMore;
|
||||
bool get hasMore => _hasMore;
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get usePagination => _usePagination;
|
||||
|
||||
/// S'assure que les équipements sont chargés (charge si nécessaire)
|
||||
Future<void> ensureLoaded() async {
|
||||
// Si déjà en train de charger, attendre
|
||||
if (_isLoading) {
|
||||
print('[EquipmentProvider] Equipment loading in progress, waiting...');
|
||||
return;
|
||||
}
|
||||
|
||||
// Si initialisé MAIS _equipment est vide, forcer le rechargement
|
||||
if (_isInitialized && _equipment.isEmpty) {
|
||||
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
|
||||
_isInitialized = false; // Réinitialiser le flag
|
||||
await loadEquipments();
|
||||
return;
|
||||
}
|
||||
|
||||
// Si déjà initialisé avec des données, ne rien faire
|
||||
if (_isInitialized) {
|
||||
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
print('[EquipmentProvider] Equipment not loaded, loading now...');
|
||||
await loadEquipments();
|
||||
}
|
||||
|
||||
/// Charger tous les équipements via l'API (utilisé par les dialogs et sélection)
|
||||
Future<void> loadEquipments() async {
|
||||
print('[EquipmentProvider] Starting to load ALL equipments...');
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_equipment.clear();
|
||||
String? lastVisible;
|
||||
bool hasMore = true;
|
||||
int pageCount = 0;
|
||||
|
||||
// Charger toutes les pages en boucle
|
||||
while (hasMore) {
|
||||
pageCount++;
|
||||
print('[EquipmentProvider] Loading page $pageCount...');
|
||||
|
||||
final result = await _dataService.getEquipmentsPaginated(
|
||||
limit: 100, // Charger 100 par page pour aller plus vite
|
||||
startAfter: lastVisible,
|
||||
sortBy: 'id',
|
||||
sortOrder: 'asc',
|
||||
);
|
||||
|
||||
final equipmentsData = result['equipments'] as List<dynamic>;
|
||||
print('[EquipmentProvider] Page $pageCount: ${equipmentsData.length} equipments');
|
||||
|
||||
final pageEquipments = equipmentsData.map((data) {
|
||||
final id = data['id'] as String;
|
||||
return EquipmentModel.fromMap(data as Map<String, dynamic>, id);
|
||||
}).toList();
|
||||
|
||||
_equipment.addAll(pageEquipments);
|
||||
|
||||
hasMore = result['hasMore'] as bool? ?? false;
|
||||
lastVisible = result['lastVisible'] as String?;
|
||||
|
||||
if (!hasMore) {
|
||||
print('[EquipmentProvider] All pages loaded. Total: ${_equipment.length} equipments');
|
||||
}
|
||||
}
|
||||
|
||||
// Extraire les modèles et marques uniques
|
||||
_extractUniqueValues();
|
||||
|
||||
_isInitialized = true;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
print('[EquipmentProvider] Equipment loading complete: ${_equipment.length} equipments');
|
||||
} catch (e) {
|
||||
print('[EquipmentProvider] Error loading equipments: $e');
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge plusieurs équipements par leurs IDs (optimisé pour les détails d'événement)
|
||||
Future<List<EquipmentModel>> getEquipmentsByIds(List<String> equipmentIds) async {
|
||||
if (equipmentIds.isEmpty) return [];
|
||||
|
||||
print('[EquipmentProvider] Loading ${equipmentIds.length} equipments by IDs...');
|
||||
|
||||
try {
|
||||
// Vérifier d'abord le cache local
|
||||
final cachedEquipments = <EquipmentModel>[];
|
||||
final missingIds = <String>[];
|
||||
|
||||
for (final id in equipmentIds) {
|
||||
final cached = _equipment.firstWhere(
|
||||
(eq) => eq.id == id,
|
||||
orElse: () => EquipmentModel(
|
||||
id: '',
|
||||
name: '',
|
||||
category: EquipmentCategory.other,
|
||||
status: EquipmentStatus.available,
|
||||
maintenanceIds: [],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (cached.id.isNotEmpty) {
|
||||
cachedEquipments.add(cached);
|
||||
} else {
|
||||
missingIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
print('[EquipmentProvider] Found ${cachedEquipments.length} in cache, ${missingIds.length} missing');
|
||||
|
||||
// Si tous sont en cache, retourner directement
|
||||
if (missingIds.isEmpty) {
|
||||
return cachedEquipments;
|
||||
}
|
||||
|
||||
// Charger les manquants depuis l'API
|
||||
final equipmentsData = await _dataService.getEquipmentsByIds(missingIds);
|
||||
|
||||
final loadedEquipments = equipmentsData.map((data) {
|
||||
final id = data['id'] as String; // L'ID vient du backend
|
||||
return EquipmentModel.fromMap(data, id);
|
||||
}).toList();
|
||||
|
||||
// Ajouter au cache
|
||||
for (final eq in loadedEquipments) {
|
||||
if (!_equipment.any((e) => e.id == eq.id)) {
|
||||
_equipment.add(eq);
|
||||
}
|
||||
}
|
||||
|
||||
print('[EquipmentProvider] Loaded ${loadedEquipments.length} equipments from API');
|
||||
|
||||
// Retourner tous les équipements (cache + chargés)
|
||||
return [...cachedEquipments, ...loadedEquipments];
|
||||
} catch (e) {
|
||||
print('[EquipmentProvider] Error loading equipments by IDs: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extraire modèles et marques uniques
|
||||
void _extractUniqueValues() {
|
||||
final modelSet = <String>{};
|
||||
final brandSet = <String>{};
|
||||
|
||||
for (final eq in _equipment) {
|
||||
if (eq.model != null && eq.model!.isNotEmpty) {
|
||||
modelSet.add(eq.model!);
|
||||
}
|
||||
if (eq.brand != null && eq.brand!.isNotEmpty) {
|
||||
brandSet.add(eq.brand!);
|
||||
}
|
||||
}
|
||||
|
||||
_models = modelSet.toList()..sort();
|
||||
_brands = brandSet.toList()..sort();
|
||||
}
|
||||
|
||||
/// Obtenir les équipements filtrés
|
||||
List<EquipmentModel> get _filteredEquipment {
|
||||
var filtered = _equipment;
|
||||
|
||||
if (_selectedCategory != null) {
|
||||
filtered = filtered.where((eq) => eq.category == _selectedCategory).toList();
|
||||
}
|
||||
|
||||
if (_selectedStatus != null) {
|
||||
filtered = filtered.where((eq) => eq.status == _selectedStatus).toList();
|
||||
}
|
||||
|
||||
if (_selectedModel != null && _selectedModel!.isNotEmpty) {
|
||||
filtered = filtered.where((eq) => eq.model == _selectedModel).toList();
|
||||
}
|
||||
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final query = _searchQuery.toLowerCase();
|
||||
filtered = filtered.where((eq) {
|
||||
return eq.name.toLowerCase().contains(query) ||
|
||||
eq.id.toLowerCase().contains(query) ||
|
||||
(eq.model?.toLowerCase().contains(query) ?? false) ||
|
||||
(eq.brand?.toLowerCase().contains(query) ?? false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PAGINATION - Nouvelles méthodes
|
||||
// ============================================================================
|
||||
|
||||
/// Active le mode pagination (pour la page de gestion)
|
||||
void enablePagination() {
|
||||
if (!_usePagination) {
|
||||
_usePagination = true;
|
||||
DebugLog.info('[EquipmentProvider] Pagination mode enabled');
|
||||
}
|
||||
}
|
||||
|
||||
/// Désactive le mode pagination (pour les autres pages)
|
||||
void disablePagination() {
|
||||
if (_usePagination) {
|
||||
_usePagination = false;
|
||||
DebugLog.info('[EquipmentProvider] Pagination mode disabled');
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge la première page (réinitialise tout)
|
||||
Future<void> loadFirstPage() async {
|
||||
DebugLog.info('[EquipmentProvider] Loading first page...');
|
||||
|
||||
_paginatedEquipment.clear();
|
||||
_lastVisible = null;
|
||||
_hasMore = true;
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await loadNextPage();
|
||||
_isInitialized = true;
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentProvider] Error loading first page', e);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge la page suivante (scroll infini)
|
||||
Future<void> loadNextPage() async {
|
||||
if (_isLoadingMore || !_hasMore) {
|
||||
DebugLog.info('[EquipmentProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
|
||||
return;
|
||||
}
|
||||
|
||||
DebugLog.info('[EquipmentProvider] Loading next page... (current: ${_paginatedEquipment.length})');
|
||||
|
||||
_isLoadingMore = true;
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await _dataService.getEquipmentsPaginated(
|
||||
limit: 20,
|
||||
startAfter: _lastVisible,
|
||||
category: _selectedCategory?.name,
|
||||
status: _selectedStatus?.name,
|
||||
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||
sortBy: 'id',
|
||||
sortOrder: 'asc',
|
||||
);
|
||||
|
||||
final newEquipments = (result['equipments'] as List<dynamic>)
|
||||
.map((data) {
|
||||
final map = data as Map<String, dynamic>;
|
||||
final id = map['id'] as String; // L'ID vient du backend dans le JSON
|
||||
return EquipmentModel.fromMap(map, id);
|
||||
})
|
||||
.toList();
|
||||
|
||||
_paginatedEquipment.addAll(newEquipments);
|
||||
_hasMore = result['hasMore'] as bool? ?? false;
|
||||
_lastVisible = result['lastVisible'] as String?;
|
||||
|
||||
DebugLog.info('[EquipmentProvider] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipment.length}, hasMore: $_hasMore');
|
||||
|
||||
_isLoadingMore = false;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentProvider] Error loading next page', e);
|
||||
_isLoadingMore = false;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recharge en changeant de filtre ou recherche
|
||||
Future<void> reload() async {
|
||||
DebugLog.info('[EquipmentProvider] Reloading with new filters...');
|
||||
await loadFirstPage();
|
||||
}
|
||||
|
||||
/// Définir le filtre de catégorie
|
||||
void setSelectedCategory(EquipmentCategory? category) async {
|
||||
if (_selectedCategory == category) return;
|
||||
_selectedCategory = category;
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Définir le filtre de statut
|
||||
void setSelectedStatus(EquipmentStatus? status) async {
|
||||
if (_selectedStatus == status) return;
|
||||
_selectedStatus = status;
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Définir le filtre de modèle
|
||||
void setSelectedModel(String? model) async {
|
||||
if (_selectedModel == model) return;
|
||||
_selectedModel = model;
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Définir la requête de recherche (avec debouncing)
|
||||
void setSearchQuery(String query) {
|
||||
if (_searchQuery == query) return;
|
||||
_searchQuery = query;
|
||||
|
||||
// Annuler le timer précédent
|
||||
_searchDebounceTimer?.cancel();
|
||||
|
||||
if (_usePagination) {
|
||||
// Attendre 500ms avant de recharger (debouncing)
|
||||
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||
reload();
|
||||
});
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Réinitialiser tous les filtres
|
||||
void clearFilters() async {
|
||||
_selectedCategory = null;
|
||||
_selectedStatus = null;
|
||||
_selectedModel = null;
|
||||
_searchQuery = '';
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MÉTHODES COMPATIBILITÉ (pour ancien code)
|
||||
// ============================================================================
|
||||
|
||||
/// Recharger les équipements (ancien système)
|
||||
Future<void> refresh() async {
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
await loadEquipments();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream des équipements (pour compatibilité avec ancien code)
|
||||
Stream<List<EquipmentModel>> get equipmentStream async* {
|
||||
if (!_isInitialized && !_usePagination) {
|
||||
await loadEquipments();
|
||||
}
|
||||
yield equipment;
|
||||
}
|
||||
|
||||
/// Supprimer un équipement
|
||||
Future<void> deleteEquipment(String equipmentId) async {
|
||||
try {
|
||||
await _dataService.deleteEquipment(equipmentId);
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
await loadEquipments();
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajouter un équipement
|
||||
Future<void> addEquipment(EquipmentModel equipment) async {
|
||||
try {
|
||||
await _dataService.createEquipment(equipment.id, equipment.toMap());
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
await loadEquipments();
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentProvider] Error adding equipment', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mettre à jour un équipement
|
||||
Future<void> updateEquipment(EquipmentModel equipment) async {
|
||||
try {
|
||||
await _dataService.updateEquipment(equipment.id, equipment.toMap());
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
await loadEquipments();
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentProvider] Error updating equipment', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les marques
|
||||
Future<void> loadBrands() async {
|
||||
await ensureLoaded();
|
||||
_extractUniqueValues();
|
||||
}
|
||||
|
||||
/// Charger les modèles
|
||||
Future<void> loadModels() async {
|
||||
await ensureLoaded();
|
||||
_extractUniqueValues();
|
||||
}
|
||||
|
||||
/// Charger les modèles d'une marque spécifique
|
||||
Future<List<String>> loadModelsByBrand(String brand) async {
|
||||
await ensureLoaded();
|
||||
return _equipment
|
||||
.where((eq) => eq.brand?.toLowerCase() == brand.toLowerCase())
|
||||
.map((eq) => eq.model ?? '')
|
||||
.where((model) => model.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
}
|
||||
|
||||
/// Charger les sous-catégories d'une catégorie spécifique
|
||||
Future<List<String>> loadSubCategoriesByCategory(EquipmentCategory category) async {
|
||||
await ensureLoaded();
|
||||
return _equipment
|
||||
.where((eq) => eq.category == category)
|
||||
.map((eq) => eq.subCategory ?? '')
|
||||
.where((sub) => sub.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
}
|
||||
|
||||
/// Calculer le statut réel d'un équipement (pour badge)
|
||||
EquipmentStatus calculateRealStatus(EquipmentModel equipment) {
|
||||
// Pour les consommables/câbles, vérifier le seuil critique
|
||||
if (equipment.category == EquipmentCategory.consumable ||
|
||||
equipment.category == EquipmentCategory.cable) {
|
||||
final availableQty = equipment.availableQuantity ?? 0;
|
||||
final criticalThreshold = equipment.criticalThreshold ?? 0;
|
||||
|
||||
if (criticalThreshold > 0 && availableQty <= criticalThreshold) {
|
||||
return EquipmentStatus.maintenance; // Utiliser maintenance pour indiquer un problème
|
||||
}
|
||||
}
|
||||
|
||||
// Sinon retourner le statut de base
|
||||
return equipment.status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,59 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/event_model.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
class EventProvider with ChangeNotifier {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
List<EventModel> _events = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
List<EventModel> get events => _events;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
// Récupérer les événements pour un utilisateur spécifique
|
||||
Future<void> loadUserEvents(String userId,
|
||||
{bool canViewAllEvents = false}) async {
|
||||
// Cache des utilisateurs chargés depuis getEvents
|
||||
Map<String, Map<String, dynamic>> _usersCache = {};
|
||||
|
||||
/// Charger les événements d'un utilisateur via l'API
|
||||
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
// Sauvegarder les paramètres
|
||||
_saveLastLoadParams(userId, canViewAllEvents);
|
||||
|
||||
try {
|
||||
print(
|
||||
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||
QuerySnapshot eventsSnapshot;
|
||||
if (canViewAllEvents) {
|
||||
eventsSnapshot = await _firestore.collection('events').get();
|
||||
} else {
|
||||
eventsSnapshot = await _firestore
|
||||
.collection('events')
|
||||
.where('workforce', arrayContains: userId)
|
||||
.get();
|
||||
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||
|
||||
// Charger via l'API - les permissions sont vérifiées côté serveur
|
||||
final result = await _dataService.getEvents(userId: userId);
|
||||
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||
final usersData = result['users'] as Map<String, dynamic>;
|
||||
|
||||
// Stocker les utilisateurs dans le cache
|
||||
_usersCache = usersData.map((key, value) =>
|
||||
MapEntry(key, value as Map<String, dynamic>)
|
||||
);
|
||||
|
||||
print('Found ${eventsData.length} events from API');
|
||||
|
||||
List<EventModel> allEvents = [];
|
||||
int failedCount = 0;
|
||||
|
||||
// Parser chaque événement
|
||||
for (var eventData in eventsData) {
|
||||
try {
|
||||
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||
allEvents.add(event);
|
||||
} catch (e) {
|
||||
print('Failed to parse event ${eventData['id']}: $e');
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
print('Found ${eventsSnapshot.docs.length} events for user');
|
||||
|
||||
_events = eventsSnapshot.docs.map((doc) {
|
||||
print('Event data: ${doc.data()}');
|
||||
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
||||
}).toList();
|
||||
|
||||
print('Parsed ${_events.length} events');
|
||||
_events = allEvents;
|
||||
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -48,37 +65,35 @@ class EventProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer un événement spécifique
|
||||
Future<EventModel?> getEvent(String eventId) async {
|
||||
try {
|
||||
final doc = await _firestore.collection('events').doc(eventId).get();
|
||||
if (doc.exists) {
|
||||
return EventModel.fromMap(doc.data()!, doc.id);
|
||||
/// Recharger les événements (utilise le dernier userId)
|
||||
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
|
||||
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
||||
}
|
||||
return null;
|
||||
|
||||
/// Récupérer un événement spécifique par ID
|
||||
EventModel? getEventById(String eventId) {
|
||||
try {
|
||||
return _events.firstWhere((event) => event.id == eventId);
|
||||
} catch (e) {
|
||||
print('Error getting event: $e');
|
||||
rethrow;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter un nouvel événement
|
||||
/// Ajouter un nouvel événement
|
||||
Future<void> addEvent(EventModel event) async {
|
||||
try {
|
||||
final docRef = await _firestore.collection('events').add(event.toMap());
|
||||
final newEvent = EventModel.fromMap(event.toMap(), docRef.id);
|
||||
_events.add(newEvent);
|
||||
notifyListeners();
|
||||
// L'événement est créé via l'API dans le service
|
||||
await refreshEvents(_lastUserId ?? '', canViewAllEvents: _lastCanViewAll);
|
||||
} catch (e) {
|
||||
print('Error adding event: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour un événement
|
||||
/// Mettre à jour un événement
|
||||
Future<void> updateEvent(EventModel event) async {
|
||||
try {
|
||||
await _firestore.collection('events').doc(event.id).update(event.toMap());
|
||||
// Mise à jour locale immédiate
|
||||
final index = _events.indexWhere((e) => e.id == event.id);
|
||||
if (index != -1) {
|
||||
_events[index] = event;
|
||||
@@ -90,10 +105,10 @@ class EventProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer un événement
|
||||
/// Supprimer un événement
|
||||
Future<void> deleteEvent(String eventId) async {
|
||||
try {
|
||||
await _firestore.collection('events').doc(eventId).delete();
|
||||
await _dataService.deleteEvent(eventId);
|
||||
_events.removeWhere((event) => event.id == eventId);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -102,9 +117,56 @@ class EventProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Vider la liste des événements
|
||||
/// Récupérer les données d'un utilisateur depuis le cache
|
||||
Map<String, dynamic>? getUserFromCache(String uid) {
|
||||
return _usersCache[uid];
|
||||
}
|
||||
|
||||
/// Récupérer les utilisateurs de la workforce d'un événement
|
||||
List<Map<String, dynamic>> getWorkforceUsers(EventModel event) {
|
||||
final users = <Map<String, dynamic>>[];
|
||||
|
||||
for (final dynamic userRef in event.workforce) {
|
||||
try {
|
||||
String? uid;
|
||||
|
||||
// Tenter d'extraire l'UID
|
||||
if (userRef is String) {
|
||||
uid = userRef;
|
||||
} else {
|
||||
// Essayer d'extraire l'ID si c'est une DocumentReference
|
||||
final ref = userRef as DocumentReference?;
|
||||
uid = ref?.id;
|
||||
}
|
||||
|
||||
if (uid != null) {
|
||||
final userData = getUserFromCache(uid);
|
||||
if (userData != null) {
|
||||
users.add(userData);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignorer les références invalides
|
||||
print('Skipping invalid workforce reference: $userRef');
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/// Vider la liste des événements
|
||||
void clearEvents() {
|
||||
_events = [];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Variables pour stocker le dernier appel
|
||||
String? _lastUserId;
|
||||
bool _lastCanViewAll = false;
|
||||
|
||||
/// Sauvegarder les paramètres du dernier chargement
|
||||
void _saveLastLoadParams(String userId, bool canViewAllEvents) {
|
||||
_lastUserId = userId;
|
||||
_lastCanViewAll = canViewAllEvents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../models/user_model.dart';
|
||||
import '../models/role_model.dart';
|
||||
import '../models/notification_preferences_model.dart';
|
||||
import '../utils/firebase_storage_manager.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../services/data_service.dart';
|
||||
|
||||
class LocalUserProvider with ChangeNotifier {
|
||||
UserModel? _currentUser;
|
||||
RoleModel? _currentRole;
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
||||
final DataService _dataService = DataService(apiService);
|
||||
|
||||
UserModel? get currentUser => _currentUser;
|
||||
String? get uid => _currentUser?.uid;
|
||||
@@ -24,7 +26,7 @@ class LocalUserProvider with ChangeNotifier {
|
||||
RoleModel? get currentRole => _currentRole;
|
||||
List<String> get permissions => _currentRole?.permissions ?? [];
|
||||
|
||||
/// Charge les données de l'utilisateur actuel
|
||||
/// Charge les données de l'utilisateur actuel via Cloud Function
|
||||
Future<void> loadUserData() async {
|
||||
if (_auth.currentUser == null) {
|
||||
print('No current user in Auth');
|
||||
@@ -33,53 +35,31 @@ class LocalUserProvider with ChangeNotifier {
|
||||
|
||||
print('Loading user data for: ${_auth.currentUser!.uid}');
|
||||
try {
|
||||
DocumentSnapshot userDoc = await _firestore
|
||||
.collection('users')
|
||||
.doc(_auth.currentUser!.uid)
|
||||
.get();
|
||||
// Utiliser la Cloud Function getCurrentUser
|
||||
final result = await apiService.call('getCurrentUser', {});
|
||||
final userData = result['user'] as Map<String, dynamic>;
|
||||
|
||||
if (userDoc.exists) {
|
||||
print('User document found in Firestore');
|
||||
final userData = userDoc.data() as Map<String, dynamic>;
|
||||
print('User data: $userData');
|
||||
print('User data loaded from API: ${userData['uid']}');
|
||||
|
||||
// Si le document n'a pas d'UID, l'ajouter
|
||||
if (!userData.containsKey('uid')) {
|
||||
await userDoc.reference.update({'uid': _auth.currentUser!.uid});
|
||||
userData['uid'] = _auth.currentUser!.uid;
|
||||
// Extraire le rôle
|
||||
final roleData = userData['role'] as Map<String, dynamic>?;
|
||||
if (roleData != null) {
|
||||
_currentRole = RoleModel.fromMap(roleData, roleData['id'] as String);
|
||||
}
|
||||
|
||||
setUser(UserModel.fromMap(userData, userDoc.id));
|
||||
print('User data loaded successfully');
|
||||
await loadRole();
|
||||
} else {
|
||||
print('No user document found in Firestore');
|
||||
// Créer un document utilisateur par défaut
|
||||
final defaultUser = UserModel(
|
||||
uid: _auth.currentUser!.uid,
|
||||
email: _auth.currentUser!.email ?? '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: 'USER',
|
||||
phoneNumber: '',
|
||||
profilePhotoUrl: '',
|
||||
// Créer le UserModel
|
||||
_currentUser = UserModel(
|
||||
uid: userData['uid'] as String,
|
||||
email: userData['email'] as String? ?? '',
|
||||
firstName: userData['firstName'] as String? ?? '',
|
||||
lastName: userData['lastName'] as String? ?? '',
|
||||
role: roleData?['id'] as String? ?? 'USER',
|
||||
phoneNumber: userData['phoneNumber'] as String? ?? '',
|
||||
profilePhotoUrl: userData['profilePhotoUrl'] as String? ?? '',
|
||||
);
|
||||
|
||||
await _firestore.collection('users').doc(_auth.currentUser!.uid).set({
|
||||
'uid': _auth.currentUser!.uid,
|
||||
'email': _auth.currentUser!.email,
|
||||
'firstName': '',
|
||||
'lastName': '',
|
||||
'role': 'USER',
|
||||
'phoneNumber': '',
|
||||
'profilePhotoUrl': '',
|
||||
'createdAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
setUser(defaultUser);
|
||||
print('Default user document created');
|
||||
await loadRole();
|
||||
}
|
||||
print('User data loaded successfully');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error loading user data: $e');
|
||||
rethrow;
|
||||
@@ -95,28 +75,55 @@ class LocalUserProvider with ChangeNotifier {
|
||||
/// Efface les données utilisateur
|
||||
void clearUser() {
|
||||
_currentUser = null;
|
||||
_currentRole = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Mise à jour des informations utilisateur
|
||||
Future<void> updateUserData(
|
||||
{String? firstName, String? lastName, String? phoneNumber}) async {
|
||||
/// Mise à jour des informations utilisateur via Cloud Function
|
||||
Future<void> updateUserData({
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? phoneNumber,
|
||||
}) async {
|
||||
if (_currentUser == null) return;
|
||||
try {
|
||||
await _firestore.collection('users').doc(_currentUser!.uid).set({
|
||||
await _dataService.updateUser(
|
||||
_currentUser!.uid,
|
||||
{
|
||||
'firstName': firstName ?? _currentUser!.firstName,
|
||||
'lastName': lastName ?? _currentUser!.lastName,
|
||||
'phone': phoneNumber ?? _currentUser!.phoneNumber,
|
||||
}, SetOptions(merge: true));
|
||||
'phoneNumber': phoneNumber ?? _currentUser!.phoneNumber,
|
||||
},
|
||||
);
|
||||
|
||||
_currentUser = _currentUser!.copyWith(
|
||||
firstName: firstName ?? _currentUser!.firstName,
|
||||
lastName: lastName ?? _currentUser!.lastName,
|
||||
phoneNumber: phoneNumber ?? _currentUser!.phoneNumber,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
phoneNumber: phoneNumber,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur mise à jour utilisateur : $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mise à jour des préférences de notifications
|
||||
Future<void> updateNotificationPreferences(NotificationPreferences preferences) async {
|
||||
if (_currentUser == null) return;
|
||||
try {
|
||||
await _dataService.updateUser(
|
||||
_currentUser!.uid,
|
||||
{
|
||||
'notificationPreferences': preferences.toMap(),
|
||||
},
|
||||
);
|
||||
|
||||
_currentUser = _currentUser!.copyWith(notificationPreferences: preferences);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur mise à jour préférences notifications : $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,16 +136,18 @@ class LocalUserProvider with ChangeNotifier {
|
||||
uid: _currentUser!.uid,
|
||||
);
|
||||
if (newProfilePhotoUrl != null) {
|
||||
_firestore
|
||||
.collection('users')
|
||||
.doc(_currentUser!.uid)
|
||||
.update({'profilePhotoUrl': newProfilePhotoUrl});
|
||||
_currentUser =
|
||||
_currentUser!.copyWith(profilePhotoUrl: newProfilePhotoUrl);
|
||||
// Mettre à jour via Cloud Function
|
||||
await _dataService.updateUser(
|
||||
_currentUser!.uid,
|
||||
{'profilePhotoUrl': newProfilePhotoUrl},
|
||||
);
|
||||
|
||||
_currentUser = _currentUser!.copyWith(profilePhotoUrl: newProfilePhotoUrl);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur mise à jour photo de profil : $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,23 +170,20 @@ class LocalUserProvider with ChangeNotifier {
|
||||
clearUser();
|
||||
}
|
||||
|
||||
Future<void> loadRole() async {
|
||||
if (_currentUser == null) return;
|
||||
final roleId = _currentUser!.role;
|
||||
if (roleId.isEmpty) return;
|
||||
try {
|
||||
final doc = await _firestore.collection('roles').doc(roleId).get();
|
||||
if (doc.exists) {
|
||||
_currentRole =
|
||||
RoleModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error loading role: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a une permission spécifique
|
||||
bool hasPermission(String permission) {
|
||||
return _currentRole?.permissions.contains(permission) ?? false;
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a toutes les permissions données
|
||||
bool hasAllPermissions(List<String> permissions) {
|
||||
if (_currentRole == null) return false;
|
||||
return permissions.every((p) => _currentRole!.permissions.contains(p));
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a au moins une des permissions données
|
||||
bool hasAnyPermission(List<String> permissions) {
|
||||
if (_currentRole == null) return false;
|
||||
return permissions.any((p) => _currentRole!.permissions.contains(p));
|
||||
}
|
||||
}
|
||||
|
||||
106
em2rp/lib/providers/maintenance_provider.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:em2rp/models/maintenance_model.dart';
|
||||
import 'package:em2rp/services/maintenance_service.dart';
|
||||
|
||||
class MaintenanceProvider extends ChangeNotifier {
|
||||
final MaintenanceService _service = MaintenanceService();
|
||||
|
||||
List<MaintenanceModel> _maintenances = [];
|
||||
|
||||
// Getters
|
||||
List<MaintenanceModel> get maintenances => _maintenances;
|
||||
|
||||
/// Récupérer les maintenances pour un équipement spécifique
|
||||
Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async {
|
||||
return await _service.getMaintenancesByEquipment(equipmentId);
|
||||
}
|
||||
|
||||
/// Récupérer toutes les maintenances
|
||||
Future<List<MaintenanceModel>> getAllMaintenances() async {
|
||||
return await _service.getAllMaintenances();
|
||||
}
|
||||
|
||||
/// Créer une nouvelle maintenance
|
||||
Future<void> createMaintenance(MaintenanceModel maintenance) async {
|
||||
try {
|
||||
await _service.createMaintenance(maintenance);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error creating maintenance: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mettre à jour une maintenance
|
||||
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _service.updateMaintenance(id, data);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error updating maintenance: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprimer une maintenance
|
||||
Future<void> deleteMaintenance(String id) async {
|
||||
try {
|
||||
await _service.deleteMaintenance(id);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error deleting maintenance: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer une maintenance par ID
|
||||
Future<MaintenanceModel?> getMaintenanceById(String id) async {
|
||||
try {
|
||||
return await _service.getMaintenanceById(id);
|
||||
} catch (e) {
|
||||
print('Error getting maintenance: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marquer une maintenance comme complétée
|
||||
Future<void> completeMaintenance(
|
||||
String id, {
|
||||
String? performedBy,
|
||||
double? cost,
|
||||
}) async {
|
||||
try {
|
||||
await _service.completeMaintenance(id, performedBy: performedBy, cost: cost);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error completing maintenance: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifier les maintenances à venir
|
||||
Future<void> checkUpcomingMaintenances() async {
|
||||
try {
|
||||
await _service.checkUpcomingMaintenances();
|
||||
} catch (e) {
|
||||
print('Error checking upcoming maintenances: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer les maintenances en retard
|
||||
List<MaintenanceModel> get overdueMaintances {
|
||||
return _maintenances.where((m) => m.isOverdue).toList();
|
||||
}
|
||||
|
||||
/// Récupérer les maintenances complétées
|
||||
List<MaintenanceModel> get completedMaintenances {
|
||||
return _maintenances.where((m) => m.isCompleted).toList();
|
||||
}
|
||||
|
||||
/// Récupérer les maintenances à venir
|
||||
List<MaintenanceModel> get upcomingMaintenances {
|
||||
return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList();
|
||||
}
|
||||
}
|
||||
|
||||
51
em2rp/lib/providers/maintenance_provider_new.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:em2rp/models/maintenance_model.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
class MaintenanceProvider extends ChangeNotifier {
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
|
||||
List<MaintenanceModel> _maintenances = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
List<MaintenanceModel> get maintenances => _maintenances;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// Charger toutes les maintenances via l'API
|
||||
Future<void> loadMaintenances({String? equipmentId}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final maintenancesData = await _dataService.getMaintenances(
|
||||
equipmentId: equipmentId,
|
||||
);
|
||||
|
||||
_maintenances = maintenancesData.map((data) {
|
||||
return MaintenanceModel.fromMap(data, data['id'] as String);
|
||||
}).toList();
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error loading maintenances: $e');
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recharger les maintenances
|
||||
Future<void> refresh({String? equipmentId}) async {
|
||||
await loadMaintenances(equipmentId: equipmentId);
|
||||
}
|
||||
|
||||
/// Obtenir les maintenances pour un équipement spécifique
|
||||
List<MaintenanceModel> getForEquipment(String equipmentId) {
|
||||
return _maintenances.where((m) =>
|
||||
m.equipmentIds.contains(equipmentId)
|
||||
).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/user_model.dart';
|
||||
import '../services/user_service.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/models/user_model.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
class UsersProvider with ChangeNotifier {
|
||||
final UserService _userService;
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
List<UserModel> _users = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
List<UserModel> get users => _users;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
UsersProvider(this._userService);
|
||||
|
||||
/// Récupération de tous les utilisateurs
|
||||
/// Récupération de tous les utilisateurs via l'API
|
||||
Future<void> fetchUsers() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final snapshot = await _firestore.collection('users').get();
|
||||
_users = snapshot.docs
|
||||
.map((doc) => UserModel.fromMap(doc.data(), doc.id))
|
||||
.toList();
|
||||
final usersData = await _dataService.getUsers();
|
||||
_users = usersData.map((data) {
|
||||
return UserModel.fromMap(data, data['id'] as String);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
print('Error fetching users: $e');
|
||||
_users = [];
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Mise à jour d'un utilisateur
|
||||
Future<void> updateUser(UserModel user, {String? roleId}) async {
|
||||
/// Recharger les utilisateurs
|
||||
Future<void> refresh() async {
|
||||
await fetchUsers();
|
||||
}
|
||||
|
||||
/// Obtenir un utilisateur par ID
|
||||
UserModel? getUserById(String uid) {
|
||||
try {
|
||||
await _firestore.collection('users').doc(user.uid).update({
|
||||
'firstName': user.firstName,
|
||||
'lastName': user.lastName,
|
||||
'email': user.email,
|
||||
'phoneNumber': user.phoneNumber,
|
||||
'role': roleId != null
|
||||
? _firestore.collection('roles').doc(roleId)
|
||||
: user.role,
|
||||
'profilePhotoUrl': user.profilePhotoUrl,
|
||||
});
|
||||
return _users.firstWhere((u) => u.uid == uid);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mettre à jour un utilisateur
|
||||
Future<void> updateUser(UserModel user) async {
|
||||
try {
|
||||
await _dataService.updateUser(user.uid, user.toMap());
|
||||
|
||||
final index = _users.indexWhere((u) => u.uid == user.uid);
|
||||
if (index != -1) {
|
||||
@@ -62,10 +61,10 @@ class UsersProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Suppression d'un utilisateur
|
||||
/// Suppression d'un utilisateur via Cloud Function
|
||||
Future<void> deleteUser(String uid) async {
|
||||
try {
|
||||
await _firestore.collection('users').doc(uid).delete();
|
||||
await _dataService.deleteUser(uid);
|
||||
_users.removeWhere((user) => user.uid == uid);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -74,97 +73,44 @@ class UsersProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Créer un utilisateur avec invitation par email
|
||||
Future<void> createUserWithEmailInvite({
|
||||
required String email,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
String? phoneNumber,
|
||||
required String roleId,
|
||||
}) async {
|
||||
try {
|
||||
print('Creating user with email invite: $email');
|
||||
|
||||
// Appeler la Cloud Function pour créer l'utilisateur
|
||||
await _dataService.createUserWithInvite(
|
||||
email: email,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
phoneNumber: phoneNumber,
|
||||
roleId: roleId,
|
||||
);
|
||||
|
||||
// Recharger la liste des utilisateurs
|
||||
await fetchUsers();
|
||||
|
||||
print('User created successfully: $email');
|
||||
} catch (e) {
|
||||
print('Error creating user with email invite: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Réinitialisation du mot de passe
|
||||
Future<void> resetPassword(String email) async {
|
||||
await _userService.resetPassword(email);
|
||||
}
|
||||
|
||||
Future<void> createUserWithEmailInvite(BuildContext context, UserModel user,
|
||||
{String? roleId}) async {
|
||||
String? authUid;
|
||||
|
||||
try {
|
||||
// Vérifier l'état de l'authentification
|
||||
final currentUser = _auth.currentUser;
|
||||
print('Current user: ${currentUser?.email}');
|
||||
|
||||
if (currentUser == null) {
|
||||
throw Exception('Aucun utilisateur connecté');
|
||||
}
|
||||
|
||||
// Vérifier la permission via le provider
|
||||
final localUserProvider =
|
||||
Provider.of<LocalUserProvider>(context, listen: false);
|
||||
if (!localUserProvider.hasPermission('add_user')) {
|
||||
throw Exception(
|
||||
'Vous n\'avez pas la permission de créer des utilisateurs');
|
||||
}
|
||||
|
||||
try {
|
||||
// Créer l'utilisateur dans Firebase Authentication
|
||||
final userCredential = await _auth.createUserWithEmailAndPassword(
|
||||
email: user.email,
|
||||
password: 'TemporaryPassword123!', // Mot de passe temporaire
|
||||
);
|
||||
|
||||
authUid = userCredential.user!.uid;
|
||||
print('User created in Auth with UID: $authUid');
|
||||
|
||||
// Créer le document dans Firestore avec l'UID de Auth comme ID
|
||||
await _firestore.collection('users').doc(authUid).set({
|
||||
'uid': authUid,
|
||||
'firstName': user.firstName,
|
||||
'lastName': user.lastName,
|
||||
'email': user.email,
|
||||
'phoneNumber': user.phoneNumber,
|
||||
'role': roleId != null
|
||||
? _firestore.collection('roles').doc(roleId)
|
||||
: user.role,
|
||||
'profilePhotoUrl': user.profilePhotoUrl,
|
||||
'createdAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
print('User document created in Firestore with Auth UID');
|
||||
|
||||
// Envoyer un email de réinitialisation de mot de passe
|
||||
await _auth.sendPasswordResetEmail(
|
||||
email: user.email,
|
||||
actionCodeSettings: ActionCodeSettings(
|
||||
url: 'http://localhost:63337/finishSignUp?email=${user.email}',
|
||||
handleCodeInApp: true,
|
||||
androidPackageName: 'com.em2rp.app',
|
||||
androidInstallApp: true,
|
||||
androidMinimumVersion: '12',
|
||||
),
|
||||
);
|
||||
|
||||
print('Password reset email sent');
|
||||
|
||||
// Ajouter l'utilisateur à la liste locale
|
||||
final newUser = UserModel(
|
||||
uid: authUid,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
phoneNumber: user.phoneNumber,
|
||||
role: roleId ?? user.role,
|
||||
profilePhotoUrl: user.profilePhotoUrl,
|
||||
);
|
||||
_users.add(newUser);
|
||||
notifyListeners();
|
||||
// Firebase Auth reste OK (ce n'est pas Firestore)
|
||||
await FirebaseAuth.instance.sendPasswordResetEmail(email: email);
|
||||
print('Email de réinitialisation envoyé à $email');
|
||||
} catch (e) {
|
||||
// En cas d'erreur, supprimer l'utilisateur Auth si créé
|
||||
if (authUid != null) {
|
||||
try {
|
||||
await _auth.currentUser?.delete();
|
||||
} catch (deleteError) {
|
||||
print('Warning: Could not delete Auth user: $deleteError');
|
||||
}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error creating user: $e');
|
||||
print('Error reset password: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
255
em2rp/lib/services/alert_service.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import '../models/alert_model.dart';
|
||||
import '../utils/debug_log.dart';
|
||||
import 'api_service.dart' show FirebaseFunctionsApiService;
|
||||
/// Service de gestion des alertes
|
||||
/// Architecture simplifiée : le client appelle uniquement les Cloud Functions
|
||||
/// Toute la logique métier est gérée côté backend
|
||||
class AlertService {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
|
||||
/// Stream des alertes pour l'utilisateur connecté
|
||||
Stream<List<AlertModel>> getAlertsStream() {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) {
|
||||
DebugLog.info('[AlertService] Pas d\'utilisateur connecté');
|
||||
return Stream.value([]);
|
||||
}
|
||||
|
||||
DebugLog.info('[AlertService] Stream alertes pour utilisateur: ${user.uid}');
|
||||
|
||||
return _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: user.uid)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
final alerts = snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList();
|
||||
|
||||
DebugLog.info('[AlertService] ${alerts.length} alertes actives');
|
||||
return alerts;
|
||||
});
|
||||
}
|
||||
|
||||
/// Récupère les alertes non lues
|
||||
Future<List<AlertModel>> getUnreadAlerts() async {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) return [];
|
||||
|
||||
try {
|
||||
final snapshot = await _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: user.uid)
|
||||
.where('isRead', isEqualTo: false)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.get();
|
||||
|
||||
return snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur récupération alertes', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque une alerte comme lue
|
||||
Future<void> markAsRead(String alertId) async {
|
||||
try {
|
||||
await _firestore.collection('alerts').doc(alertId).update({
|
||||
'isRead': true,
|
||||
'readAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
DebugLog.info('[AlertService] Alerte $alertId marquée comme lue');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur marquage alerte', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque toutes les alertes comme lues
|
||||
Future<void> markAllAsRead() async {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) return;
|
||||
|
||||
try {
|
||||
final snapshot = await _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: user.uid)
|
||||
.where('isRead', isEqualTo: false)
|
||||
.get();
|
||||
|
||||
final batch = _firestore.batch();
|
||||
for (var doc in snapshot.docs) {
|
||||
batch.update(doc.reference, {
|
||||
'isRead': true,
|
||||
'readAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
DebugLog.info('[AlertService] ${snapshot.docs.length} alertes marquées comme lues');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur marquage alertes', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive une alerte
|
||||
Future<void> archiveAlert(String alertId) async {
|
||||
try {
|
||||
await _firestore.collection('alerts').doc(alertId).update({
|
||||
'status': 'ARCHIVED',
|
||||
'archivedAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
DebugLog.info('[AlertService] Alerte $alertId archivée');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur archivage alerte', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une alerte manuelle (appelée par l'utilisateur)
|
||||
/// Cette méthode appelle la Cloud Function createAlert
|
||||
Future<String> createManualAlert({
|
||||
required AlertType type,
|
||||
required AlertSeverity severity,
|
||||
required String message,
|
||||
String? title,
|
||||
String? equipmentId,
|
||||
String? eventId,
|
||||
String? actionUrl,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
try {
|
||||
DebugLog.info('[AlertService] === CRÉATION ALERTE MANUELLE ===');
|
||||
DebugLog.info('[AlertService] Type: $type');
|
||||
DebugLog.info('[AlertService] Severity: $severity');
|
||||
|
||||
final apiService = FirebaseFunctionsApiService();
|
||||
final result = await apiService.call(
|
||||
'createAlert',
|
||||
{
|
||||
'type': alertTypeToString(type),
|
||||
'severity': severity.name.toUpperCase(),
|
||||
'title': title,
|
||||
'message': message,
|
||||
'equipmentId': equipmentId,
|
||||
'eventId': eventId,
|
||||
'actionUrl': actionUrl,
|
||||
'metadata': metadata ?? {},
|
||||
},
|
||||
);
|
||||
|
||||
final alertId = result['alertId'] as String;
|
||||
DebugLog.info('[AlertService] ✓ Alerte créée: $alertId');
|
||||
|
||||
return alertId;
|
||||
} catch (e, stackTrace) {
|
||||
DebugLog.error('[AlertService] ❌ Erreur création alerte', e);
|
||||
DebugLog.error('[AlertService] Stack', stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream des alertes pour un utilisateur spécifique
|
||||
Stream<List<AlertModel>> alertsStreamForUser(String userId) {
|
||||
return _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: userId)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.snapshots()
|
||||
.map((snapshot) => snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList());
|
||||
}
|
||||
|
||||
/// Récupère les alertes pour un utilisateur
|
||||
Future<List<AlertModel>> getAlertsForUser(String userId) async {
|
||||
try {
|
||||
final snapshot = await _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: userId)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.get();
|
||||
|
||||
return snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur récupération alertes', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream du nombre d'alertes non lues pour un utilisateur
|
||||
Stream<int> unreadCountStreamForUser(String userId) {
|
||||
return _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: userId)
|
||||
.where('isRead', isEqualTo: false)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.snapshots()
|
||||
.map((snapshot) => snapshot.docs.length);
|
||||
}
|
||||
|
||||
/// Supprime une alerte
|
||||
Future<void> deleteAlert(String alertId) async {
|
||||
try {
|
||||
await _firestore.collection('alerts').doc(alertId).delete();
|
||||
DebugLog.info('[AlertService] Alerte $alertId supprimée');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur suppression alerte', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une alerte de création d'événement
|
||||
Future<void> createEventCreatedAlert({
|
||||
required String eventId,
|
||||
required String eventName,
|
||||
required DateTime eventDate,
|
||||
}) async {
|
||||
await createManualAlert(
|
||||
type: AlertType.eventCreated,
|
||||
severity: AlertSeverity.info,
|
||||
message: 'Nouvel événement créé: "$eventName" le ${_formatDate(eventDate)}',
|
||||
eventId: eventId,
|
||||
metadata: {
|
||||
'eventName': eventName,
|
||||
'eventDate': eventDate.toIso8601String(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée une alerte de modification d'événement
|
||||
Future<void> createEventModifiedAlert({
|
||||
required String eventId,
|
||||
required String eventName,
|
||||
required String modification,
|
||||
}) async {
|
||||
await createManualAlert(
|
||||
type: AlertType.eventModified,
|
||||
severity: AlertSeverity.info,
|
||||
message: 'Événement "$eventName" modifié: $modification',
|
||||
eventId: eventId,
|
||||
metadata: {
|
||||
'eventName': eventName,
|
||||
'modification': modification,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||