Compare commits
18 Commits
fb6a271f66
...
mise-en-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a182f1b922 | ||
|
|
b79791ff7a | ||
|
|
7e111ec041 | ||
|
|
4e7af9119a | ||
|
|
1ea5cea6fc | ||
|
|
06f394b728 | ||
|
|
67b85d323c | ||
|
|
beaabceda4 | ||
|
|
60d0e1c6c4 | ||
|
|
b30ae0f10a | ||
|
|
fb3f41df4d | ||
|
|
4e4573f57b | ||
|
|
4545bdba81 | ||
|
|
272b4bc9c9 | ||
|
|
0f7a886cf7 | ||
|
|
2bcd1ca4c3 | ||
|
|
f38d75362c | ||
|
|
13a890606d |
47
em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache
Normal file
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
|
||||||
2
em2rp/.gitignore
vendored
2
em2rp/.gitignore
vendored
@@ -44,4 +44,4 @@ app.*.map.json
|
|||||||
|
|
||||||
# Environment configuration with credentials
|
# Environment configuration with credentials
|
||||||
lib/config/env.dev.dart
|
lib/config/env.dev.dart
|
||||||
|
functions/.env
|
||||||
|
|||||||
37
em2rp/CHANGELOG.md
Normal file
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
|
||||||
|
|
||||||
337
em2rp/SYSTEME_MISE_A_JOUR.md
Normal file
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é.
|
||||||
|
|
||||||
@@ -25,6 +25,16 @@ if %ERRORLEVEL% NEQ 0 (
|
|||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
|
echo [1.5/4] Mise à jour du fichier version.json...
|
||||||
|
node scripts\update_version_json.js
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors de la mise à jour de version.json
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
echo [2/4] Build Flutter Web...
|
echo [2/4] Build Flutter Web...
|
||||||
call flutter build web --release
|
call flutter build web --release
|
||||||
if %ERRORLEVEL% NEQ 0 (
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
|||||||
32
em2rp/deploy_alert_corrections.ps1
Normal file
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
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
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
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
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,70 +0,0 @@
|
|||||||
# Export vers Google Calendar
|
|
||||||
|
|
||||||
## Fonctionnalité
|
|
||||||
|
|
||||||
L'application permet d'exporter un événement au format ICS (iCalendar), compatible avec Google Calendar, Apple Calendar, Outlook et la plupart des applications de calendrier.
|
|
||||||
|
|
||||||
## Utilisation
|
|
||||||
|
|
||||||
1. Ouvrir les détails d'un événement
|
|
||||||
2. Cliquer sur l'icône de calendrier 📅 dans l'en-tête
|
|
||||||
3. Le fichier `.ics` sera automatiquement téléchargé
|
|
||||||
4. Ouvrir le fichier pour l'importer dans votre application de calendrier
|
|
||||||
|
|
||||||
## Informations exportées
|
|
||||||
|
|
||||||
Le fichier ICS contient :
|
|
||||||
|
|
||||||
### Informations principales
|
|
||||||
- **Titre** : Nom de l'événement
|
|
||||||
- **Date de début** : Date et heure de début
|
|
||||||
- **Date de fin** : Date et heure de fin
|
|
||||||
- **Lieu** : Adresse de l'événement
|
|
||||||
- **Statut** : Confirmé / Annulé / En attente
|
|
||||||
|
|
||||||
### Description détaillée
|
|
||||||
- Type d'événement
|
|
||||||
- Description complète
|
|
||||||
- Jauge (nombre de personnes)
|
|
||||||
- Email de contact
|
|
||||||
- Téléphone de contact
|
|
||||||
- Temps d'installation et démontage
|
|
||||||
- Liste de la main d'œuvre
|
|
||||||
- Options sélectionnées (avec quantités)
|
|
||||||
- Prix de base
|
|
||||||
|
|
||||||
## Format du fichier
|
|
||||||
|
|
||||||
Le fichier généré suit le standard **RFC 5545** (iCalendar) et est nommé selon le format :
|
|
||||||
```
|
|
||||||
event_[nom_evenement]_[date].ics
|
|
||||||
```
|
|
||||||
|
|
||||||
Exemple : `event_Concert_Mairie_20251225.ics`
|
|
||||||
|
|
||||||
## Compatibilité
|
|
||||||
|
|
||||||
✅ Google Calendar
|
|
||||||
✅ Apple Calendar (macOS, iOS)
|
|
||||||
✅ Microsoft Outlook
|
|
||||||
✅ Thunderbird
|
|
||||||
✅ Autres applications supportant le format ICS
|
|
||||||
|
|
||||||
## Import dans Google Calendar
|
|
||||||
|
|
||||||
1. Télécharger le fichier `.ics`
|
|
||||||
2. Ouvrir Google Calendar
|
|
||||||
3. Cliquer sur l'icône ⚙️ (Paramètres)
|
|
||||||
4. Sélectionner "Importation et exportation"
|
|
||||||
5. Cliquer sur "Sélectionner un fichier sur votre ordinateur"
|
|
||||||
6. Choisir le fichier `.ics` téléchargé
|
|
||||||
7. Sélectionner le calendrier de destination
|
|
||||||
8. Cliquer sur "Importer"
|
|
||||||
|
|
||||||
## Notes techniques
|
|
||||||
|
|
||||||
- Les dates sont converties en UTC pour assurer la compatibilité internationale
|
|
||||||
- Les caractères spéciaux sont correctement échappés selon le standard ICS
|
|
||||||
- Un UID unique est généré pour chaque événement (`em2rp-[eventId]@em2rp.app`)
|
|
||||||
- Le fichier est encodé en UTF-8
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//EM2RP//Event Manager//FR
|
|
||||||
CALSCALE:GREGORIAN
|
|
||||||
METHOD:PUBLISH
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:em2rp-example123@em2rp.app
|
|
||||||
DTSTAMP:20251220T120000Z
|
|
||||||
DTSTART:20251225T190000Z
|
|
||||||
DTEND:20251225T230000Z
|
|
||||||
SUMMARY:Concert de Noël
|
|
||||||
DESCRIPTION:TYPE: Concert\n\nDESCRIPTION:\nConcert de Noël avec orchestre symphonique et chorale.\n\nJAUGE: 500 personnes\nEMAIL DE CONTACT: contact@example.com\nTÉLÉPHONE DE CONTACT: 06 12 34 56 78\n\nADRESSE: Salle des fêtes\, Place de la Mairie\, 75001 Paris\n\nINSTALLATION: 4h\nDÉMONTAGE: 2h\n\nMAIN D'ŒUVRE:\n - Jean Dupont\n - Marie Martin\n - Pierre Durand\n\nOPTIONS:\n - Système son professionnel\n - Éclairage scénique (x2)\n\nPRIX DE BASE: 2500.00€\n\n---\nGéré par EM2RP Event Manager
|
|
||||||
LOCATION:Salle des fêtes\, Place de la Mairie\, 75001 Paris
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
CATEGORIES:Concert
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR
|
|
||||||
|
|
||||||
@@ -48,5 +48,25 @@
|
|||||||
"destination": "/index.html"
|
"destination": "/index.html"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"firestore": {
|
||||||
|
"rules": "firestore.rules",
|
||||||
|
"indexes": "firestore.indexes.json"
|
||||||
|
},
|
||||||
|
"emulators": {
|
||||||
|
"functions": {
|
||||||
|
"port": 5051
|
||||||
|
},
|
||||||
|
"firestore": {
|
||||||
|
"port": 8088
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"port": 9199
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 4040
|
||||||
|
},
|
||||||
|
"singleProjectMode": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
em2rp/firestore.indexes.json
Normal file
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
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
|
||||||
|
//
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
2
em2rp/functions/.gitignore
vendored
2
em2rp/functions/.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|||||||
267
em2rp/functions/createAlert.js
Normal file
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
113
em2rp/functions/migrate_email_prefs.js
Normal file
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
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
735
em2rp/functions/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,14 @@
|
|||||||
},
|
},
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google-cloud/storage": "^7.18.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"envdot": "^0.0.3",
|
||||||
"firebase-admin": "^12.6.0",
|
"firebase-admin": "^12.6.0",
|
||||||
"firebase-functions": "^6.0.1"
|
"firebase-functions": "^7.0.3",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
|
"nodemailer": "^6.10.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.15.0",
|
"eslint": "^8.15.0",
|
||||||
|
|||||||
415
em2rp/functions/processEquipmentValidation.js
Normal file
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
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
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
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
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
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
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
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
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
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,
|
||||||
|
};
|
||||||
|
|
||||||
19
em2rp/lib/config/api_config.dart
Normal file
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
/// Configuration de la version de l'application
|
/// Configuration de la version de l'application
|
||||||
class AppVersion {
|
class AppVersion {
|
||||||
static const String version = '0.3.7';
|
static const String version = '1.0.4';
|
||||||
|
|
||||||
/// Retourne la version complète de l'application
|
/// Retourne la version complète de l'application
|
||||||
static String get fullVersion => 'v$version';
|
static String get fullVersion => 'v$version';
|
||||||
|
|
||||||
|
|
||||||
/// Retourne la version avec un préfixe personnalisé
|
/// Retourne la version avec un préfixe personnalisé
|
||||||
static String getVersionWithPrefix(String prefix) => '$prefix $version';
|
static String getVersionWithPrefix(String prefix) => '$prefix $version';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:em2rp/models/event_model.dart';
|
|||||||
import 'package:em2rp/models/event_type_model.dart';
|
import 'package:em2rp/models/event_type_model.dart';
|
||||||
import 'package:em2rp/models/user_model.dart';
|
import 'package:em2rp/models/user_model.dart';
|
||||||
import 'package:em2rp/services/event_form_service.dart';
|
import 'package:em2rp/services/event_form_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
@@ -125,7 +127,14 @@ class EventFormController extends ChangeNotifier {
|
|||||||
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
|
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
|
||||||
_assignedContainers = List<String>.from(event.assignedContainers);
|
_assignedContainers = List<String>.from(event.assignedContainers);
|
||||||
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
||||||
_selectedUserIds = event.workforce.map((ref) => ref.id).toList();
|
|
||||||
|
// Gérer workforce qui peut contenir String ou DocumentReference
|
||||||
|
_selectedUserIds = event.workforce.map((ref) {
|
||||||
|
if (ref is String) return ref;
|
||||||
|
if (ref is DocumentReference) return ref.id;
|
||||||
|
return '';
|
||||||
|
}).where((id) => id.isNotEmpty).toList();
|
||||||
|
|
||||||
_uploadedFiles = List<Map<String, String>>.from(event.documents);
|
_uploadedFiles = List<Map<String, String>>.from(event.documents);
|
||||||
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
|
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
|
||||||
_selectedStatus = event.status;
|
_selectedStatus = event.status;
|
||||||
@@ -183,15 +192,15 @@ class EventFormController extends ChangeNotifier {
|
|||||||
if (newTypeId != null) {
|
if (newTypeId != null) {
|
||||||
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
|
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
|
||||||
|
|
||||||
// Utiliser le prix par défaut du type d'événement
|
// Utiliser le prix par défaut du type d'événement (prix TTC stocké dans basePrice)
|
||||||
final defaultPrice = selectedType.defaultPrice;
|
final defaultPriceTTC = selectedType.defaultPrice;
|
||||||
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
||||||
final oldDefaultPrice = oldEventType?.defaultPrice;
|
final oldDefaultPrice = oldEventType?.defaultPrice;
|
||||||
|
|
||||||
// Mettre à jour le prix si le champ est vide ou si c'était l'ancien prix par défaut
|
// Mettre à jour le prix TTC si le champ est vide ou si c'était l'ancien prix par défaut
|
||||||
if (basePriceController.text.isEmpty ||
|
if (basePriceController.text.isEmpty ||
|
||||||
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
|
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
|
||||||
basePriceController.text = defaultPrice.toStringAsFixed(2);
|
basePriceController.text = defaultPriceTTC.toStringAsFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
||||||
@@ -285,7 +294,7 @@ class EventFormController extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final eventTypeRef = _selectedEventTypeId != null
|
final eventTypeRef = _selectedEventTypeId != null
|
||||||
? FirebaseFirestore.instance.collection('eventTypes').doc(_selectedEventTypeId)
|
? null // Les références Firestore ne sont plus nécessaires, l'ID suffit
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (existingEvent != null) {
|
if (existingEvent != null) {
|
||||||
@@ -325,9 +334,8 @@ class EventFormController extends ChangeNotifier {
|
|||||||
eventTypeRef: eventTypeRef,
|
eventTypeRef: eventTypeRef,
|
||||||
customerId: existingEvent.customerId,
|
customerId: existingEvent.customerId,
|
||||||
address: addressController.text.trim(),
|
address: addressController.text.trim(),
|
||||||
workforce: _selectedUserIds
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
.map((id) => FirebaseFirestore.instance.collection('users').doc(id))
|
workforce: _selectedUserIds,
|
||||||
.toList(),
|
|
||||||
latitude: existingEvent.latitude,
|
latitude: existingEvent.latitude,
|
||||||
longitude: existingEvent.longitude,
|
longitude: existingEvent.longitude,
|
||||||
documents: finalDocuments,
|
documents: finalDocuments,
|
||||||
@@ -370,9 +378,8 @@ class EventFormController extends ChangeNotifier {
|
|||||||
eventTypeRef: eventTypeRef,
|
eventTypeRef: eventTypeRef,
|
||||||
customerId: '',
|
customerId: '',
|
||||||
address: addressController.text.trim(),
|
address: addressController.text.trim(),
|
||||||
workforce: _selectedUserIds
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
.map((id) => FirebaseFirestore.instance.collection('users').doc(id))
|
workforce: _selectedUserIds,
|
||||||
.toList(),
|
|
||||||
latitude: 0.0,
|
latitude: 0.0,
|
||||||
longitude: 0.0,
|
longitude: 0.0,
|
||||||
documents: _uploadedFiles,
|
documents: _uploadedFiles,
|
||||||
@@ -386,8 +393,14 @@ class EventFormController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final eventId = await EventFormService.createEvent(newEvent);
|
final eventId = await EventFormService.createEvent(newEvent);
|
||||||
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
|
|
||||||
await EventFormService.updateEventDocuments(eventId, newFiles);
|
// 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
|
// Reload events
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
@@ -422,8 +435,9 @@ class EventFormController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Supprimer l'événement de Firestore
|
// Supprimer l'événement via l'API
|
||||||
await FirebaseFirestore.instance.collection('events').doc(eventId).delete();
|
final dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
await dataService.deleteEvent(eventId);
|
||||||
|
|
||||||
// Recharger la liste des événements
|
// Recharger la liste des événements
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:em2rp/providers/container_provider.dart';
|
|||||||
import 'package:em2rp/providers/maintenance_provider.dart';
|
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||||
import 'package:em2rp/providers/alert_provider.dart';
|
import 'package:em2rp/providers/alert_provider.dart';
|
||||||
import 'package:em2rp/utils/auth_guard_widget.dart';
|
import 'package:em2rp/utils/auth_guard_widget.dart';
|
||||||
|
import 'package:em2rp/views/alerts_page.dart';
|
||||||
import 'package:em2rp/views/calendar_page.dart';
|
import 'package:em2rp/views/calendar_page.dart';
|
||||||
import 'package:em2rp/views/login_page.dart';
|
import 'package:em2rp/views/login_page.dart';
|
||||||
import 'package:em2rp/views/equipment_management_page.dart';
|
import 'package:em2rp/views/equipment_management_page.dart';
|
||||||
@@ -15,6 +16,7 @@ import 'package:em2rp/views/event_preparation_page.dart';
|
|||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
@@ -23,45 +25,61 @@ import 'views/my_account_page.dart';
|
|||||||
import 'views/user_management_page.dart';
|
import 'views/user_management_page.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'providers/local_user_provider.dart';
|
import 'providers/local_user_provider.dart';
|
||||||
import 'services/user_service.dart';
|
|
||||||
import 'views/reset_password_page.dart';
|
import 'views/reset_password_page.dart';
|
||||||
import 'config/env.dart';
|
import 'config/env.dart';
|
||||||
|
import 'config/api_config.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'views/widgets/common/update_dialog.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Firebase.initializeApp(
|
await Firebase.initializeApp(
|
||||||
options: DefaultFirebaseOptions.currentPlatform,
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Configuration des émulateurs en mode développement
|
||||||
|
if (ApiConfig.isDevelopment) {
|
||||||
|
print('🔧 Mode développement activé - Utilisation des émulateurs');
|
||||||
|
|
||||||
|
// Configurer l'émulateur Auth
|
||||||
|
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
|
||||||
|
print('✓ Auth émulateur configuré: localhost:9199');
|
||||||
|
|
||||||
|
// Configurer l'émulateur Firestore
|
||||||
|
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
|
||||||
|
print('✓ Firestore émulateur configuré: localhost:8088');
|
||||||
|
}
|
||||||
|
|
||||||
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
|
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
// Injection du service UserService
|
|
||||||
Provider<UserService>(create: (_) => UserService()),
|
|
||||||
|
|
||||||
// LocalUserProvider pour la gestion de l'authentification
|
// LocalUserProvider pour la gestion de l'authentification
|
||||||
ChangeNotifierProvider<LocalUserProvider>(
|
ChangeNotifierProvider<LocalUserProvider>(
|
||||||
create: (context) => LocalUserProvider()),
|
create: (context) => LocalUserProvider()),
|
||||||
|
|
||||||
// Injection des Providers en utilisant UserService
|
// UsersProvider migré vers l'API
|
||||||
ChangeNotifierProvider<UsersProvider>(
|
ChangeNotifierProvider<UsersProvider>(
|
||||||
create: (context) => UsersProvider(context.read<UserService>()),
|
create: (context) => UsersProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// EventProvider pour la gestion des événements
|
// EventProvider migré vers l'API
|
||||||
ChangeNotifierProvider<EventProvider>(
|
ChangeNotifierProvider<EventProvider>(
|
||||||
create: (context) => EventProvider(),
|
create: (context) => EventProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Providers pour la gestion du matériel
|
// EquipmentProvider migré vers l'API
|
||||||
ChangeNotifierProvider<EquipmentProvider>(
|
ChangeNotifierProvider<EquipmentProvider>(
|
||||||
create: (context) => EquipmentProvider(),
|
create: (context) => EquipmentProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ContainerProvider migré vers l'API
|
||||||
ChangeNotifierProvider<ContainerProvider>(
|
ChangeNotifierProvider<ContainerProvider>(
|
||||||
create: (context) => ContainerProvider(),
|
create: (context) => ContainerProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// MaintenanceProvider migré vers l'API
|
||||||
ChangeNotifierProvider<MaintenanceProvider>(
|
ChangeNotifierProvider<MaintenanceProvider>(
|
||||||
create: (context) => MaintenanceProvider(),
|
create: (context) => MaintenanceProvider(),
|
||||||
),
|
),
|
||||||
@@ -79,9 +97,10 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return UpdateChecker(
|
||||||
title: 'EM2 ERP',
|
child: MaterialApp(
|
||||||
theme: ThemeData(
|
title: 'EM2 Hub',
|
||||||
|
theme: ThemeData(
|
||||||
primarySwatch: Colors.red,
|
primarySwatch: Colors.red,
|
||||||
primaryColor: AppColors.noir,
|
primaryColor: AppColors.noir,
|
||||||
colorScheme:
|
colorScheme:
|
||||||
@@ -115,9 +134,11 @@ class MyApp extends StatelessWidget {
|
|||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
home: const AutoLoginWrapper(),
|
initialRoute: '/',
|
||||||
routes: {
|
routes: {
|
||||||
|
'/': (context) => const AutoLoginWrapper(),
|
||||||
'/login': (context) => const LoginPage(),
|
'/login': (context) => const LoginPage(),
|
||||||
|
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
|
||||||
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
|
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
|
||||||
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
|
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
|
||||||
'/user_management': (context) => const AuthGuard(
|
'/user_management': (context) => const AuthGuard(
|
||||||
@@ -162,6 +183,7 @@ class MyApp extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +220,22 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
await localAuthProvider.loadUserData();
|
await localAuthProvider.loadUserData();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
// 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) {
|
} catch (e) {
|
||||||
print('Auto login failed: $e');
|
print('Auto login failed: $e');
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
/// Type d'alerte
|
||||||
enum AlertType {
|
enum AlertType {
|
||||||
lowStock, // Stock faible
|
lowStock, // Stock faible
|
||||||
maintenanceDue, // Maintenance à venir
|
maintenanceDue, // Maintenance à venir
|
||||||
conflict // Conflit disponibilité
|
conflict, // Conflit disponibilité
|
||||||
|
lost, // Équipement perdu
|
||||||
|
eventCreated, // Événement créé
|
||||||
|
eventModified, // Événement modifié
|
||||||
|
eventCancelled, // Événement annulé
|
||||||
|
eventAssigned, // Assigné à un événement
|
||||||
|
maintenanceReminder, // Rappel maintenance périodique
|
||||||
|
equipmentMissing, // Équipement manquant à une étape
|
||||||
|
quantityMismatch, // Quantité incorrecte
|
||||||
|
damaged, // Équipement endommagé
|
||||||
|
workforceAdded, // Ajouté à la workforce d'un événement
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gravité de l'alerte
|
||||||
|
enum AlertSeverity {
|
||||||
|
info, // Information (bleu)
|
||||||
|
warning, // Avertissement (orange)
|
||||||
|
critical, // Critique (rouge)
|
||||||
}
|
}
|
||||||
|
|
||||||
String alertTypeToString(AlertType type) {
|
String alertTypeToString(AlertType type) {
|
||||||
@@ -14,6 +32,26 @@ String alertTypeToString(AlertType type) {
|
|||||||
return 'MAINTENANCE_DUE';
|
return 'MAINTENANCE_DUE';
|
||||||
case AlertType.conflict:
|
case AlertType.conflict:
|
||||||
return 'CONFLICT';
|
return 'CONFLICT';
|
||||||
|
case AlertType.lost:
|
||||||
|
return 'LOST';
|
||||||
|
case AlertType.eventCreated:
|
||||||
|
return 'EVENT_CREATED';
|
||||||
|
case AlertType.eventModified:
|
||||||
|
return 'EVENT_MODIFIED';
|
||||||
|
case AlertType.eventCancelled:
|
||||||
|
return 'EVENT_CANCELLED';
|
||||||
|
case AlertType.eventAssigned:
|
||||||
|
return 'EVENT_ASSIGNED';
|
||||||
|
case AlertType.maintenanceReminder:
|
||||||
|
return 'MAINTENANCE_REMINDER';
|
||||||
|
case AlertType.equipmentMissing:
|
||||||
|
return 'EQUIPMENT_MISSING';
|
||||||
|
case AlertType.quantityMismatch:
|
||||||
|
return 'QUANTITY_MISMATCH';
|
||||||
|
case AlertType.damaged:
|
||||||
|
return 'DAMAGED';
|
||||||
|
case AlertType.workforceAdded:
|
||||||
|
return 'WORKFORCE_ADDED';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,65 +63,211 @@ AlertType alertTypeFromString(String? type) {
|
|||||||
return AlertType.maintenanceDue;
|
return AlertType.maintenanceDue;
|
||||||
case 'CONFLICT':
|
case 'CONFLICT':
|
||||||
return AlertType.conflict;
|
return AlertType.conflict;
|
||||||
|
case 'LOST':
|
||||||
|
return AlertType.lost;
|
||||||
|
case 'EVENT_CREATED':
|
||||||
|
return AlertType.eventCreated;
|
||||||
|
case 'EVENT_MODIFIED':
|
||||||
|
return AlertType.eventModified;
|
||||||
|
case 'EVENT_CANCELLED':
|
||||||
|
return AlertType.eventCancelled;
|
||||||
|
case 'EVENT_ASSIGNED':
|
||||||
|
return AlertType.eventAssigned;
|
||||||
|
case 'MAINTENANCE_REMINDER':
|
||||||
|
return AlertType.maintenanceReminder;
|
||||||
|
case 'EQUIPMENT_MISSING':
|
||||||
|
return AlertType.equipmentMissing;
|
||||||
|
case 'QUANTITY_MISMATCH':
|
||||||
|
return AlertType.quantityMismatch;
|
||||||
|
case 'DAMAGED':
|
||||||
|
return AlertType.damaged;
|
||||||
|
case 'WORKFORCE_ADDED':
|
||||||
|
return AlertType.workforceAdded;
|
||||||
default:
|
default:
|
||||||
return AlertType.conflict;
|
return AlertType.conflict;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String alertSeverityToString(AlertSeverity severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case AlertSeverity.info:
|
||||||
|
return 'INFO';
|
||||||
|
case AlertSeverity.warning:
|
||||||
|
return 'WARNING';
|
||||||
|
case AlertSeverity.critical:
|
||||||
|
return 'CRITICAL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertSeverity alertSeverityFromString(String? severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case 'INFO':
|
||||||
|
return AlertSeverity.info;
|
||||||
|
case 'WARNING':
|
||||||
|
return AlertSeverity.warning;
|
||||||
|
case 'CRITICAL':
|
||||||
|
return AlertSeverity.critical;
|
||||||
|
default:
|
||||||
|
return AlertSeverity.info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AlertModel {
|
class AlertModel {
|
||||||
final String id; // ID généré automatiquement
|
final String id; // ID généré automatiquement
|
||||||
final AlertType type; // Type d'alerte
|
final AlertType type; // Type d'alerte
|
||||||
final String message; // Message de l'alerte
|
final AlertSeverity severity; // Gravité de l'alerte
|
||||||
final String? equipmentId; // ID de l'équipement concerné (optionnel)
|
final String message; // Message de l'alerte
|
||||||
final DateTime createdAt; // Date de création
|
final List<String> assignedToUserIds; // Utilisateurs concernés
|
||||||
final bool isRead; // Statut lu/non lu
|
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({
|
AlertModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.type,
|
required this.type,
|
||||||
|
this.severity = AlertSeverity.info,
|
||||||
required this.message,
|
required this.message,
|
||||||
|
this.assignedToUserIds = const [],
|
||||||
|
this.eventId,
|
||||||
this.equipmentId,
|
this.equipmentId,
|
||||||
|
this.createdByUserId,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.dueDate,
|
||||||
|
this.actionUrl,
|
||||||
this.isRead = false,
|
this.isRead = false,
|
||||||
|
this.isResolved = false,
|
||||||
|
this.resolution,
|
||||||
|
this.resolvedAt,
|
||||||
|
this.resolvedByUserId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime _parseDate(dynamic value) {
|
||||||
|
if (value == null) return DateTime.now();
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser les assignedToUserIds (peut être List ou null)
|
||||||
|
List<String> parseUserIds(dynamic value) {
|
||||||
|
if (value == null) return [];
|
||||||
|
if (value is List) return value.map((e) => e.toString()).toList();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return AlertModel(
|
return AlertModel(
|
||||||
id: id,
|
id: id,
|
||||||
type: alertTypeFromString(map['type']),
|
type: alertTypeFromString(map['type']),
|
||||||
|
severity: alertSeverityFromString(map['severity']),
|
||||||
message: map['message'] ?? '',
|
message: map['message'] ?? '',
|
||||||
|
assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']),
|
||||||
|
eventId: map['eventId'],
|
||||||
equipmentId: map['equipmentId'],
|
equipmentId: map['equipmentId'],
|
||||||
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
||||||
|
createdAt: _parseDate(map['createdAt']),
|
||||||
|
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
|
||||||
|
actionUrl: map['actionUrl'],
|
||||||
isRead: map['isRead'] ?? false,
|
isRead: map['isRead'] ?? false,
|
||||||
|
isResolved: map['isResolved'] ?? false,
|
||||||
|
resolution: map['resolution'],
|
||||||
|
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null,
|
||||||
|
resolvedByUserId: map['resolvedByUserId'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Factory depuis un document Firestore
|
||||||
|
factory AlertModel.fromFirestore(DocumentSnapshot doc) {
|
||||||
|
final data = doc.data() as Map<String, dynamic>?;
|
||||||
|
if (data == null) {
|
||||||
|
throw Exception('Document vide: ${doc.id}');
|
||||||
|
}
|
||||||
|
return AlertModel.fromMap(data, doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'type': alertTypeToString(type),
|
'type': alertTypeToString(type),
|
||||||
|
'severity': alertSeverityToString(severity),
|
||||||
'message': message,
|
'message': message,
|
||||||
'equipmentId': equipmentId,
|
'assignedToUserIds': assignedToUserIds,
|
||||||
|
if (eventId != null) 'eventId': eventId,
|
||||||
|
if (equipmentId != null) 'equipmentId': equipmentId,
|
||||||
|
if (createdByUserId != null) 'createdByUserId': createdByUserId,
|
||||||
'createdAt': Timestamp.fromDate(createdAt),
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!),
|
||||||
|
if (actionUrl != null) 'actionUrl': actionUrl,
|
||||||
'isRead': isRead,
|
'isRead': isRead,
|
||||||
|
'isResolved': isResolved,
|
||||||
|
if (resolution != null) 'resolution': resolution,
|
||||||
|
if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!),
|
||||||
|
if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
AlertModel copyWith({
|
AlertModel copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
AlertType? type,
|
AlertType? type,
|
||||||
|
AlertSeverity? severity,
|
||||||
String? message,
|
String? message,
|
||||||
|
List<String>? assignedToUserIds,
|
||||||
|
String? eventId,
|
||||||
String? equipmentId,
|
String? equipmentId,
|
||||||
|
String? createdByUserId,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
DateTime? dueDate,
|
||||||
|
String? actionUrl,
|
||||||
bool? isRead,
|
bool? isRead,
|
||||||
|
bool? isResolved,
|
||||||
|
String? resolution,
|
||||||
|
DateTime? resolvedAt,
|
||||||
|
String? resolvedByUserId,
|
||||||
}) {
|
}) {
|
||||||
return AlertModel(
|
return AlertModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
|
severity: severity ?? this.severity,
|
||||||
message: message ?? this.message,
|
message: message ?? this.message,
|
||||||
|
assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds,
|
||||||
|
eventId: eventId ?? this.eventId,
|
||||||
equipmentId: equipmentId ?? this.equipmentId,
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
|
createdByUserId: createdByUserId ?? this.createdByUserId,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
dueDate: dueDate ?? this.dueDate,
|
||||||
|
actionUrl: actionUrl ?? this.actionUrl,
|
||||||
isRead: isRead ?? this.isRead,
|
isRead: isRead ?? this.isRead,
|
||||||
|
isResolved: isResolved ?? this.isResolved,
|
||||||
|
resolution: resolution ?? this.resolution,
|
||||||
|
resolvedAt: resolvedAt ?? this.resolvedAt,
|
||||||
|
resolvedByUserId: resolvedByUserId ?? this.resolvedByUserId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper : Retourne true si l'alerte est pour un événement
|
||||||
|
bool get isEventAlert =>
|
||||||
|
type == AlertType.eventCreated ||
|
||||||
|
type == AlertType.eventModified ||
|
||||||
|
type == AlertType.eventCancelled ||
|
||||||
|
type == AlertType.eventAssigned;
|
||||||
|
|
||||||
|
/// Helper : Retourne true si l'alerte est pour la maintenance
|
||||||
|
bool get isMaintenanceAlert =>
|
||||||
|
type == AlertType.maintenanceDue ||
|
||||||
|
type == AlertType.maintenanceReminder;
|
||||||
|
|
||||||
|
/// Helper : Retourne true si l'alerte est pour un équipement
|
||||||
|
bool get isEquipmentAlert =>
|
||||||
|
type == AlertType.lost ||
|
||||||
|
type == AlertType.equipmentMissing ||
|
||||||
|
type == AlertType.lowStock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,14 @@ class ContainerModel {
|
|||||||
|
|
||||||
/// Factory depuis Firestore
|
/// Factory depuis Firestore
|
||||||
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
@@ -262,8 +270,8 @@ class ContainerModel {
|
|||||||
equipmentIds: equipmentIds,
|
equipmentIds: equipmentIds,
|
||||||
eventId: map['eventId'],
|
eventId: map['eventId'],
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
history: history,
|
history: history,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -342,8 +350,16 @@ class ContainerHistoryEntry {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
||||||
|
// Helper pour parser la date
|
||||||
|
DateTime _parseDate(dynamic value) {
|
||||||
|
if (value == null) return DateTime.now();
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
return ContainerHistoryEntry(
|
return ContainerHistoryEntry(
|
||||||
timestamp: (map['timestamp'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
timestamp: _parseDate(map['timestamp']),
|
||||||
action: map['action'] ?? '',
|
action: map['action'] ?? '',
|
||||||
equipmentId: map['equipmentId'],
|
equipmentId: map['equipmentId'],
|
||||||
previousValue: map['previousValue'],
|
previousValue: map['previousValue'],
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ enum EquipmentCategory {
|
|||||||
structure, // Structure
|
structure, // Structure
|
||||||
consumable, // Consommable
|
consumable, // Consommable
|
||||||
cable, // Câble
|
cable, // Câble
|
||||||
|
vehicle, // Véhicule
|
||||||
|
backline, // Régie / Backline
|
||||||
other // Autre
|
other // Autre
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,10 @@ String equipmentCategoryToString(EquipmentCategory category) {
|
|||||||
return 'CONSUMABLE';
|
return 'CONSUMABLE';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'CABLE';
|
return 'CABLE';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'VEHICLE';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'BACKLINE';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'OTHER';
|
return 'OTHER';
|
||||||
case EquipmentCategory.effect:
|
case EquipmentCategory.effect:
|
||||||
@@ -93,6 +99,10 @@ EquipmentCategory equipmentCategoryFromString(String? category) {
|
|||||||
return EquipmentCategory.consumable;
|
return EquipmentCategory.consumable;
|
||||||
case 'CABLE':
|
case 'CABLE':
|
||||||
return EquipmentCategory.cable;
|
return EquipmentCategory.cable;
|
||||||
|
case 'VEHICLE':
|
||||||
|
return EquipmentCategory.vehicle;
|
||||||
|
case 'BACKLINE':
|
||||||
|
return EquipmentCategory.backline;
|
||||||
case 'EFFECT':
|
case 'EFFECT':
|
||||||
return EquipmentCategory.effect;
|
return EquipmentCategory.effect;
|
||||||
case 'OTHER':
|
case 'OTHER':
|
||||||
@@ -120,6 +130,10 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return 'Consommable';
|
return 'Consommable';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'Câble';
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'Régie / Backline';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
@@ -142,6 +156,10 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return Icons.inventory_2;
|
return Icons.inventory_2;
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return Icons.cable;
|
return Icons.cable;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Icons.local_shipping;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Icons.piano;
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return Icons.more_horiz;
|
return Icons.more_horiz;
|
||||||
}
|
}
|
||||||
@@ -164,6 +182,10 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return Colors.orange;
|
return Colors.orange;
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return Colors.grey;
|
return Colors.grey;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Colors.teal;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Colors.indigo;
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return Colors.blueGrey;
|
return Colors.blueGrey;
|
||||||
}
|
}
|
||||||
@@ -176,7 +198,14 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return 'assets/icons/truss.svg';
|
return 'assets/icons/truss.svg';
|
||||||
case EquipmentCategory.consumable:
|
case EquipmentCategory.consumable:
|
||||||
return 'assets/icons/tape.svg';
|
return 'assets/icons/tape.svg';
|
||||||
default:
|
case EquipmentCategory.lighting:
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
case EquipmentCategory.other:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +324,7 @@ class EquipmentModel {
|
|||||||
final String? brand; // Marque (indexé)
|
final String? brand; // Marque (indexé)
|
||||||
final String? model; // Modèle (indexé)
|
final String? model; // Modèle (indexé)
|
||||||
final EquipmentCategory category; // Catégorie
|
final EquipmentCategory category; // Catégorie
|
||||||
|
final String? subCategory; // Sous-catégorie (indexé par catégorie)
|
||||||
final EquipmentStatus status; // Statut actuel
|
final EquipmentStatus status; // Statut actuel
|
||||||
|
|
||||||
// Prix (visible uniquement avec manage_equipment)
|
// Prix (visible uniquement avec manage_equipment)
|
||||||
@@ -306,8 +336,6 @@ class EquipmentModel {
|
|||||||
final int? availableQuantity; // Quantité disponible
|
final int? availableQuantity; // Quantité disponible
|
||||||
final int? criticalThreshold; // Seuil critique pour alerte
|
final int? criticalThreshold; // Seuil critique pour alerte
|
||||||
|
|
||||||
// Boîtes parentes (plusieurs possibles)
|
|
||||||
final List<String> parentBoxIds; // IDs des boîtes contenant cet équipement
|
|
||||||
|
|
||||||
// Caractéristiques physiques
|
// Caractéristiques physiques
|
||||||
final double? weight; // Poids (kg)
|
final double? weight; // Poids (kg)
|
||||||
@@ -337,13 +365,13 @@ class EquipmentModel {
|
|||||||
this.brand,
|
this.brand,
|
||||||
this.model,
|
this.model,
|
||||||
required this.category,
|
required this.category,
|
||||||
|
this.subCategory,
|
||||||
this.status = EquipmentStatus.available,
|
this.status = EquipmentStatus.available,
|
||||||
this.purchasePrice,
|
this.purchasePrice,
|
||||||
this.rentalPrice,
|
this.rentalPrice,
|
||||||
this.totalQuantity,
|
this.totalQuantity,
|
||||||
this.availableQuantity,
|
this.availableQuantity,
|
||||||
this.criticalThreshold,
|
this.criticalThreshold,
|
||||||
this.parentBoxIds = const [],
|
|
||||||
this.weight,
|
this.weight,
|
||||||
this.length,
|
this.length,
|
||||||
this.width,
|
this.width,
|
||||||
@@ -359,10 +387,15 @@ class EquipmentModel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Gestion des listes
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
final List<dynamic> parentBoxIdsRaw = map['parentBoxIds'] ?? [];
|
DateTime? _parseDate(dynamic value) {
|
||||||
final List<String> parentBoxIds = parentBoxIdsRaw.map((e) => e.toString()).toList();
|
if (value == null) return null;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion des listes
|
||||||
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
|
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
|
||||||
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
|
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
@@ -372,24 +405,24 @@ class EquipmentModel {
|
|||||||
brand: map['brand'],
|
brand: map['brand'],
|
||||||
model: map['model'],
|
model: map['model'],
|
||||||
category: equipmentCategoryFromString(map['category']),
|
category: equipmentCategoryFromString(map['category']),
|
||||||
|
subCategory: map['subCategory'],
|
||||||
status: equipmentStatusFromString(map['status']),
|
status: equipmentStatusFromString(map['status']),
|
||||||
purchasePrice: map['purchasePrice']?.toDouble(),
|
purchasePrice: map['purchasePrice']?.toDouble(),
|
||||||
rentalPrice: map['rentalPrice']?.toDouble(),
|
rentalPrice: map['rentalPrice']?.toDouble(),
|
||||||
totalQuantity: map['totalQuantity']?.toInt(),
|
totalQuantity: map['totalQuantity']?.toInt(),
|
||||||
availableQuantity: map['availableQuantity']?.toInt(),
|
availableQuantity: map['availableQuantity']?.toInt(),
|
||||||
criticalThreshold: map['criticalThreshold']?.toInt(),
|
criticalThreshold: map['criticalThreshold']?.toInt(),
|
||||||
parentBoxIds: parentBoxIds,
|
|
||||||
weight: map['weight']?.toDouble(),
|
weight: map['weight']?.toDouble(),
|
||||||
length: map['length']?.toDouble(),
|
length: map['length']?.toDouble(),
|
||||||
width: map['width']?.toDouble(),
|
width: map['width']?.toDouble(),
|
||||||
height: map['height']?.toDouble(),
|
height: map['height']?.toDouble(),
|
||||||
purchaseDate: (map['purchaseDate'] as Timestamp?)?.toDate(),
|
purchaseDate: _parseDate(map['purchaseDate']),
|
||||||
nextMaintenanceDate: (map['nextMaintenanceDate'] as Timestamp?)?.toDate(),
|
nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']),
|
||||||
maintenanceIds: maintenanceIds,
|
maintenanceIds: maintenanceIds,
|
||||||
imageUrl: map['imageUrl'],
|
imageUrl: map['imageUrl'],
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,13 +432,13 @@ class EquipmentModel {
|
|||||||
'brand': brand,
|
'brand': brand,
|
||||||
'model': model,
|
'model': model,
|
||||||
'category': equipmentCategoryToString(category),
|
'category': equipmentCategoryToString(category),
|
||||||
|
'subCategory': subCategory,
|
||||||
'status': equipmentStatusToString(status),
|
'status': equipmentStatusToString(status),
|
||||||
'purchasePrice': purchasePrice,
|
'purchasePrice': purchasePrice,
|
||||||
'rentalPrice': rentalPrice,
|
'rentalPrice': rentalPrice,
|
||||||
'totalQuantity': totalQuantity,
|
'totalQuantity': totalQuantity,
|
||||||
'availableQuantity': availableQuantity,
|
'availableQuantity': availableQuantity,
|
||||||
'criticalThreshold': criticalThreshold,
|
'criticalThreshold': criticalThreshold,
|
||||||
'parentBoxIds': parentBoxIds,
|
|
||||||
'weight': weight,
|
'weight': weight,
|
||||||
'length': length,
|
'length': length,
|
||||||
'width': width,
|
'width': width,
|
||||||
@@ -427,13 +460,13 @@ class EquipmentModel {
|
|||||||
String? name,
|
String? name,
|
||||||
String? model,
|
String? model,
|
||||||
EquipmentCategory? category,
|
EquipmentCategory? category,
|
||||||
|
String? subCategory,
|
||||||
EquipmentStatus? status,
|
EquipmentStatus? status,
|
||||||
double? purchasePrice,
|
double? purchasePrice,
|
||||||
double? rentalPrice,
|
double? rentalPrice,
|
||||||
int? totalQuantity,
|
int? totalQuantity,
|
||||||
int? availableQuantity,
|
int? availableQuantity,
|
||||||
int? criticalThreshold,
|
int? criticalThreshold,
|
||||||
List<String>? parentBoxIds,
|
|
||||||
double? weight,
|
double? weight,
|
||||||
double? length,
|
double? length,
|
||||||
double? width,
|
double? width,
|
||||||
@@ -453,13 +486,13 @@ class EquipmentModel {
|
|||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
model: model ?? this.model,
|
model: model ?? this.model,
|
||||||
category: category ?? this.category,
|
category: category ?? this.category,
|
||||||
|
subCategory: subCategory ?? this.subCategory,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
purchasePrice: purchasePrice ?? this.purchasePrice,
|
purchasePrice: purchasePrice ?? this.purchasePrice,
|
||||||
rentalPrice: rentalPrice ?? this.rentalPrice,
|
rentalPrice: rentalPrice ?? this.rentalPrice,
|
||||||
totalQuantity: totalQuantity ?? this.totalQuantity,
|
totalQuantity: totalQuantity ?? this.totalQuantity,
|
||||||
availableQuantity: availableQuantity ?? this.availableQuantity,
|
availableQuantity: availableQuantity ?? this.availableQuantity,
|
||||||
criticalThreshold: criticalThreshold ?? this.criticalThreshold,
|
criticalThreshold: criticalThreshold ?? this.criticalThreshold,
|
||||||
parentBoxIds: parentBoxIds ?? this.parentBoxIds,
|
|
||||||
weight: weight ?? this.weight,
|
weight: weight ?? this.weight,
|
||||||
length: length ?? this.length,
|
length: length ?? this.length,
|
||||||
width: width ?? this.width,
|
width: width ?? this.width,
|
||||||
|
|||||||
@@ -173,12 +173,23 @@ ReturnStatus returnStatusFromString(String? status) {
|
|||||||
|
|
||||||
class EventEquipment {
|
class EventEquipment {
|
||||||
final String equipmentId; // ID de l'équipement
|
final String equipmentId; // ID de l'équipement
|
||||||
final int quantity; // Quantité (pour consommables)
|
final int quantity; // Quantité initiale assignée
|
||||||
final bool isPrepared; // Validé en préparation
|
final bool isPrepared; // Validé en préparation
|
||||||
final bool isLoaded; // Validé au chargement
|
final bool isLoaded; // Validé au chargement
|
||||||
final bool isUnloaded; // Validé au déchargement
|
final bool isUnloaded; // Validé au déchargement
|
||||||
final bool isReturned; // Validé au retour
|
final bool isReturned; // Validé au retour
|
||||||
final int? returnedQuantity; // Quantité retournée (pour consommables)
|
|
||||||
|
// Tracking des manquants à chaque étape
|
||||||
|
final bool isMissingAtPreparation; // Manquant à la préparation
|
||||||
|
final bool isMissingAtLoading; // Manquant au chargement
|
||||||
|
final bool isMissingAtUnloading; // Manquant au déchargement
|
||||||
|
final bool isMissingAtReturn; // Manquant au retour
|
||||||
|
|
||||||
|
// Quantités réelles à chaque étape (pour les quantifiables)
|
||||||
|
final int? quantityAtPreparation; // Quantité comptée en préparation
|
||||||
|
final int? quantityAtLoading; // Quantité comptée au chargement
|
||||||
|
final int? quantityAtUnloading; // Quantité comptée au déchargement
|
||||||
|
final int? quantityAtReturn; // Quantité retournée
|
||||||
|
|
||||||
EventEquipment({
|
EventEquipment({
|
||||||
required this.equipmentId,
|
required this.equipmentId,
|
||||||
@@ -187,7 +198,14 @@ class EventEquipment {
|
|||||||
this.isLoaded = false,
|
this.isLoaded = false,
|
||||||
this.isUnloaded = false,
|
this.isUnloaded = false,
|
||||||
this.isReturned = false,
|
this.isReturned = false,
|
||||||
this.returnedQuantity,
|
this.isMissingAtPreparation = false,
|
||||||
|
this.isMissingAtLoading = false,
|
||||||
|
this.isMissingAtUnloading = false,
|
||||||
|
this.isMissingAtReturn = false,
|
||||||
|
this.quantityAtPreparation,
|
||||||
|
this.quantityAtLoading,
|
||||||
|
this.quantityAtUnloading,
|
||||||
|
this.quantityAtReturn,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory EventEquipment.fromMap(Map<String, dynamic> map) {
|
factory EventEquipment.fromMap(Map<String, dynamic> map) {
|
||||||
@@ -198,7 +216,14 @@ class EventEquipment {
|
|||||||
isLoaded: map['isLoaded'] ?? false,
|
isLoaded: map['isLoaded'] ?? false,
|
||||||
isUnloaded: map['isUnloaded'] ?? false,
|
isUnloaded: map['isUnloaded'] ?? false,
|
||||||
isReturned: map['isReturned'] ?? false,
|
isReturned: map['isReturned'] ?? false,
|
||||||
returnedQuantity: map['returnedQuantity'],
|
isMissingAtPreparation: map['isMissingAtPreparation'] ?? false,
|
||||||
|
isMissingAtLoading: map['isMissingAtLoading'] ?? false,
|
||||||
|
isMissingAtUnloading: map['isMissingAtUnloading'] ?? false,
|
||||||
|
isMissingAtReturn: map['isMissingAtReturn'] ?? false,
|
||||||
|
quantityAtPreparation: map['quantityAtPreparation'],
|
||||||
|
quantityAtLoading: map['quantityAtLoading'],
|
||||||
|
quantityAtUnloading: map['quantityAtUnloading'],
|
||||||
|
quantityAtReturn: map['quantityAtReturn'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +235,14 @@ class EventEquipment {
|
|||||||
'isLoaded': isLoaded,
|
'isLoaded': isLoaded,
|
||||||
'isUnloaded': isUnloaded,
|
'isUnloaded': isUnloaded,
|
||||||
'isReturned': isReturned,
|
'isReturned': isReturned,
|
||||||
'returnedQuantity': returnedQuantity,
|
'isMissingAtPreparation': isMissingAtPreparation,
|
||||||
|
'isMissingAtLoading': isMissingAtLoading,
|
||||||
|
'isMissingAtUnloading': isMissingAtUnloading,
|
||||||
|
'isMissingAtReturn': isMissingAtReturn,
|
||||||
|
'quantityAtPreparation': quantityAtPreparation,
|
||||||
|
'quantityAtLoading': quantityAtLoading,
|
||||||
|
'quantityAtUnloading': quantityAtUnloading,
|
||||||
|
'quantityAtReturn': quantityAtReturn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +253,14 @@ class EventEquipment {
|
|||||||
bool? isLoaded,
|
bool? isLoaded,
|
||||||
bool? isUnloaded,
|
bool? isUnloaded,
|
||||||
bool? isReturned,
|
bool? isReturned,
|
||||||
int? returnedQuantity,
|
bool? isMissingAtPreparation,
|
||||||
|
bool? isMissingAtLoading,
|
||||||
|
bool? isMissingAtUnloading,
|
||||||
|
bool? isMissingAtReturn,
|
||||||
|
int? quantityAtPreparation,
|
||||||
|
int? quantityAtLoading,
|
||||||
|
int? quantityAtUnloading,
|
||||||
|
int? quantityAtReturn,
|
||||||
}) {
|
}) {
|
||||||
return EventEquipment(
|
return EventEquipment(
|
||||||
equipmentId: equipmentId ?? this.equipmentId,
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
@@ -230,7 +269,14 @@ class EventEquipment {
|
|||||||
isLoaded: isLoaded ?? this.isLoaded,
|
isLoaded: isLoaded ?? this.isLoaded,
|
||||||
isUnloaded: isUnloaded ?? this.isUnloaded,
|
isUnloaded: isUnloaded ?? this.isUnloaded,
|
||||||
isReturned: isReturned ?? this.isReturned,
|
isReturned: isReturned ?? this.isReturned,
|
||||||
returnedQuantity: returnedQuantity ?? this.returnedQuantity,
|
isMissingAtPreparation: isMissingAtPreparation ?? this.isMissingAtPreparation,
|
||||||
|
isMissingAtLoading: isMissingAtLoading ?? this.isMissingAtLoading,
|
||||||
|
isMissingAtUnloading: isMissingAtUnloading ?? this.isMissingAtUnloading,
|
||||||
|
isMissingAtReturn: isMissingAtReturn ?? this.isMissingAtReturn,
|
||||||
|
quantityAtPreparation: quantityAtPreparation ?? this.quantityAtPreparation,
|
||||||
|
quantityAtLoading: quantityAtLoading ?? this.quantityAtLoading,
|
||||||
|
quantityAtUnloading: quantityAtUnloading ?? this.quantityAtUnloading,
|
||||||
|
quantityAtReturn: quantityAtReturn ?? this.quantityAtReturn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +296,7 @@ class EventModel {
|
|||||||
final String address;
|
final String address;
|
||||||
final double latitude;
|
final double latitude;
|
||||||
final double longitude;
|
final double longitude;
|
||||||
final List<DocumentReference> workforce;
|
final List<dynamic> workforce; // Peut contenir DocumentReference OU String (UIDs)
|
||||||
final List<Map<String, String>> documents;
|
final List<Map<String, String>> documents;
|
||||||
final List<Map<String, dynamic>> options;
|
final List<Map<String, dynamic>> options;
|
||||||
final EventStatus status;
|
final EventStatus status;
|
||||||
@@ -300,25 +346,32 @@ class EventModel {
|
|||||||
|
|
||||||
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
try {
|
try {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime _parseDate(dynamic value, DateTime defaultValue) {
|
||||||
|
if (value == null) return defaultValue;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
// Gestion sécurisée des références workforce
|
// Gestion sécurisée des références workforce
|
||||||
final List<dynamic> workforceRefs = map['workforce'] ?? [];
|
final List<dynamic> workforceRefs = map['workforce'] ?? [];
|
||||||
final List<DocumentReference> safeWorkforce = [];
|
final List<dynamic> safeWorkforce = [];
|
||||||
|
|
||||||
for (var ref in workforceRefs) {
|
for (var ref in workforceRefs) {
|
||||||
if (ref is DocumentReference) {
|
if (ref is DocumentReference) {
|
||||||
safeWorkforce.add(ref);
|
safeWorkforce.add(ref);
|
||||||
|
} else if (ref is String) {
|
||||||
|
// Accepter directement les UIDs (envoyés par le backend)
|
||||||
|
safeWorkforce.add(ref);
|
||||||
} else {
|
} else {
|
||||||
print('Warning: Invalid workforce reference in event $id: $ref');
|
print('Warning: Invalid workforce reference in event $id: $ref');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion sécurisée des timestamps
|
// Gestion sécurisée des timestamps avec support ISO string
|
||||||
final Timestamp? startTimestamp = map['StartDateTime'] as Timestamp?;
|
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
|
||||||
final Timestamp? endTimestamp = map['EndDateTime'] as Timestamp?;
|
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
|
||||||
|
|
||||||
final DateTime startDate = startTimestamp?.toDate() ?? DateTime.now();
|
|
||||||
final DateTime endDate = endTimestamp?.toDate() ??
|
|
||||||
startDate.add(const Duration(hours: 1));
|
|
||||||
|
|
||||||
// Gestion sécurisée des documents
|
// Gestion sécurisée des documents
|
||||||
final docsRaw = map['documents'] ?? [];
|
final docsRaw = map['documents'] ?? [];
|
||||||
@@ -365,7 +418,13 @@ class EventModel {
|
|||||||
eventTypeRef = map['EventType'] as DocumentReference;
|
eventTypeRef = map['EventType'] as DocumentReference;
|
||||||
eventTypeId = eventTypeRef.id;
|
eventTypeId = eventTypeRef.id;
|
||||||
} else if (map['EventType'] is String) {
|
} else if (map['EventType'] is String) {
|
||||||
eventTypeId = map['EventType'] as String;
|
final eventTypeString = map['EventType'] as String;
|
||||||
|
// Si c'est un path (ex: "eventTypes/Mariage"), extraire juste l'ID
|
||||||
|
if (eventTypeString.contains('/')) {
|
||||||
|
eventTypeId = eventTypeString.split('/').last;
|
||||||
|
} else {
|
||||||
|
eventTypeId = eventTypeString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion sécurisée du customer
|
// Gestion sécurisée du customer
|
||||||
@@ -373,7 +432,13 @@ class EventModel {
|
|||||||
if (map['customer'] is DocumentReference) {
|
if (map['customer'] is DocumentReference) {
|
||||||
customerId = (map['customer'] as DocumentReference).id;
|
customerId = (map['customer'] as DocumentReference).id;
|
||||||
} else if (map['customer'] is String) {
|
} else if (map['customer'] is String) {
|
||||||
customerId = map['customer'] as String;
|
final customerString = map['customer'] as String;
|
||||||
|
// Si c'est un path (ex: "clients/abc123"), extraire juste l'ID
|
||||||
|
if (customerString.contains('/')) {
|
||||||
|
customerId = customerString.split('/').last;
|
||||||
|
} else {
|
||||||
|
customerId = customerString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion des équipements assignés
|
// Gestion des équipements assignés
|
||||||
@@ -470,12 +535,10 @@ class EventModel {
|
|||||||
'BasePrice': basePrice,
|
'BasePrice': basePrice,
|
||||||
'InstallationTime': installationTime,
|
'InstallationTime': installationTime,
|
||||||
'DisassemblyTime': disassemblyTime,
|
'DisassemblyTime': disassemblyTime,
|
||||||
'EventType': eventTypeId.isNotEmpty
|
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
? FirebaseFirestore.instance.collection('eventTypes').doc(eventTypeId)
|
'EventType': eventTypeId.isNotEmpty ? eventTypeId : null,
|
||||||
: null,
|
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
'customer': customerId.isNotEmpty
|
'customer': customerId.isNotEmpty ? customerId : null,
|
||||||
? FirebaseFirestore.instance.collection('customers').doc(customerId)
|
|
||||||
: null,
|
|
||||||
'Address': address,
|
'Address': address,
|
||||||
'Position': GeoPoint(latitude, longitude),
|
'Position': GeoPoint(latitude, longitude),
|
||||||
'Latitude': latitude,
|
'Latitude': latitude,
|
||||||
@@ -495,4 +558,64 @@ class EventModel {
|
|||||||
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
|
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EventModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
DateTime? startDateTime,
|
||||||
|
DateTime? endDateTime,
|
||||||
|
double? basePrice,
|
||||||
|
int? installationTime,
|
||||||
|
int? disassemblyTime,
|
||||||
|
String? eventTypeId,
|
||||||
|
DocumentReference? eventTypeRef,
|
||||||
|
String? customerId,
|
||||||
|
String? address,
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
List<dynamic>? workforce,
|
||||||
|
List<Map<String, String>>? documents,
|
||||||
|
List<Map<String, dynamic>>? options,
|
||||||
|
EventStatus? status,
|
||||||
|
int? jauge,
|
||||||
|
String? contactEmail,
|
||||||
|
String? contactPhone,
|
||||||
|
List<EventEquipment>? assignedEquipment,
|
||||||
|
List<String>? assignedContainers,
|
||||||
|
PreparationStatus? preparationStatus,
|
||||||
|
LoadingStatus? loadingStatus,
|
||||||
|
UnloadingStatus? unloadingStatus,
|
||||||
|
ReturnStatus? returnStatus,
|
||||||
|
}) {
|
||||||
|
return EventModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
description: description ?? this.description,
|
||||||
|
startDateTime: startDateTime ?? this.startDateTime,
|
||||||
|
endDateTime: endDateTime ?? this.endDateTime,
|
||||||
|
basePrice: basePrice ?? this.basePrice,
|
||||||
|
installationTime: installationTime ?? this.installationTime,
|
||||||
|
disassemblyTime: disassemblyTime ?? this.disassemblyTime,
|
||||||
|
eventTypeId: eventTypeId ?? this.eventTypeId,
|
||||||
|
eventTypeRef: eventTypeRef ?? this.eventTypeRef,
|
||||||
|
customerId: customerId ?? this.customerId,
|
||||||
|
address: address ?? this.address,
|
||||||
|
latitude: latitude ?? this.latitude,
|
||||||
|
longitude: longitude ?? this.longitude,
|
||||||
|
workforce: workforce ?? this.workforce,
|
||||||
|
documents: documents ?? this.documents,
|
||||||
|
options: options ?? this.options,
|
||||||
|
status: status ?? this.status,
|
||||||
|
jauge: jauge ?? this.jauge,
|
||||||
|
contactEmail: contactEmail ?? this.contactEmail,
|
||||||
|
contactPhone: contactPhone ?? this.contactPhone,
|
||||||
|
assignedEquipment: assignedEquipment ?? this.assignedEquipment,
|
||||||
|
assignedContainers: assignedContainers ?? this.assignedContainers,
|
||||||
|
preparationStatus: preparationStatus ?? this.preparationStatus,
|
||||||
|
loadingStatus: loadingStatus ?? this.loadingStatus,
|
||||||
|
unloadingStatus: unloadingStatus ?? this.unloadingStatus,
|
||||||
|
returnStatus: returnStatus ?? this.returnStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
class EventTypeModel {
|
class EventTypeModel {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -12,11 +14,19 @@ class EventTypeModel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory EventTypeModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EventTypeModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Gérer createdAt qui peut être Timestamp (Firestore) ou String ISO (API)
|
||||||
|
DateTime parseCreatedAt(dynamic value) {
|
||||||
|
if (value == null) return DateTime.now();
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
return EventTypeModel(
|
return EventTypeModel(
|
||||||
id: id,
|
id: id,
|
||||||
name: map['name'] ?? '',
|
name: map['name'] ?? '',
|
||||||
defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(),
|
defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(),
|
||||||
createdAt: map['createdAt']?.toDate() ?? DateTime.now(),
|
createdAt: parseCreatedAt(map['createdAt']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ class MaintenanceModel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Gestion de la liste des équipements
|
// Gestion de la liste des équipements
|
||||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||||
@@ -68,15 +76,15 @@ class MaintenanceModel {
|
|||||||
id: id,
|
id: id,
|
||||||
equipmentIds: equipmentIds,
|
equipmentIds: equipmentIds,
|
||||||
type: maintenanceTypeFromString(map['type']),
|
type: maintenanceTypeFromString(map['type']),
|
||||||
scheduledDate: (map['scheduledDate'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(),
|
||||||
completedDate: (map['completedDate'] as Timestamp?)?.toDate(),
|
completedDate: _parseDate(map['completedDate']),
|
||||||
name: map['name'] ?? '',
|
name: map['name'] ?? '',
|
||||||
description: map['description'] ?? '',
|
description: map['description'] ?? '',
|
||||||
performedBy: map['performedBy'],
|
performedBy: map['performedBy'],
|
||||||
cost: map['cost']?.toDouble(),
|
cost: map['cost']?.toDouble(),
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
em2rp/lib/models/notification_preferences_model.dart
Normal file
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,4 +1,5 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:em2rp/models/notification_preferences_model.dart';
|
||||||
|
|
||||||
class UserModel {
|
class UserModel {
|
||||||
final String uid;
|
final String uid;
|
||||||
@@ -8,6 +9,7 @@ class UserModel {
|
|||||||
final String profilePhotoUrl;
|
final String profilePhotoUrl;
|
||||||
final String email;
|
final String email;
|
||||||
final String phoneNumber;
|
final String phoneNumber;
|
||||||
|
final NotificationPreferences? notificationPreferences;
|
||||||
|
|
||||||
UserModel({
|
UserModel({
|
||||||
required this.uid,
|
required this.uid,
|
||||||
@@ -17,19 +19,39 @@ class UserModel {
|
|||||||
required this.profilePhotoUrl,
|
required this.profilePhotoUrl,
|
||||||
required this.email,
|
required this.email,
|
||||||
required this.phoneNumber,
|
required this.phoneNumber,
|
||||||
|
this.notificationPreferences,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convertit une Map (Firestore) en UserModel
|
// Convertit une Map (Firestore) en UserModel
|
||||||
factory UserModel.fromMap(Map<String, dynamic> data, String uid) {
|
factory UserModel.fromMap(Map<String, dynamic> data, String uid) {
|
||||||
String roleString;
|
String roleString;
|
||||||
final roleField = data['role'];
|
final roleField = data['role'];
|
||||||
|
|
||||||
if (roleField is String) {
|
if (roleField is String) {
|
||||||
|
// Cas 1 : role est déjà un String (ex: "roles/ADMIN")
|
||||||
roleString = roleField;
|
roleString = roleField;
|
||||||
} else if (roleField is DocumentReference) {
|
} else if (roleField is DocumentReference) {
|
||||||
|
// Cas 2 : role est une DocumentReference
|
||||||
roleString = roleField.id;
|
roleString = roleField.id;
|
||||||
|
} else if (roleField is Map) {
|
||||||
|
// Cas 3 : role est un Map sérialisé (ex: {"_path": {"segments": ["roles", "ADMIN"]}})
|
||||||
|
// On extrait le path
|
||||||
|
final pathData = roleField['_path'];
|
||||||
|
if (pathData is Map && pathData['segments'] is List) {
|
||||||
|
final segments = pathData['segments'] as List;
|
||||||
|
if (segments.length >= 2) {
|
||||||
|
roleString = segments[1].toString(); // Ex: "ADMIN"
|
||||||
|
} else {
|
||||||
|
roleString = 'USER';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roleString = 'USER';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Cas par défaut
|
||||||
roleString = 'USER';
|
roleString = 'USER';
|
||||||
}
|
}
|
||||||
|
|
||||||
return UserModel(
|
return UserModel(
|
||||||
uid: uid,
|
uid: uid,
|
||||||
firstName: data['firstName'] ?? '',
|
firstName: data['firstName'] ?? '',
|
||||||
@@ -38,6 +60,9 @@ class UserModel {
|
|||||||
profilePhotoUrl: data['profilePhotoUrl'] ?? '',
|
profilePhotoUrl: data['profilePhotoUrl'] ?? '',
|
||||||
email: data['email'] ?? '',
|
email: data['email'] ?? '',
|
||||||
phoneNumber: data['phoneNumber'] ?? '',
|
phoneNumber: data['phoneNumber'] ?? '',
|
||||||
|
notificationPreferences: data['notificationPreferences'] != null
|
||||||
|
? NotificationPreferences.fromMap(data['notificationPreferences'] as Map<String, dynamic>)
|
||||||
|
: NotificationPreferences.defaults(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,10 +71,12 @@ class UserModel {
|
|||||||
return {
|
return {
|
||||||
'firstName': firstName,
|
'firstName': firstName,
|
||||||
'lastName': lastName,
|
'lastName': lastName,
|
||||||
'role': FirebaseFirestore.instance.collection('roles').doc(role),
|
'role': role, // Envoyer directement le string roleId au lieu de créer une DocumentReference
|
||||||
'profilePhotoUrl': profilePhotoUrl,
|
'profilePhotoUrl': profilePhotoUrl,
|
||||||
'email': email,
|
'email': email,
|
||||||
'phoneNumber': phoneNumber,
|
'phoneNumber': phoneNumber,
|
||||||
|
if (notificationPreferences != null)
|
||||||
|
'notificationPreferences': notificationPreferences!.toMap(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +87,7 @@ class UserModel {
|
|||||||
String? profilePhotoUrl,
|
String? profilePhotoUrl,
|
||||||
String? email,
|
String? email,
|
||||||
String? phoneNumber,
|
String? phoneNumber,
|
||||||
|
NotificationPreferences? notificationPreferences,
|
||||||
}) {
|
}) {
|
||||||
return UserModel(
|
return UserModel(
|
||||||
uid: uid, // L'UID ne change pas
|
uid: uid, // L'UID ne change pas
|
||||||
@@ -69,6 +97,7 @@ class UserModel {
|
|||||||
profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
|
profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
|
||||||
email: email ?? this.email,
|
email: email ?? this.email,
|
||||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||||
|
notificationPreferences: notificationPreferences ?? this.notificationPreferences,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/alert_model.dart';
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class AlertProvider extends ChangeNotifier {
|
class AlertProvider extends ChangeNotifier {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
|
|
||||||
List<AlertModel> _alerts = [];
|
List<AlertModel> _alerts = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
List<AlertModel> get alerts => _alerts;
|
List<AlertModel> get alerts => _alerts;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
/// Nombre d'alertes non lues
|
/// Nombre d'alertes non lues
|
||||||
int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
|
int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
|
||||||
@@ -25,57 +27,58 @@ class AlertProvider extends ChangeNotifier {
|
|||||||
/// Alertes de conflit
|
/// Alertes de conflit
|
||||||
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
|
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
|
||||||
|
|
||||||
/// Stream des alertes
|
/// Charger toutes les alertes via Cloud Function
|
||||||
Stream<List<AlertModel>> get alertsStream {
|
Future<void> loadAlerts() async {
|
||||||
return _firestore
|
_isLoading = true;
|
||||||
.collection('alerts')
|
notifyListeners();
|
||||||
.orderBy('createdAt', descending: true)
|
|
||||||
.snapshots()
|
try {
|
||||||
.map((snapshot) {
|
final result = await _apiService.call('getAlerts', {});
|
||||||
_alerts = snapshot.docs
|
final alertsData = result['alerts'] as List<dynamic>;
|
||||||
.map((doc) => AlertModel.fromMap(doc.data(), doc.id))
|
|
||||||
.toList();
|
_alerts = alertsData.map((data) {
|
||||||
return _alerts;
|
return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
|
||||||
});
|
}).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading alerts: $e');
|
||||||
|
_alerts = [];
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marquer une alerte comme lue
|
/// Marquer une alerte comme lue via Cloud Function
|
||||||
Future<void> markAsRead(String alertId) async {
|
Future<void> markAsRead(String alertId) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('alerts').doc(alertId).update({
|
await _apiService.call('markAlertAsRead', {'alertId': alertId});
|
||||||
'isRead': true,
|
|
||||||
});
|
// Mettre à jour localement
|
||||||
notifyListeners();
|
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) {
|
} catch (e) {
|
||||||
print('Error marking alert as read: $e');
|
print('Error marking alert as read: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marquer toutes les alertes comme lues
|
/// Supprimer une alerte via Cloud Function
|
||||||
Future<void> markAllAsRead() async {
|
|
||||||
try {
|
|
||||||
final batch = _firestore.batch();
|
|
||||||
|
|
||||||
for (var alert in _alerts.where((a) => !a.isRead)) {
|
|
||||||
batch.update(
|
|
||||||
_firestore.collection('alerts').doc(alert.id),
|
|
||||||
{'isRead': true},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await batch.commit();
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error marking all alerts as read: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Supprimer une alerte
|
|
||||||
Future<void> deleteAlert(String alertId) async {
|
Future<void> deleteAlert(String alertId) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('alerts').doc(alertId).delete();
|
await _apiService.call('deleteAlert', {'alertId': alertId});
|
||||||
|
|
||||||
|
// Supprimer localement
|
||||||
|
_alerts.removeWhere((a) => a.id == alertId);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting alert: $e');
|
print('Error deleting alert: $e');
|
||||||
@@ -83,46 +86,32 @@ class AlertProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supprimer toutes les alertes lues
|
/// Marquer toutes les alertes comme lues
|
||||||
|
Future<void> 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 {
|
Future<void> deleteReadAlerts() async {
|
||||||
try {
|
try {
|
||||||
final batch = _firestore.batch();
|
final readAlertIds = _alerts.where((a) => a.isRead).map((a) => a.id).toList();
|
||||||
|
|
||||||
for (var alert in _alerts.where((a) => a.isRead)) {
|
for (final alertId in readAlertIds) {
|
||||||
batch.delete(_firestore.collection('alerts').doc(alert.id));
|
await deleteAlert(alertId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await batch.commit();
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting read alerts: $e');
|
print('Error deleting read alerts: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Créer une alerte (utilisé principalement par les services)
|
|
||||||
Future<void> createAlert(AlertModel alert) async {
|
|
||||||
try {
|
|
||||||
await _firestore.collection('alerts').doc(alert.id).set(alert.toMap());
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error creating alert: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupérer les alertes pour un équipement spécifique
|
|
||||||
Stream<List<AlertModel>> getAlertsForEquipment(String equipmentId) {
|
|
||||||
return _firestore
|
|
||||||
.collection('alerts')
|
|
||||||
.where('equipmentId', isEqualTo: equipmentId)
|
|
||||||
.orderBy('createdAt', descending: true)
|
|
||||||
.snapshots()
|
|
||||||
.map((snapshot) {
|
|
||||||
return snapshot.docs
|
|
||||||
.map((doc) => AlertModel.fromMap(doc.data(), doc.id))
|
|
||||||
.toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
em2rp/lib/providers/alert_provider_new.dart
Normal file
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,44 +1,268 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:async';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/services/container_service.dart';
|
import 'package:em2rp/services/container_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
class ContainerProvider with ChangeNotifier {
|
class ContainerProvider with ChangeNotifier {
|
||||||
final ContainerService _containerService = ContainerService();
|
final ContainerService _containerService = ContainerService();
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
// Timer pour le debouncing de la recherche
|
||||||
|
Timer? _searchDebounceTimer;
|
||||||
|
|
||||||
|
// Liste paginée pour la page de gestion
|
||||||
|
List<ContainerModel> _paginatedContainers = [];
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
String? _lastVisible;
|
||||||
|
|
||||||
|
// Cache complet pour compatibilité
|
||||||
|
List<ContainerModel> _containers = [];
|
||||||
|
|
||||||
|
// Filtres et recherche
|
||||||
ContainerType? _selectedType;
|
ContainerType? _selectedType;
|
||||||
EquipmentStatus? _selectedStatus;
|
EquipmentStatus? _selectedStatus;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
// Mode de chargement (pagination vs full)
|
||||||
|
bool _usePagination = false;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
List<ContainerModel> get containers => _usePagination ? _paginatedContainers : _containers;
|
||||||
ContainerType? get selectedType => _selectedType;
|
ContainerType? get selectedType => _selectedType;
|
||||||
EquipmentStatus? get selectedStatus => _selectedStatus;
|
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||||
String get searchQuery => _searchQuery;
|
String get searchQuery => _searchQuery;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get usePagination => _usePagination;
|
||||||
|
|
||||||
/// Stream des containers avec filtres appliqués
|
/// S'assure que les conteneurs sont chargés (charge si nécessaire)
|
||||||
Stream<List<ContainerModel>> get containersStream {
|
Future<void> ensureLoaded() async {
|
||||||
return _containerService.getContainers(
|
if (_isInitialized || _isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger tous les containers via l'API (avec pagination automatique)
|
||||||
|
Future<void> loadContainers() async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
_containers.clear();
|
||||||
|
String? lastVisible;
|
||||||
|
bool hasMore = true;
|
||||||
|
int pageCount = 0;
|
||||||
|
|
||||||
|
// Charger toutes les pages en boucle
|
||||||
|
while (hasMore) {
|
||||||
|
pageCount++;
|
||||||
|
print('[ContainerProvider] Loading page $pageCount...');
|
||||||
|
|
||||||
|
final result = await _dataService.getContainersPaginated(
|
||||||
|
limit: 100, // Charger 100 par page pour aller plus vite
|
||||||
|
startAfter: lastVisible,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
type: _selectedType?.name,
|
||||||
|
status: _selectedStatus?.name,
|
||||||
|
searchQuery: _searchQuery,
|
||||||
|
);
|
||||||
|
|
||||||
|
final containers = (result['containers'] as List<dynamic>)
|
||||||
|
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_containers.addAll(containers);
|
||||||
|
hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
|
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
_isInitialized = true;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading containers: $e');
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer les containers avec filtres appliqués
|
||||||
|
Future<List<ContainerModel>> getContainers() async {
|
||||||
|
return await _containerService.getContainers(
|
||||||
type: _selectedType,
|
type: _selectedType,
|
||||||
status: _selectedStatus,
|
status: _selectedStatus,
|
||||||
searchQuery: _searchQuery,
|
searchQuery: _searchQuery,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stream des containers - retourne un stream depuis les données en cache
|
||||||
|
/// Pour compatibilité avec les widgets existants qui utilisent StreamBuilder
|
||||||
|
Stream<List<ContainerModel>> get containersStream async* {
|
||||||
|
// Si les données ne sont pas chargées, charger d'abord
|
||||||
|
if (!_isInitialized) {
|
||||||
|
await loadContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Émettre les données actuelles
|
||||||
|
yield _containers;
|
||||||
|
|
||||||
|
// Continuer à émettre les mises à jour du cache
|
||||||
|
// Note: Pour un vrai temps réel, il faudrait implémenter un StreamController
|
||||||
|
// et notifier quand les données changent
|
||||||
|
}
|
||||||
|
|
||||||
/// Définir le type sélectionné
|
/// Définir le type sélectionné
|
||||||
void setSelectedType(ContainerType? type) {
|
void setSelectedType(ContainerType? type) async {
|
||||||
|
if (_selectedType == type) return;
|
||||||
_selectedType = type;
|
_selectedType = type;
|
||||||
notifyListeners();
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Définir le statut sélectionné
|
/// Définir le statut sélectionné
|
||||||
void setSelectedStatus(EquipmentStatus? status) {
|
void setSelectedStatus(EquipmentStatus? status) async {
|
||||||
|
if (_selectedStatus == status) return;
|
||||||
_selectedStatus = status;
|
_selectedStatus = status;
|
||||||
notifyListeners();
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Définir la requête de recherche
|
/// Définir la requête de recherche (avec debouncing)
|
||||||
void setSearchQuery(String query) {
|
void setSearchQuery(String query) {
|
||||||
|
if (_searchQuery == query) return;
|
||||||
_searchQuery = query;
|
_searchQuery = query;
|
||||||
|
|
||||||
|
// Annuler le timer précédent
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
|
||||||
|
if (_usePagination) {
|
||||||
|
// Attendre 500ms avant de recharger (debouncing)
|
||||||
|
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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();
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadNextPage();
|
||||||
|
_isInitialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[ContainerProvider] Error loading first page', e);
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge la page suivante (scroll infini)
|
||||||
|
Future<void> loadNextPage() async {
|
||||||
|
if (_isLoadingMore || !_hasMore) {
|
||||||
|
DebugLog.info('[ContainerProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[ContainerProvider] Loading next page... (current: ${_paginatedContainers.length})');
|
||||||
|
|
||||||
|
_isLoadingMore = true;
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _dataService.getContainersPaginated(
|
||||||
|
limit: 20,
|
||||||
|
startAfter: _lastVisible,
|
||||||
|
type: _selectedType != null ? containerTypeToString(_selectedType!) : null,
|
||||||
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final newContainers = (result['containers'] as List<dynamic>)
|
||||||
|
.map((data) {
|
||||||
|
final map = data as Map<String, dynamic>;
|
||||||
|
return ContainerModel.fromMap(map, map['id'] as String);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_paginatedContainers.addAll(newContainers);
|
||||||
|
_hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
_lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
|
DebugLog.info('[ContainerProvider] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMore');
|
||||||
|
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[ContainerProvider] Error loading next page', e);
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recharge en changeant de filtre ou recherche
|
||||||
|
Future<void> reload() async {
|
||||||
|
DebugLog.info('[ContainerProvider] Reloading with new filters...');
|
||||||
|
await loadFirstPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Créer un nouveau container
|
/// Créer un nouveau container
|
||||||
@@ -64,6 +288,69 @@ class ContainerProvider with ChangeNotifier {
|
|||||||
return await _containerService.getContainerById(id);
|
return await _containerService.getContainerById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Charge plusieurs conteneurs par leurs IDs (optimisé pour les détails d'événement)
|
||||||
|
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
|
||||||
|
if (containerIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier d'abord le cache local
|
||||||
|
final cachedContainers = <ContainerModel>[];
|
||||||
|
final missingIds = <String>[];
|
||||||
|
|
||||||
|
for (final id in containerIds) {
|
||||||
|
final cached = _containers.firstWhere(
|
||||||
|
(c) => c.id == id,
|
||||||
|
orElse: () => ContainerModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: ContainerType.flightCase,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
equipmentIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cached.id.isNotEmpty) {
|
||||||
|
cachedContainers.add(cached);
|
||||||
|
} else {
|
||||||
|
missingIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
|
||||||
|
|
||||||
|
// Si tous sont en cache, retourner directement
|
||||||
|
if (missingIds.isEmpty) {
|
||||||
|
return cachedContainers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les manquants depuis l'API
|
||||||
|
final containersData = await _dataService.getContainersByIds(missingIds);
|
||||||
|
|
||||||
|
final loadedContainers = containersData.map((data) {
|
||||||
|
return ContainerModel.fromMap(data, data['id'] as String);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Ajouter au cache
|
||||||
|
for (final container in loadedContainers) {
|
||||||
|
if (!_containers.any((c) => c.id == container.id)) {
|
||||||
|
_containers.add(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
|
||||||
|
|
||||||
|
// Retourner tous les conteneurs (cache + chargés)
|
||||||
|
return [...cachedContainers, ...loadedContainers];
|
||||||
|
} catch (e) {
|
||||||
|
print('[ContainerProvider] Error loading containers by IDs: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ajouter un équipement à un container
|
/// Ajouter un équipement à un container
|
||||||
Future<Map<String, dynamic>> addEquipmentToContainer({
|
Future<Map<String, dynamic>> addEquipmentToContainer({
|
||||||
required String containerId,
|
required String containerId,
|
||||||
|
|||||||
@@ -1,229 +1,533 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:async';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/services/equipment_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
import 'package:em2rp/services/equipment_status_calculator.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
class EquipmentProvider extends ChangeNotifier {
|
class EquipmentProvider extends ChangeNotifier {
|
||||||
final EquipmentService _service = EquipmentService();
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
final EquipmentStatusCalculator _statusCalculator = EquipmentStatusCalculator();
|
|
||||||
|
|
||||||
|
// Timer pour le debouncing de la recherche
|
||||||
|
Timer? _searchDebounceTimer;
|
||||||
|
|
||||||
|
// Liste paginée pour la page de gestion
|
||||||
|
List<EquipmentModel> _paginatedEquipment = [];
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
String? _lastVisible;
|
||||||
|
|
||||||
|
// Cache complet pour getEquipmentsByIds et compatibilité
|
||||||
List<EquipmentModel> _equipment = [];
|
List<EquipmentModel> _equipment = [];
|
||||||
List<String> _models = [];
|
List<String> _models = [];
|
||||||
List<String> _brands = [];
|
List<String> _brands = [];
|
||||||
|
|
||||||
|
// Filtres et recherche
|
||||||
EquipmentCategory? _selectedCategory;
|
EquipmentCategory? _selectedCategory;
|
||||||
EquipmentStatus? _selectedStatus;
|
EquipmentStatus? _selectedStatus;
|
||||||
String? _selectedModel;
|
String? _selectedModel;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
// Mode de chargement (pagination vs full)
|
||||||
|
bool _usePagination = false;
|
||||||
|
|
||||||
|
EquipmentProvider();
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
List<EquipmentModel> get equipment => _equipment;
|
List<EquipmentModel> get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment;
|
||||||
|
List<EquipmentModel> get allEquipment => _equipment;
|
||||||
List<String> get models => _models;
|
List<String> get models => _models;
|
||||||
List<String> get brands => _brands;
|
List<String> get brands => _brands;
|
||||||
EquipmentCategory? get selectedCategory => _selectedCategory;
|
EquipmentCategory? get selectedCategory => _selectedCategory;
|
||||||
EquipmentStatus? get selectedStatus => _selectedStatus;
|
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||||
String? get selectedModel => _selectedModel;
|
String? get selectedModel => _selectedModel;
|
||||||
String get searchQuery => _searchQuery;
|
String get searchQuery => _searchQuery;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get usePagination => _usePagination;
|
||||||
|
|
||||||
/// Stream des équipements avec filtres appliqués
|
/// S'assure que les équipements sont chargés (charge si nécessaire)
|
||||||
Stream<List<EquipmentModel>> get equipmentStream {
|
Future<void> ensureLoaded() async {
|
||||||
return _service.getEquipment(
|
// Si déjà en train de charger, attendre
|
||||||
category: _selectedCategory,
|
if (_isLoading) {
|
||||||
status: _selectedStatus,
|
print('[EquipmentProvider] Equipment loading in progress, waiting...');
|
||||||
model: _selectedModel,
|
return;
|
||||||
searchQuery: _searchQuery,
|
}
|
||||||
);
|
|
||||||
|
// Si initialisé MAIS _equipment est vide, forcer le rechargement
|
||||||
|
if (_isInitialized && _equipment.isEmpty) {
|
||||||
|
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
|
||||||
|
_isInitialized = false; // Réinitialiser le flag
|
||||||
|
await loadEquipments();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si déjà initialisé avec des données, ne rien faire
|
||||||
|
if (_isInitialized) {
|
||||||
|
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[EquipmentProvider] Equipment not loaded, loading now...');
|
||||||
|
await loadEquipments();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Charger tous les modèles uniques
|
/// Charger tous les équipements via l'API (utilisé par les dialogs et sélection)
|
||||||
Future<void> loadModels() async {
|
Future<void> loadEquipments() async {
|
||||||
|
print('[EquipmentProvider] Starting to load ALL equipments...');
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_models = await _service.getAllModels();
|
_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();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading models: $e');
|
DebugLog.error('[EquipmentProvider] Error loading next page', e);
|
||||||
rethrow;
|
_isLoadingMore = false;
|
||||||
}
|
_isLoading = false;
|
||||||
}
|
|
||||||
|
|
||||||
/// Charger toutes les marques uniques
|
|
||||||
Future<void> loadBrands() async {
|
|
||||||
try {
|
|
||||||
_brands = await _service.getAllBrands();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
|
||||||
print('Error loading brands: $e');
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Charger les modèles filtrés par marque
|
/// Recharge en changeant de filtre ou recherche
|
||||||
Future<List<String>> loadModelsByBrand(String brand) async {
|
Future<void> reload() async {
|
||||||
|
DebugLog.info('[EquipmentProvider] Reloading with new filters...');
|
||||||
|
await loadFirstPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le filtre de catégorie
|
||||||
|
void setSelectedCategory(EquipmentCategory? category) async {
|
||||||
|
if (_selectedCategory == category) return;
|
||||||
|
_selectedCategory = category;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le filtre de statut
|
||||||
|
void setSelectedStatus(EquipmentStatus? status) async {
|
||||||
|
if (_selectedStatus == status) return;
|
||||||
|
_selectedStatus = status;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le filtre de modèle
|
||||||
|
void setSelectedModel(String? model) async {
|
||||||
|
if (_selectedModel == model) return;
|
||||||
|
_selectedModel = model;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir la requête de recherche (avec debouncing)
|
||||||
|
void setSearchQuery(String query) {
|
||||||
|
if (_searchQuery == query) return;
|
||||||
|
_searchQuery = query;
|
||||||
|
|
||||||
|
// Annuler le timer précédent
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
|
||||||
|
if (_usePagination) {
|
||||||
|
// Attendre 500ms avant de recharger (debouncing)
|
||||||
|
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réinitialiser tous les filtres
|
||||||
|
void clearFilters() async {
|
||||||
|
_selectedCategory = null;
|
||||||
|
_selectedStatus = null;
|
||||||
|
_selectedModel = null;
|
||||||
|
_searchQuery = '';
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MÉTHODES COMPATIBILITÉ (pour ancien code)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Recharger les équipements (ancien système)
|
||||||
|
Future<void> refresh() async {
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream des équipements (pour compatibilité avec ancien code)
|
||||||
|
Stream<List<EquipmentModel>> get equipmentStream async* {
|
||||||
|
if (!_isInitialized && !_usePagination) {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
yield equipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprimer un équipement
|
||||||
|
Future<void> deleteEquipment(String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
return await _service.getModelsByBrand(brand);
|
await _dataService.deleteEquipment(equipmentId);
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading models by brand: $e');
|
DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Ajouter un équipement
|
/// Ajouter un équipement
|
||||||
Future<void> addEquipment(EquipmentModel equipment) async {
|
Future<void> addEquipment(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
await _service.createEquipment(equipment);
|
await _dataService.createEquipment(equipment.id, equipment.toMap());
|
||||||
|
if (_usePagination) {
|
||||||
// Recharger les modèles si un nouveau modèle a été ajouté
|
await reload();
|
||||||
if (equipment.model != null && !_models.contains(equipment.model)) {
|
} else {
|
||||||
await loadModels();
|
await loadEquipments();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error adding equipment: $e');
|
DebugLog.error('[EquipmentProvider] Error adding equipment', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre à jour un équipement
|
/// Mettre à jour un équipement
|
||||||
Future<void> updateEquipment(String id, Map<String, dynamic> data) async {
|
Future<void> updateEquipment(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
await _service.updateEquipment(id, data);
|
await _dataService.updateEquipment(equipment.id, equipment.toMap());
|
||||||
|
if (_usePagination) {
|
||||||
// Recharger les modèles si le modèle a changé
|
await reload();
|
||||||
if (data.containsKey('model')) {
|
} else {
|
||||||
await loadModels();
|
await loadEquipments();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating equipment: $e');
|
DebugLog.error('[EquipmentProvider] Error updating equipment', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supprimer un équipement
|
/// Charger les marques
|
||||||
Future<void> deleteEquipment(String id) async {
|
Future<void> loadBrands() async {
|
||||||
try {
|
await ensureLoaded();
|
||||||
await _service.deleteEquipment(id);
|
_extractUniqueValues();
|
||||||
} catch (e) {
|
}
|
||||||
print('Error deleting equipment: $e');
|
|
||||||
rethrow;
|
/// Charger les modèles
|
||||||
|
Future<void> loadModels() async {
|
||||||
|
await ensureLoaded();
|
||||||
|
_extractUniqueValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger les modèles d'une marque spécifique
|
||||||
|
Future<List<String>> loadModelsByBrand(String brand) async {
|
||||||
|
await ensureLoaded();
|
||||||
|
return _equipment
|
||||||
|
.where((eq) => eq.brand?.toLowerCase() == brand.toLowerCase())
|
||||||
|
.map((eq) => eq.model ?? '')
|
||||||
|
.where((model) => model.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger les sous-catégories d'une catégorie spécifique
|
||||||
|
Future<List<String>> loadSubCategoriesByCategory(EquipmentCategory category) async {
|
||||||
|
await ensureLoaded();
|
||||||
|
return _equipment
|
||||||
|
.where((eq) => eq.category == category)
|
||||||
|
.map((eq) => eq.subCategory ?? '')
|
||||||
|
.where((sub) => sub.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculer le statut réel d'un équipement (pour badge)
|
||||||
|
EquipmentStatus calculateRealStatus(EquipmentModel equipment) {
|
||||||
|
// Pour les consommables/câbles, vérifier le seuil critique
|
||||||
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
|
equipment.category == EquipmentCategory.cable) {
|
||||||
|
final availableQty = equipment.availableQuantity ?? 0;
|
||||||
|
final criticalThreshold = equipment.criticalThreshold ?? 0;
|
||||||
|
|
||||||
|
if (criticalThreshold > 0 && availableQty <= criticalThreshold) {
|
||||||
|
return EquipmentStatus.maintenance; // Utiliser maintenance pour indiquer un problème
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupérer un équipement par ID
|
// Sinon retourner le statut de base
|
||||||
Future<EquipmentModel?> getEquipmentById(String id) async {
|
return equipment.status;
|
||||||
try {
|
|
||||||
return await _service.getEquipmentById(id);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error getting equipment: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trouver des alternatives disponibles
|
|
||||||
Future<List<EquipmentModel>> findAlternatives(
|
|
||||||
String model,
|
|
||||||
DateTime startDate,
|
|
||||||
DateTime endDate,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
return await _service.findAlternatives(model, startDate, endDate);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error finding alternatives: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifier la disponibilité d'un équipement
|
|
||||||
Future<List<String>> checkAvailability(
|
|
||||||
String equipmentId,
|
|
||||||
DateTime startDate,
|
|
||||||
DateTime endDate,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
return await _service.checkAvailability(equipmentId, startDate, endDate);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error checking availability: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mettre à jour le stock d'un consommable
|
|
||||||
Future<void> updateStock(String id, int quantityChange) async {
|
|
||||||
try {
|
|
||||||
await _service.updateStock(id, quantityChange);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error updating stock: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifier les stocks critiques
|
|
||||||
Future<void> checkCriticalStock() async {
|
|
||||||
try {
|
|
||||||
await _service.checkCriticalStock();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error checking critical stock: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Générer les données du QR code
|
|
||||||
String generateQRCodeData(String equipmentId) {
|
|
||||||
return _service.generateQRCodeData(equipmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifier si un ID est unique
|
|
||||||
Future<bool> isIdUnique(String id) async {
|
|
||||||
try {
|
|
||||||
return await _service.isIdUnique(id);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error checking ID uniqueness: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculer le statut réel d'un équipement (asynchrone)
|
|
||||||
Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async {
|
|
||||||
return await _statusCalculator.calculateRealStatus(equipment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Invalider le cache du calculateur de statut
|
|
||||||
void invalidateStatusCache() {
|
|
||||||
_statusCalculator.invalidateCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
// === FILTRES ===
|
|
||||||
|
|
||||||
/// Définir la catégorie sélectionnée
|
|
||||||
void setSelectedCategory(EquipmentCategory? category) {
|
|
||||||
_selectedCategory = category;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Définir le statut sélectionné
|
|
||||||
void setSelectedStatus(EquipmentStatus? status) {
|
|
||||||
_selectedStatus = status;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Définir le modèle sélectionné
|
|
||||||
void setSelectedModel(String? model) {
|
|
||||||
_selectedModel = model;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Définir la recherche
|
|
||||||
void setSearchQuery(String query) {
|
|
||||||
_searchQuery = query;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Réinitialiser tous les filtres
|
|
||||||
void resetFilters() {
|
|
||||||
_selectedCategory = null;
|
|
||||||
_selectedStatus = null;
|
|
||||||
_selectedModel = null;
|
|
||||||
_searchQuery = '';
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +1,99 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/services/equipment_status_calculator.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import '../models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class EventProvider with ChangeNotifier {
|
class EventProvider with ChangeNotifier {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
List<EventModel> _events = [];
|
List<EventModel> _events = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
List<EventModel> get events => _events;
|
List<EventModel> get events => _events;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
// Récupérer les événements pour un utilisateur spécifique
|
// Cache des utilisateurs chargés depuis getEvents
|
||||||
Future<void> loadUserEvents(String userId,
|
Map<String, Map<String, dynamic>> _usersCache = {};
|
||||||
{bool canViewAllEvents = false}) async {
|
|
||||||
|
/// Charger les événements d'un utilisateur via l'API
|
||||||
|
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false}) async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
// Sauvegarder les paramètres
|
||||||
print(
|
_saveLastLoadParams(userId, canViewAllEvents);
|
||||||
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
|
||||||
|
|
||||||
QuerySnapshot eventsSnapshot = await _firestore.collection('events').get();
|
try {
|
||||||
print('Found ${eventsSnapshot.docs.length} events total');
|
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||||
|
|
||||||
|
// Charger via l'API - les permissions sont vérifiées côté serveur
|
||||||
|
final result = await _dataService.getEvents(userId: userId);
|
||||||
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Stocker les utilisateurs dans le cache
|
||||||
|
_usersCache = usersData.map((key, value) =>
|
||||||
|
MapEntry(key, value as Map<String, dynamic>)
|
||||||
|
);
|
||||||
|
|
||||||
|
print('Found ${eventsData.length} events from API');
|
||||||
|
|
||||||
List<EventModel> allEvents = [];
|
List<EventModel> allEvents = [];
|
||||||
int failedCount = 0;
|
int failedCount = 0;
|
||||||
|
|
||||||
// Parser chaque événement individuellement pour éviter qu'une erreur interrompe tout
|
// Parser chaque événement
|
||||||
for (var doc in eventsSnapshot.docs) {
|
for (var eventData in eventsData) {
|
||||||
try {
|
try {
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
final event = EventModel.fromMap(data, doc.id);
|
|
||||||
allEvents.add(event);
|
allEvents.add(event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Failed to parse event ${doc.id}: $e');
|
print('Failed to parse event ${eventData['id']}: $e');
|
||||||
failedCount++;
|
failedCount++;
|
||||||
// Continue avec les autres événements au lieu d'arrêter
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtrage amélioré pour les utilisateurs non-admin
|
_events = allEvents;
|
||||||
if (canViewAllEvents) {
|
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
|
||||||
_events = allEvents;
|
|
||||||
print('Admin user: showing all ${_events.length} events');
|
|
||||||
} else {
|
|
||||||
// Créer la référence utilisateur correctement
|
|
||||||
final userDocRef = _firestore.collection('users').doc(userId);
|
|
||||||
|
|
||||||
_events = allEvents.where((event) {
|
|
||||||
// Vérifier si l'utilisateur est dans l'équipe de l'événement
|
|
||||||
bool isInWorkforce = event.workforce.any((workforceRef) {
|
|
||||||
return workforceRef.path == userDocRef.path;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isInWorkforce) {
|
|
||||||
print('Event ${event.name} includes user $userId');
|
|
||||||
}
|
|
||||||
|
|
||||||
return isInWorkforce;
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading events: $e');
|
print('Error loading events: $e');
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_events = []; // S'assurer que la liste est vide en cas d'erreur
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer un événement spécifique
|
/// Recharger les événements (utilise le dernier userId)
|
||||||
Future<EventModel?> getEvent(String eventId) async {
|
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
|
||||||
|
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer un événement spécifique par ID
|
||||||
|
EventModel? getEventById(String eventId) {
|
||||||
try {
|
try {
|
||||||
final doc = await _firestore.collection('events').doc(eventId).get();
|
return _events.firstWhere((event) => event.id == eventId);
|
||||||
if (doc.exists) {
|
|
||||||
return EventModel.fromMap(doc.data()!, doc.id);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting event: $e');
|
return null;
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajouter un nouvel événement
|
/// Ajouter un nouvel événement
|
||||||
Future<void> addEvent(EventModel event) async {
|
Future<void> addEvent(EventModel event) async {
|
||||||
try {
|
try {
|
||||||
final docRef = await _firestore.collection('events').add(event.toMap());
|
// L'événement est créé via l'API dans le service
|
||||||
final newEvent = EventModel.fromMap(event.toMap(), docRef.id);
|
await refreshEvents(_lastUserId ?? '', canViewAllEvents: _lastCanViewAll);
|
||||||
_events.add(newEvent);
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error adding event: $e');
|
print('Error adding event: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre à jour un événement
|
/// Mettre à jour un événement
|
||||||
Future<void> updateEvent(EventModel event) async {
|
Future<void> updateEvent(EventModel event) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('events').doc(event.id).update(event.toMap());
|
// Mise à jour locale immédiate
|
||||||
final index = _events.indexWhere((e) => e.id == event.id);
|
final index = _events.indexWhere((e) => e.id == event.id);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
_events[index] = event;
|
_events[index] = event;
|
||||||
@@ -116,15 +105,11 @@ class EventProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supprimer un événement
|
/// Supprimer un événement
|
||||||
Future<void> deleteEvent(String eventId) async {
|
Future<void> deleteEvent(String eventId) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('events').doc(eventId).delete();
|
await _dataService.deleteEvent(eventId);
|
||||||
_events.removeWhere((event) => event.id == eventId);
|
_events.removeWhere((event) => event.id == eventId);
|
||||||
|
|
||||||
// Invalider le cache des statuts d'équipement
|
|
||||||
EquipmentStatusCalculator.invalidateGlobalCache();
|
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting event: $e');
|
print('Error deleting event: $e');
|
||||||
@@ -132,9 +117,56 @@ class EventProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vider la liste des événements
|
/// Récupérer les données d'un utilisateur depuis le cache
|
||||||
|
Map<String, dynamic>? getUserFromCache(String uid) {
|
||||||
|
return _usersCache[uid];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer les utilisateurs de la workforce d'un événement
|
||||||
|
List<Map<String, dynamic>> getWorkforceUsers(EventModel event) {
|
||||||
|
final users = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
for (final dynamic userRef in event.workforce) {
|
||||||
|
try {
|
||||||
|
String? uid;
|
||||||
|
|
||||||
|
// Tenter d'extraire l'UID
|
||||||
|
if (userRef is String) {
|
||||||
|
uid = userRef;
|
||||||
|
} else {
|
||||||
|
// Essayer d'extraire l'ID si c'est une DocumentReference
|
||||||
|
final ref = userRef as DocumentReference?;
|
||||||
|
uid = ref?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uid != null) {
|
||||||
|
final userData = getUserFromCache(uid);
|
||||||
|
if (userData != null) {
|
||||||
|
users.add(userData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignorer les références invalides
|
||||||
|
print('Skipping invalid workforce reference: $userRef');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vider la liste des événements
|
||||||
void clearEvents() {
|
void clearEvents() {
|
||||||
_events = [];
|
_events = [];
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variables pour stocker le dernier appel
|
||||||
|
String? _lastUserId;
|
||||||
|
bool _lastCanViewAll = false;
|
||||||
|
|
||||||
|
/// Sauvegarder les paramètres du dernier chargement
|
||||||
|
void _saveLastLoadParams(String userId, bool canViewAllEvents) {
|
||||||
|
_lastUserId = userId;
|
||||||
|
_lastCanViewAll = canViewAllEvents;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../models/user_model.dart';
|
import '../models/user_model.dart';
|
||||||
import '../models/role_model.dart';
|
import '../models/role_model.dart';
|
||||||
|
import '../models/notification_preferences_model.dart';
|
||||||
import '../utils/firebase_storage_manager.dart';
|
import '../utils/firebase_storage_manager.dart';
|
||||||
|
import '../services/api_service.dart';
|
||||||
|
import '../services/data_service.dart';
|
||||||
|
|
||||||
class LocalUserProvider with ChangeNotifier {
|
class LocalUserProvider with ChangeNotifier {
|
||||||
UserModel? _currentUser;
|
UserModel? _currentUser;
|
||||||
RoleModel? _currentRole;
|
RoleModel? _currentRole;
|
||||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
|
||||||
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
||||||
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
UserModel? get currentUser => _currentUser;
|
UserModel? get currentUser => _currentUser;
|
||||||
String? get uid => _currentUser?.uid;
|
String? get uid => _currentUser?.uid;
|
||||||
@@ -24,7 +26,7 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
RoleModel? get currentRole => _currentRole;
|
RoleModel? get currentRole => _currentRole;
|
||||||
List<String> get permissions => _currentRole?.permissions ?? [];
|
List<String> get permissions => _currentRole?.permissions ?? [];
|
||||||
|
|
||||||
/// Charge les données de l'utilisateur actuel
|
/// Charge les données de l'utilisateur actuel via Cloud Function
|
||||||
Future<void> loadUserData() async {
|
Future<void> loadUserData() async {
|
||||||
if (_auth.currentUser == null) {
|
if (_auth.currentUser == null) {
|
||||||
print('No current user in Auth');
|
print('No current user in Auth');
|
||||||
@@ -33,53 +35,31 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
|
|
||||||
print('Loading user data for: ${_auth.currentUser!.uid}');
|
print('Loading user data for: ${_auth.currentUser!.uid}');
|
||||||
try {
|
try {
|
||||||
DocumentSnapshot userDoc = await _firestore
|
// Utiliser la Cloud Function getCurrentUser
|
||||||
.collection('users')
|
final result = await apiService.call('getCurrentUser', {});
|
||||||
.doc(_auth.currentUser!.uid)
|
final userData = result['user'] as Map<String, dynamic>;
|
||||||
.get();
|
|
||||||
|
|
||||||
if (userDoc.exists) {
|
print('User data loaded from API: ${userData['uid']}');
|
||||||
print('User document found in Firestore');
|
|
||||||
final userData = userDoc.data() as Map<String, dynamic>;
|
|
||||||
print('User data: $userData');
|
|
||||||
|
|
||||||
// Si le document n'a pas d'UID, l'ajouter
|
// Extraire le rôle
|
||||||
if (!userData.containsKey('uid')) {
|
final roleData = userData['role'] as Map<String, dynamic>?;
|
||||||
await userDoc.reference.update({'uid': _auth.currentUser!.uid});
|
if (roleData != null) {
|
||||||
userData['uid'] = _auth.currentUser!.uid;
|
_currentRole = RoleModel.fromMap(roleData, roleData['id'] as String);
|
||||||
}
|
|
||||||
|
|
||||||
setUser(UserModel.fromMap(userData, userDoc.id));
|
|
||||||
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: '',
|
|
||||||
);
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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? ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
print('User data loaded successfully');
|
||||||
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading user data: $e');
|
print('Error loading user data: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -95,28 +75,55 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
/// Efface les données utilisateur
|
/// Efface les données utilisateur
|
||||||
void clearUser() {
|
void clearUser() {
|
||||||
_currentUser = null;
|
_currentUser = null;
|
||||||
|
_currentRole = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mise à jour des informations utilisateur
|
/// Mise à jour des informations utilisateur via Cloud Function
|
||||||
Future<void> updateUserData(
|
Future<void> updateUserData({
|
||||||
{String? firstName, String? lastName, String? phoneNumber}) async {
|
String? firstName,
|
||||||
|
String? lastName,
|
||||||
|
String? phoneNumber,
|
||||||
|
}) async {
|
||||||
if (_currentUser == null) return;
|
if (_currentUser == null) return;
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(_currentUser!.uid).set({
|
await _dataService.updateUser(
|
||||||
'firstName': firstName ?? _currentUser!.firstName,
|
_currentUser!.uid,
|
||||||
'lastName': lastName ?? _currentUser!.lastName,
|
{
|
||||||
'phone': phoneNumber ?? _currentUser!.phoneNumber,
|
'firstName': firstName ?? _currentUser!.firstName,
|
||||||
}, SetOptions(merge: true));
|
'lastName': lastName ?? _currentUser!.lastName,
|
||||||
|
'phoneNumber': phoneNumber ?? _currentUser!.phoneNumber,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
_currentUser = _currentUser!.copyWith(
|
_currentUser = _currentUser!.copyWith(
|
||||||
firstName: firstName ?? _currentUser!.firstName,
|
firstName: firstName,
|
||||||
lastName: lastName ?? _currentUser!.lastName,
|
lastName: lastName,
|
||||||
phoneNumber: phoneNumber ?? _currentUser!.phoneNumber,
|
phoneNumber: phoneNumber,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Erreur mise à jour utilisateur : $e');
|
debugPrint('Erreur mise à jour utilisateur : $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mise à jour des préférences de notifications
|
||||||
|
Future<void> updateNotificationPreferences(NotificationPreferences preferences) async {
|
||||||
|
if (_currentUser == null) return;
|
||||||
|
try {
|
||||||
|
await _dataService.updateUser(
|
||||||
|
_currentUser!.uid,
|
||||||
|
{
|
||||||
|
'notificationPreferences': preferences.toMap(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_currentUser = _currentUser!.copyWith(notificationPreferences: preferences);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Erreur mise à jour préférences notifications : $e');
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,16 +136,18 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
uid: _currentUser!.uid,
|
uid: _currentUser!.uid,
|
||||||
);
|
);
|
||||||
if (newProfilePhotoUrl != null) {
|
if (newProfilePhotoUrl != null) {
|
||||||
_firestore
|
// Mettre à jour via Cloud Function
|
||||||
.collection('users')
|
await _dataService.updateUser(
|
||||||
.doc(_currentUser!.uid)
|
_currentUser!.uid,
|
||||||
.update({'profilePhotoUrl': newProfilePhotoUrl});
|
{'profilePhotoUrl': newProfilePhotoUrl},
|
||||||
_currentUser =
|
);
|
||||||
_currentUser!.copyWith(profilePhotoUrl: newProfilePhotoUrl);
|
|
||||||
|
_currentUser = _currentUser!.copyWith(profilePhotoUrl: newProfilePhotoUrl);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Erreur mise à jour photo de profil : $e');
|
debugPrint('Erreur mise à jour photo de profil : $e');
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,23 +170,20 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
clearUser();
|
clearUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadRole() async {
|
/// Vérifie si l'utilisateur a une permission spécifique
|
||||||
if (_currentUser == null) return;
|
|
||||||
final roleId = _currentUser!.role;
|
|
||||||
if (roleId.isEmpty) return;
|
|
||||||
try {
|
|
||||||
final doc = await _firestore.collection('roles').doc(roleId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
_currentRole =
|
|
||||||
RoleModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error loading role: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hasPermission(String permission) {
|
bool hasPermission(String permission) {
|
||||||
return _currentRole?.permissions.contains(permission) ?? false;
|
return _currentRole?.permissions.contains(permission) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'utilisateur a toutes les permissions données
|
||||||
|
bool hasAllPermissions(List<String> permissions) {
|
||||||
|
if (_currentRole == null) return false;
|
||||||
|
return permissions.every((p) => _currentRole!.permissions.contains(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'utilisateur a au moins une des permissions données
|
||||||
|
bool hasAnyPermission(List<String> permissions) {
|
||||||
|
if (_currentRole == null) return false;
|
||||||
|
return permissions.any((p) => _currentRole!.permissions.contains(p));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ class MaintenanceProvider extends ChangeNotifier {
|
|||||||
// Getters
|
// Getters
|
||||||
List<MaintenanceModel> get maintenances => _maintenances;
|
List<MaintenanceModel> get maintenances => _maintenances;
|
||||||
|
|
||||||
/// Stream des maintenances pour un équipement spécifique
|
/// Récupérer les maintenances pour un équipement spécifique
|
||||||
Stream<List<MaintenanceModel>> getMaintenancesStream(String equipmentId) {
|
Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async {
|
||||||
return _service.getMaintenances(equipmentId);
|
return await _service.getMaintenancesByEquipment(equipmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream de toutes les maintenances
|
/// Récupérer toutes les maintenances
|
||||||
Stream<List<MaintenanceModel>> get allMaintenancesStream {
|
Future<List<MaintenanceModel>> getAllMaintenances() async {
|
||||||
return _service.getAllMaintenances();
|
return await _service.getAllMaintenances();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Créer une nouvelle maintenance
|
/// Créer une nouvelle maintenance
|
||||||
|
|||||||
51
em2rp/lib/providers/maintenance_provider_new.dart
Normal file
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,54 +1,54 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/user_model.dart';
|
|
||||||
import '../services/user_service.dart';
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:em2rp/models/user_model.dart';
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class UsersProvider with ChangeNotifier {
|
class UsersProvider with ChangeNotifier {
|
||||||
final UserService _userService;
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
|
||||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
|
||||||
List<UserModel> _users = [];
|
List<UserModel> _users = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
List<UserModel> get users => _users;
|
List<UserModel> get users => _users;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
UsersProvider(this._userService);
|
/// Récupération de tous les utilisateurs via l'API
|
||||||
|
|
||||||
/// Récupération de tous les utilisateurs
|
|
||||||
Future<void> fetchUsers() async {
|
Future<void> fetchUsers() async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final snapshot = await _firestore.collection('users').get();
|
final usersData = await _dataService.getUsers();
|
||||||
_users = snapshot.docs
|
_users = usersData.map((data) {
|
||||||
.map((doc) => UserModel.fromMap(doc.data(), doc.id))
|
return UserModel.fromMap(data, data['id'] as String);
|
||||||
.toList();
|
}).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error fetching users: $e');
|
print('Error fetching users: $e');
|
||||||
|
_users = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mise à jour d'un utilisateur
|
/// Recharger les utilisateurs
|
||||||
Future<void> updateUser(UserModel user, {String? roleId}) async {
|
Future<void> refresh() async {
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir un utilisateur par ID
|
||||||
|
UserModel? getUserById(String uid) {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(user.uid).update({
|
return _users.firstWhere((u) => u.uid == uid);
|
||||||
'firstName': user.firstName,
|
} catch (e) {
|
||||||
'lastName': user.lastName,
|
return null;
|
||||||
'email': user.email,
|
}
|
||||||
'phoneNumber': user.phoneNumber,
|
}
|
||||||
'role': roleId != null
|
|
||||||
? _firestore.collection('roles').doc(roleId)
|
/// Mettre à jour un utilisateur
|
||||||
: user.role,
|
Future<void> updateUser(UserModel user) async {
|
||||||
'profilePhotoUrl': user.profilePhotoUrl,
|
try {
|
||||||
});
|
await _dataService.updateUser(user.uid, user.toMap());
|
||||||
|
|
||||||
final index = _users.indexWhere((u) => u.uid == user.uid);
|
final index = _users.indexWhere((u) => u.uid == user.uid);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
@@ -61,10 +61,10 @@ class UsersProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Suppression d'un utilisateur
|
/// Suppression d'un utilisateur via Cloud Function
|
||||||
Future<void> deleteUser(String uid) async {
|
Future<void> deleteUser(String uid) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(uid).delete();
|
await _dataService.deleteUser(uid);
|
||||||
_users.removeWhere((user) => user.uid == uid);
|
_users.removeWhere((user) => user.uid == uid);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -73,97 +73,44 @@ class UsersProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Réinitialisation du mot de passe
|
/// Créer un utilisateur avec invitation par email
|
||||||
Future<void> resetPassword(String email) async {
|
Future<void> createUserWithEmailInvite({
|
||||||
await _userService.resetPassword(email);
|
required String email,
|
||||||
|
required String firstName,
|
||||||
|
required String lastName,
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createUserWithEmailInvite(BuildContext context, UserModel user,
|
/// Réinitialisation du mot de passe
|
||||||
{String? roleId}) async {
|
Future<void> resetPassword(String email) async {
|
||||||
String? authUid;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Vérifier l'état de l'authentification
|
// Firebase Auth reste OK (ce n'est pas Firestore)
|
||||||
final currentUser = _auth.currentUser;
|
await FirebaseAuth.instance.sendPasswordResetEmail(email: email);
|
||||||
print('Current user: ${currentUser?.email}');
|
print('Email de réinitialisation envoyé à $email');
|
||||||
|
|
||||||
if (currentUser == null) {
|
|
||||||
throw Exception('Aucun utilisateur connecté');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier la permission via le provider
|
|
||||||
final localUserProvider =
|
|
||||||
Provider.of<LocalUserProvider>(context, listen: false);
|
|
||||||
if (!localUserProvider.hasPermission('add_user')) {
|
|
||||||
throw Exception(
|
|
||||||
'Vous n\'avez pas la permission de créer des utilisateurs');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Créer l'utilisateur dans Firebase Authentication
|
|
||||||
final userCredential = await _auth.createUserWithEmailAndPassword(
|
|
||||||
email: user.email,
|
|
||||||
password: 'TemporaryPassword123!', // Mot de passe temporaire
|
|
||||||
);
|
|
||||||
|
|
||||||
authUid = userCredential.user!.uid;
|
|
||||||
print('User created in Auth with UID: $authUid');
|
|
||||||
|
|
||||||
// Créer le document dans Firestore avec l'UID de Auth comme ID
|
|
||||||
await _firestore.collection('users').doc(authUid).set({
|
|
||||||
'uid': authUid,
|
|
||||||
'firstName': user.firstName,
|
|
||||||
'lastName': user.lastName,
|
|
||||||
'email': user.email,
|
|
||||||
'phoneNumber': user.phoneNumber,
|
|
||||||
'role': roleId != null
|
|
||||||
? _firestore.collection('roles').doc(roleId)
|
|
||||||
: user.role,
|
|
||||||
'profilePhotoUrl': user.profilePhotoUrl,
|
|
||||||
'createdAt': FieldValue.serverTimestamp(),
|
|
||||||
});
|
|
||||||
|
|
||||||
print('User document created in Firestore with Auth UID');
|
|
||||||
|
|
||||||
// Envoyer un email de réinitialisation de mot de passe
|
|
||||||
await _auth.sendPasswordResetEmail(
|
|
||||||
email: user.email,
|
|
||||||
actionCodeSettings: ActionCodeSettings(
|
|
||||||
url: 'http://app.em2events.fr/finishSignUp?email=${user.email}',
|
|
||||||
handleCodeInApp: true,
|
|
||||||
androidPackageName: 'com.em2rp.app',
|
|
||||||
androidInstallApp: true,
|
|
||||||
androidMinimumVersion: '12',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
print('Password reset email sent');
|
|
||||||
|
|
||||||
// Ajouter l'utilisateur à la liste locale
|
|
||||||
final newUser = UserModel(
|
|
||||||
uid: authUid,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
email: user.email,
|
|
||||||
phoneNumber: user.phoneNumber,
|
|
||||||
role: roleId ?? user.role,
|
|
||||||
profilePhotoUrl: user.profilePhotoUrl,
|
|
||||||
);
|
|
||||||
_users.add(newUser);
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
// 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) {
|
} catch (e) {
|
||||||
print('Error creating user: $e');
|
print('Error reset password: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
255
em2rp/lib/services/alert_service.dart
Normal file
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}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
360
em2rp/lib/services/api_service.dart
Normal file
360
em2rp/lib/services/api_service.dart
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:em2rp/config/api_config.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Interface abstraite pour les opérations API
|
||||||
|
/// Permet de changer facilement de backend (Firebase Functions, REST API personnalisé, etc.)
|
||||||
|
abstract class ApiService {
|
||||||
|
Future<Map<String, dynamic>> call(String functionName, Map<String, dynamic> data);
|
||||||
|
Future<T?> get<T>(String endpoint, {Map<String, dynamic>? params});
|
||||||
|
Future<T> post<T>(String endpoint, Map<String, dynamic> data);
|
||||||
|
Future<T> put<T>(String endpoint, Map<String, dynamic> data);
|
||||||
|
Future<void> delete(String endpoint, {Map<String, dynamic>? data});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implémentation pour Firebase Cloud Functions
|
||||||
|
class FirebaseFunctionsApiService implements ApiService {
|
||||||
|
// URL de base - gérée par ApiConfig
|
||||||
|
String get _baseUrl => ApiConfig.baseUrl;
|
||||||
|
|
||||||
|
/// Récupère le token d'authentification Firebase
|
||||||
|
Future<String?> _getAuthToken() async {
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user == null) return null;
|
||||||
|
return await user.getIdToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headers par défaut avec authentification
|
||||||
|
Future<Map<String, String>> _getHeaders() async {
|
||||||
|
final token = await _getAuthToken();
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit récursivement TOUT en types JSON standards (String, num, bool, List, Map)
|
||||||
|
/// Garantit que toutes les Maps sont des Map<String, dynamic> littérales
|
||||||
|
dynamic _toJsonSafe(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
|
||||||
|
// Types primitifs JSON-safe
|
||||||
|
if (value is String || value is num || value is bool) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types Firestore
|
||||||
|
if (value is Timestamp) {
|
||||||
|
return value.toDate().toIso8601String();
|
||||||
|
}
|
||||||
|
if (value is DateTime) {
|
||||||
|
return value.toIso8601String();
|
||||||
|
}
|
||||||
|
if (value is DocumentReference) {
|
||||||
|
return value.path;
|
||||||
|
}
|
||||||
|
if (value is GeoPoint) {
|
||||||
|
// Créer une Map littérale explicite
|
||||||
|
return <String, dynamic>{
|
||||||
|
'latitude': value.latitude,
|
||||||
|
'longitude': value.longitude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listes - créer une nouvelle List littérale
|
||||||
|
if (value is List) {
|
||||||
|
final result = <dynamic>[];
|
||||||
|
for (final item in value) {
|
||||||
|
result.add(_toJsonSafe(item));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps - créer une nouvelle Map littérale explicite
|
||||||
|
if (value is Map) {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
value.forEach((k, v) {
|
||||||
|
final key = k.toString();
|
||||||
|
final convertedValue = _toJsonSafe(v);
|
||||||
|
result[key] = convertedValue;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type non supporté - retourner en String
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prépare les données pour jsonEncode en faisant un double passage
|
||||||
|
Map<String, dynamic> _prepareForJson(Map<String, dynamic> data) {
|
||||||
|
try {
|
||||||
|
// Premier passage : convertir tous les types Firestore
|
||||||
|
final safeData = _toJsonSafe(data);
|
||||||
|
|
||||||
|
// Deuxième passage : encoder puis décoder pour forcer la normalisation
|
||||||
|
// Cela garantit que tout est 100% compatible JSON et élimine tous les _JsonMap
|
||||||
|
final jsonString = jsonEncode(safeData);
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
|
||||||
|
// Force le type Map<String, dynamic>
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback - ne devrait jamais arriver
|
||||||
|
return Map<String, dynamic>.from(safeData as Map);
|
||||||
|
} catch (e) {
|
||||||
|
// Si l'encodage échoue, essayer de créer une copie profonde manuelle
|
||||||
|
DebugLog.error('[API] Error in _prepareForJson', e);
|
||||||
|
DebugLog.info('[API] Trying manual deep copy...');
|
||||||
|
return _deepCopyMap(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copie profonde manuelle d'une Map pour éviter les _JsonMap
|
||||||
|
Map<String, dynamic> _deepCopyMap(Map<String, dynamic> source) {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
source.forEach((key, value) {
|
||||||
|
if (value is Map) {
|
||||||
|
result[key] = _deepCopyMap(Map<String, dynamic>.from(value));
|
||||||
|
} else if (value is List) {
|
||||||
|
result[key] = _deepCopyList(value);
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copie profonde manuelle d'une List
|
||||||
|
List<dynamic> _deepCopyList(List<dynamic> source) {
|
||||||
|
return source.map((item) {
|
||||||
|
if (item is Map) {
|
||||||
|
return _deepCopyMap(Map<String, dynamic>.from(item));
|
||||||
|
} else if (item is List) {
|
||||||
|
return _deepCopyList(item);
|
||||||
|
} else {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> call(String functionName, Map<String, dynamic> data) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/$functionName');
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
// Préparer les données avec double passage pour éviter les _JsonMap
|
||||||
|
final preparedData = _prepareForJson(data);
|
||||||
|
|
||||||
|
// Log pour débogage (seulement en mode debug)
|
||||||
|
DebugLog.info('[API] Calling $functionName with eventId: ${preparedData['eventId']}');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Encoder directement avec jsonEncode standard
|
||||||
|
final bodyJson = jsonEncode({'data': preparedData});
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: bodyJson,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final responseData = jsonDecode(response.body);
|
||||||
|
return responseData is Map<String, dynamic> ? responseData : {};
|
||||||
|
} else {
|
||||||
|
final error = jsonDecode(response.body);
|
||||||
|
throw ApiException(
|
||||||
|
message: error['error'] ?? 'Unknown error',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[API] Error during request: $functionName', e);
|
||||||
|
throw ApiException(
|
||||||
|
message: 'Error calling $functionName: $e',
|
||||||
|
statusCode: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> get<T>(String endpoint, {Map<String, dynamic>? params}) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/$endpoint').replace(queryParameters: params);
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
final response = await http.get(url, headers: headers);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final responseData = jsonDecode(response.body);
|
||||||
|
return responseData as T?;
|
||||||
|
} else if (response.statusCode == 404) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
final error = jsonDecode(response.body);
|
||||||
|
throw ApiException(
|
||||||
|
message: error['error'] ?? 'Unknown error',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T> post<T>(String endpoint, Map<String, dynamic> data) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/$endpoint');
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
// Préparer les données avec double passage
|
||||||
|
final preparedData = _prepareForJson(data);
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'data': preparedData}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final responseData = jsonDecode(response.body);
|
||||||
|
return responseData as T;
|
||||||
|
} else {
|
||||||
|
final error = jsonDecode(response.body);
|
||||||
|
throw ApiException(
|
||||||
|
message: error['error'] ?? 'Unknown error',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T> put<T>(String endpoint, Map<String, dynamic> data) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/$endpoint');
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
// Préparer les données avec double passage
|
||||||
|
final preparedData = _prepareForJson(data);
|
||||||
|
|
||||||
|
final response = await http.put(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'data': preparedData}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final responseData = jsonDecode(response.body);
|
||||||
|
return responseData as T;
|
||||||
|
} else {
|
||||||
|
final error = jsonDecode(response.body);
|
||||||
|
throw ApiException(
|
||||||
|
message: error['error'] ?? 'Unknown error',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String endpoint, {Map<String, dynamic>? data}) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/$endpoint');
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
|
||||||
|
// Préparer les données avec double passage si data existe
|
||||||
|
final preparedData = data != null ? _prepareForJson(data) : null;
|
||||||
|
|
||||||
|
final response = await http.delete(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: preparedData != null ? jsonEncode({'data': preparedData}) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final error = jsonDecode(response.body);
|
||||||
|
throw ApiException(
|
||||||
|
message: error['error'] ?? 'Unknown error',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appelle une Cloud Function avec pagination
|
||||||
|
Future<Map<String, dynamic>> callPaginated(
|
||||||
|
String functionName,
|
||||||
|
Map<String, dynamic> params,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final headers = await _getHeaders();
|
||||||
|
final url = Uri.parse('$_baseUrl/$functionName');
|
||||||
|
|
||||||
|
DebugLog.info('[API] Calling paginated function: $functionName with params: $params');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'data': params}),
|
||||||
|
);
|
||||||
|
|
||||||
|
DebugLog.info('[API] Response status: ${response.statusCode}');
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
DebugLog.error('[API] Error response: ${response.body}');
|
||||||
|
throw Exception('API call failed with status ${response.statusCode}: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[API] Exception in callPaginated: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche rapide avec autocomplétion
|
||||||
|
Future<List<Map<String, dynamic>>> quickSearch(
|
||||||
|
String query, {
|
||||||
|
int limit = 10,
|
||||||
|
bool includeEquipments = true,
|
||||||
|
bool includeContainers = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = {
|
||||||
|
'query': query,
|
||||||
|
'limit': limit,
|
||||||
|
'includeEquipments': includeEquipments.toString(),
|
||||||
|
'includeContainers': includeContainers.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await callPaginated('quickSearch', params);
|
||||||
|
final results = response['results'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
return results.cast<Map<String, dynamic>>();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[API] Error in quickSearch: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception personnalisée pour les erreurs API
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int statusCode;
|
||||||
|
|
||||||
|
ApiException({
|
||||||
|
required this.message,
|
||||||
|
required this.statusCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ApiException($statusCode): $message';
|
||||||
|
|
||||||
|
bool get isForbidden => statusCode == 403;
|
||||||
|
bool get isUnauthorized => statusCode == 401;
|
||||||
|
bool get isNotFound => statusCode == 404;
|
||||||
|
bool get isConflict => statusCode == 409;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instance singleton du service API
|
||||||
|
final ApiService apiService = FirebaseFunctionsApiService();
|
||||||
|
|
||||||
52
em2rp/lib/services/container_equipment_service.dart
Normal file
52
em2rp/lib/services/container_equipment_service.dart
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
/// Service pour gérer la relation entre containers et équipements
|
||||||
|
/// Utilise le principe : seul le container stocke la référence aux équipements
|
||||||
|
class ContainerEquipmentService {
|
||||||
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
|
/// Récupère tous les containers contenant un équipement spécifique
|
||||||
|
/// Utilise une Cloud Function avec authentification et permissions
|
||||||
|
Future<List<ContainerModel>> getContainersByEquipment(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
final containersData = await _dataService.getContainersByEquipment(equipmentId);
|
||||||
|
|
||||||
|
return containersData.map((data) {
|
||||||
|
// L'ID est dans le champ 'id' retourné par la fonction
|
||||||
|
final id = data['id'] as String;
|
||||||
|
return ContainerModel.fromMap(data, id);
|
||||||
|
}).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[ContainerEquipmentService] Error getting containers for equipment $equipmentId: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un équipement est dans au moins un container
|
||||||
|
Future<bool> isEquipmentInAnyContainer(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
final containers = await getContainersByEquipment(equipmentId);
|
||||||
|
return containers.isNotEmpty;
|
||||||
|
} catch (e) {
|
||||||
|
print('[ContainerEquipmentService] Error checking if equipment is in container: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère le nombre de containers contenant un équipement
|
||||||
|
Future<int> getContainerCountForEquipment(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
final containers = await getContainersByEquipment(equipmentId);
|
||||||
|
return containers.length;
|
||||||
|
} catch (e) {
|
||||||
|
print('[ContainerEquipmentService] Error getting container count: $e');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instance globale singleton
|
||||||
|
final containerEquipmentService = ContainerEquipmentService();
|
||||||
|
|
||||||
@@ -1,61 +1,44 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
|
||||||
class ContainerService {
|
class ContainerService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
// Collection references
|
// ============================================================================
|
||||||
CollectionReference get _containersCollection => _firestore.collection('containers');
|
// CRUD Operations - Utilise le backend sécurisé
|
||||||
CollectionReference get _equipmentCollection => _firestore.collection('equipments');
|
// ============================================================================
|
||||||
|
|
||||||
// CRUD Operations
|
/// Créer un nouveau container (via Cloud Function)
|
||||||
|
|
||||||
/// Créer un nouveau container
|
|
||||||
Future<void> createContainer(ContainerModel container) async {
|
Future<void> createContainer(ContainerModel container) async {
|
||||||
try {
|
try {
|
||||||
await _containersCollection.doc(container.id).set(container.toMap());
|
await _apiService.call('createContainer', container.toMap()..['id'] = container.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating container: $e');
|
print('Error creating container: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre à jour un container
|
/// Mettre à jour un container (via Cloud Function)
|
||||||
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
|
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
data['updatedAt'] = Timestamp.fromDate(DateTime.now());
|
await _apiService.call('updateContainer', {
|
||||||
await _containersCollection.doc(id).update(data);
|
'containerId': id,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating container: $e');
|
print('Error updating container: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supprimer un container
|
/// Supprimer un container (via Cloud Function)
|
||||||
Future<void> deleteContainer(String id) async {
|
Future<void> deleteContainer(String id) async {
|
||||||
try {
|
try {
|
||||||
// Récupérer le container pour obtenir les équipements
|
await _apiService.call('deleteContainer', {'containerId': id});
|
||||||
final container = await getContainerById(id);
|
// Note: La Cloud Function gère maintenant la mise à jour des équipements
|
||||||
if (container != null && container.equipmentIds.isNotEmpty) {
|
|
||||||
// Retirer le container des parentBoxIds de chaque équipement
|
|
||||||
for (final equipmentId in container.equipmentIds) {
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
final updatedParents = equipment.parentBoxIds.where((boxId) => boxId != id).toList();
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'parentBoxIds': updatedParents,
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _containersCollection.doc(id).delete();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting container: $e');
|
print('Error deleting container: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -65,11 +48,10 @@ class ContainerService {
|
|||||||
/// Récupérer un container par ID
|
/// Récupérer un container par ID
|
||||||
Future<ContainerModel?> getContainerById(String id) async {
|
Future<ContainerModel?> getContainerById(String id) async {
|
||||||
try {
|
try {
|
||||||
final doc = await _containersCollection.doc(id).get();
|
final containersData = await _dataService.getContainersByIds([id]);
|
||||||
if (doc.exists) {
|
if (containersData.isEmpty) return null;
|
||||||
return ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
}
|
return ContainerModel.fromMap(containersData.first, id);
|
||||||
return null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting container: $e');
|
print('Error getting container: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -77,40 +59,40 @@ class ContainerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer tous les containers
|
/// Récupérer tous les containers
|
||||||
Stream<List<ContainerModel>> getContainers({
|
Future<List<ContainerModel>> getContainers({
|
||||||
ContainerType? type,
|
ContainerType? type,
|
||||||
EquipmentStatus? status,
|
EquipmentStatus? status,
|
||||||
String? searchQuery,
|
String? searchQuery,
|
||||||
}) {
|
}) async {
|
||||||
try {
|
try {
|
||||||
Query query = _containersCollection;
|
final containersData = await _dataService.getContainers();
|
||||||
|
|
||||||
// Filtre par type
|
var containerList = containersData
|
||||||
|
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Filtres côté client
|
||||||
if (type != null) {
|
if (type != null) {
|
||||||
query = query.where('type', isEqualTo: containerTypeToString(type));
|
containerList = containerList
|
||||||
}
|
.where((c) => c.type == type)
|
||||||
|
|
||||||
// Filtre par statut
|
|
||||||
if (status != null) {
|
|
||||||
query = query.where('status', isEqualTo: equipmentStatusToString(status));
|
|
||||||
}
|
|
||||||
|
|
||||||
return query.snapshots().map((snapshot) {
|
|
||||||
List<ContainerModel> containerList = snapshot.docs
|
|
||||||
.map((doc) => ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id))
|
|
||||||
.toList();
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// Filtre par recherche texte (côté client)
|
if (status != null) {
|
||||||
if (searchQuery != null && searchQuery.isNotEmpty) {
|
containerList = containerList
|
||||||
final lowerSearch = searchQuery.toLowerCase();
|
.where((c) => c.status == status)
|
||||||
containerList = containerList.where((container) {
|
.toList();
|
||||||
return container.name.toLowerCase().contains(lowerSearch) ||
|
}
|
||||||
container.id.toLowerCase().contains(lowerSearch);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
return containerList;
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
});
|
final lowerSearch = searchQuery.toLowerCase();
|
||||||
|
containerList = containerList.where((container) {
|
||||||
|
return container.name.toLowerCase().contains(lowerSearch) ||
|
||||||
|
container.id.toLowerCase().contains(lowerSearch);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return containerList;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting containers: $e');
|
print('Error getting containers: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -124,67 +106,16 @@ class ContainerService {
|
|||||||
String? userId,
|
String? userId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Récupérer le container
|
final response = await _apiService.call('addEquipmentToContainer', {
|
||||||
final container = await getContainerById(containerId);
|
'containerId': containerId,
|
||||||
if (container == null) {
|
'equipmentId': equipmentId,
|
||||||
return {'success': false, 'message': 'Container non trouvé'};
|
if (userId != null) 'userId': userId,
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier si l'équipement n'est pas déjà dans ce container
|
|
||||||
if (container.equipmentIds.contains(equipmentId)) {
|
|
||||||
return {'success': false, 'message': 'Cet équipement est déjà dans ce container'};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Récupérer l'équipement pour vérifier s'il est déjà dans d'autres containers
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (!equipmentDoc.exists) {
|
|
||||||
return {'success': false, 'message': 'Équipement non trouvé'};
|
|
||||||
}
|
|
||||||
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Avertir si l'équipement est déjà dans d'autres containers
|
|
||||||
List<String> otherContainers = [];
|
|
||||||
if (equipment.parentBoxIds.isNotEmpty) {
|
|
||||||
for (final boxId in equipment.parentBoxIds) {
|
|
||||||
final box = await getContainerById(boxId);
|
|
||||||
if (box != null) {
|
|
||||||
otherContainers.add(box.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre à jour le container
|
|
||||||
final updatedEquipmentIds = [...container.equipmentIds, equipmentId];
|
|
||||||
await updateContainer(containerId, {
|
|
||||||
'equipmentIds': updatedEquipmentIds,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour l'équipement
|
|
||||||
final updatedParentBoxIds = [...equipment.parentBoxIds, containerId];
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'parentBoxIds': updatedParentBoxIds,
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ajouter une entrée dans l'historique
|
|
||||||
await _addHistoryEntry(
|
|
||||||
containerId: containerId,
|
|
||||||
action: 'equipment_added',
|
|
||||||
equipmentId: equipmentId,
|
|
||||||
newValue: equipmentId,
|
|
||||||
userId: userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': response['success'] ?? false,
|
||||||
'message': 'Équipement ajouté avec succès',
|
'message': response['message'] ?? '',
|
||||||
'warnings': otherContainers.isNotEmpty
|
'warnings': response['warnings'],
|
||||||
? 'Attention : cet équipement est également dans les boites suivants : ${otherContainers.join(", ")}'
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error adding equipment to container: $e');
|
print('Error adding equipment to container: $e');
|
||||||
@@ -199,38 +130,11 @@ class ContainerService {
|
|||||||
String? userId,
|
String? userId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Récupérer le container
|
await _apiService.call('removeEquipmentFromContainer', {
|
||||||
final container = await getContainerById(containerId);
|
'containerId': containerId,
|
||||||
if (container == null) throw Exception('Container non trouvé');
|
'equipmentId': equipmentId,
|
||||||
|
if (userId != null) 'userId': userId,
|
||||||
// Mettre à jour le container
|
|
||||||
final updatedEquipmentIds = container.equipmentIds.where((id) => id != equipmentId).toList();
|
|
||||||
await updateContainer(containerId, {
|
|
||||||
'equipmentIds': updatedEquipmentIds,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour l'équipement
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
final updatedParentBoxIds = equipment.parentBoxIds.where((id) => id != containerId).toList();
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'parentBoxIds': updatedParentBoxIds,
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajouter une entrée dans l'historique
|
|
||||||
await _addHistoryEntry(
|
|
||||||
containerId: containerId,
|
|
||||||
action: 'equipment_removed',
|
|
||||||
equipmentId: equipmentId,
|
|
||||||
previousValue: equipmentId,
|
|
||||||
userId: userId,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error removing equipment from container: $e');
|
print('Error removing equipment from container: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -260,14 +164,13 @@ class ContainerService {
|
|||||||
|
|
||||||
// Vérifier la disponibilité de chaque équipement dans le container
|
// Vérifier la disponibilité de chaque équipement dans le container
|
||||||
List<String> unavailableEquipment = [];
|
List<String> unavailableEquipment = [];
|
||||||
for (final equipmentId in container.equipmentIds) {
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (container.equipmentIds.isNotEmpty) {
|
||||||
|
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
|
||||||
|
|
||||||
|
for (var data in equipmentsData) {
|
||||||
|
final id = data['id'] as String;
|
||||||
|
final equipment = EquipmentModel.fromMap(data, id);
|
||||||
if (equipment.status != EquipmentStatus.available) {
|
if (equipment.status != EquipmentStatus.available) {
|
||||||
unavailableEquipment.add('${equipment.name} (${equipment.status})');
|
unavailableEquipment.add('${equipment.name} (${equipment.status})');
|
||||||
}
|
}
|
||||||
@@ -295,15 +198,16 @@ class ContainerService {
|
|||||||
final container = await getContainerById(containerId);
|
final container = await getContainerById(containerId);
|
||||||
if (container == null) return [];
|
if (container == null) return [];
|
||||||
|
|
||||||
List<EquipmentModel> equipment = [];
|
if (container.equipmentIds.isEmpty) return [];
|
||||||
for (final equipmentId in container.equipmentIds) {
|
|
||||||
final doc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
equipment.add(EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return equipment;
|
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
|
||||||
|
|
||||||
|
return equipmentsData
|
||||||
|
.map((data) {
|
||||||
|
final id = data['id'] as String;
|
||||||
|
return EquipmentModel.fromMap(data, id);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting container equipment: $e');
|
print('Error getting container equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -313,12 +217,10 @@ class ContainerService {
|
|||||||
/// Trouver tous les containers contenant un équipement spécifique
|
/// Trouver tous les containers contenant un équipement spécifique
|
||||||
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
|
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
final snapshot = await _containersCollection
|
final containersData = await _dataService.getContainersByEquipment(equipmentId);
|
||||||
.where('equipmentIds', arrayContains: equipmentId)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
return snapshot.docs
|
return containersData
|
||||||
.map((doc) => ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id))
|
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
|
||||||
.toList();
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error finding containers with equipment: $e');
|
print('Error finding containers with equipment: $e');
|
||||||
@@ -367,8 +269,8 @@ class ContainerService {
|
|||||||
/// Vérifier si un ID de container existe déjà
|
/// Vérifier si un ID de container existe déjà
|
||||||
Future<bool> checkContainerIdExists(String id) async {
|
Future<bool> checkContainerIdExists(String id) async {
|
||||||
try {
|
try {
|
||||||
final doc = await _containersCollection.doc(id).get();
|
final container = await getContainerById(id);
|
||||||
return doc.exists;
|
return container != null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error checking container ID: $e');
|
print('Error checking container ID: $e');
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
646
em2rp/lib/services/data_service.dart
Normal file
646
em2rp/lib/services/data_service.dart
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Service générique pour les opérations de lecture de données via Cloud Functions
|
||||||
|
class DataService {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
DataService(this._apiService);
|
||||||
|
|
||||||
|
/// Récupère toutes les options
|
||||||
|
Future<List<Map<String, dynamic>>> getOptions() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getOptions', {});
|
||||||
|
final options = result['options'] as List<dynamic>?;
|
||||||
|
if (options == null) return [];
|
||||||
|
return options.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des options: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les types d'événements
|
||||||
|
Future<List<Map<String, dynamic>>> getEventTypes() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getEventTypes', {});
|
||||||
|
final eventTypes = result['eventTypes'] as List<dynamic>?;
|
||||||
|
if (eventTypes == null) return [];
|
||||||
|
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des types d\'événements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les rôles
|
||||||
|
Future<List<Map<String, dynamic>>> getRoles() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getRoles', {});
|
||||||
|
final roles = result['roles'] as List<dynamic>?;
|
||||||
|
if (roles == null) return [];
|
||||||
|
return roles.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des rôles: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour les équipements d'un événement
|
||||||
|
Future<void> updateEventEquipment({
|
||||||
|
required String eventId,
|
||||||
|
List<Map<String, dynamic>>? assignedEquipment,
|
||||||
|
String? preparationStatus,
|
||||||
|
String? loadingStatus,
|
||||||
|
String? unloadingStatus,
|
||||||
|
String? returnStatus,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{'eventId': eventId};
|
||||||
|
|
||||||
|
if (assignedEquipment != null) data['assignedEquipment'] = assignedEquipment;
|
||||||
|
if (preparationStatus != null) data['preparationStatus'] = preparationStatus;
|
||||||
|
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
|
||||||
|
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
|
||||||
|
if (returnStatus != null) data['returnStatus'] = returnStatus;
|
||||||
|
|
||||||
|
await _apiService.call('updateEventEquipment', data);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour des équipements de l\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour uniquement le statut d'un équipement
|
||||||
|
Future<void> updateEquipmentStatusOnly({
|
||||||
|
required String equipmentId,
|
||||||
|
String? status,
|
||||||
|
int? availableQuantity,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{'equipmentId': equipmentId};
|
||||||
|
|
||||||
|
if (status != null) data['status'] = status;
|
||||||
|
if (availableQuantity != null) data['availableQuantity'] = availableQuantity;
|
||||||
|
|
||||||
|
await _apiService.call('updateEquipmentStatusOnly', data);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour du statut de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un événement
|
||||||
|
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final requestData = {'eventId': eventId, 'data': data};
|
||||||
|
await _apiService.call('updateEvent', requestData);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un événement
|
||||||
|
Future<void> deleteEvent(String eventId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEvent', {'eventId': eventId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un équipement
|
||||||
|
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
// S'assurer que l'ID est dans les données
|
||||||
|
final equipmentData = Map<String, dynamic>.from(data);
|
||||||
|
equipmentData['id'] = equipmentId;
|
||||||
|
|
||||||
|
await _apiService.call('createEquipment', equipmentData);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un équipement
|
||||||
|
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('updateEquipment', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un équipement
|
||||||
|
Future<void> deleteEquipment(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEquipment', {'equipmentId': equipmentId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les événements utilisant un type d'événement donné
|
||||||
|
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getEventsByEventType', {'eventTypeId': eventTypeId});
|
||||||
|
final events = result['events'] as List<dynamic>?;
|
||||||
|
if (events == null) return [];
|
||||||
|
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des événements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un type d'événement
|
||||||
|
Future<String> createEventType({
|
||||||
|
required String name,
|
||||||
|
required double defaultPrice,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('createEventType', {
|
||||||
|
'name': name,
|
||||||
|
'defaultPrice': defaultPrice,
|
||||||
|
});
|
||||||
|
return result['id'] as String;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création du type d\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un type d'événement
|
||||||
|
Future<void> updateEventType({
|
||||||
|
required String eventTypeId,
|
||||||
|
String? name,
|
||||||
|
double? defaultPrice,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{'eventTypeId': eventTypeId};
|
||||||
|
if (name != null) data['name'] = name;
|
||||||
|
if (defaultPrice != null) data['defaultPrice'] = defaultPrice;
|
||||||
|
|
||||||
|
await _apiService.call('updateEventType', data);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour du type d\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un type d'événement
|
||||||
|
Future<void> deleteEventType(String eventTypeId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression du type d\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une option
|
||||||
|
Future<String> createOption(String code, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final requestData = {'code': code, ...data};
|
||||||
|
final result = await _apiService.call('createOption', requestData);
|
||||||
|
return result['id'] as String? ?? code;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création de l\'option: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour une option
|
||||||
|
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final requestData = {'optionId': optionId, ...data};
|
||||||
|
await _apiService.call('updateOption', requestData);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une option
|
||||||
|
Future<void> deleteOption(String optionId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteOption', {'optionId': optionId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'option: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LECTURE DES DONNÉES (avec permissions côté serveur)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère tous les événements (filtrés selon permissions)
|
||||||
|
/// Retourne { events: List<Map>, users: Map<String, Map> }
|
||||||
|
Future<Map<String, dynamic>> getEvents({String? userId}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{};
|
||||||
|
if (userId != null) data['userId'] = userId;
|
||||||
|
|
||||||
|
final result = await _apiService.call('getEvents', data);
|
||||||
|
|
||||||
|
// Extraire events et users
|
||||||
|
final events = result['events'] as List<dynamic>? ?? [];
|
||||||
|
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||||
|
'users': users,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des événements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les équipements (avec masquage des prix selon permissions)
|
||||||
|
Future<List<Map<String, dynamic>>> getEquipments() async {
|
||||||
|
try {
|
||||||
|
print('[DataService] Calling getEquipments API...');
|
||||||
|
final result = await _apiService.call('getEquipments', {});
|
||||||
|
print('[DataService] API call successful, parsing result...');
|
||||||
|
final equipments = result['equipments'] as List<dynamic>?;
|
||||||
|
if (equipments == null) {
|
||||||
|
print('[DataService] No equipments in result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
print('[DataService] Found ${equipments.length} equipments');
|
||||||
|
return equipments.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting equipments: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des équipements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère plusieurs équipements par leurs IDs
|
||||||
|
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) async {
|
||||||
|
try {
|
||||||
|
if (equipmentIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print('[DataService] Getting equipments by IDs: ${equipmentIds.length} items');
|
||||||
|
final result = await _apiService.call('getEquipmentsByIds', {
|
||||||
|
'equipmentIds': equipmentIds,
|
||||||
|
});
|
||||||
|
final equipments = result['equipments'] as List<dynamic>?;
|
||||||
|
if (equipments == null) {
|
||||||
|
print('[DataService] No equipments in result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
print('[DataService] Found ${equipments.length} equipments by IDs');
|
||||||
|
return equipments.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting equipments by IDs: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des équipements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les conteneurs
|
||||||
|
Future<List<Map<String, dynamic>>> getContainers() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getContainers', {});
|
||||||
|
final containers = result['containers'] as List<dynamic>?;
|
||||||
|
if (containers == null) return [];
|
||||||
|
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des conteneurs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère plusieurs containers par leurs IDs
|
||||||
|
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async {
|
||||||
|
try {
|
||||||
|
if (containerIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print('[DataService] Getting containers by IDs: ${containerIds.length} items');
|
||||||
|
final result = await _apiService.call('getContainersByIds', {
|
||||||
|
'containerIds': containerIds,
|
||||||
|
});
|
||||||
|
final containers = result['containers'] as List<dynamic>?;
|
||||||
|
if (containers == null) {
|
||||||
|
print('[DataService] No containers in result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
print('[DataService] Found ${containers.length} containers by IDs');
|
||||||
|
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting containers by IDs: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des containers: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EQUIPMENTS & CONTAINERS - Pagination
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère les équipements avec pagination et filtrage
|
||||||
|
Future<Map<String, dynamic>> getEquipmentsPaginated({
|
||||||
|
int limit = 20,
|
||||||
|
String? startAfter,
|
||||||
|
String? category,
|
||||||
|
String? status,
|
||||||
|
String? searchQuery,
|
||||||
|
String sortBy = 'id',
|
||||||
|
String sortOrder = 'asc',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{
|
||||||
|
'limit': limit,
|
||||||
|
'sortBy': sortBy,
|
||||||
|
'sortOrder': sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startAfter != null) params['startAfter'] = startAfter;
|
||||||
|
if (category != null) params['category'] = category;
|
||||||
|
if (status != null) params['status'] = status;
|
||||||
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
|
params['searchQuery'] = searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||||
|
'getEquipmentsPaginated',
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'equipments': (result['equipments'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as Map<String, dynamic>)
|
||||||
|
.toList() ?? [],
|
||||||
|
'hasMore': result['hasMore'] as bool? ?? false,
|
||||||
|
'lastVisible': result['lastVisible'] as String?,
|
||||||
|
'total': result['total'] as int? ?? 0,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
|
||||||
|
throw Exception('Erreur lors de la récupération paginée des équipements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les containers avec pagination et filtrage
|
||||||
|
Future<Map<String, dynamic>> getContainersPaginated({
|
||||||
|
int limit = 20,
|
||||||
|
String? startAfter,
|
||||||
|
String? type,
|
||||||
|
String? status,
|
||||||
|
String? searchQuery,
|
||||||
|
String? category,
|
||||||
|
String sortBy = 'id',
|
||||||
|
String sortOrder = 'asc',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{
|
||||||
|
'limit': limit,
|
||||||
|
'sortBy': sortBy,
|
||||||
|
'sortOrder': sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startAfter != null) params['startAfter'] = startAfter;
|
||||||
|
if (type != null) params['type'] = type;
|
||||||
|
if (status != null) params['status'] = status;
|
||||||
|
if (category != null) params['category'] = category;
|
||||||
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
|
params['searchQuery'] = searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||||
|
'getContainersPaginated',
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'containers': (result['containers'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as Map<String, dynamic>)
|
||||||
|
.toList() ?? [],
|
||||||
|
'hasMore': result['hasMore'] as bool? ?? false,
|
||||||
|
'lastVisible': result['lastVisible'] as String?,
|
||||||
|
'total': result['total'] as int? ?? 0,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[DataService] Error in getContainersPaginated', e);
|
||||||
|
throw Exception('Erreur lors de la récupération paginée des containers: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche rapide (autocomplétion)
|
||||||
|
Future<List<Map<String, dynamic>>> quickSearch(
|
||||||
|
String query, {
|
||||||
|
int limit = 10,
|
||||||
|
bool includeEquipments = true,
|
||||||
|
bool includeContainers = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
|
||||||
|
query,
|
||||||
|
limit: limit,
|
||||||
|
includeEquipments: includeEquipments,
|
||||||
|
includeContainers: includeContainers,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[DataService] Error in quickSearch', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USER - Current User
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère l'utilisateur actuellement authentifié avec son rôle
|
||||||
|
Future<Map<String, dynamic>> getCurrentUser() async {
|
||||||
|
try {
|
||||||
|
print('[DataService] Calling getCurrentUser API...');
|
||||||
|
final result = await _apiService.call('getCurrentUser', {});
|
||||||
|
print('[DataService] Current user loaded successfully');
|
||||||
|
return result['user'] as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
print('[DataService] Error getting current user: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération de l\'utilisateur actuel: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ALERTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère toutes les alertes
|
||||||
|
Future<List<Map<String, dynamic>>> getAlerts() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getAlerts', {});
|
||||||
|
final alerts = result['alerts'] as List<dynamic>?;
|
||||||
|
if (alerts == null) return [];
|
||||||
|
return alerts.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des alertes: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marque une alerte comme lue
|
||||||
|
Future<void> markAlertAsRead(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('markAlertAsRead', {'alertId': alertId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une alerte
|
||||||
|
Future<void> deleteAlert(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteAlert', {'alertId': alertId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'alerte: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EQUIPMENT AVAILABILITY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Vérifie la disponibilité d'un équipement
|
||||||
|
Future<Map<String, dynamic>> checkEquipmentAvailability({
|
||||||
|
required String equipmentId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('checkEquipmentAvailability', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la vérification de disponibilité: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période
|
||||||
|
/// Optimisé : une seule requête au lieu d'une par équipement
|
||||||
|
Future<Map<String, dynamic>> getConflictingEquipmentIds({
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
int installationTime = 0,
|
||||||
|
int disassemblyTime = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getConflictingEquipmentIds', {
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||||
|
'installationTime': installationTime,
|
||||||
|
'disassemblyTime': disassemblyTime,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des équipements en conflit: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAINTENANCES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère toutes les maintenances
|
||||||
|
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{};
|
||||||
|
if (equipmentId != null) data['equipmentId'] = equipmentId;
|
||||||
|
|
||||||
|
final result = await _apiService.call('getMaintenances', data);
|
||||||
|
final maintenances = result['maintenances'] as List<dynamic>?;
|
||||||
|
if (maintenances == null) return [];
|
||||||
|
return maintenances.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des maintenances: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une maintenance
|
||||||
|
Future<void> deleteMaintenance(String maintenanceId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteMaintenance', {'maintenanceId': maintenanceId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de la maintenance: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les containers contenant un équipement
|
||||||
|
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getContainersByEquipment', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
});
|
||||||
|
final containers = result['containers'] as List<dynamic>?;
|
||||||
|
if (containers == null) return [];
|
||||||
|
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des containers: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Récupère tous les utilisateurs (selon permissions)
|
||||||
|
Future<List<Map<String, dynamic>>> getUsers() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getUsers', {});
|
||||||
|
final users = result['users'] as List<dynamic>?;
|
||||||
|
if (users == null) return [];
|
||||||
|
return users.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des utilisateurs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère un utilisateur spécifique
|
||||||
|
Future<Map<String, dynamic>> getUser(String userId) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getUser', {'userId': userId});
|
||||||
|
return result['user'] as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un utilisateur (Auth + Firestore)
|
||||||
|
Future<void> deleteUser(String userId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteUser', {'userId': userId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un utilisateur
|
||||||
|
Future<void> updateUser(String userId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('updateUser', {
|
||||||
|
'userId': userId,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un utilisateur avec invitation par email
|
||||||
|
Future<Map<String, dynamic>> createUserWithInvite({
|
||||||
|
required String email,
|
||||||
|
required String firstName,
|
||||||
|
required String lastName,
|
||||||
|
String? phoneNumber,
|
||||||
|
required String roleId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('createUserWithInvite', {
|
||||||
|
'email': email,
|
||||||
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
'phoneNumber': phoneNumber ?? '',
|
||||||
|
'roleId': roleId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
150
em2rp/lib/services/email_service.dart
Normal file
150
em2rp/lib/services/email_service.dart
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:cloud_functions/cloud_functions.dart';
|
||||||
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
import 'package:em2rp/models/user_model.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
|
||||||
|
/// Service d'envoi d'emails via Cloud Functions
|
||||||
|
class EmailService {
|
||||||
|
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'us-central1');
|
||||||
|
|
||||||
|
/// Envoie un email d'alerte à un utilisateur
|
||||||
|
///
|
||||||
|
/// [alert] : L'alerte à envoyer
|
||||||
|
/// [userId] : ID de l'utilisateur destinataire
|
||||||
|
/// [templateType] : Type de template à utiliser (par défaut: 'alert-individual')
|
||||||
|
Future<bool> sendAlertEmail({
|
||||||
|
required AlertModel alert,
|
||||||
|
required String userId,
|
||||||
|
String templateType = 'alert-individual',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Vérifier que l'utilisateur est authentifié
|
||||||
|
final currentUser = FirebaseAuth.instance.currentUser;
|
||||||
|
if (currentUser == null) {
|
||||||
|
DebugLog.error('[EmailService] Utilisateur non authentifié');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[EmailService] Envoi email alerte ${alert.id} à $userId');
|
||||||
|
|
||||||
|
final result = await _functions.httpsCallable('sendAlertEmail').call({
|
||||||
|
'alertId': alert.id,
|
||||||
|
'userId': userId,
|
||||||
|
'templateType': templateType,
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = result.data as Map<String, dynamic>;
|
||||||
|
final success = data['success'] as bool? ?? false;
|
||||||
|
final skipped = data['skipped'] as bool? ?? false;
|
||||||
|
|
||||||
|
if (skipped) {
|
||||||
|
final reason = data['reason'] as String? ?? 'unknown';
|
||||||
|
DebugLog.info('[EmailService] Email non envoyé: $reason');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
DebugLog.info('[EmailService] Email envoyé avec succès');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EmailService] Erreur envoi email', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un email d'alerte à plusieurs utilisateurs
|
||||||
|
///
|
||||||
|
/// [alert] : L'alerte à envoyer
|
||||||
|
/// [userIds] : Liste des IDs des utilisateurs destinataires
|
||||||
|
Future<Map<String, bool>> sendAlertEmailToMultipleUsers({
|
||||||
|
required AlertModel alert,
|
||||||
|
required List<String> userIds,
|
||||||
|
String templateType = 'alert-individual',
|
||||||
|
}) async {
|
||||||
|
final results = <String, bool>{};
|
||||||
|
|
||||||
|
DebugLog.info('[EmailService] Envoi emails à ${userIds.length} utilisateurs');
|
||||||
|
|
||||||
|
// Envoyer en parallèle (max 5 à la fois pour éviter surcharge)
|
||||||
|
final batches = <List<String>>[];
|
||||||
|
for (var i = 0; i < userIds.length; i += 5) {
|
||||||
|
batches.add(userIds.sublist(
|
||||||
|
i,
|
||||||
|
i + 5 > userIds.length ? userIds.length : i + 5,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final batch in batches) {
|
||||||
|
final futures = batch.map((userId) => sendAlertEmail(
|
||||||
|
alert: alert,
|
||||||
|
userId: userId,
|
||||||
|
templateType: templateType,
|
||||||
|
));
|
||||||
|
|
||||||
|
final batchResults = await Future.wait(futures);
|
||||||
|
|
||||||
|
for (var i = 0; i < batch.length; i++) {
|
||||||
|
results[batch[i]] = batchResults[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final successCount = results.values.where((v) => v).length;
|
||||||
|
DebugLog.info('[EmailService] $successCount/${ userIds.length} emails envoyés');
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Détermine si une alerte doit être envoyée immédiatement ou en digest
|
||||||
|
///
|
||||||
|
/// [alert] : L'alerte à vérifier
|
||||||
|
/// Returns: true si immédiat, false si digest
|
||||||
|
bool shouldSendImmediate(AlertModel alert) {
|
||||||
|
// Les alertes critiques sont envoyées immédiatement
|
||||||
|
if (alert.severity == AlertSeverity.critical) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types d'alertes toujours immédiates
|
||||||
|
const immediateTypes = [
|
||||||
|
AlertType.lost, // Équipement perdu
|
||||||
|
AlertType.eventCancelled, // Événement annulé
|
||||||
|
];
|
||||||
|
|
||||||
|
return immediateTypes.contains(alert.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un email d'alerte en tenant compte des préférences
|
||||||
|
///
|
||||||
|
/// [alert] : L'alerte à envoyer
|
||||||
|
/// [userIds] : Liste des IDs des utilisateurs destinataires
|
||||||
|
Future<void> sendAlertWithPreferences({
|
||||||
|
required AlertModel alert,
|
||||||
|
required List<String> userIds,
|
||||||
|
}) async {
|
||||||
|
if (userIds.isEmpty) {
|
||||||
|
DebugLog.warning('[EmailService] Aucun utilisateur à notifier');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final immediate = shouldSendImmediate(alert);
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
DebugLog.info('[EmailService] Envoi immédiat (alerte critique)');
|
||||||
|
await sendAlertEmailToMultipleUsers(
|
||||||
|
alert: alert,
|
||||||
|
userIds: userIds,
|
||||||
|
templateType: 'alert-individual',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
DebugLog.info('[EmailService] Ajout au digest (alerte non critique)');
|
||||||
|
// Les alertes non critiques seront envoyées dans le digest quotidien
|
||||||
|
// La Cloud Function sendDailyDigest s'en occupera
|
||||||
|
// Rien à faire ici, les alertes sont déjà dans Firestore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,57 +1,101 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/models/alert_model.dart';
|
|
||||||
import 'package:em2rp/models/maintenance_model.dart';
|
import 'package:em2rp/models/maintenance_model.dart';
|
||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/maintenance_service.dart';
|
||||||
|
|
||||||
class EquipmentService {
|
class EquipmentService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
// Collection references
|
// ============================================================================
|
||||||
CollectionReference get _equipmentCollection => _firestore.collection('equipments');
|
// Helper privée - Charge TOUS les équipements avec pagination
|
||||||
CollectionReference get _alertsCollection => _firestore.collection('alerts');
|
// ============================================================================
|
||||||
CollectionReference get _eventsCollection => _firestore.collection('events');
|
|
||||||
|
|
||||||
// CRUD Operations
|
/// Charge tous les équipements en utilisant la pagination
|
||||||
|
Future<List<Map<String, dynamic>>> _getAllEquipmentsPaginated() async {
|
||||||
|
final allEquipments = <Map<String, dynamic>>[];
|
||||||
|
String? lastVisible;
|
||||||
|
bool hasMore = true;
|
||||||
|
|
||||||
/// Créer un nouvel équipement
|
while (hasMore) {
|
||||||
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
|
limit: 100,
|
||||||
|
startAfter: lastVisible,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipments = result['equipments'] as List<dynamic>;
|
||||||
|
allEquipments.addAll(equipments.cast<Map<String, dynamic>>());
|
||||||
|
|
||||||
|
hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
lastVisible = result['lastVisible'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEquipments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CRUD Operations - Utilise le backend sécurisé
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Créer un nouvel équipement (via Cloud Function)
|
||||||
Future<void> createEquipment(EquipmentModel equipment) async {
|
Future<void> createEquipment(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
await _equipmentCollection.doc(equipment.id).set(equipment.toMap());
|
if (equipment.id.isEmpty) {
|
||||||
|
throw Exception('L\'ID de l\'équipement est requis pour la création');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = equipment.toMap();
|
||||||
|
data['id'] = equipment.id; // S'assurer que l'ID est inclus
|
||||||
|
|
||||||
|
await _apiService.call('createEquipment', data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating equipment: $e');
|
print('Error creating equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre à jour un équipement
|
/// Mettre à jour un équipement (via Cloud Function)
|
||||||
Future<void> updateEquipment(String id, Map<String, dynamic> data) async {
|
Future<void> updateEquipment(String id, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
data['updatedAt'] = Timestamp.fromDate(DateTime.now());
|
if (data.isEmpty) {
|
||||||
await _equipmentCollection.doc(id).update(data);
|
throw Exception('Aucune donnée à mettre à jour');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _apiService.call('updateEquipment', {
|
||||||
|
'equipmentId': id,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating equipment: $e');
|
print('Error updating equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supprimer un équipement
|
/// Supprimer un équipement (via Cloud Function)
|
||||||
Future<void> deleteEquipment(String id) async {
|
Future<void> deleteEquipment(String id) async {
|
||||||
try {
|
try {
|
||||||
await _equipmentCollection.doc(id).delete();
|
await _apiService.call('deleteEquipment', {'equipmentId': id});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting equipment: $e');
|
print('Error deleting equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// READ Operations - Utilise Firestore streams (temps réel)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Récupérer un équipement par ID
|
/// Récupérer un équipement par ID
|
||||||
Future<EquipmentModel?> getEquipmentById(String id) async {
|
Future<EquipmentModel?> getEquipmentById(String id) async {
|
||||||
try {
|
try {
|
||||||
final doc = await _equipmentCollection.doc(id).get();
|
final equipmentsData = await _dataService.getEquipmentsByIds([id]);
|
||||||
if (doc.exists) {
|
if (equipmentsData.isEmpty) return null;
|
||||||
return EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
}
|
return EquipmentModel.fromMap(equipmentsData.first, id);
|
||||||
return null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting equipment: $e');
|
print('Error getting equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -59,81 +103,77 @@ class EquipmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer les équipements avec filtres
|
/// Récupérer les équipements avec filtres
|
||||||
Stream<List<EquipmentModel>> getEquipment({
|
Future<List<EquipmentModel>> getEquipment({
|
||||||
EquipmentCategory? category,
|
EquipmentCategory? category,
|
||||||
EquipmentStatus? status,
|
EquipmentStatus? status,
|
||||||
String? model,
|
String? model,
|
||||||
String? searchQuery,
|
String? searchQuery,
|
||||||
}) {
|
}) async {
|
||||||
try {
|
try {
|
||||||
Query query = _equipmentCollection;
|
final equipmentsData = await _getAllEquipmentsPaginated();
|
||||||
|
|
||||||
// Filtre par catégorie
|
var equipmentList = equipmentsData
|
||||||
|
.map((data) {
|
||||||
|
final id = data['id'] as String;
|
||||||
|
return EquipmentModel.fromMap(data, id);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Filtres côté client
|
||||||
if (category != null) {
|
if (category != null) {
|
||||||
query = query.where('category', isEqualTo: equipmentCategoryToString(category));
|
equipmentList = equipmentList
|
||||||
}
|
.where((e) => e.category == category)
|
||||||
|
|
||||||
// Filtre par statut
|
|
||||||
if (status != null) {
|
|
||||||
query = query.where('status', isEqualTo: equipmentStatusToString(status));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre par modèle
|
|
||||||
if (model != null && model.isNotEmpty) {
|
|
||||||
query = query.where('model', isEqualTo: model);
|
|
||||||
}
|
|
||||||
|
|
||||||
return query.snapshots().map((snapshot) {
|
|
||||||
List<EquipmentModel> equipmentList = snapshot.docs
|
|
||||||
.map((doc) => EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id))
|
|
||||||
.toList();
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// Filtre par recherche texte (côté client car Firestore ne supporte pas les recherches texte complexes)
|
if (status != null) {
|
||||||
if (searchQuery != null && searchQuery.isNotEmpty) {
|
equipmentList = equipmentList
|
||||||
final lowerSearch = searchQuery.toLowerCase();
|
.where((e) => e.status == status)
|
||||||
equipmentList = equipmentList.where((equipment) {
|
.toList();
|
||||||
return equipment.name.toLowerCase().contains(lowerSearch) ||
|
}
|
||||||
(equipment.model?.toLowerCase().contains(lowerSearch) ?? false) ||
|
|
||||||
equipment.id.toLowerCase().contains(lowerSearch);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
return equipmentList;
|
if (model != null && model.isNotEmpty) {
|
||||||
});
|
equipmentList = equipmentList
|
||||||
|
.where((e) => e.model == model)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
|
final lowerSearch = searchQuery.toLowerCase();
|
||||||
|
equipmentList = equipmentList.where((equipment) {
|
||||||
|
return equipment.name.toLowerCase().contains(lowerSearch) ||
|
||||||
|
(equipment.model?.toLowerCase().contains(lowerSearch) ?? false) ||
|
||||||
|
equipment.id.toLowerCase().contains(lowerSearch);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return equipmentList;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error streaming equipment: $e');
|
print('Error getting equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Availability & Stock Management - Logique métier côté client
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Vérifier la disponibilité d'un équipement pour une période donnée
|
/// Vérifier la disponibilité d'un équipement pour une période donnée
|
||||||
Future<List<String>> checkAvailability(
|
Future<List<Map<String, dynamic>>> checkAvailability(
|
||||||
String equipmentId,
|
String equipmentId,
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
DateTime endDate,
|
DateTime endDate,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final conflicts = <String>[];
|
final response = await _apiService.call('checkEquipmentAvailability', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
// Récupérer tous les événements qui chevauchent la période
|
final conflicts = (response['conflicts'] as List?)
|
||||||
final eventsQuery = await _eventsCollection
|
?.map((c) => c as Map<String, dynamic>)
|
||||||
.where('StartDateTime', isLessThanOrEqualTo: Timestamp.fromDate(endDate))
|
.toList() ?? [];
|
||||||
.where('EndDateTime', isGreaterThanOrEqualTo: Timestamp.fromDate(startDate))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
for (var eventDoc in eventsQuery.docs) {
|
|
||||||
final eventData = eventDoc.data() as Map<String, dynamic>;
|
|
||||||
final assignedEquipmentRaw = eventData['assignedEquipment'] ?? [];
|
|
||||||
|
|
||||||
if (assignedEquipmentRaw is List) {
|
|
||||||
for (var eq in assignedEquipmentRaw) {
|
|
||||||
if (eq is Map && eq['equipmentId'] == equipmentId) {
|
|
||||||
conflicts.add(eventDoc.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return conflicts;
|
return conflicts;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -149,26 +189,19 @@ class EquipmentService {
|
|||||||
DateTime endDate,
|
DateTime endDate,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
// Récupérer tous les équipements du même modèle
|
final response = await _apiService.call('findAlternativeEquipment', {
|
||||||
final equipmentQuery = await _equipmentCollection
|
'model': model,
|
||||||
.where('model', isEqualTo: model)
|
'startDate': startDate.toIso8601String(),
|
||||||
.get();
|
'endDate': endDate.toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
final alternatives = <EquipmentModel>[];
|
final alternatives = (response['alternatives'] as List?)
|
||||||
|
?.map((a) {
|
||||||
for (var doc in equipmentQuery.docs) {
|
final map = a as Map<String, dynamic>;
|
||||||
final equipment = EquipmentModel.fromMap(
|
final id = map['id'] as String;
|
||||||
doc.data() as Map<String, dynamic>,
|
return EquipmentModel.fromMap(map, id);
|
||||||
doc.id,
|
})
|
||||||
);
|
.toList() ?? [];
|
||||||
|
|
||||||
// Vérifier la disponibilité
|
|
||||||
final conflicts = await checkAvailability(equipment.id, startDate, endDate);
|
|
||||||
|
|
||||||
if (conflicts.isEmpty && equipment.status == EquipmentStatus.available) {
|
|
||||||
alternatives.add(equipment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return alternatives;
|
return alternatives;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -206,56 +239,22 @@ class EquipmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifier les stocks critiques et créer des alertes
|
|
||||||
Future<void> checkCriticalStock() async {
|
|
||||||
try {
|
|
||||||
final equipmentQuery = await _equipmentCollection
|
|
||||||
.where('category', whereIn: [
|
|
||||||
equipmentCategoryToString(EquipmentCategory.consumable),
|
|
||||||
equipmentCategoryToString(EquipmentCategory.cable),
|
|
||||||
])
|
|
||||||
.get();
|
|
||||||
|
|
||||||
for (var doc in equipmentQuery.docs) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
doc.data() as Map<String, dynamic>,
|
|
||||||
doc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipment.isCriticalStock) {
|
|
||||||
await _createLowStockAlert(equipment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error checking critical stock: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Créer une alerte de stock faible
|
/// Créer une alerte de stock faible
|
||||||
Future<void> _createLowStockAlert(EquipmentModel equipment) async {
|
Future<void> _createLowStockAlert(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
// Vérifier si une alerte existe déjà pour cet équipement
|
// Note: Cette fonction pourrait utiliser une Cloud Function dédiée dans le futur
|
||||||
final existingAlerts = await _alertsCollection
|
// Pour l'instant, on utilise l'API directement pour éviter de créer trop de fonctions
|
||||||
.where('equipmentId', isEqualTo: equipment.id)
|
// Cette méthode est appelée rarement et en arrière-plan
|
||||||
.where('type', isEqualTo: alertTypeToString(AlertType.lowStock))
|
await _apiService.call('createAlert', {
|
||||||
.where('isRead', isEqualTo: false)
|
'type': 'LOW_STOCK',
|
||||||
.get();
|
'title': 'Stock critique',
|
||||||
|
'message': 'Stock critique pour ${equipment.name} (${equipment.model ?? ""}): ${equipment.availableQuantity}/${equipment.criticalThreshold}',
|
||||||
if (existingAlerts.docs.isEmpty) {
|
'severity': 'HIGH',
|
||||||
final alert = AlertModel(
|
'equipmentId': equipment.id,
|
||||||
id: _alertsCollection.doc().id,
|
});
|
||||||
type: AlertType.lowStock,
|
|
||||||
message: 'Stock critique pour ${equipment.name} (${equipment.model ?? ""}): ${equipment.availableQuantity}/${equipment.criticalThreshold}',
|
|
||||||
equipmentId: equipment.id,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _alertsCollection.doc(alert.id).set(alert.toMap());
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating low stock alert: $e');
|
print('Error creating low stock alert: $e');
|
||||||
rethrow;
|
// Ne pas rethrow pour ne pas bloquer le processus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,61 +265,18 @@ class EquipmentService {
|
|||||||
return equipmentId;
|
return equipmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer tous les modèles uniques (pour l'indexation/autocomplete)
|
|
||||||
Future<List<String>> getAllModels() async {
|
|
||||||
try {
|
|
||||||
final equipmentQuery = await _equipmentCollection.get();
|
|
||||||
final models = <String>{};
|
|
||||||
|
|
||||||
for (var doc in equipmentQuery.docs) {
|
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
|
||||||
final model = data['model'] as String?;
|
|
||||||
if (model != null && model.isNotEmpty) {
|
|
||||||
models.add(model);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return models.toList()..sort();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error getting all models: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupérer toutes les marques uniques (pour l'indexation/autocomplete)
|
|
||||||
Future<List<String>> getAllBrands() async {
|
|
||||||
try {
|
|
||||||
final equipmentQuery = await _equipmentCollection.get();
|
|
||||||
final brands = <String>{};
|
|
||||||
|
|
||||||
for (var doc in equipmentQuery.docs) {
|
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
|
||||||
final brand = data['brand'] as String?;
|
|
||||||
if (brand != null && brand.isNotEmpty) {
|
|
||||||
brands.add(brand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return brands.toList()..sort();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error getting all brands: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupérer les modèles filtrés par marque
|
/// Récupérer les modèles filtrés par marque
|
||||||
Future<List<String>> getModelsByBrand(String brand) async {
|
Future<List<String>> getModelsByBrand(String brand) async {
|
||||||
try {
|
try {
|
||||||
final equipmentQuery = await _equipmentCollection
|
final equipmentsData = await _getAllEquipmentsPaginated();
|
||||||
.where('brand', isEqualTo: brand)
|
|
||||||
.get();
|
|
||||||
final models = <String>{};
|
final models = <String>{};
|
||||||
|
|
||||||
for (var doc in equipmentQuery.docs) {
|
for (var data in equipmentsData) {
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
if (data['brand'] == brand) {
|
||||||
final model = data['model'] as String?;
|
final model = data['model'] as String?;
|
||||||
if (model != null && model.isNotEmpty) {
|
if (model != null && model.isNotEmpty) {
|
||||||
models.add(model);
|
models.add(model);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,37 +287,51 @@ class EquipmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Récupérer les sous-catégories filtrées par catégorie
|
||||||
|
Future<List<String>> getSubCategoriesByCategory(EquipmentCategory category) async {
|
||||||
|
try {
|
||||||
|
final equipmentsData = await _getAllEquipmentsPaginated();
|
||||||
|
final subCategories = <String>{};
|
||||||
|
|
||||||
|
final categoryString = equipmentCategoryToString(category);
|
||||||
|
|
||||||
|
for (var data in equipmentsData) {
|
||||||
|
if (data['category'] == categoryString) {
|
||||||
|
final subCategory = data['subCategory'] as String?;
|
||||||
|
if (subCategory != null && subCategory.isNotEmpty) {
|
||||||
|
subCategories.add(subCategory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subCategories.toList()..sort();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error getting subcategories by category: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Vérifier si un ID existe déjà
|
/// Vérifier si un ID existe déjà
|
||||||
Future<bool> isIdUnique(String id) async {
|
Future<bool> isIdUnique(String id) async {
|
||||||
try {
|
try {
|
||||||
final doc = await _equipmentCollection.doc(id).get();
|
final equipment = await getEquipmentById(id);
|
||||||
return !doc.exists;
|
return equipment == null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error checking ID uniqueness: $e');
|
print('Error checking ID uniqueness: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer toutes les boîtes (équipements qui peuvent contenir d'autres équipements)
|
/// Récupérer toutes les boîtes/containers disponibles
|
||||||
Future<List<EquipmentModel>> getBoxes() async {
|
Future<List<ContainerModel>> getBoxes() async {
|
||||||
try {
|
try {
|
||||||
// Les boîtes sont généralement des équipements de catégorie "structure" ou "other"
|
final containersData = await _dataService.getContainers();
|
||||||
// On pourrait aussi ajouter un champ spécifique "isBox" dans le modèle
|
|
||||||
final equipmentQuery = await _equipmentCollection
|
|
||||||
.where('category', whereIn: [
|
|
||||||
equipmentCategoryToString(EquipmentCategory.structure),
|
|
||||||
equipmentCategoryToString(EquipmentCategory.other),
|
|
||||||
])
|
|
||||||
.get();
|
|
||||||
|
|
||||||
final boxes = <EquipmentModel>[];
|
final boxes = <ContainerModel>[];
|
||||||
for (var doc in equipmentQuery.docs) {
|
for (var data in containersData) {
|
||||||
final equipment = EquipmentModel.fromMap(
|
final id = data['id'] as String;
|
||||||
doc.data() as Map<String, dynamic>,
|
final container = ContainerModel.fromMap(data, id);
|
||||||
doc.id,
|
boxes.add(container);
|
||||||
);
|
|
||||||
// On pourrait ajouter un filtre supplémentaire ici si besoin
|
|
||||||
boxes.add(equipment);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return boxes;
|
return boxes;
|
||||||
@@ -376,27 +346,14 @@ class EquipmentService {
|
|||||||
try {
|
try {
|
||||||
if (ids.isEmpty) return [];
|
if (ids.isEmpty) return [];
|
||||||
|
|
||||||
final equipments = <EquipmentModel>[];
|
final equipmentsData = await _dataService.getEquipmentsByIds(ids);
|
||||||
|
|
||||||
// Firestore limite les requêtes whereIn à 10 éléments
|
return equipmentsData
|
||||||
// On doit donc diviser en plusieurs requêtes si nécessaire
|
.map((data) {
|
||||||
for (int i = 0; i < ids.length; i += 10) {
|
final id = data['id'] as String;
|
||||||
final batch = ids.skip(i).take(10).toList();
|
return EquipmentModel.fromMap(data, id);
|
||||||
final query = await _equipmentCollection
|
})
|
||||||
.where(FieldPath.documentId, whereIn: batch)
|
.toList();
|
||||||
.get();
|
|
||||||
|
|
||||||
for (var doc in query.docs) {
|
|
||||||
equipments.add(
|
|
||||||
EquipmentModel.fromMap(
|
|
||||||
doc.data() as Map<String, dynamic>,
|
|
||||||
doc.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return equipments;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting equipments by IDs: $e');
|
print('Error getting equipments by IDs: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -404,25 +361,13 @@ class EquipmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer les maintenances pour un équipement
|
/// Récupérer les maintenances pour un équipement
|
||||||
|
/// Note: Cette méthode est maintenant déléguée au MaintenanceService
|
||||||
|
/// pour éviter la duplication de code
|
||||||
Future<List<MaintenanceModel>> getMaintenancesForEquipment(String equipmentId) async {
|
Future<List<MaintenanceModel>> getMaintenancesForEquipment(String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
final maintenanceQuery = await _firestore
|
// Déléguer au MaintenanceService qui utilise déjà les Cloud Functions
|
||||||
.collection('maintenances')
|
final maintenanceService = MaintenanceService();
|
||||||
.where('equipmentIds', arrayContains: equipmentId)
|
return await maintenanceService.getMaintenancesByEquipment(equipmentId);
|
||||||
.orderBy('scheduledDate', descending: true)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
final maintenances = <MaintenanceModel>[];
|
|
||||||
for (var doc in maintenanceQuery.docs) {
|
|
||||||
maintenances.add(
|
|
||||||
MaintenanceModel.fromMap(
|
|
||||||
doc.data(),
|
|
||||||
doc.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return maintenances;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting maintenances for equipment: $e');
|
print('Error getting maintenances for equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
/// Service pour calculer dynamiquement le statut réel d'un équipement
|
/// Service pour calculer dynamiquement le statut réel d'un équipement
|
||||||
/// basé sur les événements en cours
|
/// basé sur les événements en cours
|
||||||
class EquipmentStatusCalculator {
|
class EquipmentStatusCalculator {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
|
|
||||||
/// Cache des événements pour éviter de multiples requêtes
|
/// Cache des statuts pour éviter de multiples requêtes
|
||||||
List<EventModel>? _cachedEvents;
|
Map<String, EquipmentStatus>? _cachedStatuses;
|
||||||
DateTime? _cacheTime;
|
DateTime? _cacheTime;
|
||||||
static const _cacheDuration = Duration(minutes: 1);
|
static const _cacheDuration = Duration(minutes: 1);
|
||||||
|
|
||||||
@@ -25,205 +24,57 @@ class EquipmentStatusCalculator {
|
|||||||
Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async {
|
Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async {
|
||||||
print('[StatusCalculator] Calculating status for: ${equipment.id}');
|
print('[StatusCalculator] Calculating status for: ${equipment.id}');
|
||||||
|
|
||||||
// Si l'équipement est marqué comme perdu ou HS, on garde ce statut
|
try {
|
||||||
// car c'est une information métier importante
|
final statuses = await calculateMultipleStatuses([equipment]);
|
||||||
if (equipment.status == EquipmentStatus.lost ||
|
return statuses[equipment.id] ?? equipment.status;
|
||||||
equipment.status == EquipmentStatus.outOfService) {
|
} catch (e) {
|
||||||
print('[StatusCalculator] ${equipment.id} is lost/outOfService -> keeping status');
|
print('[StatusCalculator] Error calculating status: $e');
|
||||||
return equipment.status;
|
return equipment.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charger les événements (avec cache)
|
|
||||||
await _loadEventsIfNeeded();
|
|
||||||
print('[StatusCalculator] Events loaded: ${_cachedEvents?.length ?? 0}');
|
|
||||||
|
|
||||||
// Vérifier si l'équipement est utilisé dans un événement en cours
|
|
||||||
final isInUse = await _isEquipmentInUse(equipment.id);
|
|
||||||
print('[StatusCalculator] ${equipment.id} isInUse: $isInUse');
|
|
||||||
|
|
||||||
if (isInUse) {
|
|
||||||
return EquipmentStatus.inUse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier si l'équipement est en maintenance
|
|
||||||
if (equipment.status == EquipmentStatus.maintenance) {
|
|
||||||
// On pourrait vérifier si la maintenance est toujours valide
|
|
||||||
// Pour l'instant on garde le statut
|
|
||||||
return EquipmentStatus.maintenance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier si l'équipement est loué
|
|
||||||
if (equipment.status == EquipmentStatus.rented) {
|
|
||||||
// On pourrait vérifier une date de retour prévue
|
|
||||||
// Pour l'instant on garde le statut
|
|
||||||
return EquipmentStatus.rented;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Par défaut, l'équipement est disponible
|
|
||||||
print('[StatusCalculator] ${equipment.id} -> AVAILABLE');
|
|
||||||
return EquipmentStatus.available;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calcule les statuts pour une liste d'équipements (optimisé)
|
/// Calcule les statuts pour une liste d'équipements (optimisé)
|
||||||
Future<Map<String, EquipmentStatus>> calculateMultipleStatuses(
|
Future<Map<String, EquipmentStatus>> calculateMultipleStatuses(
|
||||||
List<EquipmentModel> equipments,
|
List<EquipmentModel> equipments,
|
||||||
) async {
|
) async {
|
||||||
await _loadEventsIfNeeded();
|
|
||||||
|
|
||||||
final statuses = <String, EquipmentStatus>{};
|
|
||||||
|
|
||||||
// Trouver tous les équipements en cours d'utilisation
|
|
||||||
final equipmentIdsInUse = <String>{};
|
|
||||||
final containerIdsInUse = <String>{};
|
|
||||||
|
|
||||||
for (var event in _cachedEvents ?? []) {
|
|
||||||
// Un équipement est "en prestation" dès que la préparation est complétée
|
|
||||||
// et jusqu'à ce que le retour soit complété
|
|
||||||
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
|
|
||||||
event.preparationStatus == PreparationStatus.completedWithMissing;
|
|
||||||
|
|
||||||
final isReturned = event.returnStatus == ReturnStatus.completed ||
|
|
||||||
event.returnStatus == ReturnStatus.completedWithMissing;
|
|
||||||
|
|
||||||
final isInProgress = isPrepared && !isReturned;
|
|
||||||
|
|
||||||
if (isInProgress) {
|
|
||||||
// Ajouter les équipements directs
|
|
||||||
for (var eq in event.assignedEquipment) {
|
|
||||||
equipmentIdsInUse.add(eq.equipmentId);
|
|
||||||
}
|
|
||||||
// Ajouter les conteneurs
|
|
||||||
containerIdsInUse.addAll(event.assignedContainers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Récupérer les équipements dans les conteneurs en cours d'utilisation
|
|
||||||
if (containerIdsInUse.isNotEmpty) {
|
|
||||||
final containersSnapshot = await _firestore
|
|
||||||
.collection('containers')
|
|
||||||
.where(FieldPath.documentId, whereIn: containerIdsInUse.toList())
|
|
||||||
.get();
|
|
||||||
|
|
||||||
for (var doc in containersSnapshot.docs) {
|
|
||||||
final data = doc.data();
|
|
||||||
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
|
|
||||||
equipmentIdsInUse.addAll(equipmentIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculer le statut pour chaque équipement
|
|
||||||
for (var equipment in equipments) {
|
|
||||||
// Si perdu ou HS, on garde le statut
|
|
||||||
if (equipment.status == EquipmentStatus.lost ||
|
|
||||||
equipment.status == EquipmentStatus.outOfService) {
|
|
||||||
statuses[equipment.id] = equipment.status;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si en cours d'utilisation
|
|
||||||
if (equipmentIdsInUse.contains(equipment.id)) {
|
|
||||||
statuses[equipment.id] = EquipmentStatus.inUse;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si en maintenance ou loué, on garde le statut
|
|
||||||
if (equipment.status == EquipmentStatus.maintenance ||
|
|
||||||
equipment.status == EquipmentStatus.rented) {
|
|
||||||
statuses[equipment.id] = equipment.status;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Par défaut, disponible
|
|
||||||
statuses[equipment.id] = EquipmentStatus.available;
|
|
||||||
}
|
|
||||||
|
|
||||||
return statuses;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifie si un équipement est actuellement en cours d'utilisation
|
|
||||||
Future<bool> _isEquipmentInUse(String equipmentId) async {
|
|
||||||
print('[StatusCalculator] Checking if $equipmentId is in use...');
|
|
||||||
|
|
||||||
// Vérifier dans les événements directs
|
|
||||||
for (var event in _cachedEvents ?? []) {
|
|
||||||
// Un équipement est "en prestation" dès que la préparation est complétée
|
|
||||||
// et jusqu'à ce que le retour soit complété
|
|
||||||
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
|
|
||||||
event.preparationStatus == PreparationStatus.completedWithMissing;
|
|
||||||
|
|
||||||
final isReturned = event.returnStatus == ReturnStatus.completed ||
|
|
||||||
event.returnStatus == ReturnStatus.completedWithMissing;
|
|
||||||
|
|
||||||
final isInProgress = isPrepared && !isReturned;
|
|
||||||
|
|
||||||
if (!isInProgress) continue;
|
|
||||||
|
|
||||||
print('[StatusCalculator] Event ${event.name} is IN PROGRESS (prepared and not returned)');
|
|
||||||
|
|
||||||
// Vérifier si l'équipement est directement assigné
|
|
||||||
if (event.assignedEquipment.any((eq) => eq.equipmentId == equipmentId)) {
|
|
||||||
print('[StatusCalculator] $equipmentId found DIRECTLY in event ${event.name}');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier si l'équipement est dans un conteneur assigné
|
|
||||||
if (event.assignedContainers.isNotEmpty) {
|
|
||||||
print('[StatusCalculator] Checking containers for event ${event.name}: ${event.assignedContainers}');
|
|
||||||
final containersSnapshot = await _firestore
|
|
||||||
.collection('containers')
|
|
||||||
.where(FieldPath.documentId, whereIn: event.assignedContainers)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
for (var doc in containersSnapshot.docs) {
|
|
||||||
final data = doc.data();
|
|
||||||
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
|
|
||||||
print('[StatusCalculator] Container ${doc.id} contains: $equipmentIds');
|
|
||||||
if (equipmentIds.contains(equipmentId)) {
|
|
||||||
print('[StatusCalculator] $equipmentId found in CONTAINER ${doc.id}');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print('[StatusCalculator] $equipmentId is NOT in use');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Charge les événements si le cache est expiré
|
|
||||||
Future<void> _loadEventsIfNeeded() async {
|
|
||||||
if (_cachedEvents != null &&
|
|
||||||
_cacheTime != null &&
|
|
||||||
DateTime.now().difference(_cacheTime!) < _cacheDuration) {
|
|
||||||
return; // Cache encore valide
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final eventsSnapshot = await _firestore.collection('events').get();
|
final equipmentIds = equipments.map((e) => e.id).toList();
|
||||||
|
|
||||||
_cachedEvents = eventsSnapshot.docs
|
final response = await _apiService.call('calculateEquipmentStatuses', {
|
||||||
.map((doc) {
|
'equipmentIds': equipmentIds,
|
||||||
try {
|
});
|
||||||
return EventModel.fromMap(doc.data(), doc.id);
|
|
||||||
} catch (e) {
|
|
||||||
print('[EquipmentStatusCalculator] Error parsing event ${doc.id}: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.whereType<EventModel>()
|
|
||||||
.where((event) => event.status != EventStatus.canceled) // Ignorer les événements annulés
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
|
final statusesMap = response['statuses'] as Map<String, dynamic>?;
|
||||||
|
if (statusesMap == null) {
|
||||||
|
throw Exception('Invalid response from calculateEquipmentStatuses');
|
||||||
|
}
|
||||||
|
|
||||||
|
final statuses = <String, EquipmentStatus>{};
|
||||||
|
statusesMap.forEach((equipmentId, statusString) {
|
||||||
|
if (statusString != null) {
|
||||||
|
statuses[equipmentId] = equipmentStatusFromString(statusString as String);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mise en cache
|
||||||
|
_cachedStatuses = statuses;
|
||||||
_cacheTime = DateTime.now();
|
_cacheTime = DateTime.now();
|
||||||
|
|
||||||
|
return statuses;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentStatusCalculator] Error loading events: $e');
|
print('[StatusCalculator] Error calculating multiple statuses: $e');
|
||||||
_cachedEvents = [];
|
// En cas d'erreur, retourner les statuts actuels
|
||||||
|
final fallbackStatuses = <String, EquipmentStatus>{};
|
||||||
|
for (var equipment in equipments) {
|
||||||
|
fallbackStatuses[equipment.id] = equipment.status;
|
||||||
|
}
|
||||||
|
return fallbackStatuses;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invalide le cache (à appeler après une modification d'événement)
|
/// Invalide le cache (à appeler après une modification d'événement)
|
||||||
void invalidateCache() {
|
void invalidateCache() {
|
||||||
_cachedEvents = null;
|
_cachedStatuses = null;
|
||||||
_cacheTime = null;
|
_cacheTime = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
|
||||||
/// Type de conflit
|
/// Type de conflit
|
||||||
enum ConflictType {
|
enum ConflictType {
|
||||||
@@ -63,9 +64,16 @@ class AvailabilityConflict {
|
|||||||
|
|
||||||
/// Service pour vérifier la disponibilité du matériel
|
/// Service pour vérifier la disponibilité du matériel
|
||||||
class EventAvailabilityService {
|
class EventAvailabilityService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
/// Vérifie si un équipement est disponible pour une plage de dates
|
/// Helper pour récupérer uniquement la liste d'événements
|
||||||
|
Future<List<Map<String, dynamic>>> _getEventsList() async {
|
||||||
|
final result = await _dataService.getEvents();
|
||||||
|
final events = result['events'] as List<dynamic>? ?? [];
|
||||||
|
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un équipement est disponible pour une plage de dates via Cloud Function
|
||||||
Future<List<AvailabilityConflict>> checkEquipmentAvailability({
|
Future<List<AvailabilityConflict>> checkEquipmentAvailability({
|
||||||
required String equipmentId,
|
required String equipmentId,
|
||||||
required String equipmentName,
|
required String equipmentName,
|
||||||
@@ -76,73 +84,57 @@ class EventAvailabilityService {
|
|||||||
final conflicts = <AvailabilityConflict>[];
|
final conflicts = <AvailabilityConflict>[];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer TOUS les événements (on filtre côté client car arrayContains avec objet ne marche pas)
|
print('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
|
||||||
final eventsSnapshot = await _firestore.collection('events').get();
|
|
||||||
|
|
||||||
for (var doc in eventsSnapshot.docs) {
|
// Utiliser la Cloud Function pour vérifier la disponibilité
|
||||||
if (excludeEventId != null && doc.id == excludeEventId) {
|
final result = await _dataService.checkEquipmentAvailability(
|
||||||
continue; // Ignorer l'événement en cours d'édition
|
equipmentId: equipmentId,
|
||||||
}
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
print('[EventAvailabilityService] Result for $equipmentId: $result');
|
||||||
final data = doc.data();
|
|
||||||
final event = EventModel.fromMap(data, doc.id);
|
|
||||||
|
|
||||||
// Ignorer les événements annulés
|
final available = result['available'] as bool? ?? true;
|
||||||
if (event.status == EventStatus.canceled) {
|
print('[EventAvailabilityService] Equipment $equipmentId available: $available');
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier si cet événement contient l'équipement recherché
|
if (!available) {
|
||||||
final assignedEquipment = event.assignedEquipment.firstWhere(
|
final conflictsData = result['conflicts'] as List<dynamic>? ?? [];
|
||||||
(eq) => eq.equipmentId == equipmentId,
|
print('[EventAvailabilityService] Found ${conflictsData.length} conflicts for equipment $equipmentId');
|
||||||
orElse: () => EventEquipment(equipmentId: ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Si l'équipement est assigné à cet événement, il est indisponible
|
for (final conflictData in conflictsData) {
|
||||||
// (peu importe le statut de préparation/chargement/retour)
|
final conflict = conflictData as Map<String, dynamic>;
|
||||||
if (assignedEquipment.equipmentId.isNotEmpty) {
|
final eventId = conflict['eventId'] as String;
|
||||||
// Calculer les dates réelles avec temps d'installation et démontage
|
|
||||||
final eventRealStartDate = event.startDateTime.subtract(
|
|
||||||
Duration(hours: event.installationTime),
|
|
||||||
);
|
|
||||||
final eventRealEndDate = event.endDateTime.add(
|
|
||||||
Duration(hours: event.disassemblyTime),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Vérifier le chevauchement des dates
|
// Le backend retourne déjà eventData
|
||||||
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
|
final eventData = conflict['eventData'] as Map<String, dynamic>?;
|
||||||
final overlapDays = _calculateOverlapDays(
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
eventRealStartDate,
|
|
||||||
eventRealEndDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (eventData != null && eventData.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final event = EventModel.fromMap(eventData, eventId);
|
||||||
conflicts.add(AvailabilityConflict(
|
conflicts.add(AvailabilityConflict(
|
||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
equipmentName: equipmentName,
|
equipmentName: equipmentName,
|
||||||
conflictingEvent: event,
|
conflictingEvent: event,
|
||||||
overlapDays: overlapDays,
|
overlapDays: conflict['overlapDays'] as int? ?? 0,
|
||||||
));
|
));
|
||||||
|
print('[EventAvailabilityService] Added conflict with event ${event.name}');
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventAvailabilityService] Error creating EventModel: $e');
|
||||||
|
print('[EventAvailabilityService] EventData: $eventData');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
print('[EventAvailabilityService] Error processing event ${doc.id}: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventAvailabilityService] Error checking availability: $e');
|
print('[EventAvailabilityService] Error checking availability: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print('[EventAvailabilityService] Returning ${conflicts.length} conflicts for equipment $equipmentId');
|
||||||
return conflicts;
|
return conflicts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper pour formater les dates dans les logs
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifie la disponibilité pour une liste d'équipements
|
/// Vérifie la disponibilité pour une liste d'équipements
|
||||||
Future<Map<String, List<AvailabilityConflict>>> checkMultipleEquipmentAvailability({
|
Future<Map<String, List<AvailabilityConflict>>> checkMultipleEquipmentAvailability({
|
||||||
required List<String> equipmentIds,
|
required List<String> equipmentIds,
|
||||||
@@ -203,16 +195,17 @@ class EventAvailabilityService {
|
|||||||
int reservedQuantity = 0;
|
int reservedQuantity = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer tous les événements (on filtre côté client)
|
// Récupérer tous les événements via Cloud Function
|
||||||
final eventsSnapshot = await _firestore.collection('events').get();
|
final eventsData = await _getEventsList();
|
||||||
|
|
||||||
for (var doc in eventsSnapshot.docs) {
|
for (var eventData in eventsData) {
|
||||||
if (excludeEventId != null && doc.id == excludeEventId) {
|
final eventId = eventData['id'] as String;
|
||||||
|
if (excludeEventId != null && eventId == excludeEventId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final event = EventModel.fromMap(doc.data(), doc.id);
|
final event = EventModel.fromMap(eventData, eventId);
|
||||||
|
|
||||||
// Ignorer les événements annulés
|
// Ignorer les événements annulés
|
||||||
if (event.status == EventStatus.canceled) {
|
if (event.status == EventStatus.canceled) {
|
||||||
@@ -241,7 +234,7 @@ class EventAvailabilityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventAvailabilityService] Error processing event ${doc.id} for quantity: $e');
|
print('[EventAvailabilityService] Error processing event $eventId for quantity: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -275,13 +268,14 @@ class EventAvailabilityService {
|
|||||||
// ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante
|
// ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante
|
||||||
if (availableQty < requestedQuantity) {
|
if (availableQty < requestedQuantity) {
|
||||||
// Trouver les événements qui réservent cette quantité
|
// Trouver les événements qui réservent cette quantité
|
||||||
final eventsSnapshot = await _firestore.collection('events').get();
|
final eventsData = await _getEventsList();
|
||||||
|
|
||||||
for (var doc in eventsSnapshot.docs) {
|
for (var eventData in eventsData) {
|
||||||
if (excludeEventId != null && doc.id == excludeEventId) continue;
|
final eventId = eventData['id'] as String;
|
||||||
|
if (excludeEventId != null && eventId == excludeEventId) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final event = EventModel.fromMap(doc.data(), doc.id);
|
final event = EventModel.fromMap(eventData, eventId);
|
||||||
|
|
||||||
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
|
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
|
||||||
final assignedEquipment = event.assignedEquipment.firstWhere(
|
final assignedEquipment = event.assignedEquipment.firstWhere(
|
||||||
@@ -304,7 +298,7 @@ class EventAvailabilityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventAvailabilityService] Error processing event ${doc.id}: $e');
|
print('[EventAvailabilityService] Error processing event $eventId: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,15 +328,16 @@ class EventAvailabilityService {
|
|||||||
final conflictingChildrenIds = <String>[];
|
final conflictingChildrenIds = <String>[];
|
||||||
|
|
||||||
// Vérifier d'abord si la boîte complète est utilisée
|
// Vérifier d'abord si la boîte complète est utilisée
|
||||||
final eventsSnapshot = await _firestore.collection('events').get();
|
final eventsData = await _getEventsList();
|
||||||
bool isContainerFullyUsed = false;
|
bool isContainerFullyUsed = false;
|
||||||
EventModel? containerConflictingEvent;
|
EventModel? containerConflictingEvent;
|
||||||
|
|
||||||
for (var doc in eventsSnapshot.docs) {
|
for (var eventData in eventsData) {
|
||||||
if (excludeEventId != null && doc.id == excludeEventId) continue;
|
final eventId = eventData['id'] as String;
|
||||||
|
if (excludeEventId != null && eventId == excludeEventId) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final event = EventModel.fromMap(doc.data(), doc.id);
|
final event = EventModel.fromMap(eventData, eventId);
|
||||||
|
|
||||||
// Ignorer les événements annulés
|
// Ignorer les événements annulés
|
||||||
if (event.status == EventStatus.canceled) {
|
if (event.status == EventStatus.canceled) {
|
||||||
@@ -366,7 +361,7 @@ class EventAvailabilityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventAvailabilityService] Error processing event ${doc.id}: $e');
|
print('[EventAvailabilityService] Error processing event $eventId: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:firebase_storage/firebase_storage.dart';
|
import 'package:firebase_storage/firebase_storage.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -7,32 +6,46 @@ import 'dart:convert';
|
|||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/models/event_type_model.dart';
|
import 'package:em2rp/models/event_type_model.dart';
|
||||||
import 'package:em2rp/models/user_model.dart';
|
import 'package:em2rp/models/user_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/alert_service.dart';
|
||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
class EventFormService {
|
class EventFormService {
|
||||||
|
static final ApiService _apiService = apiService;
|
||||||
|
static final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// READ Operations - Utilise l'API (sécurisé avec permissions côté serveur)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
static Future<List<EventTypeModel>> fetchEventTypes() async {
|
static Future<List<EventTypeModel>> fetchEventTypes() async {
|
||||||
developer.log('Fetching event types from Firestore...', name: 'EventFormService');
|
developer.log('Fetching event types via API...', name: 'EventFormService');
|
||||||
try {
|
try {
|
||||||
final snapshot = await FirebaseFirestore.instance.collection('eventTypes').get();
|
final eventTypesData = await _dataService.getEventTypes();
|
||||||
final eventTypes = snapshot.docs.map((doc) => EventTypeModel.fromMap(doc.data(), doc.id)).toList();
|
final eventTypes = eventTypesData.map((data) => EventTypeModel.fromMap(data, data['id'] as String)).toList();
|
||||||
developer.log('${eventTypes.length} event types loaded.', name: 'EventFormService');
|
developer.log('${eventTypes.length} event types loaded.', name: 'EventFormService');
|
||||||
return eventTypes;
|
return eventTypes;
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
developer.log('Error fetching event types', name: 'EventFormService', error: e, stackTrace: s);
|
developer.log('Error fetching event types', name: 'EventFormService', error: e, stackTrace: s);
|
||||||
throw Exception("Could not load event types. Please check Firestore permissions.");
|
throw Exception("Could not load event types. Please check permissions.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<UserModel>> fetchUsers() async {
|
static Future<List<UserModel>> fetchUsers() async {
|
||||||
try {
|
try {
|
||||||
final snapshot = await FirebaseFirestore.instance.collection('users').get();
|
final usersData = await _dataService.getUsers();
|
||||||
return snapshot.docs.map((doc) => UserModel.fromMap(doc.data(), doc.id)).toList();
|
return usersData.map((data) => UserModel.fromMap(data, data['id'] as String)).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Error fetching users', name: 'EventFormService', error: e);
|
developer.log('Error fetching users', name: 'EventFormService', error: e);
|
||||||
throw Exception("Could not load users.");
|
throw Exception("Could not load users.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STORAGE - Reste inchangé (déjà via Cloud Function)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
static Future<List<Map<String, String>>> uploadFiles(List<PlatformFile> files) async {
|
static Future<List<Map<String, String>>> uploadFiles(List<PlatformFile> files) async {
|
||||||
List<Map<String, String>> uploadedFiles = [];
|
List<Map<String, String>> uploadedFiles = [];
|
||||||
|
|
||||||
@@ -90,14 +103,81 @@ class EventFormService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CRUD Operations - Utilise le backend sécurisé
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
static Future<String> createEvent(EventModel event) async {
|
static Future<String> createEvent(EventModel event) async {
|
||||||
final docRef = await FirebaseFirestore.instance.collection('events').add(event.toMap());
|
try {
|
||||||
return docRef.id;
|
final result = await _apiService.call('createEvent', event.toMap());
|
||||||
|
final eventId = result['id'] as String;
|
||||||
|
|
||||||
|
// NOUVEAU : Créer alerte automatique pour les utilisateurs assignés
|
||||||
|
try {
|
||||||
|
await AlertService().createEventCreatedAlert(
|
||||||
|
eventId: eventId,
|
||||||
|
eventName: event.name,
|
||||||
|
eventDate: event.startDateTime,
|
||||||
|
);
|
||||||
|
developer.log('Alert created for new event: $eventId', name: 'EventFormService');
|
||||||
|
} catch (alertError) {
|
||||||
|
// Ne pas bloquer la création de l'événement si l'alerte échoue
|
||||||
|
developer.log('Warning: Could not create alert for event',
|
||||||
|
name: 'EventFormService',
|
||||||
|
error: alertError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventId;
|
||||||
|
} catch (e) {
|
||||||
|
developer.log('Error creating event', name: 'EventFormService', error: e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> updateEvent(EventModel event) async {
|
static Future<void> updateEvent(EventModel event) async {
|
||||||
final docRef = FirebaseFirestore.instance.collection('events').doc(event.id);
|
try {
|
||||||
await docRef.update(event.toMap());
|
if (event.id.isEmpty) {
|
||||||
|
throw Exception("Cannot update event: Event ID is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
developer.log('Updating event with ID: ${event.id}', name: 'EventFormService');
|
||||||
|
|
||||||
|
final eventData = event.toMap();
|
||||||
|
eventData['eventId'] = event.id;
|
||||||
|
await _apiService.call('updateEvent', eventData);
|
||||||
|
|
||||||
|
developer.log('Event updated successfully', name: 'EventFormService');
|
||||||
|
|
||||||
|
// NOUVEAU : Créer alerte automatique pour les utilisateurs assignés
|
||||||
|
try {
|
||||||
|
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
|
||||||
|
if (currentUserId != null) {
|
||||||
|
await AlertService().createEventModifiedAlert(
|
||||||
|
eventId: event.id,
|
||||||
|
eventName: event.name,
|
||||||
|
modification: 'Informations modifiées',
|
||||||
|
);
|
||||||
|
developer.log('Alert created for modified event: ${event.id}', name: 'EventFormService');
|
||||||
|
}
|
||||||
|
} catch (alertError) {
|
||||||
|
// Ne pas bloquer la modification de l'événement si l'alerte échoue
|
||||||
|
developer.log('Warning: Could not create alert for event modification',
|
||||||
|
name: 'EventFormService',
|
||||||
|
error: alertError);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
developer.log('Error updating event', name: 'EventFormService', error: e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteEvent(String eventId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEvent', {'eventId': eventId});
|
||||||
|
} catch (e) {
|
||||||
|
developer.log('Error deleting event', name: 'EventFormService', error: e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<Map<String, String>>> moveFilesToEvent(
|
static Future<List<Map<String, String>>> moveFilesToEvent(
|
||||||
@@ -135,9 +215,22 @@ class EventFormService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> updateEventDocuments(String eventId, List<Map<String, String>> documents) async {
|
static Future<void> updateEventDocuments(String eventId, List<Map<String, String>> documents) async {
|
||||||
await FirebaseFirestore.instance
|
try {
|
||||||
.collection('events')
|
if (eventId.isEmpty) {
|
||||||
.doc(eventId)
|
throw Exception("Event ID cannot be empty");
|
||||||
.update({'documents': documents});
|
}
|
||||||
|
|
||||||
|
developer.log('Updating event documents for ID: $eventId (${documents.length} documents)', name: 'EventFormService');
|
||||||
|
|
||||||
|
await _apiService.call('updateEvent', {
|
||||||
|
'eventId': eventId,
|
||||||
|
'documents': documents,
|
||||||
|
});
|
||||||
|
|
||||||
|
developer.log('Event documents updated successfully', name: 'EventFormService');
|
||||||
|
} catch (e) {
|
||||||
|
developer.log('Error updating event documents', name: 'EventFormService', error: e);
|
||||||
|
throw Exception("Could not update event documents.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,18 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/event_model.dart';
|
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
|
||||||
import 'package:em2rp/services/equipment_service.dart';
|
|
||||||
import 'package:em2rp/services/equipment_status_calculator.dart';
|
import 'package:em2rp/services/equipment_status_calculator.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class EventPreparationService {
|
class EventPreparationService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
final EquipmentService _equipmentService = EquipmentService();
|
|
||||||
|
|
||||||
// Collection references
|
|
||||||
CollectionReference get _eventsCollection => _firestore.collection('events');
|
|
||||||
CollectionReference get _equipmentCollection => _firestore.collection('equipments');
|
|
||||||
|
|
||||||
// === PRÉPARATION ===
|
// === PRÉPARATION ===
|
||||||
|
|
||||||
/// Valider un équipement individuel en préparation
|
/// Valider un équipement individuel en préparation
|
||||||
Future<void> validateEquipmentPreparation(String eventId, String equipmentId) async {
|
Future<void> validateEquipmentPreparation(String eventId, String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateEquipmentPreparation', {
|
||||||
if (event == null) {
|
'eventId': eventId,
|
||||||
throw Exception('Event not found');
|
'equipmentId': equipmentId,
|
||||||
}
|
});
|
||||||
|
|
||||||
// Mettre à jour le statut de l'équipement dans la liste
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
|
||||||
if (eq.equipmentId == equipmentId) {
|
|
||||||
return eq.copyWith(isPrepared: true);
|
|
||||||
}
|
|
||||||
return eq;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Vérifier si tous les équipements sont préparés
|
|
||||||
final allPrepared = updatedEquipment.every((eq) => eq.isPrepared);
|
|
||||||
|
|
||||||
final updateData = <String, dynamic>{
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mettre à jour le statut selon la complétion
|
|
||||||
if (allPrepared) {
|
|
||||||
updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed);
|
|
||||||
} else {
|
|
||||||
updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.inProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update(updateData);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating equipment preparation: $e');
|
print('Error validating equipment preparation: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -54,69 +22,30 @@ class EventPreparationService {
|
|||||||
/// Valider tous les équipements en préparation
|
/// Valider tous les équipements en préparation
|
||||||
Future<void> validateAllPreparation(String eventId) async {
|
Future<void> validateAllPreparation(String eventId) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateAllPreparation', {
|
||||||
if (event == null) {
|
'eventId': eventId,
|
||||||
throw Exception('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer tous les équipements comme préparés
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
|
||||||
return eq.copyWith(isPrepared: true);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
'preparationStatus': preparationStatusToString(PreparationStatus.completed),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalider le cache des statuts d'équipement
|
// Invalider le cache des statuts d'équipement
|
||||||
EquipmentStatusCalculator.invalidateGlobalCache();
|
EquipmentStatusCalculator.invalidateGlobalCache();
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements à "inUse" (seulement pour les équipements qui existent)
|
|
||||||
for (var equipment in event.assignedEquipment) {
|
|
||||||
// Vérifier si l'équipement existe avant de mettre à jour son statut
|
|
||||||
final doc = await _equipmentCollection.doc(equipment.equipmentId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating all preparation: $e');
|
print('Error validating all preparation: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finaliser la préparation avec des équipements manquants
|
// Ces méthodes ne sont plus utilisées et devraient être remplacées par des Cloud Functions
|
||||||
|
// si nécessaire dans le futur
|
||||||
|
|
||||||
|
/*
|
||||||
|
@Deprecated('Use Cloud Functions instead')
|
||||||
Future<void> completePreparationWithMissing(
|
Future<void> completePreparationWithMissing(
|
||||||
String eventId,
|
String eventId,
|
||||||
List<String> missingEquipmentIds,
|
List<String> missingEquipmentIds,
|
||||||
) async {
|
) async {
|
||||||
try {
|
throw UnimplementedError('This method is deprecated. Use Cloud Functions instead.');
|
||||||
final event = await _getEvent(eventId);
|
|
||||||
if (event == null) {
|
|
||||||
throw Exception('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer comme complété avec manquants
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'preparationStatus': preparationStatusToString(PreparationStatus.completedWithMissing),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements préparés à "inUse"
|
|
||||||
for (var equipment in event.assignedEquipment) {
|
|
||||||
if (equipment.isPrepared && !missingEquipmentIds.contains(equipment.equipmentId)) {
|
|
||||||
// Vérifier si l'équipement existe avant de mettre à jour son statut
|
|
||||||
final doc = await _equipmentCollection.doc(equipment.equipmentId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error completing preparation with missing: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// === RETOUR ===
|
// === RETOUR ===
|
||||||
|
|
||||||
@@ -128,55 +57,11 @@ class EventPreparationService {
|
|||||||
int? returnedQuantity,
|
int? returnedQuantity,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateEquipmentReturn', {
|
||||||
if (event == null) {
|
'eventId': eventId,
|
||||||
throw Exception('Event not found');
|
'equipmentId': equipmentId,
|
||||||
}
|
if (returnedQuantity != null) 'returnedQuantity': returnedQuantity,
|
||||||
|
});
|
||||||
// Mettre à jour le statut de l'équipement dans la liste
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
|
||||||
if (eq.equipmentId == equipmentId) {
|
|
||||||
return eq.copyWith(
|
|
||||||
isReturned: true,
|
|
||||||
returnedQuantity: returnedQuantity,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return eq;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Vérifier si tous les équipements sont retournés
|
|
||||||
final allReturned = updatedEquipment.every((eq) => eq.isReturned);
|
|
||||||
|
|
||||||
final updateData = <String, dynamic>{
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mettre à jour le statut selon la complétion
|
|
||||||
if (allReturned) {
|
|
||||||
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
|
|
||||||
} else {
|
|
||||||
updateData['returnStatus'] = returnStatusToString(ReturnStatus.inProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update(updateData);
|
|
||||||
|
|
||||||
// Mettre à jour le stock si c'est un consommable
|
|
||||||
if (returnedQuantity != null) {
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipment.hasQuantity) {
|
|
||||||
final currentAvailable = equipment.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable + returnedQuantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating equipment return: $e');
|
print('Error validating equipment return: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -189,53 +74,11 @@ class EventPreparationService {
|
|||||||
Map<String, int>? returnedQuantities,
|
Map<String, int>? returnedQuantities,
|
||||||
]) async {
|
]) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateAllReturn', {
|
||||||
if (event == null) {
|
'eventId': eventId,
|
||||||
throw Exception('Event not found');
|
if (returnedQuantities != null) 'returnedQuantities': returnedQuantities,
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer tous les équipements comme retournés
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
|
||||||
final returnedQty = returnedQuantities?[eq.equipmentId] ??
|
|
||||||
eq.returnedQuantity ??
|
|
||||||
eq.quantity;
|
|
||||||
return eq.copyWith(
|
|
||||||
isReturned: true,
|
|
||||||
returnedQuantity: returnedQty,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
'returnStatus': returnStatusToString(ReturnStatus.completed),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements à "available" et gérer les stocks
|
|
||||||
for (var equipment in updatedEquipment) {
|
|
||||||
// Vérifier si le document existe
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipmentData = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mettre à jour le statut uniquement pour les équipements non quantifiables
|
|
||||||
if (!equipmentData.hasQuantity) {
|
|
||||||
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restaurer le stock pour les consommables
|
|
||||||
if (equipmentData.hasQuantity && equipment.returnedQuantity != null) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipment.equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable + equipment.returnedQuantity!,
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalider le cache des statuts d'équipement
|
// Invalider le cache des statuts d'équipement
|
||||||
EquipmentStatusCalculator.invalidateGlobalCache();
|
EquipmentStatusCalculator.invalidateGlobalCache();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -244,186 +87,18 @@ class EventPreparationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finaliser le retour avec des équipements manquants
|
/*
|
||||||
|
@Deprecated('Use Cloud Functions instead')
|
||||||
Future<void> completeReturnWithMissing(
|
Future<void> completeReturnWithMissing(
|
||||||
String eventId,
|
String eventId,
|
||||||
List<String> missingEquipmentIds,
|
List<String> missingEquipmentIds,
|
||||||
) async {
|
) async {
|
||||||
try {
|
throw UnimplementedError('This method is deprecated. Use Cloud Functions instead.');
|
||||||
final event = await _getEvent(eventId);
|
|
||||||
if (event == null) {
|
|
||||||
throw Exception('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer comme complété avec manquants
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'returnStatus': returnStatusToString(ReturnStatus.completedWithMissing),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements retournés à "available"
|
|
||||||
for (var equipment in event.assignedEquipment) {
|
|
||||||
// Vérifier si le document existe
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get();
|
|
||||||
if (!equipmentDoc.exists) {
|
|
||||||
continue; // Passer cet équipement s'il n'existe pas
|
|
||||||
}
|
|
||||||
|
|
||||||
final equipmentData = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipment.isReturned && !missingEquipmentIds.contains(equipment.equipmentId)) {
|
|
||||||
// Mettre à jour le statut uniquement pour les équipements non quantifiables
|
|
||||||
if (!equipmentData.hasQuantity) {
|
|
||||||
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restaurer le stock pour les consommables
|
|
||||||
if (equipmentData.hasQuantity && equipment.returnedQuantity != null) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipment.equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable + equipment.returnedQuantity!,
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (missingEquipmentIds.contains(equipment.equipmentId)) {
|
|
||||||
// Marquer comme perdu uniquement pour les équipements non quantifiables
|
|
||||||
if (!equipmentData.hasQuantity) {
|
|
||||||
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.lost);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error completing return with missing: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === HELPERS ===
|
// Les méthodes helper suivantes étaient uniquement utilisées par les méthodes deprecated ci-dessus.
|
||||||
|
// Elles ont été supprimées car elles accédaient directement à Firestore.
|
||||||
/// Mettre à jour le statut d'un équipement
|
// Si ces fonctionnalités sont nécessaires à l'avenir, elles doivent être implémentées
|
||||||
Future<void> updateEquipmentStatus(String equipmentId, EquipmentStatus status) async {
|
// via des Cloud Functions pour respecter l'architecture.
|
||||||
try {
|
*/
|
||||||
// Vérifier que le document existe avant de le mettre à jour
|
|
||||||
final doc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (!doc.exists) {
|
|
||||||
print('Warning: Equipment document $equipmentId does not exist, skipping status update');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'status': equipmentStatusToString(status),
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error updating equipment status for $equipmentId: $e');
|
|
||||||
// Ne pas rethrow pour ne pas bloquer le processus si un équipement n'existe pas
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupérer un événement
|
|
||||||
Future<EventModel?> _getEvent(String eventId) async {
|
|
||||||
try {
|
|
||||||
final doc = await _eventsCollection.doc(eventId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
print('Error getting event: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ajouter un équipement à un événement
|
|
||||||
Future<void> addEquipmentToEvent(
|
|
||||||
String eventId,
|
|
||||||
String equipmentId, {
|
|
||||||
int quantity = 1,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
final event = await _getEvent(eventId);
|
|
||||||
if (event == null) {
|
|
||||||
throw Exception('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier que l'équipement n'est pas déjà ajouté
|
|
||||||
final alreadyAdded = event.assignedEquipment.any((eq) => eq.equipmentId == equipmentId);
|
|
||||||
if (alreadyAdded) {
|
|
||||||
throw Exception('Equipment already added to event');
|
|
||||||
}
|
|
||||||
|
|
||||||
final newEquipment = EventEquipment(
|
|
||||||
equipmentId: equipmentId,
|
|
||||||
quantity: quantity,
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedEquipment = [...event.assignedEquipment, newEquipment];
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Décrémenter le stock pour les consommables
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipmentData = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipmentData.hasQuantity) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable - quantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error adding equipment to event: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retirer un équipement d'un événement
|
|
||||||
Future<void> removeEquipmentFromEvent(String eventId, String equipmentId) async {
|
|
||||||
try {
|
|
||||||
final event = await _getEvent(eventId);
|
|
||||||
if (event == null) {
|
|
||||||
throw Exception('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
final equipmentToRemove = event.assignedEquipment.firstWhere(
|
|
||||||
(eq) => eq.equipmentId == equipmentId,
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedEquipment = event.assignedEquipment
|
|
||||||
.where((eq) => eq.equipmentId != equipmentId)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restaurer le stock pour les consommables
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipmentData = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (equipmentData.hasQuantity) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable + equipmentToRemove.quantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error removing equipment from event: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,21 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
|
||||||
import 'package:em2rp/services/equipment_status_calculator.dart';
|
import 'package:em2rp/services/equipment_status_calculator.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
/// Service étendu pour gérer les 4 étapes : Préparation, Chargement, Déchargement, Retour
|
/// Service étendu pour gérer les 4 étapes : Préparation, Chargement, Déchargement, Retour
|
||||||
class EventPreparationServiceExtended {
|
class EventPreparationServiceExtended {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
|
|
||||||
CollectionReference get _eventsCollection => _firestore.collection('events');
|
|
||||||
CollectionReference get _equipmentCollection => _firestore.collection('equipments');
|
|
||||||
|
|
||||||
// === CHARGEMENT (LOADING) ===
|
// === CHARGEMENT (LOADING) ===
|
||||||
|
|
||||||
/// Valider un équipement individuel pour le chargement
|
/// Valider un équipement individuel pour le chargement
|
||||||
Future<void> validateEquipmentLoading(String eventId, String equipmentId) async {
|
Future<void> validateEquipmentLoading(String eventId, String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateEquipmentLoading', {
|
||||||
if (event == null) throw Exception('Event not found');
|
'eventId': eventId,
|
||||||
|
'equipmentId': equipmentId,
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
});
|
||||||
if (eq.equipmentId == equipmentId) {
|
|
||||||
return eq.copyWith(isLoaded: true);
|
|
||||||
}
|
|
||||||
return eq;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Vérifier si tous les équipements sont chargés
|
|
||||||
final allLoaded = updatedEquipment.every((eq) => eq.isLoaded);
|
|
||||||
|
|
||||||
final updateData = <String, dynamic>{
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Si tous sont chargés, mettre à jour le statut
|
|
||||||
if (allLoaded) {
|
|
||||||
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
|
|
||||||
} else {
|
|
||||||
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.inProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update(updateData);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating equipment loading: $e');
|
print('Error validating equipment loading: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -49,16 +25,8 @@ class EventPreparationServiceExtended {
|
|||||||
/// Valider tous les équipements pour le chargement
|
/// Valider tous les équipements pour le chargement
|
||||||
Future<void> validateAllLoading(String eventId) async {
|
Future<void> validateAllLoading(String eventId) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateAllLoading', {
|
||||||
if (event == null) throw Exception('Event not found');
|
'eventId': eventId,
|
||||||
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
|
||||||
return eq.copyWith(isLoaded: true);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
'loadingStatus': loadingStatusToString(LoadingStatus.completed),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalider le cache des statuts d'équipement
|
// Invalider le cache des statuts d'équipement
|
||||||
@@ -74,31 +42,10 @@ class EventPreparationServiceExtended {
|
|||||||
/// Valider un équipement individuel pour le déchargement
|
/// Valider un équipement individuel pour le déchargement
|
||||||
Future<void> validateEquipmentUnloading(String eventId, String equipmentId) async {
|
Future<void> validateEquipmentUnloading(String eventId, String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateEquipmentUnloading', {
|
||||||
if (event == null) throw Exception('Event not found');
|
'eventId': eventId,
|
||||||
|
'equipmentId': equipmentId,
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
});
|
||||||
if (eq.equipmentId == equipmentId) {
|
|
||||||
return eq.copyWith(isUnloaded: true);
|
|
||||||
}
|
|
||||||
return eq;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Vérifier si tous les équipements sont déchargés
|
|
||||||
final allUnloaded = updatedEquipment.every((eq) => eq.isUnloaded);
|
|
||||||
|
|
||||||
final updateData = <String, dynamic>{
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Si tous sont déchargés, mettre à jour le statut
|
|
||||||
if (allUnloaded) {
|
|
||||||
updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed);
|
|
||||||
} else {
|
|
||||||
updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.inProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update(updateData);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating equipment unloading: $e');
|
print('Error validating equipment unloading: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -108,16 +55,8 @@ class EventPreparationServiceExtended {
|
|||||||
/// Valider tous les équipements pour le déchargement
|
/// Valider tous les équipements pour le déchargement
|
||||||
Future<void> validateAllUnloading(String eventId) async {
|
Future<void> validateAllUnloading(String eventId) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
await _apiService.call('validateAllUnloading', {
|
||||||
if (event == null) throw Exception('Event not found');
|
'eventId': eventId,
|
||||||
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
|
||||||
return eq.copyWith(isUnloaded: true);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
'unloadingStatus': unloadingStatusToString(UnloadingStatus.completed),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalider le cache des statuts d'équipement
|
// Invalider le cache des statuts d'équipement
|
||||||
@@ -133,26 +72,13 @@ class EventPreparationServiceExtended {
|
|||||||
/// Valider préparation ET chargement en même temps
|
/// Valider préparation ET chargement en même temps
|
||||||
Future<void> validateAllPreparationAndLoading(String eventId) async {
|
Future<void> validateAllPreparationAndLoading(String eventId) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
// Note: On pourrait créer une fonction cloud dédiée pour ça,
|
||||||
if (event == null) throw Exception('Event not found');
|
// mais pour l'instant on appelle les deux séquentiellement
|
||||||
|
await _apiService.call('validateAllPreparation', {'eventId': eventId});
|
||||||
|
await _apiService.call('validateAllLoading', {'eventId': eventId});
|
||||||
|
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
// Invalider le cache
|
||||||
return eq.copyWith(isPrepared: true, isLoaded: true);
|
EquipmentStatusCalculator.invalidateGlobalCache();
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
'preparationStatus': preparationStatusToString(PreparationStatus.completed),
|
|
||||||
'loadingStatus': loadingStatusToString(LoadingStatus.completed),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements
|
|
||||||
for (var equipment in event.assignedEquipment) {
|
|
||||||
final doc = await _equipmentCollection.doc(equipment.equipmentId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
await _updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating all preparation and loading: $e');
|
print('Error validating all preparation and loading: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -167,81 +93,20 @@ class EventPreparationServiceExtended {
|
|||||||
Map<String, int>? returnedQuantities,
|
Map<String, int>? returnedQuantities,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final event = await _getEvent(eventId);
|
// Note: On pourrait créer une fonction cloud dédiée pour ça,
|
||||||
if (event == null) throw Exception('Event not found');
|
// mais pour l'instant on appelle les deux séquentiellement
|
||||||
|
await _apiService.call('validateAllUnloading', {'eventId': eventId});
|
||||||
final updatedEquipment = event.assignedEquipment.map((eq) {
|
await _apiService.call('validateAllReturn', {
|
||||||
final returnedQty = returnedQuantities?[eq.equipmentId] ??
|
'eventId': eventId,
|
||||||
eq.returnedQuantity ??
|
if (returnedQuantities != null) 'returnedQuantities': returnedQuantities,
|
||||||
eq.quantity;
|
|
||||||
return eq.copyWith(
|
|
||||||
isUnloaded: true,
|
|
||||||
isReturned: true,
|
|
||||||
returnedQuantity: returnedQty,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _eventsCollection.doc(eventId).update({
|
|
||||||
'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(),
|
|
||||||
'unloadingStatus': unloadingStatusToString(UnloadingStatus.completed),
|
|
||||||
'returnStatus': returnStatusToString(ReturnStatus.completed),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mettre à jour les statuts et stocks
|
// Invalider le cache
|
||||||
for (var equipment in updatedEquipment) {
|
EquipmentStatusCalculator.invalidateGlobalCache();
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipmentData = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!equipmentData.hasQuantity) {
|
|
||||||
await _updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (equipmentData.hasQuantity && equipment.returnedQuantity != null) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await _equipmentCollection.doc(equipment.equipmentId).update({
|
|
||||||
'availableQuantity': currentAvailable + equipment.returnedQuantity!,
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error validating all unloading and return: $e');
|
print('Error validating all unloading and return: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === HELPERS ===
|
|
||||||
|
|
||||||
Future<void> _updateEquipmentStatus(String equipmentId, EquipmentStatus status) async {
|
|
||||||
try {
|
|
||||||
final doc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (!doc.exists) return;
|
|
||||||
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'status': equipmentStatusToString(status),
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error updating equipment status: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<EventModel?> _getEvent(String eventId) async {
|
|
||||||
try {
|
|
||||||
final doc = await _eventsCollection.doc(eventId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
print('Error getting event: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
|
import 'package:em2rp/config/app_version.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
class IcsExportService {
|
class IcsExportService {
|
||||||
/// Génère un fichier ICS à partir d'un événement
|
/// Génère un fichier ICS à partir d'un événement
|
||||||
static Future<String> generateIcsContent(EventModel event) async {
|
///
|
||||||
|
/// [eventTypeName] : Nom du type d'événement (optionnel, sera résolu si non fourni)
|
||||||
|
/// [userNames] : Map des IDs utilisateurs vers leurs noms complets (optionnel)
|
||||||
|
/// [optionNames] : Map des IDs options vers leurs noms (optionnel)
|
||||||
|
static Future<String> generateIcsContent(
|
||||||
|
EventModel event, {
|
||||||
|
String? eventTypeName,
|
||||||
|
Map<String, String>? userNames,
|
||||||
|
Map<String, String>? optionNames,
|
||||||
|
}) async {
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z';
|
final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z';
|
||||||
|
|
||||||
// Récupérer les informations supplémentaires
|
// Récupérer les informations supplémentaires
|
||||||
final eventTypeName = await _getEventTypeName(event.eventTypeId);
|
final resolvedEventTypeName = eventTypeName ?? await _getEventTypeName(event.eventTypeId);
|
||||||
final workforce = await _getWorkforceDetails(event.workforce);
|
final workforce = await _getWorkforceDetails(event.workforce, userNames: userNames);
|
||||||
final optionsWithNames = await _getOptionsDetails(event.options);
|
final optionsWithNames = await _getOptionsDetails(event.options, optionNames: optionNames);
|
||||||
|
|
||||||
// Formater les dates au format ICS (UTC)
|
// Formater les dates au format ICS (UTC)
|
||||||
final startDate = _formatDateForIcs(event.startDateTime);
|
final startDate = _formatDateForIcs(event.startDateTime);
|
||||||
final endDate = _formatDateForIcs(event.endDateTime);
|
final endDate = _formatDateForIcs(event.endDateTime);
|
||||||
|
|
||||||
// Construire la description détaillée
|
// Construire la description détaillée
|
||||||
final description = _buildDescription(event, eventTypeName, workforce, optionsWithNames);
|
final description = _buildDescription(event, resolvedEventTypeName, workforce, optionsWithNames);
|
||||||
|
|
||||||
// Générer un UID unique basé sur l'ID de l'événement
|
// Générer un UID unique basé sur l'ID de l'événement
|
||||||
final uid = 'em2rp-${event.id}@em2rp.app';
|
final uid = 'em2rp-${event.id}@em2rp.app';
|
||||||
@@ -38,52 +49,64 @@ SUMMARY:${_escapeIcsText(event.name)}
|
|||||||
DESCRIPTION:${_escapeIcsText(description)}
|
DESCRIPTION:${_escapeIcsText(description)}
|
||||||
LOCATION:${_escapeIcsText(event.address)}
|
LOCATION:${_escapeIcsText(event.address)}
|
||||||
STATUS:${_getEventStatus(event.status)}
|
STATUS:${_getEventStatus(event.status)}
|
||||||
CATEGORIES:${_escapeIcsText(eventTypeName)}
|
CATEGORIES:${_escapeIcsText(resolvedEventTypeName)}
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR''';
|
END:VCALENDAR''';
|
||||||
|
|
||||||
return icsContent;
|
return icsContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère le nom du type d'événement
|
/// Récupère le nom du type d'événement depuis EventModel (déjà chargé)
|
||||||
|
/// Note: Les eventTypes sont maintenant chargés via Cloud Function dans l'EventModel
|
||||||
static Future<String> _getEventTypeName(String eventTypeId) async {
|
static Future<String> _getEventTypeName(String eventTypeId) async {
|
||||||
if (eventTypeId.isEmpty) return 'Non spécifié';
|
if (eventTypeId.isEmpty) return 'Non spécifié';
|
||||||
|
|
||||||
try {
|
// Les eventTypes sont publics et déjà chargés dans l'app via Cloud Function
|
||||||
final doc = await FirebaseFirestore.instance
|
// On retourne simplement l'ID, le nom sera résolu par l'app
|
||||||
.collection('eventTypes')
|
|
||||||
.doc(eventTypeId)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (doc.exists) {
|
|
||||||
return doc.data()?['name'] as String? ?? eventTypeId;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Erreur lors de la récupération du type d\'événement: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
return eventTypeId;
|
return eventTypeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère les détails de la main d'œuvre
|
/// Récupère les détails de la main d'œuvre
|
||||||
static Future<List<String>> _getWorkforceDetails(List<DocumentReference> workforce) async {
|
/// Si userNames est fourni, utilise les noms déjà résolus pour de meilleures performances
|
||||||
|
static Future<List<String>> _getWorkforceDetails(
|
||||||
|
List<dynamic> workforce, {
|
||||||
|
Map<String, String>? userNames,
|
||||||
|
}) async {
|
||||||
final List<String> workforceNames = [];
|
final List<String> workforceNames = [];
|
||||||
|
|
||||||
for (final ref in workforce) {
|
for (final ref in workforce) {
|
||||||
try {
|
try {
|
||||||
final doc = await ref.get();
|
// Si c'est déjà une Map avec les données, l'utiliser directement
|
||||||
if (doc.exists) {
|
if (ref is Map<String, dynamic>) {
|
||||||
final data = doc.data() as Map<String, dynamic>?;
|
final firstName = ref['firstName'] ?? '';
|
||||||
if (data != null) {
|
final lastName = ref['lastName'] ?? '';
|
||||||
final firstName = data['firstName'] ?? '';
|
if (firstName.isNotEmpty || lastName.isNotEmpty) {
|
||||||
final lastName = data['lastName'] ?? '';
|
workforceNames.add('$firstName $lastName'.trim());
|
||||||
if (firstName.isNotEmpty || lastName.isNotEmpty) {
|
}
|
||||||
workforceNames.add('$firstName $lastName'.trim());
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si c'est un String (UID) et qu'on a les noms résolus, les utiliser
|
||||||
|
if (ref is String) {
|
||||||
|
if (userNames != null && userNames.containsKey(ref)) {
|
||||||
|
workforceNames.add(userNames[ref]!);
|
||||||
|
} else {
|
||||||
|
workforceNames.add('Utilisateur $ref');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si c'est une DocumentReference
|
||||||
|
if (ref is DocumentReference) {
|
||||||
|
final userId = ref.id;
|
||||||
|
if (userNames != null && userNames.containsKey(userId)) {
|
||||||
|
workforceNames.add(userNames[userId]!);
|
||||||
|
} else {
|
||||||
|
workforceNames.add('Utilisateur $userId');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors de la récupération des détails utilisateur: $e');
|
print('Erreur lors du traitement des détails utilisateur: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,46 +114,32 @@ END:VCALENDAR''';
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère les détails des options
|
/// Récupère les détails des options
|
||||||
static Future<List<Map<String, dynamic>>> _getOptionsDetails(List<Map<String, dynamic>> options) async {
|
/// Si optionNames est fourni, utilise les noms déjà résolus
|
||||||
|
static Future<List<Map<String, dynamic>>> _getOptionsDetails(
|
||||||
|
List<Map<String, dynamic>> options, {
|
||||||
|
Map<String, String>? optionNames,
|
||||||
|
}) async {
|
||||||
final List<Map<String, dynamic>> optionsWithNames = [];
|
final List<Map<String, dynamic>> optionsWithNames = [];
|
||||||
|
|
||||||
for (final option in options) {
|
for (final option in options) {
|
||||||
try {
|
try {
|
||||||
|
String optionName = option['name'] ?? 'Option inconnue';
|
||||||
|
|
||||||
|
// Si on a l'ID de l'option et les noms résolus, utiliser le nom résolu
|
||||||
final optionId = option['id'] ?? option['optionId'];
|
final optionId = option['id'] ?? option['optionId'];
|
||||||
if (optionId == null || optionId.toString().isEmpty) {
|
if (optionId != null && optionNames != null && optionNames.containsKey(optionId)) {
|
||||||
// Si pas d'ID, garder le nom tel quel
|
optionName = optionNames[optionId]!;
|
||||||
optionsWithNames.add({
|
} else if (optionName == 'Option inconnue' && optionId != null) {
|
||||||
'name': option['name'] ?? 'Option inconnue',
|
optionName = 'Option $optionId';
|
||||||
'quantity': option['quantity'],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer le nom depuis Firestore
|
|
||||||
final doc = await FirebaseFirestore.instance
|
|
||||||
.collection('options')
|
|
||||||
.doc(optionId.toString())
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (doc.exists) {
|
|
||||||
final data = doc.data();
|
|
||||||
optionsWithNames.add({
|
|
||||||
'name': data?['name'] ?? option['name'] ?? 'Option inconnue',
|
|
||||||
'quantity': option['quantity'],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Document n'existe pas, garder le nom de l'option
|
|
||||||
optionsWithNames.add({
|
|
||||||
'name': option['name'] ?? 'Option inconnue',
|
|
||||||
'quantity': option['quantity'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Erreur lors de la récupération des détails option: $e');
|
|
||||||
optionsWithNames.add({
|
optionsWithNames.add({
|
||||||
'name': option['name'] ?? 'Option inconnue',
|
'name': optionName,
|
||||||
'quantity': option['quantity'],
|
'quantity': option['quantity'],
|
||||||
|
'price': option['price'],
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
print('Erreur lors du traitement des options: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +231,7 @@ END:VCALENDAR''';
|
|||||||
// Lien vers l'application
|
// Lien vers l'application
|
||||||
buffer.writeln('');
|
buffer.writeln('');
|
||||||
buffer.writeln('---');
|
buffer.writeln('---');
|
||||||
buffer.writeln('Géré par EM2RP Event Manager');
|
buffer.writeln('Généré par EM2 Hub ${AppVersion.fullVersion} http://app.em2events.fr');
|
||||||
|
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,32 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/maintenance_model.dart';
|
import 'package:em2rp/models/maintenance_model.dart';
|
||||||
import 'package:em2rp/models/alert_model.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
|
||||||
import 'package:em2rp/services/equipment_service.dart';
|
|
||||||
|
|
||||||
class MaintenanceService {
|
class MaintenanceService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final ApiService _apiService = apiService;
|
||||||
final EquipmentService _equipmentService = EquipmentService();
|
|
||||||
|
|
||||||
// Collection references
|
|
||||||
CollectionReference get _maintenancesCollection => _firestore.collection('maintenances');
|
|
||||||
CollectionReference get _equipmentCollection => _firestore.collection('equipment');
|
|
||||||
CollectionReference get _alertsCollection => _firestore.collection('alerts');
|
|
||||||
|
|
||||||
// CRUD Operations
|
// ============================================================================
|
||||||
|
// CRUD Operations - Utilise le backend sécurisé
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Créer une nouvelle maintenance
|
/// Créer une nouvelle maintenance (via Cloud Function)
|
||||||
Future<void> createMaintenance(MaintenanceModel maintenance) async {
|
Future<void> createMaintenance(MaintenanceModel maintenance) async {
|
||||||
try {
|
try {
|
||||||
await _maintenancesCollection.doc(maintenance.id).set(maintenance.toMap());
|
await _apiService.call('createMaintenance', maintenance.toMap());
|
||||||
|
// Note: La Cloud Function gère maintenant la mise à jour des équipements et la création des alertes
|
||||||
// Mettre à jour les équipements concernés
|
|
||||||
for (String equipmentId in maintenance.equipmentIds) {
|
|
||||||
await _updateEquipmentMaintenanceList(equipmentId, maintenance.id);
|
|
||||||
|
|
||||||
// Si la maintenance est planifiée dans les 7 prochains jours, créer une alerte
|
|
||||||
if (maintenance.scheduledDate.isBefore(DateTime.now().add(const Duration(days: 7)))) {
|
|
||||||
await _createMaintenanceAlert(equipmentId, maintenance);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating maintenance: $e');
|
print('Error creating maintenance: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre à jour une maintenance
|
/// Mettre à jour une maintenance (via Cloud Function)
|
||||||
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
|
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
data['updatedAt'] = Timestamp.fromDate(DateTime.now());
|
await _apiService.call('updateMaintenance', {
|
||||||
await _maintenancesCollection.doc(id).update(data);
|
'maintenanceId': id,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating maintenance: $e');
|
print('Error updating maintenance: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -49,21 +36,10 @@ class MaintenanceService {
|
|||||||
/// Supprimer une maintenance
|
/// Supprimer une maintenance
|
||||||
Future<void> deleteMaintenance(String id) async {
|
Future<void> deleteMaintenance(String id) async {
|
||||||
try {
|
try {
|
||||||
// Récupérer la maintenance pour connaître les équipements
|
await _apiService.call('deleteMaintenance', {
|
||||||
final doc = await _maintenancesCollection.doc(id).get();
|
'maintenanceId': id,
|
||||||
if (doc.exists) {
|
});
|
||||||
final maintenance = MaintenanceModel.fromMap(
|
// Note: La Cloud Function gère la mise à jour des équipements
|
||||||
doc.data() as Map<String, dynamic>,
|
|
||||||
doc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Retirer la maintenance des équipements
|
|
||||||
for (String equipmentId in maintenance.equipmentIds) {
|
|
||||||
await _removeMaintenanceFromEquipment(equipmentId, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _maintenancesCollection.doc(id).delete();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting maintenance: $e');
|
print('Error deleting maintenance: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -73,54 +49,54 @@ class MaintenanceService {
|
|||||||
/// Récupérer une maintenance par ID
|
/// Récupérer une maintenance par ID
|
||||||
Future<MaintenanceModel?> getMaintenanceById(String id) async {
|
Future<MaintenanceModel?> getMaintenanceById(String id) async {
|
||||||
try {
|
try {
|
||||||
final doc = await _maintenancesCollection.doc(id).get();
|
final response = await _apiService.call('getMaintenances', {
|
||||||
if (doc.exists) {
|
'maintenanceId': id,
|
||||||
return MaintenanceModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
});
|
||||||
}
|
|
||||||
return null;
|
final maintenances = (response['maintenances'] as List?)
|
||||||
|
?.map((m) => MaintenanceModel.fromMap(m as Map<String, dynamic>, m['id'] as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return maintenances?.firstWhere(
|
||||||
|
(m) => m.id == id,
|
||||||
|
orElse: () => throw Exception('Maintenance not found'),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting maintenance: $e');
|
print('Error getting maintenance: $e');
|
||||||
rethrow;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer l'historique des maintenances pour un équipement
|
/// Récupérer l'historique des maintenances pour un équipement
|
||||||
Stream<List<MaintenanceModel>> getMaintenances(String equipmentId) {
|
Future<List<MaintenanceModel>> getMaintenancesByEquipment(String equipmentId) async {
|
||||||
try {
|
try {
|
||||||
return _maintenancesCollection
|
final response = await _apiService.call('getMaintenances', {
|
||||||
.where('equipmentIds', arrayContains: equipmentId)
|
'equipmentId': equipmentId,
|
||||||
.orderBy('scheduledDate', descending: true)
|
|
||||||
.snapshots()
|
|
||||||
.map((snapshot) {
|
|
||||||
return snapshot.docs
|
|
||||||
.map((doc) => MaintenanceModel.fromMap(
|
|
||||||
doc.data() as Map<String, dynamic>,
|
|
||||||
doc.id,
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final maintenances = (response['maintenances'] as List?)
|
||||||
|
?.map((m) => MaintenanceModel.fromMap(m as Map<String, dynamic>, m['id'] as String))
|
||||||
|
.toList() ?? [];
|
||||||
|
|
||||||
|
return maintenances;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error streaming maintenances: $e');
|
print('Error getting maintenances: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupérer toutes les maintenances
|
/// Récupérer toutes les maintenances
|
||||||
Stream<List<MaintenanceModel>> getAllMaintenances() {
|
Future<List<MaintenanceModel>> getAllMaintenances() async {
|
||||||
try {
|
try {
|
||||||
return _maintenancesCollection
|
final response = await _apiService.call('getMaintenances', {});
|
||||||
.orderBy('scheduledDate', descending: true)
|
|
||||||
.snapshots()
|
final maintenances = (response['maintenances'] as List?)
|
||||||
.map((snapshot) {
|
?.map((m) => MaintenanceModel.fromMap(m as Map<String, dynamic>, m['id'] as String))
|
||||||
return snapshot.docs
|
.toList() ?? [];
|
||||||
.map((doc) => MaintenanceModel.fromMap(
|
|
||||||
doc.data() as Map<String, dynamic>,
|
return maintenances;
|
||||||
doc.id,
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error streaming all maintenances: $e');
|
print('Error getting all maintenances: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,30 +104,11 @@ class MaintenanceService {
|
|||||||
/// Marquer une maintenance comme complétée
|
/// Marquer une maintenance comme complétée
|
||||||
Future<void> completeMaintenance(String id, {String? performedBy, double? cost}) async {
|
Future<void> completeMaintenance(String id, {String? performedBy, double? cost}) async {
|
||||||
try {
|
try {
|
||||||
final updateData = <String, dynamic>{
|
await _apiService.call('completeMaintenance', {
|
||||||
'completedDate': Timestamp.fromDate(DateTime.now()),
|
'maintenanceId': id,
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
if (performedBy != null) 'performedBy': performedBy,
|
||||||
};
|
if (cost != null) 'cost': cost,
|
||||||
|
});
|
||||||
if (performedBy != null) {
|
|
||||||
updateData['performedBy'] = performedBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cost != null) {
|
|
||||||
updateData['cost'] = cost;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateMaintenance(id, updateData);
|
|
||||||
|
|
||||||
// Mettre à jour la date de dernière maintenance des équipements
|
|
||||||
final maintenance = await getMaintenanceById(id);
|
|
||||||
if (maintenance != null) {
|
|
||||||
for (String equipmentId in maintenance.equipmentIds) {
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'lastMaintenanceDate': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error completing maintenance: $e');
|
print('Error completing maintenance: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -161,121 +118,10 @@ class MaintenanceService {
|
|||||||
/// Vérifier les maintenances à venir et créer des alertes
|
/// Vérifier les maintenances à venir et créer des alertes
|
||||||
Future<void> checkUpcomingMaintenances() async {
|
Future<void> checkUpcomingMaintenances() async {
|
||||||
try {
|
try {
|
||||||
final sevenDaysFromNow = DateTime.now().add(const Duration(days: 7));
|
await _apiService.call('checkUpcomingMaintenances', {});
|
||||||
|
|
||||||
// Récupérer les maintenances planifiées dans les 7 prochains jours
|
|
||||||
final maintenancesQuery = await _maintenancesCollection
|
|
||||||
.where('scheduledDate', isLessThanOrEqualTo: Timestamp.fromDate(sevenDaysFromNow))
|
|
||||||
.where('completedDate', isNull: true)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
for (var doc in maintenancesQuery.docs) {
|
|
||||||
final maintenance = MaintenanceModel.fromMap(
|
|
||||||
doc.data() as Map<String, dynamic>,
|
|
||||||
doc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (String equipmentId in maintenance.equipmentIds) {
|
|
||||||
await _createMaintenanceAlert(equipmentId, maintenance);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error checking upcoming maintenances: $e');
|
print('Error checking upcoming maintenances: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Créer une alerte de maintenance à venir
|
|
||||||
Future<void> _createMaintenanceAlert(String equipmentId, MaintenanceModel maintenance) async {
|
|
||||||
try {
|
|
||||||
// Vérifier si une alerte existe déjà
|
|
||||||
final existingAlerts = await _alertsCollection
|
|
||||||
.where('equipmentId', isEqualTo: equipmentId)
|
|
||||||
.where('type', isEqualTo: alertTypeToString(AlertType.maintenanceDue))
|
|
||||||
.where('isRead', isEqualTo: false)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
// Vérifier si l'alerte concerne la même maintenance
|
|
||||||
bool alertExists = false;
|
|
||||||
for (var alertDoc in existingAlerts.docs) {
|
|
||||||
final alertData = alertDoc.data() as Map<String, dynamic>;
|
|
||||||
if (alertData['message']?.contains(maintenance.name) ?? false) {
|
|
||||||
alertExists = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!alertExists) {
|
|
||||||
// Récupérer l'équipement pour le nom
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
String equipmentName = equipmentId;
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipmentData = equipmentDoc.data() as Map<String, dynamic>;
|
|
||||||
equipmentName = equipmentData['name'] ?? equipmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
final daysUntil = maintenance.scheduledDate.difference(DateTime.now()).inDays;
|
|
||||||
final alert = AlertModel(
|
|
||||||
id: _alertsCollection.doc().id,
|
|
||||||
type: AlertType.maintenanceDue,
|
|
||||||
message: 'Maintenance "${maintenance.name}" prévue dans $daysUntil jour(s) pour $equipmentName',
|
|
||||||
equipmentId: equipmentId,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _alertsCollection.doc(alert.id).set(alert.toMap());
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error creating maintenance alert: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mettre à jour la liste des maintenances d'un équipement
|
|
||||||
Future<void> _updateEquipmentMaintenanceList(String equipmentId, String maintenanceId) async {
|
|
||||||
try {
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedMaintenanceIds = List<String>.from(equipment.maintenanceIds);
|
|
||||||
if (!updatedMaintenanceIds.contains(maintenanceId)) {
|
|
||||||
updatedMaintenanceIds.add(maintenanceId);
|
|
||||||
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'maintenanceIds': updatedMaintenanceIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error updating equipment maintenance list: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retirer une maintenance de la liste d'un équipement
|
|
||||||
Future<void> _removeMaintenanceFromEquipment(String equipmentId, String maintenanceId) async {
|
|
||||||
try {
|
|
||||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
final equipment = EquipmentModel.fromMap(
|
|
||||||
equipmentDoc.data() as Map<String, dynamic>,
|
|
||||||
equipmentDoc.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedMaintenanceIds = List<String>.from(equipment.maintenanceIds);
|
|
||||||
updatedMaintenanceIds.remove(maintenanceId);
|
|
||||||
|
|
||||||
await _equipmentCollection.doc(equipmentId).update({
|
|
||||||
'maintenanceIds': updatedMaintenanceIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Error removing maintenance from equipment: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ class PDFGeneratorConfig {
|
|||||||
itemsPerPage: 50,
|
itemsPerPage: 50,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4 colonnes x 10 lignes = 40 étiquettes
|
||||||
static const medium = PDFGeneratorConfig(
|
static const medium = PDFGeneratorConfig(
|
||||||
qrCodeSize: 250,
|
qrCodeSize: 150, // Réduit légèrement pour entrer dans 25.4mm de haut
|
||||||
itemsPerPage: 20,
|
itemsPerPage: 40,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 2 colonnes x 5 lignes = 10 étiquettes
|
||||||
static const large = PDFGeneratorConfig(
|
static const large = PDFGeneratorConfig(
|
||||||
qrCodeSize: 300,
|
qrCodeSize: 300,
|
||||||
itemsPerPage: 12,
|
itemsPerPage: 10,
|
||||||
);
|
);
|
||||||
|
|
||||||
static PDFGeneratorConfig fromFormat(QRLabelFormat format) {
|
static PDFGeneratorConfig fromFormat(QRLabelFormat format) {
|
||||||
@@ -47,7 +49,6 @@ class PDFGeneratorConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Service UNIQUE et optimisé pour la génération de PDFs avec QR codes
|
/// Service UNIQUE et optimisé pour la génération de PDFs avec QR codes
|
||||||
/// Remplace PDFGeneratorService, ContainerPDFGeneratorService et UnifiedPDFGeneratorService
|
|
||||||
class PDFService {
|
class PDFService {
|
||||||
static Uint8List? _cachedLogoBytes;
|
static Uint8List? _cachedLogoBytes;
|
||||||
static bool _logoLoadAttempted = false;
|
static bool _logoLoadAttempted = false;
|
||||||
@@ -71,13 +72,6 @@ class PDFService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Génère un PDF avec QR codes pour n'importe quel type d'objet
|
/// Génère un PDF avec QR codes pour n'importe quel type d'objet
|
||||||
///
|
|
||||||
/// [items] : Liste des objets à générer
|
|
||||||
/// [format] : Format des étiquettes (small, medium, large)
|
|
||||||
/// [getId] : Fonction pour obtenir l'ID unique
|
|
||||||
/// [getTitle] : Fonction pour obtenir le titre (optionnel)
|
|
||||||
/// [getDetails] : Fonction pour obtenir les détails (optionnel, seulement pour large)
|
|
||||||
/// [onProgress] : Callback de progression (optionnel)
|
|
||||||
static Future<Uint8List> generatePDF<T>({
|
static Future<Uint8List> generatePDF<T>({
|
||||||
required List<T> items,
|
required List<T> items,
|
||||||
required QRLabelFormat format,
|
required QRLabelFormat format,
|
||||||
@@ -93,8 +87,8 @@ class PDFService {
|
|||||||
final config = PDFGeneratorConfig.fromFormat(format);
|
final config = PDFGeneratorConfig.fromFormat(format);
|
||||||
final pdf = pw.Document();
|
final pdf = pw.Document();
|
||||||
|
|
||||||
// Pré-charger le logo pour format large
|
// Pré-charger le logo pour formats medium et large
|
||||||
if (format == QRLabelFormat.large) {
|
if (format == QRLabelFormat.medium || format == QRLabelFormat.large) {
|
||||||
await _ensureLogoLoaded();
|
await _ensureLogoLoaded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,16 +118,16 @@ class PDFService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// PETITS LABELS (2x2 cm, 20 par page)
|
// PETITS LABELS (Original: 2x2 cm approx)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
static void _addSmallLabels<T>(
|
static void _addSmallLabels<T>(
|
||||||
pw.Document pdf,
|
pw.Document pdf,
|
||||||
List<T> items,
|
List<T> items,
|
||||||
String Function(T) getId,
|
String Function(T) getId,
|
||||||
List<Uint8List> qrImages,
|
List<Uint8List> qrImages,
|
||||||
PDFGeneratorConfig config,
|
PDFGeneratorConfig config,
|
||||||
) {
|
) {
|
||||||
const qrSize = 56.69; // 2cm
|
const qrSize = 56.69; // ~2cm
|
||||||
|
|
||||||
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
||||||
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
||||||
@@ -169,19 +163,30 @@ class PDFService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ========================================================================
|
||||||
// ========================================================================
|
// LABELS MOYENS (49 x 26 mm | 4 colonnes, 10 lignes)
|
||||||
// LABELS MOYENS (4x4 cm, 6 par page)
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
static void _addMediumLabels<T>(
|
static void _addMediumLabels<T>(
|
||||||
pw.Document pdf,
|
pw.Document pdf,
|
||||||
List<T> items,
|
List<T> items,
|
||||||
String Function(T) getId,
|
String Function(T) getId,
|
||||||
String Function(T)? getTitle,
|
String Function(T)? getTitle,
|
||||||
List<Uint8List> qrImages,
|
List<Uint8List> qrImages,
|
||||||
PDFGeneratorConfig config,
|
PDFGeneratorConfig config,
|
||||||
) {
|
) {
|
||||||
const qrSize = 113.39; // 4cm
|
// 1. Dimensions exactes des étiquettes
|
||||||
|
const double labelWidth = 50 * PdfPageFormat.mm;
|
||||||
|
const double labelHeight = 26.0 * PdfPageFormat.mm;
|
||||||
|
|
||||||
|
// 2. Calcul du centrage manuel
|
||||||
|
// Marge théorique = (210mm - (49*4)) / 2 = 7mm
|
||||||
|
// CORRECTION : On enlève 1.5mm pour réduire la marge de gauche (décalage vers la gauche)
|
||||||
|
const double horizontalCorrection = PdfPageFormat.mm;
|
||||||
|
|
||||||
|
final double leftMargin = ((PdfPageFormat.a4.width - (labelWidth * 4)) / 2) + horizontalCorrection;
|
||||||
|
|
||||||
|
// Centrage vertical standard
|
||||||
|
final double topMargin = (PdfPageFormat.a4.height - (labelHeight * 10)) / 2 -0.75;
|
||||||
|
|
||||||
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
||||||
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
||||||
@@ -190,130 +195,56 @@ class PDFService {
|
|||||||
pdf.addPage(
|
pdf.addPage(
|
||||||
pw.Page(
|
pw.Page(
|
||||||
pageFormat: PdfPageFormat.a4,
|
pageFormat: PdfPageFormat.a4,
|
||||||
margin: const pw.EdgeInsets.all(20),
|
// 3. Application des marges calculées (plus de pw.Center)
|
||||||
build: (_) => pw.Wrap(
|
margin: pw.EdgeInsets.only(
|
||||||
spacing: 20,
|
left: leftMargin,
|
||||||
runSpacing: 20,
|
top: topMargin,
|
||||||
children: List.generate(pageItems.length, (i) {
|
right: 0,
|
||||||
return pw.Container(
|
bottom: 0
|
||||||
width: qrSize,
|
|
||||||
height: qrSize + 30,
|
|
||||||
child: pw.Column(
|
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
pw.Image(pw.MemoryImage(pageQRs[i])),
|
|
||||||
pw.SizedBox(height: 4),
|
|
||||||
pw.Text(
|
|
||||||
getId(pageItems[i]),
|
|
||||||
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold),
|
|
||||||
textAlign: pw.TextAlign.center,
|
|
||||||
),
|
|
||||||
if (getTitle != null) ...[
|
|
||||||
pw.SizedBox(height: 2),
|
|
||||||
pw.Text(
|
|
||||||
_truncate(getTitle(pageItems[i]), 25),
|
|
||||||
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
|
|
||||||
textAlign: pw.TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// GRANDS LABELS (avec détails, 10 par page)
|
|
||||||
// ========================================================================
|
|
||||||
static void _addLargeLabels<T>(
|
|
||||||
pw.Document pdf,
|
|
||||||
List<T> items,
|
|
||||||
String Function(T) getId,
|
|
||||||
String Function(T)? getTitle,
|
|
||||||
List<String> Function(T)? getDetails,
|
|
||||||
List<Uint8List> qrImages,
|
|
||||||
PDFGeneratorConfig config,
|
|
||||||
) {
|
|
||||||
const qrSize = 100.0;
|
|
||||||
|
|
||||||
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
|
||||||
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
|
||||||
final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList();
|
|
||||||
|
|
||||||
pdf.addPage(
|
|
||||||
pw.Page(
|
|
||||||
pageFormat: PdfPageFormat.a4,
|
|
||||||
margin: const pw.EdgeInsets.all(20),
|
|
||||||
build: (_) => pw.Wrap(
|
build: (_) => pw.Wrap(
|
||||||
spacing: 10,
|
spacing: 0,
|
||||||
runSpacing: 10,
|
runSpacing: 0,
|
||||||
children: List.generate(pageItems.length, (i) {
|
children: List.generate(pageItems.length, (i) {
|
||||||
final item = pageItems[i];
|
|
||||||
return pw.Container(
|
return pw.Container(
|
||||||
width: 260,
|
width: labelWidth,
|
||||||
height: 120,
|
height: labelHeight,
|
||||||
decoration: pw.BoxDecoration(
|
padding: const pw.EdgeInsets.all(2),
|
||||||
border: pw.Border.all(color: PdfColors.grey400),
|
|
||||||
borderRadius: pw.BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
padding: const pw.EdgeInsets.all(8),
|
|
||||||
child: pw.Row(
|
child: pw.Row(
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
// QR Code
|
// QR Code à gauche
|
||||||
pw.Container(
|
pw.Container(
|
||||||
width: qrSize,
|
width: labelHeight - 4,
|
||||||
height: qrSize,
|
height: labelHeight - 4,
|
||||||
child: pw.Image(pw.MemoryImage(pageQRs[i])),
|
child: pw.Image(pw.MemoryImage(pageQRs[i])),
|
||||||
),
|
),
|
||||||
pw.SizedBox(width: 8),
|
pw.SizedBox(width: 4),
|
||||||
// Détails
|
// Texte à droite
|
||||||
pw.Expanded(
|
pw.Expanded(
|
||||||
child: pw.Column(
|
child: pw.Column(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Logo
|
// Logo
|
||||||
if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
|
if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
|
||||||
pw.Center(
|
pw.Container(
|
||||||
child: pw.Container(
|
height: 12,
|
||||||
height: 25,
|
alignment: pw.Alignment.centerLeft,
|
||||||
margin: const pw.EdgeInsets.only(bottom: 6),
|
margin: const pw.EdgeInsets.only(bottom: 2),
|
||||||
child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)),
|
child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
// Titre
|
|
||||||
if (getTitle != null) ...[
|
|
||||||
pw.SizedBox(height: 2),
|
|
||||||
pw.Text(
|
|
||||||
_truncate(getTitle(item), 20),
|
|
||||||
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold),
|
|
||||||
maxLines: 2,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// ID
|
|
||||||
pw.SizedBox(height: 2),
|
|
||||||
pw.Text(
|
pw.Text(
|
||||||
getId(item),
|
getId(pageItems[i]),
|
||||||
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
|
style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
),
|
),
|
||||||
// Détails supplémentaires
|
if (getTitle != null)
|
||||||
if (getDetails != null) ...[
|
pw.Text(
|
||||||
pw.SizedBox(height: 4),
|
_truncate(getTitle(pageItems[i]), 18),
|
||||||
...getDetails(item).take(5).map((line) {
|
style: const pw.TextStyle(fontSize: 6, color: PdfColors.grey700),
|
||||||
return pw.Padding(
|
maxLines: 2,
|
||||||
padding: const pw.EdgeInsets.only(bottom: 1),
|
overflow: pw.TextOverflow.clip,
|
||||||
child: pw.Text(
|
),
|
||||||
_truncate(line, 25),
|
|
||||||
style: const pw.TextStyle(fontSize: 6, color: PdfColors.grey800),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -327,10 +258,120 @@ class PDFService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// GRANDS LABELS (105 x 57 mm | 2 colonnes, 5 lignes)
|
||||||
|
// ========================================================================
|
||||||
|
static void _addLargeLabels<T>(
|
||||||
|
pw.Document pdf,
|
||||||
|
List<T> items,
|
||||||
|
String Function(T) getId,
|
||||||
|
String Function(T)? getTitle,
|
||||||
|
List<String> Function(T)? getDetails,
|
||||||
|
List<Uint8List> qrImages,
|
||||||
|
PDFGeneratorConfig config,
|
||||||
|
) {
|
||||||
|
// UTILISATION DE LA LARGEUR A4 DIVISÉE PAR 2
|
||||||
|
// Cela garantit que 2 colonnes rentrent pile poil (210mm / 2 = 105mm)
|
||||||
|
final double labelWidth = PdfPageFormat.a4.width / 2;
|
||||||
|
const double labelHeight = 57.0 * PdfPageFormat.mm;
|
||||||
|
const int cols = 2;
|
||||||
|
const int rows = 5;
|
||||||
|
|
||||||
|
final double totalGridWidth = labelWidth * cols;
|
||||||
|
final double totalGridHeight = labelHeight * rows;
|
||||||
|
|
||||||
|
const double innerQrSize = 45.0 * PdfPageFormat.mm;
|
||||||
|
|
||||||
|
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
|
||||||
|
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
|
||||||
|
final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList();
|
||||||
|
|
||||||
|
pdf.addPage(
|
||||||
|
pw.Page(
|
||||||
|
pageFormat: PdfPageFormat.a4,
|
||||||
|
margin: pw.EdgeInsets.zero,
|
||||||
|
build: (_) => pw.Center(
|
||||||
|
child: pw.Container(
|
||||||
|
width: totalGridWidth,
|
||||||
|
height: totalGridHeight,
|
||||||
|
child: pw.Wrap(
|
||||||
|
spacing: 0, // Très important : 0 espace entre les colonnes
|
||||||
|
runSpacing: 0, // 0 espace entre les lignes
|
||||||
|
children: List.generate(pageItems.length, (i) {
|
||||||
|
final item = pageItems[i];
|
||||||
|
return pw.Container(
|
||||||
|
width: labelWidth,
|
||||||
|
height: labelHeight,
|
||||||
|
padding: const pw.EdgeInsets.all(6),
|
||||||
|
// Suppression de la décoration (bordure)
|
||||||
|
child: pw.Row(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// QR Code
|
||||||
|
pw.Container(
|
||||||
|
width: innerQrSize,
|
||||||
|
height: innerQrSize,
|
||||||
|
child: pw.Image(pw.MemoryImage(pageQRs[i])),
|
||||||
|
),
|
||||||
|
pw.SizedBox(width: 8),
|
||||||
|
// Détails
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Logo
|
||||||
|
if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
|
||||||
|
pw.Container(
|
||||||
|
height: 20,
|
||||||
|
alignment: pw.Alignment.centerLeft,
|
||||||
|
margin: const pw.EdgeInsets.only(bottom: 4),
|
||||||
|
child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)),
|
||||||
|
),
|
||||||
|
// Titre
|
||||||
|
if (getTitle != null) ...[
|
||||||
|
pw.Text(
|
||||||
|
_truncate(getTitle(item), 40),
|
||||||
|
style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// ID
|
||||||
|
pw.SizedBox(height: 2),
|
||||||
|
pw.Text(
|
||||||
|
getId(item),
|
||||||
|
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
|
||||||
|
),
|
||||||
|
// Détails supplémentaires
|
||||||
|
if (getDetails != null) ...[
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
...getDetails(item).take(4).map((line) {
|
||||||
|
return pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.only(bottom: 1),
|
||||||
|
child: pw.Text(
|
||||||
|
_truncate(line, 35),
|
||||||
|
style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey800),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Nettoie le cache (logo)
|
/// Nettoie le cache (logo)
|
||||||
static void clearCache() {
|
static void clearCache() {
|
||||||
_cachedLogoBytes = null;
|
_cachedLogoBytes = null;
|
||||||
_logoLoadAttempted = false;
|
_logoLoadAttempted = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
117
em2rp/lib/services/update_service.dart
Normal file
117
em2rp/lib/services/update_service.dart
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:em2rp/config/app_version.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
/// Service pour gérer les mises à jour de l'application
|
||||||
|
class UpdateService {
|
||||||
|
// URL de votre version.json déployé sur Firebase Hosting
|
||||||
|
static const String versionUrl = 'https://app.em2events.fr/version.json';
|
||||||
|
|
||||||
|
/// Vérifie si une mise à jour est disponible
|
||||||
|
static Future<UpdateInfo?> checkForUpdate() async {
|
||||||
|
try {
|
||||||
|
// Récupérer la version actuelle depuis AppVersion
|
||||||
|
final currentVersion = AppVersion.version;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[UpdateService] Current version: $currentVersion');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer la version depuis le serveur (avec cache-busting)
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$versionUrl?t=$timestamp'),
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0',
|
||||||
|
},
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final serverVersion = data['version'] as String;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[UpdateService] Server version: $serverVersion');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparer les versions
|
||||||
|
if (_isNewerVersion(serverVersion, currentVersion)) {
|
||||||
|
return UpdateInfo(
|
||||||
|
currentVersion: currentVersion,
|
||||||
|
newVersion: serverVersion,
|
||||||
|
updateUrl: data['updateUrl'] as String?,
|
||||||
|
releaseNotes: data['releaseNotes'] as String?,
|
||||||
|
forceUpdate: data['forceUpdate'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[UpdateService] Error checking for update: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare deux versions sémantiques (x.y.z)
|
||||||
|
/// Retourne true si newVersion > currentVersion
|
||||||
|
static bool _isNewerVersion(String newVersion, String currentVersion) {
|
||||||
|
final newParts = newVersion.split('.').map(int.parse).toList();
|
||||||
|
final currentParts = currentVersion.split('.').map(int.parse).toList();
|
||||||
|
|
||||||
|
// Comparer major
|
||||||
|
if (newParts[0] > currentParts[0]) return true;
|
||||||
|
if (newParts[0] < currentParts[0]) return false;
|
||||||
|
|
||||||
|
// Comparer minor
|
||||||
|
if (newParts[1] > currentParts[1]) return true;
|
||||||
|
if (newParts[1] < currentParts[1]) return false;
|
||||||
|
|
||||||
|
// Comparer patch
|
||||||
|
return newParts[2] > currentParts[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force le rechargement de l'application (vide le cache)
|
||||||
|
static Future<void> reloadApp() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Pour le web, recharger la page en utilisant JavaScript
|
||||||
|
final url = Uri.base;
|
||||||
|
await launchUrl(url, webOnlyWindowName: '_self');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérification automatique au démarrage
|
||||||
|
static Future<UpdateInfo?> checkOnStartup() async {
|
||||||
|
// Attendre un peu avant de vérifier (pour ne pas ralentir le démarrage)
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
return await checkForUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Informations sur une mise à jour disponible
|
||||||
|
class UpdateInfo {
|
||||||
|
final String currentVersion;
|
||||||
|
final String newVersion;
|
||||||
|
final String? updateUrl;
|
||||||
|
final String? releaseNotes;
|
||||||
|
final bool forceUpdate;
|
||||||
|
|
||||||
|
UpdateInfo({
|
||||||
|
required this.currentVersion,
|
||||||
|
required this.newVersion,
|
||||||
|
this.updateUrl,
|
||||||
|
this.releaseNotes,
|
||||||
|
this.forceUpdate = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get versionDifference {
|
||||||
|
return 'Nouvelle version disponible';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,40 +1,49 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
|
||||||
import '../models/user_model.dart';
|
import '../models/user_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
/// @deprecated Ce service est obsolète. Utilisez UsersProvider avec DataService à la place.
|
||||||
|
/// Ce service reste pour compatibilité mais toutes les opérations passent par l'API.
|
||||||
class UserService {
|
class UserService {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
/// @deprecated Utilisez UsersProvider.fetchUsers() à la place
|
||||||
Future<List<UserModel>> fetchUsers() async {
|
Future<List<UserModel>> fetchUsers() async {
|
||||||
try {
|
try {
|
||||||
final snapshot = await _firestore.collection('users').get();
|
final usersData = await _dataService.getUsers();
|
||||||
return snapshot.docs
|
return usersData.map((data) => UserModel.fromMap(data, data['id'] as String)).toList();
|
||||||
.map((doc) => UserModel.fromMap(doc.data(), doc.id))
|
|
||||||
.toList();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Erreur: $e");
|
print("Erreur: $e");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @deprecated Utilisez DataService.updateUser() à la place
|
||||||
Future<void> updateUser(UserModel user) async {
|
Future<void> updateUser(UserModel user) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(user.uid).update(user.toMap());
|
await _dataService.updateUser(user.uid, user.toMap());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Erreur mise à jour: $e");
|
print("Erreur mise à jour: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @deprecated Utilisez API deleteUser à la place
|
||||||
Future<void> deleteUser(String uid) async {
|
Future<void> deleteUser(String uid) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(uid).delete();
|
final apiService = FirebaseFunctionsApiService();
|
||||||
|
await apiService.call('deleteUser', {'userId': uid});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Erreur suppression: $e");
|
print("Erreur suppression: $e");
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Firebase Auth reste OK (pas Firestore)
|
||||||
Future<void> resetPassword(String email) async {
|
Future<void> resetPassword(String email) async {
|
||||||
try {
|
try {
|
||||||
|
// Firebase Auth est OK, ce n'est pas Firestore
|
||||||
await FirebaseAuth.instance.sendPasswordResetEmail(email: email);
|
await FirebaseAuth.instance.sendPasswordResetEmail(email: email);
|
||||||
print("Email de réinitialisation envoyé à $email");
|
print("Email de réinitialisation envoyé à $email");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
274
em2rp/lib/utils/app_permissions.dart
Normal file
274
em2rp/lib/utils/app_permissions.dart
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/// Énumération centralisée de toutes les permissions de l'application
|
||||||
|
/// Chaque permission contrôle l'accès à une fonctionnalité spécifique
|
||||||
|
enum AppPermission {
|
||||||
|
// ============= ÉVÉNEMENTS =============
|
||||||
|
/// Permet de voir les événements
|
||||||
|
viewEvents('view_events'),
|
||||||
|
|
||||||
|
/// Permet de créer de nouveaux événements
|
||||||
|
createEvents('create_events'),
|
||||||
|
|
||||||
|
/// Permet de modifier les événements existants
|
||||||
|
editEvents('edit_events'),
|
||||||
|
|
||||||
|
/// Permet de supprimer des événements
|
||||||
|
deleteEvents('delete_events'),
|
||||||
|
|
||||||
|
/// Permet de voir tous les événements de tous les utilisateurs
|
||||||
|
/// (nécessaire pour le filtre par utilisateur dans le calendrier)
|
||||||
|
viewAllUserEvents('view_all_user_events'),
|
||||||
|
|
||||||
|
// ============= ÉQUIPEMENTS =============
|
||||||
|
/// Permet de voir la liste des équipements
|
||||||
|
viewEquipment('view_equipment'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des équipements
|
||||||
|
/// Inclut aussi la gestion des prix d'achat/location
|
||||||
|
manageEquipment('manage_equipment'),
|
||||||
|
|
||||||
|
// ============= CONTENEURS =============
|
||||||
|
/// Permet de voir les conteneurs
|
||||||
|
viewContainers('view_containers'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des conteneurs
|
||||||
|
manageContainers('manage_containers'),
|
||||||
|
|
||||||
|
// ============= MAINTENANCE =============
|
||||||
|
/// Permet de voir les maintenances
|
||||||
|
viewMaintenance('view_maintenance'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des maintenances
|
||||||
|
manageMaintenance('manage_maintenance'),
|
||||||
|
|
||||||
|
// ============= UTILISATEURS =============
|
||||||
|
/// Permet de voir la liste de tous les utilisateurs
|
||||||
|
viewAllUsers('view_all_users'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des utilisateurs
|
||||||
|
/// Inclut la gestion des rôles
|
||||||
|
manageUsers('manage_users'),
|
||||||
|
|
||||||
|
// ============= ALERTES =============
|
||||||
|
/// Reçoit les alertes de maintenance
|
||||||
|
receiveMaintenanceAlerts('receive_maintenance_alerts'),
|
||||||
|
|
||||||
|
/// Reçoit les alertes d'événements (création, modification)
|
||||||
|
receiveEventAlerts('receive_event_alerts'),
|
||||||
|
|
||||||
|
/// Reçoit les alertes de stock faible
|
||||||
|
receiveStockAlerts('receive_stock_alerts'),
|
||||||
|
|
||||||
|
// ============= NOTIFICATIONS =============
|
||||||
|
/// Peut recevoir des notifications par email
|
||||||
|
receiveEmailNotifications('receive_email_notifications'),
|
||||||
|
|
||||||
|
/// Peut recevoir des notifications push dans le navigateur
|
||||||
|
receivePushNotifications('receive_push_notifications'),
|
||||||
|
|
||||||
|
// ============= PRÉPARATION/CHARGEMENT =============
|
||||||
|
/// Permet d'accéder aux pages de préparation d'événements
|
||||||
|
accessPreparation('access_preparation'),
|
||||||
|
|
||||||
|
/// Permet de valider les étapes de préparation
|
||||||
|
validatePreparation('validate_preparation'),
|
||||||
|
|
||||||
|
// ============= EXPORTS/RAPPORTS =============
|
||||||
|
/// Permet d'exporter des données (ICS, PDF, etc.)
|
||||||
|
exportData('export_data'),
|
||||||
|
|
||||||
|
/// Permet de générer des rapports
|
||||||
|
generateReports('generate_reports');
|
||||||
|
|
||||||
|
/// L'identifiant de la permission tel qu'il est stocké dans Firestore
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
const AppPermission(this.id);
|
||||||
|
|
||||||
|
/// Convertit une string en AppPermission
|
||||||
|
static AppPermission? fromString(String? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return AppPermission.values.firstWhere((p) => p.id == value);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne une description lisible de la permission (pour l'UI admin)
|
||||||
|
String get description {
|
||||||
|
switch (this) {
|
||||||
|
// Événements
|
||||||
|
case AppPermission.viewEvents:
|
||||||
|
return 'Voir les événements';
|
||||||
|
case AppPermission.createEvents:
|
||||||
|
return 'Créer des événements';
|
||||||
|
case AppPermission.editEvents:
|
||||||
|
return 'Modifier des événements';
|
||||||
|
case AppPermission.deleteEvents:
|
||||||
|
return 'Supprimer des événements';
|
||||||
|
case AppPermission.viewAllUserEvents:
|
||||||
|
return 'Voir les événements de tous les utilisateurs';
|
||||||
|
|
||||||
|
// Équipements
|
||||||
|
case AppPermission.viewEquipment:
|
||||||
|
return 'Voir les équipements';
|
||||||
|
case AppPermission.manageEquipment:
|
||||||
|
return 'Gérer les équipements';
|
||||||
|
|
||||||
|
// Conteneurs
|
||||||
|
case AppPermission.viewContainers:
|
||||||
|
return 'Voir les conteneurs';
|
||||||
|
case AppPermission.manageContainers:
|
||||||
|
return 'Gérer les conteneurs';
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
case AppPermission.viewMaintenance:
|
||||||
|
return 'Voir les maintenances';
|
||||||
|
case AppPermission.manageMaintenance:
|
||||||
|
return 'Gérer les maintenances';
|
||||||
|
|
||||||
|
// Utilisateurs
|
||||||
|
case AppPermission.viewAllUsers:
|
||||||
|
return 'Voir tous les utilisateurs';
|
||||||
|
case AppPermission.manageUsers:
|
||||||
|
return 'Gérer les utilisateurs';
|
||||||
|
|
||||||
|
// Alertes
|
||||||
|
case AppPermission.receiveMaintenanceAlerts:
|
||||||
|
return 'Recevoir les alertes de maintenance';
|
||||||
|
case AppPermission.receiveEventAlerts:
|
||||||
|
return 'Recevoir les alertes d\'événements';
|
||||||
|
case AppPermission.receiveStockAlerts:
|
||||||
|
return 'Recevoir les alertes de stock';
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
case AppPermission.receiveEmailNotifications:
|
||||||
|
return 'Recevoir les notifications par email';
|
||||||
|
case AppPermission.receivePushNotifications:
|
||||||
|
return 'Recevoir les notifications push';
|
||||||
|
|
||||||
|
// Préparation
|
||||||
|
case AppPermission.accessPreparation:
|
||||||
|
return 'Accéder aux préparations d\'événements';
|
||||||
|
case AppPermission.validatePreparation:
|
||||||
|
return 'Valider les préparations';
|
||||||
|
|
||||||
|
// Exports
|
||||||
|
case AppPermission.exportData:
|
||||||
|
return 'Exporter des données';
|
||||||
|
case AppPermission.generateReports:
|
||||||
|
return 'Générer des rapports';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la catégorie de la permission (pour l'UI de gestion des rôles)
|
||||||
|
String get category {
|
||||||
|
switch (this) {
|
||||||
|
case AppPermission.viewEvents:
|
||||||
|
case AppPermission.createEvents:
|
||||||
|
case AppPermission.editEvents:
|
||||||
|
case AppPermission.deleteEvents:
|
||||||
|
case AppPermission.viewAllUserEvents:
|
||||||
|
return 'Événements';
|
||||||
|
|
||||||
|
case AppPermission.viewEquipment:
|
||||||
|
case AppPermission.manageEquipment:
|
||||||
|
return 'Équipements';
|
||||||
|
|
||||||
|
case AppPermission.viewContainers:
|
||||||
|
case AppPermission.manageContainers:
|
||||||
|
return 'Conteneurs';
|
||||||
|
|
||||||
|
case AppPermission.viewMaintenance:
|
||||||
|
case AppPermission.manageMaintenance:
|
||||||
|
return 'Maintenance';
|
||||||
|
|
||||||
|
case AppPermission.viewAllUsers:
|
||||||
|
case AppPermission.manageUsers:
|
||||||
|
return 'Utilisateurs';
|
||||||
|
|
||||||
|
case AppPermission.receiveMaintenanceAlerts:
|
||||||
|
case AppPermission.receiveEventAlerts:
|
||||||
|
case AppPermission.receiveStockAlerts:
|
||||||
|
return 'Alertes';
|
||||||
|
|
||||||
|
case AppPermission.receiveEmailNotifications:
|
||||||
|
case AppPermission.receivePushNotifications:
|
||||||
|
return 'Notifications';
|
||||||
|
|
||||||
|
case AppPermission.accessPreparation:
|
||||||
|
case AppPermission.validatePreparation:
|
||||||
|
return 'Préparation';
|
||||||
|
|
||||||
|
case AppPermission.exportData:
|
||||||
|
case AppPermission.generateReports:
|
||||||
|
return 'Exports & Rapports';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension pour faciliter les vérifications de permissions
|
||||||
|
extension PermissionListExtension on List<String> {
|
||||||
|
/// Vérifie si la liste contient une permission donnée
|
||||||
|
bool hasPermission(AppPermission permission) {
|
||||||
|
return contains(permission.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si la liste contient toutes les permissions données
|
||||||
|
bool hasAllPermissions(List<AppPermission> permissions) {
|
||||||
|
return permissions.every((p) => contains(p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si la liste contient au moins une des permissions données
|
||||||
|
bool hasAnyPermission(List<AppPermission> permissions) {
|
||||||
|
return permissions.any((p) => contains(p.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rôles prédéfinis avec leurs permissions
|
||||||
|
class PredefinedRoles {
|
||||||
|
/// Rôle ADMIN : Accès complet à toutes les fonctionnalités
|
||||||
|
static List<String> get admin => AppPermission.values.map((p) => p.id).toList();
|
||||||
|
|
||||||
|
/// Rôle TECHNICIEN : Gestion des équipements et préparation
|
||||||
|
static List<String> get technician => [
|
||||||
|
AppPermission.viewEvents.id,
|
||||||
|
AppPermission.viewEquipment.id,
|
||||||
|
AppPermission.manageEquipment.id,
|
||||||
|
AppPermission.viewContainers.id,
|
||||||
|
AppPermission.manageContainers.id,
|
||||||
|
AppPermission.viewMaintenance.id,
|
||||||
|
AppPermission.manageMaintenance.id,
|
||||||
|
AppPermission.receiveMaintenanceAlerts.id,
|
||||||
|
AppPermission.receiveStockAlerts.id,
|
||||||
|
AppPermission.accessPreparation.id,
|
||||||
|
AppPermission.validatePreparation.id,
|
||||||
|
AppPermission.exportData.id,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Rôle MANAGER : Gestion des événements et vue d'ensemble
|
||||||
|
static List<String> get manager => [
|
||||||
|
AppPermission.viewEvents.id,
|
||||||
|
AppPermission.createEvents.id,
|
||||||
|
AppPermission.editEvents.id,
|
||||||
|
AppPermission.deleteEvents.id,
|
||||||
|
AppPermission.viewAllUserEvents.id,
|
||||||
|
AppPermission.viewEquipment.id,
|
||||||
|
AppPermission.viewContainers.id,
|
||||||
|
AppPermission.viewMaintenance.id,
|
||||||
|
AppPermission.viewAllUsers.id,
|
||||||
|
AppPermission.receiveEventAlerts.id,
|
||||||
|
AppPermission.accessPreparation.id,
|
||||||
|
AppPermission.exportData.id,
|
||||||
|
AppPermission.generateReports.id,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Rôle USER : Consultation uniquement
|
||||||
|
static List<String> get user => [
|
||||||
|
AppPermission.viewEvents.id,
|
||||||
|
AppPermission.viewEquipment.id,
|
||||||
|
AppPermission.viewContainers.id,
|
||||||
|
AppPermission.receiveEventAlerts.id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,14 +17,19 @@ class AuthGuard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final localAuthProvider = Provider.of<LocalUserProvider>(context);
|
final localAuthProvider = Provider.of<LocalUserProvider>(context);
|
||||||
|
|
||||||
|
// Log pour débug
|
||||||
|
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
|
||||||
|
|
||||||
// Si l'utilisateur n'est pas connecté
|
// Si l'utilisateur n'est pas connecté
|
||||||
if (localAuthProvider.currentUser == null) {
|
if (localAuthProvider.currentUser == null) {
|
||||||
|
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
|
||||||
return const LoginPage();
|
return const LoginPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si la page requiert une permission spécifique et que l'utilisateur ne la possède pas
|
// Si la page requiert une permission spécifique et que l'utilisateur ne la possède pas
|
||||||
if (requiredPermission != null &&
|
if (requiredPermission != null &&
|
||||||
!localAuthProvider.hasPermission(requiredPermission!)) {
|
!localAuthProvider.hasPermission(requiredPermission!)) {
|
||||||
|
print('[AuthGuard] Permission "$requiredPermission" refusée');
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("Accès refusé")),
|
appBar: AppBar(title: const Text("Accès refusé")),
|
||||||
body: const Center(
|
body: const Center(
|
||||||
@@ -34,6 +39,7 @@ class AuthGuard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sinon, afficher la page demandée
|
// Sinon, afficher la page demandée
|
||||||
|
print('[AuthGuard] Accès autorisé, affichage de la page');
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
em2rp/lib/utils/debug_log.dart
Normal file
33
em2rp/lib/utils/debug_log.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Helper pour gérer les logs de debug
|
||||||
|
/// Les logs sont automatiquement désactivés en mode release
|
||||||
|
class DebugLog {
|
||||||
|
/// Flag pour activer/désactiver les logs manuellement
|
||||||
|
static const bool _forceEnableLogs = false;
|
||||||
|
|
||||||
|
/// Vérifie si les logs doivent être affichés
|
||||||
|
static bool get _shouldLog => kDebugMode || _forceEnableLogs;
|
||||||
|
|
||||||
|
/// Log une information
|
||||||
|
static void info(String message) {
|
||||||
|
if (_shouldLog) {
|
||||||
|
print(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log une erreur (toujours affiché, même en production)
|
||||||
|
static void error(String message, [Object? error, StackTrace? stackTrace]) {
|
||||||
|
print('ERROR: $message');
|
||||||
|
if (error != null) print(' Error: $error');
|
||||||
|
if (stackTrace != null && kDebugMode) print(' StackTrace: $stackTrace');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log un warning
|
||||||
|
static void warning(String message) {
|
||||||
|
if (_shouldLog) {
|
||||||
|
print('WARNING: $message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
35
em2rp/lib/utils/equipment_helpers.dart
Normal file
35
em2rp/lib/utils/equipment_helpers.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
|
||||||
|
/// Helpers pour la gestion et l'affichage des équipements
|
||||||
|
class EquipmentHelpers {
|
||||||
|
/// Détermine si un équipement devrait avoir une quantité par défaut
|
||||||
|
/// Retourne true pour câbles, consommables et structures
|
||||||
|
static bool shouldBeQuantifiableByDefault(EquipmentCategory category) {
|
||||||
|
return category == EquipmentCategory.cable ||
|
||||||
|
category == EquipmentCategory.consumable ||
|
||||||
|
category == EquipmentCategory.structure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule la quantité disponible d'un équipement
|
||||||
|
/// Prend en compte la quantité totale et la quantité déjà assignée
|
||||||
|
static int calculateAvailableQuantity(
|
||||||
|
EquipmentModel equipment,
|
||||||
|
int assignedQuantity,
|
||||||
|
) {
|
||||||
|
if (!equipment.hasQuantity) return 0;
|
||||||
|
|
||||||
|
final total = equipment.availableQuantity ?? equipment.totalQuantity ?? 0;
|
||||||
|
return (total - assignedQuantity).clamp(0, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un équipement est en stock faible
|
||||||
|
/// (quantité disponible en dessous du seuil critique)
|
||||||
|
static bool isLowStock(EquipmentModel equipment) {
|
||||||
|
if (!equipment.hasQuantity) return false;
|
||||||
|
if (equipment.criticalThreshold == null) return false;
|
||||||
|
|
||||||
|
final available = equipment.availableQuantity ?? 0;
|
||||||
|
return available <= equipment.criticalThreshold!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import 'package:flutter/foundation.dart'; // pour kIsWeb
|
import 'package:flutter/foundation.dart'; // pour kIsWeb
|
||||||
import 'package:firebase_storage/firebase_storage.dart';
|
import 'package:firebase_storage/firebase_storage.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class FirebaseStorageManager {
|
class FirebaseStorageManager {
|
||||||
final FirebaseStorage _storage = FirebaseStorage.instance;
|
final FirebaseStorage _storage = FirebaseStorage.instance;
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
|
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
|
||||||
/// Pour le Web, on fixe l'extension .jpg.
|
/// Pour le Web, on fixe l'extension .jpg.
|
||||||
/// 1. Construit le chemin : "ProfilePictures/UID.jpg"
|
/// 1. Construit le chemin : "ProfilePictures/UID.jpg"
|
||||||
/// 2. Supprime l'ancienne photo (si elle existe).
|
/// 2. Supprime l'ancienne photo (si elle existe).
|
||||||
/// 3. Upload la nouvelle photo.
|
/// 3. Upload la nouvelle photo.
|
||||||
/// 4. Met à jour Firestore avec l'URL de la nouvelle image.
|
/// 4. Met à jour Firestore avec l'URL de la nouvelle image via l'API.
|
||||||
Future<String?> sendProfilePicture(
|
Future<String?> sendProfilePicture(
|
||||||
{required XFile imageFile, required String uid}) async {
|
{required XFile imageFile, required String uid}) async {
|
||||||
try {
|
try {
|
||||||
@@ -57,17 +58,14 @@ class FirebaseStorageManager {
|
|||||||
print(
|
print(
|
||||||
"FirebaseStorageManager: Nouvelle photo uploadée pour l'utilisateur $uid. URL: $downloadUrl");
|
"FirebaseStorageManager: Nouvelle photo uploadée pour l'utilisateur $uid. URL: $downloadUrl");
|
||||||
|
|
||||||
// 5. Mettre à jour Firestore avec l'URL de la photo de profil
|
// 5. Mettre à jour via l'API (plus sécurisé)
|
||||||
try {
|
try {
|
||||||
await _firestore
|
await _dataService.updateUser(uid, {'profilePhotoUrl': downloadUrl});
|
||||||
.collection('users')
|
|
||||||
.doc(uid)
|
|
||||||
.update({'profilePhotoUrl': downloadUrl});
|
|
||||||
print(
|
print(
|
||||||
"FirebaseStorageManager: Firestore mis à jour pour l'utilisateur $uid.");
|
"FirebaseStorageManager: Profil mis à jour via API pour l'utilisateur $uid.");
|
||||||
} catch (firestoreError) {
|
} catch (apiError) {
|
||||||
print(
|
print(
|
||||||
"FirebaseStorageManager: Erreur Firestore pour l'utilisateur $uid: $firestoreError");
|
"FirebaseStorageManager: Erreur API pour l'utilisateur $uid: $apiError");
|
||||||
return downloadUrl; // On retourne l'URL même si la mise à jour échoue
|
return downloadUrl; // On retourne l'URL même si la mise à jour échoue
|
||||||
}
|
}
|
||||||
return downloadUrl;
|
return downloadUrl;
|
||||||
|
|||||||
86
em2rp/lib/utils/price_helpers.dart
Normal file
86
em2rp/lib/utils/price_helpers.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
|
||||||
|
/// Helper pour la gestion des prix HT et TTC
|
||||||
|
class PriceHelpers {
|
||||||
|
/// Taux de TVA par défaut (20%)
|
||||||
|
static const double defaultTaxRate = 0.20;
|
||||||
|
|
||||||
|
/// Calcule le prix TTC à partir du prix HT
|
||||||
|
static double calculateTTC(double priceHT, {double taxRate = defaultTaxRate}) {
|
||||||
|
return priceHT * (1 + taxRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule le prix HT à partir du prix TTC
|
||||||
|
static double calculateHT(double priceTTC, {double taxRate = defaultTaxRate}) {
|
||||||
|
return priceTTC / (1 + taxRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule le montant de TVA
|
||||||
|
static double calculateTax(double priceHT, {double taxRate = defaultTaxRate}) {
|
||||||
|
return priceHT * taxRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formate un prix en euros avec deux décimales
|
||||||
|
static String formatPrice(double price) {
|
||||||
|
return '${price.toStringAsFixed(2)} €';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un objet EventPricing avec HT, TVA et TTC calculés
|
||||||
|
static EventPricing getPricing(EventModel event, {double taxRate = defaultTaxRate}) {
|
||||||
|
// basePrice dans Firestore est le prix TTC (avec TVA 20% déjà incluse)
|
||||||
|
final priceTTC = event.basePrice;
|
||||||
|
final priceHT = calculateHT(priceTTC, taxRate: taxRate);
|
||||||
|
final taxAmount = calculateTax(priceHT, taxRate: taxRate);
|
||||||
|
|
||||||
|
return EventPricing(
|
||||||
|
priceHT: priceHT,
|
||||||
|
taxAmount: taxAmount,
|
||||||
|
priceTTC: priceTTC,
|
||||||
|
taxRate: taxRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classe pour stocker les différentes composantes du prix d'un événement
|
||||||
|
class EventPricing {
|
||||||
|
final double priceHT;
|
||||||
|
final double taxAmount;
|
||||||
|
final double priceTTC;
|
||||||
|
final double taxRate;
|
||||||
|
|
||||||
|
const EventPricing({
|
||||||
|
required this.priceHT,
|
||||||
|
required this.taxAmount,
|
||||||
|
required this.priceTTC,
|
||||||
|
required this.taxRate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Retourne le taux de TVA en pourcentage (ex: 20.0 pour 20%)
|
||||||
|
double get taxRatePercentage => taxRate * 100;
|
||||||
|
|
||||||
|
/// Formate le prix HT
|
||||||
|
String get formattedHT => PriceHelpers.formatPrice(priceHT);
|
||||||
|
|
||||||
|
/// Formate le montant de TVA
|
||||||
|
String get formattedTax => PriceHelpers.formatPrice(taxAmount);
|
||||||
|
|
||||||
|
/// Formate le prix TTC
|
||||||
|
String get formattedTTC => PriceHelpers.formatPrice(priceTTC);
|
||||||
|
|
||||||
|
/// Retourne un résumé complet du pricing
|
||||||
|
String get summary => 'HT: $formattedHT | TVA (${taxRatePercentage.toStringAsFixed(0)}%): $formattedTax | TTC: $formattedTTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget helper pour afficher les prix
|
||||||
|
class PriceDisplay {
|
||||||
|
/// Génère un Map avec les composantes de prix pour affichage
|
||||||
|
static Map<String, String> getPriceComponents(EventModel event) {
|
||||||
|
final pricing = PriceHelpers.getPricing(event);
|
||||||
|
return {
|
||||||
|
'HT': pricing.formattedHT,
|
||||||
|
'TVA': '${pricing.formattedTax} (${pricing.taxRatePercentage.toStringAsFixed(0)}%)',
|
||||||
|
'TTC': pricing.formattedTTC,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
296
em2rp/lib/views/alerts_page.dart
Normal file
296
em2rp/lib/views/alerts_page.dart
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
import 'package:em2rp/services/alert_service.dart';
|
||||||
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
|
import 'package:em2rp/views/widgets/alert_item.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
/// Page listant toutes les alertes de l'utilisateur
|
||||||
|
class AlertsPage extends StatefulWidget {
|
||||||
|
const AlertsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AlertsPage> createState() => _AlertsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlertsPageState extends State<AlertsPage> with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
final AlertService _alertService = AlertService();
|
||||||
|
AlertType? _filter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 4, vsync: this);
|
||||||
|
_tabController.addListener(() {
|
||||||
|
setState(() {
|
||||||
|
_filter = _getFilterForTab(_tabController.index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertType? _getFilterForTab(int index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
return null; // Toutes
|
||||||
|
case 1:
|
||||||
|
return AlertType.eventCreated; // Événements (on filtrera manuellement)
|
||||||
|
case 2:
|
||||||
|
return AlertType.maintenanceDue; // Maintenance
|
||||||
|
case 3:
|
||||||
|
return AlertType.lost; // Équipement
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final localUserProvider = context.watch<LocalUserProvider>();
|
||||||
|
final userId = localUserProvider.currentUser?.uid;
|
||||||
|
|
||||||
|
if (userId == null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Notifications'),
|
||||||
|
),
|
||||||
|
body: const Center(
|
||||||
|
child: Text('Veuillez vous connecter'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Notifications'),
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.done_all),
|
||||||
|
onPressed: () => _markAllAsRead(userId),
|
||||||
|
tooltip: 'Tout marquer comme lu',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
indicatorColor: Colors.white,
|
||||||
|
labelColor: Colors.white,
|
||||||
|
unselectedLabelColor: Colors.white70,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Toutes'),
|
||||||
|
Tab(text: 'Événements'),
|
||||||
|
Tab(text: 'Maintenance'),
|
||||||
|
Tab(text: 'Équipement'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: _buildAlertsList(userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlertsList(String userId) {
|
||||||
|
return StreamBuilder<List<AlertModel>>(
|
||||||
|
stream: _alertService.alertsStreamForUser(userId),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
// Log détaillé de l'erreur
|
||||||
|
print('[AlertsPage] ERREUR Stream: ${snapshot.error}');
|
||||||
|
print('[AlertsPage] StackTrace: ${snapshot.stackTrace}');
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Erreur de chargement des alertes'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
snapshot.error.toString(),
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => setState(() {}),
|
||||||
|
child: const Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allAlerts = snapshot.data ?? [];
|
||||||
|
|
||||||
|
// Filtrer selon l'onglet sélectionné
|
||||||
|
final filteredAlerts = _filterAlerts(allAlerts);
|
||||||
|
|
||||||
|
if (filteredAlerts.isEmpty) {
|
||||||
|
return _buildEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: filteredAlerts.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final alert = filteredAlerts[index];
|
||||||
|
return AlertItem(
|
||||||
|
alert: alert,
|
||||||
|
onTap: () => _handleAlertTap(alert),
|
||||||
|
onMarkAsRead: () => _markAsRead(alert.id),
|
||||||
|
onDelete: () => _deleteAlert(alert.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AlertModel> _filterAlerts(List<AlertModel> alerts) {
|
||||||
|
if (_filter == null) {
|
||||||
|
return alerts; // Toutes
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (_tabController.index) {
|
||||||
|
case 1: // Événements
|
||||||
|
return alerts.where((a) => a.isEventAlert).toList();
|
||||||
|
case 2: // Maintenance
|
||||||
|
return alerts.where((a) => a.isMaintenanceAlert).toList();
|
||||||
|
case 3: // Équipement
|
||||||
|
return alerts.where((a) => a.isEquipmentAlert).toList();
|
||||||
|
default:
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
String message;
|
||||||
|
IconData icon;
|
||||||
|
|
||||||
|
switch (_tabController.index) {
|
||||||
|
case 1:
|
||||||
|
message = 'Aucune alerte d\'événement';
|
||||||
|
icon = Icons.event;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
message = 'Aucune alerte de maintenance';
|
||||||
|
icon = Icons.build;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
message = 'Aucune alerte d\'équipement';
|
||||||
|
icon = Icons.inventory_2;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message = 'Aucune notification';
|
||||||
|
icon = Icons.notifications_none;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 64, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAlertTap(AlertModel alert) async {
|
||||||
|
// Marquer comme lu si pas déjà lu
|
||||||
|
if (!alert.isRead) {
|
||||||
|
await _markAsRead(alert.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirection selon actionUrl (pour l'instant, juste rester sur la page)
|
||||||
|
// TODO: Implémenter navigation vers événement/équipement si besoin
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markAsRead(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _alertService.markAsRead(alertId);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur : $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteAlert(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _alertService.deleteAlert(alertId);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Alerte supprimée'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur : $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markAllAsRead(String userId) async {
|
||||||
|
try {
|
||||||
|
final alerts = await _alertService.getAlertsForUser(userId);
|
||||||
|
for (final alert in alerts.where((a) => !a.isRead)) {
|
||||||
|
await _alertService.markAsRead(alert.id);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Toutes les alertes ont été marquées comme lues'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur : $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
|
|||||||
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
|
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
|
||||||
import 'package:em2rp/views/event_add_page.dart';
|
import 'package:em2rp/views/event_add_page.dart';
|
||||||
import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart';
|
import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart';
|
||||||
|
import 'package:em2rp/views/widgets/calendar_widgets/user_filter_dropdown.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
class CalendarPage extends StatefulWidget {
|
class CalendarPage extends StatefulWidget {
|
||||||
@@ -28,6 +29,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
EventModel? _selectedEvent;
|
EventModel? _selectedEvent;
|
||||||
bool _calendarCollapsed = false;
|
bool _calendarCollapsed = false;
|
||||||
int _selectedEventIndex = 0;
|
int _selectedEventIndex = 0;
|
||||||
|
String? _selectedUserId; // Filtre par utilisateur (null = tous les événements)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -50,7 +52,6 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
EventModel? selected;
|
EventModel? selected;
|
||||||
DateTime? selectedDay;
|
DateTime? selectedDay;
|
||||||
int selectedEventIndex = 0;
|
|
||||||
if (todayEvents.isNotEmpty) {
|
if (todayEvents.isNotEmpty) {
|
||||||
selected = todayEvents[0];
|
selected = todayEvents[0];
|
||||||
selectedDay = DateTime(now.year, now.month, now.day);
|
selectedDay = DateTime(now.year, now.month, now.day);
|
||||||
@@ -87,9 +88,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
Provider.of<LocalUserProvider>(context, listen: false);
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
final userId = localAuthProvider.uid;
|
final userId = localAuthProvider.uid;
|
||||||
print('Permissions utilisateur: ${localAuthProvider.permissions}');
|
|
||||||
final canViewAllEvents = localAuthProvider.hasPermission('view_all_events');
|
final canViewAllEvents = localAuthProvider.hasPermission('view_all_events');
|
||||||
print('canViewAllEvents: $canViewAllEvents');
|
|
||||||
|
|
||||||
if (userId != null) {
|
if (userId != null) {
|
||||||
await eventProvider.loadUserEvents(userId,
|
await eventProvider.loadUserEvents(userId,
|
||||||
@@ -97,6 +96,26 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif)
|
||||||
|
/// TEMPORAIREMENT DÉSACTIVÉ - À réactiver quand permission ajoutée dans Firestore
|
||||||
|
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
|
||||||
|
if (_selectedUserId == null) {
|
||||||
|
return allEvents; // Pas de filtre, retourner tous les événements
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer les événements où l'utilisateur sélectionné fait partie de la workforce
|
||||||
|
return allEvents.where((event) {
|
||||||
|
return event.workforce.any((worker) {
|
||||||
|
if (worker is String) {
|
||||||
|
return worker == _selectedUserId;
|
||||||
|
}
|
||||||
|
// Si c'est une DocumentReference, on ne peut pas facilement comparer
|
||||||
|
// On suppose que les données sont chargées correctement en String
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
void _changeWeek(int delta) {
|
void _changeWeek(int delta) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
|
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
|
||||||
@@ -107,9 +126,13 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
final eventProvider = Provider.of<EventProvider>(context);
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
||||||
final isAdmin = localUserProvider.hasPermission('view_all_users');
|
final canCreateEvents = localUserProvider.hasPermission('create_events');
|
||||||
|
final canViewAllUserEvents = localUserProvider.hasPermission('view_all_user_events');
|
||||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
|
|
||||||
|
// Appliquer le filtre utilisateur si actif
|
||||||
|
final filteredEvents = _getFilteredEvents(eventProvider.events);
|
||||||
|
|
||||||
if (eventProvider.isLoading) {
|
if (eventProvider.isLoading) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
body: Center(
|
body: Center(
|
||||||
@@ -123,8 +146,42 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
title: "Calendrier",
|
title: "Calendrier",
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/calendar'),
|
drawer: const MainDrawer(currentPage: '/calendar'),
|
||||||
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
body: Column(
|
||||||
floatingActionButton: isAdmin
|
children: [
|
||||||
|
// Filtre utilisateur dans le corps de la page
|
||||||
|
if (canViewAllUserEvents && !isMobile)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: Colors.grey[100],
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.filter_list, color: AppColors.rouge),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'Filtrer par utilisateur :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: UserFilterDropdown(
|
||||||
|
selectedUserId: _selectedUserId,
|
||||||
|
onUserSelected: (userId) {
|
||||||
|
setState(() {
|
||||||
|
_selectedUserId = userId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Corps du calendrier
|
||||||
|
Expanded(
|
||||||
|
child: isMobile ? _buildMobileLayout(filteredEvents) : _buildDesktopLayout(filteredEvents),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: canCreateEvents
|
||||||
? FloatingActionButton(
|
? FloatingActionButton(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -143,14 +200,13 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDesktopLayout() {
|
Widget _buildDesktopLayout(List<EventModel> filteredEvents) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Calendrier (65% de la largeur)
|
// Calendrier (65% de la largeur)
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 65,
|
flex: 65,
|
||||||
child: _buildCalendar(),
|
child: _buildCalendar(filteredEvents),
|
||||||
),
|
),
|
||||||
// Détails de l'événement (35% de la largeur)
|
// Détails de l'événement (35% de la largeur)
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -159,7 +215,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
? EventDetails(
|
? EventDetails(
|
||||||
event: _selectedEvent!,
|
event: _selectedEvent!,
|
||||||
selectedDate: _selectedDay,
|
selectedDate: _selectedDay,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onSelectEvent: (event, date) {
|
onSelectEvent: (event, date) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedEvent = event;
|
_selectedEvent = event;
|
||||||
@@ -178,11 +234,10 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMobileLayout() {
|
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
|
||||||
final eventsForSelectedDay = _selectedDay == null
|
final eventsForSelectedDay = _selectedDay == null
|
||||||
? []
|
? []
|
||||||
: eventProvider.events
|
: filteredEvents
|
||||||
.where((e) =>
|
.where((e) =>
|
||||||
e.startDateTime.year == _selectedDay!.year &&
|
e.startDateTime.year == _selectedDay!.year &&
|
||||||
e.startDateTime.month == _selectedDay!.month &&
|
e.startDateTime.month == _selectedDay!.month &&
|
||||||
@@ -267,9 +322,9 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
child: MobileCalendarView(
|
child: MobileCalendarView(
|
||||||
focusedDay: _focusedDay,
|
focusedDay: _focusedDay,
|
||||||
selectedDay: _selectedDay,
|
selectedDay: _selectedDay,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onDaySelected: (day) {
|
onDaySelected: (day) {
|
||||||
final eventsForDay = eventProvider.events
|
final eventsForDay = filteredEvents
|
||||||
.where((e) =>
|
.where((e) =>
|
||||||
e.startDateTime.year == day.year &&
|
e.startDateTime.year == day.year &&
|
||||||
e.startDateTime.month == day.month &&
|
e.startDateTime.month == day.month &&
|
||||||
@@ -505,13 +560,11 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCalendar() {
|
Widget _buildCalendar(List<EventModel> filteredEvents) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
|
||||||
|
|
||||||
if (_calendarFormat == CalendarFormat.week) {
|
if (_calendarFormat == CalendarFormat.week) {
|
||||||
return WeekView(
|
return WeekView(
|
||||||
focusedDay: _focusedDay,
|
focusedDay: _focusedDay,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onWeekChange: _changeWeek,
|
onWeekChange: _changeWeek,
|
||||||
onEventSelected: (event) {
|
onEventSelected: (event) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -525,7 +578,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDaySelected: (selectedDay) {
|
onDaySelected: (selectedDay) {
|
||||||
final eventsForDay = eventProvider.events
|
final eventsForDay = filteredEvents
|
||||||
.where((e) =>
|
.where((e) =>
|
||||||
e.startDateTime.year == selectedDay.year &&
|
e.startDateTime.year == selectedDay.year &&
|
||||||
e.startDateTime.month == selectedDay.month &&
|
e.startDateTime.month == selectedDay.month &&
|
||||||
@@ -557,9 +610,9 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
focusedDay: _focusedDay,
|
focusedDay: _focusedDay,
|
||||||
selectedDay: _selectedDay,
|
selectedDay: _selectedDay,
|
||||||
calendarFormat: _calendarFormat,
|
calendarFormat: _calendarFormat,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onDaySelected: (selectedDay, focusedDay) {
|
onDaySelected: (selectedDay, focusedDay) {
|
||||||
final eventsForDay = eventProvider.events
|
final eventsForDay = filteredEvents
|
||||||
.where((event) =>
|
.where((event) =>
|
||||||
event.startDateTime.year == selectedDay.year &&
|
event.startDateTime.year == selectedDay.year &&
|
||||||
event.startDateTime.month == selectedDay.month &&
|
event.startDateTime.month == selectedDay.month &&
|
||||||
|
|||||||
@@ -622,6 +622,10 @@ class _ContainerDetailPageState extends State<ContainerDetailPage> {
|
|||||||
return 'Consommable';
|
return 'Consommable';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'Câble';
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'Régie / Backline';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:em2rp/models/container_model.dart';
|
|||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/utils/id_generator.dart';
|
import 'package:em2rp/utils/id_generator.dart';
|
||||||
|
|
||||||
class ContainerFormPage extends StatefulWidget {
|
class ContainerFormPage extends StatefulWidget {
|
||||||
@@ -534,7 +535,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e');
|
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +581,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e');
|
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,7 +594,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors du retrait de l\'équipement $equipmentId: $e');
|
DebugLog.error('Erreur lors du retrait de l\'équipement $equipmentId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,6 +912,10 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
return 'Consommable';
|
return 'Consommable';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'Câble';
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'Régie / Backline';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
@@ -932,6 +937,10 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
return Icons.inventory;
|
return Icons.inventory;
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return Icons.cable;
|
return Icons.cable;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Icons.local_shipping;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Icons.piano;
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return Icons.category;
|
return Icons.category;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ import 'package:em2rp/utils/permission_gate.dart';
|
|||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/views/equipment_detail_page.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
||||||
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
||||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_search_bar.dart';
|
|
||||||
import 'package:em2rp/views/widgets/management/management_card.dart';
|
import 'package:em2rp/views/widgets/management/management_card.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_list.dart';
|
import 'package:em2rp/views/widgets/management/management_list.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||||
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||||
|
|
||||||
class ContainerManagementPage extends StatefulWidget {
|
class ContainerManagementPage extends StatefulWidget {
|
||||||
const ContainerManagementPage({super.key});
|
const ContainerManagementPage({super.key});
|
||||||
@@ -25,13 +30,61 @@ class ContainerManagementPage extends StatefulWidget {
|
|||||||
class _ContainerManagementPageState extends State<ContainerManagementPage>
|
class _ContainerManagementPageState extends State<ContainerManagementPage>
|
||||||
with SelectionModeMixin<ContainerManagementPage> {
|
with SelectionModeMixin<ContainerManagementPage> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
ContainerType? _selectedType;
|
ContainerType? _selectedType;
|
||||||
EquipmentStatus? _selectedStatus;
|
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
|
||||||
List<ContainerModel>? _cachedContainers; // Cache pour éviter le rebuild
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Activer le mode pagination
|
||||||
|
final provider = context.read<ContainerProvider>();
|
||||||
|
provider.enablePagination();
|
||||||
|
|
||||||
|
// Ajouter le listener de scroll
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
// Charger la première page
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
provider.loadFirstPage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
// Éviter les appels multiples
|
||||||
|
if (_isLoadingMore) return;
|
||||||
|
|
||||||
|
final provider = context.read<ContainerProvider>();
|
||||||
|
|
||||||
|
// Charger la page suivante quand on arrive à 300px du bas
|
||||||
|
if (_scrollController.hasClients &&
|
||||||
|
_scrollController.position.pixels >=
|
||||||
|
_scrollController.position.maxScrollExtent - 300) {
|
||||||
|
|
||||||
|
// Vérifier qu'on peut charger plus
|
||||||
|
if (provider.hasMore && !provider.isLoadingMore) {
|
||||||
|
setState(() => _isLoadingMore = true);
|
||||||
|
|
||||||
|
provider.loadNextPage().then((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoadingMore = false);
|
||||||
|
}
|
||||||
|
}).catchError((error) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoadingMore = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
context.read<ContainerProvider>().disablePagination();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +121,7 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
const NotificationBadge(),
|
||||||
if (hasSelection) ...[
|
if (hasSelection) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.qr_code, color: Colors.white),
|
icon: const Icon(Icons.qr_code, color: Colors.white),
|
||||||
@@ -82,44 +136,14 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: AppBar(
|
: CustomAppBar(
|
||||||
title: const Text('Gestion des Containers'),
|
title: 'Gestion des Containers',
|
||||||
backgroundColor: AppColors.rouge,
|
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
tooltip: 'Retour à la gestion des équipements',
|
tooltip: 'Retour à la gestion des équipements',
|
||||||
onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'),
|
onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'),
|
||||||
),
|
),
|
||||||
actions: [
|
showLogoutButton: true,
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.logout, color: Colors.white),
|
|
||||||
onPressed: () async {
|
|
||||||
final shouldLogout = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Déconnexion'),
|
|
||||||
content: const Text('Voulez-vous vraiment vous déconnecter ?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
child: const Text('Déconnexion'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (shouldLogout == true && context.mounted) {
|
|
||||||
await context.read<LocalUserProvider>().signOut();
|
|
||||||
if (context.mounted) {
|
|
||||||
Navigator.pushReplacementNamed(context, '/login');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/container_management'),
|
drawer: const MainDrawer(currentPage: '/container_management'),
|
||||||
floatingActionButton: !isSelectionMode
|
floatingActionButton: !isSelectionMode
|
||||||
@@ -169,14 +193,37 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSearchBar() {
|
Widget _buildSearchBar() {
|
||||||
return ManagementSearchBar(
|
return SearchActionsBar(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
hintText: 'Rechercher un container...',
|
hintText: 'Rechercher un container...',
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context.read<ContainerProvider>().setSearchQuery(value);
|
context.read<ContainerProvider>().setSearchQuery(value);
|
||||||
},
|
},
|
||||||
onSelectionModeToggle: isSelectionMode ? null : toggleSelectionMode,
|
onClear: () {
|
||||||
showSelectionModeButton: !isSelectionMode,
|
_searchController.clear();
|
||||||
|
context.read<ContainerProvider>().setSearchQuery('');
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: _scanQRCode,
|
||||||
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
|
tooltip: 'Scanner un QR Code',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[700],
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isSelectionMode)
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: toggleSelectionMode,
|
||||||
|
icon: const Icon(Icons.checklist),
|
||||||
|
tooltip: 'Mode sélection',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,30 +308,12 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
...ContainerType.values.map((type) {
|
...ContainerType.values.map((type) {
|
||||||
return _buildFilterOption(type, type.label);
|
return _buildFilterOption(type, type.label);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
const Divider(height: 32),
|
|
||||||
|
|
||||||
// Filtre par statut
|
|
||||||
Text(
|
|
||||||
'Statut',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppColors.noir,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildStatusFilter(null, 'Tous les statuts'),
|
|
||||||
_buildStatusFilter(EquipmentStatus.available, 'Disponible'),
|
|
||||||
_buildStatusFilter(EquipmentStatus.inUse, 'En prestation'),
|
|
||||||
_buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'),
|
|
||||||
_buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterOption(ContainerType? type, String label) {
|
Widget _buildFilterOption(ContainerType? type, String label) {
|
||||||
final isSelected = _selectedType == type;
|
|
||||||
return RadioListTile<ContainerType?>(
|
return RadioListTile<ContainerType?>(
|
||||||
title: Text(label),
|
title: Text(label),
|
||||||
value: type,
|
value: type,
|
||||||
@@ -301,36 +330,62 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatusFilter(EquipmentStatus? status, String label) {
|
|
||||||
final isSelected = _selectedStatus == status;
|
|
||||||
return RadioListTile<EquipmentStatus?>(
|
|
||||||
title: Text(label),
|
|
||||||
value: status,
|
|
||||||
groupValue: _selectedStatus,
|
|
||||||
activeColor: AppColors.rouge,
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_selectedStatus = value;
|
|
||||||
context.read<ContainerProvider>().setSelectedStatus(_selectedStatus);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildContainerList() {
|
Widget _buildContainerList() {
|
||||||
return Consumer<ContainerProvider>(
|
return Consumer<ContainerProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
return ManagementList<ContainerModel>(
|
// Afficher l'indicateur de chargement initial
|
||||||
stream: provider.containersStream,
|
if (provider.isLoading && provider.containers.isEmpty) {
|
||||||
cachedItems: _cachedContainers,
|
return const Center(child: CircularProgressIndicator());
|
||||||
emptyMessage: 'Aucun container trouvé',
|
}
|
||||||
emptyIcon: Icons.inventory_2_outlined,
|
|
||||||
onDataReceived: (items) {
|
final containers = provider.containers;
|
||||||
_cachedContainers = items;
|
|
||||||
|
// Afficher le message vide
|
||||||
|
if (containers.isEmpty && !provider.isLoading) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucun container trouvé',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer le nombre total d'items
|
||||||
|
final itemCount = containers.length + (provider.hasMore ? 1 : 0);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
// Dernier élément = indicateur de chargement
|
||||||
|
if (index == containers.length) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: provider.isLoadingMore
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildContainerCard(containers[index]);
|
||||||
},
|
},
|
||||||
itemBuilder: (container) => _buildContainerCard(container),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -417,7 +472,7 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
_editContainer(container);
|
_editContainer(container);
|
||||||
break;
|
break;
|
||||||
case 'qr':
|
case 'qr':
|
||||||
// Non utilisé - les QR codes multiples sont générés via _generateQRCodesForSelected
|
_showQRCode(container);
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
_deleteContainer(container);
|
_deleteContainer(container);
|
||||||
@@ -425,6 +480,14 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Afficher le QR code d'un conteneur
|
||||||
|
void _showQRCode(ContainerModel container) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => QRCodeDialog.forContainer(container),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _navigateToForm(BuildContext context) async {
|
void _navigateToForm(BuildContext context) async {
|
||||||
final result = await Navigator.pushNamed(context, '/container_form');
|
final result = await Navigator.pushNamed(context, '/container_form');
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
@@ -452,49 +515,81 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
Future<void> _generateQRCodesForSelected() async {
|
Future<void> _generateQRCodesForSelected() async {
|
||||||
if (!hasSelection) return;
|
if (!hasSelection) return;
|
||||||
|
|
||||||
// Récupérer les containers sélectionnés
|
// Afficher un indicateur de chargement
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
showDialog(
|
||||||
final List<ContainerModel> selectedContainers = [];
|
context: context,
|
||||||
final Map<String, List<EquipmentModel>> containerEquipmentMap = {};
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
for (final id in selectedIds) {
|
try {
|
||||||
final container = await containerProvider.getContainerById(id);
|
// Récupérer les containers sélectionnés
|
||||||
if (container != null) {
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
selectedContainers.add(container);
|
final List<ContainerModel> selectedContainers = [];
|
||||||
// Charger les équipements pour ce container
|
final Map<String, List<EquipmentModel>> containerEquipmentMap = {};
|
||||||
final equipment = await containerProvider.getContainerEquipment(id);
|
|
||||||
containerEquipmentMap[id] = equipment;
|
for (final id in selectedIds) {
|
||||||
|
final container = await containerProvider.getContainerById(id);
|
||||||
|
if (container != null) {
|
||||||
|
selectedContainers.add(container);
|
||||||
|
// Charger les équipements pour ce container
|
||||||
|
final equipment = await containerProvider.getContainerEquipment(id);
|
||||||
|
containerEquipmentMap[id] = equipment;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedContainers.isEmpty) {
|
// Fermer l'indicateur de chargement
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
Navigator.of(context).pop();
|
||||||
const SnackBar(content: Text('Aucun container trouvé')),
|
}
|
||||||
|
|
||||||
|
if (selectedContainers.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aucun container trouvé')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher le dialogue de sélection de format avec le widget générique
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => QRCodeFormatSelectorDialog<ContainerModel>(
|
||||||
|
itemList: selectedContainers,
|
||||||
|
getId: (c) => c.id,
|
||||||
|
getTitle: (c) => c.name,
|
||||||
|
getDetails: (ContainerModel c) {
|
||||||
|
final equipment = containerEquipmentMap[c.id] ?? <EquipmentModel>[];
|
||||||
|
return [
|
||||||
|
'Contenu (${equipment.length}):',
|
||||||
|
...equipment.take(5).map((eq) => '- ${eq.id}'),
|
||||||
|
if (equipment.length > 5) '... +${equipment.length - 5}',
|
||||||
|
];
|
||||||
|
},
|
||||||
|
dialogTitle: 'Générer ${selectedContainers.length} QR Code(s)',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
} catch (e) {
|
||||||
}
|
// Fermer l'indicateur si une erreur survient
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
// Afficher le dialogue de sélection de format avec le widget générique
|
DebugLog.error('[ContainerManagementPage] Error generating QR codes', e);
|
||||||
if (mounted) {
|
|
||||||
showDialog(
|
if (mounted) {
|
||||||
context: context,
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
builder: (context) => QRCodeFormatSelectorDialog<ContainerModel>(
|
SnackBar(
|
||||||
itemList: selectedContainers,
|
content: Text('Erreur lors de la génération : ${e.toString()}'),
|
||||||
getId: (c) => c.id,
|
backgroundColor: Colors.red,
|
||||||
getTitle: (c) => c.name,
|
),
|
||||||
getDetails: (ContainerModel c) {
|
);
|
||||||
final equipment = containerEquipmentMap[c.id] ?? <EquipmentModel>[];
|
}
|
||||||
return [
|
|
||||||
'Contenu (${equipment.length}):',
|
|
||||||
...equipment.take(5).map((eq) => '- ${eq.id}'),
|
|
||||||
if (equipment.length > 5) '... +${equipment.length - 5}',
|
|
||||||
];
|
|
||||||
},
|
|
||||||
dialogTitle: 'Générer ${selectedContainers.length} QR Code(s)',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,5 +678,119 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scanner un QR Code et ouvrir la vue de détail correspondante
|
||||||
|
Future<void> _scanQRCode() async {
|
||||||
|
try {
|
||||||
|
// Ouvrir le scanner
|
||||||
|
final scannedCode = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const QRCodeScannerDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scannedCode == null || scannedCode.isEmpty) {
|
||||||
|
return; // L'utilisateur a annulé
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Afficher un indicateur de chargement
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rechercher d'abord dans les conteneurs
|
||||||
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
if (containerProvider.containers.isEmpty) {
|
||||||
|
await containerProvider.loadContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
final container = containerProvider.containers.firstWhere(
|
||||||
|
(c) => c.id == scannedCode,
|
||||||
|
orElse: () => ContainerModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: ContainerType.flightCase,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
equipmentIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop(); // Fermer l'indicateur
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container.id.isNotEmpty) {
|
||||||
|
// Conteneur trouvé
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pushNamed(
|
||||||
|
context,
|
||||||
|
'/container_detail',
|
||||||
|
arguments: container,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si pas trouvé dans les conteneurs, chercher dans les équipements
|
||||||
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
|
await equipmentProvider.ensureLoaded();
|
||||||
|
|
||||||
|
final equipment = equipmentProvider.allEquipment.firstWhere(
|
||||||
|
(eq) => eq.id == scannedCode,
|
||||||
|
orElse: () => EquipmentModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
category: EquipmentCategory.other,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
maintenanceIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (equipment.id.isNotEmpty) {
|
||||||
|
// Équipement trouvé
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => EquipmentDetailPage(equipment: equipment),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rien trouvé
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Aucun conteneur ou équipement trouvé avec l\'ID : $scannedCode'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[ContainerManagementPage] Error scanning QR code', e);
|
||||||
|
if (mounted) {
|
||||||
|
// Fermer l'indicateur si ouvert
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur lors du scan : ${e.toString()}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import 'package:em2rp/services/qr_code_service.dart';
|
|||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/views/equipment_form_page.dart';
|
import 'package:em2rp/views/equipment_form_page.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_parent_containers.dart';
|
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_header_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_header_section.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_main_info_section.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_main_info_section.dart';
|
||||||
@@ -124,15 +123,9 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Containers parents (si applicable)
|
// Containers contenant cet équipement
|
||||||
if (widget.equipment.parentBoxIds.isNotEmpty) ...[
|
// Note: On utilise EquipmentReferencingContainers qui recherche dynamiquement
|
||||||
EquipmentParentContainers(
|
// les containers au lieu de se baser sur parentBoxIds qui peut être désynchronisé
|
||||||
parentBoxIds: widget.equipment.parentBoxIds,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Containers associés
|
|
||||||
EquipmentReferencingContainers(
|
EquipmentReferencingContainers(
|
||||||
equipmentId: widget.equipment.id,
|
equipmentId: widget.equipment.id,
|
||||||
),
|
),
|
||||||
@@ -256,6 +249,17 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
|
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
|
||||||
style: TextStyle(color: Colors.grey[700]),
|
style: TextStyle(color: Colors.grey[700]),
|
||||||
),
|
),
|
||||||
|
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'📁 ${widget.equipment.subCategory}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 13,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -400,26 +404,33 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
// Fermer le dialog
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
// Capturer le ScaffoldMessenger avant la suppression
|
||||||
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await context
|
await context
|
||||||
.read<EquipmentProvider>()
|
.read<EquipmentProvider>()
|
||||||
.deleteEquipment(widget.equipment.id);
|
.deleteEquipment(widget.equipment.id);
|
||||||
if (mounted) {
|
|
||||||
Navigator.pop(context);
|
// Revenir à la page précédente
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
navigator.pop();
|
||||||
const SnackBar(
|
|
||||||
content: Text('Équipement supprimé avec succès'),
|
// Afficher le snackbar (même si le widget est démonté)
|
||||||
backgroundColor: Colors.green,
|
scaffoldMessenger.showSnackBar(
|
||||||
),
|
const SnackBar(
|
||||||
);
|
content: Text('Équipement supprimé avec succès'),
|
||||||
}
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
// Afficher l'erreur
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
scaffoldMessenger.showSnackBar(
|
||||||
SnackBar(content: Text('Erreur: $e')),
|
SnackBar(content: Text('Erreur: $e')),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
|||||||
66
em2rp/lib/views/equipment_form/subcategory_selector.dart
Normal file
66
em2rp/lib/views/equipment_form/subcategory_selector.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
|
||||||
|
/// Widget de sélection de sous-catégorie avec autocomplétion
|
||||||
|
/// Similaire au système Brand/Model mais filtré par catégorie
|
||||||
|
class SubCategorySelector extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final EquipmentCategory? selectedCategory;
|
||||||
|
final List<String> filteredSubCategories;
|
||||||
|
final ValueChanged<String?>? onChanged;
|
||||||
|
|
||||||
|
const SubCategorySelector({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.selectedCategory,
|
||||||
|
required this.filteredSubCategories,
|
||||||
|
this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Autocomplete<String>(
|
||||||
|
initialValue: TextEditingValue(text: controller.text),
|
||||||
|
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||||
|
if (selectedCategory == null) {
|
||||||
|
return const Iterable<String>.empty();
|
||||||
|
}
|
||||||
|
if (textEditingValue.text.isEmpty) {
|
||||||
|
return filteredSubCategories;
|
||||||
|
}
|
||||||
|
return filteredSubCategories.where((String subCategory) {
|
||||||
|
return subCategory.toLowerCase().contains(
|
||||||
|
textEditingValue.text.toLowerCase(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSelected: (String selection) {
|
||||||
|
controller.text = selection;
|
||||||
|
onChanged?.call(selection);
|
||||||
|
},
|
||||||
|
fieldViewBuilder: (context, fieldController, focusNode, onEditingComplete) {
|
||||||
|
if (fieldController.text != controller.text) {
|
||||||
|
fieldController.text = controller.text;
|
||||||
|
}
|
||||||
|
return TextFormField(
|
||||||
|
controller: fieldController,
|
||||||
|
focusNode: focusNode,
|
||||||
|
enabled: selectedCategory != null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Sous-catégorie',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: const Icon(Icons.category_outlined),
|
||||||
|
hintText: selectedCategory == null
|
||||||
|
? 'Catégorie requise'
|
||||||
|
: 'Saisissez la sous-catégorie',
|
||||||
|
helperText: 'Optionnel - Permet un classement plus précis',
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.text = value;
|
||||||
|
onChanged?.call(value.isNotEmpty ? value : null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ import 'package:em2rp/utils/colors.dart';
|
|||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
|
import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
|
||||||
|
import 'package:em2rp/views/equipment_form/subcategory_selector.dart';
|
||||||
import 'package:em2rp/utils/id_generator.dart';
|
import 'package:em2rp/utils/id_generator.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
class EquipmentFormPage extends StatefulWidget {
|
class EquipmentFormPage extends StatefulWidget {
|
||||||
final EquipmentModel? equipment;
|
final EquipmentModel? equipment;
|
||||||
@@ -28,6 +30,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
final TextEditingController _identifierController = TextEditingController();
|
final TextEditingController _identifierController = TextEditingController();
|
||||||
final TextEditingController _brandController = TextEditingController();
|
final TextEditingController _brandController = TextEditingController();
|
||||||
final TextEditingController _modelController = TextEditingController();
|
final TextEditingController _modelController = TextEditingController();
|
||||||
|
final TextEditingController _subCategoryController = TextEditingController();
|
||||||
final TextEditingController _purchasePriceController = TextEditingController();
|
final TextEditingController _purchasePriceController = TextEditingController();
|
||||||
final TextEditingController _rentalPriceController = TextEditingController();
|
final TextEditingController _rentalPriceController = TextEditingController();
|
||||||
final TextEditingController _totalQuantityController = TextEditingController();
|
final TextEditingController _totalQuantityController = TextEditingController();
|
||||||
@@ -41,18 +44,15 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
DateTime? _purchaseDate;
|
DateTime? _purchaseDate;
|
||||||
DateTime? _lastMaintenanceDate;
|
DateTime? _lastMaintenanceDate;
|
||||||
DateTime? _nextMaintenanceDate;
|
DateTime? _nextMaintenanceDate;
|
||||||
List<String> _selectedParentBoxIds = [];
|
|
||||||
List<EquipmentModel> _availableBoxes = [];
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isLoadingBoxes = true;
|
|
||||||
bool _addMultiple = false;
|
bool _addMultiple = false;
|
||||||
String? _selectedBrand;
|
String? _selectedBrand;
|
||||||
List<String> _filteredModels = [];
|
List<String> _filteredModels = [];
|
||||||
|
List<String> _filteredSubCategories = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadAvailableBoxes();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
||||||
provider.loadBrands();
|
provider.loadBrands();
|
||||||
@@ -65,45 +65,35 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
|
|
||||||
void _populateFields() {
|
void _populateFields() {
|
||||||
final equipment = widget.equipment!;
|
final equipment = widget.equipment!;
|
||||||
_identifierController.text = equipment.id;
|
setState(() {
|
||||||
_brandController.text = equipment.brand ?? '';
|
_identifierController.text = equipment.id;
|
||||||
_selectedBrand = equipment.brand;
|
_brandController.text = equipment.brand ?? '';
|
||||||
_modelController.text = equipment.model ?? '';
|
_selectedBrand = equipment.brand;
|
||||||
_selectedCategory = equipment.category;
|
_modelController.text = equipment.model ?? '';
|
||||||
_selectedStatus = equipment.status;
|
_subCategoryController.text = equipment.subCategory ?? '';
|
||||||
_purchasePriceController.text = equipment.purchasePrice?.toStringAsFixed(2) ?? '';
|
_selectedCategory = equipment.category;
|
||||||
_rentalPriceController.text = equipment.rentalPrice?.toStringAsFixed(2) ?? '';
|
_selectedStatus = equipment.status;
|
||||||
_totalQuantityController.text = equipment.totalQuantity?.toString() ?? '';
|
_purchasePriceController.text = equipment.purchasePrice?.toStringAsFixed(2) ?? '';
|
||||||
_criticalThresholdController.text = equipment.criticalThreshold?.toString() ?? '';
|
_rentalPriceController.text = equipment.rentalPrice?.toStringAsFixed(2) ?? '';
|
||||||
_purchaseDate = equipment.purchaseDate;
|
_totalQuantityController.text = equipment.totalQuantity?.toString() ?? '';
|
||||||
_lastMaintenanceDate = equipment.lastMaintenanceDate;
|
_criticalThresholdController.text = equipment.criticalThreshold?.toString() ?? '';
|
||||||
_nextMaintenanceDate = equipment.nextMaintenanceDate;
|
_purchaseDate = equipment.purchaseDate;
|
||||||
_selectedParentBoxIds = List.from(equipment.parentBoxIds);
|
_lastMaintenanceDate = equipment.lastMaintenanceDate;
|
||||||
_notesController.text = equipment.notes ?? '';
|
_nextMaintenanceDate = equipment.nextMaintenanceDate;
|
||||||
|
_notesController.text = equipment.notes ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
|
||||||
|
|
||||||
|
|
||||||
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
|
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
|
||||||
_loadFilteredModels(_selectedBrand!);
|
_loadFilteredModels(_selectedBrand!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Charger les sous-catégories pour la catégorie sélectionnée
|
||||||
|
_loadFilteredSubCategories(_selectedCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAvailableBoxes() async {
|
|
||||||
try {
|
|
||||||
final boxes = await _equipmentService.getBoxes();
|
|
||||||
setState(() {
|
|
||||||
_availableBoxes = boxes;
|
|
||||||
_isLoadingBoxes = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_isLoadingBoxes = false;
|
|
||||||
});
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Erreur lors du chargement des boîtes : $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadFilteredModels(String brand) async {
|
Future<void> _loadFilteredModels(String brand) async {
|
||||||
try {
|
try {
|
||||||
@@ -119,11 +109,26 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFilteredSubCategories(EquipmentCategory category) async {
|
||||||
|
try {
|
||||||
|
final equipmentProvider = Provider.of<EquipmentProvider>(context, listen: false);
|
||||||
|
final subCategories = await equipmentProvider.loadSubCategoriesByCategory(category);
|
||||||
|
setState(() {
|
||||||
|
_filteredSubCategories = subCategories;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_filteredSubCategories = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_identifierController.dispose();
|
_identifierController.dispose();
|
||||||
_brandController.dispose();
|
_brandController.dispose();
|
||||||
_modelController.dispose();
|
_modelController.dispose();
|
||||||
|
_subCategoryController.dispose();
|
||||||
_purchasePriceController.dispose();
|
_purchasePriceController.dispose();
|
||||||
_rentalPriceController.dispose();
|
_rentalPriceController.dispose();
|
||||||
_totalQuantityController.dispose();
|
_totalQuantityController.dispose();
|
||||||
@@ -282,7 +287,9 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
if (value != null) {
|
if (value != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCategory = value;
|
_selectedCategory = value;
|
||||||
|
_subCategoryController.clear();
|
||||||
});
|
});
|
||||||
|
_loadFilteredSubCategories(value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -318,6 +325,19 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Sous-catégorie
|
||||||
|
SubCategorySelector(
|
||||||
|
controller: _subCategoryController,
|
||||||
|
selectedCategory: _selectedCategory,
|
||||||
|
filteredSubCategories: _filteredSubCategories,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
// La valeur est déjà dans le controller
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Prix
|
// Prix
|
||||||
if (hasManagePermission) ...[
|
if (hasManagePermission) ...[
|
||||||
Row(
|
Row(
|
||||||
@@ -389,15 +409,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Boîtes parentes
|
|
||||||
const Divider(),
|
|
||||||
const Text('Boîtes parentes', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_isLoadingBoxes
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: _buildParentBoxesSelector(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Dates
|
// Dates
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
@@ -448,38 +459,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildParentBoxesSelector() {
|
|
||||||
if (_availableBoxes.isEmpty) {
|
|
||||||
return const Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(16.0),
|
|
||||||
child: Text('Aucune boîte disponible'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
child: Column(
|
|
||||||
children: _availableBoxes.map((box) {
|
|
||||||
final isSelected = _selectedParentBoxIds.contains(box.id);
|
|
||||||
return CheckboxListTile(
|
|
||||||
title: Text(box.name),
|
|
||||||
subtitle: box.model != null ? Text('Modèle: {box.model}') : null,
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (bool? value) {
|
|
||||||
setState(() {
|
|
||||||
if (value == true) {
|
|
||||||
_selectedParentBoxIds.add(box.id);
|
|
||||||
} else {
|
|
||||||
_selectedParentBoxIds.remove(box.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) {
|
Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
@@ -617,6 +596,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
brand: brand,
|
brand: brand,
|
||||||
model: model,
|
model: model,
|
||||||
category: _selectedCategory,
|
category: _selectedCategory,
|
||||||
|
subCategory: _subCategoryController.text.trim().isNotEmpty ? _subCategoryController.text.trim() : null,
|
||||||
status: _selectedStatus,
|
status: _selectedStatus,
|
||||||
purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null,
|
purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null,
|
||||||
rentalPrice: _rentalPriceController.text.isNotEmpty ? double.tryParse(_rentalPriceController.text) : null,
|
rentalPrice: _rentalPriceController.text.isNotEmpty ? double.tryParse(_rentalPriceController.text) : null,
|
||||||
@@ -625,17 +605,13 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
purchaseDate: _purchaseDate,
|
purchaseDate: _purchaseDate,
|
||||||
lastMaintenanceDate: _lastMaintenanceDate,
|
lastMaintenanceDate: _lastMaintenanceDate,
|
||||||
nextMaintenanceDate: _nextMaintenanceDate,
|
nextMaintenanceDate: _nextMaintenanceDate,
|
||||||
parentBoxIds: _selectedParentBoxIds,
|
|
||||||
notes: _notesController.text,
|
notes: _notesController.text,
|
||||||
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
|
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
availableQuantity: availableQuantity,
|
availableQuantity: availableQuantity,
|
||||||
);
|
);
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
await equipmentProvider.updateEquipment(
|
await equipmentProvider.updateEquipment(equipment);
|
||||||
equipment.id,
|
|
||||||
equipment.toMap(),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
await equipmentProvider.addEquipment(equipment);
|
await equipmentProvider.addEquipment(equipment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,21 @@ import 'package:em2rp/utils/permission_gate.dart';
|
|||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/views/equipment_form_page.dart';
|
import 'package:em2rp/views/equipment_form_page.dart';
|
||||||
import 'package:em2rp/views/equipment_detail_page.dart';
|
import 'package:em2rp/views/equipment_detail_page.dart';
|
||||||
|
import 'package:em2rp/views/container_detail_page.dart';
|
||||||
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
|
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
||||||
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_list.dart';
|
import 'package:em2rp/views/widgets/management/management_list.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||||
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||||
|
|
||||||
class EquipmentManagementPage extends StatefulWidget {
|
class EquipmentManagementPage extends StatefulWidget {
|
||||||
const EquipmentManagementPage({super.key});
|
const EquipmentManagementPage({super.key});
|
||||||
@@ -26,12 +33,66 @@ class EquipmentManagementPage extends StatefulWidget {
|
|||||||
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||||
with SelectionModeMixin<EquipmentManagementPage> {
|
with SelectionModeMixin<EquipmentManagementPage> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
EquipmentCategory? _selectedCategory;
|
EquipmentCategory? _selectedCategory;
|
||||||
List<EquipmentModel>? _cachedEquipment;
|
List<EquipmentModel>? _cachedEquipment;
|
||||||
|
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
DebugLog.info('[EquipmentManagementPage] initState called');
|
||||||
|
|
||||||
|
// Activer le mode pagination
|
||||||
|
final provider = context.read<EquipmentProvider>();
|
||||||
|
provider.enablePagination();
|
||||||
|
|
||||||
|
// Ajouter le listener de scroll pour le chargement infini
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
// Charger la première page au démarrage
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
DebugLog.info('[EquipmentManagementPage] Loading first page...');
|
||||||
|
provider.loadFirstPage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
// Éviter les appels multiples
|
||||||
|
if (_isLoadingMore) return;
|
||||||
|
|
||||||
|
final provider = context.read<EquipmentProvider>();
|
||||||
|
|
||||||
|
// Charger la page suivante quand on arrive à 300px du bas
|
||||||
|
if (_scrollController.hasClients &&
|
||||||
|
_scrollController.position.pixels >=
|
||||||
|
_scrollController.position.maxScrollExtent - 300) {
|
||||||
|
|
||||||
|
// Vérifier qu'on peut charger plus
|
||||||
|
if (provider.hasMore && !provider.isLoadingMore) {
|
||||||
|
setState(() => _isLoadingMore = true);
|
||||||
|
|
||||||
|
provider.loadNextPage().then((_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoadingMore = false);
|
||||||
|
}
|
||||||
|
}).catchError((error) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoadingMore = false);
|
||||||
|
}
|
||||||
|
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
// Désactiver le mode pagination en quittant
|
||||||
|
context.read<EquipmentProvider>().disablePagination();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +129,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
const NotificationBadge(),
|
||||||
if (hasSelection) ...[
|
if (hasSelection) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.qr_code, color: Colors.white),
|
icon: const Icon(Icons.qr_code, color: Colors.white),
|
||||||
@@ -84,13 +146,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
)
|
)
|
||||||
: CustomAppBar(
|
: CustomAppBar(
|
||||||
title: 'Gestion du matériel',
|
title: 'Gestion du matériel',
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.checklist),
|
|
||||||
tooltip: 'Mode sélection',
|
|
||||||
onPressed: toggleSelectionMode,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/equipment_management'),
|
drawer: const MainDrawer(currentPage: '/equipment_management'),
|
||||||
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
||||||
@@ -114,50 +169,39 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
Widget _buildMobileLayout() {
|
Widget _buildMobileLayout() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Barre de recherche et bouton boîtes
|
// Barre de recherche et boutons d'action
|
||||||
Padding(
|
SearchActionsBar(
|
||||||
padding: const EdgeInsets.all(16.0),
|
controller: _searchController,
|
||||||
child: Row(
|
hintText: 'Rechercher par nom, modèle ou ID...',
|
||||||
children: [
|
onChanged: (value) {
|
||||||
Expanded(
|
context.read<EquipmentProvider>().setSearchQuery(value);
|
||||||
child: TextField(
|
},
|
||||||
controller: _searchController,
|
onClear: () {
|
||||||
decoration: InputDecoration(
|
_searchController.clear();
|
||||||
hintText: 'Rechercher par nom, modèle ou ID...',
|
context.read<EquipmentProvider>().setSearchQuery('');
|
||||||
prefixIcon: const Icon(Icons.search),
|
},
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
actions: [
|
||||||
? IconButton(
|
IconButton.filled(
|
||||||
icon: const Icon(Icons.clear),
|
onPressed: _scanQRCode,
|
||||||
onPressed: () {
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
_searchController.clear();
|
tooltip: 'Scanner un QR Code',
|
||||||
context.read<EquipmentProvider>().setSearchQuery('');
|
style: IconButton.styleFrom(
|
||||||
},
|
backgroundColor: Colors.grey[700],
|
||||||
)
|
foregroundColor: Colors.white,
|
||||||
: null,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onChanged: (value) {
|
|
||||||
context.read<EquipmentProvider>().setSearchQuery(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
// Bouton Gérer les boîtes
|
IconButton.filled(
|
||||||
IconButton.filled(
|
onPressed: () {
|
||||||
onPressed: () {
|
Navigator.pushNamed(context, '/container_management');
|
||||||
Navigator.pushNamed(context, '/container_management');
|
},
|
||||||
},
|
icon: const Icon(Icons.inventory_2),
|
||||||
icon: const Icon(Icons.inventory_2),
|
tooltip: 'Gérer les boîtes',
|
||||||
tooltip: 'Gérer les boîtes',
|
style: IconButton.styleFrom(
|
||||||
style: IconButton.styleFrom(
|
backgroundColor: AppColors.rouge,
|
||||||
backgroundColor: AppColors.rouge,
|
foregroundColor: Colors.white,
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
// Menu horizontal de filtres par catégorie
|
// Menu horizontal de filtres par catégorie
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -222,29 +266,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Bouton Gérer les boîtes
|
const SizedBox(height: 16),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pushNamed(context, '/container_management');
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.inventory_2, color: Colors.white),
|
|
||||||
label: const Text(
|
|
||||||
'Gérer les boîtes',
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.rouge,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
|
||||||
vertical: 16,
|
|
||||||
),
|
|
||||||
minimumSize: const Size(double.infinity, 50),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
// En-tête filtres
|
// En-tête filtres
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -265,37 +287,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Barre de recherche
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Rechercher...',
|
|
||||||
prefixIcon: const Icon(Icons.search, size: 20),
|
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear, size: 20),
|
|
||||||
onPressed: () {
|
|
||||||
_searchController.clear();
|
|
||||||
context
|
|
||||||
.read<EquipmentProvider>()
|
|
||||||
.setSearchQuery('');
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
isDense: true,
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
),
|
|
||||||
onChanged: (value) {
|
|
||||||
context.read<EquipmentProvider>().setSearchQuery(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Filtres par catégorie
|
// Filtres par catégorie
|
||||||
Padding(
|
Padding(
|
||||||
padding:
|
padding:
|
||||||
@@ -349,7 +340,56 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Contenu principal
|
// Contenu principal
|
||||||
Expanded(child: _buildEquipmentList()),
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SearchActionsBar(
|
||||||
|
controller: _searchController,
|
||||||
|
hintText: 'Rechercher par nom, modèle ou ID...',
|
||||||
|
onChanged: (value) {
|
||||||
|
context.read<EquipmentProvider>().setSearchQuery(value);
|
||||||
|
},
|
||||||
|
onClear: () {
|
||||||
|
_searchController.clear();
|
||||||
|
context.read<EquipmentProvider>().setSearchQuery('');
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: _scanQRCode,
|
||||||
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
|
tooltip: 'Scanner un QR Code',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[700],
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pushNamed(context, '/container_management');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.inventory_2),
|
||||||
|
tooltip: 'Gérer les boîtes',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isSelectionMode)
|
||||||
|
IconButton.filled(
|
||||||
|
onPressed: toggleSelectionMode,
|
||||||
|
icon: const Icon(Icons.checklist),
|
||||||
|
tooltip: 'Mode sélection',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(child: _buildEquipmentList()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -420,16 +460,62 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
Widget _buildEquipmentList() {
|
Widget _buildEquipmentList() {
|
||||||
return Consumer<EquipmentProvider>(
|
return Consumer<EquipmentProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
return ManagementList<EquipmentModel>(
|
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
||||||
stream: provider.equipmentStream,
|
|
||||||
cachedItems: _cachedEquipment,
|
// Afficher l'indicateur de chargement initial uniquement
|
||||||
emptyMessage: 'Aucun équipement trouvé',
|
if (provider.isLoading && provider.equipment.isEmpty) {
|
||||||
emptyIcon: Icons.inventory_2_outlined,
|
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
|
||||||
onDataReceived: (items) {
|
return const Center(child: CircularProgressIndicator());
|
||||||
_cachedEquipment = items;
|
}
|
||||||
},
|
|
||||||
itemBuilder: (equipment) {
|
final equipments = provider.equipment;
|
||||||
return _buildEquipmentCard(equipment);
|
|
||||||
|
if (equipments.isEmpty && !provider.isLoading) {
|
||||||
|
DebugLog.info('[EquipmentManagementPage] No equipment found');
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucun équipement trouvé',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
|
||||||
|
|
||||||
|
// Calculer le nombre total d'items (équipements + indicateur de chargement)
|
||||||
|
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
// Dernier élément = indicateur de chargement
|
||||||
|
if (index == equipments.length) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: provider.isLoadingMore
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildEquipmentCard(equipments[index]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -484,6 +570,18 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
: 'Marque/Modèle non défini',
|
: 'Marque/Modèle non défini',
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
||||||
),
|
),
|
||||||
|
// Afficher la sous-catégorie si elle existe
|
||||||
|
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'📁 ${equipment.subCategory}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[500],
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
// Afficher la quantité disponible pour les consommables/câbles
|
// Afficher la quantité disponible pour les consommables/câbles
|
||||||
if (equipment.category == EquipmentCategory.consumable ||
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
equipment.category == EquipmentCategory.cable) ...[
|
equipment.category == EquipmentCategory.cable) ...[
|
||||||
@@ -716,39 +814,75 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
void _generateQRCodesForSelected() async {
|
void _generateQRCodesForSelected() async {
|
||||||
if (!hasSelection) return;
|
if (!hasSelection) return;
|
||||||
|
|
||||||
// Récupérer les équipements sélectionnés
|
// Afficher un indicateur de chargement
|
||||||
final provider = context.read<EquipmentProvider>();
|
showDialog(
|
||||||
final List<EquipmentModel> selectedEquipment = [];
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// On doit récupérer les équipements depuis le stream
|
try {
|
||||||
await for (final equipmentList in provider.equipmentStream.take(1)) {
|
// Récupérer les équipements sélectionnés
|
||||||
for (final equipment in equipmentList) {
|
final provider = context.read<EquipmentProvider>();
|
||||||
if (isItemSelected(equipment.id)) {
|
final List<EquipmentModel> selectedEquipment = [];
|
||||||
selectedEquipment.add(equipment);
|
|
||||||
|
// On doit récupérer les équipements depuis le stream
|
||||||
|
await for (final equipmentList in provider.equipmentStream.take(1)) {
|
||||||
|
for (final equipment in equipmentList) {
|
||||||
|
if (isItemSelected(equipment.id)) {
|
||||||
|
selectedEquipment.add(equipment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer l'indicateur de chargement
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEquipment.isEmpty) return;
|
||||||
|
|
||||||
|
if (selectedEquipment.length == 1) {
|
||||||
|
// Un seul équipement : afficher le dialogue simple
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plusieurs équipements : afficher le sélecteur de format
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => QRCodeFormatSelectorDialog<EquipmentModel>(
|
||||||
|
itemList: selectedEquipment,
|
||||||
|
getId: (eq) => eq.id,
|
||||||
|
getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
|
||||||
|
dialogTitle: 'Générer ${selectedEquipment.length} QR Code(s)',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
} catch (e) {
|
||||||
}
|
// Fermer l'indicateur si une erreur survient
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedEquipment.isEmpty) return;
|
DebugLog.error('[EquipmentManagementPage] Error generating QR codes', e);
|
||||||
|
|
||||||
if (selectedEquipment.length == 1) {
|
if (mounted) {
|
||||||
// Un seul équipement : afficher le dialogue simple
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
showDialog(
|
SnackBar(
|
||||||
context: context,
|
content: Text('Erreur lors de la génération : ${e.toString()}'),
|
||||||
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
|
backgroundColor: Colors.red,
|
||||||
);
|
),
|
||||||
} else {
|
);
|
||||||
// Plusieurs équipements : afficher le sélecteur de format
|
}
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => QRCodeFormatSelectorDialog<EquipmentModel>(
|
|
||||||
itemList: selectedEquipment,
|
|
||||||
getId: (eq) => eq.id,
|
|
||||||
getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
|
|
||||||
dialogTitle: 'Générer ${selectedEquipment.length} QR Code(s)',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,10 +1037,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
'updatedAt': DateTime.now().toIso8601String(),
|
'updatedAt': DateTime.now().toIso8601String(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await context.read<EquipmentProvider>().updateEquipment(
|
final updatedEquipment = equipment.copyWith(
|
||||||
equipment.id,
|
availableQuantity: newAvailable,
|
||||||
updatedData,
|
totalQuantity: newTotal,
|
||||||
);
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -949,4 +1086,119 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scanner un QR Code et ouvrir la vue de détail correspondante
|
||||||
|
Future<void> _scanQRCode() async {
|
||||||
|
try {
|
||||||
|
// Ouvrir le scanner
|
||||||
|
final scannedCode = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const QRCodeScannerDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scannedCode == null || scannedCode.isEmpty) {
|
||||||
|
return; // L'utilisateur a annulé
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Afficher un indicateur de chargement
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rechercher d'abord dans les équipements
|
||||||
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
|
await equipmentProvider.ensureLoaded();
|
||||||
|
|
||||||
|
final equipment = equipmentProvider.allEquipment.firstWhere(
|
||||||
|
(eq) => eq.id == scannedCode,
|
||||||
|
orElse: () => EquipmentModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
category: EquipmentCategory.other,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
maintenanceIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop(); // Fermer l'indicateur
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipment.id.isNotEmpty) {
|
||||||
|
// Équipement trouvé
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => EquipmentDetailPage(equipment: equipment),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si pas trouvé dans les équipements, chercher dans les conteneurs
|
||||||
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
if (containerProvider.containers.isEmpty) {
|
||||||
|
await containerProvider.loadContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
final container = containerProvider.containers.firstWhere(
|
||||||
|
(c) => c.id == scannedCode,
|
||||||
|
orElse: () => ContainerModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: ContainerType.flightCase,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
equipmentIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (container.id.isNotEmpty) {
|
||||||
|
// Conteneur trouvé
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ContainerDetailPage(container: container),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rien trouvé
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentManagementPage] Error scanning QR code', e);
|
||||||
|
if (mounted) {
|
||||||
|
// Fermer l'indicateur si ouvert
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur lors du scan : ${e.toString()}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_functions/cloud_functions.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/services/event_preparation_service.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
import 'package:em2rp/services/event_preparation_service_extended.dart';
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
||||||
|
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
|
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
@@ -34,9 +38,8 @@ class EventPreparationPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin {
|
class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin {
|
||||||
final EventPreparationService _preparationService = EventPreparationService();
|
|
||||||
final EventPreparationServiceExtended _extendedService = EventPreparationServiceExtended();
|
|
||||||
late AnimationController _animationController;
|
late AnimationController _animationController;
|
||||||
|
late final DataService _dataService;
|
||||||
|
|
||||||
Map<String, EquipmentModel> _equipmentCache = {};
|
Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
Map<String, ContainerModel> _containerCache = {};
|
Map<String, ContainerModel> _containerCache = {};
|
||||||
@@ -45,6 +48,13 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
// État local des validations (non sauvegardé jusqu'à la validation finale)
|
// État local des validations (non sauvegardé jusqu'à la validation finale)
|
||||||
Map<String, bool> _localValidationState = {};
|
Map<String, bool> _localValidationState = {};
|
||||||
|
|
||||||
|
|
||||||
|
// NOUVEAU : Gestion des quantités par étape
|
||||||
|
Map<String, int> _quantitiesAtPreparation = {};
|
||||||
|
Map<String, int> _quantitiesAtLoading = {};
|
||||||
|
Map<String, int> _quantitiesAtUnloading = {};
|
||||||
|
Map<String, int> _quantitiesAtReturn = {};
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isValidating = false;
|
bool _isValidating = false;
|
||||||
bool _showSuccessAnimation = false;
|
bool _showSuccessAnimation = false;
|
||||||
@@ -89,12 +99,13 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_currentEvent = widget.initialEvent;
|
_currentEvent = widget.initialEvent;
|
||||||
|
_dataService = DataService(FirebaseFunctionsApiService());
|
||||||
_animationController = AnimationController(
|
_animationController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Vérification de sécurité : bloquer l'accès si toutes les étapes sont complétées
|
// Vérification de sécurité et chargement après le premier frame
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_isCurrentStepCompleted()) {
|
if (_isCurrentStepCompleted()) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -106,9 +117,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
_loadEquipmentAndContainers();
|
// Charger les équipements après le premier frame pour éviter setState pendant build
|
||||||
|
_loadEquipmentAndContainers();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifie si l'étape actuelle est déjà complétée
|
/// Vérifie si l'étape actuelle est déjà complétée
|
||||||
@@ -131,24 +143,6 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recharger l'événement depuis Firestore
|
|
||||||
Future<void> _reloadEvent() async {
|
|
||||||
try {
|
|
||||||
final doc = await FirebaseFirestore.instance
|
|
||||||
.collection('events')
|
|
||||||
.doc(_currentEvent.id)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (doc.exists) {
|
|
||||||
setState(() {
|
|
||||||
_currentEvent = EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('[EventPreparationPage] Error reloading event: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadEquipmentAndContainers() async {
|
Future<void> _loadEquipmentAndContainers() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
@@ -156,6 +150,10 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
final equipmentProvider = context.read<EquipmentProvider>();
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
|
||||||
|
// S'assurer que les équipements sont chargés
|
||||||
|
await equipmentProvider.ensureLoaded();
|
||||||
|
await containerProvider.ensureLoaded();
|
||||||
|
|
||||||
final equipment = await equipmentProvider.equipmentStream.first;
|
final equipment = await equipmentProvider.equipmentStream.first;
|
||||||
final containers = await containerProvider.containersStream.first;
|
final containers = await containerProvider.containersStream.first;
|
||||||
|
|
||||||
@@ -167,7 +165,6 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
name: 'Équipement inconnu',
|
name: 'Équipement inconnu',
|
||||||
category: EquipmentCategory.other,
|
category: EquipmentCategory.other,
|
||||||
status: EquipmentStatus.available,
|
status: EquipmentStatus.available,
|
||||||
parentBoxIds: [],
|
|
||||||
maintenanceIds: [],
|
maintenanceIds: [],
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
@@ -194,7 +191,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
if ((_currentStep == PreparationStep.return_ ||
|
if ((_currentStep == PreparationStep.return_ ||
|
||||||
_currentStep == PreparationStep.unloadingReturn) &&
|
_currentStep == PreparationStep.unloadingReturn) &&
|
||||||
equipmentItem.hasQuantity) {
|
equipmentItem.hasQuantity) {
|
||||||
_returnedQuantities[eq.equipmentId] = eq.returnedQuantity ?? eq.quantity;
|
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,52 +211,113 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
_containerCache[containerId] = container;
|
_containerCache[containerId] = container;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventPreparationPage] Error: $e');
|
DebugLog.error('[EventPreparationPage] Error', e);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Basculer l'état de validation d'un équipement (état local uniquement)
|
/// Basculer l'état de validation d'un équipement (état local uniquement)
|
||||||
void _toggleEquipmentValidation(String equipmentId) {
|
Future<void> _toggleEquipmentValidation(String equipmentId) async {
|
||||||
|
final currentState = _localValidationState[equipmentId] ?? false;
|
||||||
|
|
||||||
|
// Si on veut valider (passer de false à true) et que c'était manquant avant
|
||||||
|
if (!currentState && _wasMissingAtPreviousStep(equipmentId)) {
|
||||||
|
final confirmed = await _confirmValidationIfWasMissingBefore(equipmentId);
|
||||||
|
if (!confirmed) {
|
||||||
|
return; // Annulation, ne rien faire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_localValidationState[equipmentId] = !(_localValidationState[equipmentId] ?? false);
|
_localValidationState[equipmentId] = !currentState;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _validateAll() async {
|
/// Marquer TOUT comme validé et enregistrer (bouton "Tout confirmer")
|
||||||
|
Future<void> _validateAllAndConfirm() async {
|
||||||
|
// Marquer tout comme validé localement
|
||||||
|
setState(() {
|
||||||
|
for (var eq in _currentEvent.assignedEquipment) {
|
||||||
|
_localValidationState[eq.equipmentId] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Puis enregistrer
|
||||||
|
await _confirmCurrentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enregistrer l'état actuel TEL QUEL (cochés = validés, non cochés = manquants)
|
||||||
|
Future<void> _confirmCurrentState() async {
|
||||||
setState(() => _isValidating = true);
|
setState(() => _isValidating = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Si "tout valider" est cliqué, marquer tout comme validé localement
|
// Déterminer les manquants = équipements NON validés
|
||||||
for (var equipmentId in _localValidationState.keys) {
|
final Map<String, bool> missingAtThisStep = {};
|
||||||
_localValidationState[equipmentId] = true;
|
for (var eq in _currentEvent.assignedEquipment) {
|
||||||
|
final isValidated = _localValidationState[eq.equipmentId] ?? false;
|
||||||
|
missingAtThisStep[eq.equipmentId] = !isValidated; // Manquant si pas validé
|
||||||
}
|
}
|
||||||
|
|
||||||
// Préparer la liste des équipements avec leur nouvel état
|
// Préparer la liste des équipements avec leur nouvel état
|
||||||
final updatedEquipment = _currentEvent.assignedEquipment.map((eq) {
|
final updatedEquipment = _currentEvent.assignedEquipment.map((eq) {
|
||||||
final isValidated = _localValidationState[eq.equipmentId] ?? false;
|
final isValidated = _localValidationState[eq.equipmentId] ?? false;
|
||||||
|
final isMissing = missingAtThisStep[eq.equipmentId] ?? false;
|
||||||
|
|
||||||
|
// Récupérer les quantités selon l'étape
|
||||||
|
final qtyAtPrep = _quantitiesAtPreparation[eq.equipmentId];
|
||||||
|
final qtyAtLoad = _quantitiesAtLoading[eq.equipmentId];
|
||||||
|
final qtyAtUnload = _quantitiesAtUnloading[eq.equipmentId];
|
||||||
|
final qtyAtRet = _quantitiesAtReturn[eq.equipmentId];
|
||||||
|
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
case PreparationStep.preparation:
|
case PreparationStep.preparation:
|
||||||
if (_loadSimultaneously) {
|
if (_loadSimultaneously) {
|
||||||
return eq.copyWith(isPrepared: isValidated, isLoaded: isValidated);
|
return eq.copyWith(
|
||||||
|
isPrepared: isValidated,
|
||||||
|
isLoaded: isValidated,
|
||||||
|
isMissingAtPreparation: isMissing,
|
||||||
|
isMissingAtLoading: isMissing, // Propager
|
||||||
|
quantityAtPreparation: qtyAtPrep,
|
||||||
|
quantityAtLoading: qtyAtPrep, // Même quantité
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return eq.copyWith(isPrepared: isValidated);
|
return eq.copyWith(
|
||||||
|
isPrepared: isValidated,
|
||||||
|
isMissingAtPreparation: isMissing,
|
||||||
|
quantityAtPreparation: qtyAtPrep,
|
||||||
|
);
|
||||||
|
|
||||||
case PreparationStep.loadingOutbound:
|
case PreparationStep.loadingOutbound:
|
||||||
return eq.copyWith(isLoaded: isValidated);
|
return eq.copyWith(
|
||||||
|
isLoaded: isValidated,
|
||||||
|
isMissingAtLoading: isMissing,
|
||||||
|
quantityAtLoading: qtyAtLoad,
|
||||||
|
);
|
||||||
|
|
||||||
case PreparationStep.unloadingReturn:
|
case PreparationStep.unloadingReturn:
|
||||||
if (_loadSimultaneously) {
|
if (_loadSimultaneously) {
|
||||||
final returnedQty = _returnedQuantities[eq.equipmentId] ?? eq.quantity;
|
return eq.copyWith(
|
||||||
return eq.copyWith(isUnloaded: isValidated, isReturned: isValidated, returnedQuantity: returnedQty);
|
isUnloaded: isValidated,
|
||||||
|
isReturned: isValidated,
|
||||||
|
isMissingAtUnloading: isMissing,
|
||||||
|
isMissingAtReturn: isMissing, // Propager
|
||||||
|
quantityAtUnloading: qtyAtUnload,
|
||||||
|
quantityAtReturn: qtyAtRet ?? qtyAtUnload,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return eq.copyWith(isUnloaded: isValidated);
|
return eq.copyWith(
|
||||||
|
isUnloaded: isValidated,
|
||||||
|
isMissingAtUnloading: isMissing,
|
||||||
|
quantityAtUnloading: qtyAtUnload,
|
||||||
|
);
|
||||||
|
|
||||||
case PreparationStep.return_:
|
case PreparationStep.return_:
|
||||||
final returnedQty = _returnedQuantities[eq.equipmentId] ?? eq.quantity;
|
return eq.copyWith(
|
||||||
return eq.copyWith(isReturned: isValidated, returnedQuantity: returnedQty);
|
isReturned: isValidated,
|
||||||
|
isMissingAtReturn: isMissing,
|
||||||
|
quantityAtReturn: qtyAtRet,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -269,35 +327,46 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Ajouter les statuts selon l'étape et la checkbox
|
// Ajouter les statuts selon l'étape et la checkbox
|
||||||
|
String validationType = 'CHECK';
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
case PreparationStep.preparation:
|
case PreparationStep.preparation:
|
||||||
updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed);
|
updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed);
|
||||||
|
validationType = 'CHECK_OUT';
|
||||||
if (_loadSimultaneously) {
|
if (_loadSimultaneously) {
|
||||||
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
|
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
|
||||||
|
validationType = 'LOADING';
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PreparationStep.loadingOutbound:
|
case PreparationStep.loadingOutbound:
|
||||||
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
|
updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed);
|
||||||
|
validationType = 'LOADING';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PreparationStep.unloadingReturn:
|
case PreparationStep.unloadingReturn:
|
||||||
updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed);
|
updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed);
|
||||||
|
validationType = 'UNLOADING';
|
||||||
if (_loadSimultaneously) {
|
if (_loadSimultaneously) {
|
||||||
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
|
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
|
||||||
|
validationType = 'CHECK_IN';
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PreparationStep.return_:
|
case PreparationStep.return_:
|
||||||
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
|
updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed);
|
||||||
|
validationType = 'CHECK_IN';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sauvegarder dans Firestore
|
// Sauvegarder dans Firestore via l'API
|
||||||
await FirebaseFirestore.instance
|
await _dataService.updateEventEquipment(
|
||||||
.collection('events')
|
eventId: _currentEvent.id,
|
||||||
.doc(_currentEvent.id)
|
assignedEquipment: updatedEquipment.map((e) => e.toMap()).toList(),
|
||||||
.update(updateData);
|
preparationStatus: updateData['preparationStatus'],
|
||||||
|
loadingStatus: updateData['loadingStatus'],
|
||||||
|
unloadingStatus: updateData['unloadingStatus'],
|
||||||
|
returnStatus: updateData['returnStatus'],
|
||||||
|
);
|
||||||
|
|
||||||
// Mettre à jour les statuts des équipements si nécessaire
|
// Mettre à jour les statuts des équipements si nécessaire
|
||||||
if (_currentStep == PreparationStep.preparation ||
|
if (_currentStep == PreparationStep.preparation ||
|
||||||
@@ -305,6 +374,49 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
await _updateEquipmentStatuses(updatedEquipment);
|
await _updateEquipmentStatuses(updatedEquipment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOUVEAU: Appeler la Cloud Function pour traiter la validation
|
||||||
|
// et créer les alertes automatiquement
|
||||||
|
try {
|
||||||
|
DebugLog.info('[EventPreparationPage] Appel processEquipmentValidation');
|
||||||
|
|
||||||
|
final equipmentList = updatedEquipment.map((eq) {
|
||||||
|
final equipment = _equipmentCache[eq.equipmentId];
|
||||||
|
return {
|
||||||
|
'equipmentId': eq.equipmentId,
|
||||||
|
'name': equipment?.name ?? 'Équipement inconnu',
|
||||||
|
'status': _determineEquipmentStatus(eq),
|
||||||
|
'quantity': _getQuantityForStep(eq),
|
||||||
|
'expectedQuantity': eq.quantity,
|
||||||
|
'isMissingAtPreparation': eq.isMissingAtPreparation,
|
||||||
|
'isMissingAtReturn': eq.isMissingAtReturn,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final result = await FirebaseFunctions.instanceFor(region: 'us-central1')
|
||||||
|
.httpsCallable('processEquipmentValidation')
|
||||||
|
.call({
|
||||||
|
'eventId': _currentEvent.id,
|
||||||
|
'equipmentList': equipmentList,
|
||||||
|
'validationType': validationType,
|
||||||
|
});
|
||||||
|
|
||||||
|
final alertsCreated = result.data['alertsCreated'] ?? 0;
|
||||||
|
if (alertsCreated > 0) {
|
||||||
|
DebugLog.info('[EventPreparationPage] $alertsCreated alertes créées automatiquement');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EventPreparationPage] Erreur appel processEquipmentValidation', e);
|
||||||
|
// Ne pas bloquer la validation si les alertes échouent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recharger l'événement depuis le provider
|
||||||
|
final eventProvider = context.read<EventProvider>();
|
||||||
|
// Recharger la liste des événements pour rafraîchir les données
|
||||||
|
final userId = context.read<LocalUserProvider>().uid;
|
||||||
|
if (userId != null) {
|
||||||
|
await eventProvider.loadUserEvents(userId, canViewAllEvents: true);
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _showSuccessAnimation = true);
|
setState(() => _showSuccessAnimation = true);
|
||||||
_animationController.forward();
|
_animationController.forward();
|
||||||
|
|
||||||
@@ -338,52 +450,37 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async {
|
Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async {
|
||||||
for (var eq in equipment) {
|
for (var eq in equipment) {
|
||||||
try {
|
try {
|
||||||
final doc = await FirebaseFirestore.instance
|
final equipmentData = _equipmentCache[eq.equipmentId];
|
||||||
.collection('equipments')
|
if (equipmentData == null) continue;
|
||||||
.doc(eq.equipmentId)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (doc.exists) {
|
// Déterminer le nouveau statut
|
||||||
final equipmentData = EquipmentModel.fromMap(
|
EquipmentStatus newStatus;
|
||||||
doc.data() as Map<String, dynamic>,
|
if (eq.isReturned) {
|
||||||
doc.id,
|
newStatus = EquipmentStatus.available;
|
||||||
|
} else if (eq.isPrepared || eq.isLoaded) {
|
||||||
|
newStatus = EquipmentStatus.inUse;
|
||||||
|
} else {
|
||||||
|
continue; // Pas de changement
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ne mettre à jour que les équipements non quantifiables
|
||||||
|
if (!equipmentData.hasQuantity) {
|
||||||
|
await _dataService.updateEquipmentStatusOnly(
|
||||||
|
equipmentId: eq.equipmentId,
|
||||||
|
status: equipmentStatusToString(newStatus),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Déterminer le nouveau statut
|
// Gérer les stocks pour les consommables
|
||||||
EquipmentStatus newStatus;
|
if (equipmentData.hasQuantity && eq.isReturned && eq.quantityAtReturn != null) {
|
||||||
if (eq.isReturned) {
|
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
||||||
newStatus = EquipmentStatus.available;
|
await _dataService.updateEquipmentStatusOnly(
|
||||||
} else if (eq.isPrepared || eq.isLoaded) {
|
equipmentId: eq.equipmentId,
|
||||||
newStatus = EquipmentStatus.inUse;
|
availableQuantity: currentAvailable + eq.quantityAtReturn!,
|
||||||
} else {
|
);
|
||||||
continue; // Pas de changement
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ne mettre à jour que les équipements non quantifiables
|
|
||||||
if (!equipmentData.hasQuantity) {
|
|
||||||
await FirebaseFirestore.instance
|
|
||||||
.collection('equipments')
|
|
||||||
.doc(eq.equipmentId)
|
|
||||||
.update({
|
|
||||||
'status': equipmentStatusToString(newStatus),
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gérer les stocks pour les consommables
|
|
||||||
if (equipmentData.hasQuantity && eq.isReturned && eq.returnedQuantity != null) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await FirebaseFirestore.instance
|
|
||||||
.collection('equipments')
|
|
||||||
.doc(eq.equipmentId)
|
|
||||||
.update({
|
|
||||||
'availableQuantity': currentAvailable + eq.returnedQuantity!,
|
|
||||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error updating equipment status for ${eq.equipmentId}: $e');
|
// Erreur silencieuse pour ne pas bloquer le processus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,7 +573,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
|
|
||||||
if (missingEquipmentIds.isEmpty) {
|
if (missingEquipmentIds.isEmpty) {
|
||||||
// Tout est validé, confirmer directement
|
// Tout est validé, confirmer directement
|
||||||
await _validateAll();
|
await _confirmCurrentState();
|
||||||
} else {
|
} else {
|
||||||
// Afficher le dialog des manquants
|
// Afficher le dialog des manquants
|
||||||
final missingEquipmentModels = missingEquipmentIds
|
final missingEquipmentModels = missingEquipmentIds
|
||||||
@@ -499,8 +596,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (action == 'confirm_anyway') {
|
if (action == 'confirm_anyway') {
|
||||||
// Confirmer malgré les manquants
|
// Confirmer malgré les manquants (enregistrer l'état actuel TEL QUEL)
|
||||||
await _validateAll();
|
await _confirmCurrentState();
|
||||||
} else if (action == 'mark_as_validated') {
|
} else if (action == 'mark_as_validated') {
|
||||||
// Marquer les manquants comme validés localement
|
// Marquer les manquants comme validés localement
|
||||||
for (var equipmentId in missingEquipmentIds) {
|
for (var equipmentId in missingEquipmentIds) {
|
||||||
@@ -508,12 +605,167 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
}
|
}
|
||||||
setState(() {});
|
setState(() {});
|
||||||
// Puis confirmer
|
// Puis confirmer
|
||||||
await _validateAll();
|
await _confirmCurrentState();
|
||||||
}
|
}
|
||||||
// Si 'return_to_list', ne rien faire
|
// Si 'return_to_list', ne rien faire
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Valider tous les enfants d'un container
|
||||||
|
void _validateAllContainerChildren(String containerId) {
|
||||||
|
final container = _containerCache[containerId];
|
||||||
|
if (container == null) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
for (final equipmentId in container.equipmentIds) {
|
||||||
|
if (_equipmentCache.containsKey(equipmentId)) {
|
||||||
|
_localValidationState[equipmentId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mettre à jour la quantité d'un équipement à l'étape actuelle
|
||||||
|
void _updateEquipmentQuantity(String equipmentId, int newQuantity) {
|
||||||
|
setState(() {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case PreparationStep.preparation:
|
||||||
|
_quantitiesAtPreparation[equipmentId] = newQuantity;
|
||||||
|
break;
|
||||||
|
case PreparationStep.loadingOutbound:
|
||||||
|
_quantitiesAtLoading[equipmentId] = newQuantity;
|
||||||
|
break;
|
||||||
|
case PreparationStep.unloadingReturn:
|
||||||
|
_quantitiesAtUnloading[equipmentId] = newQuantity;
|
||||||
|
break;
|
||||||
|
case PreparationStep.return_:
|
||||||
|
_quantitiesAtReturn[equipmentId] = newQuantity;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifier si un équipement était manquant à l'étape précédente
|
||||||
|
bool _wasMissingAtPreviousStep(String equipmentId) {
|
||||||
|
final eq = _currentEvent.assignedEquipment.firstWhere(
|
||||||
|
(e) => e.equipmentId == equipmentId,
|
||||||
|
orElse: () => EventEquipment(equipmentId: equipmentId),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (_currentStep) {
|
||||||
|
case PreparationStep.preparation:
|
||||||
|
return false; // Pas d'étape avant
|
||||||
|
case PreparationStep.loadingOutbound:
|
||||||
|
return eq.isMissingAtPreparation;
|
||||||
|
case PreparationStep.unloadingReturn:
|
||||||
|
return eq.isMissingAtLoading;
|
||||||
|
case PreparationStep.return_:
|
||||||
|
return eq.isMissingAtUnloading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Afficher pop-up de confirmation si l'équipement était manquant avant
|
||||||
|
Future<bool> _confirmValidationIfWasMissingBefore(String equipmentId) async {
|
||||||
|
if (!_wasMissingAtPreviousStep(equipmentId)) {
|
||||||
|
return true; // Pas de problème, continuer
|
||||||
|
}
|
||||||
|
|
||||||
|
final equipment = _equipmentCache[equipmentId];
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning_amber, color: Colors.orange),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Confirmation'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
'L\'équipement "${equipment?.name ?? equipmentId}" était manquant à l\'étape précédente.\n\n'
|
||||||
|
'Êtes-vous sûr de le marquer comme présent maintenant ?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
child: const Text('Confirmer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Détermine le statut d'un équipement selon l'étape actuelle
|
||||||
|
String _determineEquipmentStatus(EventEquipment eq) {
|
||||||
|
// Vérifier d'abord si l'équipement est perdu (LOST)
|
||||||
|
if (_shouldMarkAsLost(eq)) {
|
||||||
|
return 'LOST';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si manquant à l'étape actuelle
|
||||||
|
if (_isMissingAtCurrentStep(eq)) {
|
||||||
|
return 'MISSING';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les quantités
|
||||||
|
final currentQty = _getQuantityForStep(eq);
|
||||||
|
if (currentQty != null && currentQty < eq.quantity) {
|
||||||
|
return 'QUANTITY_MISMATCH';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'AVAILABLE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un équipement doit être marqué comme LOST
|
||||||
|
bool _shouldMarkAsLost(EventEquipment eq) {
|
||||||
|
// Seulement aux étapes de retour
|
||||||
|
if (_currentStep != PreparationStep.return_ &&
|
||||||
|
!(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si manquant maintenant mais PAS manquant à la préparation = LOST
|
||||||
|
return eq.isMissingAtReturn && !eq.isMissingAtPreparation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un équipement est manquant à l'étape actuelle
|
||||||
|
bool _isMissingAtCurrentStep(EventEquipment eq) {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case PreparationStep.preparation:
|
||||||
|
return eq.isMissingAtPreparation;
|
||||||
|
case PreparationStep.loadingOutbound:
|
||||||
|
return eq.isMissingAtLoading;
|
||||||
|
case PreparationStep.unloadingReturn:
|
||||||
|
return eq.isMissingAtUnloading;
|
||||||
|
case PreparationStep.return_:
|
||||||
|
return eq.isMissingAtReturn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère la quantité pour l'étape actuelle
|
||||||
|
int? _getQuantityForStep(EventEquipment eq) {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case PreparationStep.preparation:
|
||||||
|
return eq.quantityAtPreparation;
|
||||||
|
case PreparationStep.loadingOutbound:
|
||||||
|
return eq.quantityAtLoading;
|
||||||
|
case PreparationStep.unloadingReturn:
|
||||||
|
return eq.quantityAtUnloading;
|
||||||
|
case PreparationStep.return_:
|
||||||
|
return eq.quantityAtReturn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final allValidated = _isStepCompleted();
|
final allValidated = _isStepCompleted();
|
||||||
@@ -592,7 +844,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: allValidated ? null : _validateAll,
|
onPressed: allValidated ? null : _validateAllAndConfirm,
|
||||||
icon: const Icon(Icons.check_circle_outline),
|
icon: const Icon(Icons.check_circle_outline),
|
||||||
label: Text(
|
label: Text(
|
||||||
allValidated
|
allValidated
|
||||||
@@ -608,32 +860,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: _currentEvent.assignedEquipment.length,
|
children: _buildChecklistItems(),
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final eventEquipment = _currentEvent.assignedEquipment[index];
|
|
||||||
final equipment = _equipmentCache[eventEquipment.equipmentId];
|
|
||||||
|
|
||||||
if (equipment == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return EquipmentChecklistItem(
|
|
||||||
equipment: equipment,
|
|
||||||
eventEquipment: eventEquipment,
|
|
||||||
step: _getChecklistStep(),
|
|
||||||
isValidated: _localValidationState[equipment.id] ?? false,
|
|
||||||
onToggle: () => _toggleEquipmentValidation(equipment.id),
|
|
||||||
onReturnedQuantityChanged: _currentStep == PreparationStep.return_ && equipment.hasQuantity
|
|
||||||
? (qty) {
|
|
||||||
setState(() {
|
|
||||||
_returnedQuantities[equipment.id] = qty;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -699,5 +928,92 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Construit la liste des items de checklist en groupant par containers
|
||||||
|
List<Widget> _buildChecklistItems() {
|
||||||
|
final List<Widget> items = [];
|
||||||
|
|
||||||
|
// Set pour tracker les équipements déjà affichés dans un container
|
||||||
|
final Set<String> equipmentIdsInContainers = {};
|
||||||
|
|
||||||
|
// Map des EventEquipment par ID pour accès rapide
|
||||||
|
final Map<String, EventEquipment> eventEquipmentsMap = {
|
||||||
|
for (var eq in _currentEvent.assignedEquipment) eq.equipmentId: eq,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Afficher les containers avec leurs enfants
|
||||||
|
for (final containerId in _currentEvent.assignedContainers) {
|
||||||
|
final container = _containerCache[containerId];
|
||||||
|
if (container == null) continue;
|
||||||
|
|
||||||
|
// Récupérer les équipements enfants de ce container
|
||||||
|
final List<EquipmentModel> childEquipments = [];
|
||||||
|
for (final equipmentId in container.equipmentIds) {
|
||||||
|
final equipment = _equipmentCache[equipmentId];
|
||||||
|
if (equipment != null && eventEquipmentsMap.containsKey(equipmentId)) {
|
||||||
|
childEquipments.add(equipment);
|
||||||
|
equipmentIdsInContainers.add(equipmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childEquipments.isEmpty) continue;
|
||||||
|
|
||||||
|
// Vérifier si tous les enfants sont validés
|
||||||
|
final allChildrenValidated = childEquipments.every(
|
||||||
|
(eq) => _localValidationState[eq.id] ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map des états de validation des enfants
|
||||||
|
final Map<String, bool> childValidationStates = {
|
||||||
|
for (var eq in childEquipments) eq.id: _localValidationState[eq.id] ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map des enfants manquants à l'étape précédente
|
||||||
|
final Map<String, bool> wasMissingBeforeMap = {
|
||||||
|
for (var eq in childEquipments) eq.id: _wasMissingAtPreviousStep(eq.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
ContainerChecklistItem(
|
||||||
|
container: container,
|
||||||
|
childEquipments: childEquipments,
|
||||||
|
eventEquipmentsMap: eventEquipmentsMap,
|
||||||
|
step: _getChecklistStep(),
|
||||||
|
isValidated: allChildrenValidated,
|
||||||
|
childValidationStates: childValidationStates,
|
||||||
|
onToggleContainer: () => _validateAllContainerChildren(containerId),
|
||||||
|
onToggleChild: (equipmentId) => _toggleEquipmentValidation(equipmentId),
|
||||||
|
onQuantityChanged: (equipmentId, qty) => _updateEquipmentQuantity(equipmentId, qty),
|
||||||
|
wasMissingBeforeMap: wasMissingBeforeMap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Afficher les équipements standalone (pas dans un container)
|
||||||
|
for (final eventEquipment in _currentEvent.assignedEquipment) {
|
||||||
|
// Skip si déjà affiché dans un container
|
||||||
|
if (equipmentIdsInContainers.contains(eventEquipment.equipmentId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final equipment = _equipmentCache[eventEquipment.equipmentId];
|
||||||
|
if (equipment == null) continue;
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
EquipmentChecklistItem(
|
||||||
|
equipment: equipment,
|
||||||
|
eventEquipment: eventEquipment,
|
||||||
|
step: _getChecklistStep(),
|
||||||
|
isValidated: _localValidationState[equipment.id] ?? false,
|
||||||
|
onToggle: () => _toggleEquipmentValidation(equipment.id),
|
||||||
|
onQuantityChanged: (qty) => _updateEquipmentQuantity(equipment.id, qty),
|
||||||
|
isChild: false, // Équipement standalone (pas indenté)
|
||||||
|
wasMissingBefore: _wasMissingAtPreviousStep(equipment.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:em2rp/views/widgets/inputs/styled_text_field.dart';
|
import 'package:em2rp/views/widgets/inputs/styled_text_field.dart';
|
||||||
import 'package:em2rp/views/widgets/image/profile_picture_selector.dart';
|
import 'package:em2rp/views/widgets/image/profile_picture_selector.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
|
import 'package:em2rp/views/widgets/notification_preferences_widget.dart';
|
||||||
|
|
||||||
class MyAccountPage extends StatelessWidget {
|
class MyAccountPage extends StatelessWidget {
|
||||||
const MyAccountPage({super.key});
|
const MyAccountPage({super.key});
|
||||||
@@ -86,6 +87,13 @@ class MyAccountPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Section Préférences de notifications
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 600),
|
||||||
|
child: const NotificationPreferencesWidget(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import 'package:em2rp/utils/colors.dart';
|
|||||||
import 'package:em2rp/utils/permission_gate.dart';
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
import 'package:em2rp/models/role_model.dart';
|
import 'package:em2rp/models/role_model.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class UserManagementPage extends StatefulWidget {
|
class UserManagementPage extends StatefulWidget {
|
||||||
const UserManagementPage({super.key});
|
const UserManagementPage({super.key});
|
||||||
@@ -90,7 +91,8 @@ class _UserManagementPageState extends State<UserManagementPage> {
|
|||||||
onEdit: () => showDialog(
|
onEdit: () => showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => EditUserDialog(user: user)),
|
builder: (_) => EditUserDialog(user: user)),
|
||||||
onDelete: () => usersProvider.deleteUser(user.uid),
|
onResetPassword: () => _resetPassword(context, user),
|
||||||
|
onDelete: () => _confirmDeleteUser(context, usersProvider, user),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -116,14 +118,18 @@ class _UserManagementPageState extends State<UserManagementPage> {
|
|||||||
bool isLoadingRoles = true;
|
bool isLoadingRoles = true;
|
||||||
|
|
||||||
Future<void> loadRoles() async {
|
Future<void> loadRoles() async {
|
||||||
final snapshot =
|
try {
|
||||||
await FirebaseFirestore.instance.collection('roles').get();
|
final dataService = DataService(FirebaseFunctionsApiService());
|
||||||
availableRoles = snapshot.docs
|
final rolesData = await dataService.getRoles();
|
||||||
.map((doc) => RoleModel.fromMap(doc.data(), doc.id))
|
availableRoles = rolesData
|
||||||
.toList();
|
.map((data) => RoleModel.fromMap(data, data['id'] as String))
|
||||||
selectedRoleId =
|
.toList();
|
||||||
availableRoles.isNotEmpty ? availableRoles.first.id : null;
|
selectedRoleId =
|
||||||
isLoadingRoles = false;
|
availableRoles.isNotEmpty ? availableRoles.first.id : null;
|
||||||
|
isLoadingRoles = false;
|
||||||
|
} catch (e) {
|
||||||
|
isLoadingRoles = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
InputDecoration buildInputDecoration(String label, IconData icon) {
|
InputDecoration buildInputDecoration(String label, IconData icon) {
|
||||||
@@ -254,20 +260,27 @@ class _UserManagementPageState extends State<UserManagementPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final newUser = UserModel(
|
|
||||||
uid: '', // Sera généré par Firebase
|
|
||||||
firstName: firstNameController.text,
|
|
||||||
lastName: lastNameController.text,
|
|
||||||
email: emailController.text,
|
|
||||||
phoneNumber: phoneController.text,
|
|
||||||
role: selectedRoleId!,
|
|
||||||
profilePhotoUrl: '',
|
|
||||||
);
|
|
||||||
await Provider.of<UsersProvider>(context,
|
await Provider.of<UsersProvider>(context,
|
||||||
listen: false)
|
listen: false)
|
||||||
.createUserWithEmailInvite(context, newUser,
|
.createUserWithEmailInvite(
|
||||||
roleId: selectedRoleId);
|
email: emailController.text,
|
||||||
Navigator.pop(context);
|
firstName: firstNameController.text,
|
||||||
|
lastName: lastNameController.text,
|
||||||
|
phoneNumber: phoneController.text,
|
||||||
|
roleId: selectedRoleId!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Utilisateur créé avec succès. Email de réinitialisation envoyé à ${emailController.text}',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -303,4 +316,174 @@ class _UserManagementPageState extends State<UserManagementPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Réinitialise le mot de passe d'un utilisateur
|
||||||
|
Future<void> _resetPassword(BuildContext context, UserModel user) async {
|
||||||
|
try {
|
||||||
|
await Provider.of<UsersProvider>(context, listen: false)
|
||||||
|
.resetPassword(user.email);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Email de réinitialisation envoyé à ${user.email}',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Erreur lors de l\'envoi: ${e.toString()}',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affiche une confirmation avant de supprimer un utilisateur
|
||||||
|
Future<void> _confirmDeleteUser(
|
||||||
|
BuildContext context,
|
||||||
|
UsersProvider usersProvider,
|
||||||
|
UserModel user,
|
||||||
|
) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.warning, color: Colors.orange),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Confirmer la suppression',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Êtes-vous sûr de vouloir supprimer cet utilisateur ?',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person, size: 20, color: AppColors.noir),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${user.firstName} ${user.lastName}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.email, size: 20, color: AppColors.gris),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
user.email,
|
||||||
|
style: TextStyle(color: Colors.grey[700]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Cette action est irréversible. L\'utilisateur sera supprimé et désattribué de tous les événements liés',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.red[700],
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Annuler',
|
||||||
|
style: TextStyle(color: AppColors.gris),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Supprimer',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true && context.mounted) {
|
||||||
|
try {
|
||||||
|
await usersProvider.deleteUser(user.uid);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Utilisateur ${user.firstName} ${user.lastName} supprimé avec succès',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Erreur lors de la suppression: ${e.toString()}',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
234
em2rp/lib/views/widgets/alert_item.dart
Normal file
234
em2rp/lib/views/widgets/alert_item.dart
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
// import 'package:timeago/timeago.dart' as timeago; // TODO: Ajouter dépendance dans pubspec.yaml
|
||||||
|
|
||||||
|
/// Widget pour afficher une alerte individuelle
|
||||||
|
class AlertItem extends StatelessWidget {
|
||||||
|
final AlertModel alert;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback? onMarkAsRead;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
|
const AlertItem({
|
||||||
|
super.key,
|
||||||
|
required this.alert,
|
||||||
|
this.onTap,
|
||||||
|
this.onMarkAsRead,
|
||||||
|
this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dismissible(
|
||||||
|
key: Key(alert.id),
|
||||||
|
background: _buildSwipeBackground(
|
||||||
|
Colors.blue,
|
||||||
|
Icons.check,
|
||||||
|
Alignment.centerLeft,
|
||||||
|
),
|
||||||
|
secondaryBackground: _buildSwipeBackground(
|
||||||
|
Colors.red,
|
||||||
|
Icons.delete,
|
||||||
|
Alignment.centerRight,
|
||||||
|
),
|
||||||
|
confirmDismiss: (direction) async {
|
||||||
|
if (direction == DismissDirection.startToEnd) {
|
||||||
|
// Swipe vers la droite = marquer comme lu
|
||||||
|
onMarkAsRead?.call();
|
||||||
|
return false; // Ne pas supprimer le widget
|
||||||
|
} else {
|
||||||
|
// Swipe vers la gauche = supprimer
|
||||||
|
return await _confirmDelete(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
color: alert.isRead ? Colors.white : Colors.blue.shade50,
|
||||||
|
elevation: alert.isRead ? 1 : 2,
|
||||||
|
child: ListTile(
|
||||||
|
leading: _buildIcon(),
|
||||||
|
title: Text(
|
||||||
|
alert.message,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: alert.isRead ? FontWeight.normal : FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_formatDate(alert.createdAt),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (alert.isResolved) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, size: 14, color: Colors.green),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Résolu',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.green,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: !alert.isRead
|
||||||
|
? Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getSeverityColor(alert.severity),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Nouveau',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSwipeBackground(Color color, IconData icon, Alignment alignment) {
|
||||||
|
return Container(
|
||||||
|
color: color,
|
||||||
|
alignment: alignment,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Icon(icon, color: Colors.white, size: 28),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIcon() {
|
||||||
|
IconData iconData;
|
||||||
|
Color iconColor;
|
||||||
|
|
||||||
|
switch (alert.type) {
|
||||||
|
case AlertType.eventCreated:
|
||||||
|
case AlertType.eventModified:
|
||||||
|
case AlertType.eventAssigned:
|
||||||
|
iconData = Icons.event;
|
||||||
|
iconColor = Colors.blue;
|
||||||
|
break;
|
||||||
|
case AlertType.workforceAdded:
|
||||||
|
iconData = Icons.group_add;
|
||||||
|
iconColor = Colors.green;
|
||||||
|
break;
|
||||||
|
case AlertType.eventCancelled:
|
||||||
|
iconData = Icons.event_busy;
|
||||||
|
iconColor = Colors.red;
|
||||||
|
break;
|
||||||
|
case AlertType.maintenanceDue:
|
||||||
|
case AlertType.maintenanceReminder:
|
||||||
|
iconData = Icons.build;
|
||||||
|
iconColor = Colors.orange;
|
||||||
|
break;
|
||||||
|
case AlertType.lost:
|
||||||
|
iconData = Icons.error;
|
||||||
|
iconColor = Colors.red;
|
||||||
|
break;
|
||||||
|
case AlertType.equipmentMissing:
|
||||||
|
iconData = Icons.warning;
|
||||||
|
iconColor = Colors.orange;
|
||||||
|
break;
|
||||||
|
case AlertType.lowStock:
|
||||||
|
iconData = Icons.inventory_2;
|
||||||
|
iconColor = Colors.orange;
|
||||||
|
break;
|
||||||
|
case AlertType.conflict:
|
||||||
|
iconData = Icons.error_outline;
|
||||||
|
iconColor = Colors.red;
|
||||||
|
break;
|
||||||
|
case AlertType.quantityMismatch:
|
||||||
|
iconData = Icons.compare_arrows;
|
||||||
|
iconColor = Colors.orange;
|
||||||
|
break;
|
||||||
|
case AlertType.damaged:
|
||||||
|
iconData = Icons.broken_image;
|
||||||
|
iconColor = Colors.red;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(iconData, color: iconColor, size: 24),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getSeverityColor(AlertSeverity severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case AlertSeverity.info:
|
||||||
|
return Colors.blue;
|
||||||
|
case AlertSeverity.warning:
|
||||||
|
return Colors.orange;
|
||||||
|
case AlertSeverity.critical:
|
||||||
|
return Colors.red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime date) {
|
||||||
|
// TODO: Utiliser timeago une fois la dépendance ajoutée
|
||||||
|
final now = DateTime.now();
|
||||||
|
final difference = now.difference(date);
|
||||||
|
|
||||||
|
if (difference.inSeconds < 60) {
|
||||||
|
return 'À l\'instant';
|
||||||
|
} else if (difference.inMinutes < 60) {
|
||||||
|
return 'Il y a ${difference.inMinutes} min';
|
||||||
|
} else if (difference.inHours < 24) {
|
||||||
|
return 'Il y a ${difference.inHours}h';
|
||||||
|
} else if (difference.inDays < 7) {
|
||||||
|
return 'Il y a ${difference.inDays}j';
|
||||||
|
} else {
|
||||||
|
return '${date.day}/${date.month}/${date.year}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _confirmDelete(BuildContext context) async {
|
||||||
|
return await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Supprimer l\'alerte ?'),
|
||||||
|
content: const Text('Cette action est irréversible.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
onDelete?.call();
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -41,8 +42,6 @@ class _ForgotPasswordDialogState extends State<ForgotPasswordDialogWidget> {
|
|||||||
_errorMessage = "Erreur : ${e.message}";
|
_errorMessage = "Erreur : ${e.message}";
|
||||||
_emailSent = false;
|
_emailSent = false;
|
||||||
});
|
});
|
||||||
print(
|
|
||||||
"Erreur de réinitialisation du mot de passe: ${e.code} - ${e.message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class EventDetails extends StatelessWidget {
|
|||||||
EventDetailsDescription(event: event),
|
EventDetailsDescription(event: event),
|
||||||
EventDetailsDocuments(documents: event.documents),
|
EventDetailsDocuments(documents: event.documents),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
EventDetailsEquipe(workforce: event.workforce),
|
EventDetailsEquipe(event: event),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/models/user_model.dart';
|
import 'package:em2rp/models/user_model.dart';
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart';
|
import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart';
|
||||||
|
|
||||||
class EventDetailsEquipe extends StatelessWidget {
|
class EventDetailsEquipe extends StatelessWidget {
|
||||||
final List workforce;
|
final EventModel event;
|
||||||
|
|
||||||
const EventDetailsEquipe({
|
const EventDetailsEquipe({
|
||||||
super.key,
|
super.key,
|
||||||
required this.workforce,
|
required this.event,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (workforce.isEmpty) {
|
if (event.workforce.isEmpty) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -33,105 +35,48 @@ class EventDetailsEquipe extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return FutureBuilder<List<UserModel>>(
|
// Récupérer les utilisateurs depuis le cache du provider
|
||||||
future: _fetchUsers(),
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
builder: (context, snapshot) {
|
final workforceUsers = eventProvider.getWorkforceUsers(event);
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Equipe',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
color: Colors.black,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
// Convertir en UserModel
|
||||||
return Column(
|
final users = workforceUsers.map((userData) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return UserModel(
|
||||||
children: [
|
uid: userData['uid'] ?? '',
|
||||||
Text(
|
firstName: userData['firstName'] ?? '',
|
||||||
'Equipe',
|
lastName: userData['lastName'] ?? '',
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
email: userData['email'] ?? '',
|
||||||
color: Colors.black,
|
phoneNumber: userData['phoneNumber'] ?? '',
|
||||||
fontWeight: FontWeight.bold,
|
profilePhotoUrl: userData['profilePhotoUrl'] ?? '',
|
||||||
),
|
role: '', // Pas besoin du rôle pour l'affichage
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Equipe',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: Colors.black,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
if (users.isEmpty)
|
||||||
child: Text(
|
Text(
|
||||||
snapshot.error.toString().contains('permission-denied')
|
'Aucun membre assigné.',
|
||||||
? "Vous n'avez pas la permission de voir tous les membres de l'équipe."
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
: "Erreur lors du chargement de l'équipe : ${snapshot.error}",
|
color: Colors.orange[700],
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (users.isNotEmpty)
|
||||||
);
|
UserChipsList(
|
||||||
}
|
users: users,
|
||||||
|
showRemove: false,
|
||||||
final users = snapshot.data ?? [];
|
),
|
||||||
return Column(
|
],
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Equipe',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
color: Colors.black,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (users.isEmpty)
|
|
||||||
Text(
|
|
||||||
'Aucun membre assigné ou erreur de chargement.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: Colors.orange[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (users.isNotEmpty)
|
|
||||||
UserChipsList(
|
|
||||||
users: users,
|
|
||||||
showRemove: false,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<UserModel>> _fetchUsers() async {
|
|
||||||
final firestore = FirebaseFirestore.instance;
|
|
||||||
List<UserModel> users = [];
|
|
||||||
|
|
||||||
for (int i = 0; i < workforce.length; i++) {
|
|
||||||
final ref = workforce[i];
|
|
||||||
try {
|
|
||||||
if (ref is DocumentReference) {
|
|
||||||
final doc = await firestore.doc(ref.path).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
final userData = doc.data() as Map<String, dynamic>;
|
|
||||||
users.add(UserModel.fromMap(userData, doc.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Log silencieux des erreurs individuelles
|
|
||||||
debugPrint('Error fetching user $i: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
import 'package:em2rp/views/event_add_page.dart';
|
import 'package:em2rp/views/event_add_page.dart';
|
||||||
import 'package:em2rp/services/ics_export_service.dart';
|
import 'package:em2rp/services/ics_export_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'dart:html' as html;
|
import 'dart:html' as html;
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
@@ -31,31 +32,43 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
|
|||||||
_fetchEventTypeName();
|
_fetchEventTypeName();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(EventDetailsHeader oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// Recharger le type d'événement si l'événement a changé
|
||||||
|
if (oldWidget.event.id != widget.event.id ||
|
||||||
|
oldWidget.event.eventTypeId != widget.event.eventTypeId) {
|
||||||
|
_fetchEventTypeName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _fetchEventTypeName() async {
|
Future<void> _fetchEventTypeName() async {
|
||||||
|
setState(() => _isLoadingEventType = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (widget.event.eventTypeId.isEmpty) {
|
if (widget.event.eventTypeId.isEmpty) {
|
||||||
setState(() => _isLoadingEventType = false);
|
setState(() {
|
||||||
|
_eventTypeName = null;
|
||||||
|
_isLoadingEventType = false;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final doc = await FirebaseFirestore.instance
|
// Charger tous les types d'événements via l'API
|
||||||
.collection('eventTypes')
|
final dataService = DataService(FirebaseFunctionsApiService());
|
||||||
.doc(widget.event.eventTypeId)
|
final eventTypes = await dataService.getEventTypes();
|
||||||
.get();
|
|
||||||
|
|
||||||
if (doc.exists) {
|
// Trouver le type correspondant
|
||||||
setState(() {
|
final eventType = eventTypes.firstWhere(
|
||||||
_eventTypeName = doc.data()?['name'] as String? ?? widget.event.eventTypeId;
|
(type) => type['id'] == widget.event.eventTypeId,
|
||||||
_isLoadingEventType = false;
|
orElse: () => <String, dynamic>{},
|
||||||
});
|
);
|
||||||
} else {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_eventTypeName = widget.event.eventTypeId;
|
_eventTypeName = eventType['name'] as String? ?? widget.event.eventTypeId;
|
||||||
_isLoadingEventType = false;
|
_isLoadingEventType = false;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors du chargement du type d\'événement: $e');
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_eventTypeName = widget.event.eventTypeId;
|
_eventTypeName = widget.event.eventTypeId;
|
||||||
_isLoadingEventType = false;
|
_isLoadingEventType = false;
|
||||||
@@ -142,8 +155,27 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Générer le contenu ICS
|
// Charger les utilisateurs pour résoudre leurs noms
|
||||||
final icsContent = await IcsExportService.generateIcsContent(widget.event);
|
final dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
final users = await dataService.getUsers();
|
||||||
|
|
||||||
|
// Créer une Map des IDs utilisateurs vers leurs noms complets
|
||||||
|
final Map<String, String> userNames = {};
|
||||||
|
for (final user in users) {
|
||||||
|
final userId = user['id'] as String?;
|
||||||
|
final firstName = user['firstName'] as String? ?? '';
|
||||||
|
final lastName = user['lastName'] as String? ?? '';
|
||||||
|
if (userId != null && (firstName.isNotEmpty || lastName.isNotEmpty)) {
|
||||||
|
userNames[userId] = '$firstName $lastName'.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer le contenu ICS avec le nom du type et les noms des utilisateurs
|
||||||
|
final icsContent = await IcsExportService.generateIcsContent(
|
||||||
|
widget.event,
|
||||||
|
eventTypeName: _eventTypeName ?? 'Non spécifié',
|
||||||
|
userNames: userNames, // Passer les noms des utilisateurs
|
||||||
|
);
|
||||||
final fileName = IcsExportService.generateFileName(widget.event);
|
final fileName = IcsExportService.generateFileName(widget.event);
|
||||||
|
|
||||||
// Créer un blob et télécharger le fichier
|
// Créer un blob et télécharger le fichier
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:em2rp/utils/price_helpers.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart';
|
import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart';
|
||||||
|
|
||||||
@@ -34,13 +35,48 @@ class EventDetailsInfo extends StatelessWidget {
|
|||||||
'Horaire de fin',
|
'Horaire de fin',
|
||||||
dateFormat.format(event.endDateTime),
|
dateFormat.format(event.endDateTime),
|
||||||
),
|
),
|
||||||
if (canViewPrices)
|
if (canViewPrices) ...[
|
||||||
_buildInfoRow(
|
// Calcul des prix HT/TVA/TTC
|
||||||
context,
|
Builder(
|
||||||
Icons.euro,
|
builder: (context) {
|
||||||
'Prix de base',
|
final pricing = PriceHelpers.getPricing(event);
|
||||||
currencyFormat.format(event.basePrice),
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.euro,
|
||||||
|
'Prix HT',
|
||||||
|
pricing.formattedHT,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.percent, size: 16, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'TVA (${pricing.taxRatePercentage.toStringAsFixed(0)}%) : ${pricing.formattedTax}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.attach_money,
|
||||||
|
'Prix TTC',
|
||||||
|
pricing.formattedTTC,
|
||||||
|
highlighted: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
if (event.options.isNotEmpty) ...[
|
if (event.options.isNotEmpty) ...[
|
||||||
EventOptionsDisplayWidget(
|
EventOptionsDisplayWidget(
|
||||||
optionsData: event.options,
|
optionsData: event.options,
|
||||||
@@ -52,34 +88,85 @@ class EventDetailsInfo extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final total = event.basePrice +
|
// Total TTC = basePrice (TTC) + options (TTC)
|
||||||
|
final totalTTC = event.basePrice +
|
||||||
event.options.fold<num>(
|
event.options.fold<num>(
|
||||||
0,
|
0,
|
||||||
(sum, opt) {
|
(sum, opt) {
|
||||||
final price = opt['price'] ?? 0.0;
|
final priceTTC = opt['price'] ?? 0.0;
|
||||||
final quantity = opt['quantity'] ?? 1;
|
final quantity = opt['quantity'] ?? 1;
|
||||||
return sum + (price * quantity);
|
return sum + (priceTTC * quantity);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Calculer le total HT
|
||||||
|
final totalHT = PriceHelpers.calculateHT(totalTTC.toDouble());
|
||||||
|
final totalTVA = PriceHelpers.calculateTax(totalHT);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.attach_money, color: AppColors.rouge),
|
// Séparateur visuel
|
||||||
const SizedBox(width: 8),
|
const Divider(thickness: 1),
|
||||||
Text(
|
const SizedBox(height: 8),
|
||||||
'Prix total : ',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
// Prix total HT
|
||||||
color: AppColors.noir,
|
Row(
|
||||||
fontWeight: FontWeight.bold,
|
children: [
|
||||||
),
|
const Icon(Icons.euro, color: AppColors.rouge, size: 22),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Prix total HT : ',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
currencyFormat.format(totalHT),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Text(
|
|
||||||
currencyFormat.format(total),
|
// TVA en petit
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
Padding(
|
||||||
color: AppColors.rouge,
|
padding: const EdgeInsets.only(left: 30.0, top: 4.0, bottom: 4.0),
|
||||||
fontWeight: FontWeight.bold,
|
child: Text(
|
||||||
),
|
'TVA (20%) : ${currencyFormat.format(totalTVA)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Prix total TTC en surbrillance
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.attach_money, color: AppColors.rouge, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Prix total TTC : ',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
currencyFormat.format(totalTTC),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: AppColors.rouge,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -139,24 +226,28 @@ class EventDetailsInfo extends StatelessWidget {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
IconData icon,
|
IconData icon,
|
||||||
String label,
|
String label,
|
||||||
String value,
|
String value, {
|
||||||
) {
|
bool highlighted = false,
|
||||||
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: AppColors.rouge),
|
Icon(icon, color: highlighted ? AppColors.rouge : AppColors.rouge),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'$label : ',
|
'$label : ',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: AppColors.noir,
|
color: AppColors.noir,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: highlighted ? FontWeight.w900 : FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: highlighted ? AppColors.rouge : null,
|
||||||
|
fontWeight: highlighted ? FontWeight.bold : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
import 'package:em2rp/views/event_preparation_page.dart';
|
import 'package:em2rp/views/event_preparation_page.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
@@ -20,30 +21,19 @@ class EventPreparationButtons extends StatefulWidget {
|
|||||||
class _EventPreparationButtonsState extends State<EventPreparationButtons> {
|
class _EventPreparationButtonsState extends State<EventPreparationButtons> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Écouter les changements de l'événement en temps réel
|
// Utiliser le provider pour récupérer l'événement à jour
|
||||||
return StreamBuilder<DocumentSnapshot>(
|
final eventProvider = context.watch<EventProvider>();
|
||||||
stream: FirebaseFirestore.instance
|
|
||||||
.collection('events')
|
|
||||||
.doc(widget.event.id)
|
|
||||||
.snapshots(),
|
|
||||||
initialData: null,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
// Utiliser l'événement du stream si disponible, sinon l'événement initial
|
|
||||||
final EventModel currentEvent;
|
|
||||||
if (snapshot.hasData && snapshot.data != null && snapshot.data!.exists) {
|
|
||||||
currentEvent = EventModel.fromMap(
|
|
||||||
snapshot.data!.data() as Map<String, dynamic>,
|
|
||||||
snapshot.data!.id,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
currentEvent = widget.event;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _buildButtons(context, currentEvent);
|
// Chercher l'événement mis à jour dans le provider
|
||||||
},
|
final EventModel currentEvent = eventProvider.events.firstWhere(
|
||||||
|
(e) => e.id == widget.event.id,
|
||||||
|
orElse: () => widget.event,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return _buildButtons(context, currentEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Widget _buildButtons(BuildContext context, EventModel event) {
|
Widget _buildButtons(BuildContext context, EventModel event) {
|
||||||
// Vérifier s'il y a du matériel assigné
|
// Vérifier s'il y a du matériel assigné
|
||||||
final hasMaterial = event.assignedEquipment.isNotEmpty || event.assignedContainers.isNotEmpty;
|
final hasMaterial = event.assignedEquipment.isNotEmpty || event.assignedContainers.isNotEmpty;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class EventStatusButton extends StatefulWidget {
|
class EventStatusButton extends StatefulWidget {
|
||||||
final EventModel event;
|
final EventModel event;
|
||||||
@@ -22,30 +23,37 @@ class EventStatusButton extends StatefulWidget {
|
|||||||
|
|
||||||
class _EventStatusButtonState extends State<EventStatusButton> {
|
class _EventStatusButtonState extends State<EventStatusButton> {
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
Future<void> _changeStatus(EventStatus newStatus) async {
|
Future<void> _changeStatus(EventStatus newStatus) async {
|
||||||
if (widget.event.status == newStatus) return;
|
if (widget.event.status == newStatus) return;
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await FirebaseFirestore.instance
|
// Mettre à jour via l'API
|
||||||
.collection('events')
|
await _dataService.updateEvent(widget.event.id, {
|
||||||
.doc(widget.event.id)
|
'status': eventStatusToString(newStatus),
|
||||||
.update({'status': eventStatusToString(newStatus)});
|
});
|
||||||
|
|
||||||
final snap = await FirebaseFirestore.instance
|
// Récupérer l'événement mis à jour via l'API
|
||||||
.collection('events')
|
final result = await _dataService.getEvents();
|
||||||
.doc(widget.event.id)
|
final eventsList = result['events'] as List<dynamic>;
|
||||||
.get();
|
final eventData = eventsList.firstWhere(
|
||||||
final updatedEvent = EventModel.fromMap(snap.data()!, widget.event.id);
|
(e) => e['id'] == widget.event.id,
|
||||||
|
orElse: () => <String, dynamic>{},
|
||||||
widget.onSelectEvent(
|
|
||||||
updatedEvent,
|
|
||||||
widget.selectedDate ?? updatedEvent.startDateTime,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await Provider.of<EventProvider>(context, listen: false)
|
if (eventData.isNotEmpty) {
|
||||||
.updateEvent(updatedEvent);
|
final updatedEvent = EventModel.fromMap(eventData, widget.event.id);
|
||||||
|
|
||||||
|
widget.onSelectEvent(
|
||||||
|
updatedEvent,
|
||||||
|
widget.selectedDate ?? updatedEvent.startDateTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Provider.of<EventProvider>(context, listen: false)
|
||||||
|
.updateEvent(updatedEvent);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:em2rp/models/user_model.dart';
|
||||||
|
import 'package:em2rp/providers/users_provider.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
|
/// Widget de filtre par utilisateur pour le calendrier
|
||||||
|
/// Affiche un dropdown permettant de filtrer les événements par utilisateur
|
||||||
|
class UserFilterDropdown extends StatefulWidget {
|
||||||
|
final String? selectedUserId;
|
||||||
|
final ValueChanged<String?> onUserSelected;
|
||||||
|
|
||||||
|
const UserFilterDropdown({
|
||||||
|
super.key,
|
||||||
|
required this.selectedUserId,
|
||||||
|
required this.onUserSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserFilterDropdown> createState() => _UserFilterDropdownState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserFilterDropdownState extends State<UserFilterDropdown> {
|
||||||
|
List<UserModel> _users = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Charger après le premier frame pour éviter setState pendant build
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_loadUsers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUsers() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final usersProvider = Provider.of<UsersProvider>(context, listen: false);
|
||||||
|
|
||||||
|
// Ne pas appeler fetchUsers si les utilisateurs sont déjà chargés
|
||||||
|
if (usersProvider.users.isEmpty) {
|
||||||
|
await usersProvider.fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_users = usersProvider.users;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 250,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String?>(
|
||||||
|
value: widget.selectedUserId,
|
||||||
|
hint: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.filter_list, size: 18, color: Colors.grey),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Tous les utilisateurs', style: TextStyle(fontSize: 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
isExpanded: true,
|
||||||
|
icon: const Icon(Icons.arrow_drop_down, size: 24),
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||||
|
onChanged: widget.onUserSelected,
|
||||||
|
items: [
|
||||||
|
// Option "Tous les utilisateurs"
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.people, size: 18, color: AppColors.rouge),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Tous les utilisateurs', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Liste des utilisateurs
|
||||||
|
..._users.map((user) {
|
||||||
|
return DropdownMenuItem<String?>(
|
||||||
|
value: user.uid,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 12,
|
||||||
|
backgroundImage: user.profilePhotoUrl.isNotEmpty
|
||||||
|
? NetworkImage(user.profilePhotoUrl)
|
||||||
|
: null,
|
||||||
|
child: user.profilePhotoUrl.isEmpty
|
||||||
|
? Text(
|
||||||
|
user.firstName.isNotEmpty
|
||||||
|
? user.firstName[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: const TextStyle(fontSize: 10),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${user.firstName} ${user.lastName}',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
289
em2rp/lib/views/widgets/common/qr_code_scanner_dialog.dart
Normal file
289
em2rp/lib/views/widgets/common/qr_code_scanner_dialog.dart
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
|
/// Dialog pour scanner un QR code et récupérer l'ID
|
||||||
|
class QRCodeScannerDialog extends StatefulWidget {
|
||||||
|
const QRCodeScannerDialog({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QRCodeScannerDialog> createState() => _QRCodeScannerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QRCodeScannerDialogState extends State<QRCodeScannerDialog> {
|
||||||
|
MobileScannerController? _controller;
|
||||||
|
bool _isProcessing = false;
|
||||||
|
String? _scannedCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = MobileScannerController(
|
||||||
|
detectionSpeed: DetectionSpeed.normal,
|
||||||
|
facing: CameraFacing.back,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDetect(BarcodeCapture capture) {
|
||||||
|
if (_isProcessing) return;
|
||||||
|
|
||||||
|
final List<Barcode> barcodes = capture.barcodes;
|
||||||
|
if (barcodes.isEmpty) return;
|
||||||
|
|
||||||
|
final barcode = barcodes.first;
|
||||||
|
final code = barcode.rawValue;
|
||||||
|
|
||||||
|
if (code != null && code.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_isProcessing = true;
|
||||||
|
_scannedCode = code;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retourner le code après un court délai pour montrer le feedback visuel
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
insetPadding: const EdgeInsets.all(20),
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// En-tête
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.rouge,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.qr_code_scanner, color: Colors.white, size: 28),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Scanner un QR Code',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Zone de scan
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Scanner
|
||||||
|
if (_controller != null)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||||
|
child: MobileScanner(
|
||||||
|
controller: _controller,
|
||||||
|
onDetect: _onDetect,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Overlay avec cadre de scan
|
||||||
|
Positioned.fill(
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _ScannerOverlayPainter(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Feedback visuel quand un code est détecté
|
||||||
|
if (_isProcessing && _scannedCode != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Colors.green,
|
||||||
|
size: 64,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'QR Code détecté !',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_scannedCode!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Instructions
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[900],
|
||||||
|
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: Colors.grey[400], size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Positionnez le QR code dans le cadre',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Painter pour dessiner l'overlay du scanner avec un cadre
|
||||||
|
class _ScannerOverlayPainter extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
const double scanAreaSize = 250.0;
|
||||||
|
final double left = (size.width - scanAreaSize) / 2;
|
||||||
|
final double top = (size.height - scanAreaSize) / 2;
|
||||||
|
|
||||||
|
// Fond semi-transparent
|
||||||
|
final backgroundPath = Path()
|
||||||
|
..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
|
||||||
|
final holePath = Path()
|
||||||
|
..addRRect(RRect.fromRectAndRadius(
|
||||||
|
Rect.fromLTWH(left, top, scanAreaSize, scanAreaSize),
|
||||||
|
const Radius.circular(16),
|
||||||
|
));
|
||||||
|
|
||||||
|
final backgroundPaint = Paint()
|
||||||
|
..color = Colors.black.withValues(alpha: 0.5)
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
canvas.drawPath(
|
||||||
|
Path.combine(PathOperation.difference, backgroundPath, holePath),
|
||||||
|
backgroundPaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cadre de scan (coins)
|
||||||
|
final cornerPaint = Paint()
|
||||||
|
..color = AppColors.rouge
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 4
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
|
const double cornerLength = 30.0;
|
||||||
|
|
||||||
|
// Coin haut-gauche
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(left, top + cornerLength),
|
||||||
|
Offset(left, top),
|
||||||
|
cornerPaint,
|
||||||
|
);
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(left, top),
|
||||||
|
Offset(left + cornerLength, top),
|
||||||
|
cornerPaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Coin haut-droit
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(left + scanAreaSize - cornerLength, top),
|
||||||
|
Offset(left + scanAreaSize, top),
|
||||||
|
cornerPaint,
|
||||||
|
);
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(left + scanAreaSize, top),
|
||||||
|
Offset(left + scanAreaSize, top + cornerLength),
|
||||||
|
cornerPaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Coin bas-gauche
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(left, top + scanAreaSize - cornerLength),
|
||||||
|
Offset(left, top + scanAreaSize),
|
||||||
|
cornerPaint,
|
||||||
|
);
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(left, top + scanAreaSize),
|
||||||
|
Offset(left + cornerLength, top + scanAreaSize),
|
||||||
|
cornerPaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Coin bas-droit
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(left + scanAreaSize - cornerLength, top + scanAreaSize),
|
||||||
|
Offset(left + scanAreaSize, top + scanAreaSize),
|
||||||
|
cornerPaint,
|
||||||
|
);
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(left + scanAreaSize, top + scanAreaSize - cornerLength),
|
||||||
|
Offset(left + scanAreaSize, top + scanAreaSize),
|
||||||
|
cornerPaint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
61
em2rp/lib/views/widgets/common/search_actions_bar.dart
Normal file
61
em2rp/lib/views/widgets/common/search_actions_bar.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SearchActionsBar extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String hintText;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
final VoidCallback onClear;
|
||||||
|
final List<Widget> actions;
|
||||||
|
|
||||||
|
const SearchActionsBar({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.hintText,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onClear,
|
||||||
|
this.actions = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hintText,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: controller.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: onClear,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
),
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (actions.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < actions.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(width: 8),
|
||||||
|
actions[i],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
223
em2rp/lib/views/widgets/common/update_dialog.dart
Normal file
223
em2rp/lib/views/widgets/common/update_dialog.dart
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/services/update_service.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
|
/// Dialog pour informer l'utilisateur d'une mise à jour disponible
|
||||||
|
class UpdateDialog extends StatelessWidget {
|
||||||
|
final UpdateInfo updateInfo;
|
||||||
|
|
||||||
|
const UpdateDialog({
|
||||||
|
super.key,
|
||||||
|
required this.updateInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopScope(
|
||||||
|
// Empêcher la fermeture si c'est une mise à jour forcée
|
||||||
|
canPop: !updateInfo.forceUpdate,
|
||||||
|
child: AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
updateInfo.forceUpdate ? Icons.update : Icons.system_update,
|
||||||
|
color: updateInfo.forceUpdate ? Colors.orange : AppColors.rouge,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
updateInfo.forceUpdate
|
||||||
|
? 'Mise à jour requise'
|
||||||
|
: 'Mise à jour disponible',
|
||||||
|
style: const TextStyle(fontSize: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Versions
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Version actuelle :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
updateInfo.currentVersion,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Nouvelle version :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
updateInfo.newVersion,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.rouge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Message principal
|
||||||
|
if (updateInfo.forceUpdate) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.orange, width: 2),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning, color: Colors.orange),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Cette mise à jour est obligatoire pour continuer à utiliser l\'application.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
Text(
|
||||||
|
updateInfo.forceUpdate
|
||||||
|
? 'L\'application va se recharger pour appliquer la mise à jour.'
|
||||||
|
: 'Une nouvelle version de l\'application est disponible. Voulez-vous mettre à jour maintenant ?',
|
||||||
|
style: const TextStyle(fontSize: 15),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Notes de version
|
||||||
|
if (updateInfo.releaseNotes != null &&
|
||||||
|
updateInfo.releaseNotes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Nouveautés :',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.blue.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
updateInfo.releaseNotes!,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (!updateInfo.forceUpdate)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Plus tard'),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
// Recharger l'application
|
||||||
|
await UpdateService.reloadApp();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.refresh, color: Colors.white),
|
||||||
|
label: Text(
|
||||||
|
updateInfo.forceUpdate ? 'Mettre à jour' : 'Mettre à jour maintenant',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: updateInfo.forceUpdate ? Colors.orange : AppColors.rouge,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour vérifier automatiquement les mises à jour
|
||||||
|
class UpdateChecker extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const UpdateChecker({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UpdateChecker> createState() => _UpdateCheckerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UpdateCheckerState extends State<UpdateChecker> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkForUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkForUpdate() async {
|
||||||
|
final updateInfo = await UpdateService.checkOnStartup();
|
||||||
|
|
||||||
|
if (updateInfo != null && mounted) {
|
||||||
|
// Attendre que l'interface soit complètement chargée
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: !updateInfo.forceUpdate,
|
||||||
|
builder: (context) => UpdateDialog(updateInfo: updateInfo),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user