Compare commits
48 Commits
2c61b9ce8d
...
mise-en-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a182f1b922 | ||
|
|
b79791ff7a | ||
|
|
7e111ec041 | ||
|
|
4e7af9119a | ||
|
|
1ea5cea6fc | ||
|
|
06f394b728 | ||
|
|
67b85d323c | ||
|
|
beaabceda4 | ||
|
|
60d0e1c6c4 | ||
|
|
b30ae0f10a | ||
|
|
fb3f41df4d | ||
|
|
4e4573f57b | ||
|
|
4545bdba81 | ||
|
|
272b4bc9c9 | ||
|
|
0f7a886cf7 | ||
|
|
2bcd1ca4c3 | ||
|
|
f38d75362c | ||
|
|
13a890606d | ||
|
|
fb6a271f66 | ||
|
|
25d395b41a | ||
|
|
fa1d6a4295 | ||
|
|
df9e24d3b3 | ||
|
|
28d9e008af | ||
|
|
08f046c89c | ||
|
|
e59e3e6316 | ||
|
|
6abb8f1d14 | ||
|
|
822d4443f9 | ||
|
|
df6d54a007 | ||
|
|
3fab69cb00 | ||
|
|
ae3a1b7227 | ||
|
|
ef638d8c8c | ||
|
|
5057bf9a77 | ||
|
|
f10a608801 | ||
|
|
4128ddc34a | ||
|
|
aae68f8ab7 | ||
| 080fb7d077 | |||
| 57c59c911a | |||
| acab16e101 | |||
| 9a9c932262 | |||
| 004d442e67 | |||
| 77d0d5cc81 | |||
| b80a6d2623 | |||
| 50a38816d3 | |||
| 9489183b68 | |||
| 49dffff1bf | |||
| 82d77e2b8d | |||
| 851b891a8a | |||
| 249a6d6074 |
9
.cursor/rules/rules1.mdc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
description: To remember for every prompts
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
Le plus important : Repondre en français. Toujours appliquer les modification au code sauf si le message commence par "QUESTION :"
|
||||||
|
|
||||||
|
|
||||||
|
Projet d'ERP pour une entreprise d'événemetiel. Flutter, Dart
|
||||||
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
|
||||||
5
em2rp/.firebaserc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"default": "em2rp-951dc"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
em2rp/.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CLEAN CODE très important: Toujours écrire du code propre, lisible et bien structuré. Utiliser des noms de variables et de fonctions explicites, éviter les répétitions inutiles et suivre les meilleures pratiques de codage.
|
||||||
|
Penser a créer des fonctions réutilisables pour éviter la duplication de code.
|
||||||
|
Verifier la présence de composant existants ou librairie existante avant de créer du code maison. Reutiliser le plus possible le code. Ne pas héister a analyser fréquemment la codebase existante.
|
||||||
|
Créer des fichiers séparés pour chaque composant, classe ou module afin de faciliter la maintenance et la réutilisation. Il faut eviter de dépasser 600 lignes par fichier.
|
||||||
|
Si quelque chose n'est pas clair, poser des questions pour clarifier les exigences avant de commencer à coder.
|
||||||
|
Ne pas générer de fichier résumant le code généré.
|
||||||
4
em2rp/.gitignore
vendored
@@ -41,3 +41,7 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Environment configuration with credentials
|
||||||
|
lib/config/env.dev.dart
|
||||||
|
functions/.env
|
||||||
|
|||||||
37
em2rp/CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Changelog - EM2RP
|
||||||
|
|
||||||
|
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
||||||
|
|
||||||
|
## 🚀 Nouveautés de la mise à jour
|
||||||
|
|
||||||
|
Cette version apporte des outils majeurs pour faciliter la gestion de votre parc et de vos événements :
|
||||||
|
|
||||||
|
* **Scanner QR Code :** Retrouvez instantanément la fiche d'un équipement ou d'un conteneur en scannant son code directement depuis l'application. La génération des codes a également été rendue plus fluide.
|
||||||
|
* **Centre de Notifications & Alertes :** Ne ratez plus rien ! Un nouveau système d'alertes (dans l'app et par email) vous prévient des maintenances, équipements manquants ou conflits. Vous pouvez configurer vos préférences d'envoi.
|
||||||
|
* **Checklist de Préparation 2.0 :** L'interface de préparation a été repensée. Elle regroupe désormais les objets par conteneurs et permet de suivre visuellement les équipements manquants ou perdus à chaque étape (chargement, retour, etc.).
|
||||||
|
* **Sélecteur de Matériel Optimisé :** La recherche de matériel pour un événement est beaucoup plus rapide. Vous pouvez désormais masquer automatiquement les équipements déjà utilisés sur d'autres événements aux mêmes dates.
|
||||||
|
* **Gestion & Administration :** Affichage clair des prix HT/TTC partout dans l'application. Pour les administrateurs, l'ajout d'utilisateurs et la réinitialisation de mot de passe sont simplifiés via l'envoi d'emails automatiques.
|
||||||
|
### Ajouté
|
||||||
|
- Système de vérification automatique des mises à jour
|
||||||
|
- Dialog de notification de mise à jour avec notes de version
|
||||||
|
- Rechargement automatique du cache après mise à jour
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-01-16
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Scanner QR Code pour équipements et conteneurs
|
||||||
|
- Génération de QR codes pour conteneurs
|
||||||
|
- Indicateur de chargement pour génération QR
|
||||||
|
- Sections repliables dans le dialog de sélection d'équipement
|
||||||
|
- Filtrage des équipements en conflit
|
||||||
|
- Filtrage des boîtes par catégorie
|
||||||
|
|
||||||
|
### Amélioré
|
||||||
|
- Performance du dialog de sélection d'équipement
|
||||||
|
- Gestion du cache des équipements
|
||||||
|
- Interface utilisateur générale
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Problème de cache avec les équipements non affichés
|
||||||
|
- Bouton de validation désactivé dans certains cas
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# em2rp
|
|
||||||
|
|
||||||
A new Flutter project.
|
|
||||||
337
em2rp/SYSTEME_MISE_A_JOUR.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# Système de Gestion des Mises à Jour - EM2RP
|
||||||
|
|
||||||
|
## 📋 Vue d'ensemble
|
||||||
|
|
||||||
|
Ce système permet de gérer automatiquement les mises à jour de l'application web Flutter, en notifiant les utilisateurs et en forçant le rechargement du cache si nécessaire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Architecture
|
||||||
|
|
||||||
|
### Fichiers impliqués
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
- **`lib/config/app_version.dart`** : Fichier source de vérité pour la version
|
||||||
|
- **`web/version.json`** : Fichier déployé avec l'app pour vérification côté serveur
|
||||||
|
|
||||||
|
#### Services
|
||||||
|
- **`lib/services/update_service.dart`** : Service de vérification des mises à jour
|
||||||
|
- **`lib/views/widgets/common/update_dialog.dart`** : Widget d'affichage du dialog de mise à jour
|
||||||
|
|
||||||
|
#### Scripts
|
||||||
|
- **`scripts/increment_version.js`** : Incrémente automatiquement la version
|
||||||
|
- **`scripts/update_version_json.js`** : Génère version.json depuis app_version.dart
|
||||||
|
- **`deploy.bat`** : Script de déploiement complet
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
- **`CHANGELOG.md`** : Notes de version (utilisées dans le dialog)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Workflow de déploiement
|
||||||
|
|
||||||
|
### 1. Développement normal
|
||||||
|
Travaillez normalement sur votre code en mode développement.
|
||||||
|
|
||||||
|
### 2. Déploiement d'une nouvelle version
|
||||||
|
```bash
|
||||||
|
deploy.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce script exécute automatiquement :
|
||||||
|
1. ✅ Bascule en mode PRODUCTION
|
||||||
|
2. ✅ **Incrémente la version** (0.3.8 → 0.3.9)
|
||||||
|
3. ✅ **Incrémente le buildNumber** (1 → 2)
|
||||||
|
4. ✅ **Génère version.json** depuis app_version.dart
|
||||||
|
5. ✅ Build Flutter Web
|
||||||
|
6. ✅ Déploie sur Firebase Hosting
|
||||||
|
7. ✅ Retour en mode DÉVELOPPEMENT
|
||||||
|
|
||||||
|
### 3. Mise à jour côté utilisateur
|
||||||
|
Au prochain chargement de l'app (ou après 2 secondes) :
|
||||||
|
- L'app vérifie `https://em2rp.web.app/version.json`
|
||||||
|
- Compare avec la version locale dans `app_version.dart`
|
||||||
|
- Si `buildNumber serveur > buildNumber local` → Affiche le dialog
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Format de version
|
||||||
|
|
||||||
|
### app_version.dart
|
||||||
|
```dart
|
||||||
|
class AppVersion {
|
||||||
|
static const String version = '0.3.8'; // Version sémantique
|
||||||
|
static const int buildNumber = 1; // Numéro de build (incrémenté automatiquement)
|
||||||
|
|
||||||
|
static String get fullVersion => 'v$version';
|
||||||
|
static String get fullVersionWithBuild => 'v$version+$buildNumber';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### version.json (déployé)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "0.3.8",
|
||||||
|
"buildNumber": 1,
|
||||||
|
"updateUrl": "https://em2rp.web.app",
|
||||||
|
"forceUpdate": false,
|
||||||
|
"releaseNotes": "• Scanner QR Code\n• Génération QR conteneurs\n• Performance améliorée"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Comparaison des versions
|
||||||
|
|
||||||
|
Le système compare uniquement le **buildNumber** :
|
||||||
|
- `buildNumber serveur > buildNumber local` → Mise à jour disponible
|
||||||
|
- Ignore les versions identiques même si la version sémantique change
|
||||||
|
|
||||||
|
**Exemple** :
|
||||||
|
- Local : `0.3.8+1`
|
||||||
|
- Serveur : `0.3.9+2`
|
||||||
|
- Résultat : Mise à jour proposée (2 > 1) ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Expérience utilisateur
|
||||||
|
|
||||||
|
### Mise à jour normale (forceUpdate: false)
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ 🔄 Mise à jour disponible │
|
||||||
|
├────────────────────────────────────┤
|
||||||
|
│ Version actuelle : 0.3.8 (1) │
|
||||||
|
│ Nouvelle version : 0.3.9 (2) │
|
||||||
|
│ │
|
||||||
|
│ Nouveautés : │
|
||||||
|
│ • Scanner QR Code │
|
||||||
|
│ • Performance améliorée │
|
||||||
|
│ │
|
||||||
|
│ [Plus tard] [Mettre à jour] 🔄 │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mise à jour forcée (forceUpdate: true)
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ ⚠️ Mise à jour requise │
|
||||||
|
├────────────────────────────────────┤
|
||||||
|
│ Version actuelle : 0.3.8 (1) │
|
||||||
|
│ Nouvelle version : 0.3.9 (2) │
|
||||||
|
│ │
|
||||||
|
│ ⚠️ Cette mise à jour est │
|
||||||
|
│ obligatoire pour continuer │
|
||||||
|
│ │
|
||||||
|
│ [Mettre à jour] 🔄 │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Utilisation avancée
|
||||||
|
|
||||||
|
### Forcer une mise à jour critique
|
||||||
|
Si vous déployez un correctif critique :
|
||||||
|
|
||||||
|
1. Modifiez `web/version.json` **après le déploiement** :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "0.3.9",
|
||||||
|
"buildNumber": 2,
|
||||||
|
"forceUpdate": true, // ← Changer à true
|
||||||
|
"releaseNotes": "🔴 Correctif de sécurité important"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Les utilisateurs ne pourront plus fermer le dialog jusqu'à la mise à jour
|
||||||
|
|
||||||
|
### Personnaliser les notes de version
|
||||||
|
Éditez `CHANGELOG.md` avant le déploiement :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [0.3.9] - 2026-01-16
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Scanner QR Code pour équipements
|
||||||
|
- Génération QR pour conteneurs
|
||||||
|
|
||||||
|
### Amélioré
|
||||||
|
- Performance du dialog de sélection
|
||||||
|
- Gestion du cache
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Bug de cache des équipements
|
||||||
|
```
|
||||||
|
|
||||||
|
Les 5 premières lignes de la section seront utilisées dans le dialog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Tests
|
||||||
|
|
||||||
|
### Test 1 : Vérification de version locale
|
||||||
|
```dart
|
||||||
|
// Dans n'importe quel fichier
|
||||||
|
import 'package:em2rp/config/app_version.dart';
|
||||||
|
|
||||||
|
print('Version: ${AppVersion.version}');
|
||||||
|
print('Build: ${AppVersion.buildNumber}');
|
||||||
|
print('Full: ${AppVersion.fullVersionWithBuild}');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2 : Forcer l'affichage du dialog
|
||||||
|
Modifiez temporairement `web/version.json` :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"buildNumber": 999 // Très grand nombre
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rechargez l'app → Le dialog s'affiche immédiatement
|
||||||
|
|
||||||
|
### Test 3 : Tester le rechargement
|
||||||
|
1. Cliquez sur "Mettre à jour"
|
||||||
|
2. Vérifiez que la page se recharge
|
||||||
|
3. Vérifiez que le cache est vidé (nouvelles ressources chargées)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Logs de debug
|
||||||
|
|
||||||
|
En mode debug, des logs sont affichés dans la console :
|
||||||
|
|
||||||
|
```
|
||||||
|
[UpdateService] Current version: 0.3.8+1
|
||||||
|
[UpdateService] Server version: 0.3.9+2
|
||||||
|
```
|
||||||
|
|
||||||
|
Si pas de mise à jour disponible, rien ne s'affiche.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Sécurité
|
||||||
|
|
||||||
|
### Headers HTTP pour forcer le non-cache
|
||||||
|
Le fichier `web/index.html` contient :
|
||||||
|
```html
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache-busting sur version.json
|
||||||
|
Chaque requête ajoute un timestamp :
|
||||||
|
```dart
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
Uri.parse('$versionUrl?t=$timestamp')
|
||||||
|
```
|
||||||
|
|
||||||
|
Garantit que la version la plus récente est toujours récupérée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Résolution de problèmes
|
||||||
|
|
||||||
|
### Problème : Le dialog ne s'affiche pas
|
||||||
|
**Causes possibles :**
|
||||||
|
1. Le `buildNumber` serveur n'est pas supérieur au local
|
||||||
|
2. Erreur réseau (timeout 10s)
|
||||||
|
3. Le fichier `version.json` n'existe pas sur le serveur
|
||||||
|
|
||||||
|
**Solution :**
|
||||||
|
```bash
|
||||||
|
# Vérifier la version déployée
|
||||||
|
curl https://em2rp.web.app/version.json
|
||||||
|
|
||||||
|
# Forcer un nouveau déploiement
|
||||||
|
deploy.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problème : Le cache ne se vide pas
|
||||||
|
**Causes possibles :**
|
||||||
|
1. Service Worker actif (ancienne version)
|
||||||
|
2. Cache navigateur très persistant
|
||||||
|
|
||||||
|
**Solution :**
|
||||||
|
```javascript
|
||||||
|
// Dans les DevTools du navigateur
|
||||||
|
navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||||
|
registrations.forEach(r => r.unregister());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Puis CTRL+SHIFT+R (rechargement forcé)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problème : Le script increment_version.js échoue
|
||||||
|
**Solution :**
|
||||||
|
```bash
|
||||||
|
# Vérifier la syntaxe du fichier app_version.dart
|
||||||
|
# Doit contenir exactement :
|
||||||
|
static const String version = '0.3.8';
|
||||||
|
static const int buildNumber = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Évolution future
|
||||||
|
|
||||||
|
### Fonctionnalités possibles
|
||||||
|
- [ ] Afficher un changelog complet dans le dialog
|
||||||
|
- [ ] Permettre de sauter une version (skip this version)
|
||||||
|
- [ ] Notifications push pour les mises à jour critiques
|
||||||
|
- [ ] Analytics sur le taux d'adoption des mises à jour
|
||||||
|
- [ ] Support des mises à jour en arrière-plan
|
||||||
|
|
||||||
|
### Améliorations techniques
|
||||||
|
- [ ] Utiliser un CDN pour version.json
|
||||||
|
- [ ] Implémenter un rollback automatique si erreur
|
||||||
|
- [ ] Ajouter une vérification de santé post-déploiement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Commandes rapides
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Déployer une nouvelle version
|
||||||
|
deploy.bat
|
||||||
|
|
||||||
|
# Incrémenter manuellement la version
|
||||||
|
node scripts\increment_version.js
|
||||||
|
|
||||||
|
# Générer version.json manuellement
|
||||||
|
node scripts\update_version_json.js
|
||||||
|
|
||||||
|
# Vérifier la version actuelle
|
||||||
|
type lib\config\app_version.dart
|
||||||
|
|
||||||
|
# Vérifier la version déployée
|
||||||
|
curl https://em2rp.web.app/version.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Checklist de déploiement
|
||||||
|
|
||||||
|
Avant chaque déploiement :
|
||||||
|
|
||||||
|
- [ ] Tester l'application en local
|
||||||
|
- [ ] Mettre à jour `CHANGELOG.md` avec les nouveautés
|
||||||
|
- [ ] Vérifier que tous les tests passent
|
||||||
|
- [ ] Exécuter `deploy.bat`
|
||||||
|
- [ ] Vérifier le déploiement sur https://em2rp.web.app
|
||||||
|
- [ ] Tester la mise à jour sur un navigateur propre
|
||||||
|
- [ ] Informer l'équipe de la nouvelle version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
En cas de problème avec le système de mise à jour, vérifier :
|
||||||
|
1. Les logs dans la console du navigateur
|
||||||
|
2. Le fichier `version.json` déployé
|
||||||
|
3. Le fichier `app_version.dart` local
|
||||||
|
4. La connexion réseau de l'utilisateur
|
||||||
|
|
||||||
|
**Le système est conçu pour échouer silencieusement** : Si une erreur se produit, l'utilisateur peut continuer à utiliser l'app normalement sans être bloqué.
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.9 KiB |
35
em2rp/assets/icons/flight-case.svg
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M1793 5032 c-17 -9 -82 -75 -144 -147 l-112 -130 511 -3 c282 -1 742
|
||||||
|
-1 1024 0 l511 3 -112 130 c-62 72 -127 138 -144 147 -29 16 -93 17 -767 17
|
||||||
|
-674 0 -738 -1 -767 -17z"/>
|
||||||
|
<path d="M76 4426 c-75 -45 -75 -46 -76 -443 l0 -353 410 0 410 0 0 34 c0 43
|
||||||
|
36 96 80 118 31 16 71 18 363 18 356 0 374 -2 416 -56 12 -15 26 -47 32 -71
|
||||||
|
l11 -43 842 0 842 0 2 32 c3 41 29 85 65 112 27 20 41 21 360 24 217 2 345 -1
|
||||||
|
369 -8 46 -13 85 -59 99 -116 l11 -44 404 0 404 0 0 351 c0 345 0 352 -22 391
|
||||||
|
-14 24 -38 48 -60 59 -36 19 -101 19 -2480 19 l-2443 0 -39 -24z"/>
|
||||||
|
<path d="M1120 3295 l0 -205 150 0 150 0 0 205 0 205 -150 0 -150 0 0 -205z"/>
|
||||||
|
<path d="M3710 3295 l0 -205 150 0 150 0 0 205 0 205 -150 0 -150 0 0 -205z"/>
|
||||||
|
<path d="M0 3088 l0 -243 240 240 c132 132 240 241 240 242 0 2 -108 3 -240 3
|
||||||
|
l-240 0 0 -242z"/>
|
||||||
|
<path d="M1718 3118 c-3 -234 -8 -255 -71 -302 -27 -20 -41 -21 -370 -24 -382
|
||||||
|
-3 -388 -2 -434 67 -22 32 -23 44 -23 205 l0 171 -410 -410 -410 -410 0 -460
|
||||||
|
0 -460 601 -608 601 -607 1350 0 1350 0 609 602 609 603 0 470 1 470 -403 397
|
||||||
|
-403 397 -5 -157 c-6 -172 -15 -202 -73 -246 -27 -20 -41 -21 -370 -24 -382
|
||||||
|
-3 -388 -2 -434 67 -22 33 -23 42 -23 252 l0 219 -844 0 -845 0 -3 -212z"/>
|
||||||
|
<path d="M4875 3090 c132 -132 241 -240 242 -240 2 0 3 108 3 240 l0 240 -242
|
||||||
|
0 -243 0 240 -240z"/>
|
||||||
|
<path d="M0 731 c0 -321 1 -335 21 -371 40 -71 71 -80 283 -80 l186 0 0 -45
|
||||||
|
c0 -100 56 -160 147 -160 85 0 143 57 150 147 l4 53 -395 395 -396 395 0 -334z"/>
|
||||||
|
<path d="M4723 668 c-384 -384 -393 -394 -393 -432 0 -63 34 -121 84 -145 54
|
||||||
|
-27 81 -26 136 2 54 27 80 72 80 140 l0 47 185 0 c214 0 243 8 283 78 22 40
|
||||||
|
22 44 20 371 l-3 331 -392 -392z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
32
em2rp/assets/icons/tape.svg
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M1979 5105 c-272 -46 -519 -179 -746 -403 -407 -401 -673 -1072 -701
|
||||||
|
-1767 -26 -644 82 -1147 345 -1610 131 -232 154 -409 69 -544 -39 -61 -145
|
||||||
|
-159 -201 -185 -104 -49 -330 -113 -585 -167 -58 -12 -106 -23 -108 -24 -4 -4
|
||||||
|
20 -99 27 -107 3 -4 59 0 124 7 160 19 167 16 231 -112 l36 -72 122 2 c140 2
|
||||||
|
157 -3 239 -80 l47 -44 116 26 c373 84 861 246 1020 339 94 55 141 166 139
|
||||||
|
331 -1 171 -47 317 -160 512 -254 440 -373 934 -373 1553 0 259 15 440 51 639
|
||||||
|
131 720 509 1334 1049 1703 24 16 8 17 -320 16 -231 0 -370 -4 -421 -13z"/>
|
||||||
|
<path d="M3276 5109 c-322 -47 -636 -237 -884 -534 -759 -909 -791 -2555 -68
|
||||||
|
-3530 86 -116 295 -319 401 -392 457 -309 952 -312 1410 -7 103 68 271 226
|
||||||
|
362 338 288 357 477 830 550 1381 24 182 24 628 0 810 -151 1141 -829 1953
|
||||||
|
-1622 1944 -49 -1 -116 -5 -149 -10z m399 -1047 c227 -79 439 -286 570 -557
|
||||||
|
282 -586 185 -1369 -222 -1796 -284 -297 -634 -357 -971 -167 -102 57 -260
|
||||||
|
212 -337 331 -337 521 -335 1288 7 1802 59 89 198 235 269 283 68 46 175 98
|
||||||
|
243 117 135 39 304 34 441 -13z"/>
|
||||||
|
<path d="M3307 3905 c-61 -15 -158 -60 -210 -97 -43 -30 -145 -127 -168 -159
|
||||||
|
l-19 -27 55 -113 c65 -138 107 -261 137 -411 19 -97 23 -144 23 -328 0 -184
|
||||||
|
-3 -231 -23 -328 -31 -152 -81 -302 -142 -423 -38 -76 -48 -105 -40 -118 5
|
||||||
|
-10 48 -54 95 -99 131 -125 263 -182 419 -182 350 0 651 328 756 825 27 130
|
||||||
|
37 373 21 514 -44 372 -212 694 -449 855 -132 90 -314 127 -455 91z"/>
|
||||||
|
<path d="M2327 628 c-3 -74 -11 -140 -20 -165 l-16 -43 226 0 226 0 -79 51
|
||||||
|
c-94 61 -164 118 -260 212 l-71 69 -6 -124z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
26
em2rp/assets/icons/truss.svg
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M410 3418 c-70 -35 -90 -85 -90 -218 1 -152 35 -214 132 -235 l28 -7
|
||||||
|
0 -398 0 -398 -28 -7 c-97 -21 -131 -83 -132 -235 0 -133 20 -183 90 -217 l44
|
||||||
|
-23 2106 0 2106 0 44 23 c70 34 90 84 90 217 -1 152 -35 214 -132 235 l-28 7
|
||||||
|
0 398 0 398 28 7 c97 21 131 83 132 235 0 133 -20 183 -90 218 l-44 22 -2106
|
||||||
|
0 -2106 0 -44 -22z m4230 -218 l0 -80 -2080 0 -2080 0 0 80 0 80 2080 0 2080
|
||||||
|
0 0 -80z m-3770 -640 c-130 -260 -205 -400 -215 -400 -13 0 -15 51 -15 400 l0
|
||||||
|
400 215 0 215 0 -200 -400z m640 0 l200 -400 -430 0 -430 0 200 400 c197 393
|
||||||
|
201 400 230 400 29 0 33 -7 230 -400z m640 0 c-197 -393 -201 -400 -230 -400
|
||||||
|
-29 0 -33 7 -230 400 l-200 400 430 0 430 0 -200 -400z m640 0 l200 -400 -430
|
||||||
|
0 -430 0 200 400 c197 393 201 400 230 400 29 0 33 -7 230 -400z m640 0 c-197
|
||||||
|
-393 -201 -400 -230 -400 -29 0 -33 7 -230 400 l-200 400 430 0 430 0 -200
|
||||||
|
-400z m640 0 l200 -400 -430 0 -430 0 200 400 c197 393 201 400 230 400 29 0
|
||||||
|
33 -7 230 -400z m410 0 c0 -349 -2 -400 -15 -400 -10 0 -85 140 -215 400
|
||||||
|
l-200 400 215 0 215 0 0 -400z m160 -640 l0 -80 -2080 0 -2080 0 0 80 0 80
|
||||||
|
2080 0 2080 0 0 -80z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
em2rp/assets/logos/LowQRectangleLogoBlack.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
em2rp/assets/logos/RectangleLogoBlack.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
em2rp/assets/logos/RectangleLogoWhite.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
em2rp/assets/logos/SquareLogoBlack.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
em2rp/assets/logos/SquareLogoWhite.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
70
em2rp/deploy.bat
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
@echo off
|
||||||
|
REM Script Windows pour incrémenter la version et déployer sur Firebase
|
||||||
|
|
||||||
|
echo ================================================
|
||||||
|
echo Déploiement Firebase Hosting avec EM2RP
|
||||||
|
echo ================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [0/4] Basculement en mode PRODUCTION...
|
||||||
|
node scripts\toggle_env.js prod
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors du basculement en mode production
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1/4] Incrémentation de la version...
|
||||||
|
node scripts\increment_version.js
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors de l'incrémentation de la version
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1.5/4] Mise à jour du fichier version.json...
|
||||||
|
node scripts\update_version_json.js
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors de la mise à jour de version.json
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [2/4] Build Flutter Web...
|
||||||
|
call flutter build web --release
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors du build Flutter
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [3/4] Déploiement Firebase Hosting...
|
||||||
|
call firebase deploy --only hosting
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo Erreur lors du déploiement Firebase
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [4/4] Retour en mode DÉVELOPPEMENT...
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
echo ATTENTION: Impossible de rebascule en mode dev
|
||||||
|
echo Exécutez manuellement: node scripts\toggle_env.js dev
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ================================================
|
||||||
|
echo Déploiement terminé avec succès!
|
||||||
|
echo ================================================
|
||||||
|
pause
|
||||||
|
|
||||||
32
em2rp/deploy_alert_corrections.ps1
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Script de déploiement rapide - Corrections Alertes
|
||||||
|
|
||||||
|
Write-Host "=== DÉPLOIEMENT CORRECTIONS ALERTES ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 1. Hot restart Flutter (si app en cours)
|
||||||
|
Write-Host "1. Hot restart recommandé (R dans le terminal Flutter)" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 2. Pub get
|
||||||
|
Write-Host "2. Installation des dépendances..." -ForegroundColor Yellow
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
# 3. Optionnel : Redéployer les fonctions si besoin
|
||||||
|
# Décommentez si vous avez modifié les Cloud Functions
|
||||||
|
# Write-Host "3. Déploiement Cloud Functions..." -ForegroundColor Yellow
|
||||||
|
# firebase deploy --only functions:sendAlertEmail
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== DÉPLOIEMENT TERMINÉ ===" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "PROCHAINES ÉTAPES:" -ForegroundColor Cyan
|
||||||
|
Write-Host "1. Hot restart de l'application (R dans terminal Flutter)"
|
||||||
|
Write-Host "2. Vérifier que vous êtes connecté"
|
||||||
|
Write-Host "3. Créer un événement de test avec workforce"
|
||||||
|
Write-Host "4. Créer une alerte LOST (équipement perdu)"
|
||||||
|
Write-Host "5. Vérifier les logs (F12 → Console)"
|
||||||
|
Write-Host "6. Vérifier Firestore (Firebase Console)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Voir CORRECTIONS_ALERTES_CIBLAGE.md pour détails" -ForegroundColor Yellow
|
||||||
|
|
||||||
25
em2rp/deploy_alert_trigger.ps1
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Script de déploiement de la fonction onAlertCreated
|
||||||
|
Write-Host "=== Déploiement de onAlertCreated ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Vérifier que nous sommes dans le bon répertoire
|
||||||
|
$currentPath = Get-Location
|
||||||
|
if ($currentPath.Path -notlike "*\em2rp") {
|
||||||
|
Write-Host "ERREUR: Ce script doit être exécuté depuis le répertoire em2rp" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# S'assurer qu'on utilise le bon projet
|
||||||
|
Write-Host "`nVérification du projet Firebase..." -ForegroundColor Yellow
|
||||||
|
firebase use em2rp-951dc
|
||||||
|
|
||||||
|
# Déployer la fonction
|
||||||
|
Write-Host "`nDéploiement de la fonction..." -ForegroundColor Yellow
|
||||||
|
firebase deploy --only functions:onAlertCreated
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "`nDéploiement réussi!" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "`nÉchec du déploiement" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
109
em2rp/deploy_backend.ps1
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Script de déploiement backend sécurisé
|
||||||
|
# Usage: .\deploy_backend.ps1 [test|prod]
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[ValidateSet("test", "prod")]
|
||||||
|
[string]$mode
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " Migration Backend - Déploiement" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Mode TEST : Lancer les émulateurs
|
||||||
|
if ($mode -eq "test") {
|
||||||
|
Write-Host "Mode: TEST (émulateurs)" -ForegroundColor Yellow
|
||||||
|
Write-Host "Lancement des émulateurs Firebase..." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
firebase emulators:start
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mode PROD : Déploiement en production
|
||||||
|
Write-Host "Mode: PRODUCTION" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Confirmation
|
||||||
|
Write-Host "ATTENTION: Vous allez déployer en PRODUCTION !" -ForegroundColor Red
|
||||||
|
$confirmation = Read-Host "Tapez 'OUI' pour confirmer"
|
||||||
|
|
||||||
|
if ($confirmation -ne "OUI") {
|
||||||
|
Write-Host "Déploiement annulé." -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Étape 1/4 : Vérification du code" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Vérifier que ApiConfig est en mode production
|
||||||
|
$apiConfigPath = "lib\config\api_config.dart"
|
||||||
|
$apiConfigContent = Get-Content $apiConfigPath -Raw
|
||||||
|
|
||||||
|
if ($apiConfigContent -match "isDevelopment = true") {
|
||||||
|
Write-Host "ERREUR: ApiConfig est en mode développement !" -ForegroundColor Red
|
||||||
|
Write-Host "Veuillez mettre 'isDevelopment = false' dans $apiConfigPath" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ ApiConfig en mode production" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Étape 2/4 : Installation dépendances" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
Push-Location functions
|
||||||
|
npm install
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERREUR: Installation des dépendances échouée" -ForegroundColor Red
|
||||||
|
Pop-Location
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
Write-Host "✓ Dépendances installées" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Étape 3/4 : Déploiement Cloud Functions" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
firebase deploy --only functions
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERREUR: Déploiement des functions échoué" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Cloud Functions déployées" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "Étape 4/4 : Déploiement Firestore Rules" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
firebase deploy --only firestore:rules
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERREUR: Déploiement des règles échoué" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Firestore Rules déployées" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host " DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Prochaines étapes :" -ForegroundColor Yellow
|
||||||
|
Write-Host "1. Tester les opérations CRUD (voir TESTING_PLAN.md)" -ForegroundColor Gray
|
||||||
|
Write-Host "2. Surveiller les logs: firebase functions:log" -ForegroundColor Gray
|
||||||
|
Write-Host "3. Vérifier les permissions utilisateurs" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Console Firebase:" -ForegroundColor Cyan
|
||||||
|
Write-Host "https://console.firebase.google.com/project/em2rp-951dc/functions" -ForegroundColor Blue
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
85
em2rp/deploy_firestore_rules.ps1
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Script de déploiement des règles Firestore
|
||||||
|
# Date : 15/01/2026
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " DÉPLOIEMENT RÈGLES FIRESTORE" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Vérifier que Firebase CLI est installé
|
||||||
|
Write-Host "Vérification Firebase CLI..." -ForegroundColor Yellow
|
||||||
|
$firebaseCmd = Get-Command firebase -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $firebaseCmd) {
|
||||||
|
Write-Host "❌ Firebase CLI n'est pas installé !" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Installation requise :" -ForegroundColor Yellow
|
||||||
|
Write-Host " npm install -g firebase-tools" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "OU copier-coller manuellement dans Console Firebase" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "✓ Firebase CLI trouvé" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Vérifier que le fichier firestore.rules existe
|
||||||
|
if (-Not (Test-Path "firestore.rules")) {
|
||||||
|
Write-Host "❌ Fichier firestore.rules introuvable !" -ForegroundColor Red
|
||||||
|
Write-Host "Vérifiez que vous êtes dans le bon répertoire" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "✓ Fichier firestore.rules trouvé" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Afficher un aperçu des règles pour les alertes
|
||||||
|
Write-Host "Règles à déployer (extrait) :" -ForegroundColor Yellow
|
||||||
|
Write-Host "------------------------------" -ForegroundColor Gray
|
||||||
|
Get-Content "firestore.rules" | Select-String -Pattern "alerts" -Context 3 | Select-Object -First 10
|
||||||
|
Write-Host "------------------------------" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Demander confirmation
|
||||||
|
Write-Host "Déployer les règles Firestore ? (O/N)" -ForegroundColor Yellow -NoNewline
|
||||||
|
Write-Host " " -NoNewline
|
||||||
|
$confirmation = Read-Host
|
||||||
|
|
||||||
|
if ($confirmation -ne "O" -and $confirmation -ne "o") {
|
||||||
|
Write-Host "Déploiement annulé" -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Déploiement en cours..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Déployer les règles
|
||||||
|
try {
|
||||||
|
firebase deploy --only firestore:rules
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host " ✅ DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Les règles Firestore ont été déployées avec succès." -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Prochaines étapes :" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Rafraîchir l'application (Ctrl+R)" -ForegroundColor White
|
||||||
|
Write-Host " 2. Créer un événement pour tester" -ForegroundColor White
|
||||||
|
Write-Host " 3. Vérifier qu'aucune erreur permission n'apparaît" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Red
|
||||||
|
Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red
|
||||||
|
Write-Host "========================================" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Erreur : $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Solutions :" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Vérifier connexion : firebase login" -ForegroundColor White
|
||||||
|
Write-Host " 2. Vérifier projet : firebase use" -ForegroundColor White
|
||||||
|
Write-Host " 3. OU déployer via Console Firebase" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
66
em2rp/deploy_functions.ps1
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# EM2RP - Déploiement automatique du système d'alertes
|
||||||
|
# Ce script déploie les Cloud Functions et vérifie le déploiement
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " EM2RP - Déploiement Cloud Functions " -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Vérifier qu'on est dans le bon répertoire
|
||||||
|
if (-not (Test-Path ".\firebase.json")) {
|
||||||
|
Write-Host "❌ ERREUR: Vous devez lancer ce script depuis C:\src\EM2RP\em2rp\" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vérifier que le fichier .env existe
|
||||||
|
if (-not (Test-Path ".\functions\.env")) {
|
||||||
|
Write-Host "❌ ERREUR: Le fichier functions\.env est manquant" -ForegroundColor Red
|
||||||
|
Write-Host " Créez ce fichier avec les identifiants SMTP" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✅ Vérifications préliminaires OK" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Déployer les fonctions
|
||||||
|
Write-Host "🚀 Déploiement des Cloud Functions en cours..." -ForegroundColor Cyan
|
||||||
|
Write-Host " (Cela peut prendre 3-5 minutes)" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$deployResult = firebase deploy --only functions 2>&1
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host " ✅ DÉPLOIEMENT RÉUSSI" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Lister les fonctions déployées
|
||||||
|
Write-Host "📋 Fonctions déployées:" -ForegroundColor Cyan
|
||||||
|
firebase functions:list
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🎯 Prochaines étapes:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Migrer les préférences utilisateurs: cd functions; node migrate_email_prefs.js" -ForegroundColor White
|
||||||
|
Write-Host " 2. Tester la création d'un événement avec workforce" -ForegroundColor White
|
||||||
|
Write-Host " 3. Vérifier les logs: firebase functions:log --limit 20" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📚 Voir DEPLOY_NOW.md pour plus de détails" -ForegroundColor Gray
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Red
|
||||||
|
Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red
|
||||||
|
Write-Host "========================================" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Erreur rencontrée:" -ForegroundColor Yellow
|
||||||
|
Write-Host $deployResult -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "💡 Solutions possibles:" -ForegroundColor Yellow
|
||||||
|
Write-Host " - Si 'Quota exceeded': Attendez 2 minutes et relancez" -ForegroundColor White
|
||||||
|
Write-Host " - Vérifiez que Firebase CLI est à jour: firebase --version" -ForegroundColor White
|
||||||
|
Write-Host " - Consultez les logs: firebase functions:log" -ForegroundColor White
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
description: This file stores settings for Dart & Flutter DevTools.
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
extensions:
|
extensions:
|
||||||
|
- provider: true
|
||||||
15
em2rp/env_dev.bat
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@echo off
|
||||||
|
REM Script Windows pour basculer en mode DÉVELOPPEMENT
|
||||||
|
|
||||||
|
echo Basculement en mode DÉVELOPPEMENT...
|
||||||
|
node scripts\toggle_env.js dev
|
||||||
|
|
||||||
|
if %ERRORLEVEL% EQU 0 (
|
||||||
|
echo ✅ Mode DÉVELOPPEMENT activé
|
||||||
|
echo - isDevelopment = true
|
||||||
|
echo - Auto-login activé
|
||||||
|
) else (
|
||||||
|
echo ❌ Erreur lors du basculement
|
||||||
|
echo Vérifiez que le fichier env.dev.dart existe
|
||||||
|
)
|
||||||
|
|
||||||
16
em2rp/env_prod.bat
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
REM Script Windows pour basculer en mode PRODUCTION
|
||||||
|
|
||||||
|
echo Basculement en mode PRODUCTION...
|
||||||
|
node scripts\toggle_env.js prod
|
||||||
|
|
||||||
|
if %ERRORLEVEL% EQU 0 (
|
||||||
|
echo ✅ Mode PRODUCTION activé
|
||||||
|
echo - isDevelopment = false
|
||||||
|
echo - Credentials masqués
|
||||||
|
) else (
|
||||||
|
echo ❌ Erreur lors du basculement
|
||||||
|
)
|
||||||
|
|
||||||
|
pause
|
||||||
|
|
||||||
@@ -1 +1,72 @@
|
|||||||
{"flutter":{"platforms":{"android":{"default":{"projectId":"em2rp-951dc","appId":"1:341201262902:android:8c9a9e340ecb58665a3b97","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"em2rp-951dc","configurations":{"android":"1:341201262902:android:8c9a9e340ecb58665a3b97","ios":"1:341201262902:ios:767281c23dd9e76a5a3b97","macos":"1:341201262902:ios:767281c23dd9e76a5a3b97","web":"1:341201262902:web:bb70b5ff45df80935a3b97","windows":"1:341201262902:web:faceb51cb184a2875a3b97"}}}}}}
|
{
|
||||||
|
"flutter": {
|
||||||
|
"platforms": {
|
||||||
|
"android": {
|
||||||
|
"default": {
|
||||||
|
"projectId": "em2rp-951dc",
|
||||||
|
"appId": "1:341201262902:android:8c9a9e340ecb58665a3b97",
|
||||||
|
"fileOutput": "android/app/google-services.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dart": {
|
||||||
|
"lib/firebase_options.dart": {
|
||||||
|
"projectId": "em2rp-951dc",
|
||||||
|
"configurations": {
|
||||||
|
"android": "1:341201262902:android:8c9a9e340ecb58665a3b97",
|
||||||
|
"ios": "1:341201262902:ios:767281c23dd9e76a5a3b97",
|
||||||
|
"macos": "1:341201262902:ios:767281c23dd9e76a5a3b97",
|
||||||
|
"web": "1:341201262902:web:bb70b5ff45df80935a3b97",
|
||||||
|
"windows": "1:341201262902:web:faceb51cb184a2875a3b97"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"functions": [
|
||||||
|
{
|
||||||
|
"source": "functions",
|
||||||
|
"codebase": "default",
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git",
|
||||||
|
"firebase-debug.log",
|
||||||
|
"firebase-debug.*.log",
|
||||||
|
"*.local"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hosting": {
|
||||||
|
"public": "build/web",
|
||||||
|
"ignore": [
|
||||||
|
"firebase.json",
|
||||||
|
"**/.*",
|
||||||
|
"**/node_modules/**"
|
||||||
|
],
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "**",
|
||||||
|
"destination": "/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"firestore": {
|
||||||
|
"rules": "firestore.rules",
|
||||||
|
"indexes": "firestore.indexes.json"
|
||||||
|
},
|
||||||
|
"emulators": {
|
||||||
|
"functions": {
|
||||||
|
"port": 5051
|
||||||
|
},
|
||||||
|
"firestore": {
|
||||||
|
"port": 8088
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"port": 9199
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 4040
|
||||||
|
},
|
||||||
|
"singleProjectMode": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
46
em2rp/firestore.indexes.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"collectionGroup": "events",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "EndDateTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "StartDateTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "__name__",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "events",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "StartDateTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "EndDateTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldOverrides": []
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,90 +1,184 @@
|
|||||||
rules_version = '2';
|
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 {
|
service cloud.firestore {
|
||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
// Fonction pour vérifier si l'utilisateur est authentifié
|
|
||||||
function isAuthenticated() {
|
|
||||||
return request.auth != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserRole() {
|
// ========================================================================
|
||||||
let userData = get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
|
// RÈGLE GLOBALE PAR DÉFAUT : TOUT BLOQUER
|
||||||
return userData != null ? userData.role : null;
|
// ========================================================================
|
||||||
}
|
// 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
|
||||||
|
|
||||||
// Fonction pour vérifier si l'utilisateur est un admin
|
|
||||||
function isAdmin() {
|
|
||||||
return isAuthenticated() && getUserRole() == 'ADMIN';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOwner(userId) {
|
|
||||||
return isAuthenticated() && request.auth.uid == userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nouvelle fonction pour vérifier si un CREW est assigné à un événement du client
|
|
||||||
function isAssignedToClientEvent(clientId) {
|
|
||||||
let events = getAfter(/databases/$(database)/documents/events)
|
|
||||||
.where("clientId", "==", clientId)
|
|
||||||
.where("assignedUsers." + request.auth.uid, "==", true).limit(1);
|
|
||||||
return events.size() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fonction pour vérifier si c'est le premier utilisateur
|
|
||||||
function isFirstUser() {
|
|
||||||
return !exists(/databases/$(database)/documents/users);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fonction pour vérifier si c'est une mise à jour de l'UID
|
|
||||||
function isUidUpdate() {
|
|
||||||
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['uid']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Règles pour la collection users
|
|
||||||
match /users/{userId} {
|
|
||||||
allow read: if isAuthenticated() && (isAdmin() || isOwner(userId));
|
|
||||||
// Permettre la création si admin OU si l'utilisateur crée son propre document
|
|
||||||
allow create: if isAdmin() || (isAuthenticated() && request.auth.uid == userId);
|
|
||||||
allow update: if isAdmin() ||
|
|
||||||
(isOwner(userId) &&
|
|
||||||
request.resource.data.diff(resource.data).affectedKeys()
|
|
||||||
.hasOnly(['phoneNumber', 'profilePhotoUrl', 'firstName', 'lastName', 'role']));
|
|
||||||
allow delete: if isAdmin();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Règles pour la collection clients
|
|
||||||
match /clients/{clientId} {
|
|
||||||
// Lecture :
|
|
||||||
// - Les admins peuvent tout voir
|
|
||||||
// - Les CREW ne peuvent voir que les clients liés à leurs événements
|
|
||||||
allow read: if isAdmin() ||
|
|
||||||
(getUserRole() == 'CREW' && isAssignedToClientEvent(clientId));
|
|
||||||
|
|
||||||
// Création, modification et suppression : Seuls les admins
|
|
||||||
allow create, update, delete: if isAdmin();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Règles pour la collection events (prestations)
|
|
||||||
match /events/{eventId} {
|
|
||||||
allow read: if isAdmin() ||
|
|
||||||
(isAuthenticated() && (resource.data.assignedUsers[request.auth.uid] == true));
|
|
||||||
allow create, update: if isAdmin();
|
|
||||||
allow delete: if isAdmin();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Règles pour la collection quotes (devis)
|
|
||||||
match /quotes/{quoteId} {
|
|
||||||
allow read, write: if isAdmin();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Règles pour la collection invoices (factures)
|
|
||||||
match /invoices/{invoiceId} {
|
|
||||||
allow read, write: if isAdmin();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Règles pour les autres collections
|
|
||||||
match /{document=**} {
|
match /{document=**} {
|
||||||
// Par défaut, refuser l'accès
|
// ❌ REFUSER TOUS LES ACCÈS directs depuis les clients
|
||||||
allow read, write: if false;
|
allow read, write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// EXCEPTIONS OPTIONNELLES pour les listeners temps réel
|
||||||
|
// ========================================================================
|
||||||
|
// Si vous avez besoin de listeners en temps réel pour certaines collections,
|
||||||
|
// décommentez les règles ci-dessous.
|
||||||
|
//
|
||||||
|
// ⚠️ IMPORTANT : Ces règles permettent UNIQUEMENT la LECTURE.
|
||||||
|
// Toutes les ÉCRITURES doivent passer par les Cloud Functions.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Événements : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /events/{eventId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Équipements : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /equipments/{equipmentId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conteneurs : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /containers/{containerId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenances : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /maintenances/{maintenanceId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Alertes : Lecture et création pour utilisateurs authentifiés
|
||||||
|
// Le trigger backend (onAlertCreated) s'occupe d'assigner les bonnes personnes
|
||||||
|
match /alerts/{alertId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow create: if request.auth != null
|
||||||
|
&& request.resource.data.createdBy == request.auth.uid; // Vérifier que l'utilisateur crée l'alerte en son nom
|
||||||
|
allow update: if request.auth != null
|
||||||
|
&& (
|
||||||
|
// L'utilisateur peut marquer comme lue uniquement s'il est assigné
|
||||||
|
(request.auth.uid in resource.data.assignedTo && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isRead', 'readAt']))
|
||||||
|
// Ou le backend peut tout modifier (processed, assignedTo, etc.)
|
||||||
|
|| !('createdBy' in resource.data) // Le trigger backend n'a pas de createdBy
|
||||||
|
);
|
||||||
|
allow delete: if request.auth != null && request.auth.uid in resource.data.assignedTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Utilisateurs : Lecture de son propre profil uniquement
|
||||||
|
match /users/{userId} {
|
||||||
|
allow read: if request.auth != null && request.auth.uid == userId;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types d'événements : Lecture seule
|
||||||
|
match /eventTypes/{typeId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options : Lecture seule
|
||||||
|
match /options/{optionId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clients : Lecture seule
|
||||||
|
match /customers/{customerId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// RÈGLES AVANCÉES avec vérification des permissions (OPTIONNEL)
|
||||||
|
// ========================================================================
|
||||||
|
// Décommentez ces règles si vous voulez des permissions basées sur les rôles
|
||||||
|
// pour la lecture en temps réel
|
||||||
|
//
|
||||||
|
// ⚠️ ATTENTION : Ces règles nécessitent une lecture supplémentaire dans
|
||||||
|
// la collection users, ce qui peut impacter les performances et les coûts.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Fonction helper : Récupérer les permissions de l'utilisateur
|
||||||
|
function getUserPermissions() {
|
||||||
|
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction helper : Vérifier si l'utilisateur a une permission
|
||||||
|
function hasPermission(permission) {
|
||||||
|
return request.auth != null && permission in getUserPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Équipements : Lecture uniquement si permission view_equipment
|
||||||
|
match /equipments/{equipmentId} {
|
||||||
|
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Événements : Lecture selon permissions
|
||||||
|
match /events/{eventId} {
|
||||||
|
allow read: if hasPermission('view_events') || hasPermission('edit_event');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conteneurs : Lecture uniquement si permission view_equipment
|
||||||
|
match /containers/{containerId} {
|
||||||
|
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenances : Lecture uniquement si permission view_equipment
|
||||||
|
match /maintenances/{maintenanceId} {
|
||||||
|
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NOTES DE SÉCURITÉ
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// 1. RÈGLE PAR DÉFAUT (allow read, write: if false)
|
||||||
|
// - Bloque TOUS les accès directs depuis les clients
|
||||||
|
// - Les Cloud Functions ne sont PAS affectées (elles ont un accès admin)
|
||||||
|
// - C'est la configuration la PLUS SÉCURISÉE
|
||||||
|
//
|
||||||
|
// 2. EXCEPTIONS DE LECTURE (commentées par défaut)
|
||||||
|
// - Permettent les listeners en temps réel pour certaines collections
|
||||||
|
// - UNIQUEMENT la LECTURE est autorisée
|
||||||
|
// - Les ÉCRITURES restent bloquées (doivent passer par Cloud Functions)
|
||||||
|
//
|
||||||
|
// 3. RÈGLES BASÉES SUR LES RÔLES (commentées par défaut)
|
||||||
|
// - Permettent un contrôle plus fin basé sur les permissions utilisateur
|
||||||
|
// - ⚠️ Impact sur les performances (lecture supplémentaire de la collection users)
|
||||||
|
// - À utiliser uniquement si nécessaire
|
||||||
|
//
|
||||||
|
// 4. TESTS APRÈS DÉPLOIEMENT
|
||||||
|
// - Vérifier que les Cloud Functions fonctionnent toujours
|
||||||
|
// - Tester qu'un accès direct depuis la console échoue
|
||||||
|
// - Surveiller les logs : firebase functions:log
|
||||||
|
//
|
||||||
|
// 5. ROLLBACK EN CAS DE PROBLÈME
|
||||||
|
// - Remplacer temporairement par :
|
||||||
|
// match /{document=**} {
|
||||||
|
// allow read, write: if request.auth != null;
|
||||||
|
// }
|
||||||
|
// - Déployer rapidement : firebase deploy --only firestore:rules
|
||||||
|
//
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
|||||||
20
em2rp/flutter_launcher_icons.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: true
|
||||||
|
image_path: "assets/EM2_NsurB.jpg"
|
||||||
|
web:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/EM2_NsurB.jpg"
|
||||||
|
background_color: "#ffffff"
|
||||||
|
theme_color: "#0175C2"
|
||||||
|
windows:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/EM2_NsurB.jpg"
|
||||||
|
icon_size: 48
|
||||||
|
macos:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/EM2_NsurB.jpg"
|
||||||
|
linux:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/EM2_NsurB.jpg"
|
||||||
|
|
||||||
28
em2rp/functions/.eslintrc.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
es6: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
"ecmaVersion": 2018,
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"eslint:recommended",
|
||||||
|
"google",
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-globals": ["error", "name", "length"],
|
||||||
|
"prefer-arrow-callback": "error",
|
||||||
|
"quotes": ["error", "double", {"allowTemplateLiterals": true}],
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ["**/*.spec.*"],
|
||||||
|
env: {
|
||||||
|
mocha: true,
|
||||||
|
},
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
globals: {},
|
||||||
|
};
|
||||||
4
em2rp/functions/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
267
em2rp/functions/createAlert.js
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
const {onRequest} = require('firebase-functions/v2/https');
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||||
|
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require('./utils/emailTemplates');
|
||||||
|
const auth = require('./utils/auth');
|
||||||
|
|
||||||
|
// Configuration CORS
|
||||||
|
const setCorsHeaders = (res, req) => {
|
||||||
|
// Utiliser l'origin de la requête pour permettre les credentials
|
||||||
|
const origin = req.headers.origin || '*';
|
||||||
|
|
||||||
|
res.set('Access-Control-Allow-Origin', origin);
|
||||||
|
|
||||||
|
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
|
||||||
|
if (origin !== '*') {
|
||||||
|
res.set('Access-Control-Allow-Credentials', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
|
||||||
|
res.set('Access-Control-Max-Age', '3600');
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCors = (handler) => {
|
||||||
|
return async (req, res) => {
|
||||||
|
setCorsHeaders(res, req);
|
||||||
|
// Gérer les requêtes preflight OPTIONS immédiatement
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.status(204).send('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await handler(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Unhandled error:", error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une alerte et envoie les notifications
|
||||||
|
* Gère tout le processus côté backend de A à Z
|
||||||
|
*/
|
||||||
|
exports.createAlert = onRequest({cors: false, invoker: 'public'}, withCors(async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Vérifier l'authentification
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const data = req.body.data || req.body;
|
||||||
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
severity,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
equipmentId,
|
||||||
|
eventId,
|
||||||
|
actionUrl,
|
||||||
|
metadata,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
// Validation des données
|
||||||
|
if (!type || !severity || !message) {
|
||||||
|
res.status(400).json({error: 'type, severity et message sont requis'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Déterminer les utilisateurs à notifier
|
||||||
|
const userIds = await determineTargetUsers(type, severity, eventId);
|
||||||
|
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
res.status(400).json({error: 'Aucun utilisateur à notifier'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Créer l'alerte dans Firestore
|
||||||
|
const alertRef = admin.firestore().collection('alerts').doc();
|
||||||
|
const alertData = {
|
||||||
|
id: alertRef.id,
|
||||||
|
type,
|
||||||
|
severity,
|
||||||
|
title: title || getAlertTitle(type),
|
||||||
|
message,
|
||||||
|
equipmentId: equipmentId || null,
|
||||||
|
eventId: eventId || null,
|
||||||
|
actionUrl: actionUrl || null,
|
||||||
|
metadata: metadata || {},
|
||||||
|
assignedTo: userIds,
|
||||||
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
createdBy: decodedToken.uid,
|
||||||
|
isRead: false,
|
||||||
|
emailSent: false,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
};
|
||||||
|
|
||||||
|
await alertRef.set(alertData);
|
||||||
|
|
||||||
|
// 3. Envoyer les emails si alerte critique
|
||||||
|
let emailResults = {};
|
||||||
|
if (severity === 'CRITICAL') {
|
||||||
|
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
|
||||||
|
|
||||||
|
// Mettre à jour le statut d'envoi
|
||||||
|
await alertRef.update({
|
||||||
|
emailSent: true,
|
||||||
|
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
emailResults,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
alertId: alertRef.id,
|
||||||
|
usersNotified: userIds.length,
|
||||||
|
emailsSent: Object.values(emailResults).filter((v) => v).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[createAlert] Erreur:', error);
|
||||||
|
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine les utilisateurs à notifier selon le type d'alerte
|
||||||
|
*/
|
||||||
|
async function determineTargetUsers(alertType, severity, eventId) {
|
||||||
|
const db = admin.firestore();
|
||||||
|
const targetUserIds = new Set();
|
||||||
|
|
||||||
|
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
|
||||||
|
const allUsersSnapshot = await db.collection('users').get();
|
||||||
|
|
||||||
|
allUsersSnapshot.forEach((doc) => {
|
||||||
|
const user = doc.data();
|
||||||
|
if (user.role) {
|
||||||
|
// Le rôle peut être une référence Firestore ou une string
|
||||||
|
let rolePath = '';
|
||||||
|
if (typeof user.role === 'string') {
|
||||||
|
rolePath = user.role;
|
||||||
|
} else if (user.role.path) {
|
||||||
|
rolePath = user.role.path;
|
||||||
|
} else if (user.role._path && user.role._path.segments) {
|
||||||
|
rolePath = user.role._path.segments.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si c'est un admin (path = "roles/ADMIN")
|
||||||
|
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
|
||||||
|
targetUserIds.add(doc.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Si un événement est lié, ajouter tous les membres de la workforce
|
||||||
|
if (eventId) {
|
||||||
|
try {
|
||||||
|
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||||
|
|
||||||
|
if (eventDoc.exists) {
|
||||||
|
const event = eventDoc.data();
|
||||||
|
const workforce = event.workforce || [];
|
||||||
|
|
||||||
|
workforce.forEach((member) => {
|
||||||
|
if (member.userId) {
|
||||||
|
targetUserIds.add(member.userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(targetUserIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie les emails d'alerte à tous les utilisateurs
|
||||||
|
*/
|
||||||
|
async function sendAlertEmails(alertId, alertData, userIds) {
|
||||||
|
const results = {};
|
||||||
|
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
||||||
|
|
||||||
|
// Envoyer les emails en parallèle (batch de 5)
|
||||||
|
const batches = [];
|
||||||
|
for (let i = 0; i < userIds.length; i += 5) {
|
||||||
|
batches.push(userIds.slice(i, i + 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
const promises = batch.map(async (userId) => {
|
||||||
|
try {
|
||||||
|
const sent = await sendSingleEmail(transporter, alertId, alertData, userId);
|
||||||
|
results[userId] = sent;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
|
||||||
|
results[userId] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un email à un utilisateur spécifique
|
||||||
|
*/
|
||||||
|
async function sendSingleEmail(transporter, alertId, alertData, userId) {
|
||||||
|
const db = admin.firestore();
|
||||||
|
|
||||||
|
// Récupérer l'utilisateur
|
||||||
|
const userDoc = await db.collection('users').doc(userId).get();
|
||||||
|
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userDoc.data();
|
||||||
|
|
||||||
|
// Vérifier les préférences email
|
||||||
|
const prefs = user.notificationPreferences || {};
|
||||||
|
if (!prefs.emailEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la préférence pour ce type d'alerte
|
||||||
|
if (!checkAlertPreference(alertData.type, prefs)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Préparer les données du template
|
||||||
|
const templateData = await prepareTemplateData(alertData, user);
|
||||||
|
|
||||||
|
// Rendre le template
|
||||||
|
const html = await renderTemplate('alert-individual', templateData);
|
||||||
|
|
||||||
|
// Envoyer l'email
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||||
|
to: user.email,
|
||||||
|
replyTo: EMAIL_CONFIG.replyTo,
|
||||||
|
subject: getEmailSubject(alertData),
|
||||||
|
html: html,
|
||||||
|
text: alertData.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendSingleEmail] Erreur envoi à ${userId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
3892
em2rp/functions/index.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
@@ -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);
|
||||||
|
});
|
||||||
7093
em2rp/functions/package-lock.json
generated
Normal file
32
em2rp/functions/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "functions",
|
||||||
|
"description": "Cloud Functions for Firebase",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint .",
|
||||||
|
"serve": "firebase emulators:start --only functions",
|
||||||
|
"shell": "firebase functions:shell",
|
||||||
|
"start": "npm run shell",
|
||||||
|
"deploy": "firebase deploy --only functions",
|
||||||
|
"logs": "firebase functions:log"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "22"
|
||||||
|
},
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"@google-cloud/storage": "^7.18.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"envdot": "^0.0.3",
|
||||||
|
"firebase-admin": "^12.6.0",
|
||||||
|
"firebase-functions": "^7.0.3",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
|
"nodemailer": "^6.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^8.15.0",
|
||||||
|
"eslint-config-google": "^0.14.0",
|
||||||
|
"firebase-functions-test": "^3.1.0"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
415
em2rp/functions/processEquipmentValidation.js
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
const {onCall} = require('firebase-functions/v2/https');
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||||
|
/**
|
||||||
|
* Traite la validation du matériel d'un événement
|
||||||
|
* Appelée par le client lors du chargement/déchargement
|
||||||
|
* Crée automatiquement les alertes nécessaires
|
||||||
|
*/
|
||||||
|
exports.processEquipmentValidation = onCall({cors: true}, async (request) => {
|
||||||
|
try {
|
||||||
|
// L'authentification est automatique avec onCall
|
||||||
|
const {auth, data} = request;
|
||||||
|
|
||||||
|
if (!auth) {
|
||||||
|
throw new Error('L\'utilisateur doit être authentifié');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
eventId,
|
||||||
|
equipmentList, // [{equipmentId, status, quantity, etc.}]
|
||||||
|
validationType, // 'LOADING', 'UNLOADING', 'CHECK_OUT', 'CHECK_IN'
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!eventId || !equipmentList || !validationType) {
|
||||||
|
throw new Error('eventId, equipmentList et validationType sont requis');
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = admin.firestore();
|
||||||
|
const alerts = [];
|
||||||
|
|
||||||
|
// 1. Récupérer les détails de l'événement
|
||||||
|
const eventRef = db.collection('events').doc(eventId);
|
||||||
|
const eventDoc = await eventRef.get();
|
||||||
|
|
||||||
|
if (!eventDoc.exists) {
|
||||||
|
throw new Error('Événement introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = eventDoc.data();
|
||||||
|
const eventName = event.Name || event.name || 'Événement inconnu';
|
||||||
|
const eventDate = formatEventDate(event);
|
||||||
|
|
||||||
|
// 2. Analyser les équipements et détecter les problèmes
|
||||||
|
for (const equipment of equipmentList) {
|
||||||
|
const {equipmentId, status, quantity, expectedQuantity} = equipment;
|
||||||
|
|
||||||
|
// Cas 1: Équipement PERDU
|
||||||
|
if (status === 'LOST') {
|
||||||
|
const alertData = await createAlertInFirestore({
|
||||||
|
type: 'LOST',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
title: 'Équipement perdu',
|
||||||
|
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
|
||||||
|
equipmentId,
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
eventDate,
|
||||||
|
createdBy: auth.uid,
|
||||||
|
metadata: {
|
||||||
|
validationType,
|
||||||
|
equipment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alerts.push(alertData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas 2: Équipement MANQUANT
|
||||||
|
if (status === 'MISSING') {
|
||||||
|
const alertData = await createAlertInFirestore({
|
||||||
|
type: 'EQUIPMENT_MISSING',
|
||||||
|
severity: 'WARNING',
|
||||||
|
title: 'Équipement manquant',
|
||||||
|
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
|
||||||
|
equipmentId,
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
eventDate,
|
||||||
|
createdBy: auth.uid,
|
||||||
|
metadata: {
|
||||||
|
validationType,
|
||||||
|
equipment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alerts.push(alertData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas 3: Quantité incorrecte
|
||||||
|
if (expectedQuantity && quantity !== expectedQuantity) {
|
||||||
|
const alertData = await createAlertInFirestore({
|
||||||
|
type: 'QUANTITY_MISMATCH',
|
||||||
|
severity: 'INFO',
|
||||||
|
title: 'Quantité incorrecte',
|
||||||
|
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
|
||||||
|
equipmentId,
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
eventDate,
|
||||||
|
createdBy: auth.uid,
|
||||||
|
metadata: {
|
||||||
|
validationType,
|
||||||
|
equipment,
|
||||||
|
expected: expectedQuantity,
|
||||||
|
actual: quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alerts.push(alertData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cas 4: Équipement endommagé
|
||||||
|
if (status === 'DAMAGED') {
|
||||||
|
const alertData = await createAlertInFirestore({
|
||||||
|
type: 'DAMAGED',
|
||||||
|
severity: 'WARNING',
|
||||||
|
title: 'Équipement endommagé',
|
||||||
|
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
|
||||||
|
equipmentId,
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
eventDate,
|
||||||
|
createdBy: auth.uid,
|
||||||
|
metadata: {
|
||||||
|
validationType,
|
||||||
|
equipment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alerts.push(alertData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Mettre à jour les équipements de l'événement
|
||||||
|
await eventRef.update({
|
||||||
|
equipment: equipmentList,
|
||||||
|
lastValidation: {
|
||||||
|
type: validationType,
|
||||||
|
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
by: auth.uid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Envoyer les notifications pour les alertes critiques
|
||||||
|
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
|
||||||
|
if (criticalAlerts.length > 0) {
|
||||||
|
for (const alert of criticalAlerts) {
|
||||||
|
try {
|
||||||
|
await sendAlertNotifications(alert, eventId);
|
||||||
|
} catch (notificationError) {
|
||||||
|
logger.error(`[processEquipmentValidation] Erreur notification alerte ${alert.id}:`, notificationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
alertsCreated: alerts.length,
|
||||||
|
criticalAlertsCount: criticalAlerts.length,
|
||||||
|
alertIds: alerts.map((a) => a.id),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[processEquipmentValidation] Erreur:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une alerte dans Firestore
|
||||||
|
*/
|
||||||
|
async function createAlertInFirestore(alertData) {
|
||||||
|
const db = admin.firestore();
|
||||||
|
const alertRef = db.collection('alerts').doc();
|
||||||
|
|
||||||
|
const fullAlertData = {
|
||||||
|
id: alertRef.id,
|
||||||
|
...alertData,
|
||||||
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
isRead: false,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
emailSent: false,
|
||||||
|
assignedTo: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await alertRef.set(fullAlertData);
|
||||||
|
|
||||||
|
return {...fullAlertData, id: alertRef.id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine les utilisateurs à notifier et envoie les notifications
|
||||||
|
*/
|
||||||
|
async function sendAlertNotifications(alert, eventId) {
|
||||||
|
const db = admin.firestore();
|
||||||
|
const targetUserIds = new Set();
|
||||||
|
const usersWithPermission = new Set();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Récupérer TOUS les utilisateurs et leurs permissions
|
||||||
|
const allUsersSnapshot = await db.collection('users').get();
|
||||||
|
|
||||||
|
// Créer un map pour stocker les références de rôles à récupérer
|
||||||
|
const roleRefs = new Map();
|
||||||
|
|
||||||
|
for (const doc of allUsersSnapshot.docs) {
|
||||||
|
const user = doc.data();
|
||||||
|
|
||||||
|
if (!user.role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire le chemin du rôle
|
||||||
|
let rolePath = '';
|
||||||
|
let roleId = '';
|
||||||
|
|
||||||
|
if (typeof user.role === 'string') {
|
||||||
|
rolePath = user.role;
|
||||||
|
roleId = user.role.split('/').pop();
|
||||||
|
} else if (user.role.path) {
|
||||||
|
rolePath = user.role.path;
|
||||||
|
roleId = user.role.path.split('/').pop();
|
||||||
|
} else if (user.role._path && user.role._path.segments) {
|
||||||
|
rolePath = user.role._path.segments.join('/');
|
||||||
|
roleId = user.role._path.segments[user.role._path.segments.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleId && !roleRefs.has(roleId)) {
|
||||||
|
roleRefs.set(roleId, {users: [], rolePath});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleId) {
|
||||||
|
roleRefs.get(roleId).users.push(doc.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Récupérer les permissions de chaque rôle unique
|
||||||
|
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
|
||||||
|
try {
|
||||||
|
const roleDoc = await db.collection('roles').doc(roleId).get();
|
||||||
|
|
||||||
|
if (roleDoc.exists) {
|
||||||
|
const roleData = roleDoc.data();
|
||||||
|
const permissions = roleData.permissions || [];
|
||||||
|
|
||||||
|
// Vérifier si le rôle a la permission view_all_events
|
||||||
|
if (permissions.includes('view_all_events')) {
|
||||||
|
users.forEach((userId) => {
|
||||||
|
usersWithPermission.add(userId);
|
||||||
|
targetUserIds.add(userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendAlertNotifications] Erreur récupération rôle ${roleId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ajouter la workforce de l'événement
|
||||||
|
if (eventId) {
|
||||||
|
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||||
|
|
||||||
|
if (eventDoc.exists) {
|
||||||
|
const event = eventDoc.data();
|
||||||
|
const workforce = event.workforce || [];
|
||||||
|
|
||||||
|
workforce.forEach((member) => {
|
||||||
|
// Extraire l'userId selon différentes structures possibles
|
||||||
|
let userId = null;
|
||||||
|
|
||||||
|
if (typeof member === 'string') {
|
||||||
|
userId = member;
|
||||||
|
} else if (member.userId) {
|
||||||
|
userId = member.userId;
|
||||||
|
} else if (member.id) {
|
||||||
|
userId = member.id;
|
||||||
|
} else if (member.user) {
|
||||||
|
if (typeof member.user === 'string') {
|
||||||
|
userId = member.user;
|
||||||
|
} else if (member.user.id) {
|
||||||
|
userId = member.user.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
targetUserIds.add(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = Array.from(targetUserIds);
|
||||||
|
|
||||||
|
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
|
||||||
|
await db.collection('alerts').doc(alert.id).update({
|
||||||
|
assignedTo: userIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Envoyer les emails si alerte critique
|
||||||
|
if (alert.severity === 'CRITICAL') {
|
||||||
|
await sendAlertEmails(alert, userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userIds;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[sendAlertNotifications] Erreur:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie les emails d'alerte
|
||||||
|
*/
|
||||||
|
async function sendAlertEmails(alert, userIds) {
|
||||||
|
try {
|
||||||
|
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
|
||||||
|
const db = admin.firestore();
|
||||||
|
|
||||||
|
// Vérifier que EMAIL_CONFIG est disponible
|
||||||
|
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
|
||||||
|
logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
// Envoyer les emails par lots de 5
|
||||||
|
const batches = [];
|
||||||
|
for (let i = 0; i < userIds.length; i += 5) {
|
||||||
|
batches.push(userIds.slice(i, i + 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
const promises = batch.map(async (userId) => {
|
||||||
|
try {
|
||||||
|
// Récupérer l'utilisateur
|
||||||
|
const userDoc = await db.collection('users').doc(userId).get();
|
||||||
|
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userDoc.data();
|
||||||
|
|
||||||
|
// Vérifier les préférences email
|
||||||
|
const prefs = user.notificationPreferences || {};
|
||||||
|
if (!prefs.emailEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer et envoyer l'email
|
||||||
|
let html;
|
||||||
|
try {
|
||||||
|
const templateData = await prepareTemplateData(alert, user);
|
||||||
|
html = await renderTemplate('alert-individual', templateData);
|
||||||
|
} catch (templateError) {
|
||||||
|
logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError);
|
||||||
|
html = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>${alert.title || 'Nouvelle alerte'}</h2>
|
||||||
|
<p>${alert.message}</p>
|
||||||
|
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||||
|
to: user.email,
|
||||||
|
replyTo: EMAIL_CONFIG.replyTo,
|
||||||
|
subject: getEmailSubject(alert),
|
||||||
|
html: html,
|
||||||
|
text: alert.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
successCount += results.filter((r) => r).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour l'alerte
|
||||||
|
await db.collection('alerts').doc(alert.id).update({
|
||||||
|
emailSent: true,
|
||||||
|
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
emailsSentCount: successCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return successCount;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[sendAlertEmails] Erreur globale:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate la date d'un événement
|
||||||
|
*/
|
||||||
|
function formatEventDate(event) {
|
||||||
|
if (event.startDate) {
|
||||||
|
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate);
|
||||||
|
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'});
|
||||||
|
}
|
||||||
|
return 'Date inconnue';
|
||||||
|
}
|
||||||
|
|
||||||
277
em2rp/functions/sendAlertEmail.js
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
const functions = require('firebase-functions');
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const handlebars = require('handlebars');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un email d'alerte à un utilisateur
|
||||||
|
* Appelé par le client Dart via callable function
|
||||||
|
*/
|
||||||
|
exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
|
||||||
|
// Vérifier l'authentification
|
||||||
|
if (!context.auth) {
|
||||||
|
throw new functions.https.HttpsError(
|
||||||
|
'unauthenticated',
|
||||||
|
'L\'utilisateur doit être authentifié',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {alertId, userId, templateType} = data;
|
||||||
|
|
||||||
|
if (!alertId || !userId) {
|
||||||
|
throw new functions.https.HttpsError(
|
||||||
|
'invalid-argument',
|
||||||
|
'alertId et userId sont requis',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer l'alerte depuis Firestore
|
||||||
|
const alertDoc = await admin.firestore()
|
||||||
|
.collection('alerts')
|
||||||
|
.doc(alertId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!alertDoc.exists) {
|
||||||
|
throw new functions.https.HttpsError(
|
||||||
|
'not-found',
|
||||||
|
'Alerte introuvable',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alert = alertDoc.data();
|
||||||
|
|
||||||
|
// Récupérer l'utilisateur
|
||||||
|
const userDoc = await admin.firestore()
|
||||||
|
.collection('users')
|
||||||
|
.doc(userId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
throw new functions.https.HttpsError(
|
||||||
|
'not-found',
|
||||||
|
'Utilisateur introuvable',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userDoc.data();
|
||||||
|
|
||||||
|
// Vérifier les préférences email de l'utilisateur
|
||||||
|
const prefs = user.notificationPreferences || {};
|
||||||
|
if (!prefs.emailEnabled) {
|
||||||
|
console.log(`Email désactivé pour l'utilisateur ${userId}`);
|
||||||
|
return {success: true, skipped: true, reason: 'email_disabled'};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la préférence pour ce type d'alerte
|
||||||
|
const alertType = alert.type;
|
||||||
|
const shouldSend = checkAlertPreference(alertType, prefs);
|
||||||
|
if (!shouldSend) {
|
||||||
|
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
|
||||||
|
return {success: true, skipped: true, reason: 'alert_type_disabled'};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer les données pour le template
|
||||||
|
const templateData = await prepareTemplateData(alert, user);
|
||||||
|
|
||||||
|
// Rendre le template HTML
|
||||||
|
const html = await renderTemplate(
|
||||||
|
templateType || 'alert-individual',
|
||||||
|
templateData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Configurer le transporteur SMTP
|
||||||
|
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
||||||
|
|
||||||
|
// Envoyer l'email
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||||
|
to: user.email,
|
||||||
|
replyTo: EMAIL_CONFIG.replyTo,
|
||||||
|
subject: getEmailSubject(alert),
|
||||||
|
html: html,
|
||||||
|
// Fallback texte brut
|
||||||
|
text: alert.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Email envoyé:', info.messageId);
|
||||||
|
|
||||||
|
// Marquer l'email comme envoyé dans l'alerte
|
||||||
|
await alertDoc.ref.update({
|
||||||
|
emailSent: true,
|
||||||
|
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: info.messageId,
|
||||||
|
skipped: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur envoi email:', error);
|
||||||
|
throw new functions.https.HttpsError(
|
||||||
|
'internal',
|
||||||
|
`Erreur lors de l'envoi de l'email: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
|
||||||
|
*/
|
||||||
|
function checkAlertPreference(alertType, preferences) {
|
||||||
|
const typeMapping = {
|
||||||
|
'EVENT_CREATED': 'eventsNotifications',
|
||||||
|
'EVENT_MODIFIED': 'eventsNotifications',
|
||||||
|
'EVENT_CANCELLED': 'eventsNotifications',
|
||||||
|
'LOST': 'equipmentNotifications',
|
||||||
|
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
||||||
|
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
||||||
|
'STOCK_LOW': 'stockNotifications',
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefKey = typeMapping[alertType];
|
||||||
|
return prefKey ? (preferences[prefKey] !== false) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prépare les données pour le template
|
||||||
|
*/
|
||||||
|
async function prepareTemplateData(alert, user) {
|
||||||
|
const data = {
|
||||||
|
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||||
|
'Utilisateur',
|
||||||
|
alertTitle: getAlertTitle(alert.type),
|
||||||
|
alertMessage: alert.message,
|
||||||
|
isCritical: alert.severity === 'CRITICAL',
|
||||||
|
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
||||||
|
appUrl: EMAIL_CONFIG.appUrl,
|
||||||
|
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
subject: getEmailSubject(alert),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter des détails selon le type d'alerte
|
||||||
|
if (alert.eventId) {
|
||||||
|
try {
|
||||||
|
const eventDoc = await admin.firestore()
|
||||||
|
.collection('events')
|
||||||
|
.doc(alert.eventId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (eventDoc.exists) {
|
||||||
|
const event = eventDoc.data();
|
||||||
|
data.eventName = event.Name;
|
||||||
|
if (event.StartDateTime) {
|
||||||
|
const date = event.StartDateTime.toDate();
|
||||||
|
data.eventDate = date.toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur récupération événement:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alert.equipmentId) {
|
||||||
|
try {
|
||||||
|
const eqDoc = await admin.firestore()
|
||||||
|
.collection('equipments')
|
||||||
|
.doc(alert.equipmentId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (eqDoc.exists) {
|
||||||
|
data.equipmentName = eqDoc.data().name;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur récupération équipement:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le titre de l'email selon le type d'alerte
|
||||||
|
*/
|
||||||
|
function getEmailSubject(alert) {
|
||||||
|
const subjects = {
|
||||||
|
'EVENT_CREATED': '📅 Nouvel événement créé',
|
||||||
|
'EVENT_MODIFIED': '📝 Événement modifié',
|
||||||
|
'EVENT_CANCELLED': '❌ Événement annulé',
|
||||||
|
'LOST': '🔴 Alerte critique : Équipement perdu',
|
||||||
|
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
||||||
|
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
||||||
|
'STOCK_LOW': '📦 Stock faible',
|
||||||
|
};
|
||||||
|
|
||||||
|
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le titre pour le corps de l'email
|
||||||
|
*/
|
||||||
|
function getAlertTitle(type) {
|
||||||
|
const titles = {
|
||||||
|
'EVENT_CREATED': 'Nouvel événement créé',
|
||||||
|
'EVENT_MODIFIED': 'Événement modifié',
|
||||||
|
'EVENT_CANCELLED': 'Événement annulé',
|
||||||
|
'LOST': 'Équipement perdu',
|
||||||
|
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||||
|
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
||||||
|
'STOCK_LOW': 'Stock faible',
|
||||||
|
};
|
||||||
|
|
||||||
|
return titles[type] || 'Nouvelle alerte';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend un template HTML avec Handlebars
|
||||||
|
*/
|
||||||
|
async function renderTemplate(templateName, data) {
|
||||||
|
try {
|
||||||
|
// Lire le template de base
|
||||||
|
const basePath = path.join(__dirname, 'templates', 'base-template.html');
|
||||||
|
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
||||||
|
|
||||||
|
// Lire le template de contenu
|
||||||
|
const contentPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'templates',
|
||||||
|
`${templateName}.html`,
|
||||||
|
);
|
||||||
|
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
||||||
|
|
||||||
|
// Compiler les templates
|
||||||
|
const compileContent = handlebars.compile(contentTemplate);
|
||||||
|
const compileBase = handlebars.compile(baseTemplate);
|
||||||
|
|
||||||
|
// Rendre le contenu
|
||||||
|
const renderedContent = compileContent(data);
|
||||||
|
|
||||||
|
// Rendre le template de base avec le contenu
|
||||||
|
return compileBase({
|
||||||
|
...data,
|
||||||
|
content: renderedContent,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur rendu template:', error);
|
||||||
|
// Fallback vers un template simple
|
||||||
|
return `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>${data.alertTitle}</h2>
|
||||||
|
<p>${data.alertMessage}</p>
|
||||||
|
<a href="${data.actionUrl}">Voir l'alerte</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
267
em2rp/functions/sendDailyDigest.js
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Fonction schedulée : Envoie quotidienne d'un résumé des alertes non lues
|
||||||
|
* S'exécute tous les jours à 8h00 (Europe/Paris)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const { getSmtpConfig } = require('./utils/emailConfig');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fonction principale : envoie le digest quotidien
|
||||||
|
*/
|
||||||
|
async function sendDailyDigest() {
|
||||||
|
const db = admin.firestore();
|
||||||
|
|
||||||
|
logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Récupérer tous les utilisateurs avec email activé
|
||||||
|
const usersSnapshot = await db.collection('users').get();
|
||||||
|
const eligibleUsers = [];
|
||||||
|
|
||||||
|
usersSnapshot.forEach((doc) => {
|
||||||
|
const user = doc.data();
|
||||||
|
const prefs = user.notificationPreferences || {};
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur a activé les emails
|
||||||
|
if (prefs.emailEnabled !== false && user.email) {
|
||||||
|
eligibleUsers.push({
|
||||||
|
uid: doc.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName || 'Utilisateur',
|
||||||
|
lastName: user.lastName || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[sendDailyDigest] ${eligibleUsers.length} utilisateurs éligibles`);
|
||||||
|
|
||||||
|
// 2. Pour chaque utilisateur, récupérer ses alertes non lues des dernières 24h
|
||||||
|
const now = admin.firestore.Timestamp.now();
|
||||||
|
const yesterday = admin.firestore.Timestamp.fromMillis(now.toMillis() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||||
|
let emailsSent = 0;
|
||||||
|
|
||||||
|
for (const user of eligibleUsers) {
|
||||||
|
try {
|
||||||
|
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
|
||||||
|
const alertsSnapshot = await db.collection('alerts')
|
||||||
|
.where('assignedTo', 'array-contains', user.uid)
|
||||||
|
.where('isRead', '==', false)
|
||||||
|
.where('createdAt', '>=', yesterday)
|
||||||
|
.orderBy('createdAt', 'desc')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (alertsSnapshot.empty) {
|
||||||
|
continue; // Pas d'alertes non lues pour cet utilisateur
|
||||||
|
}
|
||||||
|
|
||||||
|
const alerts = [];
|
||||||
|
alertsSnapshot.forEach((doc) => {
|
||||||
|
alerts.push({ id: doc.id, ...doc.data() });
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
|
||||||
|
|
||||||
|
// 3. Envoyer l'email de digest
|
||||||
|
const sent = await sendDigestEmail(transporter, user, alerts);
|
||||||
|
if (sent) {
|
||||||
|
emailsSent++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendDailyDigest] Erreur pour ${user.email}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
|
||||||
|
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
|
||||||
|
|
||||||
|
return { success: true, emailsSent };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[sendDailyDigest] Erreur globale:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie l'email de digest à un utilisateur
|
||||||
|
*/
|
||||||
|
async function sendDigestEmail(transporter, user, alerts) {
|
||||||
|
try {
|
||||||
|
// Grouper les alertes par sévérité
|
||||||
|
const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL');
|
||||||
|
const warningAlerts = alerts.filter(a => a.severity === 'WARNING');
|
||||||
|
const infoAlerts = alerts.filter(a => a.severity === 'INFO');
|
||||||
|
|
||||||
|
// Construire le HTML
|
||||||
|
const html = buildDigestHtml(user, {
|
||||||
|
critical: criticalAlerts,
|
||||||
|
warning: warningAlerts,
|
||||||
|
info: infoAlerts,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer l'email
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"EM2RP Notifications" <${process.env.SMTP_USER}>`,
|
||||||
|
to: user.email,
|
||||||
|
subject: `📬 ${alerts.length} nouvelle(s) alerte(s) EM2RP`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[sendDigestEmail] ✓ Email envoyé à ${user.email}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[sendDigestEmail] Erreur pour ${user.email}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le HTML du digest
|
||||||
|
*/
|
||||||
|
function buildDigestHtml(user, alertsByType) {
|
||||||
|
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
|
||||||
|
|
||||||
|
let alertsHtml = '';
|
||||||
|
|
||||||
|
// Alertes critiques
|
||||||
|
if (alertsByType.critical.length > 0) {
|
||||||
|
alertsHtml += `
|
||||||
|
<div style="margin-bottom: 24px;">
|
||||||
|
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
|
||||||
|
🔴 Alertes critiques (${alertsByType.critical.length})
|
||||||
|
</h3>
|
||||||
|
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alertes warning
|
||||||
|
if (alertsByType.warning.length > 0) {
|
||||||
|
alertsHtml += `
|
||||||
|
<div style="margin-bottom: 24px;">
|
||||||
|
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
|
||||||
|
⚠️ Avertissements (${alertsByType.warning.length})
|
||||||
|
</h3>
|
||||||
|
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alertes info
|
||||||
|
if (alertsByType.info.length > 0) {
|
||||||
|
alertsHtml += `
|
||||||
|
<div style="margin-bottom: 24px;">
|
||||||
|
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
|
||||||
|
ℹ️ Informations (${alertsByType.info.length})
|
||||||
|
</h3>
|
||||||
|
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 32px; border-radius: 12px 12px 0 0; text-align: center;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 28px;">📬 Résumé quotidien</h1>
|
||||||
|
<p style="color: rgba(255,255,255,0.9); margin: 8px 0 0 0; font-size: 16px;">
|
||||||
|
Bonjour ${user.firstName},
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu -->
|
||||||
|
<div style="background-color: white; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||||
|
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px 0;">
|
||||||
|
Vous avez <strong>${totalAlerts} nouvelle(s) alerte(s)</strong> dans les dernières 24 heures.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${alertsHtml}
|
||||||
|
|
||||||
|
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb; text-align: center;">
|
||||||
|
<a href="https://app.em2event.fr/#/alerts"
|
||||||
|
style="display: inline-block; background-color: #667eea; color: white; padding: 12px 32px; text-decoration: none; border-radius: 8px; font-weight: 600;">
|
||||||
|
Voir toutes les alertes
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pied de page -->
|
||||||
|
<div style="text-align: center; padding: 24px; color: #6b7280; font-size: 14px;">
|
||||||
|
<p style="margin: 0 0 8px 0;">EM2RP - Gestion d'événements</p>
|
||||||
|
<p style="margin: 0;">
|
||||||
|
<a href="https://app.em2event.fr/#/settings" style="color: #667eea; text-decoration: none;">
|
||||||
|
Gérer mes préférences de notification
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate un item d'alerte pour l'email
|
||||||
|
*/
|
||||||
|
function formatAlertItem(alert) {
|
||||||
|
const date = alert.createdAt?.toDate ?
|
||||||
|
new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}) :
|
||||||
|
'Date inconnue';
|
||||||
|
|
||||||
|
// Type d'alerte en français
|
||||||
|
const typeLabels = {
|
||||||
|
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||||
|
'LOST': 'Équipement perdu',
|
||||||
|
'DAMAGED': 'Équipement endommagé',
|
||||||
|
'QUANTITY_MISMATCH': 'Écart de quantité',
|
||||||
|
'EVENT_CREATED': 'Événement créé',
|
||||||
|
'EVENT_MODIFIED': 'Événement modifié',
|
||||||
|
'WORKFORCE_ADDED': 'Ajout à la workforce',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabel = typeLabels[alert.type] || alert.type;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="background-color: #f9fafb; padding: 16px; border-radius: 8px; margin-bottom: 12px; border-left: 4px solid ${getSeverityColor(alert.severity)};">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
|
||||||
|
<strong style="color: #111827; font-size: 15px;">${typeLabel}</strong>
|
||||||
|
<span style="color: #6b7280; font-size: 13px;">${date}</span>
|
||||||
|
</div>
|
||||||
|
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
|
||||||
|
${alert.message || 'Aucun message'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la couleur selon la sévérité
|
||||||
|
*/
|
||||||
|
function getSeverityColor(severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case 'CRITICAL': return '#dc2626';
|
||||||
|
case 'WARNING': return '#f59e0b';
|
||||||
|
case 'INFO': return '#3b82f6';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendDailyDigest };
|
||||||
|
|
||||||
107
em2rp/functions/templates/alert-digest.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<div style="margin-bottom: 30px;">
|
||||||
|
<!-- En-tête du digest -->
|
||||||
|
<div style="margin-bottom: 25px;">
|
||||||
|
<h2 style="color: #111827; margin: 0 0 10px 0; font-size: 24px; font-weight: 600;">
|
||||||
|
📬 Votre résumé quotidien
|
||||||
|
</h2>
|
||||||
|
<p style="color: #6b7280; margin: 0; font-size: 14px;">
|
||||||
|
{{digestDate}} • {{alertCount}} nouvelle(s) alerte(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message d'introduction -->
|
||||||
|
<p style="color: #374151; margin: 0 0 30px 0; font-size: 16px; line-height: 1.6;">
|
||||||
|
Bonjour <strong>{{userName}}</strong>,<br>
|
||||||
|
Voici le récapitulatif de vos alertes des dernières 24 heures.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Liste des alertes -->
|
||||||
|
{{#each alerts}}
|
||||||
|
<div style="background-color: #f9fafb; border-left: 4px solid {{#if this.isCritical}}#DC2626{{else}}#3B82F6{{/if}}; padding: 20px; margin-bottom: 15px; border-radius: 4px;">
|
||||||
|
<!-- Badge type -->
|
||||||
|
<div style="display: inline-block; padding: 4px 12px; border-radius: 12px; margin-bottom: 10px; background-color: {{#if this.isCritical}}#FEE2E2{{else}}#DBEAFE{{/if}}; color: {{#if this.isCritical}}#991B1B{{else}}#1E40AF{{/if}}; font-size: 11px; font-weight: 600; text-transform: uppercase;">
|
||||||
|
{{this.typeLabel}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Titre de l'alerte -->
|
||||||
|
<h3 style="color: #111827; margin: 0 0 8px 0; font-size: 16px; font-weight: 600;">
|
||||||
|
{{this.title}}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<p style="color: #4b5563; margin: 0 0 12px 0; font-size: 14px; line-height: 1.5;">
|
||||||
|
{{this.message}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Contexte -->
|
||||||
|
{{#if this.context}}
|
||||||
|
<p style="color: #6b7280; margin: 0; font-size: 13px;">
|
||||||
|
<strong>Contexte :</strong> {{this.context}}
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<!-- Timestamp -->
|
||||||
|
<p style="color: #9ca3af; margin: 8px 0 0 0; font-size: 12px;">
|
||||||
|
🕐 {{this.timestamp}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<!-- Aucune alerte -->
|
||||||
|
{{#unless alerts}}
|
||||||
|
<div style="background-color: #f0fdf4; border: 1px solid #86efac; padding: 20px; margin-bottom: 20px; border-radius: 8px; text-align: center;">
|
||||||
|
<p style="color: #166534; margin: 0; font-size: 16px;">
|
||||||
|
✅ <strong>Aucune alerte aujourd'hui</strong><br>
|
||||||
|
<span style="font-size: 14px; color: #15803d;">Tout est en ordre !</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/unless}}
|
||||||
|
|
||||||
|
<!-- Bouton d'action principal -->
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 0 auto;">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 6px; background: #3B82F6;">
|
||||||
|
<a href="{{appUrl}}/alerts" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
|
||||||
|
Voir toutes mes alertes
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistiques -->
|
||||||
|
{{#if stats}}
|
||||||
|
<div style="margin-top: 30px; padding: 20px; background-color: #fef3c7; border-radius: 8px;">
|
||||||
|
<h3 style="color: #92400e; margin: 0 0 15px 0; font-size: 16px; font-weight: 600;">
|
||||||
|
📊 Vos statistiques
|
||||||
|
</h3>
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
|
||||||
|
<strong>Alertes non lues :</strong>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
|
||||||
|
{{stats.unreadCount}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
|
||||||
|
<strong>Événements en cours :</strong>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
|
||||||
|
{{stats.activeEvents}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<!-- Note de bas de page -->
|
||||||
|
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
|
||||||
|
💡 Ce résumé est envoyé quotidiennement à 8h. Vous pouvez modifier cette préférence dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
81
em2rp/functions/templates/alert-individual.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<div style="margin-bottom: 30px;">
|
||||||
|
<!-- Badge de sévérité -->
|
||||||
|
<div style="display: inline-block; padding: 8px 16px; border-radius: 20px; margin-bottom: 20px; {{#if isCritical}}background-color: #FEE2E2; color: #991B1B;{{else}}background-color: #FEF3C7; color: #92400E;{{/if}}">
|
||||||
|
<strong style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
{{#if isCritical}}🔴 Alerte Critique{{else}}⚠️ Attention{{/if}}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Titre -->
|
||||||
|
<h2 style="color: #111827; margin: 0 0 20px 0; font-size: 24px; font-weight: 600;">
|
||||||
|
{{alertTitle}}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<p style="color: #374151; margin: 0 0 25px 0; font-size: 16px; line-height: 1.6;">
|
||||||
|
{{alertMessage}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Détails de l'alerte -->
|
||||||
|
{{#if alertDetails}}
|
||||||
|
<div style="background-color: #f9fafb; border-left: 4px solid #3B82F6; padding: 16px; margin-bottom: 25px; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||||
|
<strong style="color: #374151;">Détails :</strong><br>
|
||||||
|
{{alertDetails}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<!-- Informations contextuelles -->
|
||||||
|
{{#if eventName}}
|
||||||
|
<table style="width: 100%; margin-bottom: 25px; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||||
|
<strong style="color: #374151;">Événement :</strong>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||||
|
{{eventName}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{#if eventDate}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||||
|
<strong style="color: #374151;">Date :</strong>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||||
|
{{eventDate}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{/if}}
|
||||||
|
{{#if equipmentName}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||||
|
<strong style="color: #374151;">Équipement :</strong>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||||
|
{{equipmentName}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{/if}}
|
||||||
|
</table>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<!-- Bouton d'action -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 6px; {{#if isCritical}}background: #DC2626;{{else}}background: #3B82F6;{{/if}}">
|
||||||
|
<a href="{{actionUrl}}" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
|
||||||
|
{{#if isCritical}}Voir l'alerte immédiatement{{else}}Consulter les détails{{/if}}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note de bas de page -->
|
||||||
|
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
|
||||||
|
💡 <strong>Astuce :</strong> Vous pouvez gérer vos préférences de notifications dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
65
em2rp/functions/templates/base-template.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>{{subject}}</title>
|
||||||
|
<style>
|
||||||
|
/* Reset styles */
|
||||||
|
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||||
|
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||||
|
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
|
||||||
|
body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.container { width: 100% !important; }
|
||||||
|
.content { padding: 20px !important; }
|
||||||
|
.button { width: 100% !important; display: block !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f3f4f6;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f3f4f6;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 0;">
|
||||||
|
<!-- Container -->
|
||||||
|
<table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background: linear-gradient(135deg, #1E3A8A 0%, #3B82F6 100%); padding: 30px; border-radius: 8px 8px 0 0;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: bold;">
|
||||||
|
EM2 Events
|
||||||
|
</h1>
|
||||||
|
<p style="color: #E0E7FF; margin: 8px 0 0 0; font-size: 14px;">
|
||||||
|
Gestion d'événements professionnelle
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td class="content" style="padding: 40px 30px;">
|
||||||
|
{{{content}}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0 0 15px 0; font-size: 13px; color: #6b7280; text-align: center;">
|
||||||
|
Cet email a été envoyé automatiquement par EM2 Events
|
||||||
|
</p>
|
||||||
|
<p style="margin: 15px 0 0 0; font-size: 11px; color: #9ca3af; text-align: center;">
|
||||||
|
© {{year}} EM2 Events. Tous droits réservés.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
165
em2rp/functions/utils/auth.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Utilitaires d'authentification et d'autorisation
|
||||||
|
*/
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie le token Firebase et retourne l'utilisateur
|
||||||
|
*/
|
||||||
|
async function authenticateUser(req) {
|
||||||
|
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) {
|
||||||
|
throw new Error('Unauthorized: No token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToken = req.headers.authorization.split('Bearer ')[1];
|
||||||
|
try {
|
||||||
|
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||||
|
return decodedToken;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error verifying Firebase ID token:", e);
|
||||||
|
throw new Error('Unauthorized: Invalid token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les données utilisateur depuis Firestore
|
||||||
|
*/
|
||||||
|
async function getUserData(uid) {
|
||||||
|
const userDoc = await admin.firestore().collection('users').doc(uid).get();
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { uid, ...userDoc.data() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les permissions d'un rôle
|
||||||
|
*/
|
||||||
|
async function getRolePermissions(roleRef) {
|
||||||
|
if (!roleRef) return [];
|
||||||
|
|
||||||
|
let roleId;
|
||||||
|
if (typeof roleRef === 'string') {
|
||||||
|
roleId = roleRef;
|
||||||
|
} else if (roleRef.id) {
|
||||||
|
roleId = roleRef.id;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleDoc = await admin.firestore().collection('roles').doc(roleId).get();
|
||||||
|
if (!roleDoc.exists) return [];
|
||||||
|
|
||||||
|
return roleDoc.data().permissions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur a une permission spécifique
|
||||||
|
*/
|
||||||
|
async function hasPermission(uid, requiredPermission) {
|
||||||
|
const userData = await getUserData(uid);
|
||||||
|
if (!userData) return false;
|
||||||
|
|
||||||
|
const permissions = await getRolePermissions(userData.role);
|
||||||
|
return permissions.includes(requiredPermission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur est admin
|
||||||
|
*/
|
||||||
|
async function isAdmin(uid) {
|
||||||
|
const userData = await getUserData(uid);
|
||||||
|
if (!userData) return false;
|
||||||
|
|
||||||
|
let roleId;
|
||||||
|
const roleField = userData.role;
|
||||||
|
if (typeof roleField === 'string') {
|
||||||
|
roleId = roleField;
|
||||||
|
} else if (roleField && roleField.id) {
|
||||||
|
roleId = roleField.id;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return roleId === 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur est assigné à un événement
|
||||||
|
*/
|
||||||
|
async function isAssignedToEvent(uid, eventId) {
|
||||||
|
const eventDoc = await admin.firestore().collection('events').doc(eventId).get();
|
||||||
|
if (!eventDoc.exists) return false;
|
||||||
|
|
||||||
|
const eventData = eventDoc.data();
|
||||||
|
const workforce = eventData.workforce || [];
|
||||||
|
|
||||||
|
// workforce contient des références DocumentReference
|
||||||
|
return workforce.some(ref => {
|
||||||
|
if (typeof ref === 'string') return ref === uid;
|
||||||
|
if (ref && ref.id) return ref.id === uid;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware d'authentification pour les Cloud Functions HTTP
|
||||||
|
*/
|
||||||
|
async function authMiddleware(req, res, next) {
|
||||||
|
try {
|
||||||
|
const decodedToken = await authenticateUser(req);
|
||||||
|
req.user = decodedToken;
|
||||||
|
req.uid = decodedToken.uid;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware de vérification de permission
|
||||||
|
*/
|
||||||
|
function requirePermission(permission) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const hasAccess = await hasPermission(req.uid, permission);
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({ error: `Forbidden: Requires permission '${permission}'` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(403).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware admin uniquement
|
||||||
|
*/
|
||||||
|
async function requireAdmin(req, res, next) {
|
||||||
|
try {
|
||||||
|
const adminAccess = await isAdmin(req.uid);
|
||||||
|
if (!adminAccess) {
|
||||||
|
res.status(403).json({ error: 'Forbidden: Admin access required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(403).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
authenticateUser,
|
||||||
|
getUserData,
|
||||||
|
getRolePermissions,
|
||||||
|
hasPermission,
|
||||||
|
isAdmin,
|
||||||
|
isAssignedToEvent,
|
||||||
|
authMiddleware,
|
||||||
|
requirePermission,
|
||||||
|
requireAdmin,
|
||||||
|
};
|
||||||
|
|
||||||
39
em2rp/functions/utils/emailConfig.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Configuration SMTP pour l'envoi d'emails
|
||||||
|
* Les credentials sont stockés dans les variables d'environnement
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Configuration SMTP depuis les variables d'environnement
|
||||||
|
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
|
||||||
|
const getSmtpConfig = () => {
|
||||||
|
return {
|
||||||
|
host: process.env.SMTP_HOST || 'mail.em2events.fr',
|
||||||
|
port: parseInt(process.env.SMTP_PORT || '465'),
|
||||||
|
secure: true, // true pour port 465, false pour autres ports
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER || 'notify@em2events.fr',
|
||||||
|
pass: process.env.SMTP_PASS || '',
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
// Ne pas échouer sur certificats invalides
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configuration email par défaut
|
||||||
|
const EMAIL_CONFIG = {
|
||||||
|
from: {
|
||||||
|
name: 'EM2 Events',
|
||||||
|
address: 'notify@em2events.fr',
|
||||||
|
},
|
||||||
|
replyTo: 'contact@em2events.fr',
|
||||||
|
// URL de l'application pour les liens
|
||||||
|
appUrl: process.env.APP_URL || 'https://em2rp-951dc.web.app',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getSmtpConfig,
|
||||||
|
EMAIL_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
177
em2rp/functions/utils/emailTemplates.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
const admin = require('firebase-admin');
|
||||||
|
const handlebars = require('handlebars');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const {EMAIL_CONFIG} = require('./emailConfig');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
|
||||||
|
*/
|
||||||
|
function checkAlertPreference(alertType, preferences) {
|
||||||
|
const typeMapping = {
|
||||||
|
'EVENT_CREATED': 'eventsNotifications',
|
||||||
|
'EVENT_MODIFIED': 'eventsNotifications',
|
||||||
|
'EVENT_CANCELLED': 'eventsNotifications',
|
||||||
|
'LOST': 'equipmentNotifications',
|
||||||
|
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
||||||
|
'DAMAGED': 'equipmentNotifications',
|
||||||
|
'QUANTITY_MISMATCH': 'equipmentNotifications',
|
||||||
|
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
||||||
|
'STOCK_LOW': 'stockNotifications',
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefKey = typeMapping[alertType];
|
||||||
|
return prefKey ? (preferences[prefKey] !== false) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prépare les données pour le template
|
||||||
|
*/
|
||||||
|
async function prepareTemplateData(alert, user) {
|
||||||
|
const data = {
|
||||||
|
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||||
|
'Utilisateur',
|
||||||
|
alertTitle: getAlertTitle(alert.type),
|
||||||
|
alertMessage: alert.message,
|
||||||
|
isCritical: alert.severity === 'CRITICAL',
|
||||||
|
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
||||||
|
appUrl: EMAIL_CONFIG.appUrl,
|
||||||
|
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
subject: getEmailSubject(alert),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter des détails selon le type d'alerte
|
||||||
|
if (alert.eventId) {
|
||||||
|
try {
|
||||||
|
const eventDoc = await admin.firestore()
|
||||||
|
.collection('events')
|
||||||
|
.doc(alert.eventId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (eventDoc.exists) {
|
||||||
|
const event = eventDoc.data();
|
||||||
|
data.eventName = event.Name || event.name || 'Événement';
|
||||||
|
if (event.StartDateTime || event.startDate) {
|
||||||
|
const dateField = event.StartDateTime || event.startDate;
|
||||||
|
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
|
||||||
|
data.eventDate = date.toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignorer silencieusement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alert.equipmentId) {
|
||||||
|
try {
|
||||||
|
const eqDoc = await admin.firestore()
|
||||||
|
.collection('equipments')
|
||||||
|
.doc(alert.equipmentId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (eqDoc.exists) {
|
||||||
|
data.equipmentName = eqDoc.data().name;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignorer silencieusement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le titre de l'email selon le type d'alerte
|
||||||
|
*/
|
||||||
|
function getEmailSubject(alert) {
|
||||||
|
const subjects = {
|
||||||
|
'EVENT_CREATED': '📅 Nouvel événement créé',
|
||||||
|
'EVENT_MODIFIED': '📝 Événement modifié',
|
||||||
|
'EVENT_CANCELLED': '❌ Événement annulé',
|
||||||
|
'LOST': '🔴 Alerte critique : Équipement perdu',
|
||||||
|
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
||||||
|
'DAMAGED': '⚠️ Équipement endommagé',
|
||||||
|
'QUANTITY_MISMATCH': 'ℹ️ Quantité incorrecte',
|
||||||
|
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
||||||
|
'STOCK_LOW': '📦 Stock faible',
|
||||||
|
};
|
||||||
|
|
||||||
|
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le titre pour le corps de l'email
|
||||||
|
*/
|
||||||
|
function getAlertTitle(type) {
|
||||||
|
const titles = {
|
||||||
|
'EVENT_CREATED': 'Nouvel événement créé',
|
||||||
|
'EVENT_MODIFIED': 'Événement modifié',
|
||||||
|
'EVENT_CANCELLED': 'Événement annulé',
|
||||||
|
'LOST': 'Équipement perdu',
|
||||||
|
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||||
|
'DAMAGED': 'Équipement endommagé',
|
||||||
|
'QUANTITY_MISMATCH': 'Quantité incorrecte',
|
||||||
|
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
||||||
|
'STOCK_LOW': 'Stock faible',
|
||||||
|
};
|
||||||
|
|
||||||
|
return titles[type] || 'Nouvelle alerte';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend un template HTML avec Handlebars
|
||||||
|
*/
|
||||||
|
async function renderTemplate(templateName, data) {
|
||||||
|
try {
|
||||||
|
// Lire le template de base
|
||||||
|
const basePath = path.join(__dirname, '..', 'templates', 'base-template.html');
|
||||||
|
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
||||||
|
|
||||||
|
// Lire le template de contenu
|
||||||
|
const contentPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'templates',
|
||||||
|
`${templateName}.html`,
|
||||||
|
);
|
||||||
|
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
||||||
|
|
||||||
|
// Compiler les templates
|
||||||
|
const compileContent = handlebars.compile(contentTemplate);
|
||||||
|
const compileBase = handlebars.compile(baseTemplate);
|
||||||
|
|
||||||
|
// Rendre le contenu
|
||||||
|
const renderedContent = compileContent(data);
|
||||||
|
|
||||||
|
// Rendre le template de base avec le contenu
|
||||||
|
return compileBase({
|
||||||
|
...data,
|
||||||
|
content: renderedContent,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback vers un template simple
|
||||||
|
return `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>${data.alertTitle}</h2>
|
||||||
|
<p>${data.alertMessage}</p>
|
||||||
|
<a href="${data.actionUrl}">Voir l'alerte</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkAlertPreference,
|
||||||
|
prepareTemplateData,
|
||||||
|
getEmailSubject,
|
||||||
|
getAlertTitle,
|
||||||
|
renderTemplate,
|
||||||
|
};
|
||||||
|
|
||||||
191
em2rp/functions/utils/helpers.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Helpers pour la manipulation de données Firestore
|
||||||
|
*/
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les Timestamps Firestore en ISO strings pour JSON
|
||||||
|
*/
|
||||||
|
function serializeTimestamps(data) {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
// Éviter la récursion sur les types Firestore spéciaux
|
||||||
|
if (data._firestore || data._path || data._converter) {
|
||||||
|
// C'est un objet Firestore interne, ne pas le traiter
|
||||||
|
if (data.id && data.path) {
|
||||||
|
// C'est une DocumentReference
|
||||||
|
return data.path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { ...data };
|
||||||
|
|
||||||
|
for (const key in result) {
|
||||||
|
const value = result[key];
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gérer les Timestamps Firestore
|
||||||
|
if (value.toDate && typeof value.toDate === 'function') {
|
||||||
|
result[key] = value.toDate().toISOString();
|
||||||
|
}
|
||||||
|
// Gérer les DocumentReference
|
||||||
|
else if (value.path && value.id && typeof value.path === 'string') {
|
||||||
|
result[key] = value.path;
|
||||||
|
}
|
||||||
|
// Gérer les GeoPoint
|
||||||
|
else if (value.latitude !== undefined && value.longitude !== undefined) {
|
||||||
|
result[key] = {
|
||||||
|
latitude: value.latitude,
|
||||||
|
longitude: value.longitude
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Gérer les tableaux
|
||||||
|
else if (Array.isArray(value)) {
|
||||||
|
result[key] = value.map(item => {
|
||||||
|
if (!item || typeof item !== 'object') return item;
|
||||||
|
|
||||||
|
// DocumentReference dans un tableau
|
||||||
|
if (item.path && item.id) {
|
||||||
|
return item.path;
|
||||||
|
}
|
||||||
|
// Timestamp dans un tableau
|
||||||
|
if (item.toDate && typeof item.toDate === 'function') {
|
||||||
|
return item.toDate().toISOString();
|
||||||
|
}
|
||||||
|
// Objet normal
|
||||||
|
return serializeTimestamps(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Gérer les objets imbriqués (mais pas les objets Firestore)
|
||||||
|
else if (typeof value === 'object' && !value._firestore && !value._path) {
|
||||||
|
result[key] = serializeTimestamps(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les ISO strings en Timestamps Firestore
|
||||||
|
*/
|
||||||
|
function deserializeTimestamps(data, timestampFields = []) {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
const result = { ...data };
|
||||||
|
|
||||||
|
for (const field of timestampFields) {
|
||||||
|
if (result[field] && typeof result[field] === 'string') {
|
||||||
|
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les références DocumentReference en IDs
|
||||||
|
*/
|
||||||
|
function serializeReferences(data) {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
const result = { ...data };
|
||||||
|
|
||||||
|
for (const key in result) {
|
||||||
|
if (result[key] && result[key].path && typeof result[key].path === 'string') {
|
||||||
|
// C'est une DocumentReference
|
||||||
|
result[key] = result[key].id;
|
||||||
|
} else if (Array.isArray(result[key])) {
|
||||||
|
result[key] = result[key].map(item => {
|
||||||
|
if (item && item.path && typeof item.path === 'string') {
|
||||||
|
return item.id;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masque les champs sensibles selon les permissions
|
||||||
|
*/
|
||||||
|
function maskSensitiveFields(data, canViewSensitive) {
|
||||||
|
if (canViewSensitive) return data;
|
||||||
|
|
||||||
|
const masked = { ...data };
|
||||||
|
|
||||||
|
// Masquer les prix si pas de permission manage_equipment
|
||||||
|
delete masked.purchasePrice;
|
||||||
|
delete masked.rentalPrice;
|
||||||
|
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination helper
|
||||||
|
*/
|
||||||
|
function paginate(query, limit = 50, startAfter = null) {
|
||||||
|
let paginatedQuery = query.limit(limit);
|
||||||
|
|
||||||
|
if (startAfter) {
|
||||||
|
paginatedQuery = paginatedQuery.startAfter(startAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginatedQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtre les événements annulés
|
||||||
|
*/
|
||||||
|
function filterCancelledEvents(events) {
|
||||||
|
return events.filter(event => event.status !== 'CANCELLED');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
|
||||||
|
* @param {Object} data - Données de l'événement
|
||||||
|
* @returns {Object} - Données avec DocumentReference
|
||||||
|
*/
|
||||||
|
function convertIdsToReferences(data) {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
const result = { ...data };
|
||||||
|
|
||||||
|
// Convertir EventType (ID → DocumentReference)
|
||||||
|
if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) {
|
||||||
|
result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir customer (ID → DocumentReference)
|
||||||
|
if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) {
|
||||||
|
result.customer = admin.firestore().collection('customers').doc(result.customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir workforce (IDs → DocumentReference)
|
||||||
|
if (Array.isArray(result.workforce)) {
|
||||||
|
result.workforce = result.workforce.map(item => {
|
||||||
|
if (typeof item === 'string' && !item.includes('/')) {
|
||||||
|
return admin.firestore().collection('users').doc(item);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
serializeTimestamps,
|
||||||
|
deserializeTimestamps,
|
||||||
|
serializeReferences,
|
||||||
|
maskSensitiveFields,
|
||||||
|
paginate,
|
||||||
|
filterCancelledEvents,
|
||||||
|
convertIdsToReferences,
|
||||||
|
};
|
||||||
|
|
||||||
14
em2rp/increment_version.bat
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@echo off
|
||||||
|
REM Script Windows pour incrémenter uniquement la version
|
||||||
|
|
||||||
|
echo Incrémentation de la version...
|
||||||
|
node scripts\increment_version.js
|
||||||
|
|
||||||
|
if %ERRORLEVEL% EQU 0 (
|
||||||
|
echo Version incrémentée avec succès!
|
||||||
|
) else (
|
||||||
|
echo Erreur lors de l'incrémentation
|
||||||
|
)
|
||||||
|
|
||||||
|
pause
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|||||||
@@ -1,122 +1 @@
|
|||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 703 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.0 KiB |
19
em2rp/lib/config/api_config.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/// Configuration de l'API backend
|
||||||
|
class ApiConfig {
|
||||||
|
// Mode développement : utilise les émulateurs locaux
|
||||||
|
static const bool isDevelopment = false; // false = utilise Cloud Functions prod
|
||||||
|
|
||||||
|
// URL de base pour les Cloud Functions
|
||||||
|
static const String productionUrl = 'https://us-central1-em2rp-951dc.cloudfunctions.net';
|
||||||
|
static const String developmentUrl = 'http://localhost:5001/em2rp-951dc/us-central1';
|
||||||
|
|
||||||
|
/// Retourne l'URL de base selon l'environnement
|
||||||
|
static String get baseUrl => isDevelopment ? developmentUrl : productionUrl;
|
||||||
|
|
||||||
|
/// Configuration du timeout
|
||||||
|
static const Duration requestTimeout = Duration(seconds: 30);
|
||||||
|
|
||||||
|
/// Nombre de tentatives en cas d'échec
|
||||||
|
static const int maxRetries = 3;
|
||||||
|
}
|
||||||
|
|
||||||
12
em2rp/lib/config/app_version.dart
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/// Configuration de la version de l'application
|
||||||
|
class AppVersion {
|
||||||
|
static const String version = '1.0.4';
|
||||||
|
|
||||||
|
/// Retourne la version complète de l'application
|
||||||
|
static String get fullVersion => 'v$version';
|
||||||
|
|
||||||
|
|
||||||
|
/// Retourne la version avec un préfixe personnalisé
|
||||||
|
static String getVersionWithPrefix(String prefix) => '$prefix $version';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,8 +3,7 @@ class Env {
|
|||||||
|
|
||||||
// Configuration de l'auto-login en développement
|
// Configuration de l'auto-login en développement
|
||||||
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
||||||
static const String devAdminPassword =
|
static const String devAdminPassword = 'Pastis51!';
|
||||||
"Azerty\$1!"; // À remplacer par le vrai mot de passe
|
|
||||||
|
|
||||||
// URLs et endpoints
|
// URLs et endpoints
|
||||||
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||||
@@ -15,3 +14,4 @@ class Env {
|
|||||||
// Autres configurations
|
// Autres configurations
|
||||||
static const int apiTimeout = 30000; // 30 secondes
|
static const int apiTimeout = 30000; // 30 secondes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
em2rp/lib/config/env.dev.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
class Env {
|
||||||
|
static const bool isDevelopment = true;
|
||||||
|
|
||||||
|
// Configuration de l'auto-login en développement
|
||||||
|
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
||||||
|
static const String devAdminPassword = 'Pastis51!';
|
||||||
|
|
||||||
|
// URLs et endpoints
|
||||||
|
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||||
|
|
||||||
|
// Configuration Firebase
|
||||||
|
static const String firebaseProjectId = 'em2rp-951dc';
|
||||||
|
|
||||||
|
// Autres configurations
|
||||||
|
static const int apiTimeout = 30000; // 30 secondes
|
||||||
|
}
|
||||||
|
|
||||||
488
em2rp/lib/controllers/event_form_controller.dart
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/models/event_type_model.dart';
|
||||||
|
import 'package:em2rp/models/user_model.dart';
|
||||||
|
import 'package:em2rp/services/event_form_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
|
|
||||||
|
class EventFormController extends ChangeNotifier {
|
||||||
|
// Controllers
|
||||||
|
final TextEditingController nameController = TextEditingController();
|
||||||
|
final TextEditingController descriptionController = TextEditingController();
|
||||||
|
final TextEditingController basePriceController = TextEditingController();
|
||||||
|
final TextEditingController installationController = TextEditingController();
|
||||||
|
final TextEditingController disassemblyController = TextEditingController();
|
||||||
|
final TextEditingController addressController = TextEditingController();
|
||||||
|
final TextEditingController jaugeController = TextEditingController();
|
||||||
|
final TextEditingController contactEmailController = TextEditingController();
|
||||||
|
final TextEditingController contactPhoneController = TextEditingController();
|
||||||
|
|
||||||
|
// State variables
|
||||||
|
DateTime? _startDateTime;
|
||||||
|
DateTime? _endDateTime;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
String? _success;
|
||||||
|
String? _selectedEventTypeId;
|
||||||
|
List<EventTypeModel> _eventTypes = [];
|
||||||
|
bool _isLoadingEventTypes = true;
|
||||||
|
List<String> _selectedUserIds = [];
|
||||||
|
List<UserModel> _allUsers = [];
|
||||||
|
bool _isLoadingUsers = true;
|
||||||
|
List<Map<String, String>> _uploadedFiles = [];
|
||||||
|
List<Map<String, dynamic>> _selectedOptions = [];
|
||||||
|
bool _formChanged = false;
|
||||||
|
EventStatus _selectedStatus = EventStatus.waitingForApproval;
|
||||||
|
List<EventEquipment> _assignedEquipment = [];
|
||||||
|
List<String> _assignedContainers = [];
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
DateTime? get startDateTime => _startDateTime;
|
||||||
|
DateTime? get endDateTime => _endDateTime;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get error => _error;
|
||||||
|
String? get success => _success;
|
||||||
|
String? get selectedEventTypeId => _selectedEventTypeId;
|
||||||
|
List<EventTypeModel> get eventTypes => _eventTypes;
|
||||||
|
bool get isLoadingEventTypes => _isLoadingEventTypes;
|
||||||
|
List<String> get selectedUserIds => _selectedUserIds;
|
||||||
|
List<UserModel> get allUsers => _allUsers;
|
||||||
|
bool get isLoadingUsers => _isLoadingUsers;
|
||||||
|
List<Map<String, String>> get uploadedFiles => _uploadedFiles;
|
||||||
|
List<Map<String, dynamic>> get selectedOptions => _selectedOptions;
|
||||||
|
List<EventEquipment> get assignedEquipment => _assignedEquipment;
|
||||||
|
List<String> get assignedContainers => _assignedContainers;
|
||||||
|
bool get formChanged => _formChanged;
|
||||||
|
EventStatus get selectedStatus => _selectedStatus;
|
||||||
|
|
||||||
|
EventFormController() {
|
||||||
|
_setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupListeners() {
|
||||||
|
nameController.addListener(_onAnyFieldChanged);
|
||||||
|
basePriceController.addListener(_onAnyFieldChanged);
|
||||||
|
installationController.addListener(_onAnyFieldChanged);
|
||||||
|
disassemblyController.addListener(_onAnyFieldChanged);
|
||||||
|
addressController.addListener(_onAnyFieldChanged);
|
||||||
|
descriptionController.addListener(_onAnyFieldChanged);
|
||||||
|
jaugeController.addListener(_onAnyFieldChanged);
|
||||||
|
contactEmailController.addListener(_onAnyFieldChanged);
|
||||||
|
contactPhoneController.addListener(_onAnyFieldChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAnyFieldChanged() {
|
||||||
|
if (!_formChanged) {
|
||||||
|
_formChanged = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> initialize({EventModel? existingEvent, DateTime? selectedDate}) async {
|
||||||
|
await Future.wait([
|
||||||
|
_fetchUsers(),
|
||||||
|
_fetchEventTypes(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (existingEvent != null) {
|
||||||
|
_populateFromEvent(existingEvent);
|
||||||
|
} else {
|
||||||
|
_selectedStatus = EventStatus.waitingForApproval;
|
||||||
|
|
||||||
|
// Préremplir les dates si une date est sélectionnée dans le calendrier
|
||||||
|
if (selectedDate != null) {
|
||||||
|
// Date de début : selectedDate à 20h00
|
||||||
|
_startDateTime = DateTime(
|
||||||
|
selectedDate.year,
|
||||||
|
selectedDate.month,
|
||||||
|
selectedDate.day,
|
||||||
|
20,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
// Date de fin : selectedDate + 4 heures
|
||||||
|
_endDateTime = _startDateTime!.add(const Duration(hours: 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _populateFromEvent(EventModel event) {
|
||||||
|
nameController.text = event.name;
|
||||||
|
descriptionController.text = event.description;
|
||||||
|
basePriceController.text = event.basePrice.toStringAsFixed(2);
|
||||||
|
installationController.text = event.installationTime.toString();
|
||||||
|
disassemblyController.text = event.disassemblyTime.toString();
|
||||||
|
addressController.text = event.address;
|
||||||
|
jaugeController.text = event.jauge?.toString() ?? '';
|
||||||
|
contactEmailController.text = event.contactEmail ?? '';
|
||||||
|
contactPhoneController.text = event.contactPhone ?? '';
|
||||||
|
_startDateTime = event.startDateTime;
|
||||||
|
_endDateTime = event.endDateTime;
|
||||||
|
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
|
||||||
|
_assignedContainers = List<String>.from(event.assignedContainers);
|
||||||
|
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
||||||
|
|
||||||
|
// Gérer workforce qui peut contenir String ou DocumentReference
|
||||||
|
_selectedUserIds = event.workforce.map((ref) {
|
||||||
|
if (ref is String) return ref;
|
||||||
|
if (ref is DocumentReference) return ref.id;
|
||||||
|
return '';
|
||||||
|
}).where((id) => id.isNotEmpty).toList();
|
||||||
|
|
||||||
|
_uploadedFiles = List<Map<String, String>>.from(event.documents);
|
||||||
|
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
|
||||||
|
_selectedStatus = event.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchUsers() async {
|
||||||
|
try {
|
||||||
|
_allUsers = await EventFormService.fetchUsers();
|
||||||
|
_isLoadingUsers = false;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoadingUsers = false;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchEventTypes() async {
|
||||||
|
try {
|
||||||
|
_eventTypes = await EventFormService.fetchEventTypes();
|
||||||
|
_isLoadingEventTypes = false;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoadingEventTypes = false;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setStartDateTime(DateTime? dateTime) {
|
||||||
|
_startDateTime = dateTime;
|
||||||
|
if (_endDateTime != null &&
|
||||||
|
dateTime != null &&
|
||||||
|
(_endDateTime!.isBefore(dateTime) || _endDateTime!.isAtSameMomentAs(dateTime))) {
|
||||||
|
_endDateTime = null;
|
||||||
|
}
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setEndDateTime(DateTime? dateTime) {
|
||||||
|
_endDateTime = dateTime;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onEventTypeChanged(String? newTypeId, BuildContext context) {
|
||||||
|
if (newTypeId == _selectedEventTypeId) return;
|
||||||
|
|
||||||
|
final oldEventTypeIndex = _selectedEventTypeId != null
|
||||||
|
? _eventTypes.indexWhere((et) => et.id == _selectedEventTypeId)
|
||||||
|
: -1;
|
||||||
|
final EventTypeModel? oldEventType = oldEventTypeIndex != -1 ? _eventTypes[oldEventTypeIndex] : null;
|
||||||
|
|
||||||
|
_selectedEventTypeId = newTypeId;
|
||||||
|
|
||||||
|
if (newTypeId != null) {
|
||||||
|
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
|
||||||
|
|
||||||
|
// Utiliser le prix par défaut du type d'événement (prix TTC stocké dans basePrice)
|
||||||
|
final defaultPriceTTC = selectedType.defaultPrice;
|
||||||
|
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
||||||
|
final oldDefaultPrice = oldEventType?.defaultPrice;
|
||||||
|
|
||||||
|
// Mettre à jour le prix TTC si le champ est vide ou si c'était l'ancien prix par défaut
|
||||||
|
if (basePriceController.text.isEmpty ||
|
||||||
|
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
|
||||||
|
basePriceController.text = defaultPriceTTC.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
||||||
|
final before = _selectedOptions.length;
|
||||||
|
_selectedOptions.removeWhere((opt) {
|
||||||
|
// Vérifier si cette option est compatible avec le type d'événement sélectionné
|
||||||
|
final optionEventTypes = opt['eventTypes'] as List<dynamic>? ?? [];
|
||||||
|
return !optionEventTypes.contains(selectedType.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_selectedOptions.length < before) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Certaines options ont été retirées car non compatibles avec "${selectedType.name}".')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_selectedOptions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSelectedUserIds(List<String> userIds) {
|
||||||
|
_selectedUserIds = userIds;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUploadedFiles(List<Map<String, String>> files) {
|
||||||
|
_uploadedFiles = files;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSelectedOptions(List<Map<String, dynamic>> options) {
|
||||||
|
_selectedOptions = options;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
||||||
|
_assignedEquipment = equipment;
|
||||||
|
_assignedContainers = containers;
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pickAndUploadFiles() async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(allowMultiple: true, withData: true);
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final files = await EventFormService.uploadFiles(result.files);
|
||||||
|
_uploadedFiles.addAll(files);
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Erreur lors de l\'upload : $e';
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validateForm() {
|
||||||
|
return nameController.text.isNotEmpty &&
|
||||||
|
_startDateTime != null &&
|
||||||
|
_endDateTime != null &&
|
||||||
|
_selectedEventTypeId != null &&
|
||||||
|
addressController.text.isNotEmpty &&
|
||||||
|
(_endDateTime!.isAfter(_startDateTime!));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> submitForm(BuildContext context, {EventModel? existingEvent}) async {
|
||||||
|
if (!validateForm()) {
|
||||||
|
_error = "Veuillez remplir tous les champs obligatoires.";
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
_success = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final eventTypeRef = _selectedEventTypeId != null
|
||||||
|
? null // Les références Firestore ne sont plus nécessaires, l'ID suffit
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (existingEvent != null) {
|
||||||
|
// Mode édition
|
||||||
|
// Gérer les nouveaux fichiers uploadés s'il y en a
|
||||||
|
List<Map<String, String>> finalDocuments = List<Map<String, String>>.from(_uploadedFiles);
|
||||||
|
|
||||||
|
// Identifier les nouveaux fichiers (ceux qui ont une URL temp)
|
||||||
|
final newFiles = _uploadedFiles.where((file) =>
|
||||||
|
file['url']?.contains('events/temp/') ?? false).toList();
|
||||||
|
|
||||||
|
if (newFiles.isNotEmpty) {
|
||||||
|
// Déplacer les nouveaux fichiers vers le dossier de l'événement
|
||||||
|
final movedFiles = await EventFormService.moveFilesToEvent(newFiles, existingEvent.id);
|
||||||
|
|
||||||
|
// Remplacer les URLs temporaires par les nouvelles URLs
|
||||||
|
for (int i = 0; i < finalDocuments.length; i++) {
|
||||||
|
final tempFile = finalDocuments[i];
|
||||||
|
final movedFile = movedFiles.firstWhere(
|
||||||
|
(moved) => moved['name'] == tempFile['name'],
|
||||||
|
orElse: () => tempFile,
|
||||||
|
);
|
||||||
|
finalDocuments[i] = movedFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedEvent = EventModel(
|
||||||
|
id: existingEvent.id,
|
||||||
|
name: nameController.text.trim(),
|
||||||
|
description: descriptionController.text.trim(),
|
||||||
|
startDateTime: _startDateTime!,
|
||||||
|
endDateTime: _endDateTime!,
|
||||||
|
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
|
||||||
|
installationTime: int.tryParse(installationController.text) ?? 0,
|
||||||
|
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
|
||||||
|
eventTypeId: _selectedEventTypeId!,
|
||||||
|
eventTypeRef: eventTypeRef,
|
||||||
|
customerId: existingEvent.customerId,
|
||||||
|
address: addressController.text.trim(),
|
||||||
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
|
workforce: _selectedUserIds,
|
||||||
|
latitude: existingEvent.latitude,
|
||||||
|
longitude: existingEvent.longitude,
|
||||||
|
documents: finalDocuments,
|
||||||
|
options: _selectedOptions,
|
||||||
|
status: _selectedStatus,
|
||||||
|
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
|
||||||
|
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
|
||||||
|
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
|
||||||
|
assignedEquipment: _assignedEquipment,
|
||||||
|
assignedContainers: _assignedContainers,
|
||||||
|
preparationStatus: existingEvent.preparationStatus,
|
||||||
|
returnStatus: existingEvent.returnStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
await EventFormService.updateEvent(updatedEvent);
|
||||||
|
|
||||||
|
// Recharger les événements après modification
|
||||||
|
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
final userId = localUserProvider.uid;
|
||||||
|
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
||||||
|
|
||||||
|
if (userId != null) {
|
||||||
|
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
_success = "Événement modifié avec succès !";
|
||||||
|
} else {
|
||||||
|
// Mode création
|
||||||
|
final newEvent = EventModel(
|
||||||
|
id: '',
|
||||||
|
name: nameController.text.trim(),
|
||||||
|
description: descriptionController.text.trim(),
|
||||||
|
startDateTime: _startDateTime!,
|
||||||
|
endDateTime: _endDateTime!,
|
||||||
|
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
|
||||||
|
installationTime: int.tryParse(installationController.text) ?? 0,
|
||||||
|
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
|
||||||
|
eventTypeId: _selectedEventTypeId!,
|
||||||
|
eventTypeRef: eventTypeRef,
|
||||||
|
customerId: '',
|
||||||
|
address: addressController.text.trim(),
|
||||||
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
|
workforce: _selectedUserIds,
|
||||||
|
latitude: 0.0,
|
||||||
|
longitude: 0.0,
|
||||||
|
documents: _uploadedFiles,
|
||||||
|
options: _selectedOptions,
|
||||||
|
status: _selectedStatus,
|
||||||
|
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
|
||||||
|
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
|
||||||
|
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
|
||||||
|
assignedContainers: _assignedContainers,
|
||||||
|
assignedEquipment: _assignedEquipment,
|
||||||
|
);
|
||||||
|
|
||||||
|
final eventId = await EventFormService.createEvent(newEvent);
|
||||||
|
|
||||||
|
// Déplacer et mettre à jour les fichiers uniquement s'il y en a
|
||||||
|
if (_uploadedFiles.isNotEmpty) {
|
||||||
|
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
|
||||||
|
if (newFiles.isNotEmpty) {
|
||||||
|
await EventFormService.updateEventDocuments(eventId, newFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload events
|
||||||
|
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
final userId = localUserProvider.uid;
|
||||||
|
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
||||||
|
|
||||||
|
if (userId != null) {
|
||||||
|
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
_success = "Événement créé avec succès !";
|
||||||
|
}
|
||||||
|
|
||||||
|
_formChanged = false;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_error = "Erreur lors de la sauvegarde : $e";
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> deleteEvent(BuildContext context, String eventId) async {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
_success = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Supprimer l'événement via l'API
|
||||||
|
final dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
await dataService.deleteEvent(eventId);
|
||||||
|
|
||||||
|
// Recharger la liste des événements
|
||||||
|
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
final userId = localUserProvider.uid;
|
||||||
|
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
||||||
|
|
||||||
|
if (userId != null) {
|
||||||
|
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
_success = "Événement supprimé avec succès !";
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_error = "Erreur lors de la suppression : $e";
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearError() {
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSuccess() {
|
||||||
|
_success = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
nameController.dispose();
|
||||||
|
descriptionController.dispose();
|
||||||
|
basePriceController.dispose();
|
||||||
|
installationController.dispose();
|
||||||
|
disassemblyController.dispose();
|
||||||
|
addressController.dispose();
|
||||||
|
jaugeController.dispose();
|
||||||
|
contactEmailController.dispose();
|
||||||
|
contactPhoneController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
import 'package:em2rp/providers/users_provider.dart';
|
import 'package:em2rp/providers/users_provider.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
|
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||||
|
import 'package:em2rp/providers/alert_provider.dart';
|
||||||
import 'package:em2rp/utils/auth_guard_widget.dart';
|
import 'package:em2rp/utils/auth_guard_widget.dart';
|
||||||
|
import 'package:em2rp/views/alerts_page.dart';
|
||||||
import 'package:em2rp/views/calendar_page.dart';
|
import 'package:em2rp/views/calendar_page.dart';
|
||||||
import 'package:em2rp/views/login_page.dart';
|
import 'package:em2rp/views/login_page.dart';
|
||||||
|
import 'package:em2rp/views/equipment_management_page.dart';
|
||||||
|
import 'package:em2rp/views/container_management_page.dart';
|
||||||
|
import 'package:em2rp/views/container_form_page.dart';
|
||||||
|
import 'package:em2rp/views/container_detail_page.dart';
|
||||||
|
import 'package:em2rp/views/event_preparation_page.dart';
|
||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
@@ -12,36 +25,67 @@ import 'views/my_account_page.dart';
|
|||||||
import 'views/user_management_page.dart';
|
import 'views/user_management_page.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'providers/local_user_provider.dart';
|
import 'providers/local_user_provider.dart';
|
||||||
import 'services/user_service.dart';
|
import 'views/reset_password_page.dart';
|
||||||
import 'pages/auth/reset_password_page.dart';
|
|
||||||
import 'config/env.dart';
|
import 'config/env.dart';
|
||||||
|
import 'config/api_config.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'views/widgets/common/update_dialog.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Firebase.initializeApp(
|
await Firebase.initializeApp(
|
||||||
options: DefaultFirebaseOptions.currentPlatform,
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Configuration des émulateurs en mode développement
|
||||||
|
if (ApiConfig.isDevelopment) {
|
||||||
|
print('🔧 Mode développement activé - Utilisation des émulateurs');
|
||||||
|
|
||||||
|
// Configurer l'émulateur Auth
|
||||||
|
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
|
||||||
|
print('✓ Auth émulateur configuré: localhost:9199');
|
||||||
|
|
||||||
|
// Configurer l'émulateur Firestore
|
||||||
|
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
|
||||||
|
print('✓ Firestore émulateur configuré: localhost:8088');
|
||||||
|
}
|
||||||
|
|
||||||
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
|
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
// Injection du service UserService
|
|
||||||
Provider<UserService>(create: (_) => UserService()),
|
|
||||||
|
|
||||||
// LocalUserProvider pour la gestion de l'authentification
|
// LocalUserProvider pour la gestion de l'authentification
|
||||||
ChangeNotifierProvider<LocalUserProvider>(
|
ChangeNotifierProvider<LocalUserProvider>(
|
||||||
create: (context) => LocalUserProvider()),
|
create: (context) => LocalUserProvider()),
|
||||||
|
|
||||||
// Injection des Providers en utilisant UserService
|
// UsersProvider migré vers l'API
|
||||||
ChangeNotifierProvider<UsersProvider>(
|
ChangeNotifierProvider<UsersProvider>(
|
||||||
create: (context) => UsersProvider(context.read<UserService>()),
|
create: (context) => UsersProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// EventProvider pour la gestion des événements
|
// EventProvider migré vers l'API
|
||||||
ChangeNotifierProvider<EventProvider>(
|
ChangeNotifierProvider<EventProvider>(
|
||||||
create: (context) => EventProvider(),
|
create: (context) => EventProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// EquipmentProvider migré vers l'API
|
||||||
|
ChangeNotifierProvider<EquipmentProvider>(
|
||||||
|
create: (context) => EquipmentProvider(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ContainerProvider migré vers l'API
|
||||||
|
ChangeNotifierProvider<ContainerProvider>(
|
||||||
|
create: (context) => ContainerProvider(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// MaintenanceProvider migré vers l'API
|
||||||
|
ChangeNotifierProvider<MaintenanceProvider>(
|
||||||
|
create: (context) => MaintenanceProvider(),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<AlertProvider>(
|
||||||
|
create: (context) => AlertProvider(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: const MyApp(),
|
child: const MyApp(),
|
||||||
),
|
),
|
||||||
@@ -53,9 +97,9 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
print("test");
|
return UpdateChecker(
|
||||||
return MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'EM2 ERP',
|
title: 'EM2 Hub',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
primarySwatch: Colors.red,
|
primarySwatch: Colors.red,
|
||||||
primaryColor: AppColors.noir,
|
primaryColor: AppColors.noir,
|
||||||
@@ -81,13 +125,24 @@ class MyApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
home: const AutoLoginWrapper(),
|
locale: const Locale('fr', 'FR'),
|
||||||
|
supportedLocales: const [
|
||||||
|
Locale('fr', 'FR'),
|
||||||
|
],
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
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) =>
|
'/user_management': (context) => const AuthGuard(
|
||||||
const AuthGuard(requiredRole: "ADMIN", child: UserManagementPage()),
|
requiredPermission: "view_all_users", child: UserManagementPage()),
|
||||||
'/reset_password': (context) {
|
'/reset_password': (context) {
|
||||||
final args = ModalRoute.of(context)!.settings.arguments
|
final args = ModalRoute.of(context)!.settings.arguments
|
||||||
as Map<String, dynamic>;
|
as Map<String, dynamic>;
|
||||||
@@ -96,7 +151,39 @@ class MyApp extends StatelessWidget {
|
|||||||
actionCode: args['actionCode'] as String,
|
actionCode: args['actionCode'] as String,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
'/equipment_management': (context) => const AuthGuard(
|
||||||
|
requiredPermission: "view_equipment",
|
||||||
|
child: EquipmentManagementPage()),
|
||||||
|
'/container_management': (context) => const AuthGuard(
|
||||||
|
requiredPermission: "view_equipment",
|
||||||
|
child: ContainerManagementPage()),
|
||||||
|
'/container_form': (context) {
|
||||||
|
final args = ModalRoute.of(context)?.settings.arguments;
|
||||||
|
return AuthGuard(
|
||||||
|
requiredPermission: "manage_equipment",
|
||||||
|
child: ContainerFormPage(
|
||||||
|
container: args as ContainerModel?,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
'/container_detail': (context) {
|
||||||
|
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel;
|
||||||
|
return AuthGuard(
|
||||||
|
requiredPermission: "view_equipment",
|
||||||
|
child: ContainerDetailPage(container: container),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'/event_preparation': (context) {
|
||||||
|
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
|
||||||
|
final event = args['event'] as EventModel;
|
||||||
|
return AuthGuard(
|
||||||
|
child: EventPreparationPage(
|
||||||
|
initialEvent: event,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,8 +220,23 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
|||||||
await localAuthProvider.loadUserData();
|
await localAuthProvider.loadUserData();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
|
||||||
|
// En Flutter Web, on peut vérifier window.location.hash
|
||||||
|
final currentUri = Uri.base;
|
||||||
|
final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts
|
||||||
|
|
||||||
|
print('[AutoLoginWrapper] Fragment URL: $fragment');
|
||||||
|
|
||||||
|
// Si une route spécifique est demandée (autre que / ou vide)
|
||||||
|
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
||||||
|
print('[AutoLoginWrapper] Redirection vers: $fragment');
|
||||||
|
Navigator.of(context).pushReplacementNamed(fragment);
|
||||||
|
} else {
|
||||||
|
// Route par défaut : calendrier
|
||||||
|
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
|
||||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
Navigator.of(context).pushReplacementNamed('/calendar');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Auto login failed: $e');
|
print('Auto login failed: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
144
em2rp/lib/mixins/selection_mode_mixin.dart
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Mixin réutilisable pour gérer le mode sélection multiple
|
||||||
|
/// Utilisable dans equipment_management_page, container_management_page, etc.
|
||||||
|
mixin SelectionModeMixin<T extends StatefulWidget> on State<T> {
|
||||||
|
// État du mode sélection
|
||||||
|
bool _isSelectionMode = false;
|
||||||
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
bool get isSelectionMode => _isSelectionMode;
|
||||||
|
Set<String> get selectedIds => _selectedIds;
|
||||||
|
int get selectedCount => _selectedIds.length;
|
||||||
|
bool get hasSelection => _selectedIds.isNotEmpty;
|
||||||
|
|
||||||
|
/// Active/désactive le mode sélection
|
||||||
|
void toggleSelectionMode() {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = !_isSelectionMode;
|
||||||
|
if (!_isSelectionMode) {
|
||||||
|
_selectedIds.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active le mode sélection
|
||||||
|
void enableSelectionMode() {
|
||||||
|
if (!_isSelectionMode) {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Désactive le mode sélection et efface la sélection
|
||||||
|
void disableSelectionMode() {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = false;
|
||||||
|
_selectedIds.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle la sélection d'un item
|
||||||
|
void toggleItemSelection(String id) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedIds.contains(id)) {
|
||||||
|
_selectedIds.remove(id);
|
||||||
|
} else {
|
||||||
|
_selectedIds.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sélectionne un item
|
||||||
|
void selectItem(String id) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.add(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Désélectionne un item
|
||||||
|
void deselectItem(String id) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.remove(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un item est sélectionné
|
||||||
|
bool isItemSelected(String id) {
|
||||||
|
return _selectedIds.contains(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sélectionne tous les items
|
||||||
|
void selectAll(List<String> ids) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.addAll(ids);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Efface la sélection
|
||||||
|
void clearSelection() {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sélectionne/désélectionne tous les items
|
||||||
|
void toggleSelectAll(List<String> ids) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedIds.length == ids.length) {
|
||||||
|
// Tout est sélectionné, on désélectionne tout
|
||||||
|
_selectedIds.clear();
|
||||||
|
} else {
|
||||||
|
// Sélectionner tout
|
||||||
|
_selectedIds.addAll(ids);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour afficher le nombre d'éléments sélectionnés
|
||||||
|
Widget buildSelectionCounter({
|
||||||
|
required Color backgroundColor,
|
||||||
|
required Color textColor,
|
||||||
|
String? customText,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
customText ?? '$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AppBar pour le mode sélection
|
||||||
|
PreferredSizeWidget buildSelectionAppBar({
|
||||||
|
required String title,
|
||||||
|
required List<Widget> actions,
|
||||||
|
Color? backgroundColor,
|
||||||
|
}) {
|
||||||
|
return AppBar(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
onPressed: disableSelectionMode,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'$selectedCount $title sélectionné${selectedCount > 1 ? 's' : ''}',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
actions: actions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
273
em2rp/lib/models/alert_model.dart
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
/// Type d'alerte
|
||||||
|
enum AlertType {
|
||||||
|
lowStock, // Stock faible
|
||||||
|
maintenanceDue, // Maintenance à venir
|
||||||
|
conflict, // Conflit disponibilité
|
||||||
|
lost, // Équipement perdu
|
||||||
|
eventCreated, // Événement créé
|
||||||
|
eventModified, // Événement modifié
|
||||||
|
eventCancelled, // Événement annulé
|
||||||
|
eventAssigned, // Assigné à un événement
|
||||||
|
maintenanceReminder, // Rappel maintenance périodique
|
||||||
|
equipmentMissing, // Équipement manquant à une étape
|
||||||
|
quantityMismatch, // Quantité incorrecte
|
||||||
|
damaged, // Équipement endommagé
|
||||||
|
workforceAdded, // Ajouté à la workforce d'un événement
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gravité de l'alerte
|
||||||
|
enum AlertSeverity {
|
||||||
|
info, // Information (bleu)
|
||||||
|
warning, // Avertissement (orange)
|
||||||
|
critical, // Critique (rouge)
|
||||||
|
}
|
||||||
|
|
||||||
|
String alertTypeToString(AlertType type) {
|
||||||
|
switch (type) {
|
||||||
|
case AlertType.lowStock:
|
||||||
|
return 'LOW_STOCK';
|
||||||
|
case AlertType.maintenanceDue:
|
||||||
|
return 'MAINTENANCE_DUE';
|
||||||
|
case AlertType.conflict:
|
||||||
|
return 'CONFLICT';
|
||||||
|
case AlertType.lost:
|
||||||
|
return 'LOST';
|
||||||
|
case AlertType.eventCreated:
|
||||||
|
return 'EVENT_CREATED';
|
||||||
|
case AlertType.eventModified:
|
||||||
|
return 'EVENT_MODIFIED';
|
||||||
|
case AlertType.eventCancelled:
|
||||||
|
return 'EVENT_CANCELLED';
|
||||||
|
case AlertType.eventAssigned:
|
||||||
|
return 'EVENT_ASSIGNED';
|
||||||
|
case AlertType.maintenanceReminder:
|
||||||
|
return 'MAINTENANCE_REMINDER';
|
||||||
|
case AlertType.equipmentMissing:
|
||||||
|
return 'EQUIPMENT_MISSING';
|
||||||
|
case AlertType.quantityMismatch:
|
||||||
|
return 'QUANTITY_MISMATCH';
|
||||||
|
case AlertType.damaged:
|
||||||
|
return 'DAMAGED';
|
||||||
|
case AlertType.workforceAdded:
|
||||||
|
return 'WORKFORCE_ADDED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertType alertTypeFromString(String? type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'LOW_STOCK':
|
||||||
|
return AlertType.lowStock;
|
||||||
|
case 'MAINTENANCE_DUE':
|
||||||
|
return AlertType.maintenanceDue;
|
||||||
|
case 'CONFLICT':
|
||||||
|
return AlertType.conflict;
|
||||||
|
case 'LOST':
|
||||||
|
return AlertType.lost;
|
||||||
|
case 'EVENT_CREATED':
|
||||||
|
return AlertType.eventCreated;
|
||||||
|
case 'EVENT_MODIFIED':
|
||||||
|
return AlertType.eventModified;
|
||||||
|
case 'EVENT_CANCELLED':
|
||||||
|
return AlertType.eventCancelled;
|
||||||
|
case 'EVENT_ASSIGNED':
|
||||||
|
return AlertType.eventAssigned;
|
||||||
|
case 'MAINTENANCE_REMINDER':
|
||||||
|
return AlertType.maintenanceReminder;
|
||||||
|
case 'EQUIPMENT_MISSING':
|
||||||
|
return AlertType.equipmentMissing;
|
||||||
|
case 'QUANTITY_MISMATCH':
|
||||||
|
return AlertType.quantityMismatch;
|
||||||
|
case 'DAMAGED':
|
||||||
|
return AlertType.damaged;
|
||||||
|
case 'WORKFORCE_ADDED':
|
||||||
|
return AlertType.workforceAdded;
|
||||||
|
default:
|
||||||
|
return AlertType.conflict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String alertSeverityToString(AlertSeverity severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case AlertSeverity.info:
|
||||||
|
return 'INFO';
|
||||||
|
case AlertSeverity.warning:
|
||||||
|
return 'WARNING';
|
||||||
|
case AlertSeverity.critical:
|
||||||
|
return 'CRITICAL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertSeverity alertSeverityFromString(String? severity) {
|
||||||
|
switch (severity) {
|
||||||
|
case 'INFO':
|
||||||
|
return AlertSeverity.info;
|
||||||
|
case 'WARNING':
|
||||||
|
return AlertSeverity.warning;
|
||||||
|
case 'CRITICAL':
|
||||||
|
return AlertSeverity.critical;
|
||||||
|
default:
|
||||||
|
return AlertSeverity.info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlertModel {
|
||||||
|
final String id; // ID généré automatiquement
|
||||||
|
final AlertType type; // Type d'alerte
|
||||||
|
final AlertSeverity severity; // Gravité de l'alerte
|
||||||
|
final String message; // Message de l'alerte
|
||||||
|
final List<String> assignedToUserIds; // Utilisateurs concernés
|
||||||
|
final String? eventId; // ID de l'événement concerné (optionnel)
|
||||||
|
final String? equipmentId; // ID de l'équipement concerné (optionnel)
|
||||||
|
final String? createdByUserId; // Qui a déclenché l'alerte
|
||||||
|
final DateTime createdAt; // Date de création
|
||||||
|
final DateTime? dueDate; // Date d'échéance (pour maintenance)
|
||||||
|
final String? actionUrl; // URL de redirection (deep link)
|
||||||
|
final bool isRead; // Statut lu/non lu
|
||||||
|
final bool isResolved; // Résolue ou non
|
||||||
|
final String? resolution; // Message de résolution
|
||||||
|
final DateTime? resolvedAt; // Date de résolution
|
||||||
|
final String? resolvedByUserId; // Qui a résolu
|
||||||
|
|
||||||
|
AlertModel({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
this.severity = AlertSeverity.info,
|
||||||
|
required this.message,
|
||||||
|
this.assignedToUserIds = const [],
|
||||||
|
this.eventId,
|
||||||
|
this.equipmentId,
|
||||||
|
this.createdByUserId,
|
||||||
|
required this.createdAt,
|
||||||
|
this.dueDate,
|
||||||
|
this.actionUrl,
|
||||||
|
this.isRead = false,
|
||||||
|
this.isResolved = false,
|
||||||
|
this.resolution,
|
||||||
|
this.resolvedAt,
|
||||||
|
this.resolvedByUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime _parseDate(dynamic value) {
|
||||||
|
if (value == null) return DateTime.now();
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser les assignedToUserIds (peut être List ou null)
|
||||||
|
List<String> parseUserIds(dynamic value) {
|
||||||
|
if (value == null) return [];
|
||||||
|
if (value is List) return value.map((e) => e.toString()).toList();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertModel(
|
||||||
|
id: id,
|
||||||
|
type: alertTypeFromString(map['type']),
|
||||||
|
severity: alertSeverityFromString(map['severity']),
|
||||||
|
message: map['message'] ?? '',
|
||||||
|
assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']),
|
||||||
|
eventId: map['eventId'],
|
||||||
|
equipmentId: map['equipmentId'],
|
||||||
|
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
||||||
|
createdAt: _parseDate(map['createdAt']),
|
||||||
|
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
|
||||||
|
actionUrl: map['actionUrl'],
|
||||||
|
isRead: map['isRead'] ?? false,
|
||||||
|
isResolved: map['isResolved'] ?? false,
|
||||||
|
resolution: map['resolution'],
|
||||||
|
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null,
|
||||||
|
resolvedByUserId: map['resolvedByUserId'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory depuis un document Firestore
|
||||||
|
factory AlertModel.fromFirestore(DocumentSnapshot doc) {
|
||||||
|
final data = doc.data() as Map<String, dynamic>?;
|
||||||
|
if (data == null) {
|
||||||
|
throw Exception('Document vide: ${doc.id}');
|
||||||
|
}
|
||||||
|
return AlertModel.fromMap(data, doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'type': alertTypeToString(type),
|
||||||
|
'severity': alertSeverityToString(severity),
|
||||||
|
'message': message,
|
||||||
|
'assignedToUserIds': assignedToUserIds,
|
||||||
|
if (eventId != null) 'eventId': eventId,
|
||||||
|
if (equipmentId != null) 'equipmentId': equipmentId,
|
||||||
|
if (createdByUserId != null) 'createdByUserId': createdByUserId,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!),
|
||||||
|
if (actionUrl != null) 'actionUrl': actionUrl,
|
||||||
|
'isRead': isRead,
|
||||||
|
'isResolved': isResolved,
|
||||||
|
if (resolution != null) 'resolution': resolution,
|
||||||
|
if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!),
|
||||||
|
if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertModel copyWith({
|
||||||
|
String? id,
|
||||||
|
AlertType? type,
|
||||||
|
AlertSeverity? severity,
|
||||||
|
String? message,
|
||||||
|
List<String>? assignedToUserIds,
|
||||||
|
String? eventId,
|
||||||
|
String? equipmentId,
|
||||||
|
String? createdByUserId,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? dueDate,
|
||||||
|
String? actionUrl,
|
||||||
|
bool? isRead,
|
||||||
|
bool? isResolved,
|
||||||
|
String? resolution,
|
||||||
|
DateTime? resolvedAt,
|
||||||
|
String? resolvedByUserId,
|
||||||
|
}) {
|
||||||
|
return AlertModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
type: type ?? this.type,
|
||||||
|
severity: severity ?? this.severity,
|
||||||
|
message: message ?? this.message,
|
||||||
|
assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds,
|
||||||
|
eventId: eventId ?? this.eventId,
|
||||||
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
|
createdByUserId: createdByUserId ?? this.createdByUserId,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
dueDate: dueDate ?? this.dueDate,
|
||||||
|
actionUrl: actionUrl ?? this.actionUrl,
|
||||||
|
isRead: isRead ?? this.isRead,
|
||||||
|
isResolved: isResolved ?? this.isResolved,
|
||||||
|
resolution: resolution ?? this.resolution,
|
||||||
|
resolvedAt: resolvedAt ?? this.resolvedAt,
|
||||||
|
resolvedByUserId: resolvedByUserId ?? this.resolvedByUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper : Retourne true si l'alerte est pour un événement
|
||||||
|
bool get isEventAlert =>
|
||||||
|
type == AlertType.eventCreated ||
|
||||||
|
type == AlertType.eventModified ||
|
||||||
|
type == AlertType.eventCancelled ||
|
||||||
|
type == AlertType.eventAssigned;
|
||||||
|
|
||||||
|
/// Helper : Retourne true si l'alerte est pour la maintenance
|
||||||
|
bool get isMaintenanceAlert =>
|
||||||
|
type == AlertType.maintenanceDue ||
|
||||||
|
type == AlertType.maintenanceReminder;
|
||||||
|
|
||||||
|
/// Helper : Retourne true si l'alerte est pour un équipement
|
||||||
|
bool get isEquipmentAlert =>
|
||||||
|
type == AlertType.lost ||
|
||||||
|
type == AlertType.equipmentMissing ||
|
||||||
|
type == AlertType.lowStock;
|
||||||
|
}
|
||||||
|
|
||||||
382
em2rp/lib/models/container_model.dart
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
|
||||||
|
/// Type de container
|
||||||
|
enum ContainerType {
|
||||||
|
flightCase, // Flight case
|
||||||
|
pelicase, // Pelicase
|
||||||
|
bag, // Sac
|
||||||
|
openCrate, // Caisse ouverte
|
||||||
|
toolbox, // Boîte à outils
|
||||||
|
}
|
||||||
|
|
||||||
|
String containerTypeToString(ContainerType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ContainerType.flightCase:
|
||||||
|
return 'FLIGHT_CASE';
|
||||||
|
case ContainerType.pelicase:
|
||||||
|
return 'PELICASE';
|
||||||
|
case ContainerType.bag:
|
||||||
|
return 'BAG';
|
||||||
|
case ContainerType.openCrate:
|
||||||
|
return 'OPEN_CRATE';
|
||||||
|
case ContainerType.toolbox:
|
||||||
|
return 'TOOLBOX';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContainerType containerTypeFromString(String? type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'FLIGHT_CASE':
|
||||||
|
return ContainerType.flightCase;
|
||||||
|
case 'PELICASE':
|
||||||
|
return ContainerType.pelicase;
|
||||||
|
case 'BAG':
|
||||||
|
return ContainerType.bag;
|
||||||
|
case 'OPEN_CRATE':
|
||||||
|
return ContainerType.openCrate;
|
||||||
|
case 'TOOLBOX':
|
||||||
|
return ContainerType.toolbox;
|
||||||
|
default:
|
||||||
|
return ContainerType.flightCase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String containerTypeLabel(ContainerType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ContainerType.flightCase:
|
||||||
|
return 'Flight Case';
|
||||||
|
case ContainerType.pelicase:
|
||||||
|
return 'Pelicase';
|
||||||
|
case ContainerType.bag:
|
||||||
|
return 'Sac';
|
||||||
|
case ContainerType.openCrate:
|
||||||
|
return 'Caisse Ouverte';
|
||||||
|
case ContainerType.toolbox:
|
||||||
|
return 'Boîte à Outils';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extensions pour centraliser les informations d'affichage
|
||||||
|
extension ContainerTypeExtension on ContainerType {
|
||||||
|
/// Retourne le label français du type de container
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case ContainerType.flightCase:
|
||||||
|
return 'Flight Case';
|
||||||
|
case ContainerType.pelicase:
|
||||||
|
return 'Pelicase';
|
||||||
|
case ContainerType.bag:
|
||||||
|
return 'Sac';
|
||||||
|
case ContainerType.openCrate:
|
||||||
|
return 'Caisse Ouverte';
|
||||||
|
case ContainerType.toolbox:
|
||||||
|
return 'Boîte à Outils';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne l'icône Material du type de container
|
||||||
|
IconData get iconData {
|
||||||
|
switch (this) {
|
||||||
|
case ContainerType.flightCase:
|
||||||
|
return Icons.work;
|
||||||
|
case ContainerType.pelicase:
|
||||||
|
return Icons.work_outline;
|
||||||
|
case ContainerType.bag:
|
||||||
|
return Icons.shopping_bag;
|
||||||
|
case ContainerType.openCrate:
|
||||||
|
return Icons.inventory_2;
|
||||||
|
case ContainerType.toolbox:
|
||||||
|
return Icons.home_repair_service;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le chemin de l'icône personnalisée (si disponible)
|
||||||
|
String? get customIconPath {
|
||||||
|
switch (this) {
|
||||||
|
case ContainerType.flightCase:
|
||||||
|
return 'assets/icons/flight-case.svg';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si une icône personnalisée est disponible
|
||||||
|
bool get hasCustomIcon => customIconPath != null;
|
||||||
|
|
||||||
|
/// Retourne l'icône Widget à afficher (unifié pour Material et personnalisé)
|
||||||
|
Widget getIcon({double size = 24, Color? color}) {
|
||||||
|
final customPath = customIconPath;
|
||||||
|
if (customPath != null) {
|
||||||
|
// Détection automatique du format (SVG ou PNG)
|
||||||
|
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
// SVG : on peut appliquer la couleur sans dégrader la qualité
|
||||||
|
return SvgPicture.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter: color != null
|
||||||
|
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||||
|
: null,
|
||||||
|
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// PNG : on n'applique PAS le color filter pour préserver la qualité
|
||||||
|
return Image.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version pour CircleAvatar et contextes similaires
|
||||||
|
Widget getIconForAvatar({double size = 24, Color? color}) {
|
||||||
|
final customPath = customIconPath;
|
||||||
|
if (customPath != null) {
|
||||||
|
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
return SvgPicture.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter: color != null
|
||||||
|
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||||
|
: null,
|
||||||
|
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Image.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modèle de container/boîte pour le matériel
|
||||||
|
class ContainerModel {
|
||||||
|
final String id; // Identifiant unique (généré comme pour équipement)
|
||||||
|
final String name; // Nom du container
|
||||||
|
final ContainerType type; // Type de container
|
||||||
|
final EquipmentStatus status; // Statut actuel (même que équipement)
|
||||||
|
|
||||||
|
// Caractéristiques physiques
|
||||||
|
final double? weight; // Poids à vide (kg)
|
||||||
|
final double? length; // Longueur (cm)
|
||||||
|
final double? width; // Largeur (cm)
|
||||||
|
final double? height; // Hauteur (cm)
|
||||||
|
|
||||||
|
// Contenu
|
||||||
|
final List<String> equipmentIds; // IDs des équipements contenus
|
||||||
|
|
||||||
|
// Événement
|
||||||
|
final String? eventId; // ID de l'événement actuel (si en prestation)
|
||||||
|
|
||||||
|
// Métadonnées
|
||||||
|
final String? notes; // Notes additionnelles
|
||||||
|
final DateTime createdAt; // Date de création
|
||||||
|
final DateTime updatedAt; // Date de mise à jour
|
||||||
|
|
||||||
|
// Historique simple (optionnel)
|
||||||
|
final List<ContainerHistoryEntry> history; // Historique des modifications
|
||||||
|
|
||||||
|
ContainerModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
this.status = EquipmentStatus.available,
|
||||||
|
this.weight,
|
||||||
|
this.length,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.equipmentIds = const [],
|
||||||
|
this.eventId,
|
||||||
|
this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
this.history = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Vérifier si le container est vide
|
||||||
|
bool get isEmpty => equipmentIds.isEmpty;
|
||||||
|
|
||||||
|
/// Nombre d'équipements dans le container
|
||||||
|
int get itemCount => equipmentIds.length;
|
||||||
|
|
||||||
|
/// Calculer le volume (m³)
|
||||||
|
double? get volume {
|
||||||
|
if (length == null || width == null || height == null) return null;
|
||||||
|
return (length! * width! * height!) / 1000000; // cm³ to m³
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculer le poids total (poids vide + équipements)
|
||||||
|
/// Nécessite la liste des équipements
|
||||||
|
double calculateTotalWeight(List<EquipmentModel> equipment) {
|
||||||
|
double total = weight ?? 0.0;
|
||||||
|
for (final eq in equipment) {
|
||||||
|
if (equipmentIds.contains(eq.id) && eq.weight != null) {
|
||||||
|
total += eq.weight!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory depuis Firestore
|
||||||
|
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||||
|
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
|
final List<dynamic> historyRaw = map['history'] ?? [];
|
||||||
|
final List<ContainerHistoryEntry> history = historyRaw
|
||||||
|
.map((e) => ContainerHistoryEntry.fromMap(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ContainerModel(
|
||||||
|
id: id,
|
||||||
|
name: map['name'] ?? '',
|
||||||
|
type: containerTypeFromString(map['type']),
|
||||||
|
status: equipmentStatusFromString(map['status']),
|
||||||
|
weight: map['weight']?.toDouble(),
|
||||||
|
length: map['length']?.toDouble(),
|
||||||
|
width: map['width']?.toDouble(),
|
||||||
|
height: map['height']?.toDouble(),
|
||||||
|
equipmentIds: equipmentIds,
|
||||||
|
eventId: map['eventId'],
|
||||||
|
notes: map['notes'],
|
||||||
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
|
history: history,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertir en Map pour Firestore
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'type': containerTypeToString(type),
|
||||||
|
'status': equipmentStatusToString(status),
|
||||||
|
'weight': weight,
|
||||||
|
'length': length,
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'equipmentIds': equipmentIds,
|
||||||
|
'eventId': eventId,
|
||||||
|
'notes': notes,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||||
|
'history': history.map((e) => e.toMap()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copier avec modifications
|
||||||
|
ContainerModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
ContainerType? type,
|
||||||
|
EquipmentStatus? status,
|
||||||
|
double? weight,
|
||||||
|
double? length,
|
||||||
|
double? width,
|
||||||
|
double? height,
|
||||||
|
List<String>? equipmentIds,
|
||||||
|
String? eventId,
|
||||||
|
String? notes,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
List<ContainerHistoryEntry>? history,
|
||||||
|
}) {
|
||||||
|
return ContainerModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
type: type ?? this.type,
|
||||||
|
status: status ?? this.status,
|
||||||
|
weight: weight ?? this.weight,
|
||||||
|
length: length ?? this.length,
|
||||||
|
width: width ?? this.width,
|
||||||
|
height: height ?? this.height,
|
||||||
|
equipmentIds: equipmentIds ?? this.equipmentIds,
|
||||||
|
eventId: eventId ?? this.eventId,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
history: history ?? this.history,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entrée d'historique pour un container
|
||||||
|
class ContainerHistoryEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String action; // 'added', 'removed', 'status_change', etc.
|
||||||
|
final String? equipmentId; // ID de l'équipement concerné (si applicable)
|
||||||
|
final String? previousValue; // Valeur précédente
|
||||||
|
final String? newValue; // Nouvelle valeur
|
||||||
|
final String? userId; // ID de l'utilisateur ayant fait la modification
|
||||||
|
|
||||||
|
ContainerHistoryEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.action,
|
||||||
|
this.equipmentId,
|
||||||
|
this.previousValue,
|
||||||
|
this.newValue,
|
||||||
|
this.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
||||||
|
// Helper pour parser la date
|
||||||
|
DateTime _parseDate(dynamic value) {
|
||||||
|
if (value == null) return DateTime.now();
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContainerHistoryEntry(
|
||||||
|
timestamp: _parseDate(map['timestamp']),
|
||||||
|
action: map['action'] ?? '',
|
||||||
|
equipmentId: map['equipmentId'],
|
||||||
|
previousValue: map['previousValue'],
|
||||||
|
newValue: map['newValue'],
|
||||||
|
userId: map['userId'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'timestamp': Timestamp.fromDate(timestamp),
|
||||||
|
'action': action,
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'previousValue': previousValue,
|
||||||
|
'newValue': newValue,
|
||||||
|
'userId': userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
528
em2rp/lib/models/equipment_model.dart
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
|
enum EquipmentStatus {
|
||||||
|
available, // Disponible
|
||||||
|
inUse, // En prestation
|
||||||
|
rented, // Loué
|
||||||
|
lost, // Perdu
|
||||||
|
outOfService, // HS
|
||||||
|
maintenance, // En maintenance
|
||||||
|
}
|
||||||
|
|
||||||
|
String equipmentStatusToString(EquipmentStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case EquipmentStatus.available:
|
||||||
|
return 'AVAILABLE';
|
||||||
|
case EquipmentStatus.inUse:
|
||||||
|
return 'IN_USE';
|
||||||
|
case EquipmentStatus.rented:
|
||||||
|
return 'RENTED';
|
||||||
|
case EquipmentStatus.lost:
|
||||||
|
return 'LOST';
|
||||||
|
case EquipmentStatus.outOfService:
|
||||||
|
return 'OUT_OF_SERVICE';
|
||||||
|
case EquipmentStatus.maintenance:
|
||||||
|
return 'MAINTENANCE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentStatus equipmentStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'AVAILABLE':
|
||||||
|
return EquipmentStatus.available;
|
||||||
|
case 'IN_USE':
|
||||||
|
return EquipmentStatus.inUse;
|
||||||
|
case 'RENTED':
|
||||||
|
return EquipmentStatus.rented;
|
||||||
|
case 'LOST':
|
||||||
|
return EquipmentStatus.lost;
|
||||||
|
case 'OUT_OF_SERVICE':
|
||||||
|
return EquipmentStatus.outOfService;
|
||||||
|
case 'MAINTENANCE':
|
||||||
|
return EquipmentStatus.maintenance;
|
||||||
|
default:
|
||||||
|
return EquipmentStatus.available;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EquipmentCategory {
|
||||||
|
lighting, // Lumière
|
||||||
|
sound, // Son
|
||||||
|
video, // Vidéo
|
||||||
|
effect, // Effets spéciaux
|
||||||
|
structure, // Structure
|
||||||
|
consumable, // Consommable
|
||||||
|
cable, // Câble
|
||||||
|
vehicle, // Véhicule
|
||||||
|
backline, // Régie / Backline
|
||||||
|
other // Autre
|
||||||
|
}
|
||||||
|
|
||||||
|
String equipmentCategoryToString(EquipmentCategory category) {
|
||||||
|
switch (category) {
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
return 'LIGHTING';
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
return 'SOUND';
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
return 'VIDEO';
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return 'STRUCTURE';
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return 'CONSUMABLE';
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
return 'CABLE';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'VEHICLE';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'BACKLINE';
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return 'OTHER';
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
return 'EFFECT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentCategory equipmentCategoryFromString(String? category) {
|
||||||
|
switch (category) {
|
||||||
|
case 'LIGHTING':
|
||||||
|
return EquipmentCategory.lighting;
|
||||||
|
case 'SOUND':
|
||||||
|
return EquipmentCategory.sound;
|
||||||
|
case 'VIDEO':
|
||||||
|
return EquipmentCategory.video;
|
||||||
|
case 'STRUCTURE':
|
||||||
|
return EquipmentCategory.structure;
|
||||||
|
case 'CONSUMABLE':
|
||||||
|
return EquipmentCategory.consumable;
|
||||||
|
case 'CABLE':
|
||||||
|
return EquipmentCategory.cable;
|
||||||
|
case 'VEHICLE':
|
||||||
|
return EquipmentCategory.vehicle;
|
||||||
|
case 'BACKLINE':
|
||||||
|
return EquipmentCategory.backline;
|
||||||
|
case 'EFFECT':
|
||||||
|
return EquipmentCategory.effect;
|
||||||
|
case 'OTHER':
|
||||||
|
default:
|
||||||
|
return EquipmentCategory.other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extensions pour centraliser les informations d'affichage
|
||||||
|
extension EquipmentCategoryExtension on EquipmentCategory {
|
||||||
|
/// Retourne le label français de la catégorie
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
return 'Lumière';
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
return 'Son';
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
return 'Vidéo';
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
return 'Effets';
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return 'Structure';
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return 'Consommable';
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return 'Régie / Backline';
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return 'Autre';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne l'icône Material de la catégorie
|
||||||
|
IconData get iconData {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
return Icons.light_mode;
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
return Icons.volume_up;
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
return Icons.videocam;
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
return Icons.auto_awesome;
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return Icons.construction;
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return Icons.inventory_2;
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
return Icons.cable;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Icons.local_shipping;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Icons.piano;
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return Icons.more_horiz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la couleur associée à la catégorie
|
||||||
|
Color get color {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
return Colors.yellow.shade700;
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
return Colors.purple;
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
return Colors.blue;
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
return Colors.pink;
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return Colors.brown;
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return Colors.orange;
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
return Colors.grey;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Colors.teal;
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
return Colors.indigo;
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return Colors.blueGrey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le chemin de l'icône personnalisée (si disponible)
|
||||||
|
String? get customIconPath {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentCategory.structure:
|
||||||
|
return 'assets/icons/truss.svg';
|
||||||
|
case EquipmentCategory.consumable:
|
||||||
|
return 'assets/icons/tape.svg';
|
||||||
|
case EquipmentCategory.lighting:
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
case EquipmentCategory.backline:
|
||||||
|
case EquipmentCategory.other:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si une icône personnalisée est disponible
|
||||||
|
bool get hasCustomIcon => customIconPath != null;
|
||||||
|
|
||||||
|
/// Retourne l'icône Widget à afficher (unifié pour Material et personnalisé)
|
||||||
|
Widget getIcon({double size = 24, Color? color}) {
|
||||||
|
final customPath = customIconPath;
|
||||||
|
if (customPath != null) {
|
||||||
|
// Détection automatique du format (SVG ou PNG)
|
||||||
|
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
// SVG : on peut appliquer la couleur sans dégrader la qualité
|
||||||
|
return SvgPicture.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter: color != null
|
||||||
|
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||||
|
: null,
|
||||||
|
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// PNG : on n'applique PAS le color filter pour préserver la qualité
|
||||||
|
return Image.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version pour CircleAvatar et contextes similaires (sans ColorFilter si Material Icon)
|
||||||
|
Widget getIconForAvatar({double size = 24, Color? color}) {
|
||||||
|
final customPath = customIconPath;
|
||||||
|
if (customPath != null) {
|
||||||
|
final isSvg = customPath.toLowerCase().endsWith('.svg');
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
return SvgPicture.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter: color != null
|
||||||
|
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||||
|
: null,
|
||||||
|
placeholderBuilder: (context) => Icon(iconData, size: size, color: color),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Image.asset(
|
||||||
|
customPath,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Icon(iconData, size: size, color: color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension EquipmentStatusExtension on EquipmentStatus {
|
||||||
|
/// Retourne le label français du statut
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentStatus.available:
|
||||||
|
return 'Disponible';
|
||||||
|
case EquipmentStatus.inUse:
|
||||||
|
return 'En prestation';
|
||||||
|
case EquipmentStatus.rented:
|
||||||
|
return 'Loué';
|
||||||
|
case EquipmentStatus.lost:
|
||||||
|
return 'Perdu';
|
||||||
|
case EquipmentStatus.outOfService:
|
||||||
|
return 'HS';
|
||||||
|
case EquipmentStatus.maintenance:
|
||||||
|
return 'Maintenance';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la couleur associée au statut
|
||||||
|
Color get color {
|
||||||
|
switch (this) {
|
||||||
|
case EquipmentStatus.available:
|
||||||
|
return Colors.green;
|
||||||
|
case EquipmentStatus.inUse:
|
||||||
|
return Colors.blue;
|
||||||
|
case EquipmentStatus.rented:
|
||||||
|
return Colors.orange;
|
||||||
|
case EquipmentStatus.lost:
|
||||||
|
return Colors.red;
|
||||||
|
case EquipmentStatus.outOfService:
|
||||||
|
return Colors.red.shade900;
|
||||||
|
case EquipmentStatus.maintenance:
|
||||||
|
return Colors.amber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EquipmentModel {
|
||||||
|
final String id; // Identifiant unique (clé)
|
||||||
|
final String name; // Nom de l'équipement
|
||||||
|
final String? brand; // Marque (indexé)
|
||||||
|
final String? model; // Modèle (indexé)
|
||||||
|
final EquipmentCategory category; // Catégorie
|
||||||
|
final String? subCategory; // Sous-catégorie (indexé par catégorie)
|
||||||
|
final EquipmentStatus status; // Statut actuel
|
||||||
|
|
||||||
|
// Prix (visible uniquement avec manage_equipment)
|
||||||
|
final double? purchasePrice; // Prix d'achat
|
||||||
|
final double? rentalPrice; // Prix de location
|
||||||
|
|
||||||
|
// Quantité (pour consommables/câbles)
|
||||||
|
final int? totalQuantity; // Quantité totale
|
||||||
|
final int? availableQuantity; // Quantité disponible
|
||||||
|
final int? criticalThreshold; // Seuil critique pour alerte
|
||||||
|
|
||||||
|
|
||||||
|
// Caractéristiques physiques
|
||||||
|
final double? weight; // Poids (kg)
|
||||||
|
final double? length; // Longueur (cm)
|
||||||
|
final double? width; // Largeur (cm)
|
||||||
|
final double? height; // Hauteur (cm)
|
||||||
|
|
||||||
|
// Dates & maintenance
|
||||||
|
final DateTime? purchaseDate; // Date d'achat
|
||||||
|
final DateTime? lastMaintenanceDate; // Dernière maintenance
|
||||||
|
final DateTime? nextMaintenanceDate; // Prochaine maintenance prévue
|
||||||
|
|
||||||
|
// Maintenances (références)
|
||||||
|
final List<String> maintenanceIds; // IDs des opérations de maintenance
|
||||||
|
|
||||||
|
// Image
|
||||||
|
final String? imageUrl; // URL de l'image (Storage /materiel)
|
||||||
|
|
||||||
|
// Métadonnées
|
||||||
|
final String? notes; // Notes additionnelles
|
||||||
|
final DateTime createdAt; // Date de création
|
||||||
|
final DateTime updatedAt; // Date de mise à jour
|
||||||
|
|
||||||
|
EquipmentModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.brand,
|
||||||
|
this.model,
|
||||||
|
required this.category,
|
||||||
|
this.subCategory,
|
||||||
|
this.status = EquipmentStatus.available,
|
||||||
|
this.purchasePrice,
|
||||||
|
this.rentalPrice,
|
||||||
|
this.totalQuantity,
|
||||||
|
this.availableQuantity,
|
||||||
|
this.criticalThreshold,
|
||||||
|
this.weight,
|
||||||
|
this.length,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.purchaseDate,
|
||||||
|
this.lastMaintenanceDate,
|
||||||
|
this.nextMaintenanceDate,
|
||||||
|
this.maintenanceIds = const [],
|
||||||
|
this.imageUrl,
|
||||||
|
this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion des listes
|
||||||
|
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
|
||||||
|
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
|
return EquipmentModel(
|
||||||
|
id: id,
|
||||||
|
name: map['name'] ?? '',
|
||||||
|
brand: map['brand'],
|
||||||
|
model: map['model'],
|
||||||
|
category: equipmentCategoryFromString(map['category']),
|
||||||
|
subCategory: map['subCategory'],
|
||||||
|
status: equipmentStatusFromString(map['status']),
|
||||||
|
purchasePrice: map['purchasePrice']?.toDouble(),
|
||||||
|
rentalPrice: map['rentalPrice']?.toDouble(),
|
||||||
|
totalQuantity: map['totalQuantity']?.toInt(),
|
||||||
|
availableQuantity: map['availableQuantity']?.toInt(),
|
||||||
|
criticalThreshold: map['criticalThreshold']?.toInt(),
|
||||||
|
weight: map['weight']?.toDouble(),
|
||||||
|
length: map['length']?.toDouble(),
|
||||||
|
width: map['width']?.toDouble(),
|
||||||
|
height: map['height']?.toDouble(),
|
||||||
|
purchaseDate: _parseDate(map['purchaseDate']),
|
||||||
|
nextMaintenanceDate: _parseDate(map['nextMaintenanceDate']),
|
||||||
|
maintenanceIds: maintenanceIds,
|
||||||
|
imageUrl: map['imageUrl'],
|
||||||
|
notes: map['notes'],
|
||||||
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'brand': brand,
|
||||||
|
'model': model,
|
||||||
|
'category': equipmentCategoryToString(category),
|
||||||
|
'subCategory': subCategory,
|
||||||
|
'status': equipmentStatusToString(status),
|
||||||
|
'purchasePrice': purchasePrice,
|
||||||
|
'rentalPrice': rentalPrice,
|
||||||
|
'totalQuantity': totalQuantity,
|
||||||
|
'availableQuantity': availableQuantity,
|
||||||
|
'criticalThreshold': criticalThreshold,
|
||||||
|
'weight': weight,
|
||||||
|
'length': length,
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'lastMaintenanceDate': lastMaintenanceDate != null ? Timestamp.fromDate(lastMaintenanceDate!) : null,
|
||||||
|
'purchaseDate': purchaseDate != null ? Timestamp.fromDate(purchaseDate!) : null,
|
||||||
|
'nextMaintenanceDate': nextMaintenanceDate != null ? Timestamp.fromDate(nextMaintenanceDate!) : null,
|
||||||
|
'maintenanceIds': maintenanceIds,
|
||||||
|
'imageUrl': imageUrl,
|
||||||
|
'notes': notes,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? brand,
|
||||||
|
String? name,
|
||||||
|
String? model,
|
||||||
|
EquipmentCategory? category,
|
||||||
|
String? subCategory,
|
||||||
|
EquipmentStatus? status,
|
||||||
|
double? purchasePrice,
|
||||||
|
double? rentalPrice,
|
||||||
|
int? totalQuantity,
|
||||||
|
int? availableQuantity,
|
||||||
|
int? criticalThreshold,
|
||||||
|
double? weight,
|
||||||
|
double? length,
|
||||||
|
double? width,
|
||||||
|
double? height,
|
||||||
|
DateTime? purchaseDate,
|
||||||
|
DateTime? lastMaintenanceDate,
|
||||||
|
DateTime? nextMaintenanceDate,
|
||||||
|
List<String>? maintenanceIds,
|
||||||
|
String? imageUrl,
|
||||||
|
String? notes,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return EquipmentModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
brand: brand ?? this.brand,
|
||||||
|
name: name ?? this.name,
|
||||||
|
model: model ?? this.model,
|
||||||
|
category: category ?? this.category,
|
||||||
|
subCategory: subCategory ?? this.subCategory,
|
||||||
|
status: status ?? this.status,
|
||||||
|
purchasePrice: purchasePrice ?? this.purchasePrice,
|
||||||
|
rentalPrice: rentalPrice ?? this.rentalPrice,
|
||||||
|
totalQuantity: totalQuantity ?? this.totalQuantity,
|
||||||
|
availableQuantity: availableQuantity ?? this.availableQuantity,
|
||||||
|
criticalThreshold: criticalThreshold ?? this.criticalThreshold,
|
||||||
|
weight: weight ?? this.weight,
|
||||||
|
length: length ?? this.length,
|
||||||
|
width: width ?? this.width,
|
||||||
|
height: height ?? this.height,
|
||||||
|
lastMaintenanceDate: lastMaintenanceDate ?? this.lastMaintenanceDate,
|
||||||
|
purchaseDate: purchaseDate ?? this.purchaseDate,
|
||||||
|
nextMaintenanceDate: nextMaintenanceDate ?? this.nextMaintenanceDate,
|
||||||
|
maintenanceIds: maintenanceIds ?? this.maintenanceIds,
|
||||||
|
imageUrl: imageUrl ?? this.imageUrl,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper pour vérifier si c'est un consommable/câble avec quantité
|
||||||
|
bool get hasQuantity => category == EquipmentCategory.consumable || category == EquipmentCategory.cable;
|
||||||
|
|
||||||
|
// Helper pour vérifier si le stock est critique
|
||||||
|
bool get isCriticalStock {
|
||||||
|
if (!hasQuantity || criticalThreshold == null || availableQuantity == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return availableQuantity! <= criticalThreshold!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper pour vérifier si la maintenance est à venir
|
||||||
|
bool get isMaintenanceDue {
|
||||||
|
if (nextMaintenanceDate == null) return false;
|
||||||
|
return nextMaintenanceDate!.isBefore(DateTime.now().add(const Duration(days: 7)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,285 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
|
||||||
|
enum EventStatus {
|
||||||
|
confirmed,
|
||||||
|
canceled,
|
||||||
|
waitingForApproval,
|
||||||
|
}
|
||||||
|
|
||||||
|
String eventStatusToString(EventStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
return 'CONFIRMED';
|
||||||
|
case EventStatus.canceled:
|
||||||
|
return 'CANCELED';
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
return 'WAITING_FOR_APPROVAL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EventStatus eventStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'CONFIRMED':
|
||||||
|
return EventStatus.confirmed;
|
||||||
|
case 'CANCELED':
|
||||||
|
return EventStatus.canceled;
|
||||||
|
case 'WAITING_FOR_APPROVAL':
|
||||||
|
default:
|
||||||
|
return EventStatus.waitingForApproval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PreparationStatus {
|
||||||
|
notStarted,
|
||||||
|
inProgress,
|
||||||
|
completed,
|
||||||
|
completedWithMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
String preparationStatusToString(PreparationStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case PreparationStatus.notStarted:
|
||||||
|
return 'NOT_STARTED';
|
||||||
|
case PreparationStatus.inProgress:
|
||||||
|
return 'IN_PROGRESS';
|
||||||
|
case PreparationStatus.completed:
|
||||||
|
return 'COMPLETED';
|
||||||
|
case PreparationStatus.completedWithMissing:
|
||||||
|
return 'COMPLETED_WITH_MISSING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PreparationStatus preparationStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'NOT_STARTED':
|
||||||
|
return PreparationStatus.notStarted;
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return PreparationStatus.inProgress;
|
||||||
|
case 'COMPLETED':
|
||||||
|
return PreparationStatus.completed;
|
||||||
|
case 'COMPLETED_WITH_MISSING':
|
||||||
|
return PreparationStatus.completedWithMissing;
|
||||||
|
default:
|
||||||
|
return PreparationStatus.notStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statut de chargement (loading)
|
||||||
|
enum LoadingStatus {
|
||||||
|
notStarted,
|
||||||
|
inProgress,
|
||||||
|
completed,
|
||||||
|
completedWithMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
String loadingStatusToString(LoadingStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case LoadingStatus.notStarted:
|
||||||
|
return 'NOT_STARTED';
|
||||||
|
case LoadingStatus.inProgress:
|
||||||
|
return 'IN_PROGRESS';
|
||||||
|
case LoadingStatus.completed:
|
||||||
|
return 'COMPLETED';
|
||||||
|
case LoadingStatus.completedWithMissing:
|
||||||
|
return 'COMPLETED_WITH_MISSING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingStatus loadingStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'NOT_STARTED':
|
||||||
|
return LoadingStatus.notStarted;
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return LoadingStatus.inProgress;
|
||||||
|
case 'COMPLETED':
|
||||||
|
return LoadingStatus.completed;
|
||||||
|
case 'COMPLETED_WITH_MISSING':
|
||||||
|
return LoadingStatus.completedWithMissing;
|
||||||
|
default:
|
||||||
|
return LoadingStatus.notStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statut de déchargement (unloading)
|
||||||
|
enum UnloadingStatus {
|
||||||
|
notStarted,
|
||||||
|
inProgress,
|
||||||
|
completed,
|
||||||
|
completedWithMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
String unloadingStatusToString(UnloadingStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case UnloadingStatus.notStarted:
|
||||||
|
return 'NOT_STARTED';
|
||||||
|
case UnloadingStatus.inProgress:
|
||||||
|
return 'IN_PROGRESS';
|
||||||
|
case UnloadingStatus.completed:
|
||||||
|
return 'COMPLETED';
|
||||||
|
case UnloadingStatus.completedWithMissing:
|
||||||
|
return 'COMPLETED_WITH_MISSING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UnloadingStatus unloadingStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'NOT_STARTED':
|
||||||
|
return UnloadingStatus.notStarted;
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return UnloadingStatus.inProgress;
|
||||||
|
case 'COMPLETED':
|
||||||
|
return UnloadingStatus.completed;
|
||||||
|
case 'COMPLETED_WITH_MISSING':
|
||||||
|
return UnloadingStatus.completedWithMissing;
|
||||||
|
default:
|
||||||
|
return UnloadingStatus.notStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReturnStatus {
|
||||||
|
notStarted,
|
||||||
|
inProgress,
|
||||||
|
completed,
|
||||||
|
completedWithMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
String returnStatusToString(ReturnStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case ReturnStatus.notStarted:
|
||||||
|
return 'NOT_STARTED';
|
||||||
|
case ReturnStatus.inProgress:
|
||||||
|
return 'IN_PROGRESS';
|
||||||
|
case ReturnStatus.completed:
|
||||||
|
return 'COMPLETED';
|
||||||
|
case ReturnStatus.completedWithMissing:
|
||||||
|
return 'COMPLETED_WITH_MISSING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReturnStatus returnStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'NOT_STARTED':
|
||||||
|
return ReturnStatus.notStarted;
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return ReturnStatus.inProgress;
|
||||||
|
case 'COMPLETED':
|
||||||
|
return ReturnStatus.completed;
|
||||||
|
case 'COMPLETED_WITH_MISSING':
|
||||||
|
return ReturnStatus.completedWithMissing;
|
||||||
|
default:
|
||||||
|
return ReturnStatus.notStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventEquipment {
|
||||||
|
final String equipmentId; // ID de l'équipement
|
||||||
|
final int quantity; // Quantité initiale assignée
|
||||||
|
final bool isPrepared; // Validé en préparation
|
||||||
|
final bool isLoaded; // Validé au chargement
|
||||||
|
final bool isUnloaded; // Validé au déchargement
|
||||||
|
final bool isReturned; // Validé au retour
|
||||||
|
|
||||||
|
// Tracking des manquants à chaque étape
|
||||||
|
final bool isMissingAtPreparation; // Manquant à la préparation
|
||||||
|
final bool isMissingAtLoading; // Manquant au chargement
|
||||||
|
final bool isMissingAtUnloading; // Manquant au déchargement
|
||||||
|
final bool isMissingAtReturn; // Manquant au retour
|
||||||
|
|
||||||
|
// Quantités réelles à chaque étape (pour les quantifiables)
|
||||||
|
final int? quantityAtPreparation; // Quantité comptée en préparation
|
||||||
|
final int? quantityAtLoading; // Quantité comptée au chargement
|
||||||
|
final int? quantityAtUnloading; // Quantité comptée au déchargement
|
||||||
|
final int? quantityAtReturn; // Quantité retournée
|
||||||
|
|
||||||
|
EventEquipment({
|
||||||
|
required this.equipmentId,
|
||||||
|
this.quantity = 1,
|
||||||
|
this.isPrepared = false,
|
||||||
|
this.isLoaded = false,
|
||||||
|
this.isUnloaded = false,
|
||||||
|
this.isReturned = false,
|
||||||
|
this.isMissingAtPreparation = false,
|
||||||
|
this.isMissingAtLoading = false,
|
||||||
|
this.isMissingAtUnloading = false,
|
||||||
|
this.isMissingAtReturn = false,
|
||||||
|
this.quantityAtPreparation,
|
||||||
|
this.quantityAtLoading,
|
||||||
|
this.quantityAtUnloading,
|
||||||
|
this.quantityAtReturn,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventEquipment.fromMap(Map<String, dynamic> map) {
|
||||||
|
return EventEquipment(
|
||||||
|
equipmentId: map['equipmentId'] ?? '',
|
||||||
|
quantity: map['quantity'] ?? 1,
|
||||||
|
isPrepared: map['isPrepared'] ?? false,
|
||||||
|
isLoaded: map['isLoaded'] ?? false,
|
||||||
|
isUnloaded: map['isUnloaded'] ?? false,
|
||||||
|
isReturned: map['isReturned'] ?? false,
|
||||||
|
isMissingAtPreparation: map['isMissingAtPreparation'] ?? false,
|
||||||
|
isMissingAtLoading: map['isMissingAtLoading'] ?? false,
|
||||||
|
isMissingAtUnloading: map['isMissingAtUnloading'] ?? false,
|
||||||
|
isMissingAtReturn: map['isMissingAtReturn'] ?? false,
|
||||||
|
quantityAtPreparation: map['quantityAtPreparation'],
|
||||||
|
quantityAtLoading: map['quantityAtLoading'],
|
||||||
|
quantityAtUnloading: map['quantityAtUnloading'],
|
||||||
|
quantityAtReturn: map['quantityAtReturn'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'quantity': quantity,
|
||||||
|
'isPrepared': isPrepared,
|
||||||
|
'isLoaded': isLoaded,
|
||||||
|
'isUnloaded': isUnloaded,
|
||||||
|
'isReturned': isReturned,
|
||||||
|
'isMissingAtPreparation': isMissingAtPreparation,
|
||||||
|
'isMissingAtLoading': isMissingAtLoading,
|
||||||
|
'isMissingAtUnloading': isMissingAtUnloading,
|
||||||
|
'isMissingAtReturn': isMissingAtReturn,
|
||||||
|
'quantityAtPreparation': quantityAtPreparation,
|
||||||
|
'quantityAtLoading': quantityAtLoading,
|
||||||
|
'quantityAtUnloading': quantityAtUnloading,
|
||||||
|
'quantityAtReturn': quantityAtReturn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
EventEquipment copyWith({
|
||||||
|
String? equipmentId,
|
||||||
|
int? quantity,
|
||||||
|
bool? isPrepared,
|
||||||
|
bool? isLoaded,
|
||||||
|
bool? isUnloaded,
|
||||||
|
bool? isReturned,
|
||||||
|
bool? isMissingAtPreparation,
|
||||||
|
bool? isMissingAtLoading,
|
||||||
|
bool? isMissingAtUnloading,
|
||||||
|
bool? isMissingAtReturn,
|
||||||
|
int? quantityAtPreparation,
|
||||||
|
int? quantityAtLoading,
|
||||||
|
int? quantityAtUnloading,
|
||||||
|
int? quantityAtReturn,
|
||||||
|
}) {
|
||||||
|
return EventEquipment(
|
||||||
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
|
quantity: quantity ?? this.quantity,
|
||||||
|
isPrepared: isPrepared ?? this.isPrepared,
|
||||||
|
isLoaded: isLoaded ?? this.isLoaded,
|
||||||
|
isUnloaded: isUnloaded ?? this.isUnloaded,
|
||||||
|
isReturned: isReturned ?? this.isReturned,
|
||||||
|
isMissingAtPreparation: isMissingAtPreparation ?? this.isMissingAtPreparation,
|
||||||
|
isMissingAtLoading: isMissingAtLoading ?? this.isMissingAtLoading,
|
||||||
|
isMissingAtUnloading: isMissingAtUnloading ?? this.isMissingAtUnloading,
|
||||||
|
isMissingAtReturn: isMissingAtReturn ?? this.isMissingAtReturn,
|
||||||
|
quantityAtPreparation: quantityAtPreparation ?? this.quantityAtPreparation,
|
||||||
|
quantityAtLoading: quantityAtLoading ?? this.quantityAtLoading,
|
||||||
|
quantityAtUnloading: quantityAtUnloading ?? this.quantityAtUnloading,
|
||||||
|
quantityAtReturn: quantityAtReturn ?? this.quantityAtReturn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class EventModel {
|
class EventModel {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -7,13 +287,32 @@ class EventModel {
|
|||||||
final String description;
|
final String description;
|
||||||
final DateTime startDateTime;
|
final DateTime startDateTime;
|
||||||
final DateTime endDateTime;
|
final DateTime endDateTime;
|
||||||
final double price;
|
final double basePrice;
|
||||||
final int installationTime;
|
final int installationTime;
|
||||||
final int disassemblyTime;
|
final int disassemblyTime;
|
||||||
final String eventTypeId;
|
final String eventTypeId;
|
||||||
|
final DocumentReference? eventTypeRef;
|
||||||
final String customerId;
|
final String customerId;
|
||||||
final LatLng address;
|
final String address;
|
||||||
final List<String> workforce;
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final List<dynamic> workforce; // Peut contenir DocumentReference OU String (UIDs)
|
||||||
|
final List<Map<String, String>> documents;
|
||||||
|
final List<Map<String, dynamic>> options;
|
||||||
|
final EventStatus status;
|
||||||
|
|
||||||
|
// Champs de contact
|
||||||
|
final int? jauge;
|
||||||
|
final String? contactEmail;
|
||||||
|
final String? contactPhone;
|
||||||
|
|
||||||
|
// Nouveaux champs pour la gestion du matériel
|
||||||
|
final List<EventEquipment> assignedEquipment;
|
||||||
|
final List<String> assignedContainers; // IDs des conteneurs assignés
|
||||||
|
final PreparationStatus? preparationStatus;
|
||||||
|
final LoadingStatus? loadingStatus;
|
||||||
|
final UnloadingStatus? unloadingStatus;
|
||||||
|
final ReturnStatus? returnStatus;
|
||||||
|
|
||||||
EventModel({
|
EventModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -21,47 +320,210 @@ class EventModel {
|
|||||||
required this.description,
|
required this.description,
|
||||||
required this.startDateTime,
|
required this.startDateTime,
|
||||||
required this.endDateTime,
|
required this.endDateTime,
|
||||||
required this.price,
|
required this.basePrice,
|
||||||
required this.installationTime,
|
required this.installationTime,
|
||||||
required this.disassemblyTime,
|
required this.disassemblyTime,
|
||||||
required this.eventTypeId,
|
required this.eventTypeId,
|
||||||
|
this.eventTypeRef,
|
||||||
required this.customerId,
|
required this.customerId,
|
||||||
required this.address,
|
required this.address,
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
required this.workforce,
|
required this.workforce,
|
||||||
|
required this.documents,
|
||||||
|
this.options = const [],
|
||||||
|
this.status = EventStatus.waitingForApproval,
|
||||||
|
this.jauge,
|
||||||
|
this.contactEmail,
|
||||||
|
this.contactPhone,
|
||||||
|
this.assignedEquipment = const [],
|
||||||
|
this.assignedContainers = const [],
|
||||||
|
this.preparationStatus,
|
||||||
|
this.loadingStatus,
|
||||||
|
this.unloadingStatus,
|
||||||
|
this.returnStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
final GeoPoint? geoPoint = map['Address'] as GeoPoint?;
|
try {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime _parseDate(dynamic value, DateTime defaultValue) {
|
||||||
|
if (value == null) return defaultValue;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion sécurisée des références workforce
|
||||||
final List<dynamic> workforceRefs = map['workforce'] ?? [];
|
final List<dynamic> workforceRefs = map['workforce'] ?? [];
|
||||||
final Timestamp? startTimestamp = map['StartDateTime'] as Timestamp?;
|
final List<dynamic> safeWorkforce = [];
|
||||||
final Timestamp? endTimestamp = map['EndDateTime'] as Timestamp?;
|
|
||||||
|
for (var ref in workforceRefs) {
|
||||||
|
if (ref is DocumentReference) {
|
||||||
|
safeWorkforce.add(ref);
|
||||||
|
} else if (ref is String) {
|
||||||
|
// Accepter directement les UIDs (envoyés par le backend)
|
||||||
|
safeWorkforce.add(ref);
|
||||||
|
} else {
|
||||||
|
print('Warning: Invalid workforce reference in event $id: $ref');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion sécurisée des timestamps avec support ISO string
|
||||||
|
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
|
||||||
|
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
|
||||||
|
|
||||||
|
// Gestion sécurisée des documents
|
||||||
|
final docsRaw = map['documents'] ?? [];
|
||||||
|
final List<Map<String, String>> docs = [];
|
||||||
|
|
||||||
|
if (docsRaw is List) {
|
||||||
|
for (var e in docsRaw) {
|
||||||
|
try {
|
||||||
|
if (e is Map) {
|
||||||
|
docs.add(Map<String, String>.from(e));
|
||||||
|
} else if (e is String) {
|
||||||
|
final fileName = Uri.decodeComponent(
|
||||||
|
e.split('/').last.split('?').first,
|
||||||
|
);
|
||||||
|
docs.add({'name': fileName, 'url': e});
|
||||||
|
}
|
||||||
|
} catch (docError) {
|
||||||
|
print('Warning: Failed to parse document in event $id: $docError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion sécurisée des options
|
||||||
|
final optionsRaw = map['options'] ?? [];
|
||||||
|
final List<Map<String, dynamic>> options = [];
|
||||||
|
|
||||||
|
if (optionsRaw is List) {
|
||||||
|
for (var e in optionsRaw) {
|
||||||
|
try {
|
||||||
|
if (e is Map) {
|
||||||
|
options.add(Map<String, dynamic>.from(e));
|
||||||
|
}
|
||||||
|
} catch (optionError) {
|
||||||
|
print('Warning: Failed to parse option in event $id: $optionError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion sécurisée de l'EventType
|
||||||
|
String eventTypeId = '';
|
||||||
|
DocumentReference? eventTypeRef;
|
||||||
|
|
||||||
|
if (map['EventType'] is DocumentReference) {
|
||||||
|
eventTypeRef = map['EventType'] as DocumentReference;
|
||||||
|
eventTypeId = eventTypeRef.id;
|
||||||
|
} else if (map['EventType'] is String) {
|
||||||
|
final eventTypeString = map['EventType'] as String;
|
||||||
|
// Si c'est un path (ex: "eventTypes/Mariage"), extraire juste l'ID
|
||||||
|
if (eventTypeString.contains('/')) {
|
||||||
|
eventTypeId = eventTypeString.split('/').last;
|
||||||
|
} else {
|
||||||
|
eventTypeId = eventTypeString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion sécurisée du customer
|
||||||
|
String customerId = '';
|
||||||
|
if (map['customer'] is DocumentReference) {
|
||||||
|
customerId = (map['customer'] as DocumentReference).id;
|
||||||
|
} else if (map['customer'] is String) {
|
||||||
|
final customerString = map['customer'] as String;
|
||||||
|
// Si c'est un path (ex: "clients/abc123"), extraire juste l'ID
|
||||||
|
if (customerString.contains('/')) {
|
||||||
|
customerId = customerString.split('/').last;
|
||||||
|
} else {
|
||||||
|
customerId = customerString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion des équipements assignés
|
||||||
|
final assignedEquipmentRaw = map['assignedEquipment'] ?? [];
|
||||||
|
final List<EventEquipment> assignedEquipment = [];
|
||||||
|
|
||||||
|
if (assignedEquipmentRaw is List) {
|
||||||
|
for (var e in assignedEquipmentRaw) {
|
||||||
|
try {
|
||||||
|
if (e is Map) {
|
||||||
|
assignedEquipment.add(EventEquipment.fromMap(Map<String, dynamic>.from(e)));
|
||||||
|
}
|
||||||
|
} catch (equipmentError) {
|
||||||
|
print('Warning: Failed to parse equipment in event $id: $equipmentError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion des conteneurs assignés
|
||||||
|
final assignedContainersRaw = map['assignedContainers'] ?? [];
|
||||||
|
final List<String> assignedContainers = [];
|
||||||
|
|
||||||
|
if (assignedContainersRaw is List) {
|
||||||
|
for (var e in assignedContainersRaw) {
|
||||||
|
if (e is String) {
|
||||||
|
assignedContainers.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return EventModel(
|
return EventModel(
|
||||||
id: id,
|
id: id,
|
||||||
name: map['Name'] ?? '',
|
name: (map['Name'] ?? '').toString().trim(),
|
||||||
description: map['Description'] ?? '',
|
description: (map['Description'] ?? '').toString(),
|
||||||
startDateTime: startTimestamp?.toDate() ?? DateTime.now(),
|
startDateTime: startDate,
|
||||||
endDateTime: endTimestamp?.toDate() ??
|
endDateTime: endDate,
|
||||||
DateTime.now().add(const Duration(hours: 1)),
|
basePrice: _parseDouble(map['BasePrice'] ?? map['Price'] ?? 0.0),
|
||||||
price: (map['Price'] ?? 0.0).toDouble(),
|
installationTime: _parseInt(map['InstallationTime'] ?? 0),
|
||||||
installationTime: map['InstallationTime'] ?? 0,
|
assignedContainers: assignedContainers,
|
||||||
disassemblyTime: map['DisassemblyTime'] ?? 0,
|
disassemblyTime: _parseInt(map['DisassemblyTime'] ?? 0),
|
||||||
eventTypeId: map['EventType'] is DocumentReference
|
eventTypeId: eventTypeId,
|
||||||
? (map['EventType'] as DocumentReference).id
|
eventTypeRef: eventTypeRef,
|
||||||
: '',
|
customerId: customerId,
|
||||||
customerId: map['customer'] is DocumentReference
|
address: (map['Address'] ?? '').toString(),
|
||||||
? (map['customer'] as DocumentReference).id
|
latitude: _parseDouble(map['Latitude'] ?? 0.0),
|
||||||
: '',
|
longitude: _parseDouble(map['Longitude'] ?? 0.0),
|
||||||
address: geoPoint != null
|
workforce: safeWorkforce,
|
||||||
? LatLng(geoPoint.latitude, geoPoint.longitude)
|
documents: docs,
|
||||||
: const LatLng(0, 0),
|
options: options,
|
||||||
workforce: workforceRefs.map((ref) {
|
status: eventStatusFromString(map['status'] as String?),
|
||||||
if (ref is DocumentReference) {
|
jauge: map['jauge'] != null ? _parseInt(map['jauge']) : null,
|
||||||
return ref.id;
|
contactEmail: map['contactEmail']?.toString(),
|
||||||
}
|
contactPhone: map['contactPhone']?.toString(),
|
||||||
return ref.toString();
|
assignedEquipment: assignedEquipment,
|
||||||
}).toList(),
|
preparationStatus: preparationStatusFromString(map['preparationStatus'] as String?),
|
||||||
|
loadingStatus: loadingStatusFromString(map['loadingStatus'] as String?),
|
||||||
|
unloadingStatus: unloadingStatusFromString(map['unloadingStatus'] as String?),
|
||||||
|
returnStatus: returnStatusFromString(map['returnStatus'] as String?),
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print('Error parsing event $id: $e');
|
||||||
|
print('Event data: $map');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthodes utilitaires pour le parsing sécurisé
|
||||||
|
static double _parseDouble(dynamic value) {
|
||||||
|
if (value is double) return value;
|
||||||
|
if (value is int) return value.toDouble();
|
||||||
|
if (value is String) {
|
||||||
|
final parsed = double.tryParse(value);
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _parseInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is double) return value.toInt();
|
||||||
|
if (value is String) {
|
||||||
|
final parsed = int.tryParse(value);
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
@@ -70,13 +532,90 @@ class EventModel {
|
|||||||
'Description': description,
|
'Description': description,
|
||||||
'StartDateTime': Timestamp.fromDate(startDateTime),
|
'StartDateTime': Timestamp.fromDate(startDateTime),
|
||||||
'EndDateTime': Timestamp.fromDate(endDateTime),
|
'EndDateTime': Timestamp.fromDate(endDateTime),
|
||||||
'Price': price,
|
'BasePrice': basePrice,
|
||||||
'InstallationTime': installationTime,
|
'InstallationTime': installationTime,
|
||||||
'DisassemblyTime': disassemblyTime,
|
'DisassemblyTime': disassemblyTime,
|
||||||
'EventType': eventTypeId,
|
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
'customer': customerId,
|
'EventType': eventTypeId.isNotEmpty ? eventTypeId : null,
|
||||||
'Address': GeoPoint(address.latitude, address.longitude),
|
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
|
'customer': customerId.isNotEmpty ? customerId : null,
|
||||||
|
'Address': address,
|
||||||
|
'Position': GeoPoint(latitude, longitude),
|
||||||
|
'Latitude': latitude,
|
||||||
|
'Longitude': longitude,
|
||||||
'workforce': workforce,
|
'workforce': workforce,
|
||||||
|
'documents': documents,
|
||||||
|
'options': options,
|
||||||
|
'status': eventStatusToString(status),
|
||||||
|
'jauge': jauge,
|
||||||
|
'contactEmail': contactEmail,
|
||||||
|
'contactPhone': contactPhone,
|
||||||
|
'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(),
|
||||||
|
'assignedContainers': assignedContainers,
|
||||||
|
'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null,
|
||||||
|
'loadingStatus': loadingStatus != null ? loadingStatusToString(loadingStatus!) : null,
|
||||||
|
'unloadingStatus': unloadingStatus != null ? unloadingStatusToString(unloadingStatus!) : null,
|
||||||
|
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EventModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
DateTime? startDateTime,
|
||||||
|
DateTime? endDateTime,
|
||||||
|
double? basePrice,
|
||||||
|
int? installationTime,
|
||||||
|
int? disassemblyTime,
|
||||||
|
String? eventTypeId,
|
||||||
|
DocumentReference? eventTypeRef,
|
||||||
|
String? customerId,
|
||||||
|
String? address,
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
List<dynamic>? workforce,
|
||||||
|
List<Map<String, String>>? documents,
|
||||||
|
List<Map<String, dynamic>>? options,
|
||||||
|
EventStatus? status,
|
||||||
|
int? jauge,
|
||||||
|
String? contactEmail,
|
||||||
|
String? contactPhone,
|
||||||
|
List<EventEquipment>? assignedEquipment,
|
||||||
|
List<String>? assignedContainers,
|
||||||
|
PreparationStatus? preparationStatus,
|
||||||
|
LoadingStatus? loadingStatus,
|
||||||
|
UnloadingStatus? unloadingStatus,
|
||||||
|
ReturnStatus? returnStatus,
|
||||||
|
}) {
|
||||||
|
return EventModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
description: description ?? this.description,
|
||||||
|
startDateTime: startDateTime ?? this.startDateTime,
|
||||||
|
endDateTime: endDateTime ?? this.endDateTime,
|
||||||
|
basePrice: basePrice ?? this.basePrice,
|
||||||
|
installationTime: installationTime ?? this.installationTime,
|
||||||
|
disassemblyTime: disassemblyTime ?? this.disassemblyTime,
|
||||||
|
eventTypeId: eventTypeId ?? this.eventTypeId,
|
||||||
|
eventTypeRef: eventTypeRef ?? this.eventTypeRef,
|
||||||
|
customerId: customerId ?? this.customerId,
|
||||||
|
address: address ?? this.address,
|
||||||
|
latitude: latitude ?? this.latitude,
|
||||||
|
longitude: longitude ?? this.longitude,
|
||||||
|
workforce: workforce ?? this.workforce,
|
||||||
|
documents: documents ?? this.documents,
|
||||||
|
options: options ?? this.options,
|
||||||
|
status: status ?? this.status,
|
||||||
|
jauge: jauge ?? this.jauge,
|
||||||
|
contactEmail: contactEmail ?? this.contactEmail,
|
||||||
|
contactPhone: contactPhone ?? this.contactPhone,
|
||||||
|
assignedEquipment: assignedEquipment ?? this.assignedEquipment,
|
||||||
|
assignedContainers: assignedContainers ?? this.assignedContainers,
|
||||||
|
preparationStatus: preparationStatus ?? this.preparationStatus,
|
||||||
|
loadingStatus: loadingStatus ?? this.loadingStatus,
|
||||||
|
unloadingStatus: unloadingStatus ?? this.unloadingStatus,
|
||||||
|
returnStatus: returnStatus ?? this.returnStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
em2rp/lib/models/event_type_model.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
class EventTypeModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final double defaultPrice;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
EventTypeModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.defaultPrice,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventTypeModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Gérer createdAt qui peut être Timestamp (Firestore) ou String ISO (API)
|
||||||
|
DateTime parseCreatedAt(dynamic value) {
|
||||||
|
if (value == null) return DateTime.now();
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventTypeModel(
|
||||||
|
id: id,
|
||||||
|
name: map['name'] ?? '',
|
||||||
|
defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(),
|
||||||
|
createdAt: parseCreatedAt(map['createdAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'defaultPrice': defaultPrice,
|
||||||
|
'createdAt': createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
146
em2rp/lib/models/maintenance_model.dart
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
enum MaintenanceType {
|
||||||
|
preventive, // Préventive
|
||||||
|
corrective, // Corrective
|
||||||
|
inspection // Inspection
|
||||||
|
}
|
||||||
|
|
||||||
|
String maintenanceTypeToString(MaintenanceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case MaintenanceType.preventive:
|
||||||
|
return 'PREVENTIVE';
|
||||||
|
case MaintenanceType.corrective:
|
||||||
|
return 'CORRECTIVE';
|
||||||
|
case MaintenanceType.inspection:
|
||||||
|
return 'INSPECTION';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaintenanceType maintenanceTypeFromString(String? type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'PREVENTIVE':
|
||||||
|
return MaintenanceType.preventive;
|
||||||
|
case 'CORRECTIVE':
|
||||||
|
return MaintenanceType.corrective;
|
||||||
|
case 'INSPECTION':
|
||||||
|
return MaintenanceType.inspection;
|
||||||
|
default:
|
||||||
|
return MaintenanceType.preventive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaintenanceModel {
|
||||||
|
final String id; // ID aléatoire
|
||||||
|
final List<String> equipmentIds; // IDs des équipements concernés (peut être multiple)
|
||||||
|
final MaintenanceType type; // Type de maintenance
|
||||||
|
final DateTime scheduledDate; // Date planifiée
|
||||||
|
final DateTime? completedDate; // Date de réalisation (null si pas encore effectuée)
|
||||||
|
final String name; // Nom de l'opération
|
||||||
|
final String description; // Description détaillée
|
||||||
|
final String? performedBy; // ID de l'utilisateur qui a effectué la maintenance
|
||||||
|
final double? cost; // Coût de la maintenance
|
||||||
|
final String? notes; // Notes additionnelles
|
||||||
|
final DateTime createdAt; // Date de création
|
||||||
|
final DateTime updatedAt; // Date de mise à jour
|
||||||
|
|
||||||
|
MaintenanceModel({
|
||||||
|
required this.id,
|
||||||
|
required this.equipmentIds,
|
||||||
|
required this.type,
|
||||||
|
required this.scheduledDate,
|
||||||
|
this.completedDate,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
this.performedBy,
|
||||||
|
this.cost,
|
||||||
|
this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||||
|
DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion de la liste des équipements
|
||||||
|
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||||
|
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
|
return MaintenanceModel(
|
||||||
|
id: id,
|
||||||
|
equipmentIds: equipmentIds,
|
||||||
|
type: maintenanceTypeFromString(map['type']),
|
||||||
|
scheduledDate: _parseDate(map['scheduledDate']) ?? DateTime.now(),
|
||||||
|
completedDate: _parseDate(map['completedDate']),
|
||||||
|
name: map['name'] ?? '',
|
||||||
|
description: map['description'] ?? '',
|
||||||
|
performedBy: map['performedBy'],
|
||||||
|
cost: map['cost']?.toDouble(),
|
||||||
|
notes: map['notes'],
|
||||||
|
createdAt: _parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
|
updatedAt: _parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'equipmentIds': equipmentIds,
|
||||||
|
'type': maintenanceTypeToString(type),
|
||||||
|
'scheduledDate': Timestamp.fromDate(scheduledDate),
|
||||||
|
'completedDate': completedDate != null ? Timestamp.fromDate(completedDate!) : null,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'performedBy': performedBy,
|
||||||
|
'cost': cost,
|
||||||
|
'notes': notes,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
MaintenanceModel copyWith({
|
||||||
|
String? id,
|
||||||
|
List<String>? equipmentIds,
|
||||||
|
MaintenanceType? type,
|
||||||
|
DateTime? scheduledDate,
|
||||||
|
DateTime? completedDate,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
String? performedBy,
|
||||||
|
double? cost,
|
||||||
|
String? notes,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return MaintenanceModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
equipmentIds: equipmentIds ?? this.equipmentIds,
|
||||||
|
type: type ?? this.type,
|
||||||
|
scheduledDate: scheduledDate ?? this.scheduledDate,
|
||||||
|
completedDate: completedDate ?? this.completedDate,
|
||||||
|
name: name ?? this.name,
|
||||||
|
description: description ?? this.description,
|
||||||
|
performedBy: performedBy ?? this.performedBy,
|
||||||
|
cost: cost ?? this.cost,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper pour vérifier si la maintenance est complétée
|
||||||
|
bool get isCompleted => completedDate != null;
|
||||||
|
|
||||||
|
// Helper pour vérifier si la maintenance est en retard
|
||||||
|
bool get isOverdue {
|
||||||
|
if (isCompleted) return false;
|
||||||
|
return scheduledDate.isBefore(DateTime.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
88
em2rp/lib/models/notification_preferences_model.dart
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/// Préférences de notifications pour un utilisateur
|
||||||
|
class NotificationPreferences {
|
||||||
|
final bool emailEnabled; // Recevoir emails
|
||||||
|
final bool pushEnabled; // Recevoir notifications push
|
||||||
|
final bool inAppEnabled; // Recevoir alertes in-app
|
||||||
|
|
||||||
|
// Préférences par type d'alerte
|
||||||
|
final bool eventsNotifications; // Alertes événements
|
||||||
|
final bool maintenanceNotifications; // Alertes maintenance
|
||||||
|
final bool stockNotifications; // Alertes stock
|
||||||
|
final bool equipmentNotifications; // Alertes équipement
|
||||||
|
|
||||||
|
// Token FCM (pour push)
|
||||||
|
final String? fcmToken;
|
||||||
|
|
||||||
|
const NotificationPreferences({
|
||||||
|
this.emailEnabled = true, // ✓ Activé par défaut
|
||||||
|
this.pushEnabled = false,
|
||||||
|
this.inAppEnabled = true,
|
||||||
|
this.eventsNotifications = true,
|
||||||
|
this.maintenanceNotifications = true,
|
||||||
|
this.stockNotifications = true,
|
||||||
|
this.equipmentNotifications = true,
|
||||||
|
this.fcmToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Valeurs par défaut pour un nouvel utilisateur
|
||||||
|
factory NotificationPreferences.defaults() {
|
||||||
|
return const NotificationPreferences(
|
||||||
|
emailEnabled: true, // ✓ Activé par défaut
|
||||||
|
pushEnabled: false,
|
||||||
|
inAppEnabled: true,
|
||||||
|
eventsNotifications: true,
|
||||||
|
maintenanceNotifications: true,
|
||||||
|
stockNotifications: true,
|
||||||
|
equipmentNotifications: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory NotificationPreferences.fromMap(Map<String, dynamic> map) {
|
||||||
|
return NotificationPreferences(
|
||||||
|
emailEnabled: map['emailEnabled'] ?? true, // ✓ true par défaut
|
||||||
|
pushEnabled: map['pushEnabled'] ?? false,
|
||||||
|
inAppEnabled: map['inAppEnabled'] ?? true,
|
||||||
|
eventsNotifications: map['eventsNotifications'] ?? true,
|
||||||
|
maintenanceNotifications: map['maintenanceNotifications'] ?? true,
|
||||||
|
stockNotifications: map['stockNotifications'] ?? true,
|
||||||
|
equipmentNotifications: map['equipmentNotifications'] ?? true,
|
||||||
|
fcmToken: map['fcmToken'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'emailEnabled': emailEnabled,
|
||||||
|
'pushEnabled': pushEnabled,
|
||||||
|
'inAppEnabled': inAppEnabled,
|
||||||
|
'eventsNotifications': eventsNotifications,
|
||||||
|
'maintenanceNotifications': maintenanceNotifications,
|
||||||
|
'stockNotifications': stockNotifications,
|
||||||
|
'equipmentNotifications': equipmentNotifications,
|
||||||
|
if (fcmToken != null) 'fcmToken': fcmToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationPreferences copyWith({
|
||||||
|
bool? emailEnabled,
|
||||||
|
bool? pushEnabled,
|
||||||
|
bool? inAppEnabled,
|
||||||
|
bool? eventsNotifications,
|
||||||
|
bool? maintenanceNotifications,
|
||||||
|
bool? stockNotifications,
|
||||||
|
bool? equipmentNotifications,
|
||||||
|
String? fcmToken,
|
||||||
|
}) {
|
||||||
|
return NotificationPreferences(
|
||||||
|
emailEnabled: emailEnabled ?? this.emailEnabled,
|
||||||
|
pushEnabled: pushEnabled ?? this.pushEnabled,
|
||||||
|
inAppEnabled: inAppEnabled ?? this.inAppEnabled,
|
||||||
|
eventsNotifications: eventsNotifications ?? this.eventsNotifications,
|
||||||
|
maintenanceNotifications: maintenanceNotifications ?? this.maintenanceNotifications,
|
||||||
|
stockNotifications: stockNotifications ?? this.stockNotifications,
|
||||||
|
equipmentNotifications: equipmentNotifications ?? this.equipmentNotifications,
|
||||||
|
fcmToken: fcmToken ?? this.fcmToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
48
em2rp/lib/models/option_model.dart
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
class EventOption {
|
||||||
|
final String id;
|
||||||
|
final String code; // Nouveau champ code
|
||||||
|
final String name;
|
||||||
|
final String details;
|
||||||
|
final double valMin;
|
||||||
|
final double valMax;
|
||||||
|
final List<String> eventTypes; // Changé de List<DocumentReference> à List<String>
|
||||||
|
final bool isQuantitative; // Indique si l'option peut avoir une quantité
|
||||||
|
|
||||||
|
EventOption({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
required this.details,
|
||||||
|
required this.valMin,
|
||||||
|
required this.valMax,
|
||||||
|
required this.eventTypes,
|
||||||
|
this.isQuantitative = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventOption.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
return EventOption(
|
||||||
|
id: id,
|
||||||
|
code: map['code'] ?? id, // Utilise le code ou l'ID en fallback
|
||||||
|
name: map['name'] ?? '',
|
||||||
|
details: map['details'] ?? '',
|
||||||
|
valMin: (map['valMin'] ?? 0.0).toDouble(),
|
||||||
|
valMax: (map['valMax'] ?? 0.0).toDouble(),
|
||||||
|
eventTypes: (map['eventTypes'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => e.toString()) // Convertit en String (supporte IDs et références)
|
||||||
|
.toList(),
|
||||||
|
isQuantitative: map['isQuantitative'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'code': code,
|
||||||
|
'name': name,
|
||||||
|
'details': details,
|
||||||
|
'valMin': valMin,
|
||||||
|
'valMax': valMax,
|
||||||
|
'eventTypes': eventTypes,
|
||||||
|
'isQuantitative': isQuantitative,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,100 +1,27 @@
|
|||||||
enum Permission {
|
|
||||||
// Permissions liées aux prestations
|
|
||||||
viewAllEvents, // Voir toutes les prestations
|
|
||||||
viewAssignedEvents, // Voir uniquement les prestations assignées
|
|
||||||
editEvents, // Modifier les prestations
|
|
||||||
deleteEvents, // Supprimer les prestations
|
|
||||||
assignCrew, // Assigner des membres d'équipe aux prestations
|
|
||||||
|
|
||||||
// Permissions liées aux finances
|
class RoleModel {
|
||||||
viewPrices, // Voir les prix
|
final String id;
|
||||||
editPrices, // Modifier les prix
|
|
||||||
viewQuotes, // Voir les devis
|
|
||||||
createQuotes, // Créer des devis
|
|
||||||
editQuotes, // Modifier les devis
|
|
||||||
viewInvoices, // Voir les factures
|
|
||||||
createInvoices, // Créer des factures
|
|
||||||
editInvoices, // Modifier les factures
|
|
||||||
|
|
||||||
// Permissions liées aux utilisateurs
|
|
||||||
viewUsers, // Voir les utilisateurs
|
|
||||||
editUsers, // Modifier les utilisateurs
|
|
||||||
deleteUsers, // Supprimer les utilisateurs
|
|
||||||
|
|
||||||
// Permissions liées aux clients
|
|
||||||
viewClients, // Voir les clients
|
|
||||||
editClients, // Modifier les clients
|
|
||||||
deleteClients, // Supprimer les clients
|
|
||||||
}
|
|
||||||
|
|
||||||
class Role {
|
|
||||||
final String name;
|
final String name;
|
||||||
final Set<Permission> permissions;
|
final List<String> permissions;
|
||||||
|
|
||||||
const Role({
|
RoleModel({
|
||||||
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.permissions,
|
required this.permissions,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool hasPermission(Permission permission) => permissions.contains(permission);
|
factory RoleModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
return RoleModel(
|
||||||
bool hasAllPermissions(List<Permission> requiredPermissions) {
|
id: id,
|
||||||
return requiredPermissions
|
name: map['name'] ?? '',
|
||||||
.every((permission) => permissions.contains(permission));
|
permissions: List<String>.from(map['permissions'] ?? []),
|
||||||
}
|
|
||||||
|
|
||||||
bool hasAnyPermission(List<Permission> requiredPermissions) {
|
|
||||||
return requiredPermissions
|
|
||||||
.any((permission) => permissions.contains(permission));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Roles {
|
|
||||||
static const admin = Role(
|
|
||||||
name: 'ADMIN',
|
|
||||||
permissions: {
|
|
||||||
// Toutes les permissions pour l'administrateur
|
|
||||||
Permission.viewAllEvents,
|
|
||||||
Permission.viewAssignedEvents,
|
|
||||||
Permission.editEvents,
|
|
||||||
Permission.deleteEvents,
|
|
||||||
Permission.assignCrew,
|
|
||||||
Permission.viewPrices,
|
|
||||||
Permission.editPrices,
|
|
||||||
Permission.viewQuotes,
|
|
||||||
Permission.createQuotes,
|
|
||||||
Permission.editQuotes,
|
|
||||||
Permission.viewInvoices,
|
|
||||||
Permission.createInvoices,
|
|
||||||
Permission.editInvoices,
|
|
||||||
Permission.viewUsers,
|
|
||||||
Permission.editUsers,
|
|
||||||
Permission.deleteUsers,
|
|
||||||
Permission.viewClients,
|
|
||||||
Permission.editClients,
|
|
||||||
Permission.deleteClients,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
static const crew = Role(
|
|
||||||
name: 'CREW',
|
|
||||||
permissions: {
|
|
||||||
// Permissions limitées pour l'équipe
|
|
||||||
Permission.viewAssignedEvents,
|
|
||||||
Permission.viewClients,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
static Role fromString(String roleName) {
|
|
||||||
switch (roleName.toUpperCase()) {
|
|
||||||
case 'ADMIN':
|
|
||||||
return admin;
|
|
||||||
case 'CREW':
|
|
||||||
return crew;
|
|
||||||
default:
|
|
||||||
return crew; // Par défaut, on donne les permissions minimales
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<Role> values = [admin, crew];
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'permissions': permissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
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;
|
||||||
final String firstName;
|
final String firstName;
|
||||||
@@ -6,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,
|
||||||
@@ -15,18 +19,50 @@ 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;
|
||||||
|
final roleField = data['role'];
|
||||||
|
|
||||||
|
if (roleField is String) {
|
||||||
|
// Cas 1 : role est déjà un String (ex: "roles/ADMIN")
|
||||||
|
roleString = roleField;
|
||||||
|
} else if (roleField is DocumentReference) {
|
||||||
|
// Cas 2 : role est une DocumentReference
|
||||||
|
roleString = roleField.id;
|
||||||
|
} else if (roleField is Map) {
|
||||||
|
// Cas 3 : role est un Map sérialisé (ex: {"_path": {"segments": ["roles", "ADMIN"]}})
|
||||||
|
// On extrait le path
|
||||||
|
final pathData = roleField['_path'];
|
||||||
|
if (pathData is Map && pathData['segments'] is List) {
|
||||||
|
final segments = pathData['segments'] as List;
|
||||||
|
if (segments.length >= 2) {
|
||||||
|
roleString = segments[1].toString(); // Ex: "ADMIN"
|
||||||
|
} else {
|
||||||
|
roleString = 'USER';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roleString = 'USER';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cas par défaut
|
||||||
|
roleString = 'USER';
|
||||||
|
}
|
||||||
|
|
||||||
return UserModel(
|
return UserModel(
|
||||||
uid: uid,
|
uid: uid,
|
||||||
firstName: data['firstName'] ?? '',
|
firstName: data['firstName'] ?? '',
|
||||||
lastName: data['lastName'] ?? '',
|
lastName: data['lastName'] ?? '',
|
||||||
role: data['role'] ?? 'USER',
|
role: roleString,
|
||||||
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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,10 +71,12 @@ class UserModel {
|
|||||||
return {
|
return {
|
||||||
'firstName': firstName,
|
'firstName': firstName,
|
||||||
'lastName': lastName,
|
'lastName': lastName,
|
||||||
'role': 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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,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
|
||||||
@@ -58,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
117
em2rp/lib/providers/alert_provider.dart
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
class AlertProvider extends ChangeNotifier {
|
||||||
|
final ApiService _apiService = apiService;
|
||||||
|
|
||||||
|
List<AlertModel> _alerts = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
List<AlertModel> get alerts => _alerts;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
/// Nombre d'alertes non lues
|
||||||
|
int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
|
||||||
|
|
||||||
|
/// Alertes non lues uniquement
|
||||||
|
List<AlertModel> get unreadAlerts => _alerts.where((alert) => !alert.isRead).toList();
|
||||||
|
|
||||||
|
/// Alertes de stock critique
|
||||||
|
List<AlertModel> get lowStockAlerts => _alerts.where((alert) => alert.type == AlertType.lowStock).toList();
|
||||||
|
|
||||||
|
/// Alertes de maintenance
|
||||||
|
List<AlertModel> get maintenanceAlerts => _alerts.where((alert) => alert.type == AlertType.maintenanceDue).toList();
|
||||||
|
|
||||||
|
/// Alertes de conflit
|
||||||
|
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
|
||||||
|
|
||||||
|
/// Charger toutes les alertes via Cloud Function
|
||||||
|
Future<void> loadAlerts() async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getAlerts', {});
|
||||||
|
final alertsData = result['alerts'] as List<dynamic>;
|
||||||
|
|
||||||
|
_alerts = alertsData.map((data) {
|
||||||
|
return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
|
||||||
|
}).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading alerts: $e');
|
||||||
|
_alerts = [];
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marquer une alerte comme lue via Cloud Function
|
||||||
|
Future<void> markAsRead(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('markAlertAsRead', {'alertId': alertId});
|
||||||
|
|
||||||
|
// Mettre à jour localement
|
||||||
|
final index = _alerts.indexWhere((a) => a.id == alertId);
|
||||||
|
if (index != -1) {
|
||||||
|
_alerts[index] = AlertModel(
|
||||||
|
id: _alerts[index].id,
|
||||||
|
type: _alerts[index].type,
|
||||||
|
message: _alerts[index].message,
|
||||||
|
equipmentId: _alerts[index].equipmentId,
|
||||||
|
isRead: true,
|
||||||
|
createdAt: _alerts[index].createdAt,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error marking alert as read: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprimer une alerte via Cloud Function
|
||||||
|
Future<void> deleteAlert(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteAlert', {'alertId': alertId});
|
||||||
|
|
||||||
|
// Supprimer localement
|
||||||
|
_alerts.removeWhere((a) => a.id == alertId);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error deleting alert: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marquer toutes les alertes comme lues
|
||||||
|
Future<void> markAllAsRead() async {
|
||||||
|
try {
|
||||||
|
final unreadAlertIds = _alerts.where((a) => !a.isRead).map((a) => a.id).toList();
|
||||||
|
|
||||||
|
for (final alertId in unreadAlertIds) {
|
||||||
|
await markAsRead(alertId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error marking all alerts as read: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprimer toutes les alertes lues via Cloud Function
|
||||||
|
Future<void> deleteReadAlerts() async {
|
||||||
|
try {
|
||||||
|
final readAlertIds = _alerts.where((a) => a.isRead).map((a) => a.id).toList();
|
||||||
|
|
||||||
|
for (final alertId in readAlertIds) {
|
||||||
|
await deleteAlert(alertId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error deleting read alerts: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
62
em2rp/lib/providers/alert_provider_new.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:em2rp/models/alert_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
class AlertProvider extends ChangeNotifier {
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
List<AlertModel> _alerts = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
List<AlertModel> get alerts => _alerts;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
/// Nombre d'alertes non lues
|
||||||
|
int get unreadCount => _alerts.where((a) => !a.isRead).length;
|
||||||
|
|
||||||
|
/// Charger toutes les alertes via l'API
|
||||||
|
Future<void> loadAlerts() async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final alertsData = await _dataService.getAlerts();
|
||||||
|
|
||||||
|
_alerts = alertsData.map((data) {
|
||||||
|
return AlertModel.fromMap(data, data['id'] as String);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading alerts: $e');
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recharger les alertes
|
||||||
|
Future<void> refresh() async {
|
||||||
|
await loadAlerts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir les alertes non lues
|
||||||
|
List<AlertModel> get unreadAlerts {
|
||||||
|
return _alerts.where((a) => !a.isRead).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir les alertes par type
|
||||||
|
List<AlertModel> getByType(AlertType type) {
|
||||||
|
return _alerts.where((a) => a.type == type).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir les alertes critiques (stock bas, équipement perdu)
|
||||||
|
List<AlertModel> get criticalAlerts {
|
||||||
|
return _alerts.where((a) =>
|
||||||
|
a.type == AlertType.lowStock || a.type == AlertType.lost
|
||||||
|
).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
452
em2rp/lib/providers/container_provider.dart
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:em2rp/models/container_model.dart';
|
||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/services/container_service.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
class ContainerProvider with ChangeNotifier {
|
||||||
|
final ContainerService _containerService = ContainerService();
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
// Timer pour le debouncing de la recherche
|
||||||
|
Timer? _searchDebounceTimer;
|
||||||
|
|
||||||
|
// Liste paginée pour la page de gestion
|
||||||
|
List<ContainerModel> _paginatedContainers = [];
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
String? _lastVisible;
|
||||||
|
|
||||||
|
// Cache complet pour compatibilité
|
||||||
|
List<ContainerModel> _containers = [];
|
||||||
|
|
||||||
|
// Filtres et recherche
|
||||||
|
ContainerType? _selectedType;
|
||||||
|
EquipmentStatus? _selectedStatus;
|
||||||
|
String _searchQuery = '';
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
// Mode de chargement (pagination vs full)
|
||||||
|
bool _usePagination = false;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
List<ContainerModel> get containers => _usePagination ? _paginatedContainers : _containers;
|
||||||
|
ContainerType? get selectedType => _selectedType;
|
||||||
|
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||||
|
String get searchQuery => _searchQuery;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get usePagination => _usePagination;
|
||||||
|
|
||||||
|
/// S'assure que les conteneurs sont chargés (charge si nécessaire)
|
||||||
|
Future<void> ensureLoaded() async {
|
||||||
|
if (_isInitialized || _isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger tous les containers via l'API (avec pagination automatique)
|
||||||
|
Future<void> loadContainers() async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
_containers.clear();
|
||||||
|
String? lastVisible;
|
||||||
|
bool hasMore = true;
|
||||||
|
int pageCount = 0;
|
||||||
|
|
||||||
|
// Charger toutes les pages en boucle
|
||||||
|
while (hasMore) {
|
||||||
|
pageCount++;
|
||||||
|
print('[ContainerProvider] Loading page $pageCount...');
|
||||||
|
|
||||||
|
final result = await _dataService.getContainersPaginated(
|
||||||
|
limit: 100, // Charger 100 par page pour aller plus vite
|
||||||
|
startAfter: lastVisible,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
type: _selectedType?.name,
|
||||||
|
status: _selectedStatus?.name,
|
||||||
|
searchQuery: _searchQuery,
|
||||||
|
);
|
||||||
|
|
||||||
|
final containers = (result['containers'] as List<dynamic>)
|
||||||
|
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_containers.addAll(containers);
|
||||||
|
hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
|
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
_isInitialized = true;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading containers: $e');
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer les containers avec filtres appliqués
|
||||||
|
Future<List<ContainerModel>> getContainers() async {
|
||||||
|
return await _containerService.getContainers(
|
||||||
|
type: _selectedType,
|
||||||
|
status: _selectedStatus,
|
||||||
|
searchQuery: _searchQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream des containers - retourne un stream depuis les données en cache
|
||||||
|
/// Pour compatibilité avec les widgets existants qui utilisent StreamBuilder
|
||||||
|
Stream<List<ContainerModel>> get containersStream async* {
|
||||||
|
// Si les données ne sont pas chargées, charger d'abord
|
||||||
|
if (!_isInitialized) {
|
||||||
|
await loadContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Émettre les données actuelles
|
||||||
|
yield _containers;
|
||||||
|
|
||||||
|
// Continuer à émettre les mises à jour du cache
|
||||||
|
// Note: Pour un vrai temps réel, il faudrait implémenter un StreamController
|
||||||
|
// et notifier quand les données changent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le type sélectionné
|
||||||
|
void setSelectedType(ContainerType? type) async {
|
||||||
|
if (_selectedType == type) return;
|
||||||
|
_selectedType = type;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le statut sélectionné
|
||||||
|
void setSelectedStatus(EquipmentStatus? status) async {
|
||||||
|
if (_selectedStatus == status) return;
|
||||||
|
_selectedStatus = status;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir la requête de recherche (avec debouncing)
|
||||||
|
void setSearchQuery(String query) {
|
||||||
|
if (_searchQuery == query) return;
|
||||||
|
_searchQuery = query;
|
||||||
|
|
||||||
|
// Annuler le timer précédent
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
|
||||||
|
if (_usePagination) {
|
||||||
|
// Attendre 500ms avant de recharger (debouncing)
|
||||||
|
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAGINATION - Nouvelles méthodes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Active le mode pagination (pour la page de gestion)
|
||||||
|
void enablePagination() {
|
||||||
|
if (!_usePagination) {
|
||||||
|
_usePagination = true;
|
||||||
|
DebugLog.info('[ContainerProvider] Pagination mode enabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Désactive le mode pagination (pour les autres pages)
|
||||||
|
void disablePagination() {
|
||||||
|
if (_usePagination) {
|
||||||
|
_usePagination = false;
|
||||||
|
DebugLog.info('[ContainerProvider] Pagination mode disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge la première page (réinitialise tout)
|
||||||
|
Future<void> loadFirstPage() async {
|
||||||
|
DebugLog.info('[ContainerProvider] Loading first page...');
|
||||||
|
|
||||||
|
_paginatedContainers.clear();
|
||||||
|
_lastVisible = null;
|
||||||
|
_hasMore = true;
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadNextPage();
|
||||||
|
_isInitialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[ContainerProvider] Error loading first page', e);
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge la page suivante (scroll infini)
|
||||||
|
Future<void> loadNextPage() async {
|
||||||
|
if (_isLoadingMore || !_hasMore) {
|
||||||
|
DebugLog.info('[ContainerProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[ContainerProvider] Loading next page... (current: ${_paginatedContainers.length})');
|
||||||
|
|
||||||
|
_isLoadingMore = true;
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _dataService.getContainersPaginated(
|
||||||
|
limit: 20,
|
||||||
|
startAfter: _lastVisible,
|
||||||
|
type: _selectedType != null ? containerTypeToString(_selectedType!) : null,
|
||||||
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final newContainers = (result['containers'] as List<dynamic>)
|
||||||
|
.map((data) {
|
||||||
|
final map = data as Map<String, dynamic>;
|
||||||
|
return ContainerModel.fromMap(map, map['id'] as String);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_paginatedContainers.addAll(newContainers);
|
||||||
|
_hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
_lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
|
DebugLog.info('[ContainerProvider] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMore');
|
||||||
|
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[ContainerProvider] Error loading next page', e);
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recharge en changeant de filtre ou recherche
|
||||||
|
Future<void> reload() async {
|
||||||
|
DebugLog.info('[ContainerProvider] Reloading with new filters...');
|
||||||
|
await loadFirstPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Créer un nouveau container
|
||||||
|
Future<void> createContainer(ContainerModel container) async {
|
||||||
|
await _containerService.createContainer(container);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mettre à jour un container
|
||||||
|
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
|
||||||
|
await _containerService.updateContainer(id, data);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprimer un container
|
||||||
|
Future<void> deleteContainer(String id) async {
|
||||||
|
await _containerService.deleteContainer(id);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer un container par ID
|
||||||
|
Future<ContainerModel?> getContainerById(String id) async {
|
||||||
|
return await _containerService.getContainerById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge plusieurs conteneurs par leurs IDs (optimisé pour les détails d'événement)
|
||||||
|
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
|
||||||
|
if (containerIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier d'abord le cache local
|
||||||
|
final cachedContainers = <ContainerModel>[];
|
||||||
|
final missingIds = <String>[];
|
||||||
|
|
||||||
|
for (final id in containerIds) {
|
||||||
|
final cached = _containers.firstWhere(
|
||||||
|
(c) => c.id == id,
|
||||||
|
orElse: () => ContainerModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: ContainerType.flightCase,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
equipmentIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cached.id.isNotEmpty) {
|
||||||
|
cachedContainers.add(cached);
|
||||||
|
} else {
|
||||||
|
missingIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
|
||||||
|
|
||||||
|
// Si tous sont en cache, retourner directement
|
||||||
|
if (missingIds.isEmpty) {
|
||||||
|
return cachedContainers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les manquants depuis l'API
|
||||||
|
final containersData = await _dataService.getContainersByIds(missingIds);
|
||||||
|
|
||||||
|
final loadedContainers = containersData.map((data) {
|
||||||
|
return ContainerModel.fromMap(data, data['id'] as String);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Ajouter au cache
|
||||||
|
for (final container in loadedContainers) {
|
||||||
|
if (!_containers.any((c) => c.id == container.id)) {
|
||||||
|
_containers.add(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
|
||||||
|
|
||||||
|
// Retourner tous les conteneurs (cache + chargés)
|
||||||
|
return [...cachedContainers, ...loadedContainers];
|
||||||
|
} catch (e) {
|
||||||
|
print('[ContainerProvider] Error loading containers by IDs: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajouter un équipement à un container
|
||||||
|
Future<Map<String, dynamic>> addEquipmentToContainer({
|
||||||
|
required String containerId,
|
||||||
|
required String equipmentId,
|
||||||
|
String? userId,
|
||||||
|
}) async {
|
||||||
|
final result = await _containerService.addEquipmentToContainer(
|
||||||
|
containerId: containerId,
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
userId: userId,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retirer un équipement d'un container
|
||||||
|
Future<void> removeEquipmentFromContainer({
|
||||||
|
required String containerId,
|
||||||
|
required String equipmentId,
|
||||||
|
String? userId,
|
||||||
|
}) async {
|
||||||
|
await _containerService.removeEquipmentFromContainer(
|
||||||
|
containerId: containerId,
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
userId: userId,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifier la disponibilité d'un container
|
||||||
|
Future<Map<String, dynamic>> checkContainerAvailability({
|
||||||
|
required String containerId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
return await _containerService.checkContainerAvailability(
|
||||||
|
containerId: containerId,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer les équipements d'un container
|
||||||
|
Future<List<EquipmentModel>> getContainerEquipment(String containerId) async {
|
||||||
|
return await _containerService.getContainerEquipment(containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trouver tous les containers contenant un équipement
|
||||||
|
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
|
||||||
|
return await _containerService.findContainersWithEquipment(equipmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifier si un ID existe
|
||||||
|
Future<bool> checkContainerIdExists(String id) async {
|
||||||
|
return await _containerService.checkContainerIdExists(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Générer un ID unique pour un container
|
||||||
|
/// Format: BOX_{TYPE}_{NAME}_{NUMBER}
|
||||||
|
static String generateContainerId({
|
||||||
|
required ContainerType type,
|
||||||
|
required String name,
|
||||||
|
int? number,
|
||||||
|
}) {
|
||||||
|
// Obtenir le type en majuscules
|
||||||
|
final typeStr = containerTypeToString(type);
|
||||||
|
|
||||||
|
// Nettoyer le nom (enlever espaces, caractères spéciaux)
|
||||||
|
final cleanName = name
|
||||||
|
.replaceAll(' ', '_')
|
||||||
|
.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '')
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
if (number != null) {
|
||||||
|
return 'BOX_${typeStr}_${cleanName}_#$number';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'BOX_${typeStr}_$cleanName';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assurer l'unicité d'un ID de container
|
||||||
|
static Future<String> ensureUniqueContainerId(
|
||||||
|
String baseId,
|
||||||
|
ContainerService service,
|
||||||
|
) async {
|
||||||
|
String uniqueId = baseId;
|
||||||
|
int counter = 1;
|
||||||
|
|
||||||
|
while (await service.checkContainerIdExists(uniqueId)) {
|
||||||
|
uniqueId = '${baseId}_$counter';
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
533
em2rp/lib/providers/equipment_provider.dart
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
class EquipmentProvider extends ChangeNotifier {
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
|
// Timer pour le debouncing de la recherche
|
||||||
|
Timer? _searchDebounceTimer;
|
||||||
|
|
||||||
|
// Liste paginée pour la page de gestion
|
||||||
|
List<EquipmentModel> _paginatedEquipment = [];
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
String? _lastVisible;
|
||||||
|
|
||||||
|
// Cache complet pour getEquipmentsByIds et compatibilité
|
||||||
|
List<EquipmentModel> _equipment = [];
|
||||||
|
List<String> _models = [];
|
||||||
|
List<String> _brands = [];
|
||||||
|
|
||||||
|
// Filtres et recherche
|
||||||
|
EquipmentCategory? _selectedCategory;
|
||||||
|
EquipmentStatus? _selectedStatus;
|
||||||
|
String? _selectedModel;
|
||||||
|
String _searchQuery = '';
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
// Mode de chargement (pagination vs full)
|
||||||
|
bool _usePagination = false;
|
||||||
|
|
||||||
|
EquipmentProvider();
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
List<EquipmentModel> get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment;
|
||||||
|
List<EquipmentModel> get allEquipment => _equipment;
|
||||||
|
List<String> get models => _models;
|
||||||
|
List<String> get brands => _brands;
|
||||||
|
EquipmentCategory? get selectedCategory => _selectedCategory;
|
||||||
|
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||||
|
String? get selectedModel => _selectedModel;
|
||||||
|
String get searchQuery => _searchQuery;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get usePagination => _usePagination;
|
||||||
|
|
||||||
|
/// S'assure que les équipements sont chargés (charge si nécessaire)
|
||||||
|
Future<void> ensureLoaded() async {
|
||||||
|
// Si déjà en train de charger, attendre
|
||||||
|
if (_isLoading) {
|
||||||
|
print('[EquipmentProvider] Equipment loading in progress, waiting...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si initialisé MAIS _equipment est vide, forcer le rechargement
|
||||||
|
if (_isInitialized && _equipment.isEmpty) {
|
||||||
|
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
|
||||||
|
_isInitialized = false; // Réinitialiser le flag
|
||||||
|
await loadEquipments();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si déjà initialisé avec des données, ne rien faire
|
||||||
|
if (_isInitialized) {
|
||||||
|
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[EquipmentProvider] Equipment not loaded, loading now...');
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger tous les équipements via l'API (utilisé par les dialogs et sélection)
|
||||||
|
Future<void> loadEquipments() async {
|
||||||
|
print('[EquipmentProvider] Starting to load ALL equipments...');
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
_equipment.clear();
|
||||||
|
String? lastVisible;
|
||||||
|
bool hasMore = true;
|
||||||
|
int pageCount = 0;
|
||||||
|
|
||||||
|
// Charger toutes les pages en boucle
|
||||||
|
while (hasMore) {
|
||||||
|
pageCount++;
|
||||||
|
print('[EquipmentProvider] Loading page $pageCount...');
|
||||||
|
|
||||||
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
|
limit: 100, // Charger 100 par page pour aller plus vite
|
||||||
|
startAfter: lastVisible,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipmentsData = result['equipments'] as List<dynamic>;
|
||||||
|
print('[EquipmentProvider] Page $pageCount: ${equipmentsData.length} equipments');
|
||||||
|
|
||||||
|
final pageEquipments = equipmentsData.map((data) {
|
||||||
|
final id = data['id'] as String;
|
||||||
|
return EquipmentModel.fromMap(data as Map<String, dynamic>, id);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_equipment.addAll(pageEquipments);
|
||||||
|
|
||||||
|
hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
|
if (!hasMore) {
|
||||||
|
print('[EquipmentProvider] All pages loaded. Total: ${_equipment.length} equipments');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire les modèles et marques uniques
|
||||||
|
_extractUniqueValues();
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
print('[EquipmentProvider] Equipment loading complete: ${_equipment.length} equipments');
|
||||||
|
} catch (e) {
|
||||||
|
print('[EquipmentProvider] Error loading equipments: $e');
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge plusieurs équipements par leurs IDs (optimisé pour les détails d'événement)
|
||||||
|
Future<List<EquipmentModel>> getEquipmentsByIds(List<String> equipmentIds) async {
|
||||||
|
if (equipmentIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print('[EquipmentProvider] Loading ${equipmentIds.length} equipments by IDs...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier d'abord le cache local
|
||||||
|
final cachedEquipments = <EquipmentModel>[];
|
||||||
|
final missingIds = <String>[];
|
||||||
|
|
||||||
|
for (final id in equipmentIds) {
|
||||||
|
final cached = _equipment.firstWhere(
|
||||||
|
(eq) => eq.id == id,
|
||||||
|
orElse: () => EquipmentModel(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
category: EquipmentCategory.other,
|
||||||
|
status: EquipmentStatus.available,
|
||||||
|
maintenanceIds: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cached.id.isNotEmpty) {
|
||||||
|
cachedEquipments.add(cached);
|
||||||
|
} else {
|
||||||
|
missingIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[EquipmentProvider] Found ${cachedEquipments.length} in cache, ${missingIds.length} missing');
|
||||||
|
|
||||||
|
// Si tous sont en cache, retourner directement
|
||||||
|
if (missingIds.isEmpty) {
|
||||||
|
return cachedEquipments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les manquants depuis l'API
|
||||||
|
final equipmentsData = await _dataService.getEquipmentsByIds(missingIds);
|
||||||
|
|
||||||
|
final loadedEquipments = equipmentsData.map((data) {
|
||||||
|
final id = data['id'] as String; // L'ID vient du backend
|
||||||
|
return EquipmentModel.fromMap(data, id);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Ajouter au cache
|
||||||
|
for (final eq in loadedEquipments) {
|
||||||
|
if (!_equipment.any((e) => e.id == eq.id)) {
|
||||||
|
_equipment.add(eq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[EquipmentProvider] Loaded ${loadedEquipments.length} equipments from API');
|
||||||
|
|
||||||
|
// Retourner tous les équipements (cache + chargés)
|
||||||
|
return [...cachedEquipments, ...loadedEquipments];
|
||||||
|
} catch (e) {
|
||||||
|
print('[EquipmentProvider] Error loading equipments by IDs: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extraire modèles et marques uniques
|
||||||
|
void _extractUniqueValues() {
|
||||||
|
final modelSet = <String>{};
|
||||||
|
final brandSet = <String>{};
|
||||||
|
|
||||||
|
for (final eq in _equipment) {
|
||||||
|
if (eq.model != null && eq.model!.isNotEmpty) {
|
||||||
|
modelSet.add(eq.model!);
|
||||||
|
}
|
||||||
|
if (eq.brand != null && eq.brand!.isNotEmpty) {
|
||||||
|
brandSet.add(eq.brand!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_models = modelSet.toList()..sort();
|
||||||
|
_brands = brandSet.toList()..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtenir les équipements filtrés
|
||||||
|
List<EquipmentModel> get _filteredEquipment {
|
||||||
|
var filtered = _equipment;
|
||||||
|
|
||||||
|
if (_selectedCategory != null) {
|
||||||
|
filtered = filtered.where((eq) => eq.category == _selectedCategory).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedStatus != null) {
|
||||||
|
filtered = filtered.where((eq) => eq.status == _selectedStatus).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedModel != null && _selectedModel!.isNotEmpty) {
|
||||||
|
filtered = filtered.where((eq) => eq.model == _selectedModel).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
final query = _searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.where((eq) {
|
||||||
|
return eq.name.toLowerCase().contains(query) ||
|
||||||
|
eq.id.toLowerCase().contains(query) ||
|
||||||
|
(eq.model?.toLowerCase().contains(query) ?? false) ||
|
||||||
|
(eq.brand?.toLowerCase().contains(query) ?? false);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAGINATION - Nouvelles méthodes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Active le mode pagination (pour la page de gestion)
|
||||||
|
void enablePagination() {
|
||||||
|
if (!_usePagination) {
|
||||||
|
_usePagination = true;
|
||||||
|
DebugLog.info('[EquipmentProvider] Pagination mode enabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Désactive le mode pagination (pour les autres pages)
|
||||||
|
void disablePagination() {
|
||||||
|
if (_usePagination) {
|
||||||
|
_usePagination = false;
|
||||||
|
DebugLog.info('[EquipmentProvider] Pagination mode disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge la première page (réinitialise tout)
|
||||||
|
Future<void> loadFirstPage() async {
|
||||||
|
DebugLog.info('[EquipmentProvider] Loading first page...');
|
||||||
|
|
||||||
|
_paginatedEquipment.clear();
|
||||||
|
_lastVisible = null;
|
||||||
|
_hasMore = true;
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadNextPage();
|
||||||
|
_isInitialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentProvider] Error loading first page', e);
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge la page suivante (scroll infini)
|
||||||
|
Future<void> loadNextPage() async {
|
||||||
|
if (_isLoadingMore || !_hasMore) {
|
||||||
|
DebugLog.info('[EquipmentProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLog.info('[EquipmentProvider] Loading next page... (current: ${_paginatedEquipment.length})');
|
||||||
|
|
||||||
|
_isLoadingMore = true;
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
|
limit: 20,
|
||||||
|
startAfter: _lastVisible,
|
||||||
|
category: _selectedCategory?.name,
|
||||||
|
status: _selectedStatus?.name,
|
||||||
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final newEquipments = (result['equipments'] as List<dynamic>)
|
||||||
|
.map((data) {
|
||||||
|
final map = data as Map<String, dynamic>;
|
||||||
|
final id = map['id'] as String; // L'ID vient du backend dans le JSON
|
||||||
|
return EquipmentModel.fromMap(map, id);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_paginatedEquipment.addAll(newEquipments);
|
||||||
|
_hasMore = result['hasMore'] as bool? ?? false;
|
||||||
|
_lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
|
DebugLog.info('[EquipmentProvider] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipment.length}, hasMore: $_hasMore');
|
||||||
|
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentProvider] Error loading next page', e);
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recharge en changeant de filtre ou recherche
|
||||||
|
Future<void> reload() async {
|
||||||
|
DebugLog.info('[EquipmentProvider] Reloading with new filters...');
|
||||||
|
await loadFirstPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le filtre de catégorie
|
||||||
|
void setSelectedCategory(EquipmentCategory? category) async {
|
||||||
|
if (_selectedCategory == category) return;
|
||||||
|
_selectedCategory = category;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le filtre de statut
|
||||||
|
void setSelectedStatus(EquipmentStatus? status) async {
|
||||||
|
if (_selectedStatus == status) return;
|
||||||
|
_selectedStatus = status;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir le filtre de modèle
|
||||||
|
void setSelectedModel(String? model) async {
|
||||||
|
if (_selectedModel == model) return;
|
||||||
|
_selectedModel = model;
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Définir la requête de recherche (avec debouncing)
|
||||||
|
void setSearchQuery(String query) {
|
||||||
|
if (_searchQuery == query) return;
|
||||||
|
_searchQuery = query;
|
||||||
|
|
||||||
|
// Annuler le timer précédent
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
|
||||||
|
if (_usePagination) {
|
||||||
|
// Attendre 500ms avant de recharger (debouncing)
|
||||||
|
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchDebounceTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réinitialiser tous les filtres
|
||||||
|
void clearFilters() async {
|
||||||
|
_selectedCategory = null;
|
||||||
|
_selectedStatus = null;
|
||||||
|
_selectedModel = null;
|
||||||
|
_searchQuery = '';
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MÉTHODES COMPATIBILITÉ (pour ancien code)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Recharger les équipements (ancien système)
|
||||||
|
Future<void> refresh() async {
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream des équipements (pour compatibilité avec ancien code)
|
||||||
|
Stream<List<EquipmentModel>> get equipmentStream async* {
|
||||||
|
if (!_isInitialized && !_usePagination) {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
yield equipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprimer un équipement
|
||||||
|
Future<void> deleteEquipment(String equipmentId) async {
|
||||||
|
try {
|
||||||
|
await _dataService.deleteEquipment(equipmentId);
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajouter un équipement
|
||||||
|
Future<void> addEquipment(EquipmentModel equipment) async {
|
||||||
|
try {
|
||||||
|
await _dataService.createEquipment(equipment.id, equipment.toMap());
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentProvider] Error adding equipment', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mettre à jour un équipement
|
||||||
|
Future<void> updateEquipment(EquipmentModel equipment) async {
|
||||||
|
try {
|
||||||
|
await _dataService.updateEquipment(equipment.id, equipment.toMap());
|
||||||
|
if (_usePagination) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentProvider] Error updating equipment', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger les marques
|
||||||
|
Future<void> loadBrands() async {
|
||||||
|
await ensureLoaded();
|
||||||
|
_extractUniqueValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger les modèles
|
||||||
|
Future<void> loadModels() async {
|
||||||
|
await ensureLoaded();
|
||||||
|
_extractUniqueValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger les modèles d'une marque spécifique
|
||||||
|
Future<List<String>> loadModelsByBrand(String brand) async {
|
||||||
|
await ensureLoaded();
|
||||||
|
return _equipment
|
||||||
|
.where((eq) => eq.brand?.toLowerCase() == brand.toLowerCase())
|
||||||
|
.map((eq) => eq.model ?? '')
|
||||||
|
.where((model) => model.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charger les sous-catégories d'une catégorie spécifique
|
||||||
|
Future<List<String>> loadSubCategoriesByCategory(EquipmentCategory category) async {
|
||||||
|
await ensureLoaded();
|
||||||
|
return _equipment
|
||||||
|
.where((eq) => eq.category == category)
|
||||||
|
.map((eq) => eq.subCategory ?? '')
|
||||||
|
.where((sub) => sub.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculer le statut réel d'un équipement (pour badge)
|
||||||
|
EquipmentStatus calculateRealStatus(EquipmentModel equipment) {
|
||||||
|
// Pour les consommables/câbles, vérifier le seuil critique
|
||||||
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
|
equipment.category == EquipmentCategory.cable) {
|
||||||
|
final availableQty = equipment.availableQuantity ?? 0;
|
||||||
|
final criticalThreshold = equipment.criticalThreshold ?? 0;
|
||||||
|
|
||||||
|
if (criticalThreshold > 0 && availableQty <= criticalThreshold) {
|
||||||
|
return EquipmentStatus.maintenance; // Utiliser maintenance pour indiquer un problème
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon retourner le statut de base
|
||||||
|
return equipment.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,37 +1,59 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/event_model.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class EventProvider with ChangeNotifier {
|
class EventProvider with ChangeNotifier {
|
||||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
List<EventModel> _events = [];
|
List<EventModel> _events = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
List<EventModel> get events => _events;
|
List<EventModel> get events => _events;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
// Récupérer les événements pour un utilisateur spécifique
|
// Cache des utilisateurs chargés depuis getEvents
|
||||||
Future<void> loadUserEvents(String userId) async {
|
Map<String, Map<String, dynamic>> _usersCache = {};
|
||||||
|
|
||||||
|
/// Charger les événements d'un utilisateur via l'API
|
||||||
|
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false}) async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
// Sauvegarder les paramètres
|
||||||
|
_saveLastLoadParams(userId, canViewAllEvents);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('Loading events for user: $userId');
|
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||||
|
|
||||||
// Récupérer uniquement les événements où l'utilisateur est dans la workforce
|
// Charger via l'API - les permissions sont vérifiées côté serveur
|
||||||
final eventsSnapshot = await _firestore
|
final result = await _dataService.getEvents(userId: userId);
|
||||||
.collection('events')
|
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||||
.where('workforce', arrayContains: userId)
|
final usersData = result['users'] as Map<String, dynamic>;
|
||||||
.get();
|
|
||||||
|
|
||||||
print('Found ${eventsSnapshot.docs.length} events for user');
|
// Stocker les utilisateurs dans le cache
|
||||||
|
_usersCache = usersData.map((key, value) =>
|
||||||
|
MapEntry(key, value as Map<String, dynamic>)
|
||||||
|
);
|
||||||
|
|
||||||
_events = eventsSnapshot.docs.map((doc) {
|
print('Found ${eventsData.length} events from API');
|
||||||
print('Event data: ${doc.data()}');
|
|
||||||
return EventModel.fromMap(doc.data(), doc.id);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
print('Parsed ${_events.length} events');
|
List<EventModel> allEvents = [];
|
||||||
|
int failedCount = 0;
|
||||||
|
|
||||||
|
// Parser chaque événement
|
||||||
|
for (var eventData in eventsData) {
|
||||||
|
try {
|
||||||
|
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||||
|
allEvents.add(event);
|
||||||
|
} catch (e) {
|
||||||
|
print('Failed to parse event ${eventData['id']}: $e');
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_events = allEvents;
|
||||||
|
print('Successfully loaded ${_events.length} events (${failedCount} failed)');
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -43,37 +65,35 @@ class EventProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer un événement spécifique
|
/// Recharger les événements (utilise le dernier userId)
|
||||||
Future<EventModel?> getEvent(String eventId) async {
|
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
|
||||||
try {
|
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
||||||
final doc = await _firestore.collection('events').doc(eventId).get();
|
|
||||||
if (doc.exists) {
|
|
||||||
return EventModel.fromMap(doc.data()!, doc.id);
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
/// Récupérer un événement spécifique par ID
|
||||||
|
EventModel? getEventById(String eventId) {
|
||||||
|
try {
|
||||||
|
return _events.firstWhere((event) => event.id == eventId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error getting event: $e');
|
return null;
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajouter un nouvel événement
|
/// Ajouter un nouvel événement
|
||||||
Future<void> addEvent(EventModel event) async {
|
Future<void> addEvent(EventModel event) async {
|
||||||
try {
|
try {
|
||||||
final docRef = await _firestore.collection('events').add(event.toMap());
|
// L'événement est créé via l'API dans le service
|
||||||
final newEvent = EventModel.fromMap(event.toMap(), docRef.id);
|
await refreshEvents(_lastUserId ?? '', canViewAllEvents: _lastCanViewAll);
|
||||||
_events.add(newEvent);
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error adding event: $e');
|
print('Error adding event: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre à jour un événement
|
/// Mettre à jour un événement
|
||||||
Future<void> updateEvent(EventModel event) async {
|
Future<void> updateEvent(EventModel event) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('events').doc(event.id).update(event.toMap());
|
// Mise à jour locale immédiate
|
||||||
final index = _events.indexWhere((e) => e.id == event.id);
|
final index = _events.indexWhere((e) => e.id == event.id);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
_events[index] = event;
|
_events[index] = event;
|
||||||
@@ -85,10 +105,10 @@ class EventProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supprimer un événement
|
/// Supprimer un événement
|
||||||
Future<void> deleteEvent(String eventId) async {
|
Future<void> deleteEvent(String eventId) async {
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('events').doc(eventId).delete();
|
await _dataService.deleteEvent(eventId);
|
||||||
_events.removeWhere((event) => event.id == eventId);
|
_events.removeWhere((event) => event.id == eventId);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -97,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,15 +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/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;
|
||||||
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;
|
||||||
@@ -19,8 +23,10 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
String? get profilePhotoUrl => _currentUser?.profilePhotoUrl;
|
String? get profilePhotoUrl => _currentUser?.profilePhotoUrl;
|
||||||
String? get email => _currentUser?.email;
|
String? get email => _currentUser?.email;
|
||||||
String? get phoneNumber => _currentUser?.phoneNumber;
|
String? get phoneNumber => _currentUser?.phoneNumber;
|
||||||
|
RoleModel? get currentRole => _currentRole;
|
||||||
|
List<String> get permissions => _currentRole?.permissions ?? [];
|
||||||
|
|
||||||
/// Charge les données de l'utilisateur actuel
|
/// Charge les données de l'utilisateur actuel via Cloud Function
|
||||||
Future<void> loadUserData() async {
|
Future<void> loadUserData() async {
|
||||||
if (_auth.currentUser == null) {
|
if (_auth.currentUser == null) {
|
||||||
print('No current user in Auth');
|
print('No current user in Auth');
|
||||||
@@ -29,51 +35,31 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
|
|
||||||
print('Loading user data for: ${_auth.currentUser!.uid}');
|
print('Loading user data for: ${_auth.currentUser!.uid}');
|
||||||
try {
|
try {
|
||||||
DocumentSnapshot userDoc = await _firestore
|
// Utiliser la Cloud Function getCurrentUser
|
||||||
.collection('users')
|
final result = await apiService.call('getCurrentUser', {});
|
||||||
.doc(_auth.currentUser!.uid)
|
final userData = result['user'] as Map<String, dynamic>;
|
||||||
.get();
|
|
||||||
|
|
||||||
if (userDoc.exists) {
|
print('User data loaded from API: ${userData['uid']}');
|
||||||
print('User document found in Firestore');
|
|
||||||
final userData = userDoc.data() as Map<String, dynamic>;
|
|
||||||
print('User data: $userData');
|
|
||||||
|
|
||||||
// Si le document n'a pas d'UID, l'ajouter
|
// Extraire le rôle
|
||||||
if (!userData.containsKey('uid')) {
|
final roleData = userData['role'] as Map<String, dynamic>?;
|
||||||
await userDoc.reference.update({'uid': _auth.currentUser!.uid});
|
if (roleData != null) {
|
||||||
userData['uid'] = _auth.currentUser!.uid;
|
_currentRole = RoleModel.fromMap(roleData, roleData['id'] as String);
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(UserModel.fromMap(userData, userDoc.id));
|
// Créer le UserModel
|
||||||
print('User data loaded successfully');
|
_currentUser = UserModel(
|
||||||
} else {
|
uid: userData['uid'] as String,
|
||||||
print('No user document found in Firestore');
|
email: userData['email'] as String? ?? '',
|
||||||
// Créer un document utilisateur par défaut
|
firstName: userData['firstName'] as String? ?? '',
|
||||||
final defaultUser = UserModel(
|
lastName: userData['lastName'] as String? ?? '',
|
||||||
uid: _auth.currentUser!.uid,
|
role: roleData?['id'] as String? ?? 'USER',
|
||||||
email: _auth.currentUser!.email ?? '',
|
phoneNumber: userData['phoneNumber'] as String? ?? '',
|
||||||
firstName: '',
|
profilePhotoUrl: userData['profilePhotoUrl'] as String? ?? '',
|
||||||
lastName: '',
|
|
||||||
role: 'USER',
|
|
||||||
phoneNumber: '',
|
|
||||||
profilePhotoUrl: '',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await _firestore.collection('users').doc(_auth.currentUser!.uid).set({
|
print('User data loaded successfully');
|
||||||
'uid': _auth.currentUser!.uid,
|
notifyListeners();
|
||||||
'email': _auth.currentUser!.email,
|
|
||||||
'firstName': '',
|
|
||||||
'lastName': '',
|
|
||||||
'role': 'USER',
|
|
||||||
'phoneNumber': '',
|
|
||||||
'profilePhotoUrl': '',
|
|
||||||
'createdAt': FieldValue.serverTimestamp(),
|
|
||||||
});
|
|
||||||
|
|
||||||
setUser(defaultUser);
|
|
||||||
print('Default user document created');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading user data: $e');
|
print('Error loading user data: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -89,28 +75,55 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
/// Efface les données utilisateur
|
/// Efface les données utilisateur
|
||||||
void clearUser() {
|
void clearUser() {
|
||||||
_currentUser = null;
|
_currentUser = null;
|
||||||
|
_currentRole = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mise à jour des informations utilisateur
|
/// Mise à jour des informations utilisateur via Cloud Function
|
||||||
Future<void> updateUserData(
|
Future<void> updateUserData({
|
||||||
{String? firstName, String? lastName, String? phoneNumber}) async {
|
String? firstName,
|
||||||
|
String? lastName,
|
||||||
|
String? phoneNumber,
|
||||||
|
}) async {
|
||||||
if (_currentUser == null) return;
|
if (_currentUser == null) return;
|
||||||
try {
|
try {
|
||||||
await _firestore.collection('users').doc(_currentUser!.uid).set({
|
await _dataService.updateUser(
|
||||||
|
_currentUser!.uid,
|
||||||
|
{
|
||||||
'firstName': firstName ?? _currentUser!.firstName,
|
'firstName': firstName ?? _currentUser!.firstName,
|
||||||
'lastName': lastName ?? _currentUser!.lastName,
|
'lastName': lastName ?? _currentUser!.lastName,
|
||||||
'phone': phoneNumber ?? _currentUser!.phoneNumber,
|
'phoneNumber': phoneNumber ?? _currentUser!.phoneNumber,
|
||||||
}, SetOptions(merge: true));
|
},
|
||||||
|
);
|
||||||
|
|
||||||
_currentUser = _currentUser!.copyWith(
|
_currentUser = _currentUser!.copyWith(
|
||||||
firstName: firstName ?? _currentUser!.firstName,
|
firstName: firstName,
|
||||||
lastName: lastName ?? _currentUser!.lastName,
|
lastName: lastName,
|
||||||
phoneNumber: phoneNumber ?? _currentUser!.phoneNumber,
|
phoneNumber: phoneNumber,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Erreur mise à jour utilisateur : $e');
|
debugPrint('Erreur mise à jour utilisateur : $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mise à jour des préférences de notifications
|
||||||
|
Future<void> updateNotificationPreferences(NotificationPreferences preferences) async {
|
||||||
|
if (_currentUser == null) return;
|
||||||
|
try {
|
||||||
|
await _dataService.updateUser(
|
||||||
|
_currentUser!.uid,
|
||||||
|
{
|
||||||
|
'notificationPreferences': preferences.toMap(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_currentUser = _currentUser!.copyWith(notificationPreferences: preferences);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Erreur mise à jour préférences notifications : $e');
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,4 +169,21 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
await _auth.signOut();
|
await _auth.signOut();
|
||||||
clearUser();
|
clearUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'utilisateur a une permission spécifique
|
||||||
|
bool hasPermission(String permission) {
|
||||||
|
return _currentRole?.permissions.contains(permission) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'utilisateur a toutes les permissions données
|
||||||
|
bool hasAllPermissions(List<String> permissions) {
|
||||||
|
if (_currentRole == null) return false;
|
||||||
|
return permissions.every((p) => _currentRole!.permissions.contains(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'utilisateur a au moins une des permissions données
|
||||||
|
bool hasAnyPermission(List<String> permissions) {
|
||||||
|
if (_currentRole == null) return false;
|
||||||
|
return permissions.any((p) => _currentRole!.permissions.contains(p));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
em2rp/lib/providers/maintenance_provider.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:em2rp/models/maintenance_model.dart';
|
||||||
|
import 'package:em2rp/services/maintenance_service.dart';
|
||||||
|
|
||||||
|
class MaintenanceProvider extends ChangeNotifier {
|
||||||
|
final MaintenanceService _service = MaintenanceService();
|
||||||
|
|
||||||
|
List<MaintenanceModel> _maintenances = [];
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
List<MaintenanceModel> get maintenances => _maintenances;
|
||||||
|
|
||||||
|
/// Récupérer les maintenances pour un équipement spécifique
|
||||||
|
Future<List<MaintenanceModel>> getMaintenances(String equipmentId) async {
|
||||||
|
return await _service.getMaintenancesByEquipment(equipmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer toutes les maintenances
|
||||||
|
Future<List<MaintenanceModel>> getAllMaintenances() async {
|
||||||
|
return await _service.getAllMaintenances();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Créer une nouvelle maintenance
|
||||||
|
Future<void> createMaintenance(MaintenanceModel maintenance) async {
|
||||||
|
try {
|
||||||
|
await _service.createMaintenance(maintenance);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error creating maintenance: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mettre à jour une maintenance
|
||||||
|
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
await _service.updateMaintenance(id, data);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error updating maintenance: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprimer une maintenance
|
||||||
|
Future<void> deleteMaintenance(String id) async {
|
||||||
|
try {
|
||||||
|
await _service.deleteMaintenance(id);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error deleting maintenance: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer une maintenance par ID
|
||||||
|
Future<MaintenanceModel?> getMaintenanceById(String id) async {
|
||||||
|
try {
|
||||||
|
return await _service.getMaintenanceById(id);
|
||||||
|
} catch (e) {
|
||||||
|
print('Error getting maintenance: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marquer une maintenance comme complétée
|
||||||
|
Future<void> completeMaintenance(
|
||||||
|
String id, {
|
||||||
|
String? performedBy,
|
||||||
|
double? cost,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _service.completeMaintenance(id, performedBy: performedBy, cost: cost);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error completing maintenance: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifier les maintenances à venir
|
||||||
|
Future<void> checkUpcomingMaintenances() async {
|
||||||
|
try {
|
||||||
|
await _service.checkUpcomingMaintenances();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error checking upcoming maintenances: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer les maintenances en retard
|
||||||
|
List<MaintenanceModel> get overdueMaintances {
|
||||||
|
return _maintenances.where((m) => m.isOverdue).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer les maintenances complétées
|
||||||
|
List<MaintenanceModel> get completedMaintenances {
|
||||||
|
return _maintenances.where((m) => m.isCompleted).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupérer les maintenances à venir
|
||||||
|
List<MaintenanceModel> get upcomingMaintenances {
|
||||||
|
return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||