diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 02944a5..b132402 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -393,6 +393,30 @@ exports.deleteContainer = onRequest(httpOptions, withCors(async (req, res) => { 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' }); @@ -613,8 +637,73 @@ exports.createMaintenance = onRequest(httpOptions, withCors(async (req, res) => ]); const docRef = await db.collection('maintenances').add(dataToSave); + const maintenanceId = docRef.id; - res.status(201).json({ id: docRef.id, message: 'Maintenance created successfully' }); + // Mettre à jour les équipements concernés + 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, + }); + } + } + + // Si la maintenance est planifiée dans les 7 prochains jours, créer une alerte + 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) { + // Vérifier si une alerte existe déjà + 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 }); @@ -775,6 +864,96 @@ exports.createUser = onRequest(httpOptions, withCors(async (req, res) => { } })); +// Créer un utilisateur avec invitation par email +exports.createUserWithInvite = onRequest(httpOptions, withCors(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; + } + + // Générer un mot de passe temporaire aléatoire + const tempPassword = Math.random().toString(36).slice(-12) + 'Aa1!'; + + // Créer l'utilisateur dans Firebase Auth + 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; + } + + // Créer le document utilisateur dans Firestore + 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) { + // Si la création Firestore échoue, supprimer l'utilisateur Auth + 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; + } + + // Envoyer l'email de réinitialisation du mot de passe + // Utilisation de l'API REST de Firebase Auth pour déclencher l'envoi automatique + try { + const axios = require('axios'); + const firebaseApiKey = 'AIzaSyARQL4P-t5l-cNjQNP9cMokQrLJ8BorF0U'; // Web API Key + + 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}`); + // Ne pas faire échouer la requête si l'email ne peut pas être envoyé + } + + 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 = onRequest(httpOptions, withCors(async (req, res) => { try { @@ -810,6 +989,12 @@ exports.updateUser = onRequest(httpOptions, withCors(async (req, res) => { await db.collection('users').doc(userId).update(filteredData); } else { delete data.uid; + + // Convertir le role string en DocumentReference si présent + if (data.role && typeof data.role === 'string') { + data.role = db.collection('roles').doc(data.role); + } + await db.collection('users').doc(userId).update(data); } @@ -820,6 +1005,49 @@ exports.updateUser = onRequest(httpOptions, withCors(async (req, res) => { } })); +// Supprimer un utilisateur +exports.deleteUser = onRequest(httpOptions, withCors(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; + } + + // Empêcher un admin de se supprimer lui-même + if (decodedToken.uid === userId) { + res.status(400).json({ error: 'Cannot delete your own account' }); + return; + } + + // Supprimer le document utilisateur dans Firestore + await db.collection('users').doc(userId).delete(); + + // Optionnel: Supprimer l'utilisateur de Firebase Auth + // Note: Cela nécessite le SDK Admin et des privilèges élevés + try { + await admin.auth().deleteUser(userId); + } catch (authError) { + logger.warn(`Could not delete user from Auth: ${authError.message}`); + // On continue même si la suppression Auth échoue + } + + res.status(200).json({ message: 'User deleted successfully' }); + } catch (error) { + logger.error("Error deleting user:", error); + res.status(500).json({ error: error.message }); + } +})); + // ============================================================================ // EQUIPMENT STATUS - Batch Update // ============================================================================ @@ -946,7 +1174,15 @@ exports.updateEventEquipment = onRequest(httpOptions, withCors(async (req, res) const eventData = eventDoc.data(); const isAdminUser = await auth.hasPermission(decodedToken.uid, 'edit_event'); - const isAssigned = eventData.workforce?.some(ref => ref.path.endsWith(decodedToken.uid)); + + // Vérifier si l'utilisateur est assigné en vérifiant workforce de manière sécurisée + let isAssigned = false; + if (eventData.workforce && Array.isArray(eventData.workforce)) { + isAssigned = eventData.workforce.some(ref => { + if (!ref || !ref.path) return false; + return ref.path.endsWith(decodedToken.uid) || ref.path === `/users/${decodedToken.uid}`; + }); + } if (!isAssigned && !isAdminUser) { res.status(403).json({ error: 'Forbidden: Not assigned to this event' }); @@ -1573,148 +1809,6 @@ exports.getUser = onCall(async (request) => { } }); -// ============================================================================ -// USER MANAGEMENT - Delete & Update -// ============================================================================ - -/** - * Supprime un utilisateur (Auth + Firestore) - * Permissions: 'delete_user' OU propriétaire - */ -exports.deleteUser = onCall(async (request) => { - const { auth, data } = request; - - if (!auth) { - throw new Error("Unauthorized: Authentication required"); - } - - const { userId } = data; - if (!userId) { - throw new Error("userId is required"); - } - - try { - // Vérifier les permissions - const callerDoc = await db.collection("users").doc(auth.uid).get(); - const callerData = callerDoc.data(); - - if (!callerData) { - throw new Error("Caller user not found"); - } - - // Vérifier si l'utilisateur a la permission delete_user - let canDelete = false; - if (callerData.role) { - const roleDoc = await callerData.role.get(); - const roleData = roleDoc.data(); - canDelete = roleData?.permissions?.includes("delete_user") || false; - } - - // Ou si c'est le propriétaire (mais on ne peut pas se supprimer soi-même) - if (userId === auth.uid) { - throw new Error("Cannot delete your own account"); - } - - if (!canDelete) { - throw new Error("Unauthorized: Missing delete_user permission"); - } - - // Supprimer de Firebase Auth - try { - await admin.auth().deleteUser(userId); - } catch (authError) { - logger.warn(`Could not delete user from Auth: ${authError.message}`); - // Continuer même si Auth échoue (l'utilisateur peut ne plus exister dans Auth) - } - - // Supprimer de Firestore - await db.collection("users").doc(userId).delete(); - - logger.info(`User ${userId} deleted by ${auth.uid}`); - return { success: true, message: "User deleted successfully" }; - } catch (error) { - logger.error("Error deleting user:", error); - throw new Error(error.message || "Failed to delete user"); - } -}); - -/** - * Met à jour un utilisateur - * Permissions: 'edit_user' OU propriétaire (modifications limitées) - */ -exports.updateUser = onCall(async (request) => { - const { auth, data } = request; - - if (!auth) { - throw new Error("Unauthorized: Authentication required"); - } - - const { userId, userData } = data; - if (!userId || !userData) { - throw new Error("userId and userData are required"); - } - - try { - // Vérifier les permissions - const callerDoc = await db.collection("users").doc(auth.uid).get(); - const callerData = callerDoc.data(); - - if (!callerData) { - throw new Error("Caller user not found"); - } - - let canEditAll = false; - if (callerData.role) { - const roleDoc = await callerData.role.get(); - const roleData = roleDoc.data(); - canEditAll = roleData?.permissions?.includes("edit_user") || false; - } - - const isOwner = userId === auth.uid; - - // Si pas de permission edit_user et pas propriétaire, refuser - if (!canEditAll && !isOwner) { - throw new Error("Unauthorized: Missing edit_user permission"); - } - - // Préparer les données à mettre à jour - const updateData = { - firstName: userData.firstName, - lastName: userData.lastName, - email: userData.email, - phoneNumber: userData.phoneNumber || "", - }; - - // Seuls ceux avec edit_user peuvent changer le rôle - if (userData.role) { - if (!canEditAll) { - throw new Error("Unauthorized: Cannot change role without edit_user permission"); - } - // Créer la référence au rôle - updateData.role = db.collection("roles").doc(userData.role); - } - - // Mettre à jour Firestore - await db.collection("users").doc(userId).update(updateData); - - // Mettre à jour Firebase Auth si email a changé (seulement avec edit_user) - if (userData.email && canEditAll) { - try { - await admin.auth().updateUser(userId, { - email: userData.email, - }); - } catch (authError) { - logger.warn(`Could not update email in Auth: ${authError.message}`); - } - } - - logger.info(`User ${userId} updated by ${auth.uid}`); - return { success: true, message: "User updated successfully" }; - } catch (error) { - logger.error("Error updating user:", error); - throw new Error(error.message || "Failed to update user"); - } -}); // ============================================================================ // EQUIPMENT AVAILABILITY - Vérification de disponibilité @@ -2264,3 +2358,438 @@ exports.deleteMaintenance = onRequest(httpOptions, withCors(async (req, res) => res.status(500).json({ error: error.message }); } })); + +// ============================================================================ +// EVENT PREPARATION - Validation des étapes de préparation +// ============================================================================ + +// Helper: Mettre à jour le statut d'un équipement +async function updateEquipmentStatus(equipmentId, status) { + try { + const doc = await db.collection('equipments').doc(equipmentId).get(); + if (!doc.exists) { + logger.warn(`Equipment ${equipmentId} does not exist, skipping status update`); + return; + } + + await db.collection('equipments').doc(equipmentId).update({ + status: status, + updatedAt: admin.firestore.Timestamp.now(), + }); + } catch (error) { + logger.error(`Error updating equipment status for ${equipmentId}:`, error); + } +} + +// Valider un équipement individuel en préparation +exports.validateEquipmentPreparation = onRequest(httpOptions, withCors(async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); + if (!canManage) { + res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); + return; + } + + const { eventId, equipmentId } = req.body.data; + if (!eventId || !equipmentId) { + res.status(400).json({ error: 'eventId and equipmentId are required' }); + return; + } + + const eventDoc = await db.collection('events').doc(eventId).get(); + if (!eventDoc.exists) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + const eventData = eventDoc.data(); + const assignedEquipment = eventData.assignedEquipment || []; + + // Mettre à jour le statut de l'équipement + const updatedEquipment = assignedEquipment.map(eq => { + if (eq.equipmentId === equipmentId) { + return { ...eq, isPrepared: true }; + } + return eq; + }); + + // Vérifier si tous sont préparés + const allPrepared = updatedEquipment.every(eq => eq.isPrepared); + + const updateData = { + assignedEquipment: updatedEquipment, + preparationStatus: allPrepared ? 'completed' : 'inProgress', + }; + + await db.collection('events').doc(eventId).update(updateData); + + res.status(200).json({ success: true, allPrepared }); + } catch (error) { + logger.error("Error validating equipment preparation:", error); + res.status(500).json({ error: error.message }); + } +})); + +// Valider tous les équipements en préparation +exports.validateAllPreparation = onRequest(httpOptions, withCors(async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); + if (!canManage) { + res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); + return; + } + + const { eventId } = req.body.data; + if (!eventId) { + res.status(400).json({ error: 'eventId is required' }); + return; + } + + const eventDoc = await db.collection('events').doc(eventId).get(); + if (!eventDoc.exists) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + const eventData = eventDoc.data(); + const assignedEquipment = eventData.assignedEquipment || []; + + // Marquer tous comme préparés + const updatedEquipment = assignedEquipment.map(eq => ({ + ...eq, + isPrepared: true, + })); + + await db.collection('events').doc(eventId).update({ + assignedEquipment: updatedEquipment, + preparationStatus: 'completed', + }); + + // Mettre à jour le statut des équipements à "inUse" + for (const equipment of assignedEquipment) { + await updateEquipmentStatus(equipment.equipmentId, 'inUse'); + } + + res.status(200).json({ success: true }); + } catch (error) { + logger.error("Error validating all preparation:", error); + res.status(500).json({ error: error.message }); + } +})); + +// Valider un équipement individuel pour le chargement +exports.validateEquipmentLoading = onRequest(httpOptions, withCors(async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); + if (!canManage) { + res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); + return; + } + + const { eventId, equipmentId } = req.body.data; + if (!eventId || !equipmentId) { + res.status(400).json({ error: 'eventId and equipmentId are required' }); + return; + } + + const eventDoc = await db.collection('events').doc(eventId).get(); + if (!eventDoc.exists) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + const eventData = eventDoc.data(); + const assignedEquipment = eventData.assignedEquipment || []; + + const updatedEquipment = assignedEquipment.map(eq => { + if (eq.equipmentId === equipmentId) { + return { ...eq, isLoaded: true }; + } + return eq; + }); + + const allLoaded = updatedEquipment.every(eq => eq.isLoaded); + + const updateData = { + assignedEquipment: updatedEquipment, + loadingStatus: allLoaded ? 'completed' : 'inProgress', + }; + + await db.collection('events').doc(eventId).update(updateData); + + res.status(200).json({ success: true, allLoaded }); + } catch (error) { + logger.error("Error validating equipment loading:", error); + res.status(500).json({ error: error.message }); + } +})); + +// Valider tous les équipements pour le chargement +exports.validateAllLoading = onRequest(httpOptions, withCors(async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); + if (!canManage) { + res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); + return; + } + + const { eventId } = req.body.data; + if (!eventId) { + res.status(400).json({ error: 'eventId is required' }); + return; + } + + const eventDoc = await db.collection('events').doc(eventId).get(); + if (!eventDoc.exists) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + const eventData = eventDoc.data(); + const assignedEquipment = eventData.assignedEquipment || []; + + const updatedEquipment = assignedEquipment.map(eq => ({ + ...eq, + isLoaded: true, + })); + + await db.collection('events').doc(eventId).update({ + assignedEquipment: updatedEquipment, + loadingStatus: 'completed', + }); + + res.status(200).json({ success: true }); + } catch (error) { + logger.error("Error validating all loading:", error); + res.status(500).json({ error: error.message }); + } +})); + +// Valider un équipement individuel pour le déchargement +exports.validateEquipmentUnloading = onRequest(httpOptions, withCors(async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); + if (!canManage) { + res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); + return; + } + + const { eventId, equipmentId } = req.body.data; + if (!eventId || !equipmentId) { + res.status(400).json({ error: 'eventId and equipmentId are required' }); + return; + } + + const eventDoc = await db.collection('events').doc(eventId).get(); + if (!eventDoc.exists) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + const eventData = eventDoc.data(); + const assignedEquipment = eventData.assignedEquipment || []; + + const updatedEquipment = assignedEquipment.map(eq => { + if (eq.equipmentId === equipmentId) { + return { ...eq, isUnloaded: true }; + } + return eq; + }); + + const allUnloaded = updatedEquipment.every(eq => eq.isUnloaded); + + const updateData = { + assignedEquipment: updatedEquipment, + unloadingStatus: allUnloaded ? 'completed' : 'inProgress', + }; + + await db.collection('events').doc(eventId).update(updateData); + + res.status(200).json({ success: true, allUnloaded }); + } catch (error) { + logger.error("Error validating equipment unloading:", error); + res.status(500).json({ error: error.message }); + } +})); + +// Valider tous les équipements pour le déchargement +exports.validateAllUnloading = onRequest(httpOptions, withCors(async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); + if (!canManage) { + res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); + return; + } + + const { eventId } = req.body.data; + if (!eventId) { + res.status(400).json({ error: 'eventId is required' }); + return; + } + + const eventDoc = await db.collection('events').doc(eventId).get(); + if (!eventDoc.exists) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + const eventData = eventDoc.data(); + const assignedEquipment = eventData.assignedEquipment || []; + + const updatedEquipment = assignedEquipment.map(eq => ({ + ...eq, + isUnloaded: true, + })); + + await db.collection('events').doc(eventId).update({ + assignedEquipment: updatedEquipment, + unloadingStatus: 'completed', + }); + + res.status(200).json({ success: true }); + } catch (error) { + logger.error("Error validating all unloading:", error); + res.status(500).json({ error: error.message }); + } +})); + +// Valider un équipement individuel pour le retour +exports.validateEquipmentReturn = onRequest(httpOptions, withCors(async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); + if (!canManage) { + res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); + return; + } + + const { eventId, equipmentId, returnedQuantity } = req.body.data; + if (!eventId || !equipmentId) { + res.status(400).json({ error: 'eventId and equipmentId are required' }); + return; + } + + const eventDoc = await db.collection('events').doc(eventId).get(); + if (!eventDoc.exists) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + const eventData = eventDoc.data(); + const assignedEquipment = eventData.assignedEquipment || []; + + const updatedEquipment = assignedEquipment.map(eq => { + if (eq.equipmentId === equipmentId) { + return { + ...eq, + isReturned: true, + returnedQuantity: returnedQuantity !== undefined ? returnedQuantity : eq.returnedQuantity, + }; + } + return eq; + }); + + const allReturned = updatedEquipment.every(eq => eq.isReturned); + + const updateData = { + assignedEquipment: updatedEquipment, + returnStatus: allReturned ? 'completed' : 'inProgress', + }; + + await db.collection('events').doc(eventId).update(updateData); + + // Mettre à jour le stock si c'est un consommable + if (returnedQuantity !== undefined) { + const equipmentDoc = await db.collection('equipments').doc(equipmentId).get(); + if (equipmentDoc.exists) { + const equipmentData = equipmentDoc.data(); + if (equipmentData.hasQuantity) { + const currentAvailable = equipmentData.availableQuantity || 0; + await db.collection('equipments').doc(equipmentId).update({ + availableQuantity: currentAvailable + returnedQuantity, + }); + } + } + } + + res.status(200).json({ success: true, allReturned }); + } catch (error) { + logger.error("Error validating equipment return:", error); + res.status(500).json({ error: error.message }); + } +})); + +// Valider tous les retours +exports.validateAllReturn = onRequest(httpOptions, withCors(async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); + if (!canManage) { + res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); + return; + } + + const { eventId, returnedQuantities } = req.body.data; + if (!eventId) { + res.status(400).json({ error: 'eventId is required' }); + return; + } + + const eventDoc = await db.collection('events').doc(eventId).get(); + if (!eventDoc.exists) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + const eventData = eventDoc.data(); + const assignedEquipment = eventData.assignedEquipment || []; + + const updatedEquipment = assignedEquipment.map(eq => { + const returnedQty = returnedQuantities?.[eq.equipmentId] || eq.returnedQuantity || eq.quantity; + return { + ...eq, + isReturned: true, + returnedQuantity: returnedQty, + }; + }); + + await db.collection('events').doc(eventId).update({ + assignedEquipment: updatedEquipment, + returnStatus: 'completed', + }); + + // Mettre à jour le statut des équipements à "available" et gérer les stocks + for (const equipment of updatedEquipment) { + const equipmentDoc = await db.collection('equipments').doc(equipment.equipmentId).get(); + if (equipmentDoc.exists) { + const equipmentData = equipmentDoc.data(); + + // Mettre à jour le statut uniquement pour les équipements non quantifiables + if (!equipmentData.hasQuantity) { + await updateEquipmentStatus(equipment.equipmentId, 'available'); + } + + // Restaurer le stock pour les consommables + if (equipmentData.hasQuantity && equipment.returnedQuantity) { + const currentAvailable = equipmentData.availableQuantity || 0; + await db.collection('equipments').doc(equipment.equipmentId).update({ + availableQuantity: currentAvailable + equipment.returnedQuantity, + updatedAt: admin.firestore.Timestamp.now(), + }); + } + } + } + + res.status(200).json({ success: true }); + } catch (error) { + logger.error("Error validating all return:", error); + res.status(500).json({ error: error.message }); + } +})); + diff --git a/em2rp/functions/package-lock.json b/em2rp/functions/package-lock.json index df722ef..a58e0b2 100644 --- a/em2rp/functions/package-lock.json +++ b/em2rp/functions/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "functions", "dependencies": { + "axios": "^1.13.2", "firebase-admin": "^12.6.0", "firebase-functions": "^6.0.1" }, @@ -1923,8 +1924,34 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", - "optional": true + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } }, "node_modules/babel-jest": { "version": "29.7.0", @@ -2387,7 +2414,6 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", - "optional": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -2549,7 +2575,6 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=0.4.0" } @@ -2739,7 +2764,6 @@ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", - "optional": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -3342,6 +3366,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz", @@ -3725,7 +3769,6 @@ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", - "optional": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -5758,6 +5801,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/em2rp/functions/package.json b/em2rp/functions/package.json index b8816bd..6f72645 100644 --- a/em2rp/functions/package.json +++ b/em2rp/functions/package.json @@ -14,6 +14,7 @@ }, "main": "index.js", "dependencies": { + "axios": "^1.13.2", "firebase-admin": "^12.6.0", "firebase-functions": "^6.0.1" }, diff --git a/em2rp/lib/models/user_model.dart b/em2rp/lib/models/user_model.dart index 2bffff0..e9b6cf1 100644 --- a/em2rp/lib/models/user_model.dart +++ b/em2rp/lib/models/user_model.dart @@ -65,7 +65,7 @@ class UserModel { return { 'firstName': firstName, 'lastName': lastName, - 'role': FirebaseFirestore.instance.collection('roles').doc(role), + 'role': role, // Envoyer directement le string roleId au lieu de créer une DocumentReference 'profilePhotoUrl': profilePhotoUrl, 'email': email, 'phoneNumber': phoneNumber, diff --git a/em2rp/lib/providers/users_provider.dart b/em2rp/lib/providers/users_provider.dart index 0638b4e..7d03368 100644 --- a/em2rp/lib/providers/users_provider.dart +++ b/em2rp/lib/providers/users_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/api_service.dart'; @@ -81,9 +82,21 @@ class UsersProvider with ChangeNotifier { required String roleId, }) async { try { - // TODO: Implémenter via Cloud Function print('Creating user with email invite: $email'); - await fetchUsers(); // Recharger la liste + + // Appeler la Cloud Function pour créer l'utilisateur + await _dataService.createUserWithInvite( + email: email, + firstName: firstName, + lastName: lastName, + phoneNumber: phoneNumber, + roleId: roleId, + ); + + // Recharger la liste des utilisateurs + await fetchUsers(); + + print('User created successfully: $email'); } catch (e) { print('Error creating user with email invite: $e'); rethrow; @@ -92,9 +105,13 @@ class UsersProvider with ChangeNotifier { /// Réinitialisation du mot de passe Future resetPassword(String email) async { - // Firebase Auth reste OK - // await _userService.resetPassword(email); - // TODO: Implémenter via Cloud Function - print('Reset password for: $email'); + try { + // Firebase Auth reste OK (ce n'est pas Firestore) + await FirebaseAuth.instance.sendPasswordResetEmail(email: email); + print('Email de réinitialisation envoyé à $email'); + } catch (e) { + print('Error reset password: $e'); + rethrow; + } } } diff --git a/em2rp/lib/services/api_service.dart b/em2rp/lib/services/api_service.dart index 3e1b937..b5f7650 100644 --- a/em2rp/lib/services/api_service.dart +++ b/em2rp/lib/services/api_service.dart @@ -35,38 +35,110 @@ class FirebaseFunctionsApiService implements ApiService { }; } - /// Convertit récursivement les Timestamps Firestore, DocumentReference et GeoPoint en formats encodables - dynamic _convertTimestamps(dynamic value) { + /// Convertit récursivement TOUT en types JSON standards (String, num, bool, List, Map) + /// Garantit que toutes les Maps sont des Map littérales + dynamic _toJsonSafe(dynamic value) { if (value == null) return null; + // Types primitifs JSON-safe + if (value is String || value is num || value is bool) { + return value; + } + + // Types Firestore if (value is Timestamp) { - // Convertir Timestamp en ISO string return value.toDate().toIso8601String(); - } else if (value is DateTime) { - // Convertir DateTime en ISO string + } + if (value is DateTime) { return value.toIso8601String(); - } else if (value is DocumentReference) { - // Convertir DocumentReference en path string + } + if (value is DocumentReference) { return value.path; - } else if (value is GeoPoint) { - // Convertir GeoPoint en objet avec latitude et longitude - return { + } + if (value is GeoPoint) { + // Créer une Map littérale explicite + return { 'latitude': value.latitude, 'longitude': value.longitude, }; - } else if (value is Map) { - // Parcourir récursivement les Maps et créer une nouvelle Map typée - final Map result = {}; - value.forEach((key, val) { - result[key.toString()] = _convertTimestamps(val); - }); - return result; - } else if (value is List) { - // Parcourir récursivement les Lists - return value.map((item) => _convertTimestamps(item)).toList(); } - return value; + // Listes - créer une nouvelle List littérale + if (value is List) { + final result = []; + for (final item in value) { + result.add(_toJsonSafe(item)); + } + return result; + } + + // Maps - créer une nouvelle Map littérale explicite + if (value is Map) { + final result = {}; + value.forEach((k, v) { + final key = k.toString(); + final convertedValue = _toJsonSafe(v); + result[key] = convertedValue; + }); + return result; + } + + // Type non supporté - retourner en String + return value.toString(); + } + + /// Prépare les données pour jsonEncode en faisant un double passage + Map _prepareForJson(Map data) { + try { + // Premier passage : convertir tous les types Firestore + final safeData = _toJsonSafe(data); + + // Deuxième passage : encoder puis décoder pour forcer la normalisation + // Cela garantit que tout est 100% compatible JSON et élimine tous les _JsonMap + final jsonString = jsonEncode(safeData); + final decoded = jsonDecode(jsonString); + + // Force le type Map + if (decoded is Map) { + return Map.from(decoded); + } + + // Fallback - ne devrait jamais arriver + return Map.from(safeData as Map); + } catch (e) { + // Si l'encodage échoue, essayer de créer une copie profonde manuelle + print('[API] Error in _prepareForJson: $e'); + print('[API] Trying manual deep copy...'); + return _deepCopyMap(data); + } + } + + /// Copie profonde manuelle d'une Map pour éviter les _JsonMap + Map _deepCopyMap(Map source) { + final result = {}; + source.forEach((key, value) { + if (value is Map) { + result[key] = _deepCopyMap(Map.from(value)); + } else if (value is List) { + result[key] = _deepCopyList(value); + } else { + result[key] = value; + } + }); + return result; + } + + /// Copie profonde manuelle d'une List + List _deepCopyList(List source) { + return source.map((item) { + if (item is Map) { + return _deepCopyMap(Map.from(item)); + } else if (item is List) { + return _deepCopyList(item); + } else { + return item; + } + }).toList(); } @override @@ -74,26 +146,38 @@ class FirebaseFunctionsApiService implements ApiService { final url = Uri.parse('$_baseUrl/$functionName'); final headers = await _getHeaders(); - // Convertir les Timestamps avant l'envoi - final convertedData = _convertTimestamps(data) as Map; + // Préparer les données avec double passage pour éviter les _JsonMap + final preparedData = _prepareForJson(data); // Log pour débogage - print('[API] Calling $functionName with eventId: ${convertedData['eventId']}'); + print('[API] Calling $functionName with eventId: ${preparedData['eventId']}'); - final response = await http.post( - url, - headers: headers, - body: jsonEncode({'data': convertedData}), - ); + try { + // Encoder directement avec jsonEncode standard + final bodyJson = jsonEncode({'data': preparedData}); - if (response.statusCode >= 200 && response.statusCode < 300) { - final responseData = jsonDecode(response.body); - return responseData is Map ? responseData : {}; - } else { - final error = jsonDecode(response.body); + final response = await http.post( + url, + headers: headers, + body: bodyJson, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final responseData = jsonDecode(response.body); + return responseData is Map ? responseData : {}; + } else { + final error = jsonDecode(response.body); + throw ApiException( + message: error['error'] ?? 'Unknown error', + statusCode: response.statusCode, + ); + } + } catch (e) { + print('[API] Error during request: $e'); + print('[API] Error type: ${e.runtimeType}'); throw ApiException( - message: error['error'] ?? 'Unknown error', - statusCode: response.statusCode, + message: 'Error calling $functionName: $e', + statusCode: 0, ); } } @@ -124,13 +208,13 @@ class FirebaseFunctionsApiService implements ApiService { final url = Uri.parse('$_baseUrl/$endpoint'); final headers = await _getHeaders(); - // Convertir les Timestamps avant l'envoi - final convertedData = _convertTimestamps(data) as Map; + // Préparer les données avec double passage + final preparedData = _prepareForJson(data); final response = await http.post( url, headers: headers, - body: jsonEncode({'data': convertedData}), + body: jsonEncode({'data': preparedData}), ); if (response.statusCode >= 200 && response.statusCode < 300) { @@ -150,13 +234,13 @@ class FirebaseFunctionsApiService implements ApiService { final url = Uri.parse('$_baseUrl/$endpoint'); final headers = await _getHeaders(); - // Convertir les Timestamps avant l'envoi - final convertedData = _convertTimestamps(data) as Map; + // Préparer les données avec double passage + final preparedData = _prepareForJson(data); final response = await http.put( url, headers: headers, - body: jsonEncode({'data': convertedData}), + body: jsonEncode({'data': preparedData}), ); if (response.statusCode >= 200 && response.statusCode < 300) { @@ -176,13 +260,13 @@ class FirebaseFunctionsApiService implements ApiService { final url = Uri.parse('$_baseUrl/$endpoint'); final headers = await _getHeaders(); - // Convertir les Timestamps avant l'envoi si data existe - final convertedData = data != null ? _convertTimestamps(data) as Map : null; + // Préparer les données avec double passage si data existe + final preparedData = data != null ? _prepareForJson(data) : null; final response = await http.delete( url, headers: headers, - body: convertedData != null ? jsonEncode({'data': convertedData}) : null, + body: preparedData != null ? jsonEncode({'data': preparedData}) : null, ); if (response.statusCode < 200 || response.statusCode >= 300) { diff --git a/em2rp/lib/services/container_service.dart b/em2rp/lib/services/container_service.dart index bac37c6..58d0835 100644 --- a/em2rp/lib/services/container_service.dart +++ b/em2rp/lib/services/container_service.dart @@ -41,27 +41,8 @@ class ContainerService { /// Supprimer un container (via Cloud Function) Future deleteContainer(String id) async { try { - // Récupérer le container pour obtenir les équipements - final container = await getContainerById(id); - if (container != null && container.equipmentIds.isNotEmpty) { - // Retirer le container des parentBoxIds de chaque équipement - for (final equipmentId in container.equipmentIds) { - final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); - if (equipmentDoc.exists) { - final equipment = EquipmentModel.fromMap( - equipmentDoc.data() as Map, - equipmentDoc.id, - ); - final updatedParents = equipment.parentBoxIds.where((boxId) => boxId != id).toList(); - await _equipmentCollection.doc(equipmentId).update({ - 'parentBoxIds': updatedParents, - 'updatedAt': Timestamp.fromDate(DateTime.now()), - }); - } - } - } - await _apiService.call('deleteContainer', {'containerId': id}); + // Note: La Cloud Function gère maintenant la mise à jour des équipements } catch (e) { print('Error deleting container: $e'); rethrow; diff --git a/em2rp/lib/services/data_service.dart b/em2rp/lib/services/data_service.dart index 70bc391..7e1adad 100644 --- a/em2rp/lib/services/data_service.dart +++ b/em2rp/lib/services/data_service.dart @@ -496,11 +496,34 @@ class DataService { /// Met à jour un utilisateur Future updateUser(String userId, Map data) async { try { - final requestData = {'userId': userId, ...data}; - await _apiService.call('updateUser', requestData); + 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> 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'); + } + } +} diff --git a/em2rp/lib/services/event_preparation_service.dart b/em2rp/lib/services/event_preparation_service.dart index 83da91c..616ac23 100644 --- a/em2rp/lib/services/event_preparation_service.dart +++ b/em2rp/lib/services/event_preparation_service.dart @@ -1,14 +1,14 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/equipment_model.dart'; -import 'package:em2rp/services/equipment_service.dart'; import 'package:em2rp/services/equipment_status_calculator.dart'; +import 'package:em2rp/services/api_service.dart'; class EventPreparationService { final FirebaseFirestore _firestore = FirebaseFirestore.instance; - final EquipmentService _equipmentService = EquipmentService(); + final ApiService _apiService = apiService; - // Collection references + // Collection references (utilisées uniquement pour les lectures) CollectionReference get _eventsCollection => _firestore.collection('events'); CollectionReference get _equipmentCollection => _firestore.collection('equipments'); @@ -17,34 +17,10 @@ class EventPreparationService { /// Valider un équipement individuel en préparation Future validateEquipmentPreparation(String eventId, String equipmentId) async { try { - final event = await _getEvent(eventId); - if (event == null) { - throw Exception('Event not found'); - } - - // Mettre à jour le statut de l'équipement dans la liste - final updatedEquipment = event.assignedEquipment.map((eq) { - if (eq.equipmentId == equipmentId) { - return eq.copyWith(isPrepared: true); - } - return eq; - }).toList(); - - // Vérifier si tous les équipements sont préparés - final allPrepared = updatedEquipment.every((eq) => eq.isPrepared); - - final updateData = { - 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - }; - - // Mettre à jour le statut selon la complétion - if (allPrepared) { - updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed); - } else { - updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.inProgress); - } - - await _eventsCollection.doc(eventId).update(updateData); + await _apiService.call('validateEquipmentPreparation', { + 'eventId': eventId, + 'equipmentId': equipmentId, + }); } catch (e) { print('Error validating equipment preparation: $e'); rethrow; @@ -54,32 +30,12 @@ class EventPreparationService { /// Valider tous les équipements en préparation Future validateAllPreparation(String eventId) async { try { - final event = await _getEvent(eventId); - if (event == null) { - throw Exception('Event not found'); - } - - // Marquer tous les équipements comme préparés - final updatedEquipment = event.assignedEquipment.map((eq) { - return eq.copyWith(isPrepared: true); - }).toList(); - - await _eventsCollection.doc(eventId).update({ - 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - 'preparationStatus': preparationStatusToString(PreparationStatus.completed), + await _apiService.call('validateAllPreparation', { + 'eventId': eventId, }); // Invalider le cache des statuts d'équipement EquipmentStatusCalculator.invalidateGlobalCache(); - - // Mettre à jour le statut des équipements à "inUse" (seulement pour les équipements qui existent) - for (var equipment in event.assignedEquipment) { - // Vérifier si l'équipement existe avant de mettre à jour son statut - final doc = await _equipmentCollection.doc(equipment.equipmentId).get(); - if (doc.exists) { - await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse); - } - } } catch (e) { print('Error validating all preparation: $e'); rethrow; @@ -128,55 +84,11 @@ class EventPreparationService { int? returnedQuantity, }) async { try { - final event = await _getEvent(eventId); - if (event == null) { - throw Exception('Event not found'); - } - - // Mettre à jour le statut de l'équipement dans la liste - final updatedEquipment = event.assignedEquipment.map((eq) { - if (eq.equipmentId == equipmentId) { - return eq.copyWith( - isReturned: true, - returnedQuantity: returnedQuantity, - ); - } - return eq; - }).toList(); - - // Vérifier si tous les équipements sont retournés - final allReturned = updatedEquipment.every((eq) => eq.isReturned); - - final updateData = { - 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - }; - - // Mettre à jour le statut selon la complétion - if (allReturned) { - updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed); - } else { - updateData['returnStatus'] = returnStatusToString(ReturnStatus.inProgress); - } - - await _eventsCollection.doc(eventId).update(updateData); - - // Mettre à jour le stock si c'est un consommable - if (returnedQuantity != null) { - final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); - if (equipmentDoc.exists) { - final equipment = EquipmentModel.fromMap( - equipmentDoc.data() as Map, - equipmentDoc.id, - ); - - if (equipment.hasQuantity) { - final currentAvailable = equipment.availableQuantity ?? 0; - await _equipmentCollection.doc(equipmentId).update({ - 'availableQuantity': currentAvailable + returnedQuantity, - }); - } - } - } + await _apiService.call('validateEquipmentReturn', { + 'eventId': eventId, + 'equipmentId': equipmentId, + if (returnedQuantity != null) 'returnedQuantity': returnedQuantity, + }); } catch (e) { print('Error validating equipment return: $e'); rethrow; @@ -189,53 +101,11 @@ class EventPreparationService { Map? returnedQuantities, ]) async { try { - final event = await _getEvent(eventId); - if (event == null) { - throw Exception('Event not found'); - } - - // Marquer tous les équipements comme retournés - final updatedEquipment = event.assignedEquipment.map((eq) { - final returnedQty = returnedQuantities?[eq.equipmentId] ?? - eq.returnedQuantity ?? - eq.quantity; - return eq.copyWith( - isReturned: true, - returnedQuantity: returnedQty, - ); - }).toList(); - - await _eventsCollection.doc(eventId).update({ - 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - 'returnStatus': returnStatusToString(ReturnStatus.completed), + await _apiService.call('validateAllReturn', { + 'eventId': eventId, + if (returnedQuantities != null) 'returnedQuantities': returnedQuantities, }); - // Mettre à jour le statut des équipements à "available" et gérer les stocks - for (var equipment in updatedEquipment) { - // Vérifier si le document existe - final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get(); - if (equipmentDoc.exists) { - final equipmentData = EquipmentModel.fromMap( - equipmentDoc.data() as Map, - equipmentDoc.id, - ); - - // Mettre à jour le statut uniquement pour les équipements non quantifiables - if (!equipmentData.hasQuantity) { - await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available); - } - - // Restaurer le stock pour les consommables - if (equipmentData.hasQuantity && equipment.returnedQuantity != null) { - final currentAvailable = equipmentData.availableQuantity ?? 0; - await _equipmentCollection.doc(equipment.equipmentId).update({ - 'availableQuantity': currentAvailable + equipment.returnedQuantity!, - 'updatedAt': Timestamp.fromDate(DateTime.now()), - }); - } - } - } - // Invalider le cache des statuts d'équipement EquipmentStatusCalculator.invalidateGlobalCache(); } catch (e) { diff --git a/em2rp/lib/services/event_preparation_service_extended.dart b/em2rp/lib/services/event_preparation_service_extended.dart index 91954e8..fbbeaf3 100644 --- a/em2rp/lib/services/event_preparation_service_extended.dart +++ b/em2rp/lib/services/event_preparation_service_extended.dart @@ -1,45 +1,21 @@ import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:em2rp/models/event_model.dart'; -import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/services/equipment_status_calculator.dart'; +import 'package:em2rp/services/api_service.dart'; /// Service étendu pour gérer les 4 étapes : Préparation, Chargement, Déchargement, Retour class EventPreparationServiceExtended { - final FirebaseFirestore _firestore = FirebaseFirestore.instance; - - CollectionReference get _eventsCollection => _firestore.collection('events'); - CollectionReference get _equipmentCollection => _firestore.collection('equipments'); + final ApiService _apiService = apiService; + // === CHARGEMENT (LOADING) === /// Valider un équipement individuel pour le chargement Future validateEquipmentLoading(String eventId, String equipmentId) async { try { - final event = await _getEvent(eventId); - if (event == null) throw Exception('Event not found'); - - final updatedEquipment = event.assignedEquipment.map((eq) { - if (eq.equipmentId == equipmentId) { - return eq.copyWith(isLoaded: true); - } - return eq; - }).toList(); - - // Vérifier si tous les équipements sont chargés - final allLoaded = updatedEquipment.every((eq) => eq.isLoaded); - - final updateData = { - 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - }; - - // Si tous sont chargés, mettre à jour le statut - if (allLoaded) { - updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed); - } else { - updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.inProgress); - } - - await _eventsCollection.doc(eventId).update(updateData); + await _apiService.call('validateEquipmentLoading', { + 'eventId': eventId, + 'equipmentId': equipmentId, + }); } catch (e) { print('Error validating equipment loading: $e'); rethrow; @@ -49,16 +25,8 @@ class EventPreparationServiceExtended { /// Valider tous les équipements pour le chargement Future validateAllLoading(String eventId) async { try { - final event = await _getEvent(eventId); - if (event == null) throw Exception('Event not found'); - - final updatedEquipment = event.assignedEquipment.map((eq) { - return eq.copyWith(isLoaded: true); - }).toList(); - - await _eventsCollection.doc(eventId).update({ - 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - 'loadingStatus': loadingStatusToString(LoadingStatus.completed), + await _apiService.call('validateAllLoading', { + 'eventId': eventId, }); // Invalider le cache des statuts d'équipement @@ -74,31 +42,10 @@ class EventPreparationServiceExtended { /// Valider un équipement individuel pour le déchargement Future validateEquipmentUnloading(String eventId, String equipmentId) async { try { - final event = await _getEvent(eventId); - if (event == null) throw Exception('Event not found'); - - final updatedEquipment = event.assignedEquipment.map((eq) { - if (eq.equipmentId == equipmentId) { - return eq.copyWith(isUnloaded: true); - } - return eq; - }).toList(); - - // Vérifier si tous les équipements sont déchargés - final allUnloaded = updatedEquipment.every((eq) => eq.isUnloaded); - - final updateData = { - 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - }; - - // Si tous sont déchargés, mettre à jour le statut - if (allUnloaded) { - updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed); - } else { - updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.inProgress); - } - - await _eventsCollection.doc(eventId).update(updateData); + await _apiService.call('validateEquipmentUnloading', { + 'eventId': eventId, + 'equipmentId': equipmentId, + }); } catch (e) { print('Error validating equipment unloading: $e'); rethrow; @@ -108,16 +55,8 @@ class EventPreparationServiceExtended { /// Valider tous les équipements pour le déchargement Future validateAllUnloading(String eventId) async { try { - final event = await _getEvent(eventId); - if (event == null) throw Exception('Event not found'); - - final updatedEquipment = event.assignedEquipment.map((eq) { - return eq.copyWith(isUnloaded: true); - }).toList(); - - await _eventsCollection.doc(eventId).update({ - 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - 'unloadingStatus': unloadingStatusToString(UnloadingStatus.completed), + await _apiService.call('validateAllUnloading', { + 'eventId': eventId, }); // Invalider le cache des statuts d'équipement @@ -133,26 +72,13 @@ class EventPreparationServiceExtended { /// Valider préparation ET chargement en même temps Future validateAllPreparationAndLoading(String eventId) async { try { - final event = await _getEvent(eventId); - if (event == null) throw Exception('Event not found'); + // Note: On pourrait créer une fonction cloud dédiée pour ça, + // mais pour l'instant on appelle les deux séquentiellement + await _apiService.call('validateAllPreparation', {'eventId': eventId}); + await _apiService.call('validateAllLoading', {'eventId': eventId}); - final updatedEquipment = event.assignedEquipment.map((eq) { - return eq.copyWith(isPrepared: true, isLoaded: true); - }).toList(); - - await _eventsCollection.doc(eventId).update({ - 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - 'preparationStatus': preparationStatusToString(PreparationStatus.completed), - 'loadingStatus': loadingStatusToString(LoadingStatus.completed), - }); - - // Mettre à jour le statut des équipements - for (var equipment in event.assignedEquipment) { - final doc = await _equipmentCollection.doc(equipment.equipmentId).get(); - if (doc.exists) { - await _updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse); - } - } + // Invalider le cache + EquipmentStatusCalculator.invalidateGlobalCache(); } catch (e) { print('Error validating all preparation and loading: $e'); rethrow; @@ -167,81 +93,20 @@ class EventPreparationServiceExtended { Map? returnedQuantities, ) async { try { - final event = await _getEvent(eventId); - if (event == null) throw Exception('Event not found'); - - final updatedEquipment = event.assignedEquipment.map((eq) { - final returnedQty = returnedQuantities?[eq.equipmentId] ?? - eq.returnedQuantity ?? - eq.quantity; - return eq.copyWith( - isUnloaded: true, - isReturned: true, - returnedQuantity: returnedQty, - ); - }).toList(); - - await _eventsCollection.doc(eventId).update({ - 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - 'unloadingStatus': unloadingStatusToString(UnloadingStatus.completed), - 'returnStatus': returnStatusToString(ReturnStatus.completed), + // Note: On pourrait créer une fonction cloud dédiée pour ça, + // mais pour l'instant on appelle les deux séquentiellement + await _apiService.call('validateAllUnloading', {'eventId': eventId}); + await _apiService.call('validateAllReturn', { + 'eventId': eventId, + if (returnedQuantities != null) 'returnedQuantities': returnedQuantities, }); - // Mettre à jour les statuts et stocks - for (var equipment in updatedEquipment) { - final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get(); - if (equipmentDoc.exists) { - final equipmentData = EquipmentModel.fromMap( - equipmentDoc.data() as Map, - equipmentDoc.id, - ); - - if (!equipmentData.hasQuantity) { - await _updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available); - } - - if (equipmentData.hasQuantity && equipment.returnedQuantity != null) { - final currentAvailable = equipmentData.availableQuantity ?? 0; - await _equipmentCollection.doc(equipment.equipmentId).update({ - 'availableQuantity': currentAvailable + equipment.returnedQuantity!, - 'updatedAt': Timestamp.fromDate(DateTime.now()), - }); - } - } - } + // Invalider le cache + EquipmentStatusCalculator.invalidateGlobalCache(); } catch (e) { print('Error validating all unloading and return: $e'); rethrow; } } - - // === HELPERS === - - Future _updateEquipmentStatus(String equipmentId, EquipmentStatus status) async { - try { - final doc = await _equipmentCollection.doc(equipmentId).get(); - if (!doc.exists) return; - - await _equipmentCollection.doc(equipmentId).update({ - 'status': equipmentStatusToString(status), - 'updatedAt': Timestamp.fromDate(DateTime.now()), - }); - } catch (e) { - print('Error updating equipment status: $e'); - } - } - - Future _getEvent(String eventId) async { - try { - final doc = await _eventsCollection.doc(eventId).get(); - if (doc.exists) { - return EventModel.fromMap(doc.data() as Map, doc.id); - } - return null; - } catch (e) { - print('Error getting event: $e'); - rethrow; - } - } } diff --git a/em2rp/lib/services/maintenance_service.dart b/em2rp/lib/services/maintenance_service.dart index 94cea6e..e3ae454 100644 --- a/em2rp/lib/services/maintenance_service.dart +++ b/em2rp/lib/services/maintenance_service.dart @@ -1,13 +1,10 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/models/maintenance_model.dart'; import 'package:em2rp/models/alert_model.dart'; -import 'package:em2rp/models/equipment_model.dart'; -import 'package:em2rp/services/equipment_service.dart'; import 'package:em2rp/services/api_service.dart'; class MaintenanceService { final FirebaseFirestore _firestore = FirebaseFirestore.instance; - final EquipmentService _equipmentService = EquipmentService(); final ApiService _apiService = apiService; // Collection references @@ -23,16 +20,7 @@ class MaintenanceService { Future createMaintenance(MaintenanceModel maintenance) async { try { await _apiService.call('createMaintenance', maintenance.toMap()); - - // Mettre à jour les équipements concernés (côté client pour l'instant) - for (String equipmentId in maintenance.equipmentIds) { - await _updateEquipmentMaintenanceList(equipmentId, maintenance.id); - - // Si la maintenance est planifiée dans les 7 prochains jours, créer une alerte - if (maintenance.scheduledDate.isBefore(DateTime.now().add(const Duration(days: 7)))) { - await _createMaintenanceAlert(equipmentId, maintenance); - } - } + // Note: La Cloud Function gère maintenant la mise à jour des équipements et la création des alertes } catch (e) { print('Error creating maintenance: $e'); rethrow; @@ -55,21 +43,10 @@ class MaintenanceService { /// Supprimer une maintenance Future deleteMaintenance(String id) async { try { - // Récupérer la maintenance pour connaître les équipements - final doc = await _maintenancesCollection.doc(id).get(); - if (doc.exists) { - final maintenance = MaintenanceModel.fromMap( - doc.data() as Map, - doc.id, - ); - - // Retirer la maintenance des équipements - for (String equipmentId in maintenance.equipmentIds) { - await _removeMaintenanceFromEquipment(equipmentId, id); - } - } - - await _maintenancesCollection.doc(id).delete(); + await _apiService.call('deleteMaintenance', { + 'maintenanceId': id, + }); + // Note: La Cloud Function gère la mise à jour des équipements } catch (e) { print('Error deleting maintenance: $e'); rethrow; @@ -236,52 +213,4 @@ class MaintenanceService { rethrow; } } - - /// Mettre à jour la liste des maintenances d'un équipement - Future _updateEquipmentMaintenanceList(String equipmentId, String maintenanceId) async { - try { - final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); - if (equipmentDoc.exists) { - final equipment = EquipmentModel.fromMap( - equipmentDoc.data() as Map, - equipmentDoc.id, - ); - - final updatedMaintenanceIds = List.from(equipment.maintenanceIds); - if (!updatedMaintenanceIds.contains(maintenanceId)) { - updatedMaintenanceIds.add(maintenanceId); - - await _equipmentCollection.doc(equipmentId).update({ - 'maintenanceIds': updatedMaintenanceIds, - }); - } - } - } catch (e) { - print('Error updating equipment maintenance list: $e'); - rethrow; - } - } - - /// Retirer une maintenance de la liste d'un équipement - Future _removeMaintenanceFromEquipment(String equipmentId, String maintenanceId) async { - try { - final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); - if (equipmentDoc.exists) { - final equipment = EquipmentModel.fromMap( - equipmentDoc.data() as Map, - equipmentDoc.id, - ); - - final updatedMaintenanceIds = List.from(equipment.maintenanceIds); - updatedMaintenanceIds.remove(maintenanceId); - - await _equipmentCollection.doc(equipmentId).update({ - 'maintenanceIds': updatedMaintenanceIds, - }); - } - } catch (e) { - print('Error removing maintenance from equipment: $e'); - rethrow; - } - } } diff --git a/em2rp/lib/services/user_service.dart b/em2rp/lib/services/user_service.dart index 782fbf1..5cac972 100644 --- a/em2rp/lib/services/user_service.dart +++ b/em2rp/lib/services/user_service.dart @@ -32,10 +32,11 @@ class UserService { /// @deprecated Utilisez API deleteUser à la place Future deleteUser(String uid) async { try { - // TODO: Créer une Cloud Function deleteUser - print("Suppression d'utilisateur non implémentée via API"); + final apiService = FirebaseFunctionsApiService(); + await apiService.call('deleteUser', {'userId': uid}); } catch (e) { print("Erreur suppression: $e"); + rethrow; } } diff --git a/em2rp/lib/views/user_management_page.dart b/em2rp/lib/views/user_management_page.dart index 350ab2f..6eb0621 100644 --- a/em2rp/lib/views/user_management_page.dart +++ b/em2rp/lib/views/user_management_page.dart @@ -91,7 +91,8 @@ class _UserManagementPageState extends State { onEdit: () => showDialog( context: context, builder: (_) => EditUserDialog(user: user)), - onDelete: () => usersProvider.deleteUser(user.uid), + onResetPassword: () => _resetPassword(context, user), + onDelete: () => _confirmDeleteUser(context, usersProvider, user), ); }, ), @@ -259,19 +260,27 @@ class _UserManagementPageState extends State { return; } try { - final newUser = UserModel( - uid: '', // Sera généré par Firebase - firstName: firstNameController.text, - lastName: lastNameController.text, - email: emailController.text, - phoneNumber: phoneController.text, - role: selectedRoleId!, - profilePhotoUrl: '', - ); await Provider.of(context, listen: false) - .createUserWithEmailInvite(email: newUser.email, firstName: newUser.firstName, lastName: newUser.lastName, phoneNumber: newUser.phoneNumber, roleId: newUser.role); - Navigator.pop(context); + .createUserWithEmailInvite( + email: emailController.text, + firstName: firstNameController.text, + lastName: lastNameController.text, + phoneNumber: phoneController.text, + roleId: selectedRoleId!, + ); + + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Utilisateur créé avec succès. Email de réinitialisation envoyé à ${emailController.text}', + ), + backgroundColor: Colors.green, + ), + ); + } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -307,4 +316,174 @@ class _UserManagementPageState extends State { ), ); } + + /// Réinitialise le mot de passe d'un utilisateur + Future _resetPassword(BuildContext context, UserModel user) async { + try { + await Provider.of(context, listen: false) + .resetPassword(user.email); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Email de réinitialisation envoyé à ${user.email}', + ), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Erreur lors de l\'envoi: ${e.toString()}', + ), + backgroundColor: Colors.red, + ), + ); + } + } + } + + /// Affiche une confirmation avant de supprimer un utilisateur + Future _confirmDeleteUser( + BuildContext context, + UsersProvider usersProvider, + UserModel user, + ) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: Row( + children: [ + const Icon(Icons.warning, color: Colors.orange), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Confirmer la suppression', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Êtes-vous sûr de vouloir supprimer cet utilisateur ?', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.person, size: 20, color: AppColors.noir), + const SizedBox(width: 8), + Expanded( + child: Text( + '${user.firstName} ${user.lastName}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.email, size: 20, color: AppColors.gris), + const SizedBox(width: 8), + Expanded( + child: Text( + user.email, + style: TextStyle(color: Colors.grey[700]), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + 'Cette action est irréversible. L\'utilisateur sera supprimé et désattribué de tous les événements liés', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.red[700], + fontStyle: FontStyle.italic, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + child: const Text( + 'Annuler', + style: TextStyle(color: AppColors.gris), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Supprimer', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + try { + await usersProvider.deleteUser(user.uid); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Utilisateur ${user.firstName} ${user.lastName} supprimé avec succès', + ), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Erreur lors de la suppression: ${e.toString()}', + ), + backgroundColor: Colors.red, + ), + ); + } + } + } + } } diff --git a/em2rp/lib/views/widgets/user_management/user_card.dart b/em2rp/lib/views/widgets/user_management/user_card.dart index fcffe53..24d6b06 100644 --- a/em2rp/lib/views/widgets/user_management/user_card.dart +++ b/em2rp/lib/views/widgets/user_management/user_card.dart @@ -6,6 +6,7 @@ class UserCard extends StatefulWidget { final UserModel user; final VoidCallback onEdit; final VoidCallback onDelete; + final VoidCallback onResetPassword; static const double _desktopMaxWidth = 280; @@ -14,6 +15,7 @@ class UserCard extends StatefulWidget { required this.user, required this.onEdit, required this.onDelete, + required this.onResetPassword, }); @override @@ -128,6 +130,17 @@ class _UserCardState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ + IconButton( + icon: const Icon(Icons.lock_reset, size: 20), + onPressed: widget.onResetPassword, + color: AppColors.noir, + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + tooltip: 'Réinitialiser le mot de passe', + ), IconButton( icon: const Icon(Icons.edit, size: 20), onPressed: widget.onEdit, @@ -205,6 +218,14 @@ class _UserCardState extends State { ? Column( mainAxisSize: MainAxisSize.min, children: [ + _buildButton( + icon: Icons.lock_reset, + label: "Réinit. MDP", + onPressed: widget.onResetPassword, + color: AppColors.noir, + isNarrow: true, + ), + const SizedBox(height: 4), _buildButton( icon: Icons.edit, label: "Modifier", @@ -225,20 +246,34 @@ class _UserCardState extends State { : Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildButton( - icon: Icons.edit, - label: "Modifier", - onPressed: widget.onEdit, - color: AppColors.rouge, - isNarrow: false, + Expanded( + child: _buildButton( + icon: Icons.lock_reset, + label: "Réinit.", + onPressed: widget.onResetPassword, + color: AppColors.noir, + isNarrow: false, + ), ), - const SizedBox(width: 8), - _buildButton( - icon: Icons.delete, - label: "Supprimer", - onPressed: widget.onDelete, - color: AppColors.gris, - isNarrow: false, + const SizedBox(width: 4), + Expanded( + child: _buildButton( + icon: Icons.edit, + label: "Edit", + onPressed: widget.onEdit, + color: AppColors.rouge, + isNarrow: false, + ), + ), + const SizedBox(width: 4), + Expanded( + child: _buildButton( + icon: Icons.delete, + label: "Suppr", + onPressed: widget.onDelete, + color: AppColors.gris, + isNarrow: false, + ), ), ], ),