Compare commits
13 Commits
0bbc77ffc8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ea1e1335e3 | |||
| 323df01afe | |||
| 854b0a9bb0 | |||
| f8f6cfb102 | |||
| 9bc4e88e46 | |||
| f56615451e | |||
| 845b6e91d2 | |||
| 93c102012b | |||
| 6ee63ed29c | |||
| c35e633568 | |||
| 4284142b1e | |||
| 32f1718a8c | |||
| a59deb19a9 |
@@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
|
|||||||
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
||||||
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
||||||
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
||||||
version.json,1779745850580,c83e8cef9f09921b50bea3e26017c353fb516d339f57fbd0a8d3696f1ffc0e42
|
version.json,1779800968600,00f600f01984c1e371af870e40f78fd44ba53d05596f2f92f9b4fc56a85f52b6
|
||||||
index.html,1779745856220,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
index.html,1779800974065,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||||
flutter_bootstrap.js,1779745856203,79bfcfd09b63ba083702fd55c660d283686d9571b49febd8dcab49abbdf6f683
|
flutter_service_worker.js,1779801065196,879f42a05578f24ea45dc23326fdda6246d38dc59de0824ef8d4edfa4715e571
|
||||||
flutter_service_worker.js,1779745934512,3d18931ea97b2eeeba61c4fe7c0c8d736cc42ef9b8c2a6e4ec21e83e14e351ae
|
flutter_bootstrap.js,1779800974054,977a20d5caac8da21af648cae8fa7dba00a5cd959fd2fafa7ef538a012fe87c3
|
||||||
assets/FontManifest.json,1779745931038,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
assets/FontManifest.json,1779801061892,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||||
assets/AssetManifest.bin.json,1779745931038,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
assets/AssetManifest.json,1779801061891,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||||
assets/AssetManifest.bin,1779745931038,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
assets/AssetManifest.bin.json,1779801061892,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||||
assets/AssetManifest.json,1779745931038,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
assets/AssetManifest.bin,1779801061891,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||||
assets/shaders/ink_sparkle.frag,1779745931235,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779801064441,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779745933681,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
assets/shaders/ink_sparkle.frag,1779801062097,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||||
assets/fonts/MaterialIcons-Regular.otf,1779745933686,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
|
assets/fonts/MaterialIcons-Regular.otf,1779801064446,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
|
||||||
assets/NOTICES,1779745931041,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
|
assets/NOTICES,1779801061893,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
|
||||||
main.dart.js,1779745928953,60d92269024a5be234c7da2ebb889584e20c66a262b28f6d531a3f90c83767b3
|
main.dart.js,1779801060964,e87fb4dfca93c3384b5cb63d627186e48b0c15d78ed63bc7f5e61544ef292dd9
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
||||||
|
|
||||||
|
## 26/05/2026
|
||||||
|
Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.
|
||||||
|
|
||||||
## 25/05/2026
|
## 25/05/2026
|
||||||
Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.
|
Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ module.exports = {
|
|||||||
node: true,
|
node: true,
|
||||||
},
|
},
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
"ecmaVersion": 2018,
|
"ecmaVersion": 2020,
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
@@ -14,6 +14,14 @@ module.exports = {
|
|||||||
"no-restricted-globals": ["error", "name", "length"],
|
"no-restricted-globals": ["error", "name", "length"],
|
||||||
"prefer-arrow-callback": "error",
|
"prefer-arrow-callback": "error",
|
||||||
"quotes": ["error", "double", {"allowTemplateLiterals": true}],
|
"quotes": ["error", "double", {"allowTemplateLiterals": true}],
|
||||||
|
"max-len": "off",
|
||||||
|
"valid-jsdoc": "off",
|
||||||
|
"require-jsdoc": "off",
|
||||||
|
"guard-for-in": "off",
|
||||||
|
"no-unused-vars": "warn",
|
||||||
|
"brace-style": "off",
|
||||||
|
"object-curly-spacing": "off",
|
||||||
|
"arrow-parens": "off",
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
*.local
|
*.local
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
serviceAccountKey.json
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,34 @@
|
|||||||
const {onRequest} = require('firebase-functions/v2/https');
|
const {onRequest} = require("firebase-functions/v2/https");
|
||||||
const admin = require('firebase-admin');
|
const admin = require("firebase-admin");
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require("nodemailer");
|
||||||
const logger = require('firebase-functions/logger');
|
const logger = require("firebase-functions/logger");
|
||||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
|
||||||
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require('./utils/emailTemplates');
|
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require("./utils/emailTemplates");
|
||||||
const auth = require('./utils/auth');
|
const auth = require("./utils/auth");
|
||||||
|
|
||||||
// Configuration CORS
|
// Configuration CORS
|
||||||
const setCorsHeaders = (res, req) => {
|
const setCorsHeaders = (res, req) => {
|
||||||
// Utiliser l'origin de la requête pour permettre les credentials
|
// Utiliser l'origin de la requête pour permettre les credentials
|
||||||
const origin = req.headers.origin || '*';
|
const origin = req.headers.origin || "*";
|
||||||
|
|
||||||
res.set('Access-Control-Allow-Origin', origin);
|
res.set("Access-Control-Allow-Origin", origin);
|
||||||
|
|
||||||
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
|
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
|
||||||
if (origin !== '*') {
|
if (origin !== "*") {
|
||||||
res.set('Access-Control-Allow-Credentials', 'true');
|
res.set("Access-Control-Allow-Credentials", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
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-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With");
|
||||||
res.set('Access-Control-Max-Age', '3600');
|
res.set("Access-Control-Max-Age", "3600");
|
||||||
};
|
};
|
||||||
|
|
||||||
const withCors = (handler) => {
|
const withCors = (handler) => {
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
setCorsHeaders(res, req);
|
setCorsHeaders(res, req);
|
||||||
// Gérer les requêtes preflight OPTIONS immédiatement
|
// Gérer les requêtes preflight OPTIONS immédiatement
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === "OPTIONS") {
|
||||||
res.status(204).send('');
|
res.status(204).send("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -48,8 +48,8 @@ const withCors = (handler) => {
|
|||||||
*/
|
*/
|
||||||
exports.createAlert = onRequest({
|
exports.createAlert = onRequest({
|
||||||
cors: false,
|
cors: false,
|
||||||
invoker: 'public',
|
invoker: "public",
|
||||||
region: 'europe-west9'
|
region: "europe-west9",
|
||||||
}, withCors(async (req, res) => {
|
}, withCors(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Vérifier l'authentification
|
// Vérifier l'authentification
|
||||||
@@ -70,7 +70,7 @@ exports.createAlert = onRequest({
|
|||||||
|
|
||||||
// Validation des données
|
// Validation des données
|
||||||
if (!type || !severity || !message) {
|
if (!type || !severity || !message) {
|
||||||
res.status(400).json({error: 'type, severity et message sont requis'});
|
res.status(400).json({error: "type, severity et message sont requis"});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,12 +78,12 @@ exports.createAlert = onRequest({
|
|||||||
const userIds = await determineTargetUsers(type, severity, eventId);
|
const userIds = await determineTargetUsers(type, severity, eventId);
|
||||||
|
|
||||||
if (userIds.length === 0) {
|
if (userIds.length === 0) {
|
||||||
res.status(400).json({error: 'Aucun utilisateur à notifier'});
|
res.status(400).json({error: "Aucun utilisateur à notifier"});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Créer l'alerte dans Firestore
|
// 2. Créer l'alerte dans Firestore
|
||||||
const alertRef = admin.firestore().collection('alerts').doc();
|
const alertRef = admin.firestore().collection("alerts").doc();
|
||||||
const alertData = {
|
const alertData = {
|
||||||
id: alertRef.id,
|
id: alertRef.id,
|
||||||
type,
|
type,
|
||||||
@@ -99,14 +99,14 @@ exports.createAlert = onRequest({
|
|||||||
createdBy: decodedToken.uid,
|
createdBy: decodedToken.uid,
|
||||||
isRead: false,
|
isRead: false,
|
||||||
emailSent: false,
|
emailSent: false,
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
};
|
};
|
||||||
|
|
||||||
await alertRef.set(alertData);
|
await alertRef.set(alertData);
|
||||||
|
|
||||||
// 3. Envoyer les emails si alerte critique
|
// 3. Envoyer les emails si alerte critique
|
||||||
let emailResults = {};
|
let emailResults = {};
|
||||||
if (severity === 'CRITICAL') {
|
if (severity === "CRITICAL") {
|
||||||
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
|
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
|
||||||
|
|
||||||
// Mettre à jour le statut d'envoi
|
// Mettre à jour le statut d'envoi
|
||||||
@@ -124,7 +124,7 @@ exports.createAlert = onRequest({
|
|||||||
emailsSent: Object.values(emailResults).filter((v) => v).length,
|
emailsSent: Object.values(emailResults).filter((v) => v).length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[createAlert] Erreur:', error);
|
logger.error("[createAlert] Erreur:", error);
|
||||||
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
|
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -137,23 +137,23 @@ async function determineTargetUsers(alertType, severity, eventId) {
|
|||||||
const targetUserIds = new Set();
|
const targetUserIds = new Set();
|
||||||
|
|
||||||
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
|
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
|
||||||
const allUsersSnapshot = await db.collection('users').get();
|
const allUsersSnapshot = await db.collection("users").get();
|
||||||
|
|
||||||
allUsersSnapshot.forEach((doc) => {
|
allUsersSnapshot.forEach((doc) => {
|
||||||
const user = doc.data();
|
const user = doc.data();
|
||||||
if (user.role) {
|
if (user.role) {
|
||||||
// Le rôle peut être une référence Firestore ou une string
|
// Le rôle peut être une référence Firestore ou une string
|
||||||
let rolePath = '';
|
let rolePath = "";
|
||||||
if (typeof user.role === 'string') {
|
if (typeof user.role === "string") {
|
||||||
rolePath = user.role;
|
rolePath = user.role;
|
||||||
} else if (user.role.path) {
|
} else if (user.role.path) {
|
||||||
rolePath = user.role.path;
|
rolePath = user.role.path;
|
||||||
} else if (user.role._path && user.role._path.segments) {
|
} else if (user.role._path && user.role._path.segments) {
|
||||||
rolePath = user.role._path.segments.join('/');
|
rolePath = user.role._path.segments.join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier si c'est un admin (path = "roles/ADMIN")
|
// Vérifier si c'est un admin (path = "roles/ADMIN")
|
||||||
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
|
if (rolePath === "roles/ADMIN" || rolePath === "ADMIN") {
|
||||||
targetUserIds.add(doc.id);
|
targetUserIds.add(doc.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
|
|||||||
// 2. Si un événement est lié, ajouter tous les membres de la workforce
|
// 2. Si un événement est lié, ajouter tous les membres de la workforce
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
try {
|
try {
|
||||||
const eventDoc = await db.collection('events').doc(eventId).get();
|
const eventDoc = await db.collection("events").doc(eventId).get();
|
||||||
|
|
||||||
if (eventDoc.exists) {
|
if (eventDoc.exists) {
|
||||||
const event = eventDoc.data();
|
const event = eventDoc.data();
|
||||||
@@ -177,7 +177,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
|
|||||||
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
|
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
|
logger.error("[determineTargetUsers] Erreur récupération événement:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
|
|||||||
const db = admin.firestore();
|
const db = admin.firestore();
|
||||||
|
|
||||||
// Récupérer l'utilisateur
|
// Récupérer l'utilisateur
|
||||||
const userDoc = await db.collection('users').doc(userId).get();
|
const userDoc = await db.collection("users").doc(userId).get();
|
||||||
|
|
||||||
if (!userDoc.exists) {
|
if (!userDoc.exists) {
|
||||||
return false;
|
return false;
|
||||||
@@ -250,7 +250,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
|
|||||||
const templateData = await prepareTemplateData(alertData, user);
|
const templateData = await prepareTemplateData(alertData, user);
|
||||||
|
|
||||||
// Rendre le template
|
// Rendre le template
|
||||||
const html = await renderTemplate('alert-individual', templateData);
|
const html = await renderTemplate("alert-individual", templateData);
|
||||||
|
|
||||||
// Envoyer l'email
|
// Envoyer l'email
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
* Avec système de cache dans Firebase Storage
|
* Avec système de cache dans Firebase Storage
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const textToSpeech = require('@google-cloud/text-to-speech');
|
const textToSpeech = require("@google-cloud/text-to-speech");
|
||||||
const crypto = require('crypto');
|
const crypto = require("crypto");
|
||||||
const logger = require('firebase-functions/logger');
|
const logger = require("firebase-functions/logger");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Génère un hash MD5 pour le texte (utilisé comme clé de cache)
|
* Génère un hash MD5 pour le texte (utilisé comme clé de cache)
|
||||||
@@ -16,10 +16,10 @@ const logger = require('firebase-functions/logger');
|
|||||||
function generateCacheKey(text, voiceConfig = {}) {
|
function generateCacheKey(text, voiceConfig = {}) {
|
||||||
const cacheString = JSON.stringify({
|
const cacheString = JSON.stringify({
|
||||||
text,
|
text,
|
||||||
lang: voiceConfig.languageCode || 'fr-FR',
|
lang: voiceConfig.languageCode || "fr-FR",
|
||||||
voice: voiceConfig.name || 'fr-FR-Standard-B',
|
voice: voiceConfig.name || "fr-FR-Standard-B",
|
||||||
});
|
});
|
||||||
return crypto.createHash('md5').update(cacheString).digest('hex');
|
return crypto.createHash("md5").update(cacheString).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,21 +34,21 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
|||||||
try {
|
try {
|
||||||
// Validation du texte
|
// Validation du texte
|
||||||
if (!text || text.trim().length === 0) {
|
if (!text || text.trim().length === 0) {
|
||||||
throw new Error('Text cannot be empty');
|
throw new Error("Text cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.length > 5000) {
|
if (text.length > 5000) {
|
||||||
throw new Error('Text too long (max 5000 characters)');
|
throw new Error("Text too long (max 5000 characters)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration par défaut de la voix
|
// Configuration par défaut de la voix
|
||||||
const defaultVoiceConfig = {
|
const defaultVoiceConfig = {
|
||||||
languageCode: 'fr-FR',
|
languageCode: "fr-FR",
|
||||||
name: 'fr-FR-Standard-B', // Voix masculine française (Standard = gratuit)
|
name: "fr-FR-Standard-B", // Voix masculine française (Standard = gratuit)
|
||||||
ssmlGender: 'MALE',
|
ssmlGender: "MALE",
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalVoiceConfig = { ...defaultVoiceConfig, ...voiceConfig };
|
const finalVoiceConfig = {...defaultVoiceConfig, ...voiceConfig};
|
||||||
|
|
||||||
// Générer la clé de cache
|
// Générer la clé de cache
|
||||||
const cacheKey = generateCacheKey(text, finalVoiceConfig);
|
const cacheKey = generateCacheKey(text, finalVoiceConfig);
|
||||||
@@ -59,11 +59,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
|||||||
const [exists] = await file.exists();
|
const [exists] = await file.exists();
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
logger.info('[generateTTS] ✓ Cache HIT', { cacheKey, text: text.substring(0, 50) });
|
logger.info("[generateTTS] ✓ Cache HIT", {cacheKey, text: text.substring(0, 50)});
|
||||||
|
|
||||||
// Générer une URL signée valide 7 jours
|
// Générer une URL signée valide 7 jours
|
||||||
const [url] = await file.getSignedUrl({
|
const [url] = await file.getSignedUrl({
|
||||||
action: 'read',
|
action: "read",
|
||||||
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
|
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('[generateTTS] ○ Cache MISS - Generating audio', {
|
logger.info("[generateTTS] ○ Cache MISS - Generating audio", {
|
||||||
cacheKey,
|
cacheKey,
|
||||||
text: text.substring(0, 50),
|
text: text.substring(0, 50),
|
||||||
voice: finalVoiceConfig.name,
|
voice: finalVoiceConfig.name,
|
||||||
@@ -85,10 +85,10 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
|||||||
|
|
||||||
// Configuration de la requête
|
// Configuration de la requête
|
||||||
const request = {
|
const request = {
|
||||||
input: { text: text },
|
input: {text: text},
|
||||||
voice: finalVoiceConfig,
|
voice: finalVoiceConfig,
|
||||||
audioConfig: {
|
audioConfig: {
|
||||||
audioEncoding: 'MP3',
|
audioEncoding: "MP3",
|
||||||
speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension
|
speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension
|
||||||
pitch: -2.0, // Voix un peu plus grave
|
pitch: -2.0, // Voix un peu plus grave
|
||||||
volumeGainDb: 0.0,
|
volumeGainDb: 0.0,
|
||||||
@@ -99,17 +99,17 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
|||||||
const [response] = await client.synthesizeSpeech(request);
|
const [response] = await client.synthesizeSpeech(request);
|
||||||
|
|
||||||
if (!response.audioContent) {
|
if (!response.audioContent) {
|
||||||
throw new Error('No audio content returned from TTS API');
|
throw new Error("No audio content returned from TTS API");
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('[generateTTS] ✓ Audio generated', {
|
logger.info("[generateTTS] ✓ Audio generated", {
|
||||||
size: response.audioContent.length,
|
size: response.audioContent.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sauvegarder dans Firebase Storage
|
// Sauvegarder dans Firebase Storage
|
||||||
await file.save(response.audioContent, {
|
await file.save(response.audioContent, {
|
||||||
metadata: {
|
metadata: {
|
||||||
contentType: 'audio/mpeg',
|
contentType: "audio/mpeg",
|
||||||
metadata: {
|
metadata: {
|
||||||
text: text.substring(0, 100), // Premier 100 caractères pour debug
|
text: text.substring(0, 100), // Premier 100 caractères pour debug
|
||||||
voice: finalVoiceConfig.name,
|
voice: finalVoiceConfig.name,
|
||||||
@@ -118,11 +118,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('[generateTTS] ✓ Audio cached', { fileName });
|
logger.info("[generateTTS] ✓ Audio cached", {fileName});
|
||||||
|
|
||||||
// Générer une URL signée
|
// Générer une URL signée
|
||||||
const [url] = await file.getSignedUrl({
|
const [url] = await file.getSignedUrl({
|
||||||
action: 'read',
|
action: "read",
|
||||||
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
|
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
|||||||
cacheKey,
|
cacheKey,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[generateTTS] ✗ Error', {
|
logger.error("[generateTTS] ✗ Error", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
code: error.code,
|
code: error.code,
|
||||||
text: text?.substring(0, 50),
|
text: text?.substring(0, 50),
|
||||||
@@ -142,5 +142,5 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateTTS, generateCacheKey };
|
module.exports = {generateTTS, generateCacheKey};
|
||||||
|
|
||||||
|
|||||||
+276
-4333
File diff suppressed because it is too large
Load Diff
@@ -2,17 +2,17 @@
|
|||||||
* Script de migration : Active les emails pour tous les utilisateurs existants
|
* Script de migration : Active les emails pour tous les utilisateurs existants
|
||||||
* À exécuter une seule fois après le déploiement
|
* À exécuter une seule fois après le déploiement
|
||||||
*/
|
*/
|
||||||
const admin = require('firebase-admin');
|
const admin = require("firebase-admin");
|
||||||
const logger = require('firebase-functions/logger');
|
const logger = require("firebase-functions/logger");
|
||||||
|
|
||||||
// AJOUTER CECI : Charger le fichier de clé
|
// AJOUTER CECI : Charger le fichier de clé
|
||||||
const serviceAccount = require('./serviceAccountKey.json');
|
const serviceAccount = require("./serviceAccountKey.json");
|
||||||
|
|
||||||
// Initialiser Firebase Admin avec les credentials explicites
|
// Initialiser Firebase Admin avec les credentials explicites
|
||||||
if (!admin.apps.length) {
|
if (!admin.apps.length) {
|
||||||
admin.initializeApp({
|
admin.initializeApp({
|
||||||
credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici
|
credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici
|
||||||
projectId: 'em2rp-951dc',
|
projectId: "em2rp-951dc",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,11 +22,11 @@ const db = admin.firestore();
|
|||||||
* Active les notifications par email pour tous les utilisateurs existants
|
* Active les notifications par email pour tous les utilisateurs existants
|
||||||
*/
|
*/
|
||||||
async function migrateEmailPreferences() {
|
async function migrateEmailPreferences() {
|
||||||
console.log('=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n');
|
console.log("=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Récupérer tous les utilisateurs
|
// 1. Récupérer tous les utilisateurs
|
||||||
const usersSnapshot = await db.collection('users').get();
|
const usersSnapshot = await db.collection("users").get();
|
||||||
console.log(`✓ ${usersSnapshot.size} utilisateurs trouvés\n`);
|
console.log(`✓ ${usersSnapshot.size} utilisateurs trouvés\n`);
|
||||||
|
|
||||||
// 2. Préparer les updates
|
// 2. Préparer les updates
|
||||||
@@ -49,7 +49,7 @@ async function migrateEmailPreferences() {
|
|||||||
updates.push({
|
updates.push({
|
||||||
ref: doc.ref,
|
ref: doc.ref,
|
||||||
data: {
|
data: {
|
||||||
'notificationPreferences.emailEnabled': true,
|
"notificationPreferences.emailEnabled": true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ async function migrateEmailPreferences() {
|
|||||||
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
|
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('=== FIN MIGRATION ===');
|
console.log("=== FIN MIGRATION ===");
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
total: usersSnapshot.size,
|
total: usersSnapshot.size,
|
||||||
@@ -91,7 +91,7 @@ async function migrateEmailPreferences() {
|
|||||||
updated: toUpdate,
|
updated: toUpdate,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ ERREUR MIGRATION:', error);
|
console.error("❌ ERREUR MIGRATION:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,14 +100,14 @@ async function migrateEmailPreferences() {
|
|||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
migrateEmailPreferences()
|
migrateEmailPreferences()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
console.log('\n✓ Migration réussie:', result);
|
console.log("\n✓ Migration réussie:", result);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('\n❌ Migration échouée:', error);
|
console.error("\n❌ Migration échouée:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { migrateEmailPreferences };
|
module.exports = {migrateEmailPreferences};
|
||||||
|
|
||||||
|
|||||||
@@ -5,28 +5,28 @@
|
|||||||
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
|
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const admin = require('firebase-admin');
|
const admin = require("firebase-admin");
|
||||||
const serviceAccount = require('./serviceAccountKey.json');
|
const serviceAccount = require("./serviceAccountKey.json");
|
||||||
|
|
||||||
// Initialiser Firebase Admin
|
// Initialiser Firebase Admin
|
||||||
admin.initializeApp({
|
admin.initializeApp({
|
||||||
credential: admin.credential.cert(serviceAccount)
|
credential: admin.credential.cert(serviceAccount),
|
||||||
});
|
});
|
||||||
|
|
||||||
const db = admin.firestore();
|
const db = admin.firestore();
|
||||||
|
|
||||||
async function migrateEquipmentIds() {
|
async function migrateEquipmentIds() {
|
||||||
console.log('🔧 Migration: Ajout du champ id aux équipements');
|
console.log("🔧 Migration: Ajout du champ id aux équipements");
|
||||||
console.log('================================================\n');
|
console.log("================================================\n");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer tous les équipements
|
// Récupérer tous les équipements
|
||||||
const equipmentsSnapshot = await db.collection('equipments').get();
|
const equipmentsSnapshot = await db.collection("equipments").get();
|
||||||
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
|
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
|
||||||
|
|
||||||
let missingIdCount = 0;
|
let missingIdCount = 0;
|
||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
let errorCount = 0;
|
const errorCount = 0;
|
||||||
const batch = db.batch();
|
const batch = db.batch();
|
||||||
let batchCount = 0;
|
let batchCount = 0;
|
||||||
|
|
||||||
@@ -34,12 +34,12 @@ async function migrateEquipmentIds() {
|
|||||||
const data = doc.data();
|
const data = doc.data();
|
||||||
|
|
||||||
// Vérifier si le champ 'id' est manquant ou vide
|
// Vérifier si le champ 'id' est manquant ou vide
|
||||||
if (!data.id || data.id === '') {
|
if (!data.id || data.id === "") {
|
||||||
missingIdCount++;
|
missingIdCount++;
|
||||||
console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`);
|
console.log(`❌ Équipement ${doc.id} (${data.name || "Sans nom"}) : champ 'id' manquant`);
|
||||||
|
|
||||||
// Ajouter au batch
|
// Ajouter au batch
|
||||||
batch.update(doc.ref, { id: doc.id });
|
batch.update(doc.ref, {id: doc.id});
|
||||||
batchCount++;
|
batchCount++;
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
|
|
||||||
@@ -58,25 +58,24 @@ async function migrateEquipmentIds() {
|
|||||||
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
|
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n================================================');
|
console.log("\n================================================");
|
||||||
console.log('📊 RÉSUMÉ DE LA MIGRATION');
|
console.log("📊 RÉSUMÉ DE LA MIGRATION");
|
||||||
console.log('================================================');
|
console.log("================================================");
|
||||||
console.log(`Total d'équipements: ${equipmentsSnapshot.size}`);
|
console.log(`Total d'équipements: ${equipmentsSnapshot.size}`);
|
||||||
console.log(`Équipements avec 'id' manquant: ${missingIdCount}`);
|
console.log(`Équipements avec 'id' manquant: ${missingIdCount}`);
|
||||||
console.log(`Équipements mis à jour: ${updatedCount}`);
|
console.log(`Équipements mis à jour: ${updatedCount}`);
|
||||||
console.log(`Erreurs: ${errorCount}`);
|
console.log(`Erreurs: ${errorCount}`);
|
||||||
console.log('================================================\n');
|
console.log("================================================\n");
|
||||||
|
|
||||||
if (missingIdCount === 0) {
|
if (missingIdCount === 0) {
|
||||||
console.log('✅ Tous les équipements ont déjà un champ id !');
|
console.log("✅ Tous les équipements ont déjà un champ id !");
|
||||||
} else if (updatedCount === missingIdCount) {
|
} else if (updatedCount === missingIdCount) {
|
||||||
console.log('✅ Migration terminée avec succès !');
|
console.log("✅ Migration terminée avec succès !");
|
||||||
} else {
|
} else {
|
||||||
console.log('⚠️ Migration terminée avec des erreurs');
|
console.log("⚠️ Migration terminée avec des erreurs");
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur lors de la migration:', error);
|
console.error("❌ Erreur lors de la migration:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,10 +83,10 @@ async function migrateEquipmentIds() {
|
|||||||
// Exécuter la migration
|
// Exécuter la migration
|
||||||
migrateEquipmentIds()
|
migrateEquipmentIds()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('\n✅ Script terminé');
|
console.log("\n✅ Script terminé");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
console.error('\n❌ Script échoué:', error);
|
console.error("\n❌ Script échoué:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
const {onCall} = require('firebase-functions/v2/https');
|
const {onCall} = require("firebase-functions/v2/https");
|
||||||
const admin = require('firebase-admin');
|
const admin = require("firebase-admin");
|
||||||
const logger = require('firebase-functions/logger');
|
const logger = require("firebase-functions/logger");
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require("nodemailer");
|
||||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
|
||||||
/**
|
/**
|
||||||
* Traite la validation du matériel d'un événement
|
* Traite la validation du matériel d'un événement
|
||||||
* Appelée par le client lors du chargement/déchargement
|
* Appelée par le client lors du chargement/déchargement
|
||||||
@@ -10,14 +10,14 @@ const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
|||||||
*/
|
*/
|
||||||
exports.processEquipmentValidation = onCall({
|
exports.processEquipmentValidation = onCall({
|
||||||
cors: true,
|
cors: true,
|
||||||
region: 'europe-west9'
|
region: "europe-west9",
|
||||||
}, async (request) => {
|
}, async (request) => {
|
||||||
try {
|
try {
|
||||||
// L'authentification est automatique avec onCall
|
// L'authentification est automatique avec onCall
|
||||||
const {auth, data} = request;
|
const {auth, data} = request;
|
||||||
|
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
throw new Error('L\'utilisateur doit être authentifié');
|
throw new Error("L'utilisateur doit être authentifié");
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -28,22 +28,22 @@ exports.processEquipmentValidation = onCall({
|
|||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!eventId || !equipmentList || !validationType) {
|
if (!eventId || !equipmentList || !validationType) {
|
||||||
throw new Error('eventId, equipmentList et validationType sont requis');
|
throw new Error("eventId, equipmentList et validationType sont requis");
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = admin.firestore();
|
const db = admin.firestore();
|
||||||
const alerts = [];
|
const alerts = [];
|
||||||
|
|
||||||
// 1. Récupérer les détails de l'événement
|
// 1. Récupérer les détails de l'événement
|
||||||
const eventRef = db.collection('events').doc(eventId);
|
const eventRef = db.collection("events").doc(eventId);
|
||||||
const eventDoc = await eventRef.get();
|
const eventDoc = await eventRef.get();
|
||||||
|
|
||||||
if (!eventDoc.exists) {
|
if (!eventDoc.exists) {
|
||||||
throw new Error('Événement introuvable');
|
throw new Error("Événement introuvable");
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = eventDoc.data();
|
const event = eventDoc.data();
|
||||||
const eventName = event.Name || event.name || 'Événement inconnu';
|
const eventName = event.Name || event.name || "Événement inconnu";
|
||||||
const eventDate = formatEventDate(event);
|
const eventDate = formatEventDate(event);
|
||||||
|
|
||||||
// 2. Analyser les équipements et détecter les problèmes
|
// 2. Analyser les équipements et détecter les problèmes
|
||||||
@@ -51,16 +51,16 @@ exports.processEquipmentValidation = onCall({
|
|||||||
const {equipmentId, status, quantity, expectedQuantity} = equipment;
|
const {equipmentId, status, quantity, expectedQuantity} = equipment;
|
||||||
|
|
||||||
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
|
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
|
||||||
if (status === 'NOT_TAKEN') {
|
if (status === "NOT_TAKEN") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cas 1: Équipement PERDU
|
// Cas 1: Équipement PERDU
|
||||||
if (status === 'LOST') {
|
if (status === "LOST") {
|
||||||
const alertData = await createAlertInFirestore({
|
const alertData = await createAlertInFirestore({
|
||||||
type: 'LOST',
|
type: "LOST",
|
||||||
severity: 'CRITICAL',
|
severity: "CRITICAL",
|
||||||
title: 'Équipement perdu',
|
title: "Équipement perdu",
|
||||||
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
|
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
|
||||||
equipmentId,
|
equipmentId,
|
||||||
eventId,
|
eventId,
|
||||||
@@ -76,11 +76,11 @@ exports.processEquipmentValidation = onCall({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cas 2: Équipement MANQUANT
|
// Cas 2: Équipement MANQUANT
|
||||||
if (status === 'MISSING') {
|
if (status === "MISSING") {
|
||||||
const alertData = await createAlertInFirestore({
|
const alertData = await createAlertInFirestore({
|
||||||
type: 'EQUIPMENT_MISSING',
|
type: "EQUIPMENT_MISSING",
|
||||||
severity: 'WARNING',
|
severity: "WARNING",
|
||||||
title: 'Équipement manquant',
|
title: "Équipement manquant",
|
||||||
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
|
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
|
||||||
equipmentId,
|
equipmentId,
|
||||||
eventId,
|
eventId,
|
||||||
@@ -96,13 +96,13 @@ exports.processEquipmentValidation = onCall({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cas 3: Quantité incorrecte
|
// Cas 3: Quantité incorrecte
|
||||||
const hasExpectedQuantity = typeof expectedQuantity === 'number';
|
const hasExpectedQuantity = typeof expectedQuantity === "number";
|
||||||
const hasActualQuantity = typeof quantity === 'number';
|
const hasActualQuantity = typeof quantity === "number";
|
||||||
if (hasExpectedQuantity && hasActualQuantity && quantity !== expectedQuantity) {
|
if (hasExpectedQuantity && hasActualQuantity && quantity !== expectedQuantity) {
|
||||||
const alertData = await createAlertInFirestore({
|
const alertData = await createAlertInFirestore({
|
||||||
type: 'QUANTITY_MISMATCH',
|
type: "QUANTITY_MISMATCH",
|
||||||
severity: 'INFO',
|
severity: "INFO",
|
||||||
title: 'Quantité incorrecte',
|
title: "Quantité incorrecte",
|
||||||
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
|
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
|
||||||
equipmentId,
|
equipmentId,
|
||||||
eventId,
|
eventId,
|
||||||
@@ -120,11 +120,11 @@ exports.processEquipmentValidation = onCall({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cas 4: Équipement endommagé
|
// Cas 4: Équipement endommagé
|
||||||
if (status === 'DAMAGED') {
|
if (status === "DAMAGED") {
|
||||||
const alertData = await createAlertInFirestore({
|
const alertData = await createAlertInFirestore({
|
||||||
type: 'DAMAGED',
|
type: "DAMAGED",
|
||||||
severity: 'WARNING',
|
severity: "WARNING",
|
||||||
title: 'Équipement endommagé',
|
title: "Équipement endommagé",
|
||||||
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
|
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
|
||||||
equipmentId,
|
equipmentId,
|
||||||
eventId,
|
eventId,
|
||||||
@@ -151,7 +151,7 @@ exports.processEquipmentValidation = onCall({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 4. Envoyer les notifications pour les alertes critiques
|
// 4. Envoyer les notifications pour les alertes critiques
|
||||||
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
|
const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL");
|
||||||
if (criticalAlerts.length > 0) {
|
if (criticalAlerts.length > 0) {
|
||||||
for (const alert of criticalAlerts) {
|
for (const alert of criticalAlerts) {
|
||||||
try {
|
try {
|
||||||
@@ -169,7 +169,7 @@ exports.processEquipmentValidation = onCall({
|
|||||||
alertIds: alerts.map((a) => a.id),
|
alertIds: alerts.map((a) => a.id),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[processEquipmentValidation] Erreur:', error);
|
logger.error("[processEquipmentValidation] Erreur:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -179,14 +179,14 @@ exports.processEquipmentValidation = onCall({
|
|||||||
*/
|
*/
|
||||||
async function createAlertInFirestore(alertData) {
|
async function createAlertInFirestore(alertData) {
|
||||||
const db = admin.firestore();
|
const db = admin.firestore();
|
||||||
const alertRef = db.collection('alerts').doc();
|
const alertRef = db.collection("alerts").doc();
|
||||||
|
|
||||||
const fullAlertData = {
|
const fullAlertData = {
|
||||||
id: alertRef.id,
|
id: alertRef.id,
|
||||||
...alertData,
|
...alertData,
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
isRead: false,
|
isRead: false,
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
emailSent: false,
|
emailSent: false,
|
||||||
assignedTo: [],
|
assignedTo: [],
|
||||||
};
|
};
|
||||||
@@ -206,7 +206,7 @@ async function sendAlertNotifications(alert, eventId) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Récupérer TOUS les utilisateurs et leurs permissions
|
// 1. Récupérer TOUS les utilisateurs et leurs permissions
|
||||||
const allUsersSnapshot = await db.collection('users').get();
|
const allUsersSnapshot = await db.collection("users").get();
|
||||||
|
|
||||||
// Créer un map pour stocker les références de rôles à récupérer
|
// Créer un map pour stocker les références de rôles à récupérer
|
||||||
const roleRefs = new Map();
|
const roleRefs = new Map();
|
||||||
@@ -219,17 +219,17 @@ async function sendAlertNotifications(alert, eventId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extraire le chemin du rôle
|
// Extraire le chemin du rôle
|
||||||
let rolePath = '';
|
let rolePath = "";
|
||||||
let roleId = '';
|
let roleId = "";
|
||||||
|
|
||||||
if (typeof user.role === 'string') {
|
if (typeof user.role === "string") {
|
||||||
rolePath = user.role;
|
rolePath = user.role;
|
||||||
roleId = user.role.split('/').pop();
|
roleId = user.role.split("/").pop();
|
||||||
} else if (user.role.path) {
|
} else if (user.role.path) {
|
||||||
rolePath = user.role.path;
|
rolePath = user.role.path;
|
||||||
roleId = user.role.path.split('/').pop();
|
roleId = user.role.path.split("/").pop();
|
||||||
} else if (user.role._path && user.role._path.segments) {
|
} else if (user.role._path && user.role._path.segments) {
|
||||||
rolePath = user.role._path.segments.join('/');
|
rolePath = user.role._path.segments.join("/");
|
||||||
roleId = user.role._path.segments[user.role._path.segments.length - 1];
|
roleId = user.role._path.segments[user.role._path.segments.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,14 +245,14 @@ async function sendAlertNotifications(alert, eventId) {
|
|||||||
// 2. Récupérer les permissions de chaque rôle unique
|
// 2. Récupérer les permissions de chaque rôle unique
|
||||||
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
|
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
|
||||||
try {
|
try {
|
||||||
const roleDoc = await db.collection('roles').doc(roleId).get();
|
const roleDoc = await db.collection("roles").doc(roleId).get();
|
||||||
|
|
||||||
if (roleDoc.exists) {
|
if (roleDoc.exists) {
|
||||||
const roleData = roleDoc.data();
|
const roleData = roleDoc.data();
|
||||||
const permissions = roleData.permissions || [];
|
const permissions = roleData.permissions || [];
|
||||||
|
|
||||||
// Vérifier si le rôle a la permission view_all_events
|
// Vérifier si le rôle a la permission view_all_events
|
||||||
if (permissions.includes('view_all_events')) {
|
if (permissions.includes("view_all_events")) {
|
||||||
users.forEach((userId) => {
|
users.forEach((userId) => {
|
||||||
usersWithPermission.add(userId);
|
usersWithPermission.add(userId);
|
||||||
targetUserIds.add(userId);
|
targetUserIds.add(userId);
|
||||||
@@ -266,7 +266,7 @@ async function sendAlertNotifications(alert, eventId) {
|
|||||||
|
|
||||||
// 3. Ajouter la workforce de l'événement
|
// 3. Ajouter la workforce de l'événement
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
const eventDoc = await db.collection('events').doc(eventId).get();
|
const eventDoc = await db.collection("events").doc(eventId).get();
|
||||||
|
|
||||||
if (eventDoc.exists) {
|
if (eventDoc.exists) {
|
||||||
const event = eventDoc.data();
|
const event = eventDoc.data();
|
||||||
@@ -276,14 +276,14 @@ async function sendAlertNotifications(alert, eventId) {
|
|||||||
// Extraire l'userId selon différentes structures possibles
|
// Extraire l'userId selon différentes structures possibles
|
||||||
let userId = null;
|
let userId = null;
|
||||||
|
|
||||||
if (typeof member === 'string') {
|
if (typeof member === "string") {
|
||||||
userId = member;
|
userId = member;
|
||||||
} else if (member.userId) {
|
} else if (member.userId) {
|
||||||
userId = member.userId;
|
userId = member.userId;
|
||||||
} else if (member.id) {
|
} else if (member.id) {
|
||||||
userId = member.id;
|
userId = member.id;
|
||||||
} else if (member.user) {
|
} else if (member.user) {
|
||||||
if (typeof member.user === 'string') {
|
if (typeof member.user === "string") {
|
||||||
userId = member.user;
|
userId = member.user;
|
||||||
} else if (member.user.id) {
|
} else if (member.user.id) {
|
||||||
userId = member.user.id;
|
userId = member.user.id;
|
||||||
@@ -300,18 +300,18 @@ async function sendAlertNotifications(alert, eventId) {
|
|||||||
const userIds = Array.from(targetUserIds);
|
const userIds = Array.from(targetUserIds);
|
||||||
|
|
||||||
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
|
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
|
||||||
await db.collection('alerts').doc(alert.id).update({
|
await db.collection("alerts").doc(alert.id).update({
|
||||||
assignedTo: userIds,
|
assignedTo: userIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Envoyer les emails si alerte critique
|
// 5. Envoyer les emails si alerte critique
|
||||||
if (alert.severity === 'CRITICAL') {
|
if (alert.severity === "CRITICAL") {
|
||||||
await sendAlertEmails(alert, userIds);
|
await sendAlertEmails(alert, userIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
return userIds;
|
return userIds;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[sendAlertNotifications] Erreur:', error);
|
logger.error("[sendAlertNotifications] Erreur:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,12 +321,12 @@ async function sendAlertNotifications(alert, eventId) {
|
|||||||
*/
|
*/
|
||||||
async function sendAlertEmails(alert, userIds) {
|
async function sendAlertEmails(alert, userIds) {
|
||||||
try {
|
try {
|
||||||
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
|
const {renderTemplate, getEmailSubject, prepareTemplateData} = require("./utils/emailTemplates");
|
||||||
const db = admin.firestore();
|
const db = admin.firestore();
|
||||||
|
|
||||||
// Vérifier que EMAIL_CONFIG est disponible
|
// Vérifier que EMAIL_CONFIG est disponible
|
||||||
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
|
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
|
||||||
logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré');
|
logger.error("[sendAlertEmails] EMAIL_CONFIG non configuré");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +343,7 @@ async function sendAlertEmails(alert, userIds) {
|
|||||||
const promises = batch.map(async (userId) => {
|
const promises = batch.map(async (userId) => {
|
||||||
try {
|
try {
|
||||||
// Récupérer l'utilisateur
|
// Récupérer l'utilisateur
|
||||||
const userDoc = await db.collection('users').doc(userId).get();
|
const userDoc = await db.collection("users").doc(userId).get();
|
||||||
|
|
||||||
if (!userDoc.exists) {
|
if (!userDoc.exists) {
|
||||||
return false;
|
return false;
|
||||||
@@ -365,13 +365,13 @@ async function sendAlertEmails(alert, userIds) {
|
|||||||
let html;
|
let html;
|
||||||
try {
|
try {
|
||||||
const templateData = await prepareTemplateData(alert, user);
|
const templateData = await prepareTemplateData(alert, user);
|
||||||
html = await renderTemplate('alert-individual', templateData);
|
html = await renderTemplate("alert-individual", templateData);
|
||||||
} catch (templateError) {
|
} catch (templateError) {
|
||||||
logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError);
|
logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError);
|
||||||
html = `
|
html = `
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<h2>${alert.title || 'Nouvelle alerte'}</h2>
|
<h2>${alert.title || "Nouvelle alerte"}</h2>
|
||||||
<p>${alert.message}</p>
|
<p>${alert.message}</p>
|
||||||
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
|
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
|
||||||
</body>
|
</body>
|
||||||
@@ -399,7 +399,7 @@ async function sendAlertEmails(alert, userIds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mettre à jour l'alerte
|
// Mettre à jour l'alerte
|
||||||
await db.collection('alerts').doc(alert.id).update({
|
await db.collection("alerts").doc(alert.id).update({
|
||||||
emailSent: true,
|
emailSent: true,
|
||||||
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
emailsSentCount: successCount,
|
emailsSentCount: successCount,
|
||||||
@@ -407,7 +407,7 @@ async function sendAlertEmails(alert, userIds) {
|
|||||||
|
|
||||||
return successCount;
|
return successCount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[sendAlertEmails] Erreur globale:', error);
|
logger.error("[sendAlertEmails] Erreur globale:", error);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,10 +425,10 @@ function formatEventDate(event) {
|
|||||||
const parsedDate = parseFirestoreDate(rawDate);
|
const parsedDate = parseFirestoreDate(rawDate);
|
||||||
const safeDate = parsedDate || new Date();
|
const safeDate = parsedDate || new Date();
|
||||||
|
|
||||||
return safeDate.toLocaleDateString('fr-FR', {
|
return safeDate.toLocaleDateString("fr-FR", {
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
month: 'numeric',
|
month: "numeric",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +437,7 @@ function parseFirestoreDate(value) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value.toDate === 'function') {
|
if (typeof value.toDate === "function") {
|
||||||
return value.toDate();
|
return value.toDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,16 +445,16 @@ function parseFirestoreDate(value) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'string' || typeof value === 'number') {
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'object' && typeof value.seconds === 'number') {
|
if (typeof value === "object" && typeof value.seconds === "number") {
|
||||||
return new Date(value.seconds * 1000);
|
return new Date(value.seconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'object' && typeof value._seconds === 'number') {
|
if (typeof value === "object" && typeof value._seconds === "number") {
|
||||||
return new Date(value._seconds * 1000);
|
return new Date(value._seconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
const {onCall} = require('firebase-functions/v2/https');
|
const {onCall} = require("firebase-functions/v2/https");
|
||||||
const admin = require('firebase-admin');
|
const admin = require("firebase-admin");
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require("nodemailer");
|
||||||
const handlebars = require('handlebars');
|
const handlebars = require("handlebars");
|
||||||
const fs = require('fs').promises;
|
const fs = require("fs").promises;
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Envoie un email d'alerte à un utilisateur
|
* Envoie un email d'alerte à un utilisateur
|
||||||
* Appelé par le client Dart via callable function
|
* Appelé par le client Dart via callable function
|
||||||
*/
|
*/
|
||||||
exports.sendAlertEmail = onCall({
|
exports.sendAlertEmail = onCall({
|
||||||
region: 'europe-west9',
|
region: "europe-west9",
|
||||||
cors: true
|
cors: true,
|
||||||
}, async (request) => {
|
}, async (request) => {
|
||||||
// Vérifier l'authentification
|
// Vérifier l'authentification
|
||||||
if (!request.auth) {
|
if (!request.auth) {
|
||||||
throw new Error('L\'utilisateur doit être authentifié');
|
throw new Error("L'utilisateur doit être authentifié");
|
||||||
}
|
}
|
||||||
|
|
||||||
const {alertId, userId, templateType} = request.data;
|
const {alertId, userId, templateType} = request.data;
|
||||||
|
|
||||||
if (!alertId || !userId) {
|
if (!alertId || !userId) {
|
||||||
throw new Error('alertId et userId sont requis');
|
throw new Error("alertId et userId sont requis");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer l'alerte depuis Firestore
|
// Récupérer l'alerte depuis Firestore
|
||||||
const alertDoc = await admin.firestore()
|
const alertDoc = await admin.firestore()
|
||||||
.collection('alerts')
|
.collection("alerts")
|
||||||
.doc(alertId)
|
.doc(alertId)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!alertDoc.exists) {
|
if (!alertDoc.exists) {
|
||||||
throw new Error('Alerte introuvable');
|
throw new Error("Alerte introuvable");
|
||||||
}
|
}
|
||||||
|
|
||||||
const alert = alertDoc.data();
|
const alert = alertDoc.data();
|
||||||
|
|
||||||
// Récupérer l'utilisateur
|
// Récupérer l'utilisateur
|
||||||
const userDoc = await admin.firestore()
|
const userDoc = await admin.firestore()
|
||||||
.collection('users')
|
.collection("users")
|
||||||
.doc(userId)
|
.doc(userId)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!userDoc.exists) {
|
if (!userDoc.exists) {
|
||||||
throw new Error('Utilisateur introuvable');
|
throw new Error("Utilisateur introuvable");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userDoc.data();
|
const user = userDoc.data();
|
||||||
@@ -54,7 +54,7 @@ exports.sendAlertEmail = onCall({
|
|||||||
const prefs = user.notificationPreferences || {};
|
const prefs = user.notificationPreferences || {};
|
||||||
if (!prefs.emailEnabled) {
|
if (!prefs.emailEnabled) {
|
||||||
console.log(`Email désactivé pour l'utilisateur ${userId}`);
|
console.log(`Email désactivé pour l'utilisateur ${userId}`);
|
||||||
return {success: true, skipped: true, reason: 'email_disabled'};
|
return {success: true, skipped: true, reason: "email_disabled"};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier la préférence pour ce type d'alerte
|
// Vérifier la préférence pour ce type d'alerte
|
||||||
@@ -62,7 +62,7 @@ exports.sendAlertEmail = onCall({
|
|||||||
const shouldSend = checkAlertPreference(alertType, prefs);
|
const shouldSend = checkAlertPreference(alertType, prefs);
|
||||||
if (!shouldSend) {
|
if (!shouldSend) {
|
||||||
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
|
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
|
||||||
return {success: true, skipped: true, reason: 'alert_type_disabled'};
|
return {success: true, skipped: true, reason: "alert_type_disabled"};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Préparer les données pour le template
|
// Préparer les données pour le template
|
||||||
@@ -70,7 +70,7 @@ exports.sendAlertEmail = onCall({
|
|||||||
|
|
||||||
// Rendre le template HTML
|
// Rendre le template HTML
|
||||||
const html = await renderTemplate(
|
const html = await renderTemplate(
|
||||||
templateType || 'alert-individual',
|
templateType || "alert-individual",
|
||||||
templateData,
|
templateData,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ exports.sendAlertEmail = onCall({
|
|||||||
text: alert.message,
|
text: alert.message,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Email envoyé:', info.messageId);
|
console.log("Email envoyé:", info.messageId);
|
||||||
|
|
||||||
// Marquer l'email comme envoyé dans l'alerte
|
// Marquer l'email comme envoyé dans l'alerte
|
||||||
await alertDoc.ref.update({
|
await alertDoc.ref.update({
|
||||||
@@ -102,7 +102,7 @@ exports.sendAlertEmail = onCall({
|
|||||||
skipped: false,
|
skipped: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur envoi email:', error);
|
console.error("Erreur envoi email:", error);
|
||||||
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
|
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -112,13 +112,13 @@ exports.sendAlertEmail = onCall({
|
|||||||
*/
|
*/
|
||||||
function checkAlertPreference(alertType, preferences) {
|
function checkAlertPreference(alertType, preferences) {
|
||||||
const typeMapping = {
|
const typeMapping = {
|
||||||
'EVENT_CREATED': 'eventsNotifications',
|
"EVENT_CREATED": "eventsNotifications",
|
||||||
'EVENT_MODIFIED': 'eventsNotifications',
|
"EVENT_MODIFIED": "eventsNotifications",
|
||||||
'EVENT_CANCELLED': 'eventsNotifications',
|
"EVENT_CANCELLED": "eventsNotifications",
|
||||||
'LOST': 'equipmentNotifications',
|
"LOST": "equipmentNotifications",
|
||||||
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
"EQUIPMENT_MISSING": "equipmentNotifications",
|
||||||
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
"MAINTENANCE_REMINDER": "maintenanceNotifications",
|
||||||
'STOCK_LOW': 'stockNotifications',
|
"STOCK_LOW": "stockNotifications",
|
||||||
};
|
};
|
||||||
|
|
||||||
const prefKey = typeMapping[alertType];
|
const prefKey = typeMapping[alertType];
|
||||||
@@ -130,12 +130,12 @@ function checkAlertPreference(alertType, preferences) {
|
|||||||
*/
|
*/
|
||||||
async function prepareTemplateData(alert, user) {
|
async function prepareTemplateData(alert, user) {
|
||||||
const data = {
|
const data = {
|
||||||
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() ||
|
||||||
'Utilisateur',
|
"Utilisateur",
|
||||||
alertTitle: getAlertTitle(alert.type),
|
alertTitle: getAlertTitle(alert.type),
|
||||||
alertMessage: alert.message,
|
alertMessage: alert.message,
|
||||||
isCritical: alert.severity === 'CRITICAL',
|
isCritical: alert.severity === "CRITICAL",
|
||||||
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`,
|
||||||
appUrl: EMAIL_CONFIG.appUrl,
|
appUrl: EMAIL_CONFIG.appUrl,
|
||||||
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
@@ -146,7 +146,7 @@ async function prepareTemplateData(alert, user) {
|
|||||||
if (alert.eventId) {
|
if (alert.eventId) {
|
||||||
try {
|
try {
|
||||||
const eventDoc = await admin.firestore()
|
const eventDoc = await admin.firestore()
|
||||||
.collection('events')
|
.collection("events")
|
||||||
.doc(alert.eventId)
|
.doc(alert.eventId)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
@@ -155,22 +155,22 @@ async function prepareTemplateData(alert, user) {
|
|||||||
data.eventName = event.Name;
|
data.eventName = event.Name;
|
||||||
if (event.StartDateTime) {
|
if (event.StartDateTime) {
|
||||||
const date = event.StartDateTime.toDate();
|
const date = event.StartDateTime.toDate();
|
||||||
data.eventDate = date.toLocaleDateString('fr-FR', {
|
data.eventDate = date.toLocaleDateString("fr-FR", {
|
||||||
day: '2-digit',
|
day: "2-digit",
|
||||||
month: '2-digit',
|
month: "2-digit",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur récupération événement:', error);
|
console.error("Erreur récupération événement:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (alert.equipmentId) {
|
if (alert.equipmentId) {
|
||||||
try {
|
try {
|
||||||
const eqDoc = await admin.firestore()
|
const eqDoc = await admin.firestore()
|
||||||
.collection('equipments')
|
.collection("equipments")
|
||||||
.doc(alert.equipmentId)
|
.doc(alert.equipmentId)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ async function prepareTemplateData(alert, user) {
|
|||||||
data.equipmentName = eqDoc.data().name;
|
data.equipmentName = eqDoc.data().name;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur récupération équipement:', error);
|
console.error("Erreur récupération équipement:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,16 +190,16 @@ async function prepareTemplateData(alert, user) {
|
|||||||
*/
|
*/
|
||||||
function getEmailSubject(alert) {
|
function getEmailSubject(alert) {
|
||||||
const subjects = {
|
const subjects = {
|
||||||
'EVENT_CREATED': '📅 Nouvel événement créé',
|
"EVENT_CREATED": "📅 Nouvel événement créé",
|
||||||
'EVENT_MODIFIED': '📝 Événement modifié',
|
"EVENT_MODIFIED": "📝 Événement modifié",
|
||||||
'EVENT_CANCELLED': '❌ Événement annulé',
|
"EVENT_CANCELLED": "❌ Événement annulé",
|
||||||
'LOST': '🔴 Alerte critique : Équipement perdu',
|
"LOST": "🔴 Alerte critique : Équipement perdu",
|
||||||
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
"EQUIPMENT_MISSING": "⚠️ Équipement manquant",
|
||||||
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
"MAINTENANCE_REMINDER": "🔧 Rappel de maintenance",
|
||||||
'STOCK_LOW': '📦 Stock faible',
|
"STOCK_LOW": "📦 Stock faible",
|
||||||
};
|
};
|
||||||
|
|
||||||
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,16 +207,16 @@ function getEmailSubject(alert) {
|
|||||||
*/
|
*/
|
||||||
function getAlertTitle(type) {
|
function getAlertTitle(type) {
|
||||||
const titles = {
|
const titles = {
|
||||||
'EVENT_CREATED': 'Nouvel événement créé',
|
"EVENT_CREATED": "Nouvel événement créé",
|
||||||
'EVENT_MODIFIED': 'Événement modifié',
|
"EVENT_MODIFIED": "Événement modifié",
|
||||||
'EVENT_CANCELLED': 'Événement annulé',
|
"EVENT_CANCELLED": "Événement annulé",
|
||||||
'LOST': 'Équipement perdu',
|
"LOST": "Équipement perdu",
|
||||||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
"EQUIPMENT_MISSING": "Équipement manquant",
|
||||||
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
"MAINTENANCE_REMINDER": "Maintenance requise",
|
||||||
'STOCK_LOW': 'Stock faible',
|
"STOCK_LOW": "Stock faible",
|
||||||
};
|
};
|
||||||
|
|
||||||
return titles[type] || 'Nouvelle alerte';
|
return titles[type] || "Nouvelle alerte";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,16 +225,16 @@ function getAlertTitle(type) {
|
|||||||
async function renderTemplate(templateName, data) {
|
async function renderTemplate(templateName, data) {
|
||||||
try {
|
try {
|
||||||
// Lire le template de base
|
// Lire le template de base
|
||||||
const basePath = path.join(__dirname, 'templates', 'base-template.html');
|
const basePath = path.join(__dirname, "templates", "base-template.html");
|
||||||
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
const baseTemplate = await fs.readFile(basePath, "utf8");
|
||||||
|
|
||||||
// Lire le template de contenu
|
// Lire le template de contenu
|
||||||
const contentPath = path.join(
|
const contentPath = path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
'templates',
|
"templates",
|
||||||
`${templateName}.html`,
|
`${templateName}.html`,
|
||||||
);
|
);
|
||||||
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
const contentTemplate = await fs.readFile(contentPath, "utf8");
|
||||||
|
|
||||||
// Compiler les templates
|
// Compiler les templates
|
||||||
const compileContent = handlebars.compile(contentTemplate);
|
const compileContent = handlebars.compile(contentTemplate);
|
||||||
@@ -249,7 +249,7 @@ async function renderTemplate(templateName, data) {
|
|||||||
content: renderedContent,
|
content: renderedContent,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur rendu template:', error);
|
console.error("Erreur rendu template:", error);
|
||||||
// Fallback vers un template simple
|
// Fallback vers un template simple
|
||||||
return `
|
return `
|
||||||
<html>
|
<html>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
* S'exécute tous les jours à 8h00 (Europe/Paris)
|
* S'exécute tous les jours à 8h00 (Europe/Paris)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const admin = require('firebase-admin');
|
const admin = require("firebase-admin");
|
||||||
const logger = require('firebase-functions/logger');
|
const logger = require("firebase-functions/logger");
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require("nodemailer");
|
||||||
const { getSmtpConfig } = require('./utils/emailConfig');
|
const {getSmtpConfig} = require("./utils/emailConfig");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fonction principale : envoie le digest quotidien
|
* Fonction principale : envoie le digest quotidien
|
||||||
@@ -14,11 +14,11 @@ const { getSmtpConfig } = require('./utils/emailConfig');
|
|||||||
async function sendDailyDigest() {
|
async function sendDailyDigest() {
|
||||||
const db = admin.firestore();
|
const db = admin.firestore();
|
||||||
|
|
||||||
logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====');
|
logger.info("[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Récupérer tous les utilisateurs avec email activé
|
// 1. Récupérer tous les utilisateurs avec email activé
|
||||||
const usersSnapshot = await db.collection('users').get();
|
const usersSnapshot = await db.collection("users").get();
|
||||||
const eligibleUsers = [];
|
const eligibleUsers = [];
|
||||||
|
|
||||||
usersSnapshot.forEach((doc) => {
|
usersSnapshot.forEach((doc) => {
|
||||||
@@ -30,8 +30,8 @@ async function sendDailyDigest() {
|
|||||||
eligibleUsers.push({
|
eligibleUsers.push({
|
||||||
uid: doc.id,
|
uid: doc.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName || 'Utilisateur',
|
firstName: user.firstName || "Utilisateur",
|
||||||
lastName: user.lastName || '',
|
lastName: user.lastName || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -48,11 +48,11 @@ async function sendDailyDigest() {
|
|||||||
for (const user of eligibleUsers) {
|
for (const user of eligibleUsers) {
|
||||||
try {
|
try {
|
||||||
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
|
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
|
||||||
const alertsSnapshot = await db.collection('alerts')
|
const alertsSnapshot = await db.collection("alerts")
|
||||||
.where('assignedTo', 'array-contains', user.uid)
|
.where("assignedTo", "array-contains", user.uid)
|
||||||
.where('isRead', '==', false)
|
.where("isRead", "==", false)
|
||||||
.where('createdAt', '>=', yesterday)
|
.where("createdAt", ">=", yesterday)
|
||||||
.orderBy('createdAt', 'desc')
|
.orderBy("createdAt", "desc")
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (alertsSnapshot.empty) {
|
if (alertsSnapshot.empty) {
|
||||||
@@ -61,7 +61,7 @@ async function sendDailyDigest() {
|
|||||||
|
|
||||||
const alerts = [];
|
const alerts = [];
|
||||||
alertsSnapshot.forEach((doc) => {
|
alertsSnapshot.forEach((doc) => {
|
||||||
alerts.push({ id: doc.id, ...doc.data() });
|
alerts.push({id: doc.id, ...doc.data()});
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
|
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
|
||||||
@@ -77,11 +77,11 @@ async function sendDailyDigest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
|
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
|
||||||
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
|
logger.info("[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====");
|
||||||
|
|
||||||
return { success: true, emailsSent };
|
return {success: true, emailsSent};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[sendDailyDigest] Erreur globale:', error);
|
logger.error("[sendDailyDigest] Erreur globale:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,9 +92,9 @@ async function sendDailyDigest() {
|
|||||||
async function sendDigestEmail(transporter, user, alerts) {
|
async function sendDigestEmail(transporter, user, alerts) {
|
||||||
try {
|
try {
|
||||||
// Grouper les alertes par sévérité
|
// Grouper les alertes par sévérité
|
||||||
const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL');
|
const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL");
|
||||||
const warningAlerts = alerts.filter(a => a.severity === 'WARNING');
|
const warningAlerts = alerts.filter((a) => a.severity === "WARNING");
|
||||||
const infoAlerts = alerts.filter(a => a.severity === 'INFO');
|
const infoAlerts = alerts.filter((a) => a.severity === "INFO");
|
||||||
|
|
||||||
// Construire le HTML
|
// Construire le HTML
|
||||||
const html = buildDigestHtml(user, {
|
const html = buildDigestHtml(user, {
|
||||||
@@ -125,7 +125,7 @@ async function sendDigestEmail(transporter, user, alerts) {
|
|||||||
function buildDigestHtml(user, alertsByType) {
|
function buildDigestHtml(user, alertsByType) {
|
||||||
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
|
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
|
||||||
|
|
||||||
let alertsHtml = '';
|
let alertsHtml = "";
|
||||||
|
|
||||||
// Alertes critiques
|
// Alertes critiques
|
||||||
if (alertsByType.critical.length > 0) {
|
if (alertsByType.critical.length > 0) {
|
||||||
@@ -134,7 +134,7 @@ function buildDigestHtml(user, alertsByType) {
|
|||||||
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
|
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
|
||||||
🔴 Alertes critiques (${alertsByType.critical.length})
|
🔴 Alertes critiques (${alertsByType.critical.length})
|
||||||
</h3>
|
</h3>
|
||||||
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
|
${alertsByType.critical.map((alert) => formatAlertItem(alert)).join("")}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ function buildDigestHtml(user, alertsByType) {
|
|||||||
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
|
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
|
||||||
⚠️ Avertissements (${alertsByType.warning.length})
|
⚠️ Avertissements (${alertsByType.warning.length})
|
||||||
</h3>
|
</h3>
|
||||||
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
|
${alertsByType.warning.map((alert) => formatAlertItem(alert)).join("")}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -158,7 +158,7 @@ function buildDigestHtml(user, alertsByType) {
|
|||||||
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
|
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
|
||||||
ℹ️ Informations (${alertsByType.info.length})
|
ℹ️ Informations (${alertsByType.info.length})
|
||||||
</h3>
|
</h3>
|
||||||
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
|
${alertsByType.info.map((alert) => formatAlertItem(alert)).join("")}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -216,24 +216,24 @@ function buildDigestHtml(user, alertsByType) {
|
|||||||
*/
|
*/
|
||||||
function formatAlertItem(alert) {
|
function formatAlertItem(alert) {
|
||||||
const date = alert.createdAt?.toDate ?
|
const date = alert.createdAt?.toDate ?
|
||||||
new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', {
|
new Date(alert.createdAt.toDate()).toLocaleString("fr-FR", {
|
||||||
day: '2-digit',
|
day: "2-digit",
|
||||||
month: '2-digit',
|
month: "2-digit",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '2-digit'
|
minute: "2-digit",
|
||||||
}) :
|
}) :
|
||||||
'Date inconnue';
|
"Date inconnue";
|
||||||
|
|
||||||
// Type d'alerte en français
|
// Type d'alerte en français
|
||||||
const typeLabels = {
|
const typeLabels = {
|
||||||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
"EQUIPMENT_MISSING": "Équipement manquant",
|
||||||
'LOST': 'Équipement perdu',
|
"LOST": "Équipement perdu",
|
||||||
'DAMAGED': 'Équipement endommagé',
|
"DAMAGED": "Équipement endommagé",
|
||||||
'QUANTITY_MISMATCH': 'Écart de quantité',
|
"QUANTITY_MISMATCH": "Écart de quantité",
|
||||||
'EVENT_CREATED': 'Événement créé',
|
"EVENT_CREATED": "Événement créé",
|
||||||
'EVENT_MODIFIED': 'Événement modifié',
|
"EVENT_MODIFIED": "Événement modifié",
|
||||||
'WORKFORCE_ADDED': 'Ajout à la workforce',
|
"WORKFORCE_ADDED": "Ajout à la workforce",
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeLabel = typeLabels[alert.type] || alert.type;
|
const typeLabel = typeLabels[alert.type] || alert.type;
|
||||||
@@ -245,7 +245,7 @@ function formatAlertItem(alert) {
|
|||||||
<span style="color: #6b7280; font-size: 13px;">${date}</span>
|
<span style="color: #6b7280; font-size: 13px;">${date}</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
|
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
|
||||||
${alert.message || 'Aucun message'}
|
${alert.message || "Aucun message"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -256,12 +256,12 @@ function formatAlertItem(alert) {
|
|||||||
*/
|
*/
|
||||||
function getSeverityColor(severity) {
|
function getSeverityColor(severity) {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case 'CRITICAL': return '#dc2626';
|
case "CRITICAL": return "#dc2626";
|
||||||
case 'WARNING': return '#f59e0b';
|
case "WARNING": return "#f59e0b";
|
||||||
case 'INFO': return '#3b82f6';
|
case "INFO": return "#3b82f6";
|
||||||
default: return '#6b7280';
|
default: return "#6b7280";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { sendDailyDigest };
|
module.exports = {sendDailyDigest};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const db = admin.firestore();
|
||||||
|
const logger = require("firebase-functions/logger");
|
||||||
|
const auth = require("../utils/auth");
|
||||||
|
const helpers = require("../utils/helpers");
|
||||||
|
|
||||||
|
// Récupère toutes les alertes (filtrées et limitées)
|
||||||
|
exports.getAlerts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const snapshot = await db.collection("alerts")
|
||||||
|
.orderBy("createdAt", "desc")
|
||||||
|
.limit(100)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const alerts = snapshot.docs.map((doc) => {
|
||||||
|
const data = doc.data();
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
...helpers.serializeTimestamps(data, ["createdAt"]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({alerts});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching alerts:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Marquer une alerte comme lue
|
||||||
|
exports.markAlertAsRead = async (req, res) => {
|
||||||
|
try {
|
||||||
|
await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const alertId = req.body.data?.alertId;
|
||||||
|
if (!alertId) {
|
||||||
|
res.status(400).json({error: "alertId is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("alerts").doc(alertId).update({
|
||||||
|
isRead: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({success: true});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error marking alert as read:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer une alerte
|
||||||
|
exports.deleteAlert = async (req, res) => {
|
||||||
|
try {
|
||||||
|
await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const alertId = req.body.data?.alertId;
|
||||||
|
if (!alertId) {
|
||||||
|
res.status(400).json({error: "alertId is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("alerts").doc(alertId).delete();
|
||||||
|
|
||||||
|
res.status(200).json({success: true});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deleting alert:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const db = admin.firestore();
|
||||||
|
const logger = require("firebase-functions/logger");
|
||||||
|
const auth = require("../utils/auth");
|
||||||
|
const helpers = require("../utils/helpers");
|
||||||
|
|
||||||
|
// Vérifie si un équipement est disponible pour une plage de dates
|
||||||
|
exports.checkEquipmentAvailability = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {equipmentId, startDate, endDate, excludeEventId} = req.body.data;
|
||||||
|
|
||||||
|
if (!equipmentId || !startDate || !endDate) {
|
||||||
|
res.status(400).json({error: "equipmentId, startDate, and endDate are required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Checking availability for equipment ${equipmentId} from ${startDate} to ${endDate}, excluding event: ${excludeEventId}`);
|
||||||
|
|
||||||
|
const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate));
|
||||||
|
const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate));
|
||||||
|
|
||||||
|
const eventsSnapshot = await db.collection("events")
|
||||||
|
.where("status", "!=", "CANCELLED")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
logger.info(`Found ${eventsSnapshot.docs.length} events to check`);
|
||||||
|
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
for (const eventDoc of eventsSnapshot.docs) {
|
||||||
|
const event = eventDoc.data();
|
||||||
|
|
||||||
|
if (excludeEventId && eventDoc.id === excludeEventId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventStart; let eventEnd;
|
||||||
|
if (event.StartDateTime) {
|
||||||
|
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
|
||||||
|
}
|
||||||
|
if (event.EndDateTime) {
|
||||||
|
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventStart || !eventEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedEquipment = event.assignedEquipment || [];
|
||||||
|
const assignedContainers = event.assignedContainers || [];
|
||||||
|
|
||||||
|
const isEquipmentDirectlyAssigned = assignedEquipment.some((eq) => eq.equipmentId === equipmentId);
|
||||||
|
|
||||||
|
let isEquipmentInAssignedContainer = false;
|
||||||
|
if (assignedContainers.length > 0) {
|
||||||
|
logger.info(`Event ${eventDoc.id} has ${assignedContainers.length} assigned containers`);
|
||||||
|
for (const containerId of assignedContainers) {
|
||||||
|
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||||
|
if (containerDoc.exists) {
|
||||||
|
const containerData = containerDoc.data();
|
||||||
|
const equipmentIds = containerData.equipmentIds || [];
|
||||||
|
logger.info(`Container ${containerId} contains equipment IDs: ${equipmentIds.join(", ")}`);
|
||||||
|
if (equipmentIds.includes(equipmentId)) {
|
||||||
|
isEquipmentInAssignedContainer = true;
|
||||||
|
logger.info(`Equipment ${equipmentId} found in container ${containerId} for event ${eventDoc.id}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEquipmentDirectlyAssigned) {
|
||||||
|
logger.info(`Equipment ${equipmentId} is directly assigned to event ${eventDoc.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEquipmentDirectlyAssigned && !isEquipmentInAssignedContainer) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestStart = startTimestamp.toDate();
|
||||||
|
const requestEnd = endTimestamp.toDate();
|
||||||
|
|
||||||
|
const installationTime = event.InstallationTime || 0;
|
||||||
|
const disassemblyTime = event.DisassemblyTime || 0;
|
||||||
|
|
||||||
|
const eventStartWithSetup = new Date(eventStart);
|
||||||
|
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime);
|
||||||
|
|
||||||
|
const eventEndWithTeardown = new Date(eventEnd);
|
||||||
|
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime);
|
||||||
|
|
||||||
|
const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup;
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup));
|
||||||
|
const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown));
|
||||||
|
const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
logger.info(`Conflict detected: Equipment ${equipmentId} conflicts with event ${eventDoc.id} (${event.Name})`);
|
||||||
|
|
||||||
|
const eventData = helpers.serializeTimestamps(event);
|
||||||
|
conflicts.push({
|
||||||
|
eventId: eventDoc.id,
|
||||||
|
eventName: event.Name,
|
||||||
|
eventData: eventData,
|
||||||
|
startDate: eventStart.toISOString(),
|
||||||
|
endDate: eventEnd.toISOString(),
|
||||||
|
overlapDays: overlapDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Total conflicts found: ${conflicts.length}`);
|
||||||
|
|
||||||
|
res.status(200).json({conflicts, available: conflicts.length === 0});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error checking equipment availability:", error);
|
||||||
|
res.status(500).json({error: error.message || "Failed to check equipment availability"});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vérifie la disponibilité d'un container et de son contenu
|
||||||
|
exports.checkContainerAvailability = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {containerId, startDate, endDate, excludeEventId} = req.body.data;
|
||||||
|
|
||||||
|
if (!containerId || !startDate || !endDate) {
|
||||||
|
res.status(400).json({error: "containerId, startDate, and endDate are required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||||
|
if (!containerDoc.exists) {
|
||||||
|
throw new Error("Container not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerData = containerDoc.data();
|
||||||
|
const equipmentIds = containerData.equipmentIds || [];
|
||||||
|
|
||||||
|
const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate));
|
||||||
|
const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate));
|
||||||
|
|
||||||
|
const eventsSnapshot = await db.collection("events")
|
||||||
|
.where("status", "!=", "CANCELLED")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const containerConflicts = [];
|
||||||
|
const equipmentConflicts = {};
|
||||||
|
|
||||||
|
for (const eventDoc of eventsSnapshot.docs) {
|
||||||
|
const event = eventDoc.data();
|
||||||
|
|
||||||
|
if (excludeEventId && eventDoc.id === excludeEventId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventStart; let eventEnd;
|
||||||
|
if (event.StartDateTime) {
|
||||||
|
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
|
||||||
|
}
|
||||||
|
if (event.EndDateTime) {
|
||||||
|
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventStart || !eventEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedContainers = event.assignedContainers || [];
|
||||||
|
const isContainerAssigned = assignedContainers.includes(containerId);
|
||||||
|
|
||||||
|
const assignedEquipment = event.assignedEquipment || [];
|
||||||
|
const conflictingEquipmentIds = equipmentIds.filter((eqId) =>
|
||||||
|
assignedEquipment.some((eq) => eq.equipmentId === eqId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isContainerAssigned && conflictingEquipmentIds.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestStart = startTimestamp.toDate();
|
||||||
|
const requestEnd = endTimestamp.toDate();
|
||||||
|
|
||||||
|
const installationTime = event.InstallationTime || 0;
|
||||||
|
const disassemblyTime = event.DisassemblyTime || 0;
|
||||||
|
|
||||||
|
const eventStartWithSetup = new Date(eventStart);
|
||||||
|
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime);
|
||||||
|
|
||||||
|
const eventEndWithTeardown = new Date(eventEnd);
|
||||||
|
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime);
|
||||||
|
|
||||||
|
const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup;
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup));
|
||||||
|
const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown));
|
||||||
|
const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
const conflictInfo = {
|
||||||
|
eventId: eventDoc.id,
|
||||||
|
eventName: event.Name,
|
||||||
|
startDate: eventStart.toISOString(),
|
||||||
|
endDate: eventEnd.toISOString(),
|
||||||
|
overlapDays: overlapDays,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isContainerAssigned) {
|
||||||
|
containerConflicts.push(conflictInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
conflictingEquipmentIds.forEach((eqId) => {
|
||||||
|
if (!equipmentConflicts[eqId]) {
|
||||||
|
equipmentConflicts[eqId] = [];
|
||||||
|
}
|
||||||
|
equipmentConflicts[eqId].push(conflictInfo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasContainerConflict = containerConflicts.length > 0;
|
||||||
|
const hasPartialConflict = Object.keys(equipmentConflicts).length > 0 && !hasContainerConflict;
|
||||||
|
const conflictType = hasContainerConflict ? "complete" : (hasPartialConflict ? "partial" : "none");
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
conflictType,
|
||||||
|
containerConflicts,
|
||||||
|
equipmentConflicts,
|
||||||
|
isAvailable: conflictType === "none",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error checking container availability:", error);
|
||||||
|
res.status(500).json({error: error.message || "Failed to check container availability"});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupère tous les équipements et conteneurs en conflit pour une période donnée
|
||||||
|
exports.getConflictingEquipmentIds = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {startDate, endDate, excludeEventId, installationTime = 0, disassemblyTime = 0} = req.body.data;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
res.status(400).json({error: "startDate and endDate are required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Getting conflicting equipment IDs for period ${startDate} to ${endDate}`);
|
||||||
|
|
||||||
|
const requestStartDate = new Date(startDate);
|
||||||
|
requestStartDate.setHours(requestStartDate.getHours() - installationTime);
|
||||||
|
|
||||||
|
const requestEndDate = new Date(endDate);
|
||||||
|
requestEndDate.setHours(requestEndDate.getHours() + disassemblyTime);
|
||||||
|
|
||||||
|
const eventsSnapshot = await db.collection("events")
|
||||||
|
.where("status", "!=", "CANCELLED")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
logger.info(`Found ${eventsSnapshot.docs.length} events to check`);
|
||||||
|
|
||||||
|
const equipmentsSnapshot = await db.collection("equipments").get();
|
||||||
|
const equipmentsInfo = {};
|
||||||
|
equipmentsSnapshot.docs.forEach((doc) => {
|
||||||
|
const data = doc.data();
|
||||||
|
equipmentsInfo[doc.id] = {
|
||||||
|
category: data.category,
|
||||||
|
totalQuantity: data.totalQuantity || 0,
|
||||||
|
hasQuantity: data.category === "CABLE" || data.category === "CONSUMABLE",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const conflictingEquipmentIds = new Set();
|
||||||
|
const conflictingContainerIds = new Set();
|
||||||
|
const conflictDetails = {};
|
||||||
|
const equipmentQuantities = {};
|
||||||
|
|
||||||
|
for (const eventDoc of eventsSnapshot.docs) {
|
||||||
|
if (excludeEventId && eventDoc.id === excludeEventId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = eventDoc.data();
|
||||||
|
|
||||||
|
let eventStart; let eventEnd;
|
||||||
|
if (event.StartDateTime) {
|
||||||
|
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
|
||||||
|
}
|
||||||
|
if (event.EndDateTime) {
|
||||||
|
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventStart || !eventEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventInstallTime = event.InstallationTime || 0;
|
||||||
|
const eventDisassemblyTime = event.DisassemblyTime || 0;
|
||||||
|
|
||||||
|
const eventStartWithSetup = new Date(eventStart);
|
||||||
|
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - eventInstallTime);
|
||||||
|
|
||||||
|
const eventEndWithTeardown = new Date(eventEnd);
|
||||||
|
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + eventDisassemblyTime);
|
||||||
|
|
||||||
|
const hasOverlap = requestStartDate < eventEndWithTeardown && requestEndDate > eventStartWithSetup;
|
||||||
|
|
||||||
|
if (!hasOverlap) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedEquipment = event.assignedEquipment || [];
|
||||||
|
const assignedContainers = event.assignedContainers || [];
|
||||||
|
|
||||||
|
const conflictInfo = {
|
||||||
|
eventId: eventDoc.id,
|
||||||
|
eventName: event.Name,
|
||||||
|
startDate: eventStart.toISOString(),
|
||||||
|
endDate: eventEnd.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const eq of assignedEquipment) {
|
||||||
|
const equipmentId = eq.equipmentId;
|
||||||
|
const quantity = eq.quantity || 1;
|
||||||
|
const equipInfo = equipmentsInfo[equipmentId];
|
||||||
|
|
||||||
|
if (equipInfo && equipInfo.hasQuantity) {
|
||||||
|
if (!equipmentQuantities[equipmentId]) {
|
||||||
|
equipmentQuantities[equipmentId] = {
|
||||||
|
totalQuantity: equipInfo.totalQuantity,
|
||||||
|
reservedQuantity: 0,
|
||||||
|
availableQuantity: equipInfo.totalQuantity,
|
||||||
|
reservations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
equipmentQuantities[equipmentId].reservedQuantity += quantity;
|
||||||
|
equipmentQuantities[equipmentId].availableQuantity = equipInfo.totalQuantity - equipmentQuantities[equipmentId].reservedQuantity;
|
||||||
|
equipmentQuantities[equipmentId].reservations.push({
|
||||||
|
...conflictInfo,
|
||||||
|
quantity: quantity,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (equipmentQuantities[equipmentId].availableQuantity <= 0) {
|
||||||
|
conflictingEquipmentIds.add(equipmentId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conflictingEquipmentIds.add(equipmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conflictDetails[equipmentId]) {
|
||||||
|
conflictDetails[equipmentId] = [];
|
||||||
|
}
|
||||||
|
conflictDetails[equipmentId].push({
|
||||||
|
...conflictInfo,
|
||||||
|
quantity: quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const containerId of assignedContainers) {
|
||||||
|
conflictingContainerIds.add(containerId);
|
||||||
|
|
||||||
|
if (!conflictDetails[containerId]) {
|
||||||
|
conflictDetails[containerId] = [];
|
||||||
|
}
|
||||||
|
conflictDetails[containerId].push(conflictInfo);
|
||||||
|
|
||||||
|
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||||
|
if (containerDoc.exists) {
|
||||||
|
const containerData = containerDoc.data();
|
||||||
|
const equipmentIds = containerData.equipmentIds || [];
|
||||||
|
|
||||||
|
for (const equipmentId of equipmentIds) {
|
||||||
|
conflictingEquipmentIds.add(equipmentId);
|
||||||
|
|
||||||
|
if (!conflictDetails[equipmentId]) {
|
||||||
|
conflictDetails[equipmentId] = [];
|
||||||
|
}
|
||||||
|
conflictDetails[equipmentId].push({
|
||||||
|
...conflictInfo,
|
||||||
|
viaContainer: containerId,
|
||||||
|
viaContainerName: containerData.name || "Conteneur inconnu",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Found ${conflictingEquipmentIds.size} conflicting equipment(s) and ${conflictingContainerIds.size} conflicting container(s)`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
conflictingEquipmentIds: Array.from(conflictingEquipmentIds),
|
||||||
|
conflictingContainerIds: Array.from(conflictingContainerIds),
|
||||||
|
conflictDetails: conflictDetails,
|
||||||
|
equipmentQuantities: equipmentQuantities,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error getting conflicting equipment IDs:", error);
|
||||||
|
res.status(500).json({error: error.message || "Failed to get conflicting equipment IDs"});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouver des alternatives (même modèle) disponibles pour une période donnée
|
||||||
|
*/
|
||||||
|
exports.findAlternativeEquipment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {model, startDate, endDate} = req.body.data;
|
||||||
|
|
||||||
|
if (!model || !startDate || !endDate) {
|
||||||
|
res.status(400).json({error: "model, startDate and endDate are required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = admin.firestore.Timestamp.fromDate(new Date(startDate));
|
||||||
|
const end = admin.firestore.Timestamp.fromDate(new Date(endDate));
|
||||||
|
|
||||||
|
// Récupérer tous les équipements du même modèle
|
||||||
|
const equipmentsSnapshot = await db.collection("equipments")
|
||||||
|
.where("model", "==", model)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Récupérer tous les événements qui chevauchent la période
|
||||||
|
const eventsSnapshot = await db.collection("events")
|
||||||
|
.where("StartDateTime", "<=", end)
|
||||||
|
.where("EndDateTime", ">=", start)
|
||||||
|
.where("status", "!=", "CANCELLED")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Créer un set des équipements en conflit
|
||||||
|
const conflictingEquipmentIds = new Set();
|
||||||
|
eventsSnapshot.docs.forEach((doc) => {
|
||||||
|
const eventData = doc.data();
|
||||||
|
const assignedEquipment = eventData.assignedEquipment || [];
|
||||||
|
assignedEquipment.forEach((eq) => conflictingEquipmentIds.add(eq.equipmentId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrer les équipements disponibles
|
||||||
|
const alternatives = [];
|
||||||
|
equipmentsSnapshot.docs.forEach((doc) => {
|
||||||
|
const data = doc.data();
|
||||||
|
if (!conflictingEquipmentIds.has(doc.id) && data.status === "available") {
|
||||||
|
alternatives.push({
|
||||||
|
id: doc.id,
|
||||||
|
...helpers.serializeTimestamps(data, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({alternatives});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error finding alternative equipment:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculer le statut réel d'un ou plusieurs équipements basé sur les événements en cours
|
||||||
|
*/
|
||||||
|
exports.calculateEquipmentStatuses = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {equipmentIds} = req.body.data;
|
||||||
|
|
||||||
|
if (!equipmentIds || !Array.isArray(equipmentIds)) {
|
||||||
|
res.status(400).json({error: "equipmentIds array is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer tous les événements en cours (préparation complétée mais pas encore retournés)
|
||||||
|
const eventsSnapshot = await db.collection("events")
|
||||||
|
.where("status", "!=", "CANCELLED")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const equipmentIdsInUse = new Set();
|
||||||
|
const containerIdsInUse = new Set();
|
||||||
|
|
||||||
|
eventsSnapshot.docs.forEach((doc) => {
|
||||||
|
const event = doc.data();
|
||||||
|
|
||||||
|
const isPrepared = event.preparationStatus === "completed" ||
|
||||||
|
event.preparationStatus === "completedWithMissing";
|
||||||
|
const isReturned = event.returnStatus === "completed" ||
|
||||||
|
event.returnStatus === "completedWithMissing";
|
||||||
|
|
||||||
|
if (isPrepared && !isReturned) {
|
||||||
|
// Ajouter les équipements directs
|
||||||
|
const assignedEquipment = event.assignedEquipment || [];
|
||||||
|
assignedEquipment.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId));
|
||||||
|
|
||||||
|
// Ajouter les conteneurs
|
||||||
|
const assignedContainers = event.assignedContainers || [];
|
||||||
|
assignedContainers.forEach((containerId) => containerIdsInUse.add(containerId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Récupérer les équipements dans les conteneurs en cours d'utilisation
|
||||||
|
if (containerIdsInUse.size > 0) {
|
||||||
|
const containersSnapshot = await db.collection("containers")
|
||||||
|
.where(admin.firestore.FieldPath.documentId(), "in", Array.from(containerIdsInUse))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
containersSnapshot.docs.forEach((doc) => {
|
||||||
|
const containerData = doc.data();
|
||||||
|
const equipmentList = containerData.equipment || [];
|
||||||
|
equipmentList.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les données des équipements demandés
|
||||||
|
const statuses = {};
|
||||||
|
|
||||||
|
for (const equipmentId of equipmentIds) {
|
||||||
|
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||||
|
|
||||||
|
if (!equipmentDoc.exists) {
|
||||||
|
statuses[equipmentId] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipmentData = equipmentDoc.data();
|
||||||
|
let calculatedStatus = equipmentData.status;
|
||||||
|
|
||||||
|
// Si l'équipement est perdu ou HS, garder ce statut
|
||||||
|
if (equipmentData.status === "lost" || equipmentData.status === "outOfService") {
|
||||||
|
calculatedStatus = equipmentData.status;
|
||||||
|
} else if (equipmentIdsInUse.has(equipmentId)) {
|
||||||
|
calculatedStatus = "inUse";
|
||||||
|
} else if (equipmentData.status === "maintenance" ||
|
||||||
|
equipmentData.status === "rented") {
|
||||||
|
calculatedStatus = equipmentData.status;
|
||||||
|
} else {
|
||||||
|
calculatedStatus = "available";
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses[equipmentId] = calculatedStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({statuses});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error calculating equipment statuses:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer tous les événements en cours (pour le calcul de statuts)
|
||||||
|
*/
|
||||||
|
exports.getActiveEvents = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_events");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_events permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les événements en cours (préparation complétée mais pas encore retournés)
|
||||||
|
const eventsSnapshot = await db.collection("events")
|
||||||
|
.where("status", "!=", "CANCELLED")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const activeEvents = [];
|
||||||
|
|
||||||
|
eventsSnapshot.docs.forEach((doc) => {
|
||||||
|
const event = doc.data();
|
||||||
|
|
||||||
|
const isPrepared = event.preparationStatus === "completed" ||
|
||||||
|
event.preparationStatus === "completedWithMissing";
|
||||||
|
const isReturned = event.returnStatus === "completed" ||
|
||||||
|
event.returnStatus === "completedWithMissing";
|
||||||
|
|
||||||
|
if (isPrepared && !isReturned) {
|
||||||
|
activeEvents.push({
|
||||||
|
id: doc.id,
|
||||||
|
assignedEquipment: event.assignedEquipment || [],
|
||||||
|
assignedContainers: event.assignedContainers || [],
|
||||||
|
preparationStatus: event.preparationStatus,
|
||||||
|
returnStatus: event.returnStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({events: activeEvents});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching active events:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const db = admin.firestore();
|
||||||
|
const logger = require("firebase-functions/logger");
|
||||||
|
const auth = require("../utils/auth");
|
||||||
|
const helpers = require("../utils/helpers");
|
||||||
|
|
||||||
|
// Créer un container
|
||||||
|
exports.createContainer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerData = req.body.data;
|
||||||
|
const containerId = containerData.id;
|
||||||
|
|
||||||
|
if (!containerId) {
|
||||||
|
res.status(400).json({error: "Container ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingDoc = await db.collection("containers").doc(containerId).get();
|
||||||
|
if (existingDoc.exists) {
|
||||||
|
res.status(409).json({error: "Container ID already exists"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToSave = helpers.deserializeTimestamps(containerData, ["createdAt", "updatedAt"]);
|
||||||
|
|
||||||
|
await db.collection("containers").doc(containerId).set(dataToSave);
|
||||||
|
|
||||||
|
res.status(201).json({id: containerId, message: "Container created successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating container:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mettre à jour un container
|
||||||
|
exports.updateContainer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {containerId, data} = req.body.data;
|
||||||
|
|
||||||
|
if (!containerId) {
|
||||||
|
res.status(400).json({error: "Container ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete data.id;
|
||||||
|
data.updatedAt = admin.firestore.Timestamp.now();
|
||||||
|
|
||||||
|
await db.collection("containers").doc(containerId).update(data);
|
||||||
|
|
||||||
|
res.status(200).json({message: "Container updated successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating container:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer un container
|
||||||
|
exports.deleteContainer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {containerId} = req.body.data;
|
||||||
|
|
||||||
|
if (!containerId) {
|
||||||
|
res.status(400).json({error: "Container ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer le container pour obtenir les équipements
|
||||||
|
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||||
|
if (containerDoc.exists) {
|
||||||
|
const containerData = containerDoc.data();
|
||||||
|
const equipmentIds = containerData.equipmentIds || [];
|
||||||
|
|
||||||
|
// Retirer le container des parentBoxIds de chaque équipement
|
||||||
|
for (const equipmentId of equipmentIds) {
|
||||||
|
try {
|
||||||
|
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||||
|
if (equipmentDoc.exists) {
|
||||||
|
const equipmentData = equipmentDoc.data();
|
||||||
|
const parentBoxIds = (equipmentData.parentBoxIds || []).filter((boxId) => boxId !== containerId);
|
||||||
|
await db.collection("equipments").doc(equipmentId).update({
|
||||||
|
parentBoxIds: parentBoxIds,
|
||||||
|
updatedAt: admin.firestore.Timestamp.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Error updating equipment ${equipmentId} when deleting container:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("containers").doc(containerId).delete();
|
||||||
|
|
||||||
|
res.status(200).json({message: "Container deleted successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deleting container:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer les containers contenant un équipement
|
||||||
|
exports.getContainersByEquipment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasViewAccess && !hasManageAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {equipmentId} = req.body.data || {};
|
||||||
|
|
||||||
|
if (!equipmentId) {
|
||||||
|
res.status(400).json({error: "equipmentId is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await db.collection("containers")
|
||||||
|
.where("equipmentIds", "array-contains", equipmentId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const containers = [];
|
||||||
|
snapshot.forEach((doc) => {
|
||||||
|
let data = {id: doc.id, ...doc.data()};
|
||||||
|
data = helpers.serializeTimestamps(data);
|
||||||
|
data = helpers.serializeReferences(data);
|
||||||
|
containers.push(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({containers});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error getting containers by equipment:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer plusieurs containers par leurs IDs
|
||||||
|
exports.getContainersByIds = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasViewAccess && !hasManageAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {containerIds} = req.body.data || {};
|
||||||
|
|
||||||
|
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
|
||||||
|
res.status(400).json({error: "containerIds array is required and must not be empty"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerIds.length > 100) {
|
||||||
|
res.status(400).json({error: "Maximum 100 container IDs per request"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = containerIds.map((id) => db.collection("containers").doc(id).get());
|
||||||
|
const docs = await Promise.all(promises);
|
||||||
|
|
||||||
|
const containers = [];
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.exists) {
|
||||||
|
let data = {id: doc.id, ...doc.data()};
|
||||||
|
data = helpers.serializeTimestamps(data);
|
||||||
|
data = helpers.serializeReferences(data);
|
||||||
|
containers.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({containers});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error getting containers by IDs:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter un équipement à un container
|
||||||
|
exports.addEquipmentToContainer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {containerId, equipmentId, userId} = req.body.data;
|
||||||
|
|
||||||
|
if (!containerId || !equipmentId) {
|
||||||
|
res.status(400).json({error: "containerId and equipmentId are required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||||
|
if (!containerDoc.exists) {
|
||||||
|
res.status(404).json({success: false, message: "Container non trouvé"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerData = containerDoc.data();
|
||||||
|
const equipmentIds = containerData.equipmentIds || [];
|
||||||
|
|
||||||
|
if (equipmentIds.includes(equipmentId)) {
|
||||||
|
res.status(400).json({success: false, message: "Cet équipement est déjà dans ce container"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||||
|
if (!equipmentDoc.exists) {
|
||||||
|
res.status(404).json({success: false, message: "Équipement non trouvé"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipmentData = equipmentDoc.data();
|
||||||
|
const parentBoxIds = equipmentData.parentBoxIds || [];
|
||||||
|
|
||||||
|
const warnings = [];
|
||||||
|
if (parentBoxIds.length > 0) {
|
||||||
|
const otherContainersPromises = parentBoxIds.map((boxId) =>
|
||||||
|
db.collection("containers").doc(boxId).get(),
|
||||||
|
);
|
||||||
|
const otherContainersDocs = await Promise.all(otherContainersPromises);
|
||||||
|
const otherNames = otherContainersDocs
|
||||||
|
.filter((doc) => doc.exists)
|
||||||
|
.map((doc) => doc.data().name);
|
||||||
|
|
||||||
|
if (otherNames.length > 0) {
|
||||||
|
warnings.push(`Attention : cet équipement est également dans les boites suivants : ${otherNames.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("containers").doc(containerId).update({
|
||||||
|
equipmentIds: [...equipmentIds, equipmentId],
|
||||||
|
updatedAt: admin.firestore.Timestamp.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.collection("equipments").doc(equipmentId).update({
|
||||||
|
parentBoxIds: [...parentBoxIds, containerId],
|
||||||
|
updatedAt: admin.firestore.Timestamp.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const history = containerData.history || [];
|
||||||
|
const historyEntry = {
|
||||||
|
timestamp: admin.firestore.Timestamp.now(),
|
||||||
|
action: "equipment_added",
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
newValue: equipmentId,
|
||||||
|
userId: userId || decodedToken.uid,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedHistory = [...history, historyEntry].slice(-100);
|
||||||
|
|
||||||
|
await db.collection("containers").doc(containerId).update({
|
||||||
|
history: updatedHistory,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Équipement ajouté avec succès",
|
||||||
|
warnings: warnings.length > 0 ? warnings[0] : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error adding equipment to container:", error);
|
||||||
|
res.status(500).json({success: false, message: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retirer un équipement d'un container
|
||||||
|
exports.removeEquipmentFromContainer = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {containerId, equipmentId, userId} = req.body.data;
|
||||||
|
|
||||||
|
if (!containerId || !equipmentId) {
|
||||||
|
res.status(400).json({error: "containerId and equipmentId are required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||||
|
if (!containerDoc.exists) {
|
||||||
|
res.status(404).json({error: "Container non trouvé"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerData = containerDoc.data();
|
||||||
|
const equipmentIds = containerData.equipmentIds || [];
|
||||||
|
|
||||||
|
const updatedEquipmentIds = equipmentIds.filter((id) => id !== equipmentId);
|
||||||
|
|
||||||
|
await db.collection("containers").doc(containerId).update({
|
||||||
|
equipmentIds: updatedEquipmentIds,
|
||||||
|
updatedAt: admin.firestore.Timestamp.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||||
|
if (equipmentDoc.exists) {
|
||||||
|
const equipmentData = equipmentDoc.data();
|
||||||
|
const parentBoxIds = equipmentData.parentBoxIds || [];
|
||||||
|
const updatedParentBoxIds = parentBoxIds.filter((id) => id !== containerId);
|
||||||
|
|
||||||
|
await db.collection("equipments").doc(equipmentId).update({
|
||||||
|
parentBoxIds: updatedParentBoxIds,
|
||||||
|
updatedAt: admin.firestore.Timestamp.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = containerData.history || [];
|
||||||
|
const historyEntry = {
|
||||||
|
timestamp: admin.firestore.Timestamp.now(),
|
||||||
|
action: "equipment_removed",
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
previousValue: equipmentId,
|
||||||
|
userId: userId || decodedToken.uid,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedHistory = [...history, historyEntry].slice(-100);
|
||||||
|
|
||||||
|
await db.collection("containers").doc(containerId).update({
|
||||||
|
history: updatedHistory,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({success: true});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error removing equipment from container:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer les containers avec pagination et filtrage côté serveur
|
||||||
|
exports.getContainersPaginated = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
// Vérifier les permissions
|
||||||
|
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les paramètres de la requête
|
||||||
|
const params = req.method === "GET" ? req.query : (req.body?.data || {});
|
||||||
|
const limit = Math.min(parseInt(params.limit) || 20, 100);
|
||||||
|
const startAfterId = params.startAfter || null;
|
||||||
|
const type = params.type ? params.type.toUpperCase() : null;
|
||||||
|
const status = params.status ? params.status.toUpperCase() : null;
|
||||||
|
const searchQuery = params.searchQuery?.toLowerCase() || null;
|
||||||
|
const category = params.category ? params.category.toUpperCase() : null;
|
||||||
|
const sortBy = params.sortBy || "id";
|
||||||
|
const sortOrder = params.sortOrder === "desc" ? "desc" : "asc";
|
||||||
|
|
||||||
|
logger.info(`[getContainersPaginated] Params: limit=${limit}, startAfter=${startAfterId}, type=${type}, status=${status}, category=${category}, search=${searchQuery}`);
|
||||||
|
|
||||||
|
let query = db.collection("containers");
|
||||||
|
|
||||||
|
const queryLimit = (searchQuery || category) ? Math.min(limit * 10, 200) : limit;
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
query = query.where("type", "==", type);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
query = query.where("status", "==", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy === "id") {
|
||||||
|
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
|
||||||
|
} else {
|
||||||
|
query = query.orderBy(sortBy, sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startAfterId) {
|
||||||
|
const startAfterDoc = await db.collection("containers").doc(startAfterId).get();
|
||||||
|
if (startAfterDoc.exists) {
|
||||||
|
query = query.startAfter(startAfterDoc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.limit(queryLimit + 1);
|
||||||
|
|
||||||
|
const snapshot = await query.get();
|
||||||
|
|
||||||
|
const rawDocCount = snapshot.docs.length;
|
||||||
|
const hasMoreDocs = rawDocCount > queryLimit;
|
||||||
|
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
|
||||||
|
|
||||||
|
let containers = docsToProcess.map((doc) => {
|
||||||
|
const data = doc.data();
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
...helpers.serializeTimestamps(data, ["createdAt", "updatedAt"]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const allEquipmentIds = new Set();
|
||||||
|
containers.forEach((c) => {
|
||||||
|
if (c.equipmentIds && Array.isArray(c.equipmentIds)) {
|
||||||
|
c.equipmentIds.forEach((id) => allEquipmentIds.add(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const equipmentMap = new Map();
|
||||||
|
if (allEquipmentIds.size > 0) {
|
||||||
|
const equipmentIdArray = Array.from(allEquipmentIds);
|
||||||
|
const batchSize = 30;
|
||||||
|
|
||||||
|
for (let i = 0; i < equipmentIdArray.length; i += batchSize) {
|
||||||
|
const batch = equipmentIdArray.slice(i, i + batchSize);
|
||||||
|
const equipmentSnapshot = await db.collection("equipments")
|
||||||
|
.where(admin.firestore.FieldPath.documentId(), "in", batch)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
equipmentSnapshot.docs.forEach((doc) => {
|
||||||
|
const equipmentData = doc.data();
|
||||||
|
equipmentMap.set(doc.id, {
|
||||||
|
id: doc.id,
|
||||||
|
...helpers.serializeTimestamps(equipmentData),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
containers = containers.map((container) => ({
|
||||||
|
...container,
|
||||||
|
equipment: (container.equipmentIds || [])
|
||||||
|
.map((eqId) => equipmentMap.get(eqId))
|
||||||
|
.filter((eq) => eq !== undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
containers = containers.filter((c) => {
|
||||||
|
return c.equipment.some((eq) => eq.category === category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
containers = containers.filter((c) => {
|
||||||
|
const searchableText = [
|
||||||
|
c.name || "",
|
||||||
|
c.id || "",
|
||||||
|
...(c.equipment || []).map((eq) => eq.name || ""),
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
return searchableText.includes(searchQuery);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedContainers = containers.slice(0, limit);
|
||||||
|
const lastVisible = limitedContainers.length > 0 ? limitedContainers[limitedContainers.length - 1].id : null;
|
||||||
|
|
||||||
|
const totalEquipmentCount = limitedContainers.reduce((sum, c) => sum + (c.equipment?.length || 0), 0);
|
||||||
|
logger.info(`[getContainersPaginated] Returning ${limitedContainers.length} containers with ${totalEquipmentCount} total equipment(s)`);
|
||||||
|
|
||||||
|
limitedContainers.forEach((c) => {
|
||||||
|
logger.info(` - Container ${c.id}: ${c.equipment?.length || 0} equipment(s)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
containers: limitedContainers,
|
||||||
|
hasMore: containers.length > limit || hasMoreDocs,
|
||||||
|
lastVisible,
|
||||||
|
total: limitedContainers.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching paginated containers:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const db = admin.firestore();
|
||||||
|
const logger = require("firebase-functions/logger");
|
||||||
|
const auth = require("../utils/auth");
|
||||||
|
const helpers = require("../utils/helpers");
|
||||||
|
|
||||||
|
// Créer un équipement (admin ou manage_equipment)
|
||||||
|
exports.createEquipment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipmentData = req.body.data;
|
||||||
|
const equipmentId = equipmentData.id;
|
||||||
|
|
||||||
|
if (!equipmentId) {
|
||||||
|
res.status(400).json({error: "Equipment ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier unicité de l'ID
|
||||||
|
const existingDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||||
|
if (existingDoc.exists) {
|
||||||
|
res.status(409).json({error: "Equipment ID already exists"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir les timestamps
|
||||||
|
const dataToSave = helpers.deserializeTimestamps(equipmentData, [
|
||||||
|
"createdAt", "updatedAt", "purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.collection("equipments").doc(equipmentId).set(dataToSave);
|
||||||
|
|
||||||
|
res.status(201).json({id: equipmentId, message: "Equipment created successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating equipment:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mettre à jour un équipement
|
||||||
|
exports.updateEquipment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {equipmentId, data} = req.body.data;
|
||||||
|
|
||||||
|
if (!equipmentId) {
|
||||||
|
res.status(400).json({error: "Equipment ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || typeof data !== "object" || Object.keys(data).length === 0) {
|
||||||
|
res.status(400).json({error: "Update data is required and must be a non-empty object"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empêcher la modification de l'ID
|
||||||
|
delete data.id;
|
||||||
|
|
||||||
|
// Ajouter updatedAt
|
||||||
|
data.updatedAt = admin.firestore.Timestamp.now();
|
||||||
|
|
||||||
|
const dataToSave = helpers.deserializeTimestamps(data, [
|
||||||
|
"purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.collection("equipments").doc(equipmentId).update(dataToSave);
|
||||||
|
|
||||||
|
res.status(200).json({message: "Equipment updated successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating equipment:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer un équipement
|
||||||
|
exports.deleteEquipment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {equipmentId, forceDelete = false} = req.body.data;
|
||||||
|
|
||||||
|
if (!equipmentId) {
|
||||||
|
res.status(400).json({error: "Equipment ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si l'équipement est utilisé dans des événements à venir
|
||||||
|
const eventsSnapshot = await db.collection("events")
|
||||||
|
.where("status", "!=", "CANCELLED")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const upcomingEvents = [];
|
||||||
|
|
||||||
|
for (const eventDoc of eventsSnapshot.docs) {
|
||||||
|
const eventData = eventDoc.data();
|
||||||
|
const assignedEquipment = eventData.assignedEquipment || [];
|
||||||
|
|
||||||
|
if (!assignedEquipment.some((eq) => eq.equipmentId === equipmentId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventStart = null;
|
||||||
|
if (eventData.StartDateTime) {
|
||||||
|
eventStart = eventData.StartDateTime.toDate ?
|
||||||
|
eventData.StartDateTime.toDate() :
|
||||||
|
new Date(eventData.StartDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventStart && eventStart > now) {
|
||||||
|
upcomingEvents.push({
|
||||||
|
eventId: eventDoc.id,
|
||||||
|
eventName: eventData.Name || "",
|
||||||
|
startDate: eventStart.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upcomingEvents.length > 0 && !forceDelete) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: "FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events",
|
||||||
|
upcomingEvents,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("equipments").doc(equipmentId).delete();
|
||||||
|
|
||||||
|
res.status(200).json({message: "Equipment deleted successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deleting equipment:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer un équipement par ID
|
||||||
|
exports.getEquipment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasViewAccess && !hasManageAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {equipmentId} = req.body.data || req.query;
|
||||||
|
|
||||||
|
if (!equipmentId) {
|
||||||
|
res.status(400).json({error: "Equipment ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await db.collection("equipments").doc(equipmentId).get();
|
||||||
|
|
||||||
|
if (!doc.exists) {
|
||||||
|
res.status(404).json({error: "Equipment not found"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = {id: doc.id, ...doc.data()};
|
||||||
|
data = helpers.serializeTimestamps(data);
|
||||||
|
data = helpers.serializeReferences(data);
|
||||||
|
|
||||||
|
// Masquer les prix si pas de permission manage_equipment
|
||||||
|
data = helpers.maskSensitiveFields(data, hasManageAccess);
|
||||||
|
|
||||||
|
res.status(200).json({equipment: data});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error getting equipment:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer plusieurs équipements par leurs IDs
|
||||||
|
exports.getEquipmentsByIds = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasViewAccess && !hasManageAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {equipmentIds} = req.body.data || {};
|
||||||
|
|
||||||
|
if (!equipmentIds || !Array.isArray(equipmentIds) || equipmentIds.length === 0) {
|
||||||
|
res.status(400).json({error: "equipmentIds array is required and must not be empty"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter à 100 équipements max par requête
|
||||||
|
if (equipmentIds.length > 100) {
|
||||||
|
res.status(400).json({error: "Maximum 100 equipment IDs per request"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer tous les documents en parallèle
|
||||||
|
const promises = equipmentIds.map((id) => db.collection("equipments").doc(id).get());
|
||||||
|
const docs = await Promise.all(promises);
|
||||||
|
|
||||||
|
const equipments = [];
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.exists) {
|
||||||
|
let data = {id: doc.id, ...doc.data()};
|
||||||
|
data = helpers.serializeTimestamps(data);
|
||||||
|
data = helpers.serializeReferences(data);
|
||||||
|
// Masquer les prix si pas de permission manage_equipment
|
||||||
|
data = helpers.maskSensitiveFields(data, hasManageAccess);
|
||||||
|
equipments.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({equipments});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error getting equipments by IDs:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mettre à jour uniquement le statut d'un équipement
|
||||||
|
exports.updateEquipmentStatusOnly = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const {equipmentId, status, availableQuantity} = req.body.data;
|
||||||
|
|
||||||
|
if (!equipmentId) {
|
||||||
|
res.status(400).json({error: "Equipment ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les permissions
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {updatedAt: admin.firestore.Timestamp.now()};
|
||||||
|
if (status) updateData.status = status;
|
||||||
|
if (availableQuantity !== undefined) updateData.availableQuantity = availableQuantity;
|
||||||
|
|
||||||
|
await db.collection("equipments").doc(equipmentId).update(updateData);
|
||||||
|
|
||||||
|
res.status(200).json({message: "Equipment status updated successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating equipment status:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mettre à jour le statut de plusieurs équipements (pour préparation/retour)
|
||||||
|
exports.updateEquipmentStatus = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const {eventId, updates} = req.body.data;
|
||||||
|
|
||||||
|
if (!eventId || !updates || !Array.isArray(updates)) {
|
||||||
|
res.status(400).json({error: "Event ID and updates array are required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que l'utilisateur est assigné à l'événement ou est admin
|
||||||
|
const isAssigned = await auth.isAssignedToEvent(decodedToken.uid, eventId);
|
||||||
|
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||||
|
|
||||||
|
if (!isAssigned && !isAdminUser) {
|
||||||
|
res.status(403).json({error: "Forbidden: Not assigned to this event"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch update
|
||||||
|
const batch = db.batch();
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
const {equipmentId, status} = update;
|
||||||
|
if (equipmentId && status) {
|
||||||
|
const equipmentRef = db.collection("equipments").doc(equipmentId);
|
||||||
|
batch.update(equipmentRef, {status});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.commit();
|
||||||
|
|
||||||
|
res.status(200).json({message: "Equipment statuses updated successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating equipment statuses:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupère les équipements avec pagination et filtrage côté serveur
|
||||||
|
exports.getEquipmentsPaginated = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
// Vérifier les permissions
|
||||||
|
const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
|
||||||
|
if (!canManage && !canView) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les paramètres de la requête
|
||||||
|
const params = req.method === "GET" ? req.query : (req.body?.data || {});
|
||||||
|
const limit = Math.min(parseInt(params.limit) || 20, 100);
|
||||||
|
const startAfterId = params.startAfter || null;
|
||||||
|
const category = params.category ? params.category.toUpperCase() : null;
|
||||||
|
const status = params.status ? params.status.toUpperCase() : null;
|
||||||
|
const rawSearchQuery = typeof params.searchQuery === "string" ? params.searchQuery.trim() : "";
|
||||||
|
const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null;
|
||||||
|
const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, "") : null;
|
||||||
|
const sortBy = params.sortBy || "id";
|
||||||
|
const sortOrder = params.sortOrder === "desc" ? "desc" : "asc";
|
||||||
|
|
||||||
|
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`);
|
||||||
|
|
||||||
|
// Fast-path pour une recherche d'ID exact
|
||||||
|
if (searchQuery && !startAfterId) {
|
||||||
|
const exactIdCandidates = Array.from(new Set([
|
||||||
|
rawSearchQuery,
|
||||||
|
rawSearchQuery.toUpperCase(),
|
||||||
|
rawSearchQuery.toLowerCase(),
|
||||||
|
].filter(Boolean)));
|
||||||
|
|
||||||
|
for (const candidateId of exactIdCandidates) {
|
||||||
|
const exactDoc = await db.collection("equipments").doc(candidateId).get();
|
||||||
|
if (!exactDoc.exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactData = exactDoc.data() || {};
|
||||||
|
const matchesCategory = !category || exactData.category === category;
|
||||||
|
const matchesStatus = !status || exactData.status === status;
|
||||||
|
if (!matchesCategory || !matchesStatus) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
delete exactData.purchasePrice;
|
||||||
|
delete exactData.rentalPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactEquipment = {
|
||||||
|
...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
|
||||||
|
id: exactDoc.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`);
|
||||||
|
res.status(200).json({
|
||||||
|
equipments: [exactEquipment],
|
||||||
|
hasMore: false,
|
||||||
|
lastVisible: exactDoc.id,
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibilité legacy
|
||||||
|
for (const legacyId of exactIdCandidates) {
|
||||||
|
let legacyIdQuery = db.collection("equipments").where("id", "==", legacyId);
|
||||||
|
if (category) {
|
||||||
|
legacyIdQuery = legacyIdQuery.where("category", "==", category);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
legacyIdQuery = legacyIdQuery.where("status", "==", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacySnapshot = await legacyIdQuery.limit(1).get();
|
||||||
|
if (legacySnapshot.empty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactDoc = legacySnapshot.docs[0];
|
||||||
|
const exactData = exactDoc.data() || {};
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
delete exactData.purchasePrice;
|
||||||
|
delete exactData.rentalPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactEquipment = {
|
||||||
|
...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
|
||||||
|
id: exactDoc.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`);
|
||||||
|
res.status(200).json({
|
||||||
|
equipments: [exactEquipment],
|
||||||
|
hasMore: false,
|
||||||
|
lastVisible: exactDoc.id,
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire la requête Firestore
|
||||||
|
let query = db.collection("equipments");
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
query = query.where("category", "==", category);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
query = query.where("status", "==", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy === "id") {
|
||||||
|
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
|
||||||
|
} else {
|
||||||
|
query = query.orderBy(sortBy, sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startAfterId) {
|
||||||
|
const startAfterDoc = await db.collection("equipments").doc(startAfterId).get();
|
||||||
|
if (startAfterDoc.exists) {
|
||||||
|
query = query.startAfter(startAfterDoc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampFields = ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"];
|
||||||
|
|
||||||
|
const mapEquipmentDoc = (doc) => {
|
||||||
|
const data = {...(doc.data() || {})};
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
delete data.purchasePrice;
|
||||||
|
delete data.rentalPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyId = typeof data.id === "string" ? data.id : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
...helpers.serializeTimestamps(data, timestampFields),
|
||||||
|
id: doc.id,
|
||||||
|
_legacyId: legacyId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesSearchQuery = (equipment) => {
|
||||||
|
const searchableText = [
|
||||||
|
equipment.name || "",
|
||||||
|
equipment.id || "",
|
||||||
|
equipment._legacyId || "",
|
||||||
|
equipment.model || "",
|
||||||
|
equipment.brand || "",
|
||||||
|
equipment.subCategory || "",
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
|
||||||
|
if (searchableText.includes(searchQuery)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!compactSearchQuery) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactSearchableText = searchableText.replace(/[\s_-]+/g, "");
|
||||||
|
return compactSearchableText.includes(compactSearchQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!searchQuery) {
|
||||||
|
const snapshot = await query.limit(limit + 1).get();
|
||||||
|
const rawDocCount = snapshot.docs.length;
|
||||||
|
const hasMoreDocs = rawDocCount > limit;
|
||||||
|
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, limit) : snapshot.docs;
|
||||||
|
|
||||||
|
const limitedEquipments = docsToProcess
|
||||||
|
.map(mapEquipmentDoc)
|
||||||
|
.map(({_legacyId, ...equipment}) => equipment);
|
||||||
|
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
|
||||||
|
|
||||||
|
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
|
||||||
|
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
equipments: limitedEquipments,
|
||||||
|
hasMore: hasMoreDocs,
|
||||||
|
lastVisible,
|
||||||
|
total: limitedEquipments.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200);
|
||||||
|
const matchedEquipments = [];
|
||||||
|
let scannedDocuments = 0;
|
||||||
|
let searchQueryRef = query;
|
||||||
|
let hasMoreMatches = false;
|
||||||
|
let hasMoreDocsToScan = true;
|
||||||
|
|
||||||
|
while (hasMoreDocsToScan && !hasMoreMatches) {
|
||||||
|
const snapshot = await searchQueryRef.limit(searchBatchSize).get();
|
||||||
|
|
||||||
|
if (snapshot.empty) {
|
||||||
|
hasMoreDocsToScan = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
scannedDocuments += snapshot.docs.length;
|
||||||
|
|
||||||
|
for (const doc of snapshot.docs) {
|
||||||
|
const equipment = mapEquipmentDoc(doc);
|
||||||
|
if (!matchesSearchQuery(equipment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedEquipments.push(equipment);
|
||||||
|
if (matchedEquipments.length > limit) {
|
||||||
|
hasMoreMatches = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMoreMatches) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.docs.length < searchBatchSize) {
|
||||||
|
hasMoreDocsToScan = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1];
|
||||||
|
searchQueryRef = query.startAfter(lastDocInBatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedEquipments = matchedEquipments
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(({_legacyId, ...equipment}) => equipment);
|
||||||
|
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
|
||||||
|
|
||||||
|
logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`);
|
||||||
|
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
equipments: limitedEquipments,
|
||||||
|
hasMore: hasMoreMatches,
|
||||||
|
lastVisible,
|
||||||
|
total: limitedEquipments.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching paginated equipments:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recherche rapide d'équipements et containers pour l'autocomplétion
|
||||||
|
exports.quickSearch = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
// Vérifier les permissions
|
||||||
|
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = req.method === "GET" ? req.query : (req.body?.data || {});
|
||||||
|
const searchQuery = params.query?.toLowerCase() || "";
|
||||||
|
const limit = Math.min(parseInt(params.limit) || 10, 50);
|
||||||
|
const includeEquipments = params.includeEquipments !== "false";
|
||||||
|
const includeContainers = params.includeContainers !== "false";
|
||||||
|
|
||||||
|
if (!searchQuery || searchQuery.length < 2) {
|
||||||
|
res.status(200).json({results: []});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Rechercher dans les équipements
|
||||||
|
if (includeEquipments) {
|
||||||
|
const equipmentSnapshot = await db.collection("equipments")
|
||||||
|
.orderBy("id")
|
||||||
|
.limit(limit * 2)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
equipmentSnapshot.docs.forEach((doc) => {
|
||||||
|
const data = doc.data();
|
||||||
|
const searchableText = [
|
||||||
|
data.name || "",
|
||||||
|
doc.id || "",
|
||||||
|
data.model || "",
|
||||||
|
data.brand || "",
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
|
||||||
|
if (searchableText.includes(searchQuery)) {
|
||||||
|
results.push({
|
||||||
|
type: "equipment",
|
||||||
|
id: doc.id,
|
||||||
|
name: data.name,
|
||||||
|
category: data.category,
|
||||||
|
model: data.model,
|
||||||
|
brand: data.brand,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechercher dans les containers
|
||||||
|
if (includeContainers) {
|
||||||
|
const containerSnapshot = await db.collection("containers")
|
||||||
|
.orderBy("id")
|
||||||
|
.limit(limit * 2)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
containerSnapshot.docs.forEach((doc) => {
|
||||||
|
const data = doc.data();
|
||||||
|
const searchableText = [
|
||||||
|
data.name || "",
|
||||||
|
doc.id || "",
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
|
||||||
|
if (searchableText.includes(searchQuery)) {
|
||||||
|
results.push({
|
||||||
|
type: "container",
|
||||||
|
id: doc.id,
|
||||||
|
name: data.name,
|
||||||
|
containerType: data.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedResults = results
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aStarts = a.id.toLowerCase().startsWith(searchQuery);
|
||||||
|
const bStarts = b.id.toLowerCase().startsWith(searchQuery);
|
||||||
|
if (aStarts && !bStarts) return -1;
|
||||||
|
if (!aStarts && bStarts) return 1;
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
res.status(200).json({results: limitedResults});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in quick search:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const db = admin.firestore();
|
||||||
|
const logger = require("firebase-functions/logger");
|
||||||
|
const auth = require("../utils/auth");
|
||||||
|
const helpers = require("../utils/helpers");
|
||||||
|
|
||||||
|
// Créer une maintenance
|
||||||
|
exports.createMaintenance = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maintenanceData = req.body.data;
|
||||||
|
|
||||||
|
const dataToSave = helpers.deserializeTimestamps(maintenanceData, [
|
||||||
|
"scheduledDate", "completedDate", "createdAt", "updatedAt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const docRef = await db.collection("maintenances").add(dataToSave);
|
||||||
|
const maintenanceId = docRef.id;
|
||||||
|
|
||||||
|
if (maintenanceData.equipmentIds && Array.isArray(maintenanceData.equipmentIds)) {
|
||||||
|
for (const equipmentId of maintenanceData.equipmentIds) {
|
||||||
|
try {
|
||||||
|
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||||
|
if (equipmentDoc.exists) {
|
||||||
|
const equipmentData = equipmentDoc.data();
|
||||||
|
const maintenanceIds = equipmentData.maintenanceIds || [];
|
||||||
|
if (!maintenanceIds.includes(maintenanceId)) {
|
||||||
|
maintenanceIds.push(maintenanceId);
|
||||||
|
await db.collection("equipments").doc(equipmentId).update({
|
||||||
|
maintenanceIds: maintenanceIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maintenanceData.scheduledDate) {
|
||||||
|
const scheduledDate = maintenanceData.scheduledDate.toDate ?
|
||||||
|
maintenanceData.scheduledDate.toDate() :
|
||||||
|
new Date(maintenanceData.scheduledDate);
|
||||||
|
const sevenDaysFromNow = new Date();
|
||||||
|
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
||||||
|
|
||||||
|
if (scheduledDate <= sevenDaysFromNow) {
|
||||||
|
const existingAlerts = await db.collection("alerts")
|
||||||
|
.where("equipmentId", "==", equipmentId)
|
||||||
|
.where("type", "==", "maintenanceDue")
|
||||||
|
.where("isRead", "==", false)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
let alertExists = false;
|
||||||
|
for (const alertDoc of existingAlerts.docs) {
|
||||||
|
const alertData = alertDoc.data();
|
||||||
|
if (alertData.message && alertData.message.includes(maintenanceData.name || "")) {
|
||||||
|
alertExists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!alertExists) {
|
||||||
|
const equipmentName = equipmentDoc.exists ?
|
||||||
|
(equipmentDoc.data().name || equipmentId) :
|
||||||
|
equipmentId;
|
||||||
|
|
||||||
|
const daysUntil = Math.ceil((scheduledDate - new Date()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
await db.collection("alerts").add({
|
||||||
|
type: "maintenanceDue",
|
||||||
|
message: `Maintenance "${maintenanceData.name || "Sans nom"}" prévue dans ${daysUntil} jour(s) pour ${equipmentName}`,
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
createdAt: admin.firestore.Timestamp.now(),
|
||||||
|
isRead: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Error updating equipment ${equipmentId} for maintenance:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({id: maintenanceId, message: "Maintenance created successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating maintenance:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mettre à jour une maintenance
|
||||||
|
exports.updateMaintenance = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {maintenanceId, data} = req.body.data;
|
||||||
|
|
||||||
|
if (!maintenanceId) {
|
||||||
|
res.status(400).json({error: "Maintenance ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete data.id;
|
||||||
|
data.updatedAt = admin.firestore.Timestamp.now();
|
||||||
|
|
||||||
|
const dataToSave = helpers.deserializeTimestamps(data, [
|
||||||
|
"scheduledDate", "completedDate",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.collection("maintenances").doc(maintenanceId).update(dataToSave);
|
||||||
|
|
||||||
|
res.status(200).json({message: "Maintenance updated successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating maintenance:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer toutes les maintenances
|
||||||
|
exports.getMaintenances = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const {equipmentId} = req.body.data || {};
|
||||||
|
|
||||||
|
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = db.collection("maintenances");
|
||||||
|
|
||||||
|
if (equipmentId) {
|
||||||
|
query = query.where("equipmentIds", "array-contains", equipmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await query.get();
|
||||||
|
const maintenances = snapshot.docs.map((doc) => {
|
||||||
|
const data = doc.data();
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
...helpers.serializeTimestamps(data, ["scheduledDate", "completedDate", "createdAt", "updatedAt"]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({maintenances});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching maintenances:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer une maintenance
|
||||||
|
exports.deleteMaintenance = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
if (!canManage) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maintenanceId = req.body.data?.maintenanceId;
|
||||||
|
if (!maintenanceId) {
|
||||||
|
res.status(400).json({error: "maintenanceId is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get();
|
||||||
|
if (maintenanceDoc.exists) {
|
||||||
|
const maintenance = maintenanceDoc.data();
|
||||||
|
|
||||||
|
if (maintenance.equipmentIds) {
|
||||||
|
for (const equipmentId of maintenance.equipmentIds) {
|
||||||
|
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||||
|
if (equipmentDoc.exists) {
|
||||||
|
const equipmentData = equipmentDoc.data();
|
||||||
|
const maintenanceIds = (equipmentData.maintenanceIds || []).filter((id) => id !== maintenanceId);
|
||||||
|
await db.collection("equipments").doc(equipmentId).update({maintenanceIds});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("maintenances").doc(maintenanceId).delete();
|
||||||
|
|
||||||
|
res.status(200).json({success: true});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deleting maintenance:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifier les maintenances à venir et créer des alertes
|
||||||
|
*/
|
||||||
|
exports.checkUpcomingMaintenances = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sevenDaysFromNow = new Date();
|
||||||
|
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
||||||
|
|
||||||
|
const now = admin.firestore.Timestamp.now();
|
||||||
|
const sevenDaysTimestamp = admin.firestore.Timestamp.fromDate(sevenDaysFromNow);
|
||||||
|
|
||||||
|
// Récupérer les maintenances planifiées dans les 7 prochains jours
|
||||||
|
const maintenancesSnapshot = await db.collection("maintenances")
|
||||||
|
.where("scheduledDate", "<=", sevenDaysTimestamp)
|
||||||
|
.where("scheduledDate", ">=", now)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const alertsCreated = [];
|
||||||
|
|
||||||
|
for (const doc of maintenancesSnapshot.docs) {
|
||||||
|
const maintenance = doc.data();
|
||||||
|
|
||||||
|
// Vérifier si une alerte existe déjà pour cette maintenance
|
||||||
|
const existingAlertSnapshot = await db.collection("alerts")
|
||||||
|
.where("type", "==", "MAINTENANCE_DUE")
|
||||||
|
.where("relatedMaintenanceId", "==", doc.id)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingAlertSnapshot.empty) {
|
||||||
|
// Créer une nouvelle alerte
|
||||||
|
const alertData = {
|
||||||
|
type: "MAINTENANCE_DUE",
|
||||||
|
title: `Maintenance à venir`,
|
||||||
|
message: `Une maintenance est prévue pour ${maintenance.equipmentIds?.length || 0} équipement(s)`,
|
||||||
|
severity: "MEDIUM",
|
||||||
|
isRead: false,
|
||||||
|
relatedMaintenanceId: doc.id,
|
||||||
|
createdAt: admin.firestore.Timestamp.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const alertRef = await db.collection("alerts").add(alertData);
|
||||||
|
alertsCreated.push({id: alertRef.id, ...alertData});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
alertsCreated: alertsCreated.length,
|
||||||
|
alerts: alertsCreated,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error checking upcoming maintenances:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compléter une maintenance
|
||||||
|
*/
|
||||||
|
exports.completeMaintenance = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {maintenanceId, performedBy, cost} = req.body.data;
|
||||||
|
|
||||||
|
if (!maintenanceId) {
|
||||||
|
res.status(400).json({error: "maintenanceId is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = admin.firestore.Timestamp.now();
|
||||||
|
const updateData = {
|
||||||
|
completedDate: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (performedBy) {
|
||||||
|
updateData.performedBy = performedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cost !== undefined && cost !== null) {
|
||||||
|
updateData.cost = cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour la maintenance
|
||||||
|
await db.collection("maintenances").doc(maintenanceId).update(updateData);
|
||||||
|
|
||||||
|
// Récupérer la maintenance pour mettre à jour les équipements
|
||||||
|
const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get();
|
||||||
|
const maintenanceData = maintenanceDoc.data();
|
||||||
|
|
||||||
|
// Mettre à jour la date de dernière maintenance des équipements
|
||||||
|
if (maintenanceData && maintenanceData.equipmentIds) {
|
||||||
|
const updatePromises = maintenanceData.equipmentIds.map((equipmentId) =>
|
||||||
|
db.collection("equipments").doc(equipmentId).update({
|
||||||
|
lastMaintenanceDate: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(updatePromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({success: true});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error completing maintenance:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const db = admin.firestore();
|
||||||
|
const logger = require("firebase-functions/logger");
|
||||||
|
const auth = require("../utils/auth");
|
||||||
|
const helpers = require("../utils/helpers");
|
||||||
|
|
||||||
|
// Récupérer toutes les options (public pour utilisateurs authentifiés)
|
||||||
|
exports.getOptions = async (req, res) => {
|
||||||
|
try {
|
||||||
|
await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const snapshot = await db.collection("options").get();
|
||||||
|
const options = snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...helpers.serializeTimestamps(doc.data()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json({options});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching options:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer tous les types d'événements (public pour utilisateurs authentifiés)
|
||||||
|
exports.getEventTypes = async (req, res) => {
|
||||||
|
try {
|
||||||
|
await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const snapshot = await db.collection("eventTypes").get();
|
||||||
|
const eventTypes = snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...helpers.serializeTimestamps(doc.data()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json({eventTypes});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching event types:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Créer une option
|
||||||
|
exports.createOption = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||||
|
|
||||||
|
if (!isAdminUser) {
|
||||||
|
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionData = req.body.data;
|
||||||
|
const optionId = optionData.id;
|
||||||
|
|
||||||
|
if (!optionId) {
|
||||||
|
res.status(400).json({error: "Option ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("options").doc(optionId).set(optionData);
|
||||||
|
|
||||||
|
res.status(201).json({id: optionId, message: "Option created successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating option:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mettre à jour une option
|
||||||
|
exports.updateOption = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||||
|
|
||||||
|
if (!isAdminUser) {
|
||||||
|
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {optionId, data} = req.body.data;
|
||||||
|
|
||||||
|
if (!optionId) {
|
||||||
|
res.status(400).json({error: "Option ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete data.id;
|
||||||
|
|
||||||
|
await db.collection("options").doc(optionId).update(data);
|
||||||
|
|
||||||
|
res.status(200).json({message: "Option updated successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating option:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer une option
|
||||||
|
exports.deleteOption = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||||
|
|
||||||
|
if (!isAdminUser) {
|
||||||
|
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {optionId} = req.body.data;
|
||||||
|
|
||||||
|
if (!optionId) {
|
||||||
|
res.status(400).json({error: "Option ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("options").doc(optionId).delete();
|
||||||
|
|
||||||
|
res.status(200).json({message: "Option deleted successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deleting option:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Créer un type d'événement
|
||||||
|
exports.createEventType = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
res.status(403).json({error: "Forbidden: Admin permission required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {name, defaultPrice} = req.body.data;
|
||||||
|
|
||||||
|
if (!name || defaultPrice === undefined) {
|
||||||
|
res.status(400).json({error: "Name and defaultPrice are required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSnapshot = await db.collection("eventTypes")
|
||||||
|
.where("name", "==", name)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!existingSnapshot.empty) {
|
||||||
|
res.status(409).json({error: "Event type name already exists"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTypeData = {
|
||||||
|
name,
|
||||||
|
defaultPrice,
|
||||||
|
createdAt: admin.firestore.Timestamp.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const docRef = await db.collection("eventTypes").add(eventTypeData);
|
||||||
|
|
||||||
|
res.status(201).json({id: docRef.id, message: "Event type created successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating event type:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mettre à jour un type d'événement
|
||||||
|
exports.updateEventType = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
res.status(403).json({error: "Forbidden: Admin permission required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {eventTypeId, name, defaultPrice} = req.body.data;
|
||||||
|
|
||||||
|
if (!eventTypeId) {
|
||||||
|
res.status(400).json({error: "Event type ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docRef = db.collection("eventTypes").doc(eventTypeId);
|
||||||
|
const doc = await docRef.get();
|
||||||
|
|
||||||
|
if (!doc.exists) {
|
||||||
|
res.status(404).json({error: "Event type not found"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
const existingSnapshot = await db.collection("eventTypes")
|
||||||
|
.where("name", "==", name)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const hasDuplicate = existingSnapshot.docs.some((d) => d.id !== eventTypeId);
|
||||||
|
if (hasDuplicate) {
|
||||||
|
res.status(409).json({error: "Event type name already exists"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {};
|
||||||
|
if (name) updateData.name = name;
|
||||||
|
if (defaultPrice !== undefined) updateData.defaultPrice = defaultPrice;
|
||||||
|
|
||||||
|
await docRef.update(updateData);
|
||||||
|
|
||||||
|
res.status(200).json({message: "Event type updated successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating event type:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer un type d'événement
|
||||||
|
exports.deleteEventType = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
res.status(403).json({error: "Forbidden: Admin permission required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {eventTypeId} = req.body.data;
|
||||||
|
|
||||||
|
if (!eventTypeId) {
|
||||||
|
res.status(400).json({error: "Event type ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsSnapshot = await db.collection("events")
|
||||||
|
.where("eventTypeId", "==", eventTypeId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const now = admin.firestore.Timestamp.now();
|
||||||
|
const futureEvents = eventsSnapshot.docs.filter((doc) => {
|
||||||
|
const startDate = doc.data().StartDateTime;
|
||||||
|
return startDate && startDate > now;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (futureEvents.length > 0) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: "Cannot delete event type with future events",
|
||||||
|
futureEventsCount: futureEvents.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("eventTypes").doc(eventTypeId).delete();
|
||||||
|
|
||||||
|
res.status(200).json({message: "Event type deleted successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deleting event type:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const {Storage} = require("@google-cloud/storage");
|
||||||
|
const logger = require("firebase-functions/logger");
|
||||||
|
const auth = require("../utils/auth");
|
||||||
|
|
||||||
|
const storage = new Storage();
|
||||||
|
|
||||||
|
exports.moveEventFileV2 = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const {sourcePath, destinationPath} = req.body.data || {};
|
||||||
|
|
||||||
|
if (!sourcePath || !destinationPath) {
|
||||||
|
res.status(400).json({error: "Source and destination paths are required."});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketName = admin.storage().bucket().name;
|
||||||
|
const bucket = storage.bucket(bucketName);
|
||||||
|
|
||||||
|
await bucket.file(sourcePath).copy(bucket.file(destinationPath));
|
||||||
|
await bucket.file(sourcePath).delete();
|
||||||
|
const [url] = await bucket.file(destinationPath).getSignedUrl({
|
||||||
|
action: "read",
|
||||||
|
expires: "03-01-2500",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({url});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error moving file:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const logger = require("firebase-functions/logger");
|
||||||
|
const auth = require("../utils/auth");
|
||||||
|
|
||||||
|
exports.generateTTSV2 = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
logger.info("[generateTTSV2] Request from user:", {
|
||||||
|
uid: decodedToken.uid,
|
||||||
|
email: decodedToken.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {text, voiceConfig} = req.body.data || {};
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
res.status(400).json({error: "Text parameter is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > 5000) {
|
||||||
|
res.status(400).json({error: "Text too long (max 5000 characters)"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {Storage} = require("@google-cloud/storage");
|
||||||
|
const storage = new Storage();
|
||||||
|
const bucketName = admin.storage().bucket().name;
|
||||||
|
const bucket = storage.bucket(bucketName);
|
||||||
|
|
||||||
|
const {generateTTS} = require("../generateTTS");
|
||||||
|
const result = await generateTTS(text, storage, bucket, voiceConfig);
|
||||||
|
|
||||||
|
logger.info("[generateTTSV2] ✓ Success", {
|
||||||
|
cached: result.cached,
|
||||||
|
cacheKey: result.cacheKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
audioUrl: result.audioUrl,
|
||||||
|
cached: result.cached,
|
||||||
|
cacheKey: result.cacheKey,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[generateTTSV2] ✗ Error:", {
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error.code === "PERMISSION_DENIED") {
|
||||||
|
res.status(403).json({error: "Permission denied. Check Google Cloud TTS API is enabled."});
|
||||||
|
} else if (error.code === "QUOTA_EXCEEDED") {
|
||||||
|
res.status(429).json({error: "TTS quota exceeded. Try again later."});
|
||||||
|
} else {
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const db = admin.firestore();
|
||||||
|
const logger = require("firebase-functions/logger");
|
||||||
|
const auth = require("../utils/auth");
|
||||||
|
const helpers = require("../utils/helpers");
|
||||||
|
|
||||||
|
// Créer un utilisateur
|
||||||
|
exports.createUser = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||||
|
|
||||||
|
if (!isAdminUser) {
|
||||||
|
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = req.body.data;
|
||||||
|
const userId = userData.uid;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(400).json({error: "User ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("users").doc(userId).set(userData);
|
||||||
|
|
||||||
|
res.status(201).json({id: userId, message: "User created successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating user:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Créer un utilisateur avec invitation par email
|
||||||
|
exports.createUserWithInvite = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||||
|
|
||||||
|
if (!isAdminUser) {
|
||||||
|
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {email, firstName, lastName, phoneNumber, roleId} = req.body.data;
|
||||||
|
|
||||||
|
if (!email || !firstName || !lastName || !roleId) {
|
||||||
|
res.status(400).json({error: "email, firstName, lastName, and roleId are required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPassword = Math.random().toString(36).slice(-12) + "Aa1!";
|
||||||
|
|
||||||
|
let userRecord;
|
||||||
|
try {
|
||||||
|
userRecord = await admin.auth().createUser({
|
||||||
|
email: email,
|
||||||
|
password: tempPassword,
|
||||||
|
emailVerified: false,
|
||||||
|
displayName: `${firstName} ${lastName}`,
|
||||||
|
});
|
||||||
|
} catch (authError) {
|
||||||
|
logger.error("Error creating user in Auth:", authError);
|
||||||
|
res.status(500).json({error: `Failed to create user in Auth: ${authError.message}`});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.collection("users").doc(userRecord.uid).set({
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
email: email,
|
||||||
|
phoneNumber: phoneNumber || "",
|
||||||
|
profilePhotoUrl: "",
|
||||||
|
role: db.collection("roles").doc(roleId),
|
||||||
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
createdBy: decodedToken.uid,
|
||||||
|
});
|
||||||
|
} catch (firestoreError) {
|
||||||
|
logger.error("Error creating user in Firestore:", firestoreError);
|
||||||
|
try {
|
||||||
|
await admin.auth().deleteUser(userRecord.uid);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.error("Error cleaning up Auth user:", cleanupError);
|
||||||
|
}
|
||||||
|
res.status(500).json({error: `Failed to create user in Firestore: ${firestoreError.message}`});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const axios = require("axios");
|
||||||
|
const firebaseApiKey = "AIzaSyARQL4P-t5l-cNjQNP9cMokQrLJ8BorF0U";
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
`https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=${firebaseApiKey}`,
|
||||||
|
{
|
||||||
|
requestType: "PASSWORD_RESET",
|
||||||
|
email: email,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
logger.info(`Password reset email sent to ${email}`);
|
||||||
|
} catch (emailError) {
|
||||||
|
logger.warn(`Could not send password reset email to ${email}: ${emailError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`User ${userRecord.uid} created by ${decodedToken.uid}`);
|
||||||
|
res.status(201).json({
|
||||||
|
id: userRecord.uid,
|
||||||
|
message: "User created successfully. Password reset email sent.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in createUserWithInvite:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mettre à jour un utilisateur
|
||||||
|
exports.updateUser = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const {userId, data} = req.body.data;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(400).json({error: "User ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un utilisateur ne peut modifier que son propre compte, sauf s'il est admin
|
||||||
|
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||||
|
if (decodedToken.uid !== userId && !isAdminUser) {
|
||||||
|
res.status(403).json({error: "Forbidden: Cannot update other user accounts"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empêcher les non-admins de modifier le rôle
|
||||||
|
if (!isAdminUser && data.role) {
|
||||||
|
delete data.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si le rôle est fourni et est un string, le convertir en DocumentReference
|
||||||
|
if (data.role && typeof data.role === "string") {
|
||||||
|
data.role = db.collection("roles").doc(data.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("users").doc(userId).update(data);
|
||||||
|
|
||||||
|
res.status(200).json({message: "User updated successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating user:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer un utilisateur
|
||||||
|
exports.deleteUser = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||||
|
|
||||||
|
if (!isAdminUser) {
|
||||||
|
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {userId} = req.body.data;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(400).json({error: "User ID is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decodedToken.uid === userId) {
|
||||||
|
res.status(400).json({error: "Cannot delete your own account"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection("users").doc(userId).delete();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await admin.auth().deleteUser(userId);
|
||||||
|
} catch (authError) {
|
||||||
|
logger.warn(`Could not delete user from Auth: ${authError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({message: "User deleted successfully"});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deleting user:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer tous les utilisateurs (selon permissions)
|
||||||
|
exports.getUsers = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_users");
|
||||||
|
|
||||||
|
if (!canViewAll) {
|
||||||
|
const userDoc = await db.collection("users").doc(decodedToken.uid).get();
|
||||||
|
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
res.status(404).json({error: "User not found"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let userData = userDoc.data();
|
||||||
|
userData = helpers.serializeTimestamps(userData);
|
||||||
|
userData = helpers.serializeReferences(userData);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
users: [{
|
||||||
|
id: userDoc.id,
|
||||||
|
...userData,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await db.collection("users").get();
|
||||||
|
const users = snapshot.docs.map((doc) => {
|
||||||
|
let data = doc.data();
|
||||||
|
data = helpers.serializeTimestamps(data);
|
||||||
|
data = helpers.serializeReferences(data);
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({users});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching users:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupère un utilisateur spécifique
|
||||||
|
exports.getUser = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const {userId} = req.body.data || req.body || {};
|
||||||
|
if (!userId) {
|
||||||
|
res.status(400).json({error: "userId is required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDoc = await db.collection("users").doc(userId).get();
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
res.status(404).json({error: "User not found"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userDoc.data();
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
id: userDoc.id,
|
||||||
|
uid: user.uid || userDoc.id,
|
||||||
|
email: user.email || "",
|
||||||
|
firstName: user.firstName || "",
|
||||||
|
lastName: user.lastName || "",
|
||||||
|
phoneNumber: user.phoneNumber || "",
|
||||||
|
profilePhotoUrl: user.profilePhotoUrl || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user.role) {
|
||||||
|
const roleDoc = await user.role.get();
|
||||||
|
if (roleDoc.exists) {
|
||||||
|
userData.role = {
|
||||||
|
id: roleDoc.id,
|
||||||
|
...roleDoc.data(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({user: userData});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching user:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupère l'utilisateur actuellement authentifié avec son rôle
|
||||||
|
exports.getCurrentUser = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
const userId = decodedToken.uid;
|
||||||
|
|
||||||
|
const userDoc = await db.collection("users").doc(userId).get();
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
res.status(404).json({error: "User not found"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = userDoc.data();
|
||||||
|
|
||||||
|
let roleData = null;
|
||||||
|
if (userData.role) {
|
||||||
|
const roleDoc = await userData.role.get();
|
||||||
|
if (roleDoc.exists) {
|
||||||
|
roleData = {id: roleDoc.id, ...roleDoc.data()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
user: {
|
||||||
|
uid: userId,
|
||||||
|
...helpers.serializeTimestamps(userData),
|
||||||
|
role: roleData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error getting current user:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupère tous les rôles
|
||||||
|
exports.getRoles = async (req, res) => {
|
||||||
|
try {
|
||||||
|
await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const snapshot = await db.collection("roles").get();
|
||||||
|
const roles = snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...helpers.serializeTimestamps(doc.data()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json({roles});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching roles:", error);
|
||||||
|
res.status(500).json({error: error.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Utilitaires d'authentification et d'autorisation
|
* Utilitaires d'authentification et d'autorisation
|
||||||
*/
|
*/
|
||||||
const admin = require('firebase-admin');
|
const admin = require("firebase-admin");
|
||||||
const logger = require('firebase-functions/logger');
|
const logger = require("firebase-functions/logger");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vérifie le token Firebase et retourne l'utilisateur
|
* Vérifie le token Firebase et retourne l'utilisateur
|
||||||
*/
|
*/
|
||||||
async function authenticateUser(req) {
|
async function authenticateUser(req) {
|
||||||
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) {
|
if (!req.headers.authorization || !req.headers.authorization.startsWith("Bearer ")) {
|
||||||
throw new Error('Unauthorized: No token provided');
|
throw new Error("Unauthorized: No token provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const idToken = req.headers.authorization.split('Bearer ')[1];
|
const idToken = req.headers.authorization.split("Bearer ")[1];
|
||||||
try {
|
try {
|
||||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||||
return decodedToken;
|
return decodedToken;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("Error verifying Firebase ID token:", e);
|
logger.error("Error verifying Firebase ID token:", e);
|
||||||
throw new Error('Unauthorized: Invalid token');
|
throw new Error("Unauthorized: Invalid token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,11 +26,11 @@ async function authenticateUser(req) {
|
|||||||
* Récupère les données utilisateur depuis Firestore
|
* Récupère les données utilisateur depuis Firestore
|
||||||
*/
|
*/
|
||||||
async function getUserData(uid) {
|
async function getUserData(uid) {
|
||||||
const userDoc = await admin.firestore().collection('users').doc(uid).get();
|
const userDoc = await admin.firestore().collection("users").doc(uid).get();
|
||||||
if (!userDoc.exists) {
|
if (!userDoc.exists) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { uid, ...userDoc.data() };
|
return {uid, ...userDoc.data()};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +40,7 @@ async function getRolePermissions(roleRef) {
|
|||||||
if (!roleRef) return [];
|
if (!roleRef) return [];
|
||||||
|
|
||||||
let roleId;
|
let roleId;
|
||||||
if (typeof roleRef === 'string') {
|
if (typeof roleRef === "string") {
|
||||||
roleId = roleRef;
|
roleId = roleRef;
|
||||||
} else if (roleRef.id) {
|
} else if (roleRef.id) {
|
||||||
roleId = roleRef.id;
|
roleId = roleRef.id;
|
||||||
@@ -48,7 +48,7 @@ async function getRolePermissions(roleRef) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleDoc = await admin.firestore().collection('roles').doc(roleId).get();
|
const roleDoc = await admin.firestore().collection("roles").doc(roleId).get();
|
||||||
if (!roleDoc.exists) return [];
|
if (!roleDoc.exists) return [];
|
||||||
|
|
||||||
return roleDoc.data().permissions || [];
|
return roleDoc.data().permissions || [];
|
||||||
@@ -74,7 +74,7 @@ async function isAdmin(uid) {
|
|||||||
|
|
||||||
let roleId;
|
let roleId;
|
||||||
const roleField = userData.role;
|
const roleField = userData.role;
|
||||||
if (typeof roleField === 'string') {
|
if (typeof roleField === "string") {
|
||||||
roleId = roleField;
|
roleId = roleField;
|
||||||
} else if (roleField && roleField.id) {
|
} else if (roleField && roleField.id) {
|
||||||
roleId = roleField.id;
|
roleId = roleField.id;
|
||||||
@@ -82,22 +82,22 @@ async function isAdmin(uid) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return roleId === 'ADMIN';
|
return roleId === "ADMIN";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vérifie si l'utilisateur est assigné à un événement
|
* Vérifie si l'utilisateur est assigné à un événement
|
||||||
*/
|
*/
|
||||||
async function isAssignedToEvent(uid, eventId) {
|
async function isAssignedToEvent(uid, eventId) {
|
||||||
const eventDoc = await admin.firestore().collection('events').doc(eventId).get();
|
const eventDoc = await admin.firestore().collection("events").doc(eventId).get();
|
||||||
if (!eventDoc.exists) return false;
|
if (!eventDoc.exists) return false;
|
||||||
|
|
||||||
const eventData = eventDoc.data();
|
const eventData = eventDoc.data();
|
||||||
const workforce = eventData.workforce || [];
|
const workforce = eventData.workforce || [];
|
||||||
|
|
||||||
// workforce contient des références DocumentReference
|
// workforce contient des références DocumentReference
|
||||||
return workforce.some(ref => {
|
return workforce.some((ref) => {
|
||||||
if (typeof ref === 'string') return ref === uid;
|
if (typeof ref === "string") return ref === uid;
|
||||||
if (ref && ref.id) return ref.id === uid;
|
if (ref && ref.id) return ref.id === uid;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -113,7 +113,7 @@ async function authMiddleware(req, res, next) {
|
|||||||
req.uid = decodedToken.uid;
|
req.uid = decodedToken.uid;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(401).json({ error: error.message });
|
res.status(401).json({error: error.message});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,12 +125,12 @@ function requirePermission(permission) {
|
|||||||
try {
|
try {
|
||||||
const hasAccess = await hasPermission(req.uid, permission);
|
const hasAccess = await hasPermission(req.uid, permission);
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
res.status(403).json({ error: `Forbidden: Requires permission '${permission}'` });
|
res.status(403).json({error: `Forbidden: Requires permission '${permission}'`});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(403).json({ error: error.message });
|
res.status(403).json({error: error.message});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -142,12 +142,12 @@ async function requireAdmin(req, res, next) {
|
|||||||
try {
|
try {
|
||||||
const adminAccess = await isAdmin(req.uid);
|
const adminAccess = await isAdmin(req.uid);
|
||||||
if (!adminAccess) {
|
if (!adminAccess) {
|
||||||
res.status(403).json({ error: 'Forbidden: Admin access required' });
|
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(403).json({ error: error.message });
|
res.status(403).json({error: error.message});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
|
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
|
||||||
const getSmtpConfig = () => {
|
const getSmtpConfig = () => {
|
||||||
return {
|
return {
|
||||||
host: process.env.SMTP_HOST || 'mail.em2events.fr',
|
host: process.env.SMTP_HOST || "mail.em2events.fr",
|
||||||
port: parseInt(process.env.SMTP_PORT || '465'),
|
port: parseInt(process.env.SMTP_PORT || "465"),
|
||||||
secure: true, // true pour port 465, false pour autres ports
|
secure: true, // true pour port 465, false pour autres ports
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.SMTP_USER || 'notify@em2events.fr',
|
user: process.env.SMTP_USER || "notify@em2events.fr",
|
||||||
pass: process.env.SMTP_PASS || '',
|
pass: process.env.SMTP_PASS || "",
|
||||||
},
|
},
|
||||||
tls: {
|
tls: {
|
||||||
// Ne pas échouer sur certificats invalides
|
// Ne pas échouer sur certificats invalides
|
||||||
@@ -24,12 +24,12 @@ const getSmtpConfig = () => {
|
|||||||
// Configuration email par défaut
|
// Configuration email par défaut
|
||||||
const EMAIL_CONFIG = {
|
const EMAIL_CONFIG = {
|
||||||
from: {
|
from: {
|
||||||
name: 'EM2 Events',
|
name: "EM2 Events",
|
||||||
address: 'notify@em2events.fr',
|
address: "notify@em2events.fr",
|
||||||
},
|
},
|
||||||
replyTo: 'contact@em2events.fr',
|
replyTo: "contact@em2events.fr",
|
||||||
// URL de l'application pour les liens
|
// URL de l'application pour les liens
|
||||||
appUrl: process.env.APP_URL || 'https://app.em2events.fr',
|
appUrl: process.env.APP_URL || "https://app.em2events.fr",
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
const admin = require('firebase-admin');
|
const admin = require("firebase-admin");
|
||||||
const handlebars = require('handlebars');
|
const handlebars = require("handlebars");
|
||||||
const fs = require('fs').promises;
|
const fs = require("fs").promises;
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
const {EMAIL_CONFIG} = require('./emailConfig');
|
const {EMAIL_CONFIG} = require("./emailConfig");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
|
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
|
||||||
*/
|
*/
|
||||||
function checkAlertPreference(alertType, preferences) {
|
function checkAlertPreference(alertType, preferences) {
|
||||||
const typeMapping = {
|
const typeMapping = {
|
||||||
'EVENT_CREATED': 'eventsNotifications',
|
"EVENT_CREATED": "eventsNotifications",
|
||||||
'EVENT_MODIFIED': 'eventsNotifications',
|
"EVENT_MODIFIED": "eventsNotifications",
|
||||||
'EVENT_CANCELLED': 'eventsNotifications',
|
"EVENT_CANCELLED": "eventsNotifications",
|
||||||
'LOST': 'equipmentNotifications',
|
"LOST": "equipmentNotifications",
|
||||||
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
"EQUIPMENT_MISSING": "equipmentNotifications",
|
||||||
'DAMAGED': 'equipmentNotifications',
|
"DAMAGED": "equipmentNotifications",
|
||||||
'QUANTITY_MISMATCH': 'equipmentNotifications',
|
"QUANTITY_MISMATCH": "equipmentNotifications",
|
||||||
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
"MAINTENANCE_REMINDER": "maintenanceNotifications",
|
||||||
'STOCK_LOW': 'stockNotifications',
|
"STOCK_LOW": "stockNotifications",
|
||||||
};
|
};
|
||||||
|
|
||||||
const prefKey = typeMapping[alertType];
|
const prefKey = typeMapping[alertType];
|
||||||
@@ -29,12 +29,12 @@ function checkAlertPreference(alertType, preferences) {
|
|||||||
*/
|
*/
|
||||||
async function prepareTemplateData(alert, user) {
|
async function prepareTemplateData(alert, user) {
|
||||||
const data = {
|
const data = {
|
||||||
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() ||
|
||||||
'Utilisateur',
|
"Utilisateur",
|
||||||
alertTitle: getAlertTitle(alert.type),
|
alertTitle: getAlertTitle(alert.type),
|
||||||
alertMessage: alert.message,
|
alertMessage: alert.message,
|
||||||
isCritical: alert.severity === 'CRITICAL',
|
isCritical: alert.severity === "CRITICAL",
|
||||||
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`,
|
||||||
appUrl: EMAIL_CONFIG.appUrl,
|
appUrl: EMAIL_CONFIG.appUrl,
|
||||||
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
@@ -45,20 +45,20 @@ async function prepareTemplateData(alert, user) {
|
|||||||
if (alert.eventId) {
|
if (alert.eventId) {
|
||||||
try {
|
try {
|
||||||
const eventDoc = await admin.firestore()
|
const eventDoc = await admin.firestore()
|
||||||
.collection('events')
|
.collection("events")
|
||||||
.doc(alert.eventId)
|
.doc(alert.eventId)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (eventDoc.exists) {
|
if (eventDoc.exists) {
|
||||||
const event = eventDoc.data();
|
const event = eventDoc.data();
|
||||||
data.eventName = event.Name || event.name || 'Événement';
|
data.eventName = event.Name || event.name || "Événement";
|
||||||
if (event.StartDateTime || event.startDate) {
|
if (event.StartDateTime || event.startDate) {
|
||||||
const dateField = event.StartDateTime || event.startDate;
|
const dateField = event.StartDateTime || event.startDate;
|
||||||
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
|
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
|
||||||
data.eventDate = date.toLocaleDateString('fr-FR', {
|
data.eventDate = date.toLocaleDateString("fr-FR", {
|
||||||
day: '2-digit',
|
day: "2-digit",
|
||||||
month: '2-digit',
|
month: "2-digit",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ async function prepareTemplateData(alert, user) {
|
|||||||
if (alert.equipmentId) {
|
if (alert.equipmentId) {
|
||||||
try {
|
try {
|
||||||
const eqDoc = await admin.firestore()
|
const eqDoc = await admin.firestore()
|
||||||
.collection('equipments')
|
.collection("equipments")
|
||||||
.doc(alert.equipmentId)
|
.doc(alert.equipmentId)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
@@ -90,18 +90,18 @@ async function prepareTemplateData(alert, user) {
|
|||||||
*/
|
*/
|
||||||
function getEmailSubject(alert) {
|
function getEmailSubject(alert) {
|
||||||
const subjects = {
|
const subjects = {
|
||||||
'EVENT_CREATED': '📅 Nouvel événement créé',
|
"EVENT_CREATED": "📅 Nouvel événement créé",
|
||||||
'EVENT_MODIFIED': '📝 Événement modifié',
|
"EVENT_MODIFIED": "📝 Événement modifié",
|
||||||
'EVENT_CANCELLED': '❌ Événement annulé',
|
"EVENT_CANCELLED": "❌ Événement annulé",
|
||||||
'LOST': '🔴 Alerte critique : Équipement perdu',
|
"LOST": "🔴 Alerte critique : Équipement perdu",
|
||||||
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
"EQUIPMENT_MISSING": "⚠️ Équipement manquant",
|
||||||
'DAMAGED': '⚠️ Équipement endommagé',
|
"DAMAGED": "⚠️ Équipement endommagé",
|
||||||
'QUANTITY_MISMATCH': 'ℹ️ Quantité incorrecte',
|
"QUANTITY_MISMATCH": "ℹ️ Quantité incorrecte",
|
||||||
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
"MAINTENANCE_REMINDER": "🔧 Rappel de maintenance",
|
||||||
'STOCK_LOW': '📦 Stock faible',
|
"STOCK_LOW": "📦 Stock faible",
|
||||||
};
|
};
|
||||||
|
|
||||||
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,18 +109,18 @@ function getEmailSubject(alert) {
|
|||||||
*/
|
*/
|
||||||
function getAlertTitle(type) {
|
function getAlertTitle(type) {
|
||||||
const titles = {
|
const titles = {
|
||||||
'EVENT_CREATED': 'Nouvel événement créé',
|
"EVENT_CREATED": "Nouvel événement créé",
|
||||||
'EVENT_MODIFIED': 'Événement modifié',
|
"EVENT_MODIFIED": "Événement modifié",
|
||||||
'EVENT_CANCELLED': 'Événement annulé',
|
"EVENT_CANCELLED": "Événement annulé",
|
||||||
'LOST': 'Équipement perdu',
|
"LOST": "Équipement perdu",
|
||||||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
"EQUIPMENT_MISSING": "Équipement manquant",
|
||||||
'DAMAGED': 'Équipement endommagé',
|
"DAMAGED": "Équipement endommagé",
|
||||||
'QUANTITY_MISMATCH': 'Quantité incorrecte',
|
"QUANTITY_MISMATCH": "Quantité incorrecte",
|
||||||
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
"MAINTENANCE_REMINDER": "Maintenance requise",
|
||||||
'STOCK_LOW': 'Stock faible',
|
"STOCK_LOW": "Stock faible",
|
||||||
};
|
};
|
||||||
|
|
||||||
return titles[type] || 'Nouvelle alerte';
|
return titles[type] || "Nouvelle alerte";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,17 +129,17 @@ function getAlertTitle(type) {
|
|||||||
async function renderTemplate(templateName, data) {
|
async function renderTemplate(templateName, data) {
|
||||||
try {
|
try {
|
||||||
// Lire le template de base
|
// Lire le template de base
|
||||||
const basePath = path.join(__dirname, '..', 'templates', 'base-template.html');
|
const basePath = path.join(__dirname, "..", "templates", "base-template.html");
|
||||||
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
const baseTemplate = await fs.readFile(basePath, "utf8");
|
||||||
|
|
||||||
// Lire le template de contenu
|
// Lire le template de contenu
|
||||||
const contentPath = path.join(
|
const contentPath = path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
'..',
|
"..",
|
||||||
'templates',
|
"templates",
|
||||||
`${templateName}.html`,
|
`${templateName}.html`,
|
||||||
);
|
);
|
||||||
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
const contentTemplate = await fs.readFile(contentPath, "utf8");
|
||||||
|
|
||||||
// Compiler les templates
|
// Compiler les templates
|
||||||
const compileContent = handlebars.compile(contentTemplate);
|
const compileContent = handlebars.compile(contentTemplate);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Helpers pour la manipulation de données Firestore
|
* Helpers pour la manipulation de données Firestore
|
||||||
*/
|
*/
|
||||||
const admin = require('firebase-admin');
|
const admin = require("firebase-admin");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convertit les Timestamps Firestore en ISO strings pour JSON
|
* Convertit les Timestamps Firestore en ISO strings pour JSON
|
||||||
@@ -19,7 +19,7 @@ function serializeTimestamps(data) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = { ...data };
|
const result = {...data};
|
||||||
|
|
||||||
for (const key in result) {
|
for (const key in result) {
|
||||||
const value = result[key];
|
const value = result[key];
|
||||||
@@ -29,31 +29,31 @@ function serializeTimestamps(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gérer les Timestamps Firestore
|
// Gérer les Timestamps Firestore
|
||||||
if (value.toDate && typeof value.toDate === 'function') {
|
if (value.toDate && typeof value.toDate === "function") {
|
||||||
result[key] = value.toDate().toISOString();
|
result[key] = value.toDate().toISOString();
|
||||||
}
|
}
|
||||||
// Gérer les DocumentReference
|
// Gérer les DocumentReference
|
||||||
else if (value.path && value.id && typeof value.path === 'string') {
|
else if (value.path && value.id && typeof value.path === "string") {
|
||||||
result[key] = value.path;
|
result[key] = value.path;
|
||||||
}
|
}
|
||||||
// Gérer les GeoPoint
|
// Gérer les GeoPoint
|
||||||
else if (value.latitude !== undefined && value.longitude !== undefined) {
|
else if (value.latitude !== undefined && value.longitude !== undefined) {
|
||||||
result[key] = {
|
result[key] = {
|
||||||
latitude: value.latitude,
|
latitude: value.latitude,
|
||||||
longitude: value.longitude
|
longitude: value.longitude,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Gérer les tableaux
|
// Gérer les tableaux
|
||||||
else if (Array.isArray(value)) {
|
else if (Array.isArray(value)) {
|
||||||
result[key] = value.map(item => {
|
result[key] = value.map((item) => {
|
||||||
if (!item || typeof item !== 'object') return item;
|
if (!item || typeof item !== "object") return item;
|
||||||
|
|
||||||
// DocumentReference dans un tableau
|
// DocumentReference dans un tableau
|
||||||
if (item.path && item.id) {
|
if (item.path && item.id) {
|
||||||
return item.path;
|
return item.path;
|
||||||
}
|
}
|
||||||
// Timestamp dans un tableau
|
// Timestamp dans un tableau
|
||||||
if (item.toDate && typeof item.toDate === 'function') {
|
if (item.toDate && typeof item.toDate === "function") {
|
||||||
return item.toDate().toISOString();
|
return item.toDate().toISOString();
|
||||||
}
|
}
|
||||||
// Objet normal
|
// Objet normal
|
||||||
@@ -61,7 +61,7 @@ function serializeTimestamps(data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Gérer les objets imbriqués (mais pas les objets Firestore)
|
// Gérer les objets imbriqués (mais pas les objets Firestore)
|
||||||
else if (typeof value === 'object' && !value._firestore && !value._path) {
|
else if (typeof value === "object" && !value._firestore && !value._path) {
|
||||||
result[key] = serializeTimestamps(value);
|
result[key] = serializeTimestamps(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,10 +75,10 @@ function serializeTimestamps(data) {
|
|||||||
function deserializeTimestamps(data, timestampFields = []) {
|
function deserializeTimestamps(data, timestampFields = []) {
|
||||||
if (!data) return data;
|
if (!data) return data;
|
||||||
|
|
||||||
const result = { ...data };
|
const result = {...data};
|
||||||
|
|
||||||
for (const field of timestampFields) {
|
for (const field of timestampFields) {
|
||||||
if (result[field] && typeof result[field] === 'string') {
|
if (result[field] && typeof result[field] === "string") {
|
||||||
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
|
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,15 +92,15 @@ function deserializeTimestamps(data, timestampFields = []) {
|
|||||||
function serializeReferences(data) {
|
function serializeReferences(data) {
|
||||||
if (!data) return data;
|
if (!data) return data;
|
||||||
|
|
||||||
const result = { ...data };
|
const result = {...data};
|
||||||
|
|
||||||
for (const key in result) {
|
for (const key in result) {
|
||||||
if (result[key] && result[key].path && typeof result[key].path === 'string') {
|
if (result[key] && result[key].path && typeof result[key].path === "string") {
|
||||||
// C'est une DocumentReference
|
// C'est une DocumentReference
|
||||||
result[key] = result[key].id;
|
result[key] = result[key].id;
|
||||||
} else if (Array.isArray(result[key])) {
|
} else if (Array.isArray(result[key])) {
|
||||||
result[key] = result[key].map(item => {
|
result[key] = result[key].map((item) => {
|
||||||
if (item && item.path && typeof item.path === 'string') {
|
if (item && item.path && typeof item.path === "string") {
|
||||||
return item.id;
|
return item.id;
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
@@ -117,7 +117,7 @@ function serializeReferences(data) {
|
|||||||
function maskSensitiveFields(data, canViewSensitive) {
|
function maskSensitiveFields(data, canViewSensitive) {
|
||||||
if (canViewSensitive) return data;
|
if (canViewSensitive) return data;
|
||||||
|
|
||||||
const masked = { ...data };
|
const masked = {...data};
|
||||||
|
|
||||||
// Masquer les prix si pas de permission manage_equipment
|
// Masquer les prix si pas de permission manage_equipment
|
||||||
delete masked.purchasePrice;
|
delete masked.purchasePrice;
|
||||||
@@ -143,34 +143,34 @@ function paginate(query, limit = 50, startAfter = null) {
|
|||||||
* Filtre les événements annulés
|
* Filtre les événements annulés
|
||||||
*/
|
*/
|
||||||
function filterCancelledEvents(events) {
|
function filterCancelledEvents(events) {
|
||||||
return events.filter(event => event.status !== 'CANCELLED');
|
return events.filter((event) => event.status !== "CANCELLED");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
|
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
|
||||||
* @param {Object} data - Données de l'événement
|
* @param {Object} data - Données de l'événement
|
||||||
* @returns {Object} - Données avec DocumentReference
|
* @return {Object} - Données avec DocumentReference
|
||||||
*/
|
*/
|
||||||
function convertIdsToReferences(data) {
|
function convertIdsToReferences(data) {
|
||||||
if (!data) return data;
|
if (!data) return data;
|
||||||
|
|
||||||
const result = { ...data };
|
const result = {...data};
|
||||||
|
|
||||||
// Convertir EventType (ID → DocumentReference)
|
// Convertir EventType (ID → DocumentReference)
|
||||||
if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) {
|
if (result.EventType && typeof result.EventType === "string" && !result.EventType.includes("/")) {
|
||||||
result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType);
|
result.EventType = admin.firestore().collection("eventTypes").doc(result.EventType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convertir customer (ID → DocumentReference)
|
// Convertir customer (ID → DocumentReference)
|
||||||
if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) {
|
if (result.customer && typeof result.customer === "string" && !result.customer.includes("/")) {
|
||||||
result.customer = admin.firestore().collection('customers').doc(result.customer);
|
result.customer = admin.firestore().collection("customers").doc(result.customer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convertir workforce (IDs → DocumentReference)
|
// Convertir workforce (IDs → DocumentReference)
|
||||||
if (Array.isArray(result.workforce)) {
|
if (Array.isArray(result.workforce)) {
|
||||||
result.workforce = result.workforce.map(item => {
|
result.workforce = result.workforce.map((item) => {
|
||||||
if (typeof item === 'string' && !item.includes('/')) {
|
if (typeof item === "string" && !item.includes("/")) {
|
||||||
return admin.firestore().collection('users').doc(item);
|
return admin.firestore().collection("users").doc(item);
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// Configuration de la version de l'application
|
/// Configuration de la version de l'application
|
||||||
class AppVersion {
|
class AppVersion {
|
||||||
static const String version = '1.2.1';
|
static const String version = '1.2.3';
|
||||||
|
|
||||||
/// Retourne la version complète de l'application
|
/// Retourne la version complète de l'application
|
||||||
static String get fullVersion => 'v$version';
|
static String get fullVersion => 'v$version';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
class Env {
|
class Env {
|
||||||
static const bool isDevelopment = true;
|
static const bool isDevelopment = false;
|
||||||
|
|
||||||
// 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 = '';
|
||||||
static const String devAdminPassword = 'Pastis51!';
|
static const String devAdminPassword = '';
|
||||||
|
|
||||||
// URLs et endpoints
|
// URLs et endpoints
|
||||||
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||||
@@ -14,4 +14,3 @@ class Env {
|
|||||||
// Autres configurations
|
// Autres configurations
|
||||||
static const int apiTimeout = 30000; // 30 secondes
|
static const int apiTimeout = 30000; // 30 secondes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-20
@@ -1,5 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
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/equipment_provider.dart';
|
||||||
@@ -7,7 +9,6 @@ import 'package:em2rp/providers/container_provider.dart';
|
|||||||
import 'package:em2rp/providers/maintenance_provider.dart';
|
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||||
import 'package:em2rp/providers/alert_provider.dart';
|
import 'package:em2rp/providers/alert_provider.dart';
|
||||||
import 'package:em2rp/utils/auth_guard_widget.dart';
|
import 'package:em2rp/utils/auth_guard_widget.dart';
|
||||||
import 'package:em2rp/utils/performance_monitor.dart';
|
|
||||||
import 'package:em2rp/views/alerts_page.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';
|
||||||
@@ -104,28 +105,16 @@ class _MyAppState extends State<MyApp> {
|
|||||||
|
|
||||||
await initializer.initialize();
|
await initializer.initialize();
|
||||||
|
|
||||||
// Attendre la première valeur d'authentification avant toute décision
|
// Lancer la connexion automatique en dev sans bloquer le démarrage initial
|
||||||
// de navigation, afin d'éviter un flash de la page login.
|
if (Env.isDevelopment && FirebaseAuth.instance.currentUser == null) {
|
||||||
await FirebaseAuth.instance.authStateChanges().first;
|
|
||||||
|
|
||||||
if (FirebaseAuth.instance.currentUser != null) {
|
|
||||||
unawaited(
|
unawaited(
|
||||||
localAuthProvider.loadUserData().catchError((e) {
|
localAuthProvider.signInWithEmailAndPassword(
|
||||||
print('User data bootstrap failed: $e');
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// En développement, on garde la connexion automatique existante.
|
|
||||||
if (Env.isDevelopment) {
|
|
||||||
await localAuthProvider.signInWithEmailAndPassword(
|
|
||||||
Env.devAdminEmail,
|
Env.devAdminEmail,
|
||||||
Env.devAdminPassword,
|
Env.devAdminPassword,
|
||||||
);
|
).then((_) {
|
||||||
unawaited(
|
return localAuthProvider.loadUserData();
|
||||||
localAuthProvider.loadUserData().catchError((e) {
|
}).catchError((e) {
|
||||||
print('Dev user bootstrap failed: $e');
|
if (kDebugMode) debugPrint('Dev auto-login failed: $e');
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class AlertProvider extends ChangeNotifier {
|
|||||||
return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
|
return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading alerts: $e');
|
if (kDebugMode) debugPrint('Error loading alerts: $e');
|
||||||
_alerts = [];
|
_alerts = [];
|
||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -67,7 +67,7 @@ class AlertProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error marking alert as read: $e');
|
if (kDebugMode) debugPrint('Error marking alert as read: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ class AlertProvider extends ChangeNotifier {
|
|||||||
_alerts.removeWhere((a) => a.id == alertId);
|
_alerts.removeWhere((a) => a.id == alertId);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting alert: $e');
|
if (kDebugMode) debugPrint('Error deleting alert: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ class AlertProvider extends ChangeNotifier {
|
|||||||
await markAsRead(alertId);
|
await markAsRead(alertId);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error marking all alerts as read: $e');
|
if (kDebugMode) debugPrint('Error marking all alerts as read: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ class AlertProvider extends ChangeNotifier {
|
|||||||
await deleteAlert(alertId);
|
await deleteAlert(alertId);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deleting read alerts: $e');
|
if (kDebugMode) debugPrint('Error deleting read alerts: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:em2rp/models/alert_model.dart';
|
|
||||||
import 'package:em2rp/services/data_service.dart';
|
|
||||||
import 'package:em2rp/services/api_service.dart';
|
|
||||||
|
|
||||||
class AlertProvider extends ChangeNotifier {
|
|
||||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
|
||||||
|
|
||||||
List<AlertModel> _alerts = [];
|
|
||||||
bool _isLoading = false;
|
|
||||||
|
|
||||||
List<AlertModel> get alerts => _alerts;
|
|
||||||
bool get isLoading => _isLoading;
|
|
||||||
|
|
||||||
/// Nombre d'alertes non lues
|
|
||||||
int get unreadCount => _alerts.where((a) => !a.isRead).length;
|
|
||||||
|
|
||||||
/// Charger toutes les alertes via l'API
|
|
||||||
Future<void> loadAlerts() async {
|
|
||||||
_isLoading = true;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
final alertsData = await _dataService.getAlerts();
|
|
||||||
|
|
||||||
_alerts = alertsData.map((data) {
|
|
||||||
return AlertModel.fromMap(data, data['id'] as String);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error loading alerts: $e');
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recharger les alertes
|
|
||||||
Future<void> refresh() async {
|
|
||||||
await loadAlerts();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Obtenir les alertes non lues
|
|
||||||
List<AlertModel> get unreadAlerts {
|
|
||||||
return _alerts.where((a) => !a.isRead).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Obtenir les alertes par type
|
|
||||||
List<AlertModel> getByType(AlertType type) {
|
|
||||||
return _alerts.where((a) => a.type == type).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Obtenir les alertes critiques (stock bas, équipement perdu)
|
|
||||||
List<AlertModel> get criticalAlerts {
|
|
||||||
return _alerts.where((a) =>
|
|
||||||
a.type == AlertType.lowStock || a.type == AlertType.lost
|
|
||||||
).toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ class ContainerProvider with ChangeNotifier {
|
|||||||
// Charger toutes les pages en boucle
|
// Charger toutes les pages en boucle
|
||||||
while (hasMore) {
|
while (hasMore) {
|
||||||
pageCount++;
|
pageCount++;
|
||||||
print('[ContainerProvider] Loading page $pageCount...');
|
DebugLog.info('[ContainerProvider] Loading page $pageCount...');
|
||||||
|
|
||||||
final result = await _dataService.getContainersPaginated(
|
final result = await _dataService.getContainersPaginated(
|
||||||
limit: 100, // Charger 100 par page pour aller plus vite
|
limit: 100, // Charger 100 par page pour aller plus vite
|
||||||
@@ -86,14 +86,14 @@ class ContainerProvider with ChangeNotifier {
|
|||||||
hasMore = result['hasMore'] as bool? ?? false;
|
hasMore = result['hasMore'] as bool? ?? false;
|
||||||
lastVisible = result['lastVisible'] as String?;
|
lastVisible = result['lastVisible'] as String?;
|
||||||
|
|
||||||
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
|
DebugLog.info('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
|
||||||
}
|
}
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading containers: $e');
|
DebugLog.error('[ContainerProvider] Error loading containers', e);
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -292,7 +292,7 @@ class ContainerProvider with ChangeNotifier {
|
|||||||
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
|
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
|
||||||
if (containerIds.isEmpty) return [];
|
if (containerIds.isEmpty) return [];
|
||||||
|
|
||||||
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
|
DebugLog.info('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Vérifier d'abord le cache local
|
// Vérifier d'abord le cache local
|
||||||
@@ -320,7 +320,7 @@ class ContainerProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
|
DebugLog.info('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
|
||||||
|
|
||||||
// Si tous sont en cache, retourner directement
|
// Si tous sont en cache, retourner directement
|
||||||
if (missingIds.isEmpty) {
|
if (missingIds.isEmpty) {
|
||||||
@@ -341,12 +341,12 @@ class ContainerProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
|
DebugLog.info('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
|
||||||
|
|
||||||
// Retourner tous les conteneurs (cache + chargés)
|
// Retourner tous les conteneurs (cache + chargés)
|
||||||
return [...cachedContainers, ...loadedContainers];
|
return [...cachedContainers, ...loadedContainers];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[ContainerProvider] Error loading containers by IDs: $e');
|
DebugLog.error('[ContainerProvider] Error loading containers by IDs', e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
Future<void> loadEquipments() async {
|
Future<void> loadEquipments() async {
|
||||||
print('[EquipmentProvider] Starting to load ALL equipments...');
|
print('[EquipmentProvider] Starting to load ALL equipments...');
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
scheduleMicrotask(notifyListeners);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_equipment.clear();
|
_equipment.clear();
|
||||||
@@ -272,7 +272,7 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
_lastVisible = null;
|
_lastVisible = null;
|
||||||
_hasMore = true;
|
_hasMore = true;
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
scheduleMicrotask(notifyListeners);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadNextPage();
|
await loadNextPage();
|
||||||
@@ -296,7 +296,7 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
_isLoadingMore = true;
|
_isLoadingMore = true;
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
scheduleMicrotask(notifyListeners);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await _dataService.getEquipmentsPaginated(
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:em2rp/models/maintenance_model.dart';
|
|
||||||
import 'package:em2rp/services/data_service.dart';
|
|
||||||
import 'package:em2rp/services/api_service.dart';
|
|
||||||
|
|
||||||
class MaintenanceProvider extends ChangeNotifier {
|
|
||||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
|
||||||
|
|
||||||
List<MaintenanceModel> _maintenances = [];
|
|
||||||
bool _isLoading = false;
|
|
||||||
|
|
||||||
List<MaintenanceModel> get maintenances => _maintenances;
|
|
||||||
bool get isLoading => _isLoading;
|
|
||||||
|
|
||||||
/// Charger toutes les maintenances via l'API
|
|
||||||
Future<void> loadMaintenances({String? equipmentId}) async {
|
|
||||||
_isLoading = true;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
final maintenancesData = await _dataService.getMaintenances(
|
|
||||||
equipmentId: equipmentId,
|
|
||||||
);
|
|
||||||
|
|
||||||
_maintenances = maintenancesData.map((data) {
|
|
||||||
return MaintenanceModel.fromMap(data, data['id'] as String);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error loading maintenances: $e');
|
|
||||||
_isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recharger les maintenances
|
|
||||||
Future<void> refresh({String? equipmentId}) async {
|
|
||||||
await loadMaintenances(equipmentId: equipmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Obtenir les maintenances pour un équipement spécifique
|
|
||||||
List<MaintenanceModel> getForEquipment(String equipmentId) {
|
|
||||||
return _maintenances.where((m) =>
|
|
||||||
m.equipmentIds.contains(equipmentId)
|
|
||||||
).toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
/// Repository pour gérer toutes les opérations sur les alertes.
|
||||||
|
class AlertRepository {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
AlertRepository(this._apiService);
|
||||||
|
|
||||||
|
/// Récupère toutes les alertes
|
||||||
|
Future<List<Map<String, dynamic>>> getAlerts() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getAlerts', {});
|
||||||
|
final alerts = result['alerts'] as List<dynamic>?;
|
||||||
|
if (alerts == null) return [];
|
||||||
|
return alerts.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des alertes: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marque une alerte comme lue
|
||||||
|
Future<void> markAlertAsRead(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('markAlertAsRead', {'alertId': alertId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une alerte
|
||||||
|
Future<void> deleteAlert(String alertId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteAlert', {'alertId': alertId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'alerte: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Repository pour gérer toutes les opérations sur les conteneurs.
|
||||||
|
class ContainerRepository {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
ContainerRepository(this._apiService);
|
||||||
|
|
||||||
|
/// Récupère tous les conteneurs
|
||||||
|
Future<List<Map<String, dynamic>>> getContainers() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getContainers', {});
|
||||||
|
final containers = result['containers'] as List<dynamic>?;
|
||||||
|
if (containers == null) return [];
|
||||||
|
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des conteneurs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère plusieurs containers par leurs IDs
|
||||||
|
Future<List<Map<String, dynamic>>> getContainersByIds(
|
||||||
|
List<String> containerIds) async {
|
||||||
|
try {
|
||||||
|
if (containerIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print(
|
||||||
|
'[ContainerRepository] Getting containers by IDs: ${containerIds.length} items');
|
||||||
|
final result = await _apiService.call('getContainersByIds', {
|
||||||
|
'containerIds': containerIds,
|
||||||
|
});
|
||||||
|
final containers = result['containers'] as List<dynamic>?;
|
||||||
|
if (containers == null) {
|
||||||
|
print('[ContainerRepository] No containers in result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
print('[ContainerRepository] Found ${containers.length} containers by IDs');
|
||||||
|
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[ContainerRepository] Error getting containers by IDs: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des containers: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les containers avec pagination et filtrage
|
||||||
|
Future<Map<String, dynamic>> getContainersPaginated({
|
||||||
|
int limit = 20,
|
||||||
|
String? startAfter,
|
||||||
|
String? type,
|
||||||
|
String? status,
|
||||||
|
String? searchQuery,
|
||||||
|
String? category,
|
||||||
|
String sortBy = 'id',
|
||||||
|
String sortOrder = 'asc',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{
|
||||||
|
'limit': limit,
|
||||||
|
'sortBy': sortBy,
|
||||||
|
'sortOrder': sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startAfter != null) params['startAfter'] = startAfter;
|
||||||
|
if (type != null) params['type'] = type;
|
||||||
|
if (status != null) params['status'] = status;
|
||||||
|
if (category != null) params['category'] = category;
|
||||||
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
|
params['searchQuery'] = searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||||
|
'getContainersPaginated',
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'containers': (result['containers'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as Map<String, dynamic>)
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
'hasMore': result['hasMore'] as bool? ?? false,
|
||||||
|
'lastVisible': result['lastVisible'] as String?,
|
||||||
|
'total': result['total'] as int? ?? 0,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[ContainerRepository] Error in getContainersPaginated', e);
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération paginée des containers: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les containers contenant un équipement
|
||||||
|
Future<List<Map<String, dynamic>>> getContainersByEquipment(
|
||||||
|
String equipmentId) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getContainersByEquipment', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
});
|
||||||
|
final containers = result['containers'] as List<dynamic>?;
|
||||||
|
if (containers == null) return [];
|
||||||
|
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des containers: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie la disponibilité d'un container
|
||||||
|
Future<Map<String, dynamic>> checkContainerAvailability({
|
||||||
|
required String containerId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('checkContainerAvailability', {
|
||||||
|
'containerId': containerId,
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la vérification de disponibilité du container: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Repository pour gérer toutes les opérations sur les équipements.
|
||||||
|
class EquipmentRepository {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
EquipmentRepository(this._apiService);
|
||||||
|
|
||||||
|
/// Récupère tous les équipements (avec masquage des prix selon permissions)
|
||||||
|
Future<List<Map<String, dynamic>>> getEquipments() async {
|
||||||
|
try {
|
||||||
|
print('[EquipmentRepository] Calling getEquipments API...');
|
||||||
|
final result = await _apiService.call('getEquipments', {});
|
||||||
|
print('[EquipmentRepository] API call successful, parsing result...');
|
||||||
|
final equipments = result['equipments'] as List<dynamic>?;
|
||||||
|
if (equipments == null) {
|
||||||
|
print('[EquipmentRepository] No equipments in result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
print('[EquipmentRepository] Found ${equipments.length} equipments');
|
||||||
|
return equipments.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[EquipmentRepository] Error getting equipments: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des équipements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère plusieurs équipements par leurs IDs
|
||||||
|
Future<List<Map<String, dynamic>>> getEquipmentsByIds(
|
||||||
|
List<String> equipmentIds) async {
|
||||||
|
try {
|
||||||
|
if (equipmentIds.isEmpty) return [];
|
||||||
|
|
||||||
|
print(
|
||||||
|
'[EquipmentRepository] Getting equipments by IDs: ${equipmentIds.length} items');
|
||||||
|
final result = await _apiService.call('getEquipmentsByIds', {
|
||||||
|
'equipmentIds': equipmentIds,
|
||||||
|
});
|
||||||
|
final equipments = result['equipments'] as List<dynamic>?;
|
||||||
|
if (equipments == null) {
|
||||||
|
print('[EquipmentRepository] No equipments in result');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
print('[EquipmentRepository] Found ${equipments.length} equipments by IDs');
|
||||||
|
return equipments.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('[EquipmentRepository] Error getting equipments by IDs: $e');
|
||||||
|
throw Exception('Erreur lors de la récupération des équipements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les équipements avec pagination et filtrage
|
||||||
|
Future<Map<String, dynamic>> getEquipmentsPaginated({
|
||||||
|
int limit = 20,
|
||||||
|
String? startAfter,
|
||||||
|
String? category,
|
||||||
|
String? status,
|
||||||
|
String? searchQuery,
|
||||||
|
String sortBy = 'id',
|
||||||
|
String sortOrder = 'asc',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final params = <String, dynamic>{
|
||||||
|
'limit': limit,
|
||||||
|
'sortBy': sortBy,
|
||||||
|
'sortOrder': sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startAfter != null) params['startAfter'] = startAfter;
|
||||||
|
if (category != null) params['category'] = category;
|
||||||
|
if (status != null) params['status'] = status;
|
||||||
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
|
params['searchQuery'] = searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||||
|
'getEquipmentsPaginated',
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'equipments': (result['equipments'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as Map<String, dynamic>)
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
'hasMore': result['hasMore'] as bool? ?? false,
|
||||||
|
'lastVisible': result['lastVisible'] as String?,
|
||||||
|
'total': result['total'] as int? ?? 0,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentRepository] Error in getEquipmentsPaginated', e);
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération paginée des équipements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un équipement
|
||||||
|
Future<void> createEquipment(
|
||||||
|
String equipmentId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final equipmentData = Map<String, dynamic>.from(data);
|
||||||
|
equipmentData['id'] = equipmentId;
|
||||||
|
|
||||||
|
await _apiService.call('createEquipment', equipmentData);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un équipement
|
||||||
|
Future<void> updateEquipment(
|
||||||
|
String equipmentId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('updateEquipment', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un équipement
|
||||||
|
Future<void> deleteEquipment(String equipmentId,
|
||||||
|
{bool forceDelete = false}) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEquipment', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'forceDelete': forceDelete,
|
||||||
|
});
|
||||||
|
} on ApiException {
|
||||||
|
rethrow;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour uniquement le statut d'un équipement
|
||||||
|
Future<void> updateEquipmentStatusOnly({
|
||||||
|
required String equipmentId,
|
||||||
|
String? status,
|
||||||
|
int? availableQuantity,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{'equipmentId': equipmentId};
|
||||||
|
|
||||||
|
if (status != null) data['status'] = status;
|
||||||
|
if (availableQuantity != null) {
|
||||||
|
data['availableQuantity'] = availableQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _apiService.call('updateEquipmentStatusOnly', data);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la mise à jour du statut de l\'équipement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche rapide (autocomplétion)
|
||||||
|
Future<List<Map<String, dynamic>>> quickSearch(
|
||||||
|
String query, {
|
||||||
|
int limit = 10,
|
||||||
|
bool includeEquipments = true,
|
||||||
|
bool includeContainers = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
|
||||||
|
query,
|
||||||
|
limit: limit,
|
||||||
|
includeEquipments: includeEquipments,
|
||||||
|
includeContainers: includeContainers,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentRepository] Error in quickSearch', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche des équipements pour l'assistant IA avec fallback paginé.
|
||||||
|
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({
|
||||||
|
required String query,
|
||||||
|
int limit = 12,
|
||||||
|
}) async {
|
||||||
|
final normalizedQuery = query.trim();
|
||||||
|
if (normalizedQuery.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final quickResults = await quickSearch(
|
||||||
|
normalizedQuery,
|
||||||
|
limit: limit,
|
||||||
|
includeEquipments: true,
|
||||||
|
includeContainers: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipmentResults = quickResults
|
||||||
|
.where((item) =>
|
||||||
|
(item['type']?.toString().toLowerCase() ?? '') == 'equipment')
|
||||||
|
.map(_normalizeAssistantEquipment)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (equipmentResults.isNotEmpty) {
|
||||||
|
return equipmentResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
final paginated = await getEquipmentsPaginated(
|
||||||
|
limit: limit,
|
||||||
|
searchQuery: normalizedQuery,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipments =
|
||||||
|
paginated['equipments'] as List<Map<String, dynamic>>? ?? [];
|
||||||
|
return equipments.map(_normalizeAssistantEquipment).toList();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[EquipmentRepository] Error in searchEquipmentsForAssistant', e);
|
||||||
|
throw Exception('Erreur lors de la recherche de matériel: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA.
|
||||||
|
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
|
||||||
|
required String equipmentId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await checkEquipmentAvailability(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final available = result['available'] as bool? ?? true;
|
||||||
|
final conflicts = (result['conflicts'] as List<dynamic>? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((conflict) {
|
||||||
|
final eventData =
|
||||||
|
conflict['eventData'] as Map<String, dynamic>? ?? const {};
|
||||||
|
final eventName =
|
||||||
|
(eventData['Name'] ?? conflict['eventName'] ?? '').toString();
|
||||||
|
return {
|
||||||
|
'eventId': conflict['eventId']?.toString() ?? '',
|
||||||
|
'eventName': eventName,
|
||||||
|
'overlapDays': conflict['overlapDays'] as int? ?? 0,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'available': available,
|
||||||
|
'conflictCount': conflicts.length,
|
||||||
|
'conflicts': conflicts,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error(
|
||||||
|
'[EquipmentRepository] Error in checkEquipmentAvailabilityForAssistant', e);
|
||||||
|
throw Exception('Erreur lors de la vérification de disponibilité: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie la disponibilité d'un équipement
|
||||||
|
Future<Map<String, dynamic>> checkEquipmentAvailability({
|
||||||
|
required String equipmentId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('checkEquipmentAvailability', {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la vérification de disponibilité: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période
|
||||||
|
/// Optimisé : une seule requête au lieu d'une par équipement
|
||||||
|
Future<Map<String, dynamic>> getConflictingEquipmentIds({
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
int installationTime = 0,
|
||||||
|
int disassemblyTime = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getConflictingEquipmentIds', {
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||||
|
'installationTime': installationTime,
|
||||||
|
'disassemblyTime': disassemblyTime,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération des équipements en conflit: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _normalizeAssistantEquipment(Map<String, dynamic> item) {
|
||||||
|
return {
|
||||||
|
'id': (item['id'] ?? '').toString(),
|
||||||
|
'name': (item['name'] ?? item['id'] ?? '').toString(),
|
||||||
|
'category': (item['category'] ?? '').toString(),
|
||||||
|
'status': (item['status'] ?? '').toString(),
|
||||||
|
'brand': item['brand']?.toString(),
|
||||||
|
'model': item['model']?.toString(),
|
||||||
|
'availableQuantity': item['availableQuantity'],
|
||||||
|
'totalQuantity': item['totalQuantity'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère toutes les maintenances
|
||||||
|
Future<List<Map<String, dynamic>>> getMaintenances(
|
||||||
|
{String? equipmentId}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{};
|
||||||
|
if (equipmentId != null) data['equipmentId'] = equipmentId;
|
||||||
|
|
||||||
|
final result = await _apiService.call('getMaintenances', data);
|
||||||
|
final maintenances = result['maintenances'] as List<dynamic>?;
|
||||||
|
if (maintenances == null) return [];
|
||||||
|
return maintenances.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des maintenances: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une maintenance
|
||||||
|
Future<void> deleteMaintenance(String maintenanceId) async {
|
||||||
|
try {
|
||||||
|
await _apiService
|
||||||
|
.call('deleteMaintenance', {'maintenanceId': maintenanceId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de la maintenance: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
/// Repository pour gérer toutes les opérations sur les événements.
|
||||||
|
class EventRepository {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
EventRepository(this._apiService);
|
||||||
|
|
||||||
|
/// Met à jour les équipements d'un événement
|
||||||
|
Future<void> updateEventEquipment({
|
||||||
|
required String eventId,
|
||||||
|
List<Map<String, dynamic>>? assignedEquipment,
|
||||||
|
String? preparationStatus,
|
||||||
|
String? loadingStatus,
|
||||||
|
String? unloadingStatus,
|
||||||
|
String? returnStatus,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{'eventId': eventId};
|
||||||
|
|
||||||
|
if (assignedEquipment != null) {
|
||||||
|
data['assignedEquipment'] = assignedEquipment;
|
||||||
|
}
|
||||||
|
if (preparationStatus != null) {
|
||||||
|
data['preparationStatus'] = preparationStatus;
|
||||||
|
}
|
||||||
|
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
|
||||||
|
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
|
||||||
|
if (returnStatus != null) data['returnStatus'] = returnStatus;
|
||||||
|
|
||||||
|
await _apiService.call('updateEventEquipment', data);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la mise à jour des équipements de l\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un événement
|
||||||
|
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final requestData = {'eventId': eventId, ...data};
|
||||||
|
await _apiService.call('updateEvent', requestData);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un événement
|
||||||
|
Future<void> deleteEvent(String eventId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEvent', {'eventId': eventId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les événements utilisant un type d'événement donné
|
||||||
|
Future<List<Map<String, dynamic>>> getEventsByEventType(
|
||||||
|
String eventTypeId) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService
|
||||||
|
.call('getEventsByEventType', {'eventTypeId': eventTypeId});
|
||||||
|
final events = result['events'] as List<dynamic>?;
|
||||||
|
if (events == null) return [];
|
||||||
|
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des événements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les événements (filtrés selon permissions)
|
||||||
|
/// Retourne { events: List<Map>, users: Map<String, Map> }
|
||||||
|
Future<Map<String, dynamic>> getEvents({String? userId}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{};
|
||||||
|
if (userId != null) data['userId'] = userId;
|
||||||
|
|
||||||
|
final result = await _apiService.call('getEvents', data);
|
||||||
|
|
||||||
|
// Extraire events et users
|
||||||
|
final events = result['events'] as List<dynamic>? ?? [];
|
||||||
|
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||||
|
'users': users,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des événements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les événements d'un mois spécifique (lazy loading optimisé)
|
||||||
|
Future<Map<String, dynamic>> getEventsByMonth({
|
||||||
|
required String userId,
|
||||||
|
required int year,
|
||||||
|
required int month,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
print('[EventRepository] Calling getEventsByMonth for $year-$month');
|
||||||
|
final result = await _apiService.call('getEventsByMonth', {
|
||||||
|
'userId': userId,
|
||||||
|
'year': year,
|
||||||
|
'month': month,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extraire events et users
|
||||||
|
final events = result['events'] as List<dynamic>? ?? [];
|
||||||
|
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
print(
|
||||||
|
'[EventRepository] Events loaded for $year-$month: ${events.length} events');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||||
|
'users': users,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventRepository] Error getting events by month: $e');
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération des événements du mois: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche des événements accessibles à l'utilisateur.
|
||||||
|
Future<List<Map<String, dynamic>>> searchEvents({
|
||||||
|
required String userId,
|
||||||
|
required String query,
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('searchEvents', {
|
||||||
|
'userId': userId,
|
||||||
|
'query': query,
|
||||||
|
'limit': limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
final events = result['events'] as List<dynamic>?;
|
||||||
|
if (events == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la recherche d\'événements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||||
|
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
|
||||||
|
try {
|
||||||
|
print('[EventRepository] Getting event with details: $eventId');
|
||||||
|
final result = await _apiService.call('getEventWithDetails', {
|
||||||
|
'eventId': eventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final event = result['event'] as Map<String, dynamic>?;
|
||||||
|
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
|
||||||
|
final containers = result['containers'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
if (event == null) {
|
||||||
|
throw Exception('Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
print(
|
||||||
|
'[EventRepository] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'event': event,
|
||||||
|
'equipments': equipments,
|
||||||
|
'containers': containers,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
print('[EventRepository] Error getting event with details: $e');
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération de l\'événement avec détails: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
/// Repository pour gérer toutes les opérations sur les options et types d'événements.
|
||||||
|
class OptionRepository {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
OptionRepository(this._apiService);
|
||||||
|
|
||||||
|
/// Récupère toutes les options
|
||||||
|
Future<List<Map<String, dynamic>>> getOptions() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getOptions', {});
|
||||||
|
final options = result['options'] as List<dynamic>?;
|
||||||
|
if (options == null) return [];
|
||||||
|
return options.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des options: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les types d'événements
|
||||||
|
Future<List<Map<String, dynamic>>> getEventTypes() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getEventTypes', {});
|
||||||
|
final eventTypes = result['eventTypes'] as List<dynamic>?;
|
||||||
|
if (eventTypes == null) return [];
|
||||||
|
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération des types d\'événements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un type d'événement
|
||||||
|
Future<String> createEventType({
|
||||||
|
required String name,
|
||||||
|
required double defaultPrice,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('createEventType', {
|
||||||
|
'name': name,
|
||||||
|
'defaultPrice': defaultPrice,
|
||||||
|
});
|
||||||
|
return result['id'] as String;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création du type d\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un type d'événement
|
||||||
|
Future<void> updateEventType({
|
||||||
|
required String eventTypeId,
|
||||||
|
String? name,
|
||||||
|
double? defaultPrice,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final data = <String, dynamic>{'eventTypeId': eventTypeId};
|
||||||
|
if (name != null) data['name'] = name;
|
||||||
|
if (defaultPrice != null) data['defaultPrice'] = defaultPrice;
|
||||||
|
|
||||||
|
await _apiService.call('updateEventType', data);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour du type d\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un type d'événement
|
||||||
|
Future<void> deleteEventType(String eventTypeId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression du type d\'événement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une option
|
||||||
|
Future<String> createOption(String code, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final requestData = {
|
||||||
|
'id': code,
|
||||||
|
'code': code,
|
||||||
|
...data
|
||||||
|
};
|
||||||
|
final result = await _apiService.call('createOption', requestData);
|
||||||
|
return result['id'] as String? ?? code;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création de l\'option: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour une option
|
||||||
|
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final requestData = {'optionId': optionId, 'data': data};
|
||||||
|
await _apiService.call('updateOption', requestData);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une option
|
||||||
|
Future<void> deleteOption(String optionId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteOption', {'optionId': optionId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'option: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
|
/// Repository pour gérer toutes les opérations sur les utilisateurs et les rôles.
|
||||||
|
class UserRepository {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
UserRepository(this._apiService);
|
||||||
|
|
||||||
|
/// Récupère l'utilisateur actuellement authentifié avec son rôle
|
||||||
|
Future<Map<String, dynamic>> getCurrentUser() async {
|
||||||
|
try {
|
||||||
|
print('[UserRepository] Calling getCurrentUser API...');
|
||||||
|
final result = await _apiService.call('getCurrentUser', {});
|
||||||
|
print('[UserRepository] Current user loaded successfully');
|
||||||
|
return result['user'] as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
print('[UserRepository] Error getting current user: $e');
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération de l\'utilisateur actuel: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les utilisateurs (selon permissions)
|
||||||
|
Future<List<Map<String, dynamic>>> getUsers() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getUsers', {});
|
||||||
|
final users = result['users'] as List<dynamic>?;
|
||||||
|
if (users == null) return [];
|
||||||
|
return users.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des utilisateurs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère un utilisateur spécifique
|
||||||
|
Future<Map<String, dynamic>> getUser(String userId) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getUser', {'userId': userId});
|
||||||
|
return result['user'] as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un utilisateur (Auth + Firestore)
|
||||||
|
Future<void> deleteUser(String userId) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('deleteUser', {'userId': userId});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la suppression de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour un utilisateur
|
||||||
|
Future<void> updateUser(String userId, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
await _apiService.call('updateUser', {
|
||||||
|
'userId': userId,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un utilisateur avec invitation par email
|
||||||
|
Future<Map<String, dynamic>> createUserWithInvite({
|
||||||
|
required String email,
|
||||||
|
required String firstName,
|
||||||
|
required String lastName,
|
||||||
|
String? phoneNumber,
|
||||||
|
required String roleId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('createUserWithInvite', {
|
||||||
|
'email': email,
|
||||||
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
'phoneNumber': phoneNumber ?? '',
|
||||||
|
'roleId': roleId,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la création de l\'utilisateur: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère tous les rôles
|
||||||
|
Future<List<Map<String, dynamic>>> getRoles() async {
|
||||||
|
try {
|
||||||
|
final result = await _apiService.call('getRoles', {});
|
||||||
|
final roles = result['roles'] as List<dynamic>?;
|
||||||
|
if (roles == null) return [];
|
||||||
|
return roles.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Erreur lors de la récupération des rôles: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ class AppInitializer with ChangeNotifier {
|
|||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized || _isInitializing) return;
|
if (_isInitialized || _isInitializing) return;
|
||||||
_isInitializing = true;
|
_isInitializing = true;
|
||||||
notifyListeners();
|
scheduleMicrotask(() => notifyListeners());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialiser Firebase
|
// Initialiser Firebase
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ class ContainerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifier la disponibilité d'un container et de son contenu pour un événement
|
|
||||||
Future<Map<String, dynamic>> checkContainerAvailability({
|
Future<Map<String, dynamic>> checkContainerAvailability({
|
||||||
required String containerId,
|
required String containerId,
|
||||||
required DateTime startDate,
|
required DateTime startDate,
|
||||||
@@ -149,43 +148,21 @@ class ContainerService {
|
|||||||
String? excludeEventId,
|
String? excludeEventId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final container = await getContainerById(containerId);
|
final result = await _dataService.checkContainerAvailability(
|
||||||
if (container == null) {
|
containerId: containerId,
|
||||||
return {'available': false, 'message': 'Container non trouvé'};
|
startDate: startDate,
|
||||||
}
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
// Vérifier le statut du container
|
);
|
||||||
if (container.status != EquipmentStatus.available) {
|
|
||||||
return {
|
return {
|
||||||
'available': false,
|
'available': result['isAvailable'] ?? false,
|
||||||
'message': 'Container ${container.name} n\'est pas disponible (statut: ${container.status})',
|
'message': result['isAvailable'] == true
|
||||||
|
? 'Container et tout son contenu disponibles'
|
||||||
|
: 'Container non disponible ou en conflit',
|
||||||
|
'conflictType': result['conflictType'],
|
||||||
|
'containerConflicts': result['containerConflicts'],
|
||||||
|
'equipmentConflicts': result['equipmentConflicts'],
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier la disponibilité de chaque équipement dans le container
|
|
||||||
List<String> unavailableEquipment = [];
|
|
||||||
|
|
||||||
if (container.equipmentIds.isNotEmpty) {
|
|
||||||
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
|
|
||||||
|
|
||||||
for (var data in equipmentsData) {
|
|
||||||
final id = data['id'] as String;
|
|
||||||
final equipment = EquipmentModel.fromMap(data, id);
|
|
||||||
if (equipment.status != EquipmentStatus.available) {
|
|
||||||
unavailableEquipment.add('${equipment.name} (${equipment.status})');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unavailableEquipment.isNotEmpty) {
|
|
||||||
return {
|
|
||||||
'available': false,
|
|
||||||
'message': 'Certains équipements ne sont pas disponibles',
|
|
||||||
'unavailableItems': unavailableEquipment,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {'available': true, 'message': 'Container et tout son contenu disponibles'};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error checking container availability: $e');
|
print('Error checking container availability: $e');
|
||||||
return {'available': false, 'message': 'Erreur: $e'};
|
return {'available': false, 'message': 'Erreur: $e'};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
@@ -66,27 +67,19 @@ class AvailabilityConflict {
|
|||||||
class EventAvailabilityService {
|
class EventAvailabilityService {
|
||||||
final DataService _dataService = DataService(apiService);
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
/// Helper pour récupérer uniquement la liste d'événements
|
|
||||||
Future<List<Map<String, dynamic>>> _getEventsList() async {
|
|
||||||
final result = await _dataService.getEvents();
|
|
||||||
final events = result['events'] as List<dynamic>? ?? [];
|
|
||||||
return events.map((e) => e as Map<String, dynamic>).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifie si un équipement est disponible pour une plage de dates via Cloud Function
|
/// Vérifie si un équipement est disponible pour une plage de dates via Cloud Function
|
||||||
Future<List<AvailabilityConflict>> checkEquipmentAvailability({
|
Future<List<AvailabilityConflict>> checkEquipmentAvailability({
|
||||||
required String equipmentId,
|
required String equipmentId,
|
||||||
required String equipmentName,
|
required String equipmentName,
|
||||||
required DateTime startDate,
|
required DateTime startDate,
|
||||||
required DateTime endDate,
|
required DateTime endDate,
|
||||||
String? excludeEventId, // Pour exclure l'événement en cours d'édition
|
String? excludeEventId,
|
||||||
}) async {
|
}) async {
|
||||||
final conflicts = <AvailabilityConflict>[];
|
final conflicts = <AvailabilityConflict>[];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
|
if (kDebugMode) debugPrint('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
|
||||||
|
|
||||||
// Utiliser la Cloud Function pour vérifier la disponibilité
|
|
||||||
final result = await _dataService.checkEquipmentAvailability(
|
final result = await _dataService.checkEquipmentAvailability(
|
||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
startDate: startDate,
|
startDate: startDate,
|
||||||
@@ -94,20 +87,12 @@ class EventAvailabilityService {
|
|||||||
excludeEventId: excludeEventId,
|
excludeEventId: excludeEventId,
|
||||||
);
|
);
|
||||||
|
|
||||||
print('[EventAvailabilityService] Result for $equipmentId: $result');
|
|
||||||
|
|
||||||
final available = result['available'] as bool? ?? true;
|
final available = result['available'] as bool? ?? true;
|
||||||
print('[EventAvailabilityService] Equipment $equipmentId available: $available');
|
|
||||||
|
|
||||||
if (!available) {
|
if (!available) {
|
||||||
final conflictsData = result['conflicts'] as List<dynamic>? ?? [];
|
final conflictsData = result['conflicts'] as List<dynamic>? ?? [];
|
||||||
print('[EventAvailabilityService] Found ${conflictsData.length} conflicts for equipment $equipmentId');
|
|
||||||
|
|
||||||
for (final conflictData in conflictsData) {
|
for (final conflictData in conflictsData) {
|
||||||
final conflict = conflictData as Map<String, dynamic>;
|
final conflict = conflictData as Map<String, dynamic>;
|
||||||
final eventId = conflict['eventId'] as String;
|
final eventId = conflict['eventId'] as String;
|
||||||
|
|
||||||
// Le backend retourne déjà eventData
|
|
||||||
final eventData = conflict['eventData'] as Map<String, dynamic>?;
|
final eventData = conflict['eventData'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
if (eventData != null && eventData.isNotEmpty) {
|
if (eventData != null && eventData.isNotEmpty) {
|
||||||
@@ -119,19 +104,16 @@ class EventAvailabilityService {
|
|||||||
conflictingEvent: event,
|
conflictingEvent: event,
|
||||||
overlapDays: conflict['overlapDays'] as int? ?? 0,
|
overlapDays: conflict['overlapDays'] as int? ?? 0,
|
||||||
));
|
));
|
||||||
print('[EventAvailabilityService] Added conflict with event ${event.name}');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventAvailabilityService] Error creating EventModel: $e');
|
if (kDebugMode) debugPrint('[EventAvailabilityService] Error creating EventModel: $e');
|
||||||
print('[EventAvailabilityService] EventData: $eventData');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventAvailabilityService] Error checking availability: $e');
|
if (kDebugMode) debugPrint('[EventAvailabilityService] Error checking availability: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[EventAvailabilityService] Returning ${conflicts.length} conflicts for equipment $equipmentId');
|
|
||||||
return conflicts;
|
return conflicts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,164 +141,10 @@ class EventAvailabilityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return allConflicts;
|
return allConflicts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifie si deux plages de dates se chevauchent
|
/// Vérifie la disponibilité d'une boîte et de son contenu via le backend
|
||||||
bool _datesOverlap(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
|
|
||||||
// Deux plages se chevauchent si elles ne sont PAS complètement séparées
|
|
||||||
// Elles sont séparées si : end1 < start2 OU end2 < start1
|
|
||||||
// Donc elles se chevauchent si : NOT (end1 < start2 OU end2 < start1)
|
|
||||||
// Équivalent à : end1 >= start2 ET end2 >= start1
|
|
||||||
return !end1.isBefore(start2) && !end2.isBefore(start1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calcule le nombre de jours de chevauchement
|
|
||||||
int _calculateOverlapDays(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
|
|
||||||
final overlapStart = start1.isAfter(start2) ? start1 : start2;
|
|
||||||
final overlapEnd = end1.isBefore(end2) ? end1 : end2;
|
|
||||||
|
|
||||||
return overlapEnd.difference(overlapStart).inDays + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Récupère la quantité disponible pour un consommable/câble
|
|
||||||
Future<int> getAvailableQuantity({
|
|
||||||
required EquipmentModel equipment,
|
|
||||||
required DateTime startDate,
|
|
||||||
required DateTime endDate,
|
|
||||||
String? excludeEventId,
|
|
||||||
}) async {
|
|
||||||
if (!equipment.hasQuantity) {
|
|
||||||
return 1; // Équipement non consommable
|
|
||||||
}
|
|
||||||
|
|
||||||
final totalQuantity = equipment.totalQuantity ?? 0;
|
|
||||||
int reservedQuantity = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Récupérer tous les événements via Cloud Function
|
|
||||||
final eventsData = await _getEventsList();
|
|
||||||
|
|
||||||
for (var eventData in eventsData) {
|
|
||||||
final eventId = eventData['id'] as String;
|
|
||||||
if (excludeEventId != null && eventId == excludeEventId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final event = EventModel.fromMap(eventData, eventId);
|
|
||||||
|
|
||||||
// Ignorer les événements annulés
|
|
||||||
if (event.status == EventStatus.canceled) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculer les dates réelles avec temps d'installation et démontage
|
|
||||||
final eventRealStartDate = event.startDateTime.subtract(
|
|
||||||
Duration(hours: event.installationTime),
|
|
||||||
);
|
|
||||||
final eventRealEndDate = event.endDateTime.add(
|
|
||||||
Duration(hours: event.disassemblyTime),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Vérifier le chevauchement des dates
|
|
||||||
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
|
|
||||||
final assignedEquipment = event.assignedEquipment.firstWhere(
|
|
||||||
(eq) => eq.equipmentId == equipment.id,
|
|
||||||
orElse: () => EventEquipment(equipmentId: ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Si l'équipement est assigné, réserver la quantité
|
|
||||||
// (peu importe le statut de préparation/retour)
|
|
||||||
if (assignedEquipment.equipmentId.isNotEmpty) {
|
|
||||||
reservedQuantity += assignedEquipment.quantity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('[EventAvailabilityService] Error processing event $eventId for quantity: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('[EventAvailabilityService] Error getting available quantity: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalQuantity - reservedQuantity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifie la disponibilité d'un équipement avec gestion des quantités
|
|
||||||
Future<List<AvailabilityConflict>> checkEquipmentAvailabilityWithQuantity({
|
|
||||||
required EquipmentModel equipment,
|
|
||||||
required int requestedQuantity,
|
|
||||||
required DateTime startDate,
|
|
||||||
required DateTime endDate,
|
|
||||||
String? excludeEventId,
|
|
||||||
}) async {
|
|
||||||
final conflicts = <AvailabilityConflict>[];
|
|
||||||
|
|
||||||
// Si équipement quantifiable (consommable/câble)
|
|
||||||
if (equipment.hasQuantity) {
|
|
||||||
final totalQuantity = equipment.totalQuantity ?? 0;
|
|
||||||
final availableQty = await getAvailableQuantity(
|
|
||||||
equipment: equipment,
|
|
||||||
startDate: startDate,
|
|
||||||
endDate: endDate,
|
|
||||||
excludeEventId: excludeEventId,
|
|
||||||
);
|
|
||||||
final reservedQty = totalQuantity - availableQty;
|
|
||||||
|
|
||||||
// ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante
|
|
||||||
if (availableQty < requestedQuantity) {
|
|
||||||
// Trouver les événements qui réservent cette quantité
|
|
||||||
final eventsData = await _getEventsList();
|
|
||||||
|
|
||||||
for (var eventData in eventsData) {
|
|
||||||
final eventId = eventData['id'] as String;
|
|
||||||
if (excludeEventId != null && eventId == excludeEventId) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final event = EventModel.fromMap(eventData, eventId);
|
|
||||||
|
|
||||||
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
|
|
||||||
final assignedEquipment = event.assignedEquipment.firstWhere(
|
|
||||||
(eq) => eq.equipmentId == equipment.id,
|
|
||||||
orElse: () => EventEquipment(equipmentId: ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) {
|
|
||||||
conflicts.add(AvailabilityConflict(
|
|
||||||
equipmentId: equipment.id,
|
|
||||||
equipmentName: equipment.name,
|
|
||||||
conflictingEvent: event,
|
|
||||||
overlapDays: _calculateOverlapDays(startDate, endDate, event.startDateTime, event.endDateTime),
|
|
||||||
type: ConflictType.insufficientQuantity,
|
|
||||||
totalQuantity: totalQuantity,
|
|
||||||
availableQuantity: availableQty,
|
|
||||||
requestedQuantity: requestedQuantity,
|
|
||||||
reservedQuantity: reservedQty,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('[EventAvailabilityService] Error processing event $eventId: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Équipement non quantifiable : vérification classique
|
|
||||||
return await checkEquipmentAvailability(
|
|
||||||
equipmentId: equipment.id,
|
|
||||||
equipmentName: equipment.name,
|
|
||||||
startDate: startDate,
|
|
||||||
endDate: endDate,
|
|
||||||
excludeEventId: excludeEventId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return conflicts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vérifie la disponibilité d'une boîte et de son contenu
|
|
||||||
Future<List<AvailabilityConflict>> checkContainerAvailability({
|
Future<List<AvailabilityConflict>> checkContainerAvailability({
|
||||||
required ContainerModel container,
|
required ContainerModel container,
|
||||||
required List<EquipmentModel> containerEquipment,
|
required List<EquipmentModel> containerEquipment,
|
||||||
@@ -325,99 +153,62 @@ class EventAvailabilityService {
|
|||||||
String? excludeEventId,
|
String? excludeEventId,
|
||||||
}) async {
|
}) async {
|
||||||
final conflicts = <AvailabilityConflict>[];
|
final conflicts = <AvailabilityConflict>[];
|
||||||
final conflictingChildrenIds = <String>[];
|
|
||||||
|
|
||||||
// Vérifier d'abord si la boîte complète est utilisée
|
|
||||||
final eventsData = await _getEventsList();
|
|
||||||
bool isContainerFullyUsed = false;
|
|
||||||
EventModel? containerConflictingEvent;
|
|
||||||
|
|
||||||
for (var eventData in eventsData) {
|
|
||||||
final eventId = eventData['id'] as String;
|
|
||||||
if (excludeEventId != null && eventId == excludeEventId) continue;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final event = EventModel.fromMap(eventData, eventId);
|
final result = await _dataService.checkContainerAvailability(
|
||||||
|
|
||||||
// Ignorer les événements annulés
|
|
||||||
if (event.status == EventStatus.canceled) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculer les dates réelles avec temps d'installation et démontage
|
|
||||||
final eventRealStartDate = event.startDateTime.subtract(
|
|
||||||
Duration(hours: event.installationTime),
|
|
||||||
);
|
|
||||||
final eventRealEndDate = event.endDateTime.add(
|
|
||||||
Duration(hours: event.disassemblyTime),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Vérifier si cette boîte est assignée
|
|
||||||
if (event.assignedContainers.contains(container.id)) {
|
|
||||||
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
|
|
||||||
isContainerFullyUsed = true;
|
|
||||||
containerConflictingEvent = event;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('[EventAvailabilityService] Error processing event $eventId: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isContainerFullyUsed && containerConflictingEvent != null) {
|
|
||||||
// Boîte complète utilisée
|
|
||||||
conflicts.add(AvailabilityConflict(
|
|
||||||
equipmentId: container.id,
|
|
||||||
equipmentName: container.name,
|
|
||||||
conflictingEvent: containerConflictingEvent,
|
|
||||||
overlapDays: _calculateOverlapDays(
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
containerConflictingEvent.startDateTime,
|
|
||||||
containerConflictingEvent.endDateTime,
|
|
||||||
),
|
|
||||||
type: ConflictType.containerFullyUsed,
|
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
containerName: container.name,
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
// Vérifier chaque équipement enfant individuellement
|
|
||||||
for (var equipment in containerEquipment) {
|
|
||||||
final equipmentConflicts = await checkEquipmentAvailability(
|
|
||||||
equipmentId: equipment.id,
|
|
||||||
equipmentName: equipment.name,
|
|
||||||
startDate: startDate,
|
startDate: startDate,
|
||||||
endDate: endDate,
|
endDate: endDate,
|
||||||
excludeEventId: excludeEventId,
|
excludeEventId: excludeEventId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (equipmentConflicts.isNotEmpty) {
|
final isAvailable = result['isAvailable'] as bool? ?? true;
|
||||||
conflictingChildrenIds.add(equipment.id);
|
if (!isAvailable) {
|
||||||
conflicts.addAll(equipmentConflicts);
|
final conflictType = result['conflictType'] as String?;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si au moins un enfant en conflit, ajouter un conflit pour la boîte
|
if (conflictType == 'complete') {
|
||||||
if (conflictingChildrenIds.isNotEmpty && conflicts.isNotEmpty) {
|
final containerConflicts = result['containerConflicts'] as List<dynamic>? ?? [];
|
||||||
conflicts.insert(
|
for (var conflictData in containerConflicts) {
|
||||||
0,
|
final conflict = conflictData as Map<String, dynamic>;
|
||||||
AvailabilityConflict(
|
final eventId = conflict['eventId'] as String;
|
||||||
|
final eventName = conflict['eventName'] as String? ?? '';
|
||||||
|
final startDateStr = conflict['startDate'] as String?;
|
||||||
|
final endDateStr = conflict['endDate'] as String?;
|
||||||
|
|
||||||
|
final event = EventModel(
|
||||||
|
id: eventId,
|
||||||
|
name: eventName,
|
||||||
|
description: '',
|
||||||
|
startDateTime: startDateStr != null ? DateTime.tryParse(startDateStr) ?? DateTime.now() : DateTime.now(),
|
||||||
|
endDateTime: endDateStr != null ? DateTime.tryParse(endDateStr) ?? DateTime.now() : DateTime.now(),
|
||||||
|
basePrice: 0.0,
|
||||||
|
installationTime: 0,
|
||||||
|
disassemblyTime: 0,
|
||||||
|
eventTypeId: '',
|
||||||
|
customerId: '',
|
||||||
|
address: '',
|
||||||
|
latitude: 0.0,
|
||||||
|
longitude: 0.0,
|
||||||
|
workforce: const [],
|
||||||
|
documents: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
conflicts.add(AvailabilityConflict(
|
||||||
equipmentId: container.id,
|
equipmentId: container.id,
|
||||||
equipmentName: container.name,
|
equipmentName: container.name,
|
||||||
conflictingEvent: conflicts.first.conflictingEvent,
|
conflictingEvent: event,
|
||||||
overlapDays: conflicts.first.overlapDays,
|
overlapDays: conflict['overlapDays'] as int? ?? 0,
|
||||||
type: ConflictType.containerPartiallyUsed,
|
type: ConflictType.containerFullyUsed,
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
containerName: container.name,
|
containerName: container.name,
|
||||||
conflictingChildrenIds: conflictingChildrenIds,
|
));
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('[EventAvailabilityService] Error checking container availability: $e');
|
||||||
|
}
|
||||||
|
|
||||||
return conflicts;
|
return conflicts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
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:provider/provider.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import '../views/login_page.dart';
|
import '../views/login_page.dart';
|
||||||
import '../utils/colors.dart';
|
import '../utils/colors.dart';
|
||||||
|
import '../providers/local_user_provider.dart';
|
||||||
|
|
||||||
/// Gate de démarrage qui attend la restauration Firebase Auth avant
|
/// Gate de démarrage qui attend la restauration Firebase Auth avant
|
||||||
/// d'afficher soit le contenu connecté, soit la page de connexion.
|
/// d'afficher soit le contenu connecté, soit la page de connexion.
|
||||||
@@ -82,6 +85,13 @@ class _AuthenticatedBootstrapState extends State<_AuthenticatedBootstrap> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Charger les données utilisateur de façon non bloquante
|
||||||
|
unawaited(
|
||||||
|
context.read<LocalUserProvider>().loadUserData().catchError((e) {
|
||||||
|
debugPrint('[AppStartGate] User data bootstrap failed: $e');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
||||||
Navigator.of(context).pushReplacementNamed(fragment);
|
Navigator.of(context).pushReplacementNamed(fragment);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Utilitaire pour différer l'exécution d'une action après un délai.
|
||||||
|
/// Utilisé principalement pour les champs de recherche afin d'éviter
|
||||||
|
/// des requêtes à chaque frappe clavier.
|
||||||
|
class Debouncer {
|
||||||
|
final Duration delay;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
Debouncer({this.delay = const Duration(milliseconds: 400)});
|
||||||
|
|
||||||
|
void call(VoidCallback action) {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = Timer(delay, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
int _searchRequestId = 0;
|
int _searchRequestId = 0;
|
||||||
bool _isMobileSearchVisible = false;
|
bool _isMobileSearchVisible = false;
|
||||||
bool _isRefreshing = false;
|
bool _isRefreshing = false;
|
||||||
double _detailsPaneFraction = 0.35;
|
final ValueNotifier<double> _detailsPaneFraction = ValueNotifier<double>(0.35);
|
||||||
String? _lastLoadedUserId;
|
String? _lastLoadedUserId;
|
||||||
bool _initialLoadScheduled = false;
|
bool _initialLoadScheduled = false;
|
||||||
|
|
||||||
@@ -207,6 +207,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_searchDebounce?.cancel();
|
_searchDebounce?.cancel();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
_detailsPaneFraction.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,17 +602,26 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight: isMobile ? 240 : 280,
|
maxHeight: isMobile ? 240 : 280,
|
||||||
),
|
),
|
||||||
child: ListView.separated(
|
child: ListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
itemCount: _searchResults.length,
|
itemCount: _searchResults.length,
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
separatorBuilder: (context, index) =>
|
// ✅ prototypeItem : les résultats ont une hauteur variable
|
||||||
const SizedBox(height: 8),
|
// selon la présence du champ adresse (~56px sans, ~70px avec).
|
||||||
|
// prototypeItem à 72px (cas avec adresse + padding) pour
|
||||||
|
// que Flutter estime correctement la hauteur scrollable.
|
||||||
|
// ListView.separated ne supporte pas itemExtent/prototypeItem,
|
||||||
|
// d'où la conversion en ListView.builder avec séparateur intégré.
|
||||||
|
prototypeItem: const SizedBox(height: 72),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final event = _searchResults[index];
|
final event = _searchResults[index];
|
||||||
final isSelected = _selectedEvent?.id == event.id;
|
final isSelected = _selectedEvent?.id == event.id;
|
||||||
|
final isLast = index == _searchResults.length - 1;
|
||||||
|
|
||||||
return Material(
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Material(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.rouge.withOpacity(0.08)
|
? AppColors.rouge.withOpacity(0.08)
|
||||||
: Colors.white,
|
: Colors.white,
|
||||||
@@ -675,6 +685,9 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (!isLast) const SizedBox(height: 8),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -805,12 +818,10 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onHorizontalDragUpdate: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
setState(() {
|
_detailsPaneFraction.value = _clampDetailsPaneFraction(
|
||||||
_detailsPaneFraction = _clampDetailsPaneFraction(
|
_detailsPaneFraction.value - (details.delta.dx / totalWidth),
|
||||||
_detailsPaneFraction - (details.delta.dx / totalWidth),
|
|
||||||
totalWidth,
|
totalWidth,
|
||||||
);
|
);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: _desktopResizeHandleWidth,
|
width: _desktopResizeHandleWidth,
|
||||||
@@ -923,8 +934,11 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final totalWidth = constraints.maxWidth;
|
final totalWidth = constraints.maxWidth;
|
||||||
|
return ValueListenableBuilder<double>(
|
||||||
|
valueListenable: _detailsPaneFraction,
|
||||||
|
builder: (context, fraction, child) {
|
||||||
final detailsPaneFraction =
|
final detailsPaneFraction =
|
||||||
_clampDetailsPaneFraction(_detailsPaneFraction, totalWidth);
|
_clampDetailsPaneFraction(fraction, totalWidth);
|
||||||
final detailsWidth = totalWidth * detailsPaneFraction;
|
final detailsWidth = totalWidth * detailsPaneFraction;
|
||||||
final calendarWidth =
|
final calendarWidth =
|
||||||
totalWidth - _desktopResizeHandleWidth - detailsWidth;
|
totalWidth - _desktopResizeHandleWidth - detailsWidth;
|
||||||
@@ -945,6 +959,8 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
|
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
|
||||||
@@ -964,6 +980,10 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
? eventsForSelectedDay[_selectedEventIndex]
|
? eventsForSelectedDay[_selectedEventIndex]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxHeight = constraints.maxHeight;
|
||||||
|
|
||||||
// GESTURE DETECTOR pour swipe vertical (plier/déplier) et horizontal (mois)
|
// GESTURE DETECTOR pour swipe vertical (plier/déplier) et horizontal (mois)
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onVerticalDragEnd: (details) {
|
onVerticalDragEnd: (details) {
|
||||||
@@ -1012,12 +1032,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
top: _calendarCollapsed ? -600 : 0, // cache le calendrier en haut
|
top: _calendarCollapsed ? -maxHeight : 0, // cache le calendrier en haut
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: _calendarCollapsed ? 0 : null,
|
height: _calendarCollapsed ? 0 : null,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: MediaQuery.of(context).size.height,
|
height: maxHeight,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildMonthHeader(context),
|
_buildMonthHeader(context),
|
||||||
@@ -1128,12 +1148,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
top: _calendarCollapsed ? 0 : 600,
|
top: _calendarCollapsed ? 0 : maxHeight,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: MediaQuery.of(context).size.height,
|
height: maxHeight,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildMonthHeader(context),
|
_buildMonthHeader(context),
|
||||||
@@ -1182,7 +1202,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!hasEvents)
|
if (!hasEvents)
|
||||||
Center(
|
const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Aucun événement ne démarre à cette date'),
|
'Aucun événement ne démarre à cette date'),
|
||||||
),
|
),
|
||||||
@@ -1196,6 +1216,8 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMonthHeader(BuildContext context) {
|
Widget _buildMonthHeader(BuildContext context) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:em2rp/utils/debug_log.dart';
|
|||||||
import 'package:em2rp/utils/id_generator.dart';
|
import 'package:em2rp/utils/id_generator.dart';
|
||||||
import 'package:em2rp/services/data_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debouncer.dart';
|
||||||
|
|
||||||
class ContainerFormPage extends StatefulWidget {
|
class ContainerFormPage extends StatefulWidget {
|
||||||
final ContainerModel? container;
|
final ContainerModel? container;
|
||||||
@@ -34,7 +35,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
// Form fields
|
// Form fields
|
||||||
ContainerType _selectedType = ContainerType.flightCase;
|
ContainerType _selectedType = ContainerType.flightCase;
|
||||||
EquipmentStatus _selectedStatus = EquipmentStatus.available;
|
EquipmentStatus _selectedStatus = EquipmentStatus.available;
|
||||||
bool _autoGenerateId = true;
|
final ValueNotifier<bool> _autoGenerateIdNotifier = ValueNotifier<bool>(true);
|
||||||
final Set<String> _selectedEquipmentIds = {};
|
final Set<String> _selectedEquipmentIds = {};
|
||||||
|
|
||||||
bool _isEditing = false;
|
bool _isEditing = false;
|
||||||
@@ -60,11 +61,11 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
_heightController.text = container.height?.toString() ?? '';
|
_heightController.text = container.height?.toString() ?? '';
|
||||||
_notesController.text = container.notes ?? '';
|
_notesController.text = container.notes ?? '';
|
||||||
_selectedEquipmentIds.addAll(container.equipmentIds);
|
_selectedEquipmentIds.addAll(container.equipmentIds);
|
||||||
_autoGenerateId = false;
|
_autoGenerateIdNotifier.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateIdFromName() {
|
void _updateIdFromName() {
|
||||||
if (_autoGenerateId && !_isEditing) {
|
if (_autoGenerateIdNotifier.value && !_isEditing) {
|
||||||
final name = _nameController.text;
|
final name = _nameController.text;
|
||||||
if (name.isNotEmpty) {
|
if (name.isNotEmpty) {
|
||||||
final baseId = IdGenerator.generateContainerId(
|
final baseId = IdGenerator.generateContainerId(
|
||||||
@@ -77,7 +78,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _updateIdFromType() {
|
void _updateIdFromType() {
|
||||||
if (_autoGenerateId && !_isEditing) {
|
if (_autoGenerateIdNotifier.value && !_isEditing) {
|
||||||
final name = _nameController.text;
|
final name = _nameController.text;
|
||||||
if (name.isNotEmpty) {
|
if (name.isNotEmpty) {
|
||||||
final baseId = IdGenerator.generateContainerId(
|
final baseId = IdGenerator.generateContainerId(
|
||||||
@@ -122,7 +123,10 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ID
|
// ID
|
||||||
Row(
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _autoGenerateIdNotifier,
|
||||||
|
builder: (context, autoGenerateId, child) {
|
||||||
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -134,7 +138,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
prefixIcon: Icon(Icons.qr_code),
|
prefixIcon: Icon(Icons.qr_code),
|
||||||
),
|
),
|
||||||
enabled: !_autoGenerateId || _isEditing,
|
enabled: !autoGenerateId || _isEditing,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return 'Veuillez entrer un identifiant';
|
return 'Veuillez entrer un identifiant';
|
||||||
@@ -148,23 +152,23 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_autoGenerateId ? Icons.lock : Icons.lock_open,
|
autoGenerateId ? Icons.lock : Icons.lock_open,
|
||||||
color: _autoGenerateId ? AppColors.rouge : Colors.grey,
|
color: autoGenerateId ? AppColors.rouge : Colors.grey,
|
||||||
),
|
),
|
||||||
tooltip: _autoGenerateId
|
tooltip: autoGenerateId
|
||||||
? 'Génération automatique'
|
? 'Génération automatique'
|
||||||
: 'Saisie manuelle',
|
: 'Saisie manuelle',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
_autoGenerateIdNotifier.value = !autoGenerateId;
|
||||||
_autoGenerateId = !_autoGenerateId;
|
if (_autoGenerateIdNotifier.value) {
|
||||||
if (_autoGenerateId) {
|
|
||||||
_updateIdFromName();
|
_updateIdFromName();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -631,6 +635,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
_widthController.dispose();
|
_widthController.dispose();
|
||||||
_heightController.dispose();
|
_heightController.dispose();
|
||||||
_notesController.dispose();
|
_notesController.dispose();
|
||||||
|
_autoGenerateIdNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -658,6 +663,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
EquipmentCategory? _filterCategory;
|
EquipmentCategory? _filterCategory;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
late Set<String> _tempSelectedIds;
|
late Set<String> _tempSelectedIds;
|
||||||
|
final _searchDebouncer = Debouncer();
|
||||||
|
|
||||||
final List<EquipmentModel> _paginatedEquipments = [];
|
final List<EquipmentModel> _paginatedEquipments = [];
|
||||||
bool _isLoadingMore = false;
|
bool _isLoadingMore = false;
|
||||||
@@ -677,6 +683,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
|
_searchDebouncer.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,7 +797,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = value;
|
_searchQuery = value;
|
||||||
});
|
});
|
||||||
_reloadData();
|
_searchDebouncer(_reloadData);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
||||||
provider.loadBrands();
|
provider.loadBrands();
|
||||||
provider.loadModels();
|
provider.loadModels();
|
||||||
|
if (widget.equipment != null) {
|
||||||
|
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
|
||||||
|
_loadFilteredModels(_selectedBrand!);
|
||||||
|
}
|
||||||
|
_loadFilteredSubCategories(_selectedCategory);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (widget.equipment != null) {
|
if (widget.equipment != null) {
|
||||||
_populateFields();
|
_populateFields();
|
||||||
@@ -84,14 +90,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
|
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
|
||||||
|
|
||||||
|
|
||||||
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
|
|
||||||
_loadFilteredModels(_selectedBrand!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charger les sous-catégories pour la catégorie sélectionnée
|
|
||||||
_loadFilteredSubCategories(_selectedCategory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
|||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/views/equipment_form_page.dart';
|
import 'package:em2rp/views/equipment_form_page.dart';
|
||||||
@@ -20,6 +21,7 @@ import 'package:em2rp/utils/equipment_delete_utils.dart';
|
|||||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||||
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||||
import 'package:em2rp/views/widgets/notification_badge.dart';
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||||
|
import 'package:em2rp/utils/debouncer.dart';
|
||||||
|
|
||||||
class EquipmentManagementPage extends StatefulWidget {
|
class EquipmentManagementPage extends StatefulWidget {
|
||||||
const EquipmentManagementPage({super.key});
|
const EquipmentManagementPage({super.key});
|
||||||
@@ -33,6 +35,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
with SelectionModeMixin<EquipmentManagementPage> {
|
with SelectionModeMixin<EquipmentManagementPage> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final _searchDebouncer = Debouncer();
|
||||||
EquipmentCategory? _selectedCategory;
|
EquipmentCategory? _selectedCategory;
|
||||||
List<EquipmentModel>? _cachedEquipment;
|
List<EquipmentModel>? _cachedEquipment;
|
||||||
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
|
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
|
||||||
@@ -87,6 +90,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
_scrollController.removeListener(_onScroll);
|
_scrollController.removeListener(_onScroll);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
_searchDebouncer.dispose();
|
||||||
// Désactiver le mode pagination en quittant
|
// Désactiver le mode pagination en quittant
|
||||||
context.read<EquipmentProvider>().disablePagination();
|
context.read<EquipmentProvider>().disablePagination();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -140,7 +144,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: CustomAppBar(
|
: const CustomAppBar(
|
||||||
title: 'Gestion du matériel',
|
title: 'Gestion du matériel',
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/equipment_management'),
|
drawer: const MainDrawer(currentPage: '/equipment_management'),
|
||||||
@@ -169,9 +173,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
SearchActionsBar(
|
SearchActionsBar(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
hintText: 'Rechercher par nom, modèle ou ID...',
|
hintText: 'Rechercher par nom, modèle ou ID...',
|
||||||
onChanged: (value) {
|
onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
|
||||||
context.read<EquipmentProvider>().setSearchQuery(value);
|
|
||||||
},
|
|
||||||
onClear: () {
|
onClear: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
context.read<EquipmentProvider>().setSearchQuery('');
|
context.read<EquipmentProvider>().setSearchQuery('');
|
||||||
@@ -342,9 +344,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
SearchActionsBar(
|
SearchActionsBar(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
hintText: 'Rechercher par nom, modèle ou ID...',
|
hintText: 'Rechercher par nom, modèle ou ID...',
|
||||||
onChanged: (value) {
|
onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
|
||||||
context.read<EquipmentProvider>().setSearchQuery(value);
|
|
||||||
},
|
|
||||||
onClear: () {
|
onClear: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
context.read<EquipmentProvider>().setSearchQuery('');
|
context.read<EquipmentProvider>().setSearchQuery('');
|
||||||
@@ -501,10 +501,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
// ✅ Ajouter une estimation de la hauteur pour améliorer le scroll
|
// ✅ Augmenter le cache pour un scroll plus fluide (prototypeItem retiré car les hauteurs dynamiques varient selon le type d'équipement)
|
||||||
// Note : À ajuster selon la hauteur réelle de vos cartes
|
|
||||||
// itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur
|
|
||||||
// ✅ Augmenter le cache pour un scroll plus fluide
|
|
||||||
cacheExtent: 500, // Précharger 500px en plus
|
cacheExtent: 500, // Précharger 500px en plus
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
// Dernier élément = indicateur de chargement
|
// Dernier élément = indicateur de chargement
|
||||||
@@ -531,53 +528,100 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
key: ValueKey(equipment.id),
|
key: ValueKey(equipment.id),
|
||||||
child: Card(
|
child: Card(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
|
||||||
|
elevation: 1,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
color: isSelectionMode && isSelected
|
color: isSelectionMode && isSelected
|
||||||
? AppColors.rouge.withValues(alpha: 0.1)
|
? AppColors.rouge
|
||||||
: null,
|
: Colors.grey.shade200,
|
||||||
child: ListTile(
|
width: isSelectionMode && isSelected ? 2 : 1,
|
||||||
leading: isSelectionMode
|
),
|
||||||
? Checkbox(
|
),
|
||||||
|
color: isSelectionMode && isSelected
|
||||||
|
? AppColors.rouge.withValues(alpha: 0.05)
|
||||||
|
: Colors.white,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: isSelectionMode
|
||||||
|
? () => toggleItemSelection(equipment.id)
|
||||||
|
: () => _viewEquipmentDetails(equipment),
|
||||||
|
onLongPress: isSelectionMode
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
if (localUserProvider.hasPermission('manage_equipment')) {
|
||||||
|
_editEquipment(equipment);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text("Vous n'avez pas la permission de modifier cet équipement"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 1. leading selection or icon
|
||||||
|
if (isSelectionMode)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: Checkbox(
|
||||||
value: isSelected,
|
value: isSelected,
|
||||||
onChanged: (value) => toggleItemSelection(equipment.id),
|
onChanged: (value) => toggleItemSelection(equipment.id),
|
||||||
activeColor: AppColors.rouge,
|
activeColor: AppColors.rouge,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: CircleAvatar(
|
else
|
||||||
backgroundColor:
|
Padding(
|
||||||
equipment.category.color.withValues(alpha: 0.2),
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: equipment.category.color.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
child: equipment.category.getIcon(
|
child: equipment.category.getIcon(
|
||||||
size: 20,
|
size: 22,
|
||||||
color: equipment.category.color,
|
color: equipment.category.color,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Row(
|
),
|
||||||
children: [
|
),
|
||||||
|
|
||||||
|
// 2. Info details (ID, Brand/Model, Subcategory)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
equipment.id,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Afficher le badge de statut calculé dynamiquement
|
|
||||||
if (equipment.category != EquipmentCategory.consumable &&
|
|
||||||
equipment.category != EquipmentCategory.cable)
|
|
||||||
EquipmentStatusBadge(equipment: equipment),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
Text(
|
||||||
|
equipment.id,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
color: AppColors.noir,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
||||||
.trim()
|
.trim()
|
||||||
.isNotEmpty
|
.isNotEmpty
|
||||||
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
|
||||||
.trim()
|
|
||||||
: 'Marque/Modèle non défini',
|
: 'Marque/Modèle non défini',
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
style: TextStyle(
|
||||||
|
color: Colors.grey[700],
|
||||||
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
// Afficher la sous-catégorie si elle existe
|
),
|
||||||
|
// Sous-catégorie
|
||||||
if (equipment.subCategory != null &&
|
if (equipment.subCategory != null &&
|
||||||
equipment.subCategory!.isNotEmpty) ...[
|
equipment.subCategory!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -585,22 +629,29 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
'📁 ${equipment.subCategory}',
|
'📁 ${equipment.subCategory}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey[500],
|
color: Colors.grey[500],
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
// Afficher la quantité disponible pour les consommables/câbles
|
|
||||||
if (equipment.category == EquipmentCategory.consumable ||
|
|
||||||
equipment.category == EquipmentCategory.cable) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
_buildQuantityDisplay(equipment),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: isSelectionMode
|
),
|
||||||
? null
|
|
||||||
: Row(
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// 3. Status Badge OR Quantity Display (centered vertically in the row!)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: (equipment.category == EquipmentCategory.consumable ||
|
||||||
|
equipment.category == EquipmentCategory.cable)
|
||||||
|
? _buildQuantityDisplay(equipment)
|
||||||
|
: EquipmentStatusBadge(equipment: equipment),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 4. Trailing Action Buttons
|
||||||
|
if (!isSelectionMode)
|
||||||
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
||||||
@@ -610,46 +661,60 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
requiredPermissions: const ['manage_equipment'],
|
requiredPermissions: const ['manage_equipment'],
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.add_shopping_cart,
|
icon: const Icon(Icons.add_shopping_cart,
|
||||||
color: AppColors.rouge),
|
color: AppColors.rouge, size: 20),
|
||||||
tooltip: 'Restock',
|
tooltip: 'Restock',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
onPressed: () => _showRestockDialog(equipment),
|
onPressed: () => _showRestockDialog(equipment),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (equipment.category == EquipmentCategory.consumable ||
|
||||||
|
equipment.category == EquipmentCategory.cable)
|
||||||
|
const SizedBox(width: 8),
|
||||||
// Bouton QR Code
|
// Bouton QR Code
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
|
icon: const Icon(Icons.qr_code, color: AppColors.rouge, size: 20),
|
||||||
tooltip: 'QR Code',
|
tooltip: 'QR Code',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
onPressed: () => showDialog(
|
onPressed: () => showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
QRCodeDialog.forEquipment(equipment),
|
QRCodeDialog.forEquipment(equipment),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
// Bouton Modifier (permission required)
|
// Bouton Modifier (permission required)
|
||||||
PermissionGate(
|
PermissionGate(
|
||||||
requiredPermissions: const ['manage_equipment'],
|
requiredPermissions: const ['manage_equipment'],
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.edit, color: AppColors.rouge),
|
icon: const Icon(Icons.edit, color: AppColors.rouge, size: 20),
|
||||||
tooltip: 'Modifier',
|
tooltip: 'Modifier',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
onPressed: () => _editEquipment(equipment),
|
onPressed: () => _editEquipment(equipment),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
// Bouton Supprimer (permission required)
|
// Bouton Supprimer (permission required)
|
||||||
PermissionGate(
|
PermissionGate(
|
||||||
requiredPermissions: const ['manage_equipment'],
|
requiredPermissions: const ['manage_equipment'],
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
icon: const Icon(Icons.delete, color: Colors.red, size: 20),
|
||||||
tooltip: 'Supprimer',
|
tooltip: 'Supprimer',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
onPressed: () => _deleteEquipment(equipment),
|
onPressed: () => _deleteEquipment(equipment),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: isSelectionMode
|
],
|
||||||
? () => toggleItemSelection(equipment.id)
|
|
||||||
: () => _viewEquipmentDetails(equipment),
|
|
||||||
),
|
),
|
||||||
));
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQuantityDisplay(EquipmentModel equipment) {
|
Widget _buildQuantityDisplay(EquipmentModel equipment) {
|
||||||
@@ -658,56 +723,23 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
final criticalThreshold = equipment.criticalThreshold ?? 0;
|
final criticalThreshold = equipment.criticalThreshold ?? 0;
|
||||||
final isCritical =
|
final isCritical =
|
||||||
criticalThreshold > 0 && availableQty <= criticalThreshold;
|
criticalThreshold > 0 && availableQty <= criticalThreshold;
|
||||||
|
final color = isCritical ? Colors.red : Colors.grey.shade600;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isCritical
|
color: color.withValues(alpha: 0.15),
|
||||||
? Colors.red.withOpacity(0.15)
|
borderRadius: BorderRadius.circular(12),
|
||||||
: Colors.grey.withOpacity(0.1),
|
border: Border.all(color: color),
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(
|
|
||||||
color: isCritical ? Colors.red : Colors.grey.shade400,
|
|
||||||
width: isCritical ? 2 : 1,
|
|
||||||
),
|
),
|
||||||
),
|
child: Text(
|
||||||
child: Row(
|
'$availableQty / $totalQty',
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
isCritical ? Icons.warning : Icons.inventory,
|
|
||||||
size: 16,
|
|
||||||
color: isCritical ? Colors.red : Colors.grey[700],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'Disponible: $availableQty / $totalQty',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: isCritical ? FontWeight.bold : FontWeight.normal,
|
|
||||||
color: isCritical ? Colors.red : Colors.grey[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isCritical) ...[
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.red,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'CRITIQUE',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white,
|
color: color,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,14 +75,15 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
|
|||||||
title: 'Gestion des maintenances',
|
title: 'Gestion des maintenances',
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/maintenance_management'),
|
drawer: const MainDrawer(currentPage: '/maintenance_management'),
|
||||||
body: Consumer<MaintenanceProvider>(
|
body: Selector<MaintenanceProvider, ({bool isLoading, List<MaintenanceModel> maintenances})>(
|
||||||
builder: (context, maintenanceProvider, _) {
|
selector: (context, provider) => (isLoading: provider.isLoading, maintenances: provider.maintenances),
|
||||||
if (maintenanceProvider.isLoading) {
|
builder: (context, data, _) {
|
||||||
|
if (data.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
final filteredMaintenances = _getFilteredMaintenances(
|
final filteredMaintenances = _getFilteredMaintenances(
|
||||||
maintenanceProvider.maintenances,
|
data.maintenances,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -91,7 +92,7 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
|
|||||||
_buildFilterChips(),
|
_buildFilterChips(),
|
||||||
|
|
||||||
// Statistiques
|
// Statistiques
|
||||||
_buildStatsCards(maintenanceProvider),
|
_buildStatsCards(data.maintenances),
|
||||||
|
|
||||||
// Liste des maintenances
|
// Liste des maintenances
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -148,10 +149,10 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatsCards(MaintenanceProvider provider) {
|
Widget _buildStatsCards(List<MaintenanceModel> maintenances) {
|
||||||
final upcoming = provider.maintenances.where((m) => !m.isCompleted && !m.isOverdue).length;
|
final upcoming = maintenances.where((m) => !m.isCompleted && !m.isOverdue).length;
|
||||||
final overdue = provider.maintenances.where((m) => m.isOverdue).length;
|
final overdue = maintenances.where((m) => m.isOverdue).length;
|
||||||
final completed = provider.maintenances.where((m) => m.isCompleted).length;
|
final completed = maintenances.where((m) => m.isCompleted).length;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
|
|||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:em2rp/models/user_model.dart';
|
||||||
import 'package:em2rp/views/widgets/inputs/styled_text_field.dart';
|
import 'package:em2rp/views/widgets/inputs/styled_text_field.dart';
|
||||||
import 'package:em2rp/views/widgets/image/profile_picture_selector.dart';
|
import 'package:em2rp/views/widgets/image/profile_picture_selector.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
@@ -17,10 +18,9 @@ class MyAccountPage extends StatelessWidget {
|
|||||||
title: 'Mon compte',
|
title: 'Mon compte',
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/my_account'),
|
drawer: const MainDrawer(currentPage: '/my_account'),
|
||||||
body: Consumer<LocalUserProvider>(
|
body: Selector<LocalUserProvider, UserModel?>(
|
||||||
builder: (context, userProvider, child) {
|
selector: (context, provider) => provider.currentUser,
|
||||||
final user = userProvider.currentUser;
|
builder: (context, user, child) {
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ class MyAccountPage extends StatelessWidget {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
userProvider.updateUserData(
|
context.read<LocalUserProvider>().updateUserData(
|
||||||
firstName: firstNameController.text,
|
firstName: firstNameController.text,
|
||||||
lastName: lastNameController.text,
|
lastName: lastNameController.text,
|
||||||
phoneNumber: phoneController.text,
|
phoneNumber: phoneController.text,
|
||||||
|
|||||||
@@ -51,12 +51,13 @@ class _UserManagementPageState extends State<UserManagementPage> {
|
|||||||
title: 'Gestion des utilisateurs',
|
title: 'Gestion des utilisateurs',
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/account_management'),
|
drawer: const MainDrawer(currentPage: '/account_management'),
|
||||||
body: Consumer<UsersProvider>(
|
body: Selector<UsersProvider, ({bool isLoading, List<UserModel> users})>(
|
||||||
builder: (context, usersProvider, child) {
|
selector: (context, provider) => (isLoading: provider.isLoading, users: provider.users),
|
||||||
if (usersProvider.isLoading) {
|
builder: (context, data, child) {
|
||||||
|
if (data.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
final users = usersProvider.users;
|
final users = data.users;
|
||||||
if (users.isEmpty) {
|
if (users.isEmpty) {
|
||||||
return const Center(child: Text("Aucun utilisateur trouvé"));
|
return const Center(child: Text("Aucun utilisateur trouvé"));
|
||||||
}
|
}
|
||||||
@@ -92,7 +93,7 @@ class _UserManagementPageState extends State<UserManagementPage> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (_) => EditUserDialog(user: user)),
|
builder: (_) => EditUserDialog(user: user)),
|
||||||
onResetPassword: () => _resetPassword(context, user),
|
onResetPassword: () => _resetPassword(context, user),
|
||||||
onDelete: () => _confirmDeleteUser(context, usersProvider, user),
|
onDelete: () => _confirmDeleteUser(context, context.read<UsersProvider>(), user),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1",
|
"version": "1.2.3",
|
||||||
"updateUrl": "https://app.em2events.fr",
|
"updateUrl": "https://app.em2events.fr",
|
||||||
"forceUpdate": true,
|
"forceUpdate": true,
|
||||||
"releaseNotes": "Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.",
|
"releaseNotes": "Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.",
|
||||||
"timestamp": "2026-05-25T21:50:50.578Z"
|
"timestamp": "2026-05-26T13:34:16.390Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user