feat: implement equipment and container loading rollback functionality with corresponding backend cloud functions

This commit is contained in:
ElPoyo
2026-05-27 22:04:46 +02:00
parent 64a9fe382a
commit faff06e4df
15 changed files with 660 additions and 514 deletions
+3
View File
@@ -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.
## 27/05/2026
Ajout de la fonction retour en arriere pour la validation du chargement des equipements et des conteneurs.
## 26/05/2026 ## 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. Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.
+20 -51
View File
@@ -1,60 +1,22 @@
const {onRequest} = 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 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");
// Configuration CORS
const setCorsHeaders = (res, req) => {
// Utiliser l'origin de la requête pour permettre les credentials
const origin = req.headers.origin || "*";
res.set("Access-Control-Allow-Origin", origin);
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
if (origin !== "*") {
res.set("Access-Control-Allow-Credentials", "true");
}
res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With");
res.set("Access-Control-Max-Age", "3600");
};
const withCors = (handler) => {
return async (req, res) => {
setCorsHeaders(res, req);
// Gérer les requêtes preflight OPTIONS immédiatement
if (req.method === "OPTIONS") {
res.status(204).send("");
return;
}
try {
await handler(req, res);
} catch (error) {
logger.error("Unhandled error:", error);
if (!res.headersSent) {
res.status(500).json({error: error.message});
}
}
};
};
/** /**
* Crée une alerte et envoie les notifications * Crée une alerte et envoie les notifications
* Gère tout le processus côté backend de A à Z * Gère tout le processus côté backend de A à Z
*/ */
exports.createAlert = onRequest({ const handler = async (request) => {
cors: false,
invoker: "public",
region: "europe-west9",
}, withCors(async (req, res) => {
try { try {
const {auth, data} = request;
// Vérifier l'authentification // Vérifier l'authentification
const decodedToken = await auth.authenticateUser(req); if (!auth) {
const data = req.body.data || req.body; throw new Error("L'utilisateur doit être authentifié");
}
const { const {
@@ -96,7 +58,7 @@ exports.createAlert = onRequest({
metadata: metadata || {}, metadata: metadata || {},
assignedTo: userIds, assignedTo: userIds,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
createdBy: decodedToken.uid, createdBy: auth.uid,
isRead: false, isRead: false,
emailSent: false, emailSent: false,
status: "ACTIVE", status: "ACTIVE",
@@ -117,17 +79,17 @@ exports.createAlert = onRequest({
}); });
} }
res.status(200).json({ return {
success: true, success: true,
alertId: alertRef.id, alertId: alertRef.id,
usersNotified: userIds.length, usersNotified: userIds.length,
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}`}); throw error;
} }
})); };
/** /**
* Détermine les utilisateurs à notifier selon le type d'alerte * Détermine les utilisateurs à notifier selon le type d'alerte
@@ -189,7 +151,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
*/ */
async function sendAlertEmails(alertId, alertData, userIds) { async function sendAlertEmails(alertId, alertData, userIds) {
const results = {}; const results = {};
const transporter = nodemailer.createTransporter(getSmtpConfig()); const transporter = nodemailer.createTransport(getSmtpConfig());
// Envoyer les emails en parallèle (batch de 5) // Envoyer les emails en parallèle (batch de 5)
const batches = []; const batches = [];
@@ -269,3 +231,10 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
} }
} }
exports.createAlert = onCall({
cors: true,
region: "europe-west9",
}, handler);
exports.handler = handler;
+101 -19
View File
@@ -190,14 +190,6 @@ exports.getEventWithDetails = onRequest(httpOptions, withCors((req, res) => {
return require("./src/events").getEventWithDetails(req, res); return require("./src/events").getEventWithDetails(req, res);
})); }));
exports.validateEquipmentPreparation = onRequest(httpOptions, withCors((req, res) => {
return require("./src/events").validateEquipmentPreparation(req, res);
}));
exports.validateAllPreparation = onRequest(httpOptions, withCors((req, res) => {
return require("./src/events").validateAllPreparation(req, res);
}));
exports.validateEquipmentLoading = onRequest(httpOptions, withCors((req, res) => { exports.validateEquipmentLoading = onRequest(httpOptions, withCors((req, res) => {
return require("./src/events").validateEquipmentLoading(req, res); return require("./src/events").validateEquipmentLoading(req, res);
})); }));
@@ -214,14 +206,6 @@ exports.validateAllUnloading = onRequest(httpOptions, withCors((req, res) => {
return require("./src/events").validateAllUnloading(req, res); return require("./src/events").validateAllUnloading(req, res);
})); }));
exports.validateEquipmentReturn = onRequest(httpOptions, withCors((req, res) => {
return require("./src/events").validateEquipmentReturn(req, res);
}));
exports.validateAllReturn = onRequest(httpOptions, withCors((req, res) => {
return require("./src/events").validateAllReturn(req, res);
}));
// ============================================================================ // ============================================================================
// MAINTENANCES // MAINTENANCES
// ============================================================================ // ============================================================================
@@ -379,15 +363,19 @@ exports.aiEquipmentProposal = onRequest(aiHttpOptions, withCors((req, res) => {
// CALLABLE EMAIL & VALIDATION (LEGACY LAZY WRAPPERS) // CALLABLE EMAIL & VALIDATION (LEGACY LAZY WRAPPERS)
// ============================================================================ // ============================================================================
exports.sendAlertEmail = onCall({region: "europe-west9", cors: true}, (request) => { exports.sendAlertEmail = onCall({region: "europe-west9", cors: true}, (request) => {
return require("./sendAlertEmail").sendAlertEmail(request); return require("./sendAlertEmail").handler(request);
}); });
exports.createAlert = onCall({region: "europe-west9", cors: true}, (request) => { exports.createAlert = onCall({region: "europe-west9", cors: true}, (request) => {
return require("./createAlert").createAlert(request); return require("./createAlert").handler(request);
}); });
exports.processEquipmentValidation = onCall({region: "europe-west9", cors: true}, (request) => { exports.processEquipmentValidation = onCall({region: "europe-west9", cors: true}, (request) => {
return require("./processEquipmentValidation").processEquipmentValidation(request); return require("./processEquipmentValidation").handler(request);
});
exports.rollbackEventStep = onCall({region: "europe-west9", cors: true}, (request) => {
return require("./rollbackEventStep").handler(request);
}); });
// ============================================================================ // ============================================================================
@@ -515,3 +503,97 @@ exports.onAlertCreated = onDocumentCreated({
logger.error("[onAlertCreated] Erreur:", error); logger.error("[onAlertCreated] Erreur:", error);
} }
}); });
exports.onEventReturnCompleted = onDocumentUpdated({
document: "events/{eventId}",
region: "europe-west9",
}, async (event) => {
const before = event.data.before.data();
const after = event.data.after.data();
const eventId = event.params.eventId;
try {
const beforeReturnStatus = (before.returnStatus || "").toString().toUpperCase();
const afterReturnStatus = (after.returnStatus || "").toString().toUpperCase();
if (afterReturnStatus === "COMPLETED" && beforeReturnStatus !== "COMPLETED") {
logger.info(`[onEventReturnCompleted] Event ${eventId} returnStatus completed. Resetting assigned equipments...`);
const eventRef = db.collection("events").doc(eventId);
await db.runTransaction(async (transaction) => {
const currentEventDoc = await transaction.get(eventRef);
if (!currentEventDoc.exists) {
logger.warn(`[onEventReturnCompleted] Event doc ${eventId} not found during transaction.`);
return;
}
const currentEventData = currentEventDoc.data();
if (currentEventData.stocksRestored === true) {
logger.info(`[onEventReturnCompleted] Stocks already restored for event ${eventId}, skipping.`);
return;
}
const assignedEquipment = currentEventData.assignedEquipment || [];
if (assignedEquipment.length === 0) {
logger.info(`[onEventReturnCompleted] No assigned equipment for event ${eventId}.`);
transaction.update(eventRef, {
stocksRestored: true,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
return;
}
// Fetch all unique equipment docs in the transaction
const equipmentIds = Array.from(new Set(assignedEquipment.map((eq) => eq.equipmentId).filter(Boolean)));
const equipmentDocsMap = {};
for (const eqId of equipmentIds) {
const eqRef = db.collection("equipments").doc(eqId);
const eqDoc = await transaction.get(eqRef);
if (eqDoc.exists) {
equipmentDocsMap[eqId] = eqDoc.data();
}
}
// Update equipment statuses and quantities
for (const eq of assignedEquipment) {
const eqId = eq.equipmentId;
const equipmentData = equipmentDocsMap[eqId];
if (!equipmentData) continue;
const hasQuantity = equipmentData.hasQuantity === true ||
equipmentData.category === "CABLE" ||
equipmentData.category === "CONSUMABLE";
const eqRef = db.collection("equipments").doc(eqId);
if (!hasQuantity) {
// Non-consumable: reset to AVAILABLE
transaction.update(eqRef, {
status: "AVAILABLE",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
logger.info(`[onEventReturnCompleted] Set status to AVAILABLE for equipment ${eqId}`);
} else if (hasQuantity && eq.quantityAtReturn !== undefined && eq.quantityAtReturn !== null) {
// Consumable: increment availableQuantity
const currentAvailable = Number(equipmentData.availableQuantity) || 0;
const returnedQty = Number(eq.quantityAtReturn) || 0;
transaction.update(eqRef, {
availableQuantity: currentAvailable + returnedQty,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
logger.info(`[onEventReturnCompleted] Restored ${returnedQty} items for consumable ${eqId} (new available: ${currentAvailable + returnedQty})`);
}
}
// Mark event as stocksRestored
transaction.update(eventRef, {
stocksRestored: true,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
});
logger.info(`[onEventReturnCompleted] Transaction completed successfully for event ${eventId}`);
}
} catch (error) {
logger.error(`[onEventReturnCompleted] Error resetting equipment statuses for event ${eventId}:`, error);
}
});
@@ -8,10 +8,7 @@ const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
* Appelée par le client lors du chargement/déchargement * Appelée par le client lors du chargement/déchargement
* Crée automatiquement les alertes nécessaires * Crée automatiquement les alertes nécessaires
*/ */
exports.processEquipmentValidation = onCall({ const handler = async (request) => {
cors: true,
region: "europe-west9",
}, async (request) => {
try { try {
// L'authentification est automatique avec onCall // L'authentification est automatique avec onCall
const {auth, data} = request; const {auth, data} = request;
@@ -140,9 +137,8 @@ exports.processEquipmentValidation = onCall({
} }
} }
// 3. Mettre à jour les équipements de l'événement // 3. Mettre à jour les équipements de l'événement (uniquement lastValidation, assignedEquipment est déjà mis à jour par le client)
await eventRef.update({ await eventRef.update({
equipment: equipmentList,
lastValidation: { lastValidation: {
type: validationType, type: validationType,
timestamp: admin.firestore.FieldValue.serverTimestamp(), timestamp: admin.firestore.FieldValue.serverTimestamp(),
@@ -461,3 +457,10 @@ function parseFirestoreDate(value) {
return null; return null;
} }
exports.processEquipmentValidation = onCall({
cors: true,
region: "europe-west9",
}, handler);
exports.handler = handler;
+185
View File
@@ -0,0 +1,185 @@
const {onCall} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
/**
* Reverts the validation progress of an event to a target step.
* Resets subsequent step statuses and validation flags of assigned equipment.
* If rolling back from a completed return, it decrements the consumable stock quantities
* that were restored during return validation.
*/
const handler = async (request) => {
try {
const {auth, data} = request;
if (!auth) {
throw new Error("L'utilisateur doit être authentifié");
}
const {eventId, targetStep} = data;
if (!eventId || !targetStep) {
throw new Error("eventId et targetStep sont requis");
}
const db = admin.firestore();
const eventRef = db.collection("events").doc(eventId);
await db.runTransaction(async (transaction) => {
const eventDoc = await transaction.get(eventRef);
if (!eventDoc.exists) {
throw new Error("Événement introuvable");
}
const event = eventDoc.data();
const assignedEquipment = event.assignedEquipment || [];
// Si le retour était complété et qu'on revient en arrière, on doit annuler la restauration des stocks
const shouldRevertStocks = event.stocksRestored === true;
if (shouldRevertStocks) {
// Charger tous les équipements uniques de l'événement pour ajuster leur stock
const equipmentIds = Array.from(new Set(assignedEquipment.map((eq) => eq.equipmentId).filter(Boolean)));
const equipmentDocsMap = {};
for (const eqId of equipmentIds) {
const eqRef = db.collection("equipments").doc(eqId);
const eqDoc = await transaction.get(eqRef);
if (eqDoc.exists) {
equipmentDocsMap[eqId] = eqDoc.data();
}
}
for (const eq of assignedEquipment) {
const eqId = eq.equipmentId;
const equipmentData = equipmentDocsMap[eqId];
if (!equipmentData) continue;
const hasQuantity = equipmentData.hasQuantity === true ||
equipmentData.category === "CABLE" ||
equipmentData.category === "CONSUMABLE";
if (hasQuantity) {
// C'est un consommable, on doit déduire la quantité qui avait été restaurée
const qtyAtRet = Number(eq.quantityAtReturn) || 0;
if (qtyAtRet > 0) {
const eqRef = db.collection("equipments").doc(eqId);
const currentAvailable = Number(equipmentData.availableQuantity) || 0;
// S'assurer de ne pas descendre en dessous de 0 (ou autoriser le négatif si stock virtuel)
const newAvailable = Math.max(0, currentAvailable - qtyAtRet);
transaction.update(eqRef, {
availableQuantity: newAvailable,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
logger.info(`[rollbackEventStep] Annulé la restauration de ${qtyAtRet} pour ${eqId}. Ancien stock: ${currentAvailable}, Nouveau stock: ${newAvailable}`);
}
}
}
}
// Préparer les nouvelles valeurs des étapes
let prepStatus = event.preparationStatus;
let loadStatus = event.loadingStatus;
let unloadStatus = event.unloadingStatus;
let retStatus = event.returnStatus;
if (targetStep === 'PREPARATION') {
prepStatus = 'IN_PROGRESS';
loadStatus = 'NOT_STARTED';
unloadStatus = 'NOT_STARTED';
retStatus = 'NOT_STARTED';
} else if (targetStep === 'LOADING') {
loadStatus = 'IN_PROGRESS';
unloadStatus = 'NOT_STARTED';
retStatus = 'NOT_STARTED';
} else if (targetStep === 'UNLOADING') {
unloadStatus = 'IN_PROGRESS';
retStatus = 'NOT_STARTED';
} else if (targetStep === 'RETURN') {
retStatus = 'IN_PROGRESS';
} else {
throw new Error("targetStep invalide. Doit être PREPARATION, LOADING, UNLOADING ou RETURN");
}
// Nettoyer les champs de validation des équipements pour les étapes annulées
const updatedEquipment = assignedEquipment.map((eq) => {
let isPrepared = eq.isPrepared;
let isMissingAtPreparation = eq.isMissingAtPreparation;
let quantityAtPreparation = eq.quantityAtPreparation;
let isLoaded = eq.isLoaded;
let isMissingAtLoading = eq.isMissingAtLoading;
let quantityAtLoading = eq.quantityAtLoading;
let isUnloaded = eq.isUnloaded;
let isMissingAtUnloading = eq.isMissingAtUnloading;
let quantityAtUnloading = eq.quantityAtUnloading;
let isReturned = eq.isReturned;
let isMissingAtReturn = eq.isMissingAtReturn;
let quantityAtReturn = eq.quantityAtReturn;
if (targetStep === 'PREPARATION') {
isLoaded = false;
isMissingAtLoading = false;
quantityAtLoading = null;
isUnloaded = false;
isMissingAtUnloading = false;
quantityAtUnloading = null;
isReturned = false;
isMissingAtReturn = false;
quantityAtReturn = null;
} else if (targetStep === 'LOADING') {
isUnloaded = false;
isMissingAtUnloading = false;
quantityAtUnloading = null;
isReturned = false;
isMissingAtReturn = false;
quantityAtReturn = null;
} else if (targetStep === 'UNLOADING') {
isReturned = false;
isMissingAtReturn = false;
quantityAtReturn = null;
}
return {
...eq,
isPrepared,
isMissingAtPreparation,
quantityAtPreparation,
isLoaded,
isMissingAtLoading,
quantityAtLoading,
isUnloaded,
isMissingAtUnloading,
quantityAtUnloading,
isReturned,
isMissingAtReturn,
quantityAtReturn,
};
});
// Mettre à jour le document de l'événement
transaction.update(eventRef, {
preparationStatus: prepStatus,
loadingStatus: loadStatus,
unloadingStatus: unloadStatus,
returnStatus: retStatus,
assignedEquipment: updatedEquipment,
stocksRestored: false,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
});
logger.info(`[rollbackEventStep] Événement ${eventId} réinitialisé avec succès à l'étape ${targetStep}`);
return {success: true};
} catch (error) {
logger.error("[rollbackEventStep] Erreur:", error);
throw error;
}
};
exports.rollbackEventStep = onCall({
cors: true,
region: "europe-west9",
}, handler);
exports.handler = handler;
+9 -5
View File
@@ -10,10 +10,7 @@ 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({ const handler = async (request) => {
region: "europe-west9",
cors: true,
}, 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é");
@@ -75,7 +72,7 @@ exports.sendAlertEmail = onCall({
); );
// Configurer le transporteur SMTP // Configurer le transporteur SMTP
const transporter = nodemailer.createTransporter(getSmtpConfig()); const transporter = nodemailer.createTransport(getSmtpConfig());
// Envoyer l'email // Envoyer l'email
const info = await transporter.sendMail({ const info = await transporter.sendMail({
@@ -263,3 +260,10 @@ async function renderTemplate(templateName, data) {
} }
} }
exports.sendAlertEmail = onCall({
region: "europe-west9",
cors: true,
}, handler);
exports.handler = handler;
+6 -6
View File
@@ -470,7 +470,7 @@ exports.findAlternativeEquipment = async (req, res) => {
const alternatives = []; const alternatives = [];
equipmentsSnapshot.docs.forEach((doc) => { equipmentsSnapshot.docs.forEach((doc) => {
const data = doc.data(); const data = doc.data();
if (!conflictingEquipmentIds.has(doc.id) && data.status === "available") { if (!conflictingEquipmentIds.has(doc.id) && data.status === "AVAILABLE") {
alternatives.push({ alternatives.push({
id: doc.id, id: doc.id,
...helpers.serializeTimestamps(data, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]), ...helpers.serializeTimestamps(data, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
@@ -560,15 +560,15 @@ exports.calculateEquipmentStatuses = async (req, res) => {
let calculatedStatus = equipmentData.status; let calculatedStatus = equipmentData.status;
// Si l'équipement est perdu ou HS, garder ce statut // Si l'équipement est perdu ou HS, garder ce statut
if (equipmentData.status === "lost" || equipmentData.status === "outOfService") { if (equipmentData.status === "LOST" || equipmentData.status === "OUT_OF_SERVICE") {
calculatedStatus = equipmentData.status; calculatedStatus = equipmentData.status;
} else if (equipmentIdsInUse.has(equipmentId)) { } else if (equipmentIdsInUse.has(equipmentId)) {
calculatedStatus = "inUse"; calculatedStatus = "IN_USE";
} else if (equipmentData.status === "maintenance" || } else if (equipmentData.status === "MAINTENANCE" ||
equipmentData.status === "rented") { equipmentData.status === "RENTED") {
calculatedStatus = equipmentData.status; calculatedStatus = equipmentData.status;
} else { } else {
calculatedStatus = "available"; calculatedStatus = "AVAILABLE";
} }
statuses[equipmentId] = calculatedStatus; statuses[equipmentId] = calculatedStatus;
-246
View File
@@ -651,121 +651,7 @@ exports.getEventWithDetails = async (req, res) => {
} }
}; };
// 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 = 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 = 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 // Valider un équipement individuel pour le chargement
exports.validateEquipmentLoading = async (req, res) => { exports.validateEquipmentLoading = async (req, res) => {
@@ -947,136 +833,4 @@ exports.validateAllUnloading = async (req, res) => {
} }
}; };
// Valider un équipement individuel pour le retour
exports.validateEquipmentReturn = 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 = 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});
}
};
+1 -1
View File
@@ -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.3'; static const String version = '1.2.4';
/// Retourne la version complète de l'application /// Retourne la version complète de l'application
static String get fullVersion => 'v$version'; static String get fullVersion => 'v$version';
@@ -1,8 +1,4 @@
import 'package:em2rp/services/equipment_status_calculator.dart';
import 'package:em2rp/services/api_service.dart';
class EventPreparationService { class EventPreparationService {
final ApiService _apiService = apiService;
/// Retourne true si l'équipement était absent du flux événementiel. /// Retourne true si l'équipement était absent du flux événementiel.
/// ///
@@ -42,35 +38,7 @@ class EventPreparationService {
); );
} }
// === PRÉPARATION ===
/// Valider un équipement individuel en préparation
Future<void> validateEquipmentPreparation(String eventId, String equipmentId) async {
try {
await _apiService.call('validateEquipmentPreparation', {
'eventId': eventId,
'equipmentId': equipmentId,
});
} catch (e) {
print('Error validating equipment preparation: $e');
rethrow;
}
}
/// Valider tous les équipements en préparation
Future<void> validateAllPreparation(String eventId) async {
try {
await _apiService.call('validateAllPreparation', {
'eventId': eventId,
});
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all preparation: $e');
rethrow;
}
}
// Ces méthodes ne sont plus utilisées et devraient être remplacées par des Cloud Functions // Ces méthodes ne sont plus utilisées et devraient être remplacées par des Cloud Functions
// si nécessaire dans le futur // si nécessaire dans le futur
@@ -85,46 +53,8 @@ class EventPreparationService {
} }
*/ */
// === RETOUR ===
/// Valider le retour d'un équipement individuel
Future<void> validateEquipmentReturn(
String eventId,
String equipmentId, {
int? returnedQuantity,
}) async {
try {
await _apiService.call('validateEquipmentReturn', {
'eventId': eventId,
'equipmentId': equipmentId,
if (returnedQuantity != null) 'returnedQuantity': returnedQuantity,
});
} catch (e) {
print('Error validating equipment return: $e');
rethrow;
}
}
/// Valider tous les retours
Future<void> validateAllReturn(
String eventId, [
Map<String, int>? returnedQuantities,
]) async {
try {
await _apiService.call('validateAllReturn', {
'eventId': eventId,
if (returnedQuantities != null) 'returnedQuantities': returnedQuantities,
});
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all return: $e');
rethrow;
}
}
/* /*
@Deprecated('Use Cloud Functions instead') @Deprecated('Use Cloud Functions instead')
Future<void> completeReturnWithMissing( Future<void> completeReturnWithMissing(
+88 -56
View File
@@ -88,22 +88,22 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
// Logique stricte : on avance étape par étape // Logique stricte : on avance étape par étape
// 1. Préparation dépôt // 1. Préparation dépôt
if (prep != PreparationStatus.completed) { if (prep != PreparationStatus.completed && prep != PreparationStatus.completedWithMissing) {
return PreparationStep.preparation; return PreparationStep.preparation;
} }
// 2. Chargement aller (après préparation complète) // 2. Chargement aller (après préparation complète)
if (loading != LoadingStatus.completed) { if (loading != LoadingStatus.completed && loading != LoadingStatus.completedWithMissing) {
return PreparationStep.loadingOutbound; return PreparationStep.loadingOutbound;
} }
// 3. Chargement retour (après chargement aller complet) // 3. Chargement retour (après chargement aller complet)
if (unloading != UnloadingStatus.completed) { if (unloading != UnloadingStatus.completed && unloading != UnloadingStatus.completedWithMissing) {
return PreparationStep.unloadingReturn; return PreparationStep.unloadingReturn;
} }
// 4. Retour dépôt (après déchargement complet) // 4. Retour dépôt (après déchargement complet)
if (returnStatus != ReturnStatus.completed) { if (returnStatus != ReturnStatus.completed && returnStatus != ReturnStatus.completedWithMissing) {
return PreparationStep.return_; return PreparationStep.return_;
} }
@@ -132,7 +132,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isCurrentStepCompleted()) { if (_isCurrentStepCompleted()) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text('Cette étape est déjà terminée'), content: Text('Cette étape est déjà terminée'),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
@@ -141,6 +141,17 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
return; return;
} }
if (!_isPreviousStepCompleted()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('L\'étape précédente n\'est pas terminée. Impossible d\'accéder à cette étape.'),
backgroundColor: Colors.red,
),
);
Navigator.of(context).pop();
return;
}
// Charger les équipements après le premier frame pour éviter setState pendant build // Charger les équipements après le premier frame pour éviter setState pendant build
_loadEquipmentAndContainers(); _loadEquipmentAndContainers();
}); });
@@ -150,13 +161,34 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
bool _isCurrentStepCompleted() { bool _isCurrentStepCompleted() {
switch (_currentStep) { switch (_currentStep) {
case PreparationStep.preparation: case PreparationStep.preparation:
return (_currentEvent.preparationStatus ?? PreparationStatus.notStarted) == PreparationStatus.completed; final status = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
return status == PreparationStatus.completed || status == PreparationStatus.completedWithMissing;
case PreparationStep.loadingOutbound: case PreparationStep.loadingOutbound:
return (_currentEvent.loadingStatus ?? LoadingStatus.notStarted) == LoadingStatus.completed; final status = _currentEvent.loadingStatus ?? LoadingStatus.notStarted;
return status == LoadingStatus.completed || status == LoadingStatus.completedWithMissing;
case PreparationStep.unloadingReturn: case PreparationStep.unloadingReturn:
return (_currentEvent.unloadingStatus ?? UnloadingStatus.notStarted) == UnloadingStatus.completed; final status = _currentEvent.unloadingStatus ?? UnloadingStatus.notStarted;
return status == UnloadingStatus.completed || status == UnloadingStatus.completedWithMissing;
case PreparationStep.return_: case PreparationStep.return_:
return (_currentEvent.returnStatus ?? ReturnStatus.notStarted) == ReturnStatus.completed; final status = _currentEvent.returnStatus ?? ReturnStatus.notStarted;
return status == ReturnStatus.completed || status == ReturnStatus.completedWithMissing;
}
}
/// Vérifie si l'étape précédente est bien complétée
bool _isPreviousStepCompleted() {
switch (_currentStep) {
case PreparationStep.preparation:
return true; // Première étape, toujours OK
case PreparationStep.loadingOutbound:
final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
return prep == PreparationStatus.completed || prep == PreparationStatus.completedWithMissing;
case PreparationStep.unloadingReturn:
final loading = _currentEvent.loadingStatus ?? LoadingStatus.notStarted;
return loading == LoadingStatus.completed || loading == LoadingStatus.completedWithMissing;
case PreparationStep.return_:
final unloading = _currentEvent.unloadingStatus ?? UnloadingStatus.notStarted;
return unloading == UnloadingStatus.completed || unloading == UnloadingStatus.completedWithMissing;
} }
} }
@@ -239,10 +271,15 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
break; break;
} }
_quantitiesAtPreparation[eq.equipmentId] = eq.quantityAtPreparation ?? eq.quantity;
_quantitiesAtLoading[eq.equipmentId] = eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
_quantitiesAtUnloading[eq.equipmentId] = eq.quantityAtUnloading ?? eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
_quantitiesAtReturn[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantityAtUnloading ?? eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
if ((_currentStep == PreparationStep.return_ || if ((_currentStep == PreparationStep.return_ ||
_currentStep == PreparationStep.unloadingReturn) && _currentStep == PreparationStep.unloadingReturn) &&
(equipmentItem?.hasQuantity ?? false)) { (equipmentItem?.hasQuantity ?? false)) {
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity; _returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantityAtUnloading ?? eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
} }
} }
@@ -418,9 +455,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
returnStatus: updateData['returnStatus'], returnStatus: updateData['returnStatus'],
); );
// Mettre à jour les statuts des équipements si nécessaire // Mettre à jour les statuts des équipements si nécessaire (uniquement pour la préparation, le retour étant géré par le trigger Firestore Cloud Function)
if (_currentStep == PreparationStep.preparation || if (_currentStep == PreparationStep.preparation) {
(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) {
await _updateEquipmentStatuses(updatedEquipment); await _updateEquipmentStatuses(updatedEquipment);
} }
@@ -505,6 +541,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
} }
Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async { Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async {
final List<String> failedUpdates = [];
for (var eq in equipment) { for (var eq in equipment) {
try { try {
final equipmentData = _equipmentCache[eq.equipmentId]; final equipmentData = _equipmentCache[eq.equipmentId];
@@ -513,7 +551,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
// Déterminer le nouveau statut // Déterminer le nouveau statut
EquipmentStatus newStatus; EquipmentStatus newStatus;
if (eq.isReturned) { if (eq.isReturned) {
newStatus = EquipmentStatus.available; // Note : Le retour est géré par le trigger Firestore Cloud Function en tâche de fond.
// On évite les conflits d'écritures client/serveur et les double-restaurations de stock.
continue;
} else if (eq.isPrepared || eq.isLoaded) { } else if (eq.isPrepared || eq.isLoaded) {
newStatus = EquipmentStatus.inUse; newStatus = EquipmentStatus.inUse;
} else { } else {
@@ -527,19 +567,22 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
status: equipmentStatusToString(newStatus), status: equipmentStatusToString(newStatus),
); );
} }
// Gérer les stocks pour les consommables
if (equipmentData.hasQuantity && eq.isReturned && eq.quantityAtReturn != null) {
final currentAvailable = equipmentData.availableQuantity ?? 0;
await _dataService.updateEquipmentStatusOnly(
equipmentId: eq.equipmentId,
availableQuantity: currentAvailable + eq.quantityAtReturn!,
);
}
} catch (e) { } catch (e) {
// Erreur silencieuse pour ne pas bloquer le processus DebugLog.error('[EventPreparationPage] Échec de la mise à jour du statut pour l\'équipement ${eq.equipmentId}', e);
failedUpdates.add(eq.equipmentId);
} }
} }
if (failedUpdates.isNotEmpty && mounted) {
final names = failedUpdates.map((id) => _equipmentCache[id]?.name ?? id).join(', ');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Attention : Échec de mise à jour du statut en base pour : $names. Le matériel a tout de même été validé pour l\'événement.'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 6),
),
);
}
} }
String _getSuccessMessage() { String _getSuccessMessage() {
@@ -895,26 +938,28 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
_quantitiesAtReturn.addAll(quantities); _quantitiesAtReturn.addAll(quantities);
break; break;
} }
// Mettre à jour `_currentEvent.assignedEquipment` pour que l'UI se reconstruise avec les bonnes valeurs
final updatedList = _currentEvent.assignedEquipment.map((eq) {
final qty = quantities[eq.equipmentId];
if (qty != null) {
switch (_currentStep) {
case PreparationStep.preparation:
return eq.copyWith(quantityAtPreparation: qty);
case PreparationStep.loadingOutbound:
return eq.copyWith(quantityAtLoading: qty);
case PreparationStep.unloadingReturn:
return eq.copyWith(quantityAtUnloading: qty);
case PreparationStep.return_:
return eq.copyWith(quantityAtReturn: qty);
}
}
return eq;
}).toList();
_currentEvent = _currentEvent.copyWith(assignedEquipment: updatedList);
} }
/// Obtenir la quantité requise selon l'étape (nouvelle logique)
int _getTargetQuantity(EventEquipment eventEquipment) {
switch (_currentStep) {
case PreparationStep.preparation:
return eventEquipment.quantity; // Quantité initiale
case PreparationStep.loadingOutbound:
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
case PreparationStep.unloadingReturn:
return eventEquipment.quantityAtLoading ??
eventEquipment.quantityAtPreparation ??
eventEquipment.quantity;
case PreparationStep.return_:
return eventEquipment.quantityAtUnloading ??
eventEquipment.quantityAtLoading ??
eventEquipment.quantityAtPreparation ??
eventEquipment.quantity;
}
}
/// Afficher un message de succès /// Afficher un message de succès
void _showSuccessFeedback(String message) { void _showSuccessFeedback(String message) {
@@ -1020,20 +1065,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
/// Mettre à jour la quantité d'un équipement à l'étape actuelle /// Mettre à jour la quantité d'un équipement à l'étape actuelle
void _updateEquipmentQuantity(String equipmentId, int newQuantity) { void _updateEquipmentQuantity(String equipmentId, int newQuantity) {
setState(() { setState(() {
switch (_currentStep) { _updateQuantitiesMap({equipmentId: newQuantity});
case PreparationStep.preparation:
_quantitiesAtPreparation[equipmentId] = newQuantity;
break;
case PreparationStep.loadingOutbound:
_quantitiesAtLoading[equipmentId] = newQuantity;
break;
case PreparationStep.unloadingReturn:
_quantitiesAtUnloading[equipmentId] = newQuantity;
break;
case PreparationStep.return_:
_quantitiesAtReturn[equipmentId] = newQuantity;
break;
}
}); });
} }
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/event_preparation_page.dart'; import 'package:em2rp/views/event_preparation_page.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
/// Boutons de préparation et retour d'événement /// Boutons de préparation et retour d'événement
@@ -52,16 +54,16 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
IconData buttonIcon; IconData buttonIcon;
bool isCompleted = false; bool isCompleted = false;
if (prep != PreparationStatus.completed) { if (prep != PreparationStatus.completed && prep != PreparationStatus.completedWithMissing) {
buttonText = 'Préparation dépôt'; buttonText = 'Préparation dépôt';
buttonIcon = Icons.inventory_2; buttonIcon = Icons.inventory_2;
} else if (loading != LoadingStatus.completed) { } else if (loading != LoadingStatus.completed && loading != LoadingStatus.completedWithMissing) {
buttonText = 'Chargement aller'; buttonText = 'Chargement aller';
buttonIcon = Icons.local_shipping; buttonIcon = Icons.local_shipping;
} else if (unloading != UnloadingStatus.completed) { } else if (unloading != UnloadingStatus.completed && unloading != UnloadingStatus.completedWithMissing) {
buttonText = 'Chargement retour'; buttonText = 'Chargement retour';
buttonIcon = Icons.unarchive; buttonIcon = Icons.unarchive;
} else if (returnStatus != ReturnStatus.completed) { } else if (returnStatus != ReturnStatus.completed && returnStatus != ReturnStatus.completedWithMissing) {
buttonText = 'Retour dépôt'; buttonText = 'Retour dépôt';
buttonIcon = Icons.assignment_return; buttonIcon = Icons.assignment_return;
} else { } else {
@@ -131,9 +133,9 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green, width: 1), border: Border.all(color: Colors.green, width: 1),
), ),
child: Row( child: const Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: const [ children: [
Icon(Icons.check_circle, color: Colors.green, size: 20), Icon(Icons.check_circle, color: Colors.green, size: 20),
SizedBox(width: 8), SizedBox(width: 8),
Text( Text(
@@ -147,9 +149,163 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
), ),
), ),
), ),
// Bouton de retour en arrière si au moins une étape est commencée/validée
if (prep != PreparationStatus.notStarted) ...[
const SizedBox(height: 8),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: OutlinedButton.icon(
onPressed: () => _showRollbackDialog(context, event),
icon: const Icon(Icons.undo, size: 18),
label: const Text('Revenir à une étape précédente'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orange[800],
side: BorderSide(color: Colors.orange[300]!),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
),
),
),
],
], ],
), ),
); );
} }
Future<void> _showRollbackDialog(BuildContext context, EventModel event) async {
final prep = event.preparationStatus ?? PreparationStatus.notStarted;
final loading = event.loadingStatus ?? LoadingStatus.notStarted;
final unloading = event.unloadingStatus ?? UnloadingStatus.notStarted;
final returnStatus = event.returnStatus ?? ReturnStatus.notStarted;
final List<Map<String, String>> steps = [];
if (prep == PreparationStatus.completed || prep == PreparationStatus.completedWithMissing) {
steps.add({'key': 'PREPARATION', 'label': 'Préparation dépôt'});
}
if (loading == LoadingStatus.completed || loading == LoadingStatus.completedWithMissing) {
steps.add({'key': 'LOADING', 'label': 'Chargement aller'});
}
if (unloading == UnloadingStatus.completed || unloading == UnloadingStatus.completedWithMissing) {
steps.add({'key': 'UNLOADING', 'label': 'Chargement retour'});
}
if (returnStatus == ReturnStatus.completed || returnStatus == ReturnStatus.completedWithMissing) {
steps.add({'key': 'RETURN', 'label': 'Retour dépôt'});
}
if (steps.isEmpty) return;
final String? selectedStep = await showDialog<String>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.undo, color: Colors.orange),
SizedBox(width: 8),
Text('Revenir en arrière'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Sélectionnez l\'étape à laquelle vous souhaitez revenir :'),
const SizedBox(height: 12),
...steps.map((step) {
return ListTile(
leading: const Icon(Icons.arrow_back, color: AppColors.rouge),
title: Text(step['label']!),
onTap: () => Navigator.of(context).pop(step['key']),
);
}),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
],
);
},
);
if (selectedStep != null && context.mounted) {
final confirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Confirmer le retour en arrière'),
content: const Text(
'Êtes-vous sûr de vouloir revenir à cette étape ?\n\n'
'Toutes les validations des étapes ultérieures seront effacées, '
'et si le retour était terminé, les stocks restaurés seront annulés.'
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
),
child: const Text('Confirmer'),
),
],
);
},
);
if (confirm == true && context.mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return const Center(child: CircularProgressIndicator());
},
);
try {
final apiService = FirebaseFunctionsApiService();
await apiService.call('rollbackEventStep', {
'eventId': event.id,
'targetStep': selectedStep,
});
if (context.mounted) {
final eventProvider = Provider.of<EventProvider>(context, listen: false);
final userProvider = Provider.of<LocalUserProvider>(context, listen: false);
if (userProvider.currentUser != null) {
await eventProvider.refreshEvents(userProvider.currentUser!.uid);
}
Navigator.of(context).pop(); // Fermer le loader
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Retour en arrière effectué avec succès'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
Navigator.of(context).pop(); // Fermer le loader
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur : $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
}
} }
@@ -12,7 +12,7 @@ enum ChecklistStep {
} }
/// Widget pour afficher un équipement dans une checklist de préparation/retour /// Widget pour afficher un équipement dans une checklist de préparation/retour
class EquipmentChecklistItem extends StatelessWidget { class EquipmentChecklistItem extends StatefulWidget {
final EquipmentModel equipment; final EquipmentModel equipment;
final EventEquipment eventEquipment; final EventEquipment eventEquipment;
final ChecklistStep step; final ChecklistStep step;
@@ -34,92 +34,120 @@ class EquipmentChecklistItem extends StatelessWidget {
this.wasMissingBefore = false, this.wasMissingBefore = false,
}); });
/// Retourne la quantité actuelle selon l'étape @override
State<EquipmentChecklistItem> createState() => _EquipmentChecklistItemState();
}
class _EquipmentChecklistItemState extends State<EquipmentChecklistItem> {
late TextEditingController _quantityController;
int _getCurrentQuantity() { int _getCurrentQuantity() {
switch (step) { switch (widget.step) {
case ChecklistStep.preparation: case ChecklistStep.preparation:
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity; return widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
case ChecklistStep.loading: case ChecklistStep.loading:
return eventEquipment.quantityAtLoading ?? eventEquipment.quantityAtPreparation ?? eventEquipment.quantity; return widget.eventEquipment.quantityAtLoading ?? widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
case ChecklistStep.unloading: case ChecklistStep.unloading:
return eventEquipment.quantityAtUnloading ?? eventEquipment.quantityAtLoading ?? eventEquipment.quantity; return widget.eventEquipment.quantityAtUnloading ?? widget.eventEquipment.quantityAtLoading ?? widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
case ChecklistStep.return_: case ChecklistStep.return_:
return eventEquipment.quantityAtReturn ?? eventEquipment.quantityAtUnloading ?? eventEquipment.quantity; return widget.eventEquipment.quantityAtReturn ?? widget.eventEquipment.quantityAtUnloading ?? widget.eventEquipment.quantityAtLoading ?? widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
} }
} }
@override
void initState() {
super.initState();
_quantityController = TextEditingController(text: _getCurrentQuantity().toString());
}
@override
void didUpdateWidget(covariant EquipmentChecklistItem oldWidget) {
super.didUpdateWidget(oldWidget);
final currentQty = _getCurrentQuantity();
final controllerQty = int.tryParse(_quantityController.text);
if (controllerQty != currentQty) {
_quantityController.text = currentQty.toString();
}
}
@override
void dispose() {
_quantityController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasQuantity = equipment.hasQuantity; final hasQuantity = widget.equipment.hasQuantity;
// Déterminer la quantité actuelle selon l'étape // Déterminer la quantité actuelle selon l'étape
final int currentQuantity = _getCurrentQuantity(); final int currentQuantity = _getCurrentQuantity();
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: isChild ? 32.0 : 0.0, // Indentation pour les enfants left: widget.isChild ? 32.0 : 0.0, // Indentation pour les enfants
top: 4.0, top: 4.0,
bottom: 4.0, bottom: 4.0,
), ),
child: Card( child: Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
elevation: isChild ? 0 : 1, // Pas d'élévation pour les enfants elevation: widget.isChild ? 0 : 1, // Pas d'élévation pour les enfants
color: wasMissingBefore color: widget.wasMissingBefore
? Colors.orange.shade50 ? Colors.orange.shade50
: (isChild ? Colors.grey.shade50 : Colors.white), : (widget.isChild ? Colors.grey.shade50 : Colors.white),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
side: BorderSide( side: BorderSide(
color: wasMissingBefore color: widget.wasMissingBefore
? Colors.orange ? Colors.orange
: (isValidated ? Colors.green : Colors.grey.shade300), : (widget.isValidated ? Colors.green : Colors.grey.shade300),
width: (isValidated || wasMissingBefore) ? 2 : 1, width: (widget.isValidated || widget.wasMissingBefore) ? 2 : 1,
), ),
), ),
child: ListTile( child: ListTile(
dense: isChild, // Plus compact pour les enfants dense: widget.isChild, // Plus compact pour les enfants
contentPadding: EdgeInsets.symmetric( contentPadding: EdgeInsets.symmetric(
horizontal: isChild ? 8.0 : 16.0, horizontal: widget.isChild ? 8.0 : 16.0,
vertical: isChild ? 4.0 : 8.0, vertical: widget.isChild ? 4.0 : 8.0,
), ),
leading: Container( leading: Container(
width: isChild ? 32 : 40, width: widget.isChild ? 32 : 40,
height: isChild ? 32 : 40, height: widget.isChild ? 32 : 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: wasMissingBefore color: widget.wasMissingBefore
? Colors.orange.shade100 ? Colors.orange.shade100
: (isValidated ? Colors.green.shade100 : Colors.grey.shade100), : (widget.isValidated ? Colors.green.shade100 : Colors.grey.shade100),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(
wasMissingBefore widget.wasMissingBefore
? Icons.warning ? Icons.warning
: (isValidated ? Icons.check_circle : Icons.radio_button_unchecked), : (widget.isValidated ? Icons.check_circle : Icons.radio_button_unchecked),
color: wasMissingBefore color: widget.wasMissingBefore
? Colors.orange ? Colors.orange
: (isValidated ? Colors.green : Colors.grey), : (widget.isValidated ? Colors.green : Colors.grey),
size: isChild ? 18 : 24, size: widget.isChild ? 18 : 24,
), ),
onPressed: onToggle, onPressed: widget.onToggle,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
), ),
), ),
title: Text( title: Text(
equipment.name, widget.equipment.name,
style: TextStyle( style: TextStyle(
fontWeight: isChild ? FontWeight.w500 : FontWeight.w600, fontWeight: widget.isChild ? FontWeight.w500 : FontWeight.w600,
fontSize: isChild ? 13 : 15, fontSize: widget.isChild ? 13 : 15,
decoration: isValidated ? TextDecoration.lineThrough : null, decoration: widget.isValidated ? TextDecoration.lineThrough : null,
color: isValidated ? Colors.grey : null, color: widget.isValidated ? Colors.grey : null,
), ),
), ),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (equipment.model != null) if (widget.equipment.model != null)
Text( Text(
equipment.model!, widget.equipment.model!,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey.shade600, color: Colors.grey.shade600,
@@ -127,12 +155,12 @@ class EquipmentChecklistItem extends StatelessWidget {
), ),
// Indicateur si manquant à l'étape précédente // Indicateur si manquant à l'étape précédente
if (wasMissingBefore) if (widget.wasMissingBefore)
Padding( Padding(
padding: const EdgeInsets.only(top: 4.0), padding: const EdgeInsets.only(top: 4.0),
child: Row( child: Row(
children: [ children: [
Icon(Icons.warning_amber, size: 14, color: Colors.orange), const Icon(Icons.warning_amber, size: 14, color: Colors.orange),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'Était manquant à l\'étape précédente', 'Était manquant à l\'étape précédente',
@@ -151,7 +179,7 @@ class EquipmentChecklistItem extends StatelessWidget {
const SizedBox(height: 6), const SizedBox(height: 6),
Row( Row(
children: [ children: [
Text( const Text(
'Quantité : ', 'Quantité : ',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
@@ -159,11 +187,11 @@ class EquipmentChecklistItem extends StatelessWidget {
color: AppColors.bleuFonce, color: AppColors.bleuFonce,
), ),
), ),
if (onQuantityChanged != null) if (widget.onQuantityChanged != null)
SizedBox( SizedBox(
width: 60, width: 60,
child: TextFormField( child: TextFormField(
initialValue: currentQuantity.toString(), controller: _quantityController,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
decoration: InputDecoration( decoration: InputDecoration(
@@ -175,14 +203,14 @@ class EquipmentChecklistItem extends StatelessWidget {
), ),
onChanged: (value) { onChanged: (value) {
final qty = int.tryParse(value) ?? currentQuantity; final qty = int.tryParse(value) ?? currentQuantity;
onQuantityChanged!(qty); widget.onQuantityChanged!(qty);
}, },
), ),
) )
else else
Text( Text(
currentQuantity.toString(), currentQuantity.toString(),
style: TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.bleuFonce, color: AppColors.bleuFonce,
@@ -193,16 +221,16 @@ class EquipmentChecklistItem extends StatelessWidget {
], ],
], ],
), ),
trailing: isValidated trailing: widget.isValidated
? Container( ? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green.shade100, color: Colors.green.shade100,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: const Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: const [ children: [
Icon(Icons.check, size: 16, color: Colors.green), Icon(Icons.check, size: 16, color: Colors.green),
SizedBox(width: 4), SizedBox(width: 4),
Text( Text(
BIN
View File
Binary file not shown.
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"version": "1.2.3", "version": "1.2.4",
"updateUrl": "https://app.em2events.fr", "updateUrl": "https://app.em2events.fr",
"forceUpdate": true, "forceUpdate": true,
"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.", "releaseNotes": "Ajout de la fonction retour en arriere pour la validation du chargement des equipements et des conteneurs.",
"timestamp": "2026-05-26T13:34:16.390Z" "timestamp": "2026-05-27T20:00:59.431Z"
} }