Compare commits
46 Commits
0551f0b9c1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cb94badafe | |||
| 30d90a05fd | |||
| 68fa2b4587 | |||
| 9e3169b225 | |||
| 671136ac4b | |||
| adb0a2e7c9 | |||
| 69a65d83f2 | |||
| cb35ddac22 | |||
| 21d7bc8b87 | |||
| 8c01a21728 | |||
| 20c44cfb8b | |||
| 0744665fe2 | |||
| 555629760d | |||
| d52d40ad74 | |||
| 1d825c0233 | |||
| 55cdd168dc | |||
| 61b74f7465 | |||
| 1bf5c8061f | |||
| e14b333a67 | |||
| 4d18956abe | |||
| d9cd251bb7 | |||
| faff06e4df | |||
| 64a9fe382a | |||
| fb740d97a3 | |||
| ea1e1335e3 | |||
| 323df01afe | |||
| 854b0a9bb0 | |||
| f8f6cfb102 | |||
| 9bc4e88e46 | |||
| f56615451e | |||
| 845b6e91d2 | |||
| 93c102012b | |||
| 6ee63ed29c | |||
| c35e633568 | |||
| 4284142b1e | |||
| 32f1718a8c | |||
| a59deb19a9 | |||
| 0bbc77ffc8 | |||
| 19d3dcef69 | |||
| 32a279e0ae | |||
| 7258509528 | |||
| 7fc28f4374 | |||
| af5ecaeee1 | |||
| eac103491f | |||
| 89ab3673c4 | |||
| 84c882ac0b |
@@ -149,3 +149,4 @@ app.*.symbols
|
||||
.python-version
|
||||
.gclient_previous_custom_vars
|
||||
.gclient_previous_sync_commits
|
||||
em2rp/lib/config/env.dart
|
||||
|
||||
@@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
|
||||
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
||||
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
||||
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
||||
version.json,1774883074073,049c47e9089dc5497475a6cf7733e11235bc9cfa30d458cc9a8eae761214c2b8
|
||||
flutter_service_worker.js,1774883173949,00cc791f6cc0d2beb4b16cc382b049268125aa6a7c5b73cd4bc89a003fc70f3a
|
||||
index.html,1774883102020,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||
flutter_bootstrap.js,1774883102005,80bbca812eb76632e250fe5c6b726db647443cbabc7f90010618e6a6f445d222
|
||||
assets/FontManifest.json,1774883170660,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||
assets/AssetManifest.bin,1774883170657,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||
assets/AssetManifest.bin.json,1774883170660,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||
assets/shaders/ink_sparkle.frag,1774883170848,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1774883173201,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||
assets/AssetManifest.json,1774883170657,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||
assets/fonts/MaterialIcons-Regular.otf,1774883173207,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c
|
||||
assets/NOTICES,1774883170660,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79
|
||||
main.dart.js,1774883168025,bc4bc60206728a982496fe5977f48e690fe8abdfd1167a9226de18fe0052cdcf
|
||||
version.json,1779802456392,7626a7c596308bd2eb1add2ed984cd6dda5d4a3f0dedb3338244d2ae45c496cf
|
||||
index.html,1779802461141,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||
flutter_service_worker.js,1779802555396,fadec2c9a1e8e16c22e332aef080b0b2aacc3998c4e260e5821b79afb9e000da
|
||||
flutter_bootstrap.js,1779802461128,ad20054b92acf16bb75fbffd65f81c63c6d3cb6d752f799230dca5f2118af783
|
||||
assets/FontManifest.json,1779802551869,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||
assets/AssetManifest.json,1779802551869,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||
assets/AssetManifest.bin.json,1779802551869,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||
assets/AssetManifest.bin,1779802551869,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||
assets/shaders/ink_sparkle.frag,1779802552052,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779802554433,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||
assets/fonts/MaterialIcons-Regular.otf,1779802554440,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
|
||||
assets/NOTICES,1779802551871,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
|
||||
main.dart.js,1779802550741,ab892e930c97940c1ea4ff33079922082c7f688047d307acad0644a78cfda2d7
|
||||
|
||||
@@ -47,3 +47,4 @@ lib/config/env.dev.dart
|
||||
functions/.env
|
||||
.env
|
||||
env.dart
|
||||
functions/.env.local
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
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
|
||||
Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.
|
||||
|
||||
## 25/05/2026
|
||||
Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.
|
||||
|
||||
## 04/05/2026
|
||||
Optimisation du lancement de l'application et amélioration de la gestion du cache.
|
||||
|
||||
## 22/04/2026
|
||||
Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement
|
||||
|
||||
## 30/03/2026
|
||||
Patch bug envoi d'alerte equipement perdu, date dans les alertes, recherche par ID d'équipement.
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ service cloud.firestore {
|
||||
allow read, write: if false;
|
||||
}
|
||||
|
||||
// Autoriser l'accès aux collections de configuration de l'application
|
||||
match /depots/{document=**} {
|
||||
allow read, write: if request.auth != null;
|
||||
}
|
||||
match /vehicles/{document=**} {
|
||||
allow read, write: if request.auth != null;
|
||||
}
|
||||
match /app_config/{document=**} {
|
||||
allow read, write: if request.auth != null;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// EXCEPTIONS OPTIONNELLES pour les listeners temps réel
|
||||
// ========================================================================
|
||||
|
||||
@@ -7,3 +7,5 @@ SMTP_PASS="aL8@Rx8xqFrNij$a"
|
||||
# URL de l'application
|
||||
APP_URL="https://app.em2events.fr"
|
||||
|
||||
GEMINI_API_KEY="AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc"
|
||||
API_MAPS="AIzaSyDt2d-T9YRmHO3-QEq1uWomdqVbJqXfO04"
|
||||
|
||||
@@ -4,7 +4,7 @@ module.exports = {
|
||||
node: true,
|
||||
},
|
||||
parserOptions: {
|
||||
"ecmaVersion": 2018,
|
||||
"ecmaVersion": 2020,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
@@ -14,6 +14,14 @@ module.exports = {
|
||||
"no-restricted-globals": ["error", "name", "length"],
|
||||
"prefer-arrow-callback": "error",
|
||||
"quotes": ["error", "double", {"allowTemplateLiterals": true}],
|
||||
"max-len": "off",
|
||||
"valid-jsdoc": "off",
|
||||
"require-jsdoc": "off",
|
||||
"guard-for-in": "off",
|
||||
"no-unused-vars": "warn",
|
||||
"brace-style": "off",
|
||||
"object-curly-spacing": "off",
|
||||
"arrow-parens": "off",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
@@ -2,3 +2,4 @@ node_modules/
|
||||
*.local
|
||||
.env
|
||||
.env.local
|
||||
serviceAccountKey.json
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +1,22 @@
|
||||
const {onRequest} = require('firebase-functions/v2/https');
|
||||
const admin = require('firebase-admin');
|
||||
const nodemailer = require('nodemailer');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require('./utils/emailTemplates');
|
||||
const auth = require('./utils/auth');
|
||||
|
||||
// Configuration CORS
|
||||
const setCorsHeaders = (res, req) => {
|
||||
// Utiliser l'origin de la requête pour permettre les credentials
|
||||
const origin = req.headers.origin || '*';
|
||||
|
||||
res.set('Access-Control-Allow-Origin', origin);
|
||||
|
||||
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
|
||||
if (origin !== '*') {
|
||||
res.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
|
||||
res.set('Access-Control-Max-Age', '3600');
|
||||
};
|
||||
|
||||
const withCors = (handler) => {
|
||||
return async (req, res) => {
|
||||
setCorsHeaders(res, req);
|
||||
// Gérer les requêtes preflight OPTIONS immédiatement
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).send('');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await handler(req, res);
|
||||
} catch (error) {
|
||||
logger.error("Unhandled error:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
const {onCall} = require("firebase-functions/v2/https");
|
||||
const admin = require("firebase-admin");
|
||||
const nodemailer = require("nodemailer");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
|
||||
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require("./utils/emailTemplates");
|
||||
|
||||
/**
|
||||
* Crée une alerte et envoie les notifications
|
||||
* Gère tout le processus côté backend de A à Z
|
||||
*/
|
||||
exports.createAlert = onRequest({
|
||||
cors: false,
|
||||
invoker: 'public',
|
||||
region: 'europe-west9'
|
||||
}, withCors(async (req, res) => {
|
||||
const handler = async (request) => {
|
||||
try {
|
||||
const {auth, data} = request;
|
||||
|
||||
// Vérifier l'authentification
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const data = req.body.data || req.body;
|
||||
if (!auth) {
|
||||
throw new Error("L'utilisateur doit être authentifié");
|
||||
}
|
||||
|
||||
|
||||
const {
|
||||
@@ -70,7 +32,7 @@ exports.createAlert = onRequest({
|
||||
|
||||
// Validation des données
|
||||
if (!type || !severity || !message) {
|
||||
res.status(400).json({error: 'type, severity et message sont requis'});
|
||||
res.status(400).json({error: "type, severity et message sont requis"});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,12 +40,12 @@ exports.createAlert = onRequest({
|
||||
const userIds = await determineTargetUsers(type, severity, eventId);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
res.status(400).json({error: 'Aucun utilisateur à notifier'});
|
||||
res.status(400).json({error: "Aucun utilisateur à notifier"});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Créer l'alerte dans Firestore
|
||||
const alertRef = admin.firestore().collection('alerts').doc();
|
||||
const alertRef = admin.firestore().collection("alerts").doc();
|
||||
const alertData = {
|
||||
id: alertRef.id,
|
||||
type,
|
||||
@@ -96,17 +58,17 @@ exports.createAlert = onRequest({
|
||||
metadata: metadata || {},
|
||||
assignedTo: userIds,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
createdBy: decodedToken.uid,
|
||||
createdBy: auth.uid,
|
||||
isRead: false,
|
||||
emailSent: false,
|
||||
status: 'ACTIVE',
|
||||
status: "ACTIVE",
|
||||
};
|
||||
|
||||
await alertRef.set(alertData);
|
||||
|
||||
// 3. Envoyer les emails si alerte critique
|
||||
let emailResults = {};
|
||||
if (severity === 'CRITICAL') {
|
||||
if (severity === "CRITICAL") {
|
||||
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
|
||||
|
||||
// Mettre à jour le statut d'envoi
|
||||
@@ -117,17 +79,17 @@ exports.createAlert = onRequest({
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
return {
|
||||
success: true,
|
||||
alertId: alertRef.id,
|
||||
usersNotified: userIds.length,
|
||||
emailsSent: Object.values(emailResults).filter((v) => v).length,
|
||||
});
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[createAlert] Erreur:', error);
|
||||
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
|
||||
logger.error("[createAlert] Erreur:", error);
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Détermine les utilisateurs à notifier selon le type d'alerte
|
||||
@@ -137,23 +99,23 @@ async function determineTargetUsers(alertType, severity, eventId) {
|
||||
const targetUserIds = new Set();
|
||||
|
||||
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
|
||||
const allUsersSnapshot = await db.collection('users').get();
|
||||
const allUsersSnapshot = await db.collection("users").get();
|
||||
|
||||
allUsersSnapshot.forEach((doc) => {
|
||||
const user = doc.data();
|
||||
if (user.role) {
|
||||
// Le rôle peut être une référence Firestore ou une string
|
||||
let rolePath = '';
|
||||
if (typeof user.role === 'string') {
|
||||
let rolePath = "";
|
||||
if (typeof user.role === "string") {
|
||||
rolePath = user.role;
|
||||
} else if (user.role.path) {
|
||||
rolePath = user.role.path;
|
||||
} else if (user.role._path && user.role._path.segments) {
|
||||
rolePath = user.role._path.segments.join('/');
|
||||
rolePath = user.role._path.segments.join("/");
|
||||
}
|
||||
|
||||
// Vérifier si c'est un admin (path = "roles/ADMIN")
|
||||
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
|
||||
if (rolePath === "roles/ADMIN" || rolePath === "ADMIN") {
|
||||
targetUserIds.add(doc.id);
|
||||
}
|
||||
}
|
||||
@@ -162,7 +124,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
|
||||
// 2. Si un événement est lié, ajouter tous les membres de la workforce
|
||||
if (eventId) {
|
||||
try {
|
||||
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||
const eventDoc = await db.collection("events").doc(eventId).get();
|
||||
|
||||
if (eventDoc.exists) {
|
||||
const event = eventDoc.data();
|
||||
@@ -177,7 +139,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
|
||||
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
|
||||
logger.error("[determineTargetUsers] Erreur récupération événement:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +151,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
|
||||
*/
|
||||
async function sendAlertEmails(alertId, alertData, userIds) {
|
||||
const results = {};
|
||||
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
||||
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||
|
||||
// Envoyer les emails en parallèle (batch de 5)
|
||||
const batches = [];
|
||||
@@ -222,7 +184,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
|
||||
const db = admin.firestore();
|
||||
|
||||
// Récupérer l'utilisateur
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
const userDoc = await db.collection("users").doc(userId).get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
return false;
|
||||
@@ -250,7 +212,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
|
||||
const templateData = await prepareTemplateData(alertData, user);
|
||||
|
||||
// Rendre le template
|
||||
const html = await renderTemplate('alert-individual', templateData);
|
||||
const html = await renderTemplate("alert-individual", templateData);
|
||||
|
||||
// Envoyer l'email
|
||||
await transporter.sendMail({
|
||||
@@ -269,3 +231,10 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
|
||||
}
|
||||
}
|
||||
|
||||
exports.createAlert = onCall({
|
||||
cors: true,
|
||||
region: "europe-west9",
|
||||
}, handler);
|
||||
|
||||
exports.handler = handler;
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* Avec système de cache dans Firebase Storage
|
||||
*/
|
||||
|
||||
const textToSpeech = require('@google-cloud/text-to-speech');
|
||||
const crypto = require('crypto');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const textToSpeech = require("@google-cloud/text-to-speech");
|
||||
const crypto = require("crypto");
|
||||
const logger = require("firebase-functions/logger");
|
||||
|
||||
/**
|
||||
* Génère un hash MD5 pour le texte (utilisé comme clé de cache)
|
||||
@@ -16,10 +16,10 @@ const logger = require('firebase-functions/logger');
|
||||
function generateCacheKey(text, voiceConfig = {}) {
|
||||
const cacheString = JSON.stringify({
|
||||
text,
|
||||
lang: voiceConfig.languageCode || 'fr-FR',
|
||||
voice: voiceConfig.name || 'fr-FR-Standard-B',
|
||||
lang: voiceConfig.languageCode || "fr-FR",
|
||||
voice: voiceConfig.name || "fr-FR-Standard-B",
|
||||
});
|
||||
return crypto.createHash('md5').update(cacheString).digest('hex');
|
||||
return crypto.createHash("md5").update(cacheString).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,21 +34,21 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
||||
try {
|
||||
// Validation du texte
|
||||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Text cannot be empty');
|
||||
throw new Error("Text cannot be empty");
|
||||
}
|
||||
|
||||
if (text.length > 5000) {
|
||||
throw new Error('Text too long (max 5000 characters)');
|
||||
throw new Error("Text too long (max 5000 characters)");
|
||||
}
|
||||
|
||||
// Configuration par défaut de la voix
|
||||
const defaultVoiceConfig = {
|
||||
languageCode: 'fr-FR',
|
||||
name: 'fr-FR-Standard-B', // Voix masculine française (Standard = gratuit)
|
||||
ssmlGender: 'MALE',
|
||||
languageCode: "fr-FR",
|
||||
name: "fr-FR-Standard-B", // Voix masculine française (Standard = gratuit)
|
||||
ssmlGender: "MALE",
|
||||
};
|
||||
|
||||
const finalVoiceConfig = { ...defaultVoiceConfig, ...voiceConfig };
|
||||
const finalVoiceConfig = {...defaultVoiceConfig, ...voiceConfig};
|
||||
|
||||
// Générer la clé de cache
|
||||
const cacheKey = generateCacheKey(text, finalVoiceConfig);
|
||||
@@ -59,11 +59,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
||||
const [exists] = await file.exists();
|
||||
|
||||
if (exists) {
|
||||
logger.info('[generateTTS] ✓ Cache HIT', { cacheKey, text: text.substring(0, 50) });
|
||||
logger.info("[generateTTS] ✓ Cache HIT", {cacheKey, text: text.substring(0, 50)});
|
||||
|
||||
// Générer une URL signée valide 7 jours
|
||||
const [url] = await file.getSignedUrl({
|
||||
action: 'read',
|
||||
action: "read",
|
||||
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('[generateTTS] ○ Cache MISS - Generating audio', {
|
||||
logger.info("[generateTTS] ○ Cache MISS - Generating audio", {
|
||||
cacheKey,
|
||||
text: text.substring(0, 50),
|
||||
voice: finalVoiceConfig.name,
|
||||
@@ -85,10 +85,10 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
||||
|
||||
// Configuration de la requête
|
||||
const request = {
|
||||
input: { text: text },
|
||||
input: {text: text},
|
||||
voice: finalVoiceConfig,
|
||||
audioConfig: {
|
||||
audioEncoding: 'MP3',
|
||||
audioEncoding: "MP3",
|
||||
speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension
|
||||
pitch: -2.0, // Voix un peu plus grave
|
||||
volumeGainDb: 0.0,
|
||||
@@ -99,17 +99,17 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
||||
const [response] = await client.synthesizeSpeech(request);
|
||||
|
||||
if (!response.audioContent) {
|
||||
throw new Error('No audio content returned from TTS API');
|
||||
throw new Error("No audio content returned from TTS API");
|
||||
}
|
||||
|
||||
logger.info('[generateTTS] ✓ Audio generated', {
|
||||
logger.info("[generateTTS] ✓ Audio generated", {
|
||||
size: response.audioContent.length,
|
||||
});
|
||||
|
||||
// Sauvegarder dans Firebase Storage
|
||||
await file.save(response.audioContent, {
|
||||
metadata: {
|
||||
contentType: 'audio/mpeg',
|
||||
contentType: "audio/mpeg",
|
||||
metadata: {
|
||||
text: text.substring(0, 100), // Premier 100 caractères pour debug
|
||||
voice: finalVoiceConfig.name,
|
||||
@@ -118,11 +118,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('[generateTTS] ✓ Audio cached', { fileName });
|
||||
logger.info("[generateTTS] ✓ Audio cached", {fileName});
|
||||
|
||||
// Générer une URL signée
|
||||
const [url] = await file.getSignedUrl({
|
||||
action: 'read',
|
||||
action: "read",
|
||||
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
||||
cacheKey,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[generateTTS] ✗ Error', {
|
||||
logger.error("[generateTTS] ✗ Error", {
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
text: text?.substring(0, 50),
|
||||
@@ -142,5 +142,5 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateTTS, generateCacheKey };
|
||||
module.exports = {generateTTS, generateCacheKey};
|
||||
|
||||
|
||||
+342
-4043
File diff suppressed because it is too large
Load Diff
@@ -2,17 +2,17 @@
|
||||
* Script de migration : Active les emails pour tous les utilisateurs existants
|
||||
* À exécuter une seule fois après le déploiement
|
||||
*/
|
||||
const admin = require('firebase-admin');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("firebase-functions/logger");
|
||||
|
||||
// AJOUTER CECI : Charger le fichier de clé
|
||||
const serviceAccount = require('./serviceAccountKey.json');
|
||||
const serviceAccount = require("./serviceAccountKey.json");
|
||||
|
||||
// Initialiser Firebase Admin avec les credentials explicites
|
||||
if (!admin.apps.length) {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici
|
||||
projectId: 'em2rp-951dc',
|
||||
projectId: "em2rp-951dc",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ const db = admin.firestore();
|
||||
* Active les notifications par email pour tous les utilisateurs existants
|
||||
*/
|
||||
async function migrateEmailPreferences() {
|
||||
console.log('=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n');
|
||||
console.log("=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n");
|
||||
|
||||
try {
|
||||
// 1. Récupérer tous les utilisateurs
|
||||
const usersSnapshot = await db.collection('users').get();
|
||||
const usersSnapshot = await db.collection("users").get();
|
||||
console.log(`✓ ${usersSnapshot.size} utilisateurs trouvés\n`);
|
||||
|
||||
// 2. Préparer les updates
|
||||
@@ -49,7 +49,7 @@ async function migrateEmailPreferences() {
|
||||
updates.push({
|
||||
ref: doc.ref,
|
||||
data: {
|
||||
'notificationPreferences.emailEnabled': true,
|
||||
"notificationPreferences.emailEnabled": true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -83,7 +83,7 @@ async function migrateEmailPreferences() {
|
||||
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
|
||||
}
|
||||
|
||||
console.log('=== FIN MIGRATION ===');
|
||||
console.log("=== FIN MIGRATION ===");
|
||||
return {
|
||||
success: true,
|
||||
total: usersSnapshot.size,
|
||||
@@ -91,7 +91,7 @@ async function migrateEmailPreferences() {
|
||||
updated: toUpdate,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ ERREUR MIGRATION:', error);
|
||||
console.error("❌ ERREUR MIGRATION:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -100,14 +100,14 @@ async function migrateEmailPreferences() {
|
||||
if (require.main === module) {
|
||||
migrateEmailPreferences()
|
||||
.then((result) => {
|
||||
console.log('\n✓ Migration réussie:', result);
|
||||
console.log("\n✓ Migration réussie:", result);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Migration échouée:', error);
|
||||
console.error("\n❌ Migration échouée:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { migrateEmailPreferences };
|
||||
module.exports = {migrateEmailPreferences};
|
||||
|
||||
|
||||
@@ -5,28 +5,28 @@
|
||||
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
|
||||
*/
|
||||
|
||||
const admin = require('firebase-admin');
|
||||
const serviceAccount = require('./serviceAccountKey.json');
|
||||
const admin = require("firebase-admin");
|
||||
const serviceAccount = require("./serviceAccountKey.json");
|
||||
|
||||
// Initialiser Firebase Admin
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount)
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
});
|
||||
|
||||
const db = admin.firestore();
|
||||
|
||||
async function migrateEquipmentIds() {
|
||||
console.log('🔧 Migration: Ajout du champ id aux équipements');
|
||||
console.log('================================================\n');
|
||||
console.log("🔧 Migration: Ajout du champ id aux équipements");
|
||||
console.log("================================================\n");
|
||||
|
||||
try {
|
||||
// Récupérer tous les équipements
|
||||
const equipmentsSnapshot = await db.collection('equipments').get();
|
||||
const equipmentsSnapshot = await db.collection("equipments").get();
|
||||
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
|
||||
|
||||
let missingIdCount = 0;
|
||||
let updatedCount = 0;
|
||||
let errorCount = 0;
|
||||
const errorCount = 0;
|
||||
const batch = db.batch();
|
||||
let batchCount = 0;
|
||||
|
||||
@@ -34,12 +34,12 @@ async function migrateEquipmentIds() {
|
||||
const data = doc.data();
|
||||
|
||||
// Vérifier si le champ 'id' est manquant ou vide
|
||||
if (!data.id || data.id === '') {
|
||||
if (!data.id || data.id === "") {
|
||||
missingIdCount++;
|
||||
console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`);
|
||||
console.log(`❌ Équipement ${doc.id} (${data.name || "Sans nom"}) : champ 'id' manquant`);
|
||||
|
||||
// Ajouter au batch
|
||||
batch.update(doc.ref, { id: doc.id });
|
||||
batch.update(doc.ref, {id: doc.id});
|
||||
batchCount++;
|
||||
updatedCount++;
|
||||
|
||||
@@ -58,25 +58,24 @@ async function migrateEquipmentIds() {
|
||||
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
|
||||
}
|
||||
|
||||
console.log('\n================================================');
|
||||
console.log('📊 RÉSUMÉ DE LA MIGRATION');
|
||||
console.log('================================================');
|
||||
console.log("\n================================================");
|
||||
console.log("📊 RÉSUMÉ DE LA MIGRATION");
|
||||
console.log("================================================");
|
||||
console.log(`Total d'équipements: ${equipmentsSnapshot.size}`);
|
||||
console.log(`Équipements avec 'id' manquant: ${missingIdCount}`);
|
||||
console.log(`Équipements mis à jour: ${updatedCount}`);
|
||||
console.log(`Erreurs: ${errorCount}`);
|
||||
console.log('================================================\n');
|
||||
console.log("================================================\n");
|
||||
|
||||
if (missingIdCount === 0) {
|
||||
console.log('✅ Tous les équipements ont déjà un champ id !');
|
||||
console.log("✅ Tous les équipements ont déjà un champ id !");
|
||||
} else if (updatedCount === missingIdCount) {
|
||||
console.log('✅ Migration terminée avec succès !');
|
||||
console.log("✅ Migration terminée avec succès !");
|
||||
} else {
|
||||
console.log('⚠️ Migration terminée avec des erreurs');
|
||||
console.log("⚠️ Migration terminée avec des erreurs");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la migration:', error);
|
||||
console.error("❌ Erreur lors de la migration:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -84,10 +83,10 @@ async function migrateEquipmentIds() {
|
||||
// Exécuter la migration
|
||||
migrateEquipmentIds()
|
||||
.then(() => {
|
||||
console.log('\n✅ Script terminé');
|
||||
console.log("\n✅ Script terminé");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('\n❌ Script échoué:', error);
|
||||
.catch((error) => {
|
||||
console.error("\n❌ Script échoué:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Generated
+19
-5
@@ -8,11 +8,12 @@
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"@google-cloud/text-to-speech": "^5.4.0",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"axios": "^1.13.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"envdot": "^0.0.3",
|
||||
"firebase-admin": "^12.6.0",
|
||||
"firebase-functions": "^7.0.3",
|
||||
"firebase-functions": "^7.2.5",
|
||||
"handlebars": "^4.7.8",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
@@ -785,6 +786,15 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/generative-ai": {
|
||||
"version": "0.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz",
|
||||
"integrity": "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||
@@ -3354,9 +3364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/firebase-functions": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz",
|
||||
"integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==",
|
||||
"version": "7.2.5",
|
||||
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz",
|
||||
"integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -3375,7 +3385,8 @@
|
||||
"peerDependencies": {
|
||||
"@apollo/server": "^5.2.0",
|
||||
"@as-integrations/express4": "^1.1.2",
|
||||
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
|
||||
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0",
|
||||
"graphql": "^16.12.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@apollo/server": {
|
||||
@@ -3383,6 +3394,9 @@
|
||||
},
|
||||
"@as-integrations/express4": {
|
||||
"optional": true
|
||||
},
|
||||
"graphql": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"@google-cloud/text-to-speech": "^5.4.0",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@mapbox/polyline": "^1.2.1",
|
||||
"axios": "^1.13.2",
|
||||
"csv-parser": "^3.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"envdot": "^0.0.3",
|
||||
"firebase-admin": "^12.6.0",
|
||||
"firebase-functions": "^7.0.3",
|
||||
"firebase-functions": "^7.2.5",
|
||||
"handlebars": "^4.7.8",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
const {onCall} = require('firebase-functions/v2/https');
|
||||
const admin = require('firebase-admin');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const nodemailer = require('nodemailer');
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||
const {onCall} = require("firebase-functions/v2/https");
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const nodemailer = require("nodemailer");
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
|
||||
/**
|
||||
* Traite la validation du matériel d'un événement
|
||||
* Appelée par le client lors du chargement/déchargement
|
||||
* Crée automatiquement les alertes nécessaires
|
||||
*/
|
||||
exports.processEquipmentValidation = onCall({
|
||||
cors: true,
|
||||
region: 'europe-west9'
|
||||
}, async (request) => {
|
||||
const handler = async (request) => {
|
||||
try {
|
||||
// L'authentification est automatique avec onCall
|
||||
const {auth, data} = request;
|
||||
|
||||
if (!auth) {
|
||||
throw new Error('L\'utilisateur doit être authentifié');
|
||||
throw new Error("L'utilisateur doit être authentifié");
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -28,22 +25,22 @@ exports.processEquipmentValidation = onCall({
|
||||
|
||||
// Validation
|
||||
if (!eventId || !equipmentList || !validationType) {
|
||||
throw new Error('eventId, equipmentList et validationType sont requis');
|
||||
throw new Error("eventId, equipmentList et validationType sont requis");
|
||||
}
|
||||
|
||||
const db = admin.firestore();
|
||||
const alerts = [];
|
||||
|
||||
// 1. Récupérer les détails de l'événement
|
||||
const eventRef = db.collection('events').doc(eventId);
|
||||
const eventRef = db.collection("events").doc(eventId);
|
||||
const eventDoc = await eventRef.get();
|
||||
|
||||
if (!eventDoc.exists) {
|
||||
throw new Error('Événement introuvable');
|
||||
throw new Error("Événement introuvable");
|
||||
}
|
||||
|
||||
const event = eventDoc.data();
|
||||
const eventName = event.Name || event.name || 'Événement inconnu';
|
||||
const eventName = event.Name || event.name || "Événement inconnu";
|
||||
const eventDate = formatEventDate(event);
|
||||
|
||||
// 2. Analyser les équipements et détecter les problèmes
|
||||
@@ -51,16 +48,16 @@ exports.processEquipmentValidation = onCall({
|
||||
const {equipmentId, status, quantity, expectedQuantity} = equipment;
|
||||
|
||||
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
|
||||
if (status === 'NOT_TAKEN') {
|
||||
if (status === "NOT_TAKEN") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cas 1: Équipement PERDU
|
||||
if (status === 'LOST') {
|
||||
if (status === "LOST") {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'LOST',
|
||||
severity: 'CRITICAL',
|
||||
title: 'Équipement perdu',
|
||||
type: "LOST",
|
||||
severity: "CRITICAL",
|
||||
title: "Équipement perdu",
|
||||
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
@@ -76,11 +73,11 @@ exports.processEquipmentValidation = onCall({
|
||||
}
|
||||
|
||||
// Cas 2: Équipement MANQUANT
|
||||
if (status === 'MISSING') {
|
||||
if (status === "MISSING") {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'EQUIPMENT_MISSING',
|
||||
severity: 'WARNING',
|
||||
title: 'Équipement manquant',
|
||||
type: "EQUIPMENT_MISSING",
|
||||
severity: "WARNING",
|
||||
title: "Équipement manquant",
|
||||
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
@@ -96,13 +93,13 @@ exports.processEquipmentValidation = onCall({
|
||||
}
|
||||
|
||||
// Cas 3: Quantité incorrecte
|
||||
const hasExpectedQuantity = typeof expectedQuantity === 'number';
|
||||
const hasActualQuantity = typeof quantity === 'number';
|
||||
const hasExpectedQuantity = typeof expectedQuantity === "number";
|
||||
const hasActualQuantity = typeof quantity === "number";
|
||||
if (hasExpectedQuantity && hasActualQuantity && quantity !== expectedQuantity) {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'QUANTITY_MISMATCH',
|
||||
severity: 'INFO',
|
||||
title: 'Quantité incorrecte',
|
||||
type: "QUANTITY_MISMATCH",
|
||||
severity: "INFO",
|
||||
title: "Quantité incorrecte",
|
||||
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
@@ -120,11 +117,11 @@ exports.processEquipmentValidation = onCall({
|
||||
}
|
||||
|
||||
// Cas 4: Équipement endommagé
|
||||
if (status === 'DAMAGED') {
|
||||
if (status === "DAMAGED") {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'DAMAGED',
|
||||
severity: 'WARNING',
|
||||
title: 'Équipement endommagé',
|
||||
type: "DAMAGED",
|
||||
severity: "WARNING",
|
||||
title: "Équipement endommagé",
|
||||
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
@@ -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({
|
||||
equipment: equipmentList,
|
||||
lastValidation: {
|
||||
type: validationType,
|
||||
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||||
@@ -151,7 +147,7 @@ exports.processEquipmentValidation = onCall({
|
||||
});
|
||||
|
||||
// 4. Envoyer les notifications pour les alertes critiques
|
||||
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
|
||||
const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL");
|
||||
if (criticalAlerts.length > 0) {
|
||||
for (const alert of criticalAlerts) {
|
||||
try {
|
||||
@@ -169,7 +165,7 @@ exports.processEquipmentValidation = onCall({
|
||||
alertIds: alerts.map((a) => a.id),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[processEquipmentValidation] Erreur:', error);
|
||||
logger.error("[processEquipmentValidation] Erreur:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -179,14 +175,14 @@ exports.processEquipmentValidation = onCall({
|
||||
*/
|
||||
async function createAlertInFirestore(alertData) {
|
||||
const db = admin.firestore();
|
||||
const alertRef = db.collection('alerts').doc();
|
||||
const alertRef = db.collection("alerts").doc();
|
||||
|
||||
const fullAlertData = {
|
||||
id: alertRef.id,
|
||||
...alertData,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
isRead: false,
|
||||
status: 'ACTIVE',
|
||||
status: "ACTIVE",
|
||||
emailSent: false,
|
||||
assignedTo: [],
|
||||
};
|
||||
@@ -206,7 +202,7 @@ async function sendAlertNotifications(alert, eventId) {
|
||||
|
||||
try {
|
||||
// 1. Récupérer TOUS les utilisateurs et leurs permissions
|
||||
const allUsersSnapshot = await db.collection('users').get();
|
||||
const allUsersSnapshot = await db.collection("users").get();
|
||||
|
||||
// Créer un map pour stocker les références de rôles à récupérer
|
||||
const roleRefs = new Map();
|
||||
@@ -219,17 +215,17 @@ async function sendAlertNotifications(alert, eventId) {
|
||||
}
|
||||
|
||||
// Extraire le chemin du rôle
|
||||
let rolePath = '';
|
||||
let roleId = '';
|
||||
let rolePath = "";
|
||||
let roleId = "";
|
||||
|
||||
if (typeof user.role === 'string') {
|
||||
if (typeof user.role === "string") {
|
||||
rolePath = user.role;
|
||||
roleId = user.role.split('/').pop();
|
||||
roleId = user.role.split("/").pop();
|
||||
} else if (user.role.path) {
|
||||
rolePath = user.role.path;
|
||||
roleId = user.role.path.split('/').pop();
|
||||
roleId = user.role.path.split("/").pop();
|
||||
} else if (user.role._path && user.role._path.segments) {
|
||||
rolePath = user.role._path.segments.join('/');
|
||||
rolePath = user.role._path.segments.join("/");
|
||||
roleId = user.role._path.segments[user.role._path.segments.length - 1];
|
||||
}
|
||||
|
||||
@@ -245,14 +241,14 @@ async function sendAlertNotifications(alert, eventId) {
|
||||
// 2. Récupérer les permissions de chaque rôle unique
|
||||
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
|
||||
try {
|
||||
const roleDoc = await db.collection('roles').doc(roleId).get();
|
||||
const roleDoc = await db.collection("roles").doc(roleId).get();
|
||||
|
||||
if (roleDoc.exists) {
|
||||
const roleData = roleDoc.data();
|
||||
const permissions = roleData.permissions || [];
|
||||
|
||||
// Vérifier si le rôle a la permission view_all_events
|
||||
if (permissions.includes('view_all_events')) {
|
||||
if (permissions.includes("view_all_events")) {
|
||||
users.forEach((userId) => {
|
||||
usersWithPermission.add(userId);
|
||||
targetUserIds.add(userId);
|
||||
@@ -266,7 +262,7 @@ async function sendAlertNotifications(alert, eventId) {
|
||||
|
||||
// 3. Ajouter la workforce de l'événement
|
||||
if (eventId) {
|
||||
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||
const eventDoc = await db.collection("events").doc(eventId).get();
|
||||
|
||||
if (eventDoc.exists) {
|
||||
const event = eventDoc.data();
|
||||
@@ -276,14 +272,14 @@ async function sendAlertNotifications(alert, eventId) {
|
||||
// Extraire l'userId selon différentes structures possibles
|
||||
let userId = null;
|
||||
|
||||
if (typeof member === 'string') {
|
||||
if (typeof member === "string") {
|
||||
userId = member;
|
||||
} else if (member.userId) {
|
||||
userId = member.userId;
|
||||
} else if (member.id) {
|
||||
userId = member.id;
|
||||
} else if (member.user) {
|
||||
if (typeof member.user === 'string') {
|
||||
if (typeof member.user === "string") {
|
||||
userId = member.user;
|
||||
} else if (member.user.id) {
|
||||
userId = member.user.id;
|
||||
@@ -300,18 +296,18 @@ async function sendAlertNotifications(alert, eventId) {
|
||||
const userIds = Array.from(targetUserIds);
|
||||
|
||||
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
|
||||
await db.collection('alerts').doc(alert.id).update({
|
||||
await db.collection("alerts").doc(alert.id).update({
|
||||
assignedTo: userIds,
|
||||
});
|
||||
|
||||
// 5. Envoyer les emails si alerte critique
|
||||
if (alert.severity === 'CRITICAL') {
|
||||
if (alert.severity === "CRITICAL") {
|
||||
await sendAlertEmails(alert, userIds);
|
||||
}
|
||||
|
||||
return userIds;
|
||||
} catch (error) {
|
||||
logger.error('[sendAlertNotifications] Erreur:', error);
|
||||
logger.error("[sendAlertNotifications] Erreur:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -321,12 +317,12 @@ async function sendAlertNotifications(alert, eventId) {
|
||||
*/
|
||||
async function sendAlertEmails(alert, userIds) {
|
||||
try {
|
||||
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
|
||||
const {renderTemplate, getEmailSubject, prepareTemplateData} = require("./utils/emailTemplates");
|
||||
const db = admin.firestore();
|
||||
|
||||
// Vérifier que EMAIL_CONFIG est disponible
|
||||
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
|
||||
logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré');
|
||||
logger.error("[sendAlertEmails] EMAIL_CONFIG non configuré");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -343,7 +339,7 @@ async function sendAlertEmails(alert, userIds) {
|
||||
const promises = batch.map(async (userId) => {
|
||||
try {
|
||||
// Récupérer l'utilisateur
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
const userDoc = await db.collection("users").doc(userId).get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
return false;
|
||||
@@ -365,13 +361,13 @@ async function sendAlertEmails(alert, userIds) {
|
||||
let html;
|
||||
try {
|
||||
const templateData = await prepareTemplateData(alert, user);
|
||||
html = await renderTemplate('alert-individual', templateData);
|
||||
html = await renderTemplate("alert-individual", templateData);
|
||||
} catch (templateError) {
|
||||
logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError);
|
||||
html = `
|
||||
<html>
|
||||
<body>
|
||||
<h2>${alert.title || 'Nouvelle alerte'}</h2>
|
||||
<h2>${alert.title || "Nouvelle alerte"}</h2>
|
||||
<p>${alert.message}</p>
|
||||
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
|
||||
</body>
|
||||
@@ -399,7 +395,7 @@ async function sendAlertEmails(alert, userIds) {
|
||||
}
|
||||
|
||||
// Mettre à jour l'alerte
|
||||
await db.collection('alerts').doc(alert.id).update({
|
||||
await db.collection("alerts").doc(alert.id).update({
|
||||
emailSent: true,
|
||||
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
emailsSentCount: successCount,
|
||||
@@ -407,7 +403,7 @@ async function sendAlertEmails(alert, userIds) {
|
||||
|
||||
return successCount;
|
||||
} catch (error) {
|
||||
logger.error('[sendAlertEmails] Erreur globale:', error);
|
||||
logger.error("[sendAlertEmails] Erreur globale:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -425,10 +421,10 @@ function formatEventDate(event) {
|
||||
const parsedDate = parseFirestoreDate(rawDate);
|
||||
const safeDate = parsedDate || new Date();
|
||||
|
||||
return safeDate.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric',
|
||||
return safeDate.toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -437,7 +433,7 @@ function parseFirestoreDate(value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value.toDate === 'function') {
|
||||
if (typeof value.toDate === "function") {
|
||||
return value.toDate();
|
||||
}
|
||||
|
||||
@@ -445,19 +441,26 @@ function parseFirestoreDate(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && typeof value.seconds === 'number') {
|
||||
if (typeof value === "object" && typeof value.seconds === "number") {
|
||||
return new Date(value.seconds * 1000);
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && typeof value._seconds === 'number') {
|
||||
if (typeof value === "object" && typeof value._seconds === "number") {
|
||||
return new Date(value._seconds * 1000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
exports.processEquipmentValidation = onCall({
|
||||
cors: true,
|
||||
region: "europe-west9",
|
||||
}, handler);
|
||||
|
||||
exports.handler = handler;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,51 +1,48 @@
|
||||
const {onCall} = require('firebase-functions/v2/https');
|
||||
const admin = require('firebase-admin');
|
||||
const nodemailer = require('nodemailer');
|
||||
const handlebars = require('handlebars');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||
const {onCall} = require("firebase-functions/v2/https");
|
||||
const admin = require("firebase-admin");
|
||||
const nodemailer = require("nodemailer");
|
||||
const handlebars = require("handlebars");
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
|
||||
|
||||
/**
|
||||
* Envoie un email d'alerte à un utilisateur
|
||||
* Appelé par le client Dart via callable function
|
||||
*/
|
||||
exports.sendAlertEmail = onCall({
|
||||
region: 'europe-west9',
|
||||
cors: true
|
||||
}, async (request) => {
|
||||
const handler = async (request) => {
|
||||
// Vérifier l'authentification
|
||||
if (!request.auth) {
|
||||
throw new Error('L\'utilisateur doit être authentifié');
|
||||
throw new Error("L'utilisateur doit être authentifié");
|
||||
}
|
||||
|
||||
const {alertId, userId, templateType} = request.data;
|
||||
|
||||
if (!alertId || !userId) {
|
||||
throw new Error('alertId et userId sont requis');
|
||||
throw new Error("alertId et userId sont requis");
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer l'alerte depuis Firestore
|
||||
const alertDoc = await admin.firestore()
|
||||
.collection('alerts')
|
||||
.collection("alerts")
|
||||
.doc(alertId)
|
||||
.get();
|
||||
|
||||
if (!alertDoc.exists) {
|
||||
throw new Error('Alerte introuvable');
|
||||
throw new Error("Alerte introuvable");
|
||||
}
|
||||
|
||||
const alert = alertDoc.data();
|
||||
|
||||
// Récupérer l'utilisateur
|
||||
const userDoc = await admin.firestore()
|
||||
.collection('users')
|
||||
.collection("users")
|
||||
.doc(userId)
|
||||
.get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
throw new Error('Utilisateur introuvable');
|
||||
throw new Error("Utilisateur introuvable");
|
||||
}
|
||||
|
||||
const user = userDoc.data();
|
||||
@@ -54,7 +51,7 @@ exports.sendAlertEmail = onCall({
|
||||
const prefs = user.notificationPreferences || {};
|
||||
if (!prefs.emailEnabled) {
|
||||
console.log(`Email désactivé pour l'utilisateur ${userId}`);
|
||||
return {success: true, skipped: true, reason: 'email_disabled'};
|
||||
return {success: true, skipped: true, reason: "email_disabled"};
|
||||
}
|
||||
|
||||
// Vérifier la préférence pour ce type d'alerte
|
||||
@@ -62,7 +59,7 @@ exports.sendAlertEmail = onCall({
|
||||
const shouldSend = checkAlertPreference(alertType, prefs);
|
||||
if (!shouldSend) {
|
||||
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
|
||||
return {success: true, skipped: true, reason: 'alert_type_disabled'};
|
||||
return {success: true, skipped: true, reason: "alert_type_disabled"};
|
||||
}
|
||||
|
||||
// Préparer les données pour le template
|
||||
@@ -70,12 +67,12 @@ exports.sendAlertEmail = onCall({
|
||||
|
||||
// Rendre le template HTML
|
||||
const html = await renderTemplate(
|
||||
templateType || 'alert-individual',
|
||||
templateType || "alert-individual",
|
||||
templateData,
|
||||
);
|
||||
|
||||
// Configurer le transporteur SMTP
|
||||
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
||||
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||
|
||||
// Envoyer l'email
|
||||
const info = await transporter.sendMail({
|
||||
@@ -88,7 +85,7 @@ exports.sendAlertEmail = onCall({
|
||||
text: alert.message,
|
||||
});
|
||||
|
||||
console.log('Email envoyé:', info.messageId);
|
||||
console.log("Email envoyé:", info.messageId);
|
||||
|
||||
// Marquer l'email comme envoyé dans l'alerte
|
||||
await alertDoc.ref.update({
|
||||
@@ -102,7 +99,7 @@ exports.sendAlertEmail = onCall({
|
||||
skipped: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur envoi email:', error);
|
||||
console.error("Erreur envoi email:", error);
|
||||
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
|
||||
}
|
||||
});
|
||||
@@ -112,13 +109,13 @@ exports.sendAlertEmail = onCall({
|
||||
*/
|
||||
function checkAlertPreference(alertType, preferences) {
|
||||
const typeMapping = {
|
||||
'EVENT_CREATED': 'eventsNotifications',
|
||||
'EVENT_MODIFIED': 'eventsNotifications',
|
||||
'EVENT_CANCELLED': 'eventsNotifications',
|
||||
'LOST': 'equipmentNotifications',
|
||||
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
||||
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
||||
'STOCK_LOW': 'stockNotifications',
|
||||
"EVENT_CREATED": "eventsNotifications",
|
||||
"EVENT_MODIFIED": "eventsNotifications",
|
||||
"EVENT_CANCELLED": "eventsNotifications",
|
||||
"LOST": "equipmentNotifications",
|
||||
"EQUIPMENT_MISSING": "equipmentNotifications",
|
||||
"MAINTENANCE_REMINDER": "maintenanceNotifications",
|
||||
"STOCK_LOW": "stockNotifications",
|
||||
};
|
||||
|
||||
const prefKey = typeMapping[alertType];
|
||||
@@ -130,12 +127,12 @@ function checkAlertPreference(alertType, preferences) {
|
||||
*/
|
||||
async function prepareTemplateData(alert, user) {
|
||||
const data = {
|
||||
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||
'Utilisateur',
|
||||
userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() ||
|
||||
"Utilisateur",
|
||||
alertTitle: getAlertTitle(alert.type),
|
||||
alertMessage: alert.message,
|
||||
isCritical: alert.severity === 'CRITICAL',
|
||||
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
||||
isCritical: alert.severity === "CRITICAL",
|
||||
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`,
|
||||
appUrl: EMAIL_CONFIG.appUrl,
|
||||
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||
year: new Date().getFullYear(),
|
||||
@@ -146,7 +143,7 @@ async function prepareTemplateData(alert, user) {
|
||||
if (alert.eventId) {
|
||||
try {
|
||||
const eventDoc = await admin.firestore()
|
||||
.collection('events')
|
||||
.collection("events")
|
||||
.doc(alert.eventId)
|
||||
.get();
|
||||
|
||||
@@ -155,22 +152,22 @@ async function prepareTemplateData(alert, user) {
|
||||
data.eventName = event.Name;
|
||||
if (event.StartDateTime) {
|
||||
const date = event.StartDateTime.toDate();
|
||||
data.eventDate = date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
data.eventDate = date.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération événement:', error);
|
||||
console.error("Erreur récupération événement:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (alert.equipmentId) {
|
||||
try {
|
||||
const eqDoc = await admin.firestore()
|
||||
.collection('equipments')
|
||||
.collection("equipments")
|
||||
.doc(alert.equipmentId)
|
||||
.get();
|
||||
|
||||
@@ -178,7 +175,7 @@ async function prepareTemplateData(alert, user) {
|
||||
data.equipmentName = eqDoc.data().name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération équipement:', error);
|
||||
console.error("Erreur récupération équipement:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,16 +187,16 @@ async function prepareTemplateData(alert, user) {
|
||||
*/
|
||||
function getEmailSubject(alert) {
|
||||
const subjects = {
|
||||
'EVENT_CREATED': '📅 Nouvel événement créé',
|
||||
'EVENT_MODIFIED': '📝 Événement modifié',
|
||||
'EVENT_CANCELLED': '❌ Événement annulé',
|
||||
'LOST': '🔴 Alerte critique : Équipement perdu',
|
||||
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
||||
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
||||
'STOCK_LOW': '📦 Stock faible',
|
||||
"EVENT_CREATED": "📅 Nouvel événement créé",
|
||||
"EVENT_MODIFIED": "📝 Événement modifié",
|
||||
"EVENT_CANCELLED": "❌ Événement annulé",
|
||||
"LOST": "🔴 Alerte critique : Équipement perdu",
|
||||
"EQUIPMENT_MISSING": "⚠️ Équipement manquant",
|
||||
"MAINTENANCE_REMINDER": "🔧 Rappel de maintenance",
|
||||
"STOCK_LOW": "📦 Stock faible",
|
||||
};
|
||||
|
||||
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
||||
return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,16 +204,16 @@ function getEmailSubject(alert) {
|
||||
*/
|
||||
function getAlertTitle(type) {
|
||||
const titles = {
|
||||
'EVENT_CREATED': 'Nouvel événement créé',
|
||||
'EVENT_MODIFIED': 'Événement modifié',
|
||||
'EVENT_CANCELLED': 'Événement annulé',
|
||||
'LOST': 'Équipement perdu',
|
||||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
||||
'STOCK_LOW': 'Stock faible',
|
||||
"EVENT_CREATED": "Nouvel événement créé",
|
||||
"EVENT_MODIFIED": "Événement modifié",
|
||||
"EVENT_CANCELLED": "Événement annulé",
|
||||
"LOST": "Équipement perdu",
|
||||
"EQUIPMENT_MISSING": "Équipement manquant",
|
||||
"MAINTENANCE_REMINDER": "Maintenance requise",
|
||||
"STOCK_LOW": "Stock faible",
|
||||
};
|
||||
|
||||
return titles[type] || 'Nouvelle alerte';
|
||||
return titles[type] || "Nouvelle alerte";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,16 +222,16 @@ function getAlertTitle(type) {
|
||||
async function renderTemplate(templateName, data) {
|
||||
try {
|
||||
// Lire le template de base
|
||||
const basePath = path.join(__dirname, 'templates', 'base-template.html');
|
||||
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
||||
const basePath = path.join(__dirname, "templates", "base-template.html");
|
||||
const baseTemplate = await fs.readFile(basePath, "utf8");
|
||||
|
||||
// Lire le template de contenu
|
||||
const contentPath = path.join(
|
||||
__dirname,
|
||||
'templates',
|
||||
"templates",
|
||||
`${templateName}.html`,
|
||||
);
|
||||
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
||||
const contentTemplate = await fs.readFile(contentPath, "utf8");
|
||||
|
||||
// Compiler les templates
|
||||
const compileContent = handlebars.compile(contentTemplate);
|
||||
@@ -249,7 +246,7 @@ async function renderTemplate(templateName, data) {
|
||||
content: renderedContent,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur rendu template:', error);
|
||||
console.error("Erreur rendu template:", error);
|
||||
// Fallback vers un template simple
|
||||
return `
|
||||
<html>
|
||||
@@ -263,3 +260,10 @@ async function renderTemplate(templateName, data) {
|
||||
}
|
||||
}
|
||||
|
||||
exports.sendAlertEmail = onCall({
|
||||
region: "europe-west9",
|
||||
cors: true,
|
||||
}, handler);
|
||||
|
||||
exports.handler = handler;
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* S'exécute tous les jours à 8h00 (Europe/Paris)
|
||||
*/
|
||||
|
||||
const admin = require('firebase-admin');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const nodemailer = require('nodemailer');
|
||||
const { getSmtpConfig } = require('./utils/emailConfig');
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const nodemailer = require("nodemailer");
|
||||
const {getSmtpConfig} = require("./utils/emailConfig");
|
||||
|
||||
/**
|
||||
* Fonction principale : envoie le digest quotidien
|
||||
@@ -14,11 +14,11 @@ const { getSmtpConfig } = require('./utils/emailConfig');
|
||||
async function sendDailyDigest() {
|
||||
const db = admin.firestore();
|
||||
|
||||
logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====');
|
||||
logger.info("[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====");
|
||||
|
||||
try {
|
||||
// 1. Récupérer tous les utilisateurs avec email activé
|
||||
const usersSnapshot = await db.collection('users').get();
|
||||
const usersSnapshot = await db.collection("users").get();
|
||||
const eligibleUsers = [];
|
||||
|
||||
usersSnapshot.forEach((doc) => {
|
||||
@@ -30,8 +30,8 @@ async function sendDailyDigest() {
|
||||
eligibleUsers.push({
|
||||
uid: doc.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName || 'Utilisateur',
|
||||
lastName: user.lastName || '',
|
||||
firstName: user.firstName || "Utilisateur",
|
||||
lastName: user.lastName || "",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -48,11 +48,11 @@ async function sendDailyDigest() {
|
||||
for (const user of eligibleUsers) {
|
||||
try {
|
||||
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
|
||||
const alertsSnapshot = await db.collection('alerts')
|
||||
.where('assignedTo', 'array-contains', user.uid)
|
||||
.where('isRead', '==', false)
|
||||
.where('createdAt', '>=', yesterday)
|
||||
.orderBy('createdAt', 'desc')
|
||||
const alertsSnapshot = await db.collection("alerts")
|
||||
.where("assignedTo", "array-contains", user.uid)
|
||||
.where("isRead", "==", false)
|
||||
.where("createdAt", ">=", yesterday)
|
||||
.orderBy("createdAt", "desc")
|
||||
.get();
|
||||
|
||||
if (alertsSnapshot.empty) {
|
||||
@@ -61,7 +61,7 @@ async function sendDailyDigest() {
|
||||
|
||||
const alerts = [];
|
||||
alertsSnapshot.forEach((doc) => {
|
||||
alerts.push({ id: doc.id, ...doc.data() });
|
||||
alerts.push({id: doc.id, ...doc.data()});
|
||||
});
|
||||
|
||||
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
|
||||
@@ -77,11 +77,11 @@ async function sendDailyDigest() {
|
||||
}
|
||||
|
||||
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
|
||||
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
|
||||
logger.info("[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====");
|
||||
|
||||
return { success: true, emailsSent };
|
||||
return {success: true, emailsSent};
|
||||
} catch (error) {
|
||||
logger.error('[sendDailyDigest] Erreur globale:', error);
|
||||
logger.error("[sendDailyDigest] Erreur globale:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -92,9 +92,9 @@ async function sendDailyDigest() {
|
||||
async function sendDigestEmail(transporter, user, alerts) {
|
||||
try {
|
||||
// Grouper les alertes par sévérité
|
||||
const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL');
|
||||
const warningAlerts = alerts.filter(a => a.severity === 'WARNING');
|
||||
const infoAlerts = alerts.filter(a => a.severity === 'INFO');
|
||||
const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL");
|
||||
const warningAlerts = alerts.filter((a) => a.severity === "WARNING");
|
||||
const infoAlerts = alerts.filter((a) => a.severity === "INFO");
|
||||
|
||||
// Construire le HTML
|
||||
const html = buildDigestHtml(user, {
|
||||
@@ -125,7 +125,7 @@ async function sendDigestEmail(transporter, user, alerts) {
|
||||
function buildDigestHtml(user, alertsByType) {
|
||||
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
|
||||
|
||||
let alertsHtml = '';
|
||||
let alertsHtml = "";
|
||||
|
||||
// Alertes critiques
|
||||
if (alertsByType.critical.length > 0) {
|
||||
@@ -134,7 +134,7 @@ function buildDigestHtml(user, alertsByType) {
|
||||
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
|
||||
🔴 Alertes critiques (${alertsByType.critical.length})
|
||||
</h3>
|
||||
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
|
||||
${alertsByType.critical.map((alert) => formatAlertItem(alert)).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -146,7 +146,7 @@ function buildDigestHtml(user, alertsByType) {
|
||||
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
|
||||
⚠️ Avertissements (${alertsByType.warning.length})
|
||||
</h3>
|
||||
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
|
||||
${alertsByType.warning.map((alert) => formatAlertItem(alert)).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -158,7 +158,7 @@ function buildDigestHtml(user, alertsByType) {
|
||||
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
|
||||
ℹ️ Informations (${alertsByType.info.length})
|
||||
</h3>
|
||||
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
|
||||
${alertsByType.info.map((alert) => formatAlertItem(alert)).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -216,24 +216,24 @@ function buildDigestHtml(user, alertsByType) {
|
||||
*/
|
||||
function formatAlertItem(alert) {
|
||||
const date = alert.createdAt?.toDate ?
|
||||
new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
new Date(alert.createdAt.toDate()).toLocaleString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) :
|
||||
'Date inconnue';
|
||||
"Date inconnue";
|
||||
|
||||
// Type d'alerte en français
|
||||
const typeLabels = {
|
||||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||
'LOST': 'Équipement perdu',
|
||||
'DAMAGED': 'Équipement endommagé',
|
||||
'QUANTITY_MISMATCH': 'Écart de quantité',
|
||||
'EVENT_CREATED': 'Événement créé',
|
||||
'EVENT_MODIFIED': 'Événement modifié',
|
||||
'WORKFORCE_ADDED': 'Ajout à la workforce',
|
||||
"EQUIPMENT_MISSING": "Équipement manquant",
|
||||
"LOST": "Équipement perdu",
|
||||
"DAMAGED": "Équipement endommagé",
|
||||
"QUANTITY_MISMATCH": "Écart de quantité",
|
||||
"EVENT_CREATED": "Événement créé",
|
||||
"EVENT_MODIFIED": "Événement modifié",
|
||||
"WORKFORCE_ADDED": "Ajout à la workforce",
|
||||
};
|
||||
|
||||
const typeLabel = typeLabels[alert.type] || alert.type;
|
||||
@@ -245,7 +245,7 @@ function formatAlertItem(alert) {
|
||||
<span style="color: #6b7280; font-size: 13px;">${date}</span>
|
||||
</div>
|
||||
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
|
||||
${alert.message || 'Aucun message'}
|
||||
${alert.message || "Aucun message"}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -256,12 +256,12 @@ function formatAlertItem(alert) {
|
||||
*/
|
||||
function getSeverityColor(severity) {
|
||||
switch (severity) {
|
||||
case 'CRITICAL': return '#dc2626';
|
||||
case 'WARNING': return '#f59e0b';
|
||||
case 'INFO': return '#3b82f6';
|
||||
default: return '#6b7280';
|
||||
case "CRITICAL": return "#dc2626";
|
||||
case "WARNING": return "#f59e0b";
|
||||
case "INFO": return "#3b82f6";
|
||||
default: return "#6b7280";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendDailyDigest };
|
||||
module.exports = {sendDailyDigest};
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
const admin = require("firebase-admin");
|
||||
const db = admin.firestore();
|
||||
const logger = require("firebase-functions/logger");
|
||||
const auth = require("../utils/auth");
|
||||
const helpers = require("../utils/helpers");
|
||||
|
||||
// Récupère toutes les alertes (filtrées et limitées)
|
||||
exports.getAlerts = async (req, res) => {
|
||||
try {
|
||||
await auth.authenticateUser(req);
|
||||
|
||||
const snapshot = await db.collection("alerts")
|
||||
.orderBy("createdAt", "desc")
|
||||
.limit(100)
|
||||
.get();
|
||||
|
||||
const alerts = snapshot.docs.map((doc) => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(data, ["createdAt"]),
|
||||
};
|
||||
});
|
||||
|
||||
res.status(200).json({alerts});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching alerts:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Marquer une alerte comme lue
|
||||
exports.markAlertAsRead = async (req, res) => {
|
||||
try {
|
||||
await auth.authenticateUser(req);
|
||||
|
||||
const alertId = req.body.data?.alertId;
|
||||
if (!alertId) {
|
||||
res.status(400).json({error: "alertId is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection("alerts").doc(alertId).update({
|
||||
isRead: true,
|
||||
});
|
||||
|
||||
res.status(200).json({success: true});
|
||||
} catch (error) {
|
||||
logger.error("Error marking alert as read:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Supprimer une alerte
|
||||
exports.deleteAlert = async (req, res) => {
|
||||
try {
|
||||
await auth.authenticateUser(req);
|
||||
|
||||
const alertId = req.body.data?.alertId;
|
||||
if (!alertId) {
|
||||
res.status(400).json({error: "alertId is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection("alerts").doc(alertId).delete();
|
||||
|
||||
res.status(200).json({success: true});
|
||||
} catch (error) {
|
||||
logger.error("Error deleting alert:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,628 @@
|
||||
const admin = require("firebase-admin");
|
||||
const db = admin.firestore();
|
||||
const logger = require("firebase-functions/logger");
|
||||
const auth = require("../utils/auth");
|
||||
const helpers = require("../utils/helpers");
|
||||
|
||||
// Vérifie si un équipement est disponible pour une plage de dates
|
||||
exports.checkEquipmentAvailability = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {equipmentId, startDate, endDate, excludeEventId} = req.body.data;
|
||||
|
||||
if (!equipmentId || !startDate || !endDate) {
|
||||
res.status(400).json({error: "equipmentId, startDate, and endDate are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Checking availability for equipment ${equipmentId} from ${startDate} to ${endDate}, excluding event: ${excludeEventId}`);
|
||||
|
||||
const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate));
|
||||
const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate));
|
||||
|
||||
const eventsSnapshot = await db.collection("events")
|
||||
.where("status", "!=", "CANCELLED")
|
||||
.get();
|
||||
|
||||
logger.info(`Found ${eventsSnapshot.docs.length} events to check`);
|
||||
|
||||
const conflicts = [];
|
||||
|
||||
for (const eventDoc of eventsSnapshot.docs) {
|
||||
const event = eventDoc.data();
|
||||
|
||||
if (excludeEventId && eventDoc.id === excludeEventId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let eventStart; let eventEnd;
|
||||
if (event.StartDateTime) {
|
||||
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
|
||||
}
|
||||
if (event.EndDateTime) {
|
||||
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
|
||||
}
|
||||
|
||||
if (!eventStart || !eventEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const assignedEquipment = event.assignedEquipment || [];
|
||||
const assignedContainers = event.assignedContainers || [];
|
||||
|
||||
const isEquipmentDirectlyAssigned = assignedEquipment.some((eq) => eq.equipmentId === equipmentId);
|
||||
|
||||
let isEquipmentInAssignedContainer = false;
|
||||
if (assignedContainers.length > 0) {
|
||||
logger.info(`Event ${eventDoc.id} has ${assignedContainers.length} assigned containers`);
|
||||
for (const containerId of assignedContainers) {
|
||||
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||
if (containerDoc.exists) {
|
||||
const containerData = containerDoc.data();
|
||||
const equipmentIds = containerData.equipmentIds || [];
|
||||
logger.info(`Container ${containerId} contains equipment IDs: ${equipmentIds.join(", ")}`);
|
||||
if (equipmentIds.includes(equipmentId)) {
|
||||
isEquipmentInAssignedContainer = true;
|
||||
logger.info(`Equipment ${equipmentId} found in container ${containerId} for event ${eventDoc.id}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEquipmentDirectlyAssigned) {
|
||||
logger.info(`Equipment ${equipmentId} is directly assigned to event ${eventDoc.id}`);
|
||||
}
|
||||
|
||||
if (!isEquipmentDirectlyAssigned && !isEquipmentInAssignedContainer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const requestStart = startTimestamp.toDate();
|
||||
const requestEnd = endTimestamp.toDate();
|
||||
|
||||
const installationTime = event.InstallationTime || 0;
|
||||
const disassemblyTime = event.DisassemblyTime || 0;
|
||||
|
||||
const eventStartWithSetup = new Date(eventStart);
|
||||
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime);
|
||||
|
||||
const eventEndWithTeardown = new Date(eventEnd);
|
||||
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime);
|
||||
|
||||
const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup;
|
||||
|
||||
if (hasOverlap) {
|
||||
const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup));
|
||||
const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown));
|
||||
const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24));
|
||||
|
||||
logger.info(`Conflict detected: Equipment ${equipmentId} conflicts with event ${eventDoc.id} (${event.Name})`);
|
||||
|
||||
const eventData = helpers.serializeTimestamps(event);
|
||||
conflicts.push({
|
||||
eventId: eventDoc.id,
|
||||
eventName: event.Name,
|
||||
eventData: eventData,
|
||||
startDate: eventStart.toISOString(),
|
||||
endDate: eventEnd.toISOString(),
|
||||
overlapDays: overlapDays,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Total conflicts found: ${conflicts.length}`);
|
||||
|
||||
res.status(200).json({conflicts, available: conflicts.length === 0});
|
||||
} catch (error) {
|
||||
logger.error("Error checking equipment availability:", error);
|
||||
res.status(500).json({error: error.message || "Failed to check equipment availability"});
|
||||
}
|
||||
};
|
||||
|
||||
// Vérifie la disponibilité d'un container et de son contenu
|
||||
exports.checkContainerAvailability = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {containerId, startDate, endDate, excludeEventId} = req.body.data;
|
||||
|
||||
if (!containerId || !startDate || !endDate) {
|
||||
res.status(400).json({error: "containerId, startDate, and endDate are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||
if (!containerDoc.exists) {
|
||||
throw new Error("Container not found");
|
||||
}
|
||||
|
||||
const containerData = containerDoc.data();
|
||||
const equipmentIds = containerData.equipmentIds || [];
|
||||
|
||||
const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate));
|
||||
const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate));
|
||||
|
||||
const eventsSnapshot = await db.collection("events")
|
||||
.where("status", "!=", "CANCELLED")
|
||||
.get();
|
||||
|
||||
const containerConflicts = [];
|
||||
const equipmentConflicts = {};
|
||||
|
||||
for (const eventDoc of eventsSnapshot.docs) {
|
||||
const event = eventDoc.data();
|
||||
|
||||
if (excludeEventId && eventDoc.id === excludeEventId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let eventStart; let eventEnd;
|
||||
if (event.StartDateTime) {
|
||||
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
|
||||
}
|
||||
if (event.EndDateTime) {
|
||||
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
|
||||
}
|
||||
|
||||
if (!eventStart || !eventEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const assignedContainers = event.assignedContainers || [];
|
||||
const isContainerAssigned = assignedContainers.includes(containerId);
|
||||
|
||||
const assignedEquipment = event.assignedEquipment || [];
|
||||
const conflictingEquipmentIds = equipmentIds.filter((eqId) =>
|
||||
assignedEquipment.some((eq) => eq.equipmentId === eqId),
|
||||
);
|
||||
|
||||
if (!isContainerAssigned && conflictingEquipmentIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const requestStart = startTimestamp.toDate();
|
||||
const requestEnd = endTimestamp.toDate();
|
||||
|
||||
const installationTime = event.InstallationTime || 0;
|
||||
const disassemblyTime = event.DisassemblyTime || 0;
|
||||
|
||||
const eventStartWithSetup = new Date(eventStart);
|
||||
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime);
|
||||
|
||||
const eventEndWithTeardown = new Date(eventEnd);
|
||||
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime);
|
||||
|
||||
const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup;
|
||||
|
||||
if (hasOverlap) {
|
||||
const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup));
|
||||
const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown));
|
||||
const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const conflictInfo = {
|
||||
eventId: eventDoc.id,
|
||||
eventName: event.Name,
|
||||
startDate: eventStart.toISOString(),
|
||||
endDate: eventEnd.toISOString(),
|
||||
overlapDays: overlapDays,
|
||||
};
|
||||
|
||||
if (isContainerAssigned) {
|
||||
containerConflicts.push(conflictInfo);
|
||||
}
|
||||
|
||||
conflictingEquipmentIds.forEach((eqId) => {
|
||||
if (!equipmentConflicts[eqId]) {
|
||||
equipmentConflicts[eqId] = [];
|
||||
}
|
||||
equipmentConflicts[eqId].push(conflictInfo);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasContainerConflict = containerConflicts.length > 0;
|
||||
const hasPartialConflict = Object.keys(equipmentConflicts).length > 0 && !hasContainerConflict;
|
||||
const conflictType = hasContainerConflict ? "complete" : (hasPartialConflict ? "partial" : "none");
|
||||
|
||||
res.status(200).json({
|
||||
conflictType,
|
||||
containerConflicts,
|
||||
equipmentConflicts,
|
||||
isAvailable: conflictType === "none",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error checking container availability:", error);
|
||||
res.status(500).json({error: error.message || "Failed to check container availability"});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupère tous les équipements et conteneurs en conflit pour une période donnée
|
||||
exports.getConflictingEquipmentIds = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {startDate, endDate, excludeEventId, installationTime = 0, disassemblyTime = 0} = req.body.data;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
res.status(400).json({error: "startDate and endDate are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Getting conflicting equipment IDs for period ${startDate} to ${endDate}`);
|
||||
|
||||
const requestStartDate = new Date(startDate);
|
||||
requestStartDate.setHours(requestStartDate.getHours() - installationTime);
|
||||
|
||||
const requestEndDate = new Date(endDate);
|
||||
requestEndDate.setHours(requestEndDate.getHours() + disassemblyTime);
|
||||
|
||||
const eventsSnapshot = await db.collection("events")
|
||||
.where("status", "!=", "CANCELLED")
|
||||
.get();
|
||||
|
||||
logger.info(`Found ${eventsSnapshot.docs.length} events to check`);
|
||||
|
||||
const equipmentsSnapshot = await db.collection("equipments").get();
|
||||
const equipmentsInfo = {};
|
||||
equipmentsSnapshot.docs.forEach((doc) => {
|
||||
const data = doc.data();
|
||||
equipmentsInfo[doc.id] = {
|
||||
category: data.category,
|
||||
totalQuantity: data.totalQuantity || 0,
|
||||
hasQuantity: data.category === "CABLE" || data.category === "CONSUMABLE",
|
||||
};
|
||||
});
|
||||
|
||||
const conflictingEquipmentIds = new Set();
|
||||
const conflictingContainerIds = new Set();
|
||||
const conflictDetails = {};
|
||||
const equipmentQuantities = {};
|
||||
|
||||
for (const eventDoc of eventsSnapshot.docs) {
|
||||
if (excludeEventId && eventDoc.id === excludeEventId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = eventDoc.data();
|
||||
|
||||
let eventStart; let eventEnd;
|
||||
if (event.StartDateTime) {
|
||||
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
|
||||
}
|
||||
if (event.EndDateTime) {
|
||||
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
|
||||
}
|
||||
|
||||
if (!eventStart || !eventEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventInstallTime = event.InstallationTime || 0;
|
||||
const eventDisassemblyTime = event.DisassemblyTime || 0;
|
||||
|
||||
const eventStartWithSetup = new Date(eventStart);
|
||||
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - eventInstallTime);
|
||||
|
||||
const eventEndWithTeardown = new Date(eventEnd);
|
||||
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + eventDisassemblyTime);
|
||||
|
||||
const hasOverlap = requestStartDate < eventEndWithTeardown && requestEndDate > eventStartWithSetup;
|
||||
|
||||
if (!hasOverlap) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const assignedEquipment = event.assignedEquipment || [];
|
||||
const assignedContainers = event.assignedContainers || [];
|
||||
|
||||
const conflictInfo = {
|
||||
eventId: eventDoc.id,
|
||||
eventName: event.Name,
|
||||
startDate: eventStart.toISOString(),
|
||||
endDate: eventEnd.toISOString(),
|
||||
};
|
||||
|
||||
for (const eq of assignedEquipment) {
|
||||
const equipmentId = eq.equipmentId;
|
||||
const quantity = eq.quantity || 1;
|
||||
const equipInfo = equipmentsInfo[equipmentId];
|
||||
|
||||
if (equipInfo && equipInfo.hasQuantity) {
|
||||
if (!equipmentQuantities[equipmentId]) {
|
||||
equipmentQuantities[equipmentId] = {
|
||||
totalQuantity: equipInfo.totalQuantity,
|
||||
reservedQuantity: 0,
|
||||
availableQuantity: equipInfo.totalQuantity,
|
||||
reservations: [],
|
||||
};
|
||||
}
|
||||
|
||||
equipmentQuantities[equipmentId].reservedQuantity += quantity;
|
||||
equipmentQuantities[equipmentId].availableQuantity = equipInfo.totalQuantity - equipmentQuantities[equipmentId].reservedQuantity;
|
||||
equipmentQuantities[equipmentId].reservations.push({
|
||||
...conflictInfo,
|
||||
quantity: quantity,
|
||||
});
|
||||
|
||||
if (equipmentQuantities[equipmentId].availableQuantity <= 0) {
|
||||
conflictingEquipmentIds.add(equipmentId);
|
||||
}
|
||||
} else {
|
||||
conflictingEquipmentIds.add(equipmentId);
|
||||
}
|
||||
|
||||
if (!conflictDetails[equipmentId]) {
|
||||
conflictDetails[equipmentId] = [];
|
||||
}
|
||||
conflictDetails[equipmentId].push({
|
||||
...conflictInfo,
|
||||
quantity: quantity,
|
||||
});
|
||||
}
|
||||
|
||||
for (const containerId of assignedContainers) {
|
||||
conflictingContainerIds.add(containerId);
|
||||
|
||||
if (!conflictDetails[containerId]) {
|
||||
conflictDetails[containerId] = [];
|
||||
}
|
||||
conflictDetails[containerId].push(conflictInfo);
|
||||
|
||||
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||
if (containerDoc.exists) {
|
||||
const containerData = containerDoc.data();
|
||||
const equipmentIds = containerData.equipmentIds || [];
|
||||
|
||||
for (const equipmentId of equipmentIds) {
|
||||
conflictingEquipmentIds.add(equipmentId);
|
||||
|
||||
if (!conflictDetails[equipmentId]) {
|
||||
conflictDetails[equipmentId] = [];
|
||||
}
|
||||
conflictDetails[equipmentId].push({
|
||||
...conflictInfo,
|
||||
viaContainer: containerId,
|
||||
viaContainerName: containerData.name || "Conteneur inconnu",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${conflictingEquipmentIds.size} conflicting equipment(s) and ${conflictingContainerIds.size} conflicting container(s)`);
|
||||
|
||||
res.status(200).json({
|
||||
conflictingEquipmentIds: Array.from(conflictingEquipmentIds),
|
||||
conflictingContainerIds: Array.from(conflictingContainerIds),
|
||||
conflictDetails: conflictDetails,
|
||||
equipmentQuantities: equipmentQuantities,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error getting conflicting equipment IDs:", error);
|
||||
res.status(500).json({error: error.message || "Failed to get conflicting equipment IDs"});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Trouver des alternatives (même modèle) disponibles pour une période donnée
|
||||
*/
|
||||
exports.findAlternativeEquipment = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {model, startDate, endDate} = req.body.data;
|
||||
|
||||
if (!model || !startDate || !endDate) {
|
||||
res.status(400).json({error: "model, startDate and endDate are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const start = admin.firestore.Timestamp.fromDate(new Date(startDate));
|
||||
const end = admin.firestore.Timestamp.fromDate(new Date(endDate));
|
||||
|
||||
// Récupérer tous les équipements du même modèle
|
||||
const equipmentsSnapshot = await db.collection("equipments")
|
||||
.where("model", "==", model)
|
||||
.get();
|
||||
|
||||
// Récupérer tous les événements qui chevauchent la période
|
||||
const eventsSnapshot = await db.collection("events")
|
||||
.where("StartDateTime", "<=", end)
|
||||
.where("EndDateTime", ">=", start)
|
||||
.where("status", "!=", "CANCELLED")
|
||||
.get();
|
||||
|
||||
// Créer un set des équipements en conflit
|
||||
const conflictingEquipmentIds = new Set();
|
||||
eventsSnapshot.docs.forEach((doc) => {
|
||||
const eventData = doc.data();
|
||||
const assignedEquipment = eventData.assignedEquipment || [];
|
||||
assignedEquipment.forEach((eq) => conflictingEquipmentIds.add(eq.equipmentId));
|
||||
});
|
||||
|
||||
// Filtrer les équipements disponibles
|
||||
const alternatives = [];
|
||||
equipmentsSnapshot.docs.forEach((doc) => {
|
||||
const data = doc.data();
|
||||
if (!conflictingEquipmentIds.has(doc.id) && data.status === "AVAILABLE") {
|
||||
alternatives.push({
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(data, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).json({alternatives});
|
||||
} catch (error) {
|
||||
logger.error("Error finding alternative equipment:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculer le statut réel d'un ou plusieurs équipements basé sur les événements en cours
|
||||
*/
|
||||
exports.calculateEquipmentStatuses = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {equipmentIds} = req.body.data;
|
||||
|
||||
if (!equipmentIds || !Array.isArray(equipmentIds)) {
|
||||
res.status(400).json({error: "equipmentIds array is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer tous les événements en cours (préparation complétée mais pas encore retournés)
|
||||
const eventsSnapshot = await db.collection("events")
|
||||
.where("status", "!=", "CANCELLED")
|
||||
.get();
|
||||
|
||||
const equipmentIdsInUse = new Set();
|
||||
const containerIdsInUse = new Set();
|
||||
|
||||
eventsSnapshot.docs.forEach((doc) => {
|
||||
const event = doc.data();
|
||||
|
||||
const isPrepared = event.preparationStatus === "completed" ||
|
||||
event.preparationStatus === "completedWithMissing";
|
||||
const isReturned = event.returnStatus === "completed" ||
|
||||
event.returnStatus === "completedWithMissing";
|
||||
|
||||
if (isPrepared && !isReturned) {
|
||||
// Ajouter les équipements directs
|
||||
const assignedEquipment = event.assignedEquipment || [];
|
||||
assignedEquipment.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId));
|
||||
|
||||
// Ajouter les conteneurs
|
||||
const assignedContainers = event.assignedContainers || [];
|
||||
assignedContainers.forEach((containerId) => containerIdsInUse.add(containerId));
|
||||
}
|
||||
});
|
||||
|
||||
// Récupérer les équipements dans les conteneurs en cours d'utilisation
|
||||
if (containerIdsInUse.size > 0) {
|
||||
const containersSnapshot = await db.collection("containers")
|
||||
.where(admin.firestore.FieldPath.documentId(), "in", Array.from(containerIdsInUse))
|
||||
.get();
|
||||
|
||||
containersSnapshot.docs.forEach((doc) => {
|
||||
const containerData = doc.data();
|
||||
const equipmentList = containerData.equipment || [];
|
||||
equipmentList.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId));
|
||||
});
|
||||
}
|
||||
|
||||
// Récupérer les données des équipements demandés
|
||||
const statuses = {};
|
||||
|
||||
for (const equipmentId of equipmentIds) {
|
||||
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||
|
||||
if (!equipmentDoc.exists) {
|
||||
statuses[equipmentId] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const equipmentData = equipmentDoc.data();
|
||||
let calculatedStatus = equipmentData.status;
|
||||
|
||||
// Si l'équipement est perdu ou HS, garder ce statut
|
||||
if (equipmentData.status === "LOST" || equipmentData.status === "OUT_OF_SERVICE") {
|
||||
calculatedStatus = equipmentData.status;
|
||||
} else if (equipmentIdsInUse.has(equipmentId)) {
|
||||
calculatedStatus = "IN_USE";
|
||||
} else if (equipmentData.status === "MAINTENANCE" ||
|
||||
equipmentData.status === "RENTED") {
|
||||
calculatedStatus = equipmentData.status;
|
||||
} else {
|
||||
calculatedStatus = "AVAILABLE";
|
||||
}
|
||||
|
||||
statuses[equipmentId] = calculatedStatus;
|
||||
}
|
||||
|
||||
res.status(200).json({statuses});
|
||||
} catch (error) {
|
||||
logger.error("Error calculating equipment statuses:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupérer tous les événements en cours (pour le calcul de statuts)
|
||||
*/
|
||||
exports.getActiveEvents = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_events");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_events permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer les événements en cours (préparation complétée mais pas encore retournés)
|
||||
const eventsSnapshot = await db.collection("events")
|
||||
.where("status", "!=", "CANCELLED")
|
||||
.get();
|
||||
|
||||
const activeEvents = [];
|
||||
|
||||
eventsSnapshot.docs.forEach((doc) => {
|
||||
const event = doc.data();
|
||||
|
||||
const isPrepared = event.preparationStatus === "completed" ||
|
||||
event.preparationStatus === "completedWithMissing";
|
||||
const isReturned = event.returnStatus === "completed" ||
|
||||
event.returnStatus === "completedWithMissing";
|
||||
|
||||
if (isPrepared && !isReturned) {
|
||||
activeEvents.push({
|
||||
id: doc.id,
|
||||
assignedEquipment: event.assignedEquipment || [],
|
||||
assignedContainers: event.assignedContainers || [],
|
||||
preparationStatus: event.preparationStatus,
|
||||
returnStatus: event.returnStatus,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).json({events: activeEvents});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching active events:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,504 @@
|
||||
const admin = require("firebase-admin");
|
||||
const db = admin.firestore();
|
||||
const logger = require("firebase-functions/logger");
|
||||
const auth = require("../utils/auth");
|
||||
const helpers = require("../utils/helpers");
|
||||
|
||||
// Créer un container
|
||||
exports.createContainer = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const containerData = req.body.data;
|
||||
const containerId = containerData.id;
|
||||
|
||||
if (!containerId) {
|
||||
res.status(400).json({error: "Container ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingDoc = await db.collection("containers").doc(containerId).get();
|
||||
if (existingDoc.exists) {
|
||||
res.status(409).json({error: "Container ID already exists"});
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToSave = helpers.deserializeTimestamps(containerData, ["createdAt", "updatedAt"]);
|
||||
|
||||
await db.collection("containers").doc(containerId).set(dataToSave);
|
||||
|
||||
res.status(201).json({id: containerId, message: "Container created successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error creating container:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Mettre à jour un container
|
||||
exports.updateContainer = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {containerId, data} = req.body.data;
|
||||
|
||||
if (!containerId) {
|
||||
res.status(400).json({error: "Container ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
delete data.id;
|
||||
data.updatedAt = admin.firestore.Timestamp.now();
|
||||
|
||||
await db.collection("containers").doc(containerId).update(data);
|
||||
|
||||
res.status(200).json({message: "Container updated successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error updating container:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Supprimer un container
|
||||
exports.deleteContainer = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {containerId} = req.body.data;
|
||||
|
||||
if (!containerId) {
|
||||
res.status(400).json({error: "Container ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer le container pour obtenir les équipements
|
||||
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||
if (containerDoc.exists) {
|
||||
const containerData = containerDoc.data();
|
||||
const equipmentIds = containerData.equipmentIds || [];
|
||||
|
||||
// Retirer le container des parentBoxIds de chaque équipement
|
||||
for (const equipmentId of equipmentIds) {
|
||||
try {
|
||||
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||
if (equipmentDoc.exists) {
|
||||
const equipmentData = equipmentDoc.data();
|
||||
const parentBoxIds = (equipmentData.parentBoxIds || []).filter((boxId) => boxId !== containerId);
|
||||
await db.collection("equipments").doc(equipmentId).update({
|
||||
parentBoxIds: parentBoxIds,
|
||||
updatedAt: admin.firestore.Timestamp.now(),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error updating equipment ${equipmentId} when deleting container:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.collection("containers").doc(containerId).delete();
|
||||
|
||||
res.status(200).json({message: "Container deleted successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error deleting container:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupérer les containers contenant un équipement
|
||||
exports.getContainersByEquipment = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasViewAccess && !hasManageAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {equipmentId} = req.body.data || {};
|
||||
|
||||
if (!equipmentId) {
|
||||
res.status(400).json({error: "equipmentId is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await db.collection("containers")
|
||||
.where("equipmentIds", "array-contains", equipmentId)
|
||||
.get();
|
||||
|
||||
const containers = [];
|
||||
snapshot.forEach((doc) => {
|
||||
let data = {id: doc.id, ...doc.data()};
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
containers.push(data);
|
||||
});
|
||||
|
||||
res.status(200).json({containers});
|
||||
} catch (error) {
|
||||
logger.error("Error getting containers by equipment:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupérer plusieurs containers par leurs IDs
|
||||
exports.getContainersByIds = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasViewAccess && !hasManageAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {containerIds} = req.body.data || {};
|
||||
|
||||
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
|
||||
res.status(400).json({error: "containerIds array is required and must not be empty"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (containerIds.length > 100) {
|
||||
res.status(400).json({error: "Maximum 100 container IDs per request"});
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = containerIds.map((id) => db.collection("containers").doc(id).get());
|
||||
const docs = await Promise.all(promises);
|
||||
|
||||
const containers = [];
|
||||
for (const doc of docs) {
|
||||
if (doc.exists) {
|
||||
let data = {id: doc.id, ...doc.data()};
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
containers.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({containers});
|
||||
} catch (error) {
|
||||
logger.error("Error getting containers by IDs:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Ajouter un équipement à un container
|
||||
exports.addEquipmentToContainer = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {containerId, equipmentId, userId} = req.body.data;
|
||||
|
||||
if (!containerId || !equipmentId) {
|
||||
res.status(400).json({error: "containerId and equipmentId are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||
if (!containerDoc.exists) {
|
||||
res.status(404).json({success: false, message: "Container non trouvé"});
|
||||
return;
|
||||
}
|
||||
|
||||
const containerData = containerDoc.data();
|
||||
const equipmentIds = containerData.equipmentIds || [];
|
||||
|
||||
if (equipmentIds.includes(equipmentId)) {
|
||||
res.status(400).json({success: false, message: "Cet équipement est déjà dans ce container"});
|
||||
return;
|
||||
}
|
||||
|
||||
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||
if (!equipmentDoc.exists) {
|
||||
res.status(404).json({success: false, message: "Équipement non trouvé"});
|
||||
return;
|
||||
}
|
||||
|
||||
const equipmentData = equipmentDoc.data();
|
||||
const parentBoxIds = equipmentData.parentBoxIds || [];
|
||||
|
||||
const warnings = [];
|
||||
if (parentBoxIds.length > 0) {
|
||||
const otherContainersPromises = parentBoxIds.map((boxId) =>
|
||||
db.collection("containers").doc(boxId).get(),
|
||||
);
|
||||
const otherContainersDocs = await Promise.all(otherContainersPromises);
|
||||
const otherNames = otherContainersDocs
|
||||
.filter((doc) => doc.exists)
|
||||
.map((doc) => doc.data().name);
|
||||
|
||||
if (otherNames.length > 0) {
|
||||
warnings.push(`Attention : cet équipement est également dans les boites suivants : ${otherNames.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
await db.collection("containers").doc(containerId).update({
|
||||
equipmentIds: [...equipmentIds, equipmentId],
|
||||
updatedAt: admin.firestore.Timestamp.now(),
|
||||
});
|
||||
|
||||
await db.collection("equipments").doc(equipmentId).update({
|
||||
parentBoxIds: [...parentBoxIds, containerId],
|
||||
updatedAt: admin.firestore.Timestamp.now(),
|
||||
});
|
||||
|
||||
const history = containerData.history || [];
|
||||
const historyEntry = {
|
||||
timestamp: admin.firestore.Timestamp.now(),
|
||||
action: "equipment_added",
|
||||
equipmentId: equipmentId,
|
||||
newValue: equipmentId,
|
||||
userId: userId || decodedToken.uid,
|
||||
};
|
||||
|
||||
const updatedHistory = [...history, historyEntry].slice(-100);
|
||||
|
||||
await db.collection("containers").doc(containerId).update({
|
||||
history: updatedHistory,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Équipement ajouté avec succès",
|
||||
warnings: warnings.length > 0 ? warnings[0] : null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error adding equipment to container:", error);
|
||||
res.status(500).json({success: false, message: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Retirer un équipement d'un container
|
||||
exports.removeEquipmentFromContainer = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {containerId, equipmentId, userId} = req.body.data;
|
||||
|
||||
if (!containerId || !equipmentId) {
|
||||
res.status(400).json({error: "containerId and equipmentId are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const containerDoc = await db.collection("containers").doc(containerId).get();
|
||||
if (!containerDoc.exists) {
|
||||
res.status(404).json({error: "Container non trouvé"});
|
||||
return;
|
||||
}
|
||||
|
||||
const containerData = containerDoc.data();
|
||||
const equipmentIds = containerData.equipmentIds || [];
|
||||
|
||||
const updatedEquipmentIds = equipmentIds.filter((id) => id !== equipmentId);
|
||||
|
||||
await db.collection("containers").doc(containerId).update({
|
||||
equipmentIds: updatedEquipmentIds,
|
||||
updatedAt: admin.firestore.Timestamp.now(),
|
||||
});
|
||||
|
||||
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||
if (equipmentDoc.exists) {
|
||||
const equipmentData = equipmentDoc.data();
|
||||
const parentBoxIds = equipmentData.parentBoxIds || [];
|
||||
const updatedParentBoxIds = parentBoxIds.filter((id) => id !== containerId);
|
||||
|
||||
await db.collection("equipments").doc(equipmentId).update({
|
||||
parentBoxIds: updatedParentBoxIds,
|
||||
updatedAt: admin.firestore.Timestamp.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const history = containerData.history || [];
|
||||
const historyEntry = {
|
||||
timestamp: admin.firestore.Timestamp.now(),
|
||||
action: "equipment_removed",
|
||||
equipmentId: equipmentId,
|
||||
previousValue: equipmentId,
|
||||
userId: userId || decodedToken.uid,
|
||||
};
|
||||
|
||||
const updatedHistory = [...history, historyEntry].slice(-100);
|
||||
|
||||
await db.collection("containers").doc(containerId).update({
|
||||
history: updatedHistory,
|
||||
});
|
||||
|
||||
res.status(200).json({success: true});
|
||||
} catch (error) {
|
||||
logger.error("Error removing equipment from container:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupérer les containers avec pagination et filtrage côté serveur
|
||||
exports.getContainersPaginated = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
|
||||
// Vérifier les permissions
|
||||
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
|
||||
if (!canView) {
|
||||
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer les paramètres de la requête
|
||||
const params = req.method === "GET" ? req.query : (req.body?.data || {});
|
||||
const limit = Math.min(parseInt(params.limit) || 20, 100);
|
||||
const startAfterId = params.startAfter || null;
|
||||
const type = params.type ? params.type.toUpperCase() : null;
|
||||
const status = params.status ? params.status.toUpperCase() : null;
|
||||
const searchQuery = params.searchQuery?.toLowerCase() || null;
|
||||
const category = params.category ? params.category.toUpperCase() : null;
|
||||
const sortBy = params.sortBy || "id";
|
||||
const sortOrder = params.sortOrder === "desc" ? "desc" : "asc";
|
||||
|
||||
logger.info(`[getContainersPaginated] Params: limit=${limit}, startAfter=${startAfterId}, type=${type}, status=${status}, category=${category}, search=${searchQuery}`);
|
||||
|
||||
let query = db.collection("containers");
|
||||
|
||||
const queryLimit = (searchQuery || category) ? Math.min(limit * 10, 200) : limit;
|
||||
|
||||
if (type) {
|
||||
query = query.where("type", "==", type);
|
||||
}
|
||||
if (status) {
|
||||
query = query.where("status", "==", status);
|
||||
}
|
||||
|
||||
if (sortBy === "id") {
|
||||
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
|
||||
} else {
|
||||
query = query.orderBy(sortBy, sortOrder);
|
||||
}
|
||||
|
||||
if (startAfterId) {
|
||||
const startAfterDoc = await db.collection("containers").doc(startAfterId).get();
|
||||
if (startAfterDoc.exists) {
|
||||
query = query.startAfter(startAfterDoc);
|
||||
}
|
||||
}
|
||||
|
||||
query = query.limit(queryLimit + 1);
|
||||
|
||||
const snapshot = await query.get();
|
||||
|
||||
const rawDocCount = snapshot.docs.length;
|
||||
const hasMoreDocs = rawDocCount > queryLimit;
|
||||
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
|
||||
|
||||
let containers = docsToProcess.map((doc) => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(data, ["createdAt", "updatedAt"]),
|
||||
};
|
||||
});
|
||||
|
||||
const allEquipmentIds = new Set();
|
||||
containers.forEach((c) => {
|
||||
if (c.equipmentIds && Array.isArray(c.equipmentIds)) {
|
||||
c.equipmentIds.forEach((id) => allEquipmentIds.add(id));
|
||||
}
|
||||
});
|
||||
|
||||
const equipmentMap = new Map();
|
||||
if (allEquipmentIds.size > 0) {
|
||||
const equipmentIdArray = Array.from(allEquipmentIds);
|
||||
const batchSize = 30;
|
||||
|
||||
for (let i = 0; i < equipmentIdArray.length; i += batchSize) {
|
||||
const batch = equipmentIdArray.slice(i, i + batchSize);
|
||||
const equipmentSnapshot = await db.collection("equipments")
|
||||
.where(admin.firestore.FieldPath.documentId(), "in", batch)
|
||||
.get();
|
||||
|
||||
equipmentSnapshot.docs.forEach((doc) => {
|
||||
const equipmentData = doc.data();
|
||||
equipmentMap.set(doc.id, {
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(equipmentData),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
containers = containers.map((container) => ({
|
||||
...container,
|
||||
equipment: (container.equipmentIds || [])
|
||||
.map((eqId) => equipmentMap.get(eqId))
|
||||
.filter((eq) => eq !== undefined),
|
||||
}));
|
||||
|
||||
if (category) {
|
||||
containers = containers.filter((c) => {
|
||||
return c.equipment.some((eq) => eq.category === category);
|
||||
});
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
containers = containers.filter((c) => {
|
||||
const searchableText = [
|
||||
c.name || "",
|
||||
c.id || "",
|
||||
...(c.equipment || []).map((eq) => eq.name || ""),
|
||||
].join(" ").toLowerCase();
|
||||
return searchableText.includes(searchQuery);
|
||||
});
|
||||
}
|
||||
|
||||
const limitedContainers = containers.slice(0, limit);
|
||||
const lastVisible = limitedContainers.length > 0 ? limitedContainers[limitedContainers.length - 1].id : null;
|
||||
|
||||
const totalEquipmentCount = limitedContainers.reduce((sum, c) => sum + (c.equipment?.length || 0), 0);
|
||||
logger.info(`[getContainersPaginated] Returning ${limitedContainers.length} containers with ${totalEquipmentCount} total equipment(s)`);
|
||||
|
||||
limitedContainers.forEach((c) => {
|
||||
logger.info(` - Container ${c.id}: ${c.equipment?.length || 0} equipment(s)`);
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
containers: limitedContainers,
|
||||
hasMore: containers.length > limit || hasMoreDocs,
|
||||
lastVisible,
|
||||
total: limitedContainers.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching paginated containers:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,668 @@
|
||||
const admin = require("firebase-admin");
|
||||
const db = admin.firestore();
|
||||
const logger = require("firebase-functions/logger");
|
||||
const auth = require("../utils/auth");
|
||||
const helpers = require("../utils/helpers");
|
||||
|
||||
// Créer un équipement (admin ou manage_equipment)
|
||||
exports.createEquipment = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const equipmentData = req.body.data;
|
||||
const equipmentId = equipmentData.id;
|
||||
|
||||
if (!equipmentId) {
|
||||
res.status(400).json({error: "Equipment ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier unicité de l'ID
|
||||
const existingDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||
if (existingDoc.exists) {
|
||||
res.status(409).json({error: "Equipment ID already exists"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Convertir les timestamps
|
||||
const dataToSave = helpers.deserializeTimestamps(equipmentData, [
|
||||
"createdAt", "updatedAt", "purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate",
|
||||
]);
|
||||
|
||||
await db.collection("equipments").doc(equipmentId).set(dataToSave);
|
||||
|
||||
res.status(201).json({id: equipmentId, message: "Equipment created successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error creating equipment:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Mettre à jour un équipement
|
||||
exports.updateEquipment = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {equipmentId, data} = req.body.data;
|
||||
|
||||
if (!equipmentId) {
|
||||
res.status(400).json({error: "Equipment ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || typeof data !== "object" || Object.keys(data).length === 0) {
|
||||
res.status(400).json({error: "Update data is required and must be a non-empty object"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Empêcher la modification de l'ID
|
||||
delete data.id;
|
||||
|
||||
// Ajouter updatedAt
|
||||
data.updatedAt = admin.firestore.Timestamp.now();
|
||||
|
||||
const dataToSave = helpers.deserializeTimestamps(data, [
|
||||
"purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate",
|
||||
]);
|
||||
|
||||
await db.collection("equipments").doc(equipmentId).update(dataToSave);
|
||||
|
||||
res.status(200).json({message: "Equipment updated successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error updating equipment:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Supprimer un équipement
|
||||
exports.deleteEquipment = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {equipmentId, forceDelete = false} = req.body.data;
|
||||
|
||||
if (!equipmentId) {
|
||||
res.status(400).json({error: "Equipment ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier si l'équipement est utilisé dans des événements à venir
|
||||
const eventsSnapshot = await db.collection("events")
|
||||
.where("status", "!=", "CANCELLED")
|
||||
.get();
|
||||
|
||||
const now = new Date();
|
||||
const upcomingEvents = [];
|
||||
|
||||
for (const eventDoc of eventsSnapshot.docs) {
|
||||
const eventData = eventDoc.data();
|
||||
const assignedEquipment = eventData.assignedEquipment || [];
|
||||
|
||||
if (!assignedEquipment.some((eq) => eq.equipmentId === equipmentId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let eventStart = null;
|
||||
if (eventData.StartDateTime) {
|
||||
eventStart = eventData.StartDateTime.toDate ?
|
||||
eventData.StartDateTime.toDate() :
|
||||
new Date(eventData.StartDateTime);
|
||||
}
|
||||
|
||||
if (eventStart && eventStart > now) {
|
||||
upcomingEvents.push({
|
||||
eventId: eventDoc.id,
|
||||
eventName: eventData.Name || "",
|
||||
startDate: eventStart.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (upcomingEvents.length > 0 && !forceDelete) {
|
||||
res.status(409).json({
|
||||
error: "FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events",
|
||||
upcomingEvents,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection("equipments").doc(equipmentId).delete();
|
||||
|
||||
res.status(200).json({message: "Equipment deleted successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error deleting equipment:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupérer un équipement par ID
|
||||
exports.getEquipment = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasViewAccess && !hasManageAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {equipmentId} = req.body.data || req.query;
|
||||
|
||||
if (!equipmentId) {
|
||||
res.status(400).json({error: "Equipment ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await db.collection("equipments").doc(equipmentId).get();
|
||||
|
||||
if (!doc.exists) {
|
||||
res.status(404).json({error: "Equipment not found"});
|
||||
return;
|
||||
}
|
||||
|
||||
let data = {id: doc.id, ...doc.data()};
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
|
||||
// Masquer les prix si pas de permission manage_equipment
|
||||
data = helpers.maskSensitiveFields(data, hasManageAccess);
|
||||
|
||||
res.status(200).json({equipment: data});
|
||||
} catch (error) {
|
||||
logger.error("Error getting equipment:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupérer plusieurs équipements par leurs IDs
|
||||
exports.getEquipmentsByIds = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasViewAccess && !hasManageAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {equipmentIds} = req.body.data || {};
|
||||
|
||||
if (!equipmentIds || !Array.isArray(equipmentIds) || equipmentIds.length === 0) {
|
||||
res.status(400).json({error: "equipmentIds array is required and must not be empty"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Limiter à 100 équipements max par requête
|
||||
if (equipmentIds.length > 100) {
|
||||
res.status(400).json({error: "Maximum 100 equipment IDs per request"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer tous les documents en parallèle
|
||||
const promises = equipmentIds.map((id) => db.collection("equipments").doc(id).get());
|
||||
const docs = await Promise.all(promises);
|
||||
|
||||
const equipments = [];
|
||||
for (const doc of docs) {
|
||||
if (doc.exists) {
|
||||
let data = {id: doc.id, ...doc.data()};
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
// Masquer les prix si pas de permission manage_equipment
|
||||
data = helpers.maskSensitiveFields(data, hasManageAccess);
|
||||
equipments.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({equipments});
|
||||
} catch (error) {
|
||||
logger.error("Error getting equipments by IDs:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Mettre à jour uniquement le statut d'un équipement
|
||||
exports.updateEquipmentStatusOnly = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const {equipmentId, status, availableQuantity} = req.body.data;
|
||||
|
||||
if (!equipmentId) {
|
||||
res.status(400).json({error: "Equipment ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier les permissions
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {updatedAt: admin.firestore.Timestamp.now()};
|
||||
if (status) updateData.status = status;
|
||||
if (availableQuantity !== undefined) updateData.availableQuantity = availableQuantity;
|
||||
|
||||
await db.collection("equipments").doc(equipmentId).update(updateData);
|
||||
|
||||
res.status(200).json({message: "Equipment status updated successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error updating equipment status:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Mettre à jour le statut de plusieurs équipements (pour préparation/retour)
|
||||
exports.updateEquipmentStatus = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const {eventId, updates} = req.body.data;
|
||||
|
||||
if (!eventId || !updates || !Array.isArray(updates)) {
|
||||
res.status(400).json({error: "Event ID and updates array are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur est assigné à l'événement ou est admin
|
||||
const isAssigned = await auth.isAssignedToEvent(decodedToken.uid, eventId);
|
||||
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||
|
||||
if (!isAssigned && !isAdminUser) {
|
||||
res.status(403).json({error: "Forbidden: Not assigned to this event"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch update
|
||||
const batch = db.batch();
|
||||
|
||||
for (const update of updates) {
|
||||
const {equipmentId, status} = update;
|
||||
if (equipmentId && status) {
|
||||
const equipmentRef = db.collection("equipments").doc(equipmentId);
|
||||
batch.update(equipmentRef, {status});
|
||||
}
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
|
||||
res.status(200).json({message: "Equipment statuses updated successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error updating equipment statuses:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupère les équipements avec pagination et filtrage côté serveur
|
||||
exports.getEquipmentsPaginated = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
|
||||
// Vérifier les permissions
|
||||
const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
|
||||
if (!canManage && !canView) {
|
||||
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer les paramètres de la requête
|
||||
const params = req.method === "GET" ? req.query : (req.body?.data || {});
|
||||
const limit = Math.min(parseInt(params.limit) || 20, 100);
|
||||
const startAfterId = params.startAfter || null;
|
||||
const category = params.category ? params.category.toUpperCase() : null;
|
||||
const status = params.status ? params.status.toUpperCase() : null;
|
||||
const rawSearchQuery = typeof params.searchQuery === "string" ? params.searchQuery.trim() : "";
|
||||
const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null;
|
||||
const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, "") : null;
|
||||
const sortBy = params.sortBy || "id";
|
||||
const sortOrder = params.sortOrder === "desc" ? "desc" : "asc";
|
||||
|
||||
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`);
|
||||
|
||||
// Fast-path pour une recherche d'ID exact
|
||||
if (searchQuery && !startAfterId) {
|
||||
const exactIdCandidates = Array.from(new Set([
|
||||
rawSearchQuery,
|
||||
rawSearchQuery.toUpperCase(),
|
||||
rawSearchQuery.toLowerCase(),
|
||||
].filter(Boolean)));
|
||||
|
||||
for (const candidateId of exactIdCandidates) {
|
||||
const exactDoc = await db.collection("equipments").doc(candidateId).get();
|
||||
if (!exactDoc.exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exactData = exactDoc.data() || {};
|
||||
const matchesCategory = !category || exactData.category === category;
|
||||
const matchesStatus = !status || exactData.status === status;
|
||||
if (!matchesCategory || !matchesStatus) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
delete exactData.purchasePrice;
|
||||
delete exactData.rentalPrice;
|
||||
}
|
||||
|
||||
const exactEquipment = {
|
||||
...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
|
||||
id: exactDoc.id,
|
||||
};
|
||||
|
||||
logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`);
|
||||
res.status(200).json({
|
||||
equipments: [exactEquipment],
|
||||
hasMore: false,
|
||||
lastVisible: exactDoc.id,
|
||||
total: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Compatibilité legacy
|
||||
for (const legacyId of exactIdCandidates) {
|
||||
let legacyIdQuery = db.collection("equipments").where("id", "==", legacyId);
|
||||
if (category) {
|
||||
legacyIdQuery = legacyIdQuery.where("category", "==", category);
|
||||
}
|
||||
if (status) {
|
||||
legacyIdQuery = legacyIdQuery.where("status", "==", status);
|
||||
}
|
||||
|
||||
const legacySnapshot = await legacyIdQuery.limit(1).get();
|
||||
if (legacySnapshot.empty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exactDoc = legacySnapshot.docs[0];
|
||||
const exactData = exactDoc.data() || {};
|
||||
|
||||
if (!canManage) {
|
||||
delete exactData.purchasePrice;
|
||||
delete exactData.rentalPrice;
|
||||
}
|
||||
|
||||
const exactEquipment = {
|
||||
...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
|
||||
id: exactDoc.id,
|
||||
};
|
||||
|
||||
logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`);
|
||||
res.status(200).json({
|
||||
equipments: [exactEquipment],
|
||||
hasMore: false,
|
||||
lastVisible: exactDoc.id,
|
||||
total: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Construire la requête Firestore
|
||||
let query = db.collection("equipments");
|
||||
|
||||
if (category) {
|
||||
query = query.where("category", "==", category);
|
||||
}
|
||||
if (status) {
|
||||
query = query.where("status", "==", status);
|
||||
}
|
||||
|
||||
if (sortBy === "id") {
|
||||
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
|
||||
} else {
|
||||
query = query.orderBy(sortBy, sortOrder);
|
||||
}
|
||||
|
||||
if (startAfterId) {
|
||||
const startAfterDoc = await db.collection("equipments").doc(startAfterId).get();
|
||||
if (startAfterDoc.exists) {
|
||||
query = query.startAfter(startAfterDoc);
|
||||
}
|
||||
}
|
||||
|
||||
const timestampFields = ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"];
|
||||
|
||||
const mapEquipmentDoc = (doc) => {
|
||||
const data = {...(doc.data() || {})};
|
||||
|
||||
if (!canManage) {
|
||||
delete data.purchasePrice;
|
||||
delete data.rentalPrice;
|
||||
}
|
||||
|
||||
const legacyId = typeof data.id === "string" ? data.id : "";
|
||||
|
||||
return {
|
||||
...helpers.serializeTimestamps(data, timestampFields),
|
||||
id: doc.id,
|
||||
_legacyId: legacyId,
|
||||
};
|
||||
};
|
||||
|
||||
const matchesSearchQuery = (equipment) => {
|
||||
const searchableText = [
|
||||
equipment.name || "",
|
||||
equipment.id || "",
|
||||
equipment._legacyId || "",
|
||||
equipment.model || "",
|
||||
equipment.brand || "",
|
||||
equipment.subCategory || "",
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
if (searchableText.includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!compactSearchQuery) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const compactSearchableText = searchableText.replace(/[\s_-]+/g, "");
|
||||
return compactSearchableText.includes(compactSearchQuery);
|
||||
};
|
||||
|
||||
if (!searchQuery) {
|
||||
const snapshot = await query.limit(limit + 1).get();
|
||||
const rawDocCount = snapshot.docs.length;
|
||||
const hasMoreDocs = rawDocCount > limit;
|
||||
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, limit) : snapshot.docs;
|
||||
|
||||
const limitedEquipments = docsToProcess
|
||||
.map(mapEquipmentDoc)
|
||||
.map(({_legacyId, ...equipment}) => equipment);
|
||||
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
|
||||
|
||||
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
|
||||
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`);
|
||||
|
||||
res.status(200).json({
|
||||
equipments: limitedEquipments,
|
||||
hasMore: hasMoreDocs,
|
||||
lastVisible,
|
||||
total: limitedEquipments.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200);
|
||||
const matchedEquipments = [];
|
||||
let scannedDocuments = 0;
|
||||
let searchQueryRef = query;
|
||||
let hasMoreMatches = false;
|
||||
let hasMoreDocsToScan = true;
|
||||
|
||||
while (hasMoreDocsToScan && !hasMoreMatches) {
|
||||
const snapshot = await searchQueryRef.limit(searchBatchSize).get();
|
||||
|
||||
if (snapshot.empty) {
|
||||
hasMoreDocsToScan = false;
|
||||
break;
|
||||
}
|
||||
|
||||
scannedDocuments += snapshot.docs.length;
|
||||
|
||||
for (const doc of snapshot.docs) {
|
||||
const equipment = mapEquipmentDoc(doc);
|
||||
if (!matchesSearchQuery(equipment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matchedEquipments.push(equipment);
|
||||
if (matchedEquipments.length > limit) {
|
||||
hasMoreMatches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMoreMatches) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (snapshot.docs.length < searchBatchSize) {
|
||||
hasMoreDocsToScan = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1];
|
||||
searchQueryRef = query.startAfter(lastDocInBatch);
|
||||
}
|
||||
|
||||
const limitedEquipments = matchedEquipments
|
||||
.slice(0, limit)
|
||||
.map(({_legacyId, ...equipment}) => equipment);
|
||||
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
|
||||
|
||||
logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`);
|
||||
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`);
|
||||
|
||||
res.status(200).json({
|
||||
equipments: limitedEquipments,
|
||||
hasMore: hasMoreMatches,
|
||||
lastVisible,
|
||||
total: limitedEquipments.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching paginated equipments:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Recherche rapide d'équipements et containers pour l'autocomplétion
|
||||
exports.quickSearch = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
|
||||
// Vérifier les permissions
|
||||
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
|
||||
if (!canView) {
|
||||
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const params = req.method === "GET" ? req.query : (req.body?.data || {});
|
||||
const searchQuery = params.query?.toLowerCase() || "";
|
||||
const limit = Math.min(parseInt(params.limit) || 10, 50);
|
||||
const includeEquipments = params.includeEquipments !== "false";
|
||||
const includeContainers = params.includeContainers !== "false";
|
||||
|
||||
if (!searchQuery || searchQuery.length < 2) {
|
||||
res.status(200).json({results: []});
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
// Rechercher dans les équipements
|
||||
if (includeEquipments) {
|
||||
const equipmentSnapshot = await db.collection("equipments")
|
||||
.orderBy("id")
|
||||
.limit(limit * 2)
|
||||
.get();
|
||||
|
||||
equipmentSnapshot.docs.forEach((doc) => {
|
||||
const data = doc.data();
|
||||
const searchableText = [
|
||||
data.name || "",
|
||||
doc.id || "",
|
||||
data.model || "",
|
||||
data.brand || "",
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
if (searchableText.includes(searchQuery)) {
|
||||
results.push({
|
||||
type: "equipment",
|
||||
id: doc.id,
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
model: data.model,
|
||||
brand: data.brand,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Rechercher dans les containers
|
||||
if (includeContainers) {
|
||||
const containerSnapshot = await db.collection("containers")
|
||||
.orderBy("id")
|
||||
.limit(limit * 2)
|
||||
.get();
|
||||
|
||||
containerSnapshot.docs.forEach((doc) => {
|
||||
const data = doc.data();
|
||||
const searchableText = [
|
||||
data.name || "",
|
||||
doc.id || "",
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
if (searchableText.includes(searchQuery)) {
|
||||
results.push({
|
||||
type: "container",
|
||||
id: doc.id,
|
||||
name: data.name,
|
||||
containerType: data.type,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const limitedResults = results
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.id.toLowerCase().startsWith(searchQuery);
|
||||
const bStarts = b.id.toLowerCase().startsWith(searchQuery);
|
||||
if (aStarts && !bStarts) return -1;
|
||||
if (!aStarts && bStarts) return 1;
|
||||
return 0;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
res.status(200).json({results: limitedResults});
|
||||
} catch (error) {
|
||||
logger.error("Error in quick search:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,836 @@
|
||||
const admin = require("firebase-admin");
|
||||
const db = admin.firestore();
|
||||
const logger = require("firebase-functions/logger");
|
||||
const auth = require("../utils/auth");
|
||||
const helpers = require("../utils/helpers");
|
||||
|
||||
// Helper functions for search
|
||||
const normalizeSearchText = (value) => {
|
||||
return (value || "")
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.trim();
|
||||
};
|
||||
|
||||
const getEventStartDate = (eventData) => {
|
||||
const startValue = eventData.StartDateTime;
|
||||
|
||||
if (!startValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startValue.toDate) {
|
||||
return startValue.toDate();
|
||||
}
|
||||
|
||||
const parsedDate = new Date(startValue);
|
||||
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
|
||||
};
|
||||
|
||||
const getEventWorkforceUids = (eventData) => {
|
||||
if (!eventData.workforce || !Array.isArray(eventData.workforce)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return eventData.workforce
|
||||
.map((userRef) => {
|
||||
if (userRef && userRef.id) {
|
||||
return userRef.id;
|
||||
}
|
||||
|
||||
if (typeof userRef === "string" && userRef.startsWith("users/")) {
|
||||
return userRef.split("/")[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((uid) => uid !== null);
|
||||
};
|
||||
|
||||
const serializeEventSearchResult = (doc) => {
|
||||
const data = doc.data();
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(data),
|
||||
workforce: getEventWorkforceUids(data),
|
||||
};
|
||||
};
|
||||
|
||||
// Créer un événement
|
||||
exports.createEvent = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "edit_event");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires edit_event permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const eventData = req.body.data;
|
||||
|
||||
let dataToSave = helpers.deserializeTimestamps(eventData, [
|
||||
"StartDateTime", "EndDateTime", "createdAt", "updatedAt",
|
||||
]);
|
||||
|
||||
dataToSave = helpers.convertIdsToReferences(dataToSave);
|
||||
|
||||
const docRef = await db.collection("events").add(dataToSave);
|
||||
|
||||
res.status(201).json({id: docRef.id, message: "Event created successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error creating event:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Mettre à jour un événement
|
||||
exports.updateEvent = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "edit_event");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires edit_event permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData = req.body.data;
|
||||
logger.info(`Update event - requestData keys: ${Object.keys(requestData || {}).join(", ")}`);
|
||||
|
||||
const eventId = requestData.eventId;
|
||||
logger.info(`Update event - eventId: ${eventId}`);
|
||||
|
||||
if (!eventId) {
|
||||
logger.error("Event ID is missing from request");
|
||||
res.status(400).json({error: "Event ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {eventId: _, ...data} = requestData;
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
res.status(400).json({error: "No data to update"});
|
||||
return;
|
||||
}
|
||||
|
||||
delete data.id;
|
||||
data.updatedAt = admin.firestore.Timestamp.now();
|
||||
|
||||
let dataToSave = helpers.deserializeTimestamps(data, [
|
||||
"StartDateTime", "EndDateTime",
|
||||
]);
|
||||
|
||||
dataToSave = helpers.convertIdsToReferences(dataToSave);
|
||||
|
||||
await db.collection("events").doc(eventId).update(dataToSave);
|
||||
|
||||
res.status(200).json({message: "Event updated successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error updating event:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Supprimer un événement
|
||||
exports.deleteEvent = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "delete_event");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires delete_event permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {eventId} = req.body.data;
|
||||
|
||||
if (!eventId) {
|
||||
res.status(400).json({error: "Event ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection("events").doc(eventId).delete();
|
||||
|
||||
res.status(200).json({message: "Event deleted successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error deleting event:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Met à jour les équipements d'un événement
|
||||
exports.updateEventEquipment = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const {eventId, assignedEquipment, preparationStatus, loadingStatus, unloadingStatus, returnStatus} = req.body.data;
|
||||
|
||||
if (!eventId) {
|
||||
res.status(400).json({error: "Event ID 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 isAdminUser = await auth.hasPermission(decodedToken.uid, "edit_event");
|
||||
|
||||
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"});
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
|
||||
if (assignedEquipment) {
|
||||
updateData.assignedEquipment = assignedEquipment.map((eq) =>
|
||||
helpers.deserializeTimestamps(eq, []),
|
||||
);
|
||||
}
|
||||
|
||||
if (preparationStatus) updateData.preparationStatus = preparationStatus;
|
||||
if (loadingStatus) updateData.loadingStatus = loadingStatus;
|
||||
if (unloadingStatus) updateData.unloadingStatus = unloadingStatus;
|
||||
if (returnStatus) updateData.returnStatus = returnStatus;
|
||||
|
||||
await db.collection("events").doc(eventId).update(updateData);
|
||||
|
||||
res.status(200).json({message: "Event equipment updated successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error updating event equipment:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupérer les événements utilisant un type d'événement
|
||||
exports.getEventsByEventType = async (req, res) => {
|
||||
try {
|
||||
await auth.authenticateUser(req);
|
||||
const {eventTypeId} = req.body.data;
|
||||
|
||||
if (!eventTypeId) {
|
||||
res.status(400).json({error: "Event type ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const eventsSnapshot = await db.collection("events")
|
||||
.where("eventTypeId", "==", eventTypeId)
|
||||
.get();
|
||||
|
||||
const events = eventsSnapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.data().name,
|
||||
startDateTime: doc.data().StartDateTime,
|
||||
}));
|
||||
|
||||
res.status(200).json({events});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching events by type:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupère tous les événements (filtrés selon permissions)
|
||||
exports.getEvents = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const {userId} = req.body.data || {};
|
||||
|
||||
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events");
|
||||
|
||||
let eventsSnapshot;
|
||||
|
||||
if (canViewAll) {
|
||||
eventsSnapshot = await db.collection("events").get();
|
||||
} else {
|
||||
const userRef = db.collection("users").doc(userId || decodedToken.uid);
|
||||
eventsSnapshot = await db.collection("events")
|
||||
.where("workforce", "array-contains", userRef)
|
||||
.get();
|
||||
}
|
||||
|
||||
const userIdsSet = new Set();
|
||||
|
||||
eventsSnapshot.docs.forEach((doc) => {
|
||||
const data = doc.data();
|
||||
if (data.workforce && Array.isArray(data.workforce)) {
|
||||
data.workforce.forEach((userRef) => {
|
||||
if (userRef && userRef.id) {
|
||||
userIdsSet.add(userRef.id);
|
||||
} else if (typeof userRef === "string" && userRef.startsWith("users/")) {
|
||||
userIdsSet.add(userRef.split("/")[1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const usersMap = {};
|
||||
if (userIdsSet.size > 0) {
|
||||
const userIds = Array.from(userIdsSet);
|
||||
const batchSize = 30;
|
||||
|
||||
const batchPromises = [];
|
||||
for (let i = 0; i < userIds.length; i += batchSize) {
|
||||
const batch = userIds.slice(i, i + batchSize);
|
||||
batchPromises.push(
|
||||
db.collection("users")
|
||||
.where(admin.firestore.FieldPath.documentId(), "in", batch)
|
||||
.get(),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(batchPromises);
|
||||
results.forEach((usersSnapshot) => {
|
||||
usersSnapshot.docs.forEach((userDoc) => {
|
||||
const userData = userDoc.data();
|
||||
usersMap[userDoc.id] = {
|
||||
uid: userDoc.id,
|
||||
firstName: userData.firstName || "",
|
||||
lastName: userData.lastName || "",
|
||||
email: userData.email || "",
|
||||
phoneNumber: userData.phoneNumber || "",
|
||||
profilePhotoUrl: userData.profilePhotoUrl || "",
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const events = eventsSnapshot.docs.map((doc) => {
|
||||
const data = doc.data();
|
||||
|
||||
let workforceUids = [];
|
||||
if (data.workforce && Array.isArray(data.workforce)) {
|
||||
workforceUids = data.workforce.map((userRef) => {
|
||||
if (userRef && userRef.id) {
|
||||
return userRef.id;
|
||||
} else if (typeof userRef === "string" && userRef.startsWith("users/")) {
|
||||
return userRef.split("/")[1];
|
||||
}
|
||||
return null;
|
||||
}).filter((uid) => uid !== null);
|
||||
}
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(data),
|
||||
workforce: workforceUids,
|
||||
};
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
events,
|
||||
users: usersMap,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching events:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupère les événements d'un mois spécifique (lazy loading optimisé)
|
||||
exports.getEventsByMonth = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const {userId, year, month} = req.body.data || {};
|
||||
|
||||
if (!year || !month) {
|
||||
res.status(400).json({error: "year and month are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Fetching events for ${year}-${month}`);
|
||||
|
||||
const startOfMonth = admin.firestore.Timestamp.fromDate(
|
||||
new Date(year, month - 1, 1, 0, 0, 0),
|
||||
);
|
||||
const endOfMonth = admin.firestore.Timestamp.fromDate(
|
||||
new Date(year, month, 0, 23, 59, 59),
|
||||
);
|
||||
|
||||
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events");
|
||||
|
||||
let eventsQuery = db.collection("events")
|
||||
.where("StartDateTime", ">=", startOfMonth)
|
||||
.where("StartDateTime", "<=", endOfMonth);
|
||||
|
||||
if (!canViewAll) {
|
||||
const userRef = db.collection("users").doc(userId || decodedToken.uid);
|
||||
eventsQuery = eventsQuery.where("workforce", "array-contains", userRef);
|
||||
}
|
||||
|
||||
const eventsSnapshot = await eventsQuery.get();
|
||||
|
||||
logger.info(`Found ${eventsSnapshot.docs.length} events for ${year}-${month}`);
|
||||
|
||||
const userIdsSet = new Set();
|
||||
|
||||
eventsSnapshot.docs.forEach((doc) => {
|
||||
const data = doc.data();
|
||||
if (data.workforce && Array.isArray(data.workforce)) {
|
||||
data.workforce.forEach((userRef) => {
|
||||
if (userRef && userRef.id) {
|
||||
userIdsSet.add(userRef.id);
|
||||
} else if (typeof userRef === "string" && userRef.startsWith("users/")) {
|
||||
userIdsSet.add(userRef.split("/")[1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const usersMap = {};
|
||||
if (userIdsSet.size > 0) {
|
||||
const userIds = Array.from(userIdsSet);
|
||||
const batchSize = 30;
|
||||
|
||||
const batchPromises = [];
|
||||
for (let i = 0; i < userIds.length; i += batchSize) {
|
||||
const batch = userIds.slice(i, i + batchSize);
|
||||
batchPromises.push(
|
||||
db.collection("users")
|
||||
.where(admin.firestore.FieldPath.documentId(), "in", batch)
|
||||
.get(),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(batchPromises);
|
||||
results.forEach((usersSnapshot) => {
|
||||
usersSnapshot.docs.forEach((userDoc) => {
|
||||
const userData = userDoc.data();
|
||||
usersMap[userDoc.id] = {
|
||||
uid: userDoc.id,
|
||||
firstName: userData.firstName || "",
|
||||
lastName: userData.lastName || "",
|
||||
email: userData.email || "",
|
||||
phoneNumber: userData.phoneNumber || "",
|
||||
profilePhotoUrl: userData.profilePhotoUrl || "",
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const events = eventsSnapshot.docs.map((doc) => {
|
||||
const data = doc.data();
|
||||
|
||||
let workforceUids = [];
|
||||
if (data.workforce && Array.isArray(data.workforce)) {
|
||||
workforceUids = data.workforce.map((userRef) => {
|
||||
if (userRef && userRef.id) {
|
||||
return userRef.id;
|
||||
} else if (typeof userRef === "string" && userRef.startsWith("users/")) {
|
||||
return userRef.split("/")[1];
|
||||
}
|
||||
return null;
|
||||
}).filter((uid) => uid !== null);
|
||||
}
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(data),
|
||||
workforce: workforceUids,
|
||||
};
|
||||
});
|
||||
|
||||
logger.info(`Returning ${events.length} events with ${Object.keys(usersMap).length} unique users`);
|
||||
|
||||
res.status(200).json({
|
||||
events,
|
||||
users: usersMap,
|
||||
month: {year, month},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching events by month:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Recherche des événements accessibles à l'utilisateur
|
||||
exports.searchEvents = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const {userId, query, limit = 20} = req.body.data || {};
|
||||
const maxResults = Number.isFinite(Number(limit)) ? Math.max(1, Number(limit)) : 20;
|
||||
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
if (!normalizedQuery) {
|
||||
res.status(200).json({events: []});
|
||||
return;
|
||||
}
|
||||
|
||||
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events");
|
||||
|
||||
let eventsSnapshot;
|
||||
if (canViewAll) {
|
||||
eventsSnapshot = await db.collection("events").get();
|
||||
} else {
|
||||
const userRef = db.collection("users").doc(userId || decodedToken.uid);
|
||||
eventsSnapshot = await db.collection("events")
|
||||
.where("workforce", "array-contains", userRef)
|
||||
.get();
|
||||
}
|
||||
|
||||
const matchingEvents = eventsSnapshot.docs
|
||||
.filter((doc) => {
|
||||
const eventData = doc.data();
|
||||
const startDate = getEventStartDate(eventData);
|
||||
const searchableText = normalizeSearchText([
|
||||
eventData.Name,
|
||||
eventData.Description,
|
||||
eventData.Address,
|
||||
startDate ? startDate.toLocaleString("fr-FR") : "",
|
||||
startDate ? startDate.toISOString() : "",
|
||||
].join(" "));
|
||||
|
||||
return searchableText.includes(normalizedQuery);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const startA = getEventStartDate(a.data()) || new Date(0);
|
||||
const startB = getEventStartDate(b.data()) || new Date(0);
|
||||
return startA.getTime() - startB.getTime();
|
||||
})
|
||||
.slice(0, maxResults)
|
||||
.map((doc) => serializeEventSearchResult(doc));
|
||||
|
||||
res.status(200).json({events: matchingEvents});
|
||||
} catch (error) {
|
||||
logger.error("Error searching events:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||
exports.getEventWithDetails = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
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 canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events");
|
||||
if (!canViewAll) {
|
||||
const userRef = db.collection("users").doc(decodedToken.uid);
|
||||
const isInWorkforce = eventData.workforce && eventData.workforce.some((ref) =>
|
||||
(ref.id && ref.id === decodedToken.uid) ||
|
||||
(typeof ref === "string" && ref === `users/${decodedToken.uid}`),
|
||||
);
|
||||
|
||||
if (!isInWorkforce) {
|
||||
res.status(403).json({error: "Forbidden: Not assigned to this event"});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[getEventWithDetails] Loading details for event ${eventId}`);
|
||||
|
||||
const equipmentIds = new Set();
|
||||
const containerIds = new Set();
|
||||
|
||||
if (eventData.assignedEquipment && Array.isArray(eventData.assignedEquipment)) {
|
||||
eventData.assignedEquipment.forEach((eq) => {
|
||||
if (eq.equipmentId) {
|
||||
equipmentIds.add(eq.equipmentId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (eventData.assignedContainers && Array.isArray(eventData.assignedContainers)) {
|
||||
eventData.assignedContainers.forEach((id) => containerIds.add(id));
|
||||
}
|
||||
|
||||
logger.info(`[getEventWithDetails] Loading ${equipmentIds.size} equipments and ${containerIds.size} containers`);
|
||||
|
||||
const equipmentPromises = Array.from(equipmentIds).map((id) =>
|
||||
db.collection("equipments").doc(id).get(),
|
||||
);
|
||||
const equipmentDocs = await Promise.all(equipmentPromises);
|
||||
|
||||
const equipmentMap = {};
|
||||
for (const doc of equipmentDocs) {
|
||||
if (doc.exists) {
|
||||
let data = {id: doc.id, ...doc.data()};
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
equipmentMap[doc.id] = data;
|
||||
}
|
||||
}
|
||||
|
||||
const containerPromises = Array.from(containerIds).map((id) =>
|
||||
db.collection("containers").doc(id).get(),
|
||||
);
|
||||
const containerDocs = await Promise.all(containerPromises);
|
||||
|
||||
const childEquipmentIds = new Set();
|
||||
for (const doc of containerDocs) {
|
||||
if (doc.exists) {
|
||||
const containerData = doc.data();
|
||||
if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) {
|
||||
containerData.equipmentIds.forEach((id) => childEquipmentIds.add(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[getEventWithDetails] Loading ${childEquipmentIds.size} child equipments from containers`);
|
||||
|
||||
const childEquipmentPromises = Array.from(childEquipmentIds).map((id) =>
|
||||
db.collection("equipments").doc(id).get(),
|
||||
);
|
||||
const childEquipmentDocs = await Promise.all(childEquipmentPromises);
|
||||
|
||||
for (const doc of childEquipmentDocs) {
|
||||
if (doc.exists && !equipmentMap[doc.id]) {
|
||||
let data = {id: doc.id, ...doc.data()};
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
equipmentMap[doc.id] = data;
|
||||
}
|
||||
}
|
||||
|
||||
const containerMap = {};
|
||||
for (const doc of containerDocs) {
|
||||
if (doc.exists) {
|
||||
let containerData = {id: doc.id, ...doc.data()};
|
||||
containerData = helpers.serializeTimestamps(containerData);
|
||||
containerData = helpers.serializeReferences(containerData);
|
||||
|
||||
if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) {
|
||||
containerData.children = containerData.equipmentIds
|
||||
.map((id) => equipmentMap[id])
|
||||
.filter((eq) => eq !== undefined);
|
||||
} else {
|
||||
containerData.children = [];
|
||||
}
|
||||
|
||||
containerMap[doc.id] = containerData;
|
||||
}
|
||||
}
|
||||
|
||||
const event = {
|
||||
id: eventDoc.id,
|
||||
...helpers.serializeTimestamps(eventData),
|
||||
workforce: eventData.workforce ? eventData.workforce.map((ref) =>
|
||||
(ref.id || (typeof ref === "string" ? ref.split("/")[1] : null)),
|
||||
).filter((uid) => uid !== null) : [],
|
||||
};
|
||||
|
||||
logger.info(`[getEventWithDetails] Returning event with ${Object.keys(equipmentMap).length} equipments and ${Object.keys(containerMap).length} containers`);
|
||||
|
||||
res.status(200).json({
|
||||
event,
|
||||
equipments: equipmentMap,
|
||||
containers: containerMap,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error getting event with details:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Valider un équipement individuel pour le chargement
|
||||
exports.validateEquipmentLoading = 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 = 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 = 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 = 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});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
const admin = require("firebase-admin");
|
||||
const db = admin.firestore();
|
||||
const logger = require("firebase-functions/logger");
|
||||
const auth = require("../utils/auth");
|
||||
const helpers = require("../utils/helpers");
|
||||
|
||||
// Créer une maintenance
|
||||
exports.createMaintenance = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const maintenanceData = req.body.data;
|
||||
|
||||
const dataToSave = helpers.deserializeTimestamps(maintenanceData, [
|
||||
"scheduledDate", "completedDate", "createdAt", "updatedAt",
|
||||
]);
|
||||
|
||||
const docRef = await db.collection("maintenances").add(dataToSave);
|
||||
const maintenanceId = docRef.id;
|
||||
|
||||
if (maintenanceData.equipmentIds && Array.isArray(maintenanceData.equipmentIds)) {
|
||||
for (const equipmentId of maintenanceData.equipmentIds) {
|
||||
try {
|
||||
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||
if (equipmentDoc.exists) {
|
||||
const equipmentData = equipmentDoc.data();
|
||||
const maintenanceIds = equipmentData.maintenanceIds || [];
|
||||
if (!maintenanceIds.includes(maintenanceId)) {
|
||||
maintenanceIds.push(maintenanceId);
|
||||
await db.collection("equipments").doc(equipmentId).update({
|
||||
maintenanceIds: maintenanceIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (maintenanceData.scheduledDate) {
|
||||
const scheduledDate = maintenanceData.scheduledDate.toDate ?
|
||||
maintenanceData.scheduledDate.toDate() :
|
||||
new Date(maintenanceData.scheduledDate);
|
||||
const sevenDaysFromNow = new Date();
|
||||
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
||||
|
||||
if (scheduledDate <= sevenDaysFromNow) {
|
||||
const existingAlerts = await db.collection("alerts")
|
||||
.where("equipmentId", "==", equipmentId)
|
||||
.where("type", "==", "maintenanceDue")
|
||||
.where("isRead", "==", false)
|
||||
.get();
|
||||
|
||||
let alertExists = false;
|
||||
for (const alertDoc of existingAlerts.docs) {
|
||||
const alertData = alertDoc.data();
|
||||
if (alertData.message && alertData.message.includes(maintenanceData.name || "")) {
|
||||
alertExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!alertExists) {
|
||||
const equipmentName = equipmentDoc.exists ?
|
||||
(equipmentDoc.data().name || equipmentId) :
|
||||
equipmentId;
|
||||
|
||||
const daysUntil = Math.ceil((scheduledDate - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
await db.collection("alerts").add({
|
||||
type: "maintenanceDue",
|
||||
message: `Maintenance "${maintenanceData.name || "Sans nom"}" prévue dans ${daysUntil} jour(s) pour ${equipmentName}`,
|
||||
equipmentId: equipmentId,
|
||||
createdAt: admin.firestore.Timestamp.now(),
|
||||
isRead: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error updating equipment ${equipmentId} for maintenance:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({id: maintenanceId, message: "Maintenance created successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error creating maintenance:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Mettre à jour une maintenance
|
||||
exports.updateMaintenance = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {maintenanceId, data} = req.body.data;
|
||||
|
||||
if (!maintenanceId) {
|
||||
res.status(400).json({error: "Maintenance ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
delete data.id;
|
||||
data.updatedAt = admin.firestore.Timestamp.now();
|
||||
|
||||
const dataToSave = helpers.deserializeTimestamps(data, [
|
||||
"scheduledDate", "completedDate",
|
||||
]);
|
||||
|
||||
await db.collection("maintenances").doc(maintenanceId).update(dataToSave);
|
||||
|
||||
res.status(200).json({message: "Maintenance updated successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error updating maintenance:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupérer toutes les maintenances
|
||||
exports.getMaintenances = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const {equipmentId} = req.body.data || {};
|
||||
|
||||
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
|
||||
|
||||
if (!canView) {
|
||||
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
|
||||
return;
|
||||
}
|
||||
|
||||
let query = db.collection("maintenances");
|
||||
|
||||
if (equipmentId) {
|
||||
query = query.where("equipmentIds", "array-contains", equipmentId);
|
||||
}
|
||||
|
||||
const snapshot = await query.get();
|
||||
const maintenances = snapshot.docs.map((doc) => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(data, ["scheduledDate", "completedDate", "createdAt", "updatedAt"]),
|
||||
};
|
||||
});
|
||||
|
||||
res.status(200).json({maintenances});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching maintenances:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Supprimer une maintenance
|
||||
exports.deleteMaintenance = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
|
||||
const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
if (!canManage) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const maintenanceId = req.body.data?.maintenanceId;
|
||||
if (!maintenanceId) {
|
||||
res.status(400).json({error: "maintenanceId is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get();
|
||||
if (maintenanceDoc.exists) {
|
||||
const maintenance = maintenanceDoc.data();
|
||||
|
||||
if (maintenance.equipmentIds) {
|
||||
for (const equipmentId of maintenance.equipmentIds) {
|
||||
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
||||
if (equipmentDoc.exists) {
|
||||
const equipmentData = equipmentDoc.data();
|
||||
const maintenanceIds = (equipmentData.maintenanceIds || []).filter((id) => id !== maintenanceId);
|
||||
await db.collection("equipments").doc(equipmentId).update({maintenanceIds});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.collection("maintenances").doc(maintenanceId).delete();
|
||||
|
||||
res.status(200).json({success: true});
|
||||
} catch (error) {
|
||||
logger.error("Error deleting maintenance:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Vérifier les maintenances à venir et créer des alertes
|
||||
*/
|
||||
exports.checkUpcomingMaintenances = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const sevenDaysFromNow = new Date();
|
||||
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
||||
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
const sevenDaysTimestamp = admin.firestore.Timestamp.fromDate(sevenDaysFromNow);
|
||||
|
||||
// Récupérer les maintenances planifiées dans les 7 prochains jours
|
||||
const maintenancesSnapshot = await db.collection("maintenances")
|
||||
.where("scheduledDate", "<=", sevenDaysTimestamp)
|
||||
.where("scheduledDate", ">=", now)
|
||||
.get();
|
||||
|
||||
const alertsCreated = [];
|
||||
|
||||
for (const doc of maintenancesSnapshot.docs) {
|
||||
const maintenance = doc.data();
|
||||
|
||||
// Vérifier si une alerte existe déjà pour cette maintenance
|
||||
const existingAlertSnapshot = await db.collection("alerts")
|
||||
.where("type", "==", "MAINTENANCE_DUE")
|
||||
.where("relatedMaintenanceId", "==", doc.id)
|
||||
.get();
|
||||
|
||||
if (existingAlertSnapshot.empty) {
|
||||
// Créer une nouvelle alerte
|
||||
const alertData = {
|
||||
type: "MAINTENANCE_DUE",
|
||||
title: `Maintenance à venir`,
|
||||
message: `Une maintenance est prévue pour ${maintenance.equipmentIds?.length || 0} équipement(s)`,
|
||||
severity: "MEDIUM",
|
||||
isRead: false,
|
||||
relatedMaintenanceId: doc.id,
|
||||
createdAt: admin.firestore.Timestamp.now(),
|
||||
};
|
||||
|
||||
const alertRef = await db.collection("alerts").add(alertData);
|
||||
alertsCreated.push({id: alertRef.id, ...alertData});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
alertsCreated: alertsCreated.length,
|
||||
alerts: alertsCreated,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error checking upcoming maintenances:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Compléter une maintenance
|
||||
*/
|
||||
exports.completeMaintenance = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {maintenanceId, performedBy, cost} = req.body.data;
|
||||
|
||||
if (!maintenanceId) {
|
||||
res.status(400).json({error: "maintenanceId is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
const updateData = {
|
||||
completedDate: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (performedBy) {
|
||||
updateData.performedBy = performedBy;
|
||||
}
|
||||
|
||||
if (cost !== undefined && cost !== null) {
|
||||
updateData.cost = cost;
|
||||
}
|
||||
|
||||
// Mettre à jour la maintenance
|
||||
await db.collection("maintenances").doc(maintenanceId).update(updateData);
|
||||
|
||||
// Récupérer la maintenance pour mettre à jour les équipements
|
||||
const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get();
|
||||
const maintenanceData = maintenanceDoc.data();
|
||||
|
||||
// Mettre à jour la date de dernière maintenance des équipements
|
||||
if (maintenanceData && maintenanceData.equipmentIds) {
|
||||
const updatePromises = maintenanceData.equipmentIds.map((equipmentId) =>
|
||||
db.collection("equipments").doc(equipmentId).update({
|
||||
lastMaintenanceDate: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
}
|
||||
|
||||
res.status(200).json({success: true});
|
||||
} catch (error) {
|
||||
logger.error("Error completing maintenance:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
const admin = require("firebase-admin");
|
||||
const db = admin.firestore();
|
||||
const logger = require("firebase-functions/logger");
|
||||
const auth = require("../utils/auth");
|
||||
const helpers = require("../utils/helpers");
|
||||
|
||||
// Récupérer toutes les options (public pour utilisateurs authentifiés)
|
||||
exports.getOptions = async (req, res) => {
|
||||
try {
|
||||
await auth.authenticateUser(req);
|
||||
|
||||
const snapshot = await db.collection("options").get();
|
||||
const options = snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(doc.data()),
|
||||
}));
|
||||
|
||||
res.status(200).json({options});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching options:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupérer tous les types d'événements (public pour utilisateurs authentifiés)
|
||||
exports.getEventTypes = async (req, res) => {
|
||||
try {
|
||||
await auth.authenticateUser(req);
|
||||
|
||||
const snapshot = await db.collection("eventTypes").get();
|
||||
const eventTypes = snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(doc.data()),
|
||||
}));
|
||||
|
||||
res.status(200).json({eventTypes});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching event types:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Créer une option
|
||||
exports.createOption = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||
|
||||
if (!isAdminUser) {
|
||||
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const optionData = req.body.data;
|
||||
const optionId = optionData.id;
|
||||
|
||||
if (!optionId) {
|
||||
res.status(400).json({error: "Option ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection("options").doc(optionId).set(optionData);
|
||||
|
||||
res.status(201).json({id: optionId, message: "Option created successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error creating option:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Mettre à jour une option
|
||||
exports.updateOption = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||
|
||||
if (!isAdminUser) {
|
||||
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {optionId, data} = req.body.data;
|
||||
|
||||
if (!optionId) {
|
||||
res.status(400).json({error: "Option ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
delete data.id;
|
||||
|
||||
await db.collection("options").doc(optionId).update(data);
|
||||
|
||||
res.status(200).json({message: "Option updated successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error updating option:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Supprimer une option
|
||||
exports.deleteOption = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||
|
||||
if (!isAdminUser) {
|
||||
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {optionId} = req.body.data;
|
||||
|
||||
if (!optionId) {
|
||||
res.status(400).json({error: "Option ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection("options").doc(optionId).delete();
|
||||
|
||||
res.status(200).json({message: "Option deleted successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error deleting option:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Créer un type d'événement
|
||||
exports.createEventType = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
|
||||
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({error: "Forbidden: Admin permission required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {name, defaultPrice} = req.body.data;
|
||||
|
||||
if (!name || defaultPrice === undefined) {
|
||||
res.status(400).json({error: "Name and defaultPrice are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingSnapshot = await db.collection("eventTypes")
|
||||
.where("name", "==", name)
|
||||
.get();
|
||||
|
||||
if (!existingSnapshot.empty) {
|
||||
res.status(409).json({error: "Event type name already exists"});
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTypeData = {
|
||||
name,
|
||||
defaultPrice,
|
||||
createdAt: admin.firestore.Timestamp.now(),
|
||||
};
|
||||
|
||||
const docRef = await db.collection("eventTypes").add(eventTypeData);
|
||||
|
||||
res.status(201).json({id: docRef.id, message: "Event type created successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error creating event type:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Mettre à jour un type d'événement
|
||||
exports.updateEventType = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
|
||||
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({error: "Forbidden: Admin permission required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {eventTypeId, name, defaultPrice} = req.body.data;
|
||||
|
||||
if (!eventTypeId) {
|
||||
res.status(400).json({error: "Event type ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const docRef = db.collection("eventTypes").doc(eventTypeId);
|
||||
const doc = await docRef.get();
|
||||
|
||||
if (!doc.exists) {
|
||||
res.status(404).json({error: "Event type not found"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
const existingSnapshot = await db.collection("eventTypes")
|
||||
.where("name", "==", name)
|
||||
.get();
|
||||
|
||||
const hasDuplicate = existingSnapshot.docs.some((d) => d.id !== eventTypeId);
|
||||
if (hasDuplicate) {
|
||||
res.status(409).json({error: "Event type name already exists"});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (name) updateData.name = name;
|
||||
if (defaultPrice !== undefined) updateData.defaultPrice = defaultPrice;
|
||||
|
||||
await docRef.update(updateData);
|
||||
|
||||
res.status(200).json({message: "Event type updated successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error updating event type:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Supprimer un type d'événement
|
||||
exports.deleteEventType = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
|
||||
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({error: "Forbidden: Admin permission required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {eventTypeId} = req.body.data;
|
||||
|
||||
if (!eventTypeId) {
|
||||
res.status(400).json({error: "Event type ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const eventsSnapshot = await db.collection("events")
|
||||
.where("eventTypeId", "==", eventTypeId)
|
||||
.get();
|
||||
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
const futureEvents = eventsSnapshot.docs.filter((doc) => {
|
||||
const startDate = doc.data().StartDateTime;
|
||||
return startDate && startDate > now;
|
||||
});
|
||||
|
||||
if (futureEvents.length > 0) {
|
||||
res.status(409).json({
|
||||
error: "Cannot delete event type with future events",
|
||||
futureEventsCount: futureEvents.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection("eventTypes").doc(eventTypeId).delete();
|
||||
|
||||
res.status(200).json({message: "Event type deleted successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error deleting event type:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
const admin = require("firebase-admin");
|
||||
const {Storage} = require("@google-cloud/storage");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const auth = require("../utils/auth");
|
||||
|
||||
const storage = new Storage();
|
||||
|
||||
exports.moveEventFileV2 = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const {sourcePath, destinationPath} = req.body.data || {};
|
||||
|
||||
if (!sourcePath || !destinationPath) {
|
||||
res.status(400).json({error: "Source and destination paths are required."});
|
||||
return;
|
||||
}
|
||||
|
||||
const bucketName = admin.storage().bucket().name;
|
||||
const bucket = storage.bucket(bucketName);
|
||||
|
||||
await bucket.file(sourcePath).copy(bucket.file(destinationPath));
|
||||
await bucket.file(sourcePath).delete();
|
||||
const [url] = await bucket.file(destinationPath).getSignedUrl({
|
||||
action: "read",
|
||||
expires: "03-01-2500",
|
||||
});
|
||||
|
||||
res.status(200).json({url});
|
||||
} catch (error) {
|
||||
logger.error("Error moving file:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,375 @@
|
||||
'use strict';
|
||||
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const csv = require('csv-parser');
|
||||
const polylineLib = require('@mapbox/polyline');
|
||||
const auth = require('../utils/auth');
|
||||
const logger = require('firebase-functions/logger');
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Chargement du CSV des gares de péage (cache mémoire)
|
||||
// ─────────────────────────────────────────────
|
||||
let _tollStations = null;
|
||||
|
||||
function loadTollStations() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (_tollStations) return resolve(_tollStations);
|
||||
const csvPath = path.join(__dirname, '../travel/gares_peage_export.csv');
|
||||
if (!fs.existsSync(csvPath)) {
|
||||
logger.warn('[Travel] CSV not found at ' + csvPath);
|
||||
_tollStations = [];
|
||||
return resolve(_tollStations);
|
||||
}
|
||||
const results = [];
|
||||
fs.createReadStream(csvPath)
|
||||
.pipe(csv())
|
||||
.on('data', (row) => {
|
||||
if (row.id_gare && row.lat && row.lon) {
|
||||
results.push({
|
||||
id: row.id_gare,
|
||||
operatorId: row.id_gare.substring(0, 2),
|
||||
tollId: row.id_gare.substring(2, 5),
|
||||
name: row.nom || '',
|
||||
lat: parseFloat(row.lat),
|
||||
lon: parseFloat(row.lon),
|
||||
});
|
||||
}
|
||||
})
|
||||
.on('end', () => { _tollStations = results; resolve(results); })
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Ulys — Détection des péages sur un tracé
|
||||
// POST https://api-ulys.azure-api.net/placemark/v2/legs?precision=6&includeLayersIds=GaresPeage
|
||||
// Body = la polyline encodée (string brute, pas de JSON wrapper)
|
||||
// ─────────────────────────────────────────────
|
||||
async function getUlysTollLegs(encodedPolyline) {
|
||||
try {
|
||||
const polylineCoords = polylineLib.decode(encodedPolyline);
|
||||
const ulysUrl = 'https://api-ulys.azure-api.net/placemark/v2/legs?precision=5&includeLayersIds=GaresPeage';
|
||||
let finalPolyline = encodedPolyline;
|
||||
|
||||
// OPTION 1 : Mapbox Route Recreation
|
||||
if (process.env.MAPBOX_API_KEY && polylineCoords.length > 2) {
|
||||
logger.info('[Travel] MAPBOX_API_KEY is present. Recreating route with Mapbox for Ulys precision...');
|
||||
try {
|
||||
// Envoyer uniquement le point de départ et le point d'arrivée
|
||||
// Mapbox s'occupe de recréer l'itinéraire complet de la meilleure façon
|
||||
const waypoints = [polylineCoords[0], polylineCoords[polylineCoords.length - 1]];
|
||||
|
||||
// Mapbox expects longitude,latitude
|
||||
const coordinatesString = waypoints.map(p => `${p[1]},${p[0]}`).join(';');
|
||||
const mapboxUrl = `https://api.mapbox.com/directions/v5/mapbox/driving/${coordinatesString}?geometries=polyline&overview=full&access_token=${process.env.MAPBOX_API_KEY}`;
|
||||
|
||||
const mapboxRes = await axios.get(mapboxUrl);
|
||||
if (mapboxRes.data && mapboxRes.data.routes && mapboxRes.data.routes.length > 0) {
|
||||
finalPolyline = mapboxRes.data.routes[0].geometry;
|
||||
logger.info('[Travel] Mapbox route recreation successful.');
|
||||
}
|
||||
} catch (mapboxErr) {
|
||||
logger.error('[Travel] Mapbox API error:', mapboxErr.response ? mapboxErr.response.data : mapboxErr.message);
|
||||
// Fallback to Google Maps polyline if Mapbox fails
|
||||
}
|
||||
}
|
||||
|
||||
// Appeler Ulys /legs
|
||||
const res = await axios.post(
|
||||
ulysUrl,
|
||||
JSON.stringify(finalPolyline),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Host': 'api-ulys.azure-api.net'
|
||||
},
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
logger.warn('[Travel] Ulys /legs failed:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Ulys — Tarif pour un segment (entrée → sortie)
|
||||
// POST https://api-ulys.azure-api.net/tollstation/v1/rate
|
||||
// ─────────────────────────────────────────────
|
||||
async function getUlysRate(vehicleCategory, passages) {
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const payload = {
|
||||
vehicleCategory: String(vehicleCategory),
|
||||
paymentOption: 2,
|
||||
tollPassages: passages.map((p) => ({
|
||||
toll: { operatorId: p.operatorId, tollId: p.tollId },
|
||||
passageDate: now,
|
||||
})),
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
const res = await axios.post(
|
||||
'https://api-ulys.azure-api.net/tollstation/v1/rate',
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body).toString(),
|
||||
'Host': 'api-ulys.azure-api.net',
|
||||
},
|
||||
timeout: 8000,
|
||||
},
|
||||
);
|
||||
const data = res.data;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
if (passages.length === 2) {
|
||||
// We expect a single closed system response
|
||||
if (data.length === 1 && data[0].entranceToll && data[0].exitToll && data[0].price > 0) {
|
||||
return data[0].price;
|
||||
}
|
||||
return null;
|
||||
} else if (passages.length === 1) {
|
||||
if (data.length === 1 && data[0].price > 0) {
|
||||
return data[0].price;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Calcul du total de péage via Ulys /legs puis /rate
|
||||
// ─────────────────────────────────────────────
|
||||
async function calculateTollCost(encodedPolyline, vehicleCategory) {
|
||||
try {
|
||||
// 1. Demander à Ulys les gares sur le tracé
|
||||
const legsData = await getUlysTollLegs(encodedPolyline);
|
||||
const features = Array.isArray(legsData) ? legsData : (legsData && legsData.features ? legsData.features : []);
|
||||
|
||||
if (features && features.length > 0) {
|
||||
// Extraire les gares dans l'ordre du tracé
|
||||
const tollGates = [];
|
||||
for (const feature of features) {
|
||||
const props = feature.properties || feature.Placemark || feature.placemark || {};
|
||||
|
||||
// La réponse Ulys peut utiliser différents noms de champs
|
||||
// On cherche l'identifiant de la gare dans tous les champs connus
|
||||
let id =
|
||||
props.id_gare ||
|
||||
props.idGare ||
|
||||
props.id ||
|
||||
props.gareId ||
|
||||
props.gare_id ||
|
||||
props.tollStationId;
|
||||
|
||||
if (!id && props.Code) {
|
||||
id = props.Code.split('_')[0];
|
||||
}
|
||||
|
||||
if (!id) continue;
|
||||
const idStr = String(id);
|
||||
if (idStr.length < 5) continue;
|
||||
|
||||
tollGates.push({
|
||||
id: idStr,
|
||||
operatorId: idStr.substring(0, 2),
|
||||
tollId: idStr.substring(2, 5),
|
||||
name: props.nom || props.name || props.label || idStr,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`[Travel] Ulys /legs found ${tollGates.length} toll gates`);
|
||||
if (tollGates.length === 0) return 0;
|
||||
|
||||
// Greedy: trouver les segments fermés + barrières ouvertes
|
||||
return await _computeTollFromGates(tollGates, vehicleCategory);
|
||||
}
|
||||
|
||||
// Fallback : pas de résultat Ulys /legs, retourner 0
|
||||
logger.info('[Travel] Ulys /legs returned no toll gates for this route');
|
||||
return 0;
|
||||
} catch (e) {
|
||||
logger.error('[Travel] calculateTollCost error:', e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function _computeTollFromGates(gates, vehicleCategory) {
|
||||
let total = 0;
|
||||
let i = 0;
|
||||
while (i < gates.length) {
|
||||
let found = false;
|
||||
// Essayer le segment fermé le plus long possible (greedy backward)
|
||||
for (let j = gates.length - 1; j > i; j--) {
|
||||
const price = await getUlysRate(vehicleCategory, [gates[i], gates[j]]);
|
||||
if (price !== null && price > 0) {
|
||||
total += price;
|
||||
i = j;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// Barrière ouverte : tarif unitaire
|
||||
const price = await getUlysRate(vehicleCategory, [gates[i]]);
|
||||
if (price !== null && price > 0 && price < 20) {
|
||||
total += price;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// EXPORT: Google Maps Autocomplete (proxy CORS)
|
||||
// ─────────────────────────────────────────────
|
||||
exports.googleMapsAutocomplete = async (req, res) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(204).send('');
|
||||
}
|
||||
try {
|
||||
await auth.authenticateUser(req);
|
||||
|
||||
const body = req.body.data || req.body;
|
||||
const query = body.query || req.query.query;
|
||||
if (!query) return res.status(400).json({ error: 'query is required' });
|
||||
|
||||
const apiKey = process.env.API_MAPS;
|
||||
if (!apiKey) return res.status(500).json({ error: 'API_MAPS not configured in .env' });
|
||||
|
||||
const url = new URL('https://maps.googleapis.com/maps/api/place/autocomplete/json');
|
||||
url.searchParams.set('input', query);
|
||||
url.searchParams.set('key', apiKey);
|
||||
url.searchParams.set('language', 'fr');
|
||||
url.searchParams.set('components', 'country:fr');
|
||||
url.searchParams.set('types', 'address');
|
||||
|
||||
const gRes = await axios.get(url.toString(), { timeout: 5000 });
|
||||
return res.status(200).json(gRes.data);
|
||||
} catch (e) {
|
||||
logger.error('[Travel] googleMapsAutocomplete error:', e.message);
|
||||
return res.status(500).json({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// EXPORT: Google Maps Compute Route (2 itinéraires + péages Ulys)
|
||||
// ─────────────────────────────────────────────
|
||||
exports.googleMapsComputeRoute = async (req, res) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(204).send('');
|
||||
}
|
||||
try {
|
||||
await auth.authenticateUser(req);
|
||||
|
||||
const body = req.body.data || req.body;
|
||||
const { origin, destination, vehicleTollCategory = 2 } = body;
|
||||
|
||||
if (!origin || !destination) {
|
||||
return res.status(400).json({ error: 'origin and destination are required' });
|
||||
}
|
||||
|
||||
const apiKey = process.env.API_MAPS;
|
||||
if (!apiKey) return res.status(500).json({ error: 'API_MAPS not configured in .env' });
|
||||
|
||||
const routesUrl = 'https://routes.googleapis.com/directions/v2:computeRoutes';
|
||||
const fieldMask = [
|
||||
'routes.distanceMeters',
|
||||
'routes.duration',
|
||||
'routes.polyline.encodedPolyline',
|
||||
'routes.travelAdvisory.tollInfo',
|
||||
].join(',');
|
||||
|
||||
const commonPayload = {
|
||||
travelMode: 'DRIVE',
|
||||
routingPreference: 'TRAFFIC_AWARE',
|
||||
origin: { address: origin },
|
||||
destination: { address: destination },
|
||||
};
|
||||
|
||||
const [resToll, resNoToll] = await Promise.all([
|
||||
axios.post(routesUrl, { ...commonPayload, routeModifiers: { avoidTolls: false } }, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': fieldMask,
|
||||
},
|
||||
timeout: 15000,
|
||||
}),
|
||||
axios.post(routesUrl, { ...commonPayload, routeModifiers: { avoidTolls: true } }, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': fieldMask,
|
||||
},
|
||||
timeout: 15000,
|
||||
}),
|
||||
]);
|
||||
|
||||
const routes = [];
|
||||
|
||||
console.log("resToll.data.routes length:", resToll.data.routes ? resToll.data.routes.length : 0);
|
||||
if (!resToll.data.routes) console.log("resToll.data:", JSON.stringify(resToll.data, null, 2));
|
||||
|
||||
// --- Route avec péage ---
|
||||
if (resToll.data.routes && resToll.data.routes.length > 0) {
|
||||
const r = resToll.data.routes[0];
|
||||
const poly = r.polyline?.encodedPolyline || '';
|
||||
let tollCost = 0;
|
||||
if (poly) {
|
||||
tollCost = await calculateTollCost(poly, vehicleTollCategory);
|
||||
}
|
||||
routes.push({
|
||||
routeType: 'TOLL',
|
||||
distanceMeters: r.distanceMeters || 0,
|
||||
durationSeconds: _parseDuration(r.duration),
|
||||
encodedPolyline: poly,
|
||||
tollCost,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Route sans péage ---
|
||||
if (resNoToll.data.routes && resNoToll.data.routes.length > 0) {
|
||||
const r = resNoToll.data.routes[0];
|
||||
const poly = r.polyline?.encodedPolyline || '';
|
||||
|
||||
// N'ajouter que si différente de la route avec péage
|
||||
const isDifferent = routes.length === 0 ||
|
||||
r.distanceMeters !== routes[0].distanceMeters ||
|
||||
Math.abs(_parseDuration(r.duration) - routes[0].durationSeconds) > 60;
|
||||
|
||||
if (isDifferent) {
|
||||
routes.push({
|
||||
routeType: 'TOLL_FREE',
|
||||
distanceMeters: r.distanceMeters || 0,
|
||||
durationSeconds: _parseDuration(r.duration),
|
||||
encodedPolyline: poly,
|
||||
tollCost: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({ routes });
|
||||
} catch (e) {
|
||||
logger.error('[Travel] googleMapsComputeRoute error:', e.message, e.response?.data);
|
||||
return res.status(500).json({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
function _parseDuration(durationStr) {
|
||||
if (!durationStr) return 0;
|
||||
if (typeof durationStr === 'number') return durationStr;
|
||||
// Format: "1234s"
|
||||
const match = String(durationStr).match(/^(\d+)s?$/);
|
||||
return match ? parseInt(match[1]) : 0;
|
||||
}
|
||||
|
||||
exports.getUlysTollLegs = getUlysTollLegs;
|
||||
@@ -0,0 +1,58 @@
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const auth = require("../utils/auth");
|
||||
|
||||
exports.generateTTSV2 = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
|
||||
logger.info("[generateTTSV2] Request from user:", {
|
||||
uid: decodedToken.uid,
|
||||
email: decodedToken.email,
|
||||
});
|
||||
|
||||
const {text, voiceConfig} = req.body.data || {};
|
||||
|
||||
if (!text) {
|
||||
res.status(400).json({error: "Text parameter is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length > 5000) {
|
||||
res.status(400).json({error: "Text too long (max 5000 characters)"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {Storage} = require("@google-cloud/storage");
|
||||
const storage = new Storage();
|
||||
const bucketName = admin.storage().bucket().name;
|
||||
const bucket = storage.bucket(bucketName);
|
||||
|
||||
const {generateTTS} = require("../generateTTS");
|
||||
const result = await generateTTS(text, storage, bucket, voiceConfig);
|
||||
|
||||
logger.info("[generateTTSV2] ✓ Success", {
|
||||
cached: result.cached,
|
||||
cacheKey: result.cacheKey,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
audioUrl: result.audioUrl,
|
||||
cached: result.cached,
|
||||
cacheKey: result.cacheKey,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[generateTTSV2] ✗ Error:", {
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
|
||||
if (error.code === "PERMISSION_DENIED") {
|
||||
res.status(403).json({error: "Permission denied. Check Google Cloud TTS API is enabled."});
|
||||
} else if (error.code === "QUOTA_EXCEEDED") {
|
||||
res.status(429).json({error: "TTS quota exceeded. Try again later."});
|
||||
} else {
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
const admin = require("firebase-admin");
|
||||
const db = admin.firestore();
|
||||
const logger = require("firebase-functions/logger");
|
||||
const auth = require("../utils/auth");
|
||||
const helpers = require("../utils/helpers");
|
||||
|
||||
// Créer un utilisateur
|
||||
exports.createUser = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||
|
||||
if (!isAdminUser) {
|
||||
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = req.body.data;
|
||||
const userId = userData.uid;
|
||||
|
||||
if (!userId) {
|
||||
res.status(400).json({error: "User ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection("users").doc(userId).set(userData);
|
||||
|
||||
res.status(201).json({id: userId, message: "User created successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error creating user:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Créer un utilisateur avec invitation par email
|
||||
exports.createUserWithInvite = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||
|
||||
if (!isAdminUser) {
|
||||
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {email, firstName, lastName, phoneNumber, roleId} = req.body.data;
|
||||
|
||||
if (!email || !firstName || !lastName || !roleId) {
|
||||
res.status(400).json({error: "email, firstName, lastName, and roleId are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const tempPassword = Math.random().toString(36).slice(-12) + "Aa1!";
|
||||
|
||||
let userRecord;
|
||||
try {
|
||||
userRecord = await admin.auth().createUser({
|
||||
email: email,
|
||||
password: tempPassword,
|
||||
emailVerified: false,
|
||||
displayName: `${firstName} ${lastName}`,
|
||||
});
|
||||
} catch (authError) {
|
||||
logger.error("Error creating user in Auth:", authError);
|
||||
res.status(500).json({error: `Failed to create user in Auth: ${authError.message}`});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.collection("users").doc(userRecord.uid).set({
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
email: email,
|
||||
phoneNumber: phoneNumber || "",
|
||||
profilePhotoUrl: "",
|
||||
role: db.collection("roles").doc(roleId),
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
createdBy: decodedToken.uid,
|
||||
});
|
||||
} catch (firestoreError) {
|
||||
logger.error("Error creating user in Firestore:", firestoreError);
|
||||
try {
|
||||
await admin.auth().deleteUser(userRecord.uid);
|
||||
} catch (cleanupError) {
|
||||
logger.error("Error cleaning up Auth user:", cleanupError);
|
||||
}
|
||||
res.status(500).json({error: `Failed to create user in Firestore: ${firestoreError.message}`});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const axios = require("axios");
|
||||
const firebaseApiKey = "AIzaSyARQL4P-t5l-cNjQNP9cMokQrLJ8BorF0U";
|
||||
|
||||
await axios.post(
|
||||
`https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=${firebaseApiKey}`,
|
||||
{
|
||||
requestType: "PASSWORD_RESET",
|
||||
email: email,
|
||||
},
|
||||
);
|
||||
logger.info(`Password reset email sent to ${email}`);
|
||||
} catch (emailError) {
|
||||
logger.warn(`Could not send password reset email to ${email}: ${emailError.message}`);
|
||||
}
|
||||
|
||||
logger.info(`User ${userRecord.uid} created by ${decodedToken.uid}`);
|
||||
res.status(201).json({
|
||||
id: userRecord.uid,
|
||||
message: "User created successfully. Password reset email sent.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error in createUserWithInvite:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Mettre à jour un utilisateur
|
||||
exports.updateUser = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const {userId, data} = req.body.data;
|
||||
|
||||
if (!userId) {
|
||||
res.status(400).json({error: "User ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Un utilisateur ne peut modifier que son propre compte, sauf s'il est admin
|
||||
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||
if (decodedToken.uid !== userId && !isAdminUser) {
|
||||
res.status(403).json({error: "Forbidden: Cannot update other user accounts"});
|
||||
return;
|
||||
}
|
||||
|
||||
// Empêcher les non-admins de modifier le rôle
|
||||
if (!isAdminUser && data.role) {
|
||||
delete data.role;
|
||||
}
|
||||
|
||||
// Si le rôle est fourni et est un string, le convertir en DocumentReference
|
||||
if (data.role && typeof data.role === "string") {
|
||||
data.role = db.collection("roles").doc(data.role);
|
||||
}
|
||||
|
||||
await db.collection("users").doc(userId).update(data);
|
||||
|
||||
res.status(200).json({message: "User updated successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error updating user:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Supprimer un utilisateur
|
||||
exports.deleteUser = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const isAdminUser = await auth.isAdmin(decodedToken.uid);
|
||||
|
||||
if (!isAdminUser) {
|
||||
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const {userId} = req.body.data;
|
||||
|
||||
if (!userId) {
|
||||
res.status(400).json({error: "User ID is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (decodedToken.uid === userId) {
|
||||
res.status(400).json({error: "Cannot delete your own account"});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection("users").doc(userId).delete();
|
||||
|
||||
try {
|
||||
await admin.auth().deleteUser(userId);
|
||||
} catch (authError) {
|
||||
logger.warn(`Could not delete user from Auth: ${authError.message}`);
|
||||
}
|
||||
|
||||
res.status(200).json({message: "User deleted successfully"});
|
||||
} catch (error) {
|
||||
logger.error("Error deleting user:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupérer tous les utilisateurs (selon permissions)
|
||||
exports.getUsers = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
|
||||
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_users");
|
||||
|
||||
if (!canViewAll) {
|
||||
const userDoc = await db.collection("users").doc(decodedToken.uid).get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
res.status(404).json({error: "User not found"});
|
||||
return;
|
||||
}
|
||||
|
||||
let userData = userDoc.data();
|
||||
userData = helpers.serializeTimestamps(userData);
|
||||
userData = helpers.serializeReferences(userData);
|
||||
|
||||
res.status(200).json({
|
||||
users: [{
|
||||
id: userDoc.id,
|
||||
...userData,
|
||||
}],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await db.collection("users").get();
|
||||
const users = snapshot.docs.map((doc) => {
|
||||
let data = doc.data();
|
||||
data = helpers.serializeTimestamps(data);
|
||||
data = helpers.serializeReferences(data);
|
||||
return {
|
||||
id: doc.id,
|
||||
...data,
|
||||
};
|
||||
});
|
||||
|
||||
res.status(200).json({users});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching users:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupère un utilisateur spécifique
|
||||
exports.getUser = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
|
||||
const {userId} = req.body.data || req.body || {};
|
||||
if (!userId) {
|
||||
res.status(400).json({error: "userId is required"});
|
||||
return;
|
||||
}
|
||||
|
||||
const userDoc = await db.collection("users").doc(userId).get();
|
||||
if (!userDoc.exists) {
|
||||
res.status(404).json({error: "User not found"});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userDoc.data();
|
||||
|
||||
const userData = {
|
||||
id: userDoc.id,
|
||||
uid: user.uid || userDoc.id,
|
||||
email: user.email || "",
|
||||
firstName: user.firstName || "",
|
||||
lastName: user.lastName || "",
|
||||
phoneNumber: user.phoneNumber || "",
|
||||
profilePhotoUrl: user.profilePhotoUrl || "",
|
||||
};
|
||||
|
||||
if (user.role) {
|
||||
const roleDoc = await user.role.get();
|
||||
if (roleDoc.exists) {
|
||||
userData.role = {
|
||||
id: roleDoc.id,
|
||||
...roleDoc.data(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({user: userData});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching user:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupère l'utilisateur actuellement authentifié avec son rôle
|
||||
exports.getCurrentUser = async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const userId = decodedToken.uid;
|
||||
|
||||
const userDoc = await db.collection("users").doc(userId).get();
|
||||
if (!userDoc.exists) {
|
||||
res.status(404).json({error: "User not found"});
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = userDoc.data();
|
||||
|
||||
let roleData = null;
|
||||
if (userData.role) {
|
||||
const roleDoc = await userData.role.get();
|
||||
if (roleDoc.exists) {
|
||||
roleData = {id: roleDoc.id, ...roleDoc.data()};
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
user: {
|
||||
uid: userId,
|
||||
...helpers.serializeTimestamps(userData),
|
||||
role: roleData,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error getting current user:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
|
||||
// Récupère tous les rôles
|
||||
exports.getRoles = async (req, res) => {
|
||||
try {
|
||||
await auth.authenticateUser(req);
|
||||
|
||||
const snapshot = await db.collection("roles").get();
|
||||
const roles = snapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(doc.data()),
|
||||
}));
|
||||
|
||||
res.status(200).json({roles});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching roles:", error);
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,643 @@
|
||||
id_gare,nom,lat,lon
|
||||
09242,BEAUPONT,46.44094,5.26993
|
||||
10157,BELLEGARDE,46.11602,5.79506
|
||||
09147,SYLANS,46.15853,5.6484
|
||||
09146,ST MARTIN-DU-FRESNE,46.13243,5.54164
|
||||
09156,LA CROIX-CHALON,46.17772,5.5589
|
||||
09145,BOURG SUD,46.14563,5.28633
|
||||
09144,VIRIAT,46.23576,5.27355
|
||||
09149,SAINT GENIS,46.27197,5.01576
|
||||
09143,BOURG NORD,46.2599,5.16695
|
||||
09142,REPLONGES,46.30076,4.89584
|
||||
09141,FEILLENS,46.32883,4.88241
|
||||
09150,BEYNOST,45.82045,4.99411
|
||||
09153,LA BOISSE,45.82704,5.03146
|
||||
09253,LA COTIERE,45.83409,5.0324
|
||||
09251,LA BOISSE - MONTLUEL,45.8407,5.04702
|
||||
09422,MIONNAY,45.883,4.89889
|
||||
09151,BALAN,45.85092,5.09938
|
||||
09152,PEROUGES,45.86086,5.1813
|
||||
09154,AMBERIEU,45.97765,5.3127
|
||||
09155,PONT D AIN,46.04812,5.32788
|
||||
09443,CROTTET,46.28678,4.87001
|
||||
07144,CHATEAU-THIERRY,49.07832,3.39881
|
||||
07141,MONTREUIL,49.01321,3.15531
|
||||
07146,DORMANS,49.15379,3.71689
|
||||
07032,ST QUENTIN SUD,49.81227,3.29479
|
||||
07031,ST QUENTIN NORD,49.86139,3.24072
|
||||
07033,LA FERE,49.6872,3.46533
|
||||
07034,LAON,49.6078,3.66102
|
||||
07136,REIMS,49.32216,3.98417
|
||||
07135,GUIGNICOURT,49.42699,3.92801
|
||||
09062,FORET DE TRONCAIS,46.50647,2.63033
|
||||
09064,MONTMARAULT,46.32764,2.96645
|
||||
06060,MANOSQUE,43.80781,5.81289
|
||||
06059,ST PAUL LES DURANCE,43.70249,5.7346
|
||||
06061,LA BRILLANNE,43.92395,5.89373
|
||||
06063,PEYRUIS,44.03919,5.96354
|
||||
06065,AUBIGNOSC,44.12443,5.98428
|
||||
06064,AUBIGNOSC OUEST,44.12513,5.97854
|
||||
06067,SISTERON SUD,44.17168,5.95542
|
||||
06068,SISTERON NORD,44.2236,5.91579
|
||||
06014,ANTIBES OUEST,43.6028,7.07248
|
||||
06024,SOPHIA,43.60275,7.08124
|
||||
06022,ANTIBES PV NORD,43.60463,7.06645
|
||||
06023,ANTIBES EST SORTIE,43.60421,7.08636
|
||||
06017,CAGNES OUEST SUD,43.64321,7.13404
|
||||
06016,CAGNES EST,43.65933,7.14863
|
||||
06015,CAGNES OUEST,43.64804,7.13906
|
||||
06013,ANTIBES EST,43.60301,7.08397
|
||||
06019,ST ISIDORE ECH OUEST,43.70408,7.19005
|
||||
06025,MONACO,43.74513,7.37264
|
||||
06026,LA TURBIE ECH.,43.74346,7.37835
|
||||
04358,PAMIERS,43.15415,1.61603
|
||||
04357,MAZERES SAVERDUN,43.23448,1.63595
|
||||
09173,SAINT THIBAULT,48.22341,4.12962
|
||||
09174,TORVILLIERS,48.2844,3.96602
|
||||
09183,THENNELIERES,48.28933,4.16302
|
||||
09172,MAGNANT,48.17907,4.44056
|
||||
09171,VILLE SOUS LAFERTE,48.11496,4.78555
|
||||
09170,CHAUMONT-SEMOUTIERS,48.04283,5.05935
|
||||
07186,VALLEE DE L'AUBE,48.52551,4.18556
|
||||
07185,CHARMONT-S/BARBUISE,48.41039,4.14267
|
||||
04339,NARBONNE-SUD,43.16443,2.99012
|
||||
04338,NARBONNE-EST,43.17904,3.03496
|
||||
04340,SIGEAN,43.03314,2.95619
|
||||
04341,LEUCATE,42.93912,2.97304
|
||||
04350,CASTELNAUDARY,43.29079,1.94671
|
||||
04349,BRAM,43.23897,2.10001
|
||||
04348,CARCASSONNE-O,43.19993,2.31004
|
||||
04347,CARCASSONNE-E,43.20205,2.4182
|
||||
04346,LEZIGNAN,43.17187,2.74107
|
||||
04213,CAVAILLON,43.81988,5.02777
|
||||
04214,SENAS,43.74201,5.08971
|
||||
04215,SALON NORD,43.65943,5.10259
|
||||
04279,A8: AIX-EN-PROVENCE,43.55106,5.23769
|
||||
04278,COUDOUX,43.55294,5.23863
|
||||
06001,CANET DE MEYREUIL,43.49465,5.52496
|
||||
06035,CASSIS,43.22517,5.58151
|
||||
06034,LA CIOTAT ECH.,43.20208,5.59476
|
||||
06054,PERTUIS SUD,43.66218,5.49758
|
||||
06038,PAS DE TRETS,43.3863,5.6018
|
||||
06036,PONT DE L ETOILE,43.32437,5.59801
|
||||
04267,GRANS,43.62304,5.08354
|
||||
04266,SALON OUEST,43.6364,5.02274
|
||||
04219,SALON SUD,43.62598,5.1015
|
||||
08612,DOZULE FL ECH,49.22992,-0.08247
|
||||
08621,TROARN FL,49.1778,-0.20296
|
||||
08631,CAGNY FL,49.16833,-0.24512
|
||||
04536,ST-JEAN D'ANGELY,45.96286,-0.54589
|
||||
04537,SAINTES,45.75224,-0.66399
|
||||
04538,PONS,45.5745,-0.59558
|
||||
04540,SAINT-AUBIN,45.25434,-0.5478
|
||||
04539,MIRAMBEAU,45.37766,-0.57955
|
||||
04545,TONNAY-CHARENTE,45.95515,-0.88097
|
||||
05056,VIERZON-EST,47.2118,2.11835
|
||||
05055,VIERZON-NORD,47.24397,2.06412
|
||||
05057,BOURGES,47.04492,2.34171
|
||||
09061,ST AMAND-MONTROND,46.722,2.45831
|
||||
04118,MANSAC,45.15247,1.37821
|
||||
04123,TULLE NORD,45.32901,1.76396
|
||||
04124,TULLE EST,45.31812,1.85046
|
||||
04125,EGLETONS,45.40739,2.02119
|
||||
04126,USSEL OUEST,45.51246,2.25888
|
||||
04127,USSEL EST,45.58897,2.41459
|
||||
09111,BIERRE-LES-SEMUR,47.43663,4.30944
|
||||
09110,AVALLON,47.50932,3.99198
|
||||
09114,BEAUNE SUD,47.0024,4.85476
|
||||
09129,BEAUNE NORD,47.04201,4.85075
|
||||
09124,NUITS-ST-GEORGES,47.13099,4.96743
|
||||
09160,DIJON-ARC S/TILLE,47.34461,5.15933
|
||||
09126,DIJON-CRIMOLOIS,47.27707,5.13804
|
||||
09161,TIL CHATEL,47.53859,5.19472
|
||||
09139,SEURRE,47.02937,5.17033
|
||||
09130,SOIRANS,47.20534,5.305
|
||||
04104,MONTPON,44.98597,0.15622
|
||||
04105,MUSSIDAN SUD,45.01073,0.37757
|
||||
04107,MUSSIDAN EST,45.06515,0.43001
|
||||
04106,MUSSIDAN BARRIERE,45.06529,0.42986
|
||||
04114,THENON EST,45.15161,1.16785
|
||||
04112,THENON,45.15124,1.16747
|
||||
04116,LA BACHELLERIE SUD,45.14813,1.17472
|
||||
09128,L ISLE-S/LE-DOUBS,47.41251,6.58553
|
||||
09133,BAUME-LES-DAMES,47.37102,6.37965
|
||||
09131,BESANCON EST,47.33224,6.1513
|
||||
09134,BESANCON NORD,47.27603,5.98798
|
||||
09135,BESANCON OUEST,47.23478,5.89813
|
||||
04204,VALENCE-N,44.97018,4.88768
|
||||
04203,TAIN,45.06844,4.8685
|
||||
04205,VALENCE-S,44.90511,4.88009
|
||||
04206,LORIOL,44.75681,4.79136
|
||||
04207,MONTELIMAR-N,44.66896,4.79483
|
||||
04208,MONTELIMAR-S,44.48123,4.76353
|
||||
03092,LA BAUME D'HOSTUN,45.06467,5.20806
|
||||
03091,CHATUZANGE BARRIERE,45.02608,5.0969
|
||||
08532,HEUDEBOUVILLE FL ECH PARIS,49.19492,1.23192
|
||||
08551,BOURG ACHARD FL ECH,49.36642,0.81813
|
||||
08571,BOURNEVILLE FL ECH,49.37708,0.62672
|
||||
08581,TOUTAINVILLE FL ECH,49.36423,0.46766
|
||||
08592,BEUZEVILLE FL ECH PARIS,49.33757,0.36939
|
||||
12030,BROGLIE/ORBEC SENS 2,49.03165,0.44483
|
||||
12031,BROGLIE/ORBEC SENS 1,49.03719,0.4416
|
||||
12040,BERNAY,49.13768,0.57809
|
||||
12050,BRIONNE,49.24206,0.77249
|
||||
05306,ARTENAY,48.0843,1.8557
|
||||
05304,ALLAINES,48.20533,1.84601
|
||||
05605,CHARTRES-EST,48.45632,1.53784
|
||||
05607,THIVARS,48.36159,1.44648
|
||||
05609,LUIGNY,48.23459,1.03313
|
||||
05608,ILLIERS-COMBRAY,48.29444,1.27031
|
||||
04222,REMOULINS,43.93725,4.5982
|
||||
04221,ROQUEMAURE,44.02563,4.73546
|
||||
04224,NIMES-O,43.81363,4.34275
|
||||
04223,NIMES-E,43.85615,4.42069
|
||||
04275,LUNEL,43.70284,4.11962
|
||||
04225,GALLARGUES,43.7229,4.18087
|
||||
04260,NIMES CENTRE,43.80757,4.37448
|
||||
04261,GARONS,43.76132,4.42753
|
||||
04356,NAILLOUX,43.38237,1.61688
|
||||
04352,MONTGISCARD,43.46088,1.58831
|
||||
04351,VILLEFRANCHE,43.39858,1.6962
|
||||
04458,SAINT-JORY,43.71824,1.39796
|
||||
04455,EUROCENTRE,43.76503,1.38044
|
||||
04646,MONTREJEAU,43.10017,0.59546
|
||||
04644,LANNEMEZAN,43.09782,0.39023
|
||||
04648,ST GAUDENS,43.11568,0.7566
|
||||
04650,LESTELLE,43.12029,0.89548
|
||||
04651,LESTELLE ST MARTORY,43.11773,0.89297
|
||||
04470,L'UNION,43.64518,1.49797
|
||||
04467,PODENSAC,44.60754,-0.36681
|
||||
04466,LANGON,44.54422,-0.26201
|
||||
04465,LA REOLE,44.51137,-0.04954
|
||||
04464,MARMANDE,44.43479,0.13293
|
||||
04101,ARVEYRES,44.88514,-0.26839
|
||||
04102,LIBOURNE NORD,44.95703,-0.24522
|
||||
04103,COUTRAS,45.012,-0.09983
|
||||
04276,BAILLARGUES,43.67053,4.01301
|
||||
04333,SETE,43.47755,3.68525
|
||||
04331,MONTPELLIER ST-JEAN,43.56153,3.8305
|
||||
04335,AGDE-PEZENAS,43.37414,3.41816
|
||||
04334,BEZIERS CABRIALS,43.3433,3.28907
|
||||
04337,BEZIERS-OUEST,43.30445,3.2191
|
||||
05318,AMBOISE CH.RENAULT,47.54281,0.98489
|
||||
05320,TOURS-C/MONNAIE,47.49058,0.81927
|
||||
05486,TOURS-NORD,47.45219,0.73926
|
||||
05490,CHAMBRAY,47.34906,0.70388
|
||||
05485,LA THIBAUDIERE,47.33078,0.68772
|
||||
05484,MONTS - SORIGNY,47.25456,0.67091
|
||||
05524,SAINTE MAURE,47.10728,0.58766
|
||||
05522,TOURS-C/SORIGNY,47.21845,0.65771
|
||||
05478,NEUILLE PONT PIERRE,47.55541,0.59565
|
||||
05967,BOURGUEIL,47.25319,0.16665
|
||||
05960,VIVY,47.31056,-0.03177
|
||||
05014,BLERE,47.28654,0.98447
|
||||
04216,AUBERIVES,45.3898,4.80535
|
||||
04202,CHANAS,45.32115,4.80972
|
||||
03022,CROLLES BRIGNOUD,45.2716,5.90105
|
||||
03023,LE TOUVET,45.34745,5.96371
|
||||
03021,CROLLES BARRIERE,45.27169,5.90089
|
||||
03024,PONTCHARRA,45.42579,5.99511
|
||||
03071,CHESNES,45.65572,5.09788
|
||||
03002,ST QUENTIN FAL. BRETELLE,45.64785,5.11979
|
||||
03062,VILLEFONTAINE,45.62843,5.16432
|
||||
03003,ISLE D'ABEAU CENTRE,45.60516,5.2344
|
||||
03004,BOURGOIN,45.58235,5.30027
|
||||
03005,LA TOUR DU PIN,45.56194,5.42886
|
||||
03072,LA TOUR DU PIN EST,45.55669,5.46452
|
||||
03006,LES ABRETS,45.57134,5.60416
|
||||
03061,SAINT GENIX SUR GUIERS,45.57277,5.65913
|
||||
03085,RIVES,45.38415,5.47313
|
||||
03086,VOIRON,45.34763,5.56633
|
||||
03083,MOIRANS NORD,45.32415,5.60512
|
||||
03087,VOREPPE BARRIERE,45.28323,5.622
|
||||
03084,MOIRANS,45.32035,5.60783
|
||||
03095,TULLINS,45.287,5.52175
|
||||
03093,SAINT MARCELLIN,45.13824,5.32621
|
||||
03094,VINAY,45.19954,5.41944
|
||||
09136,GENDREY,47.18353,5.70894
|
||||
09137,DOLE,47.1361,5.50619
|
||||
09138,CHOISEY,47.06457,5.44674
|
||||
09238,ARLAY,46.77782,5.51859
|
||||
09240,BEAUREPAIRE,46.66637,5.41894
|
||||
04907,BENESSE,43.62393,-1.40031
|
||||
04906,CAPBRETON,43.63235,-1.39224
|
||||
04908,ONDRES,43.54151,-1.4356
|
||||
04624,PEYREHORADE,43.51704,-1.10384
|
||||
04687,SALIES,43.5098,-0.92187
|
||||
05314,MER,47.72856,1.50862
|
||||
05312,MEUNG SUR LOIRE,47.8328,1.669
|
||||
05316,BLOIS,47.62149,1.34635
|
||||
05053,LAMOTTE-BEUVRON,47.58173,1.99103
|
||||
05054,SALBRIS,47.41849,2.0257
|
||||
05013,ST ROMAIN SUR CHER,47.30652,1.35312
|
||||
05011,CHEMERY,47.32467,1.50014
|
||||
05010,VILLEFRANCHE S/ CHER,47.32452,1.76561
|
||||
04178,MONTBRISON,45.63736,4.19611
|
||||
04177,FEURS,45.73754,4.18636
|
||||
04174,NOIRETABLE,45.84935,3.79423
|
||||
04175,ST GERMAIN L.,45.86092,4.04202
|
||||
04180,BALBIGNY,45.84111,4.16441
|
||||
05246,ANCENIS/NANTES,47.40254,-1.19352
|
||||
05245,ANGERS/ANCENIS,47.40243,-1.1935
|
||||
05248,NANTES/ANCENIS,47.39927,-1.19276
|
||||
04556,AIGREFEUILLE,47.0693,-1.43729
|
||||
04557,BIGNON,47.11481,-1.49175
|
||||
05309,GIDY,47.96698,1.85157
|
||||
05308,ORLEANS-NORD,47.94948,1.85462
|
||||
14380,SAVIGNY /CLAIRIS,48.05598,3.08802
|
||||
14375,ST HILAIRE,48.03677,3.02262
|
||||
14365,GONDREVILLE A77/S,48.06084,2.66766
|
||||
14370,FONTENAY /LOING,48.0642,2.76711
|
||||
14360,GONDREVILLE A77/N,48.0608,2.66765
|
||||
14355,AUXY,48.08539,2.47274
|
||||
14350,ESCRENNES,48.11633,2.19111
|
||||
05052,OLIVET,47.84093,1.86936
|
||||
05050,ORLEANS-CENTRE,47.89819,1.85323
|
||||
09404,LE TOURNEAU,47.99293,2.67768
|
||||
04408,MARTEL,44.99329,1.53144
|
||||
04406,SOUILLAC,44.90066,1.50424
|
||||
04405,LABASTIDE MURAT,44.69812,1.58057
|
||||
04404,CAHORS NORD,44.53225,1.50626
|
||||
04403,CAHORS SUD,44.34273,1.49406
|
||||
04463,AIGUILLON,44.28452,0.26986
|
||||
04469,Agen Ouest,44.1875,0.54602
|
||||
04462,AGEN,44.16498,0.60573
|
||||
04783,SEICHES,47.56645,-0.32803
|
||||
04782,DURTAL,47.66779,-0.25964
|
||||
04784,CORZE,47.53933,-0.3408
|
||||
05280,ST JEAN DE LINIERES,47.46658,-0.68606
|
||||
05274,SAINT GERMAIN,47.43168,-0.81633
|
||||
05958,BEAUFORT,47.46812,-0.19348
|
||||
05959,LONGUE,47.40448,-0.11597
|
||||
04561,THOUARCE,47.3295,-0.59635
|
||||
04563,CHEMILLE,47.23593,-0.72764
|
||||
04564,CHOLET NORD,47.08333,-0.82795
|
||||
04565,CHOLET SUD,47.01894,-0.88073
|
||||
07190,REIMS EST,49.21144,4.07834
|
||||
07154,REIMS SUD,49.20521,4.00643
|
||||
07191,CHALONS LA VEUVE,49.04479,4.32266
|
||||
07189,ST GIBRIEN,48.97356,4.28868
|
||||
07192,CHALONS MOURMELON,49.04094,4.32093
|
||||
07193,ST ETIENNE AU TEMPLE,49.03349,4.43586
|
||||
07194,STE MENEHOULD,49.0764,4.88366
|
||||
07616,CLERMONT EN ARGONNE,49.09443,5.10176
|
||||
07137,LA NEUVILLETTE,49.29793,3.99801
|
||||
07188,MONT CHOISY,48.91073,4.28809
|
||||
07187,SOMMESOUS,48.73061,4.22984
|
||||
07633,VATRY,48.76782,4.24012
|
||||
09162,LANGRES SUD,47.79236,5.22621
|
||||
09163,LANGRES NORD,47.93512,5.28682
|
||||
09164,MONTIGNY-LE-ROI,47.99736,5.51194
|
||||
05821,VAIGES,48.05551,-0.48728
|
||||
05823,LAVAL-EST,48.10762,-0.73857
|
||||
05825,LAVAL-OUEST,48.10462,-0.83436
|
||||
07198,JARNY,49.19991,5.90167
|
||||
07199,BEAUMONT,49.19882,5.92401
|
||||
09168,COLOMBEY-LES-BELLES,48.54066,5.90884
|
||||
07195,VOIE SACREE,49.09382,5.27836
|
||||
07196,VERDUN,49.11096,5.41385
|
||||
07197,FRESNES EN WOEVRE,49.12909,5.62133
|
||||
07177,STE-MARIE,49.19333,5.98902
|
||||
07716,BOULAY,49.1411,6.46657
|
||||
07719,ST AVOLD,49.13582,6.71291
|
||||
07721,FAREBERSVILLER,49.10816,6.85335
|
||||
07707,PUTTELANGE,49.06986,6.91814
|
||||
07701,LOUPERSHOUSE,49.07553,6.89666
|
||||
07702,SARREGUEMINES,49.04476,7.02883
|
||||
07704,PHALSBOURG,48.77206,7.24069
|
||||
07705,SAVERNE,48.76172,7.38999
|
||||
07029,MARQUION,50.20249,3.10689
|
||||
07017,CAMBRAI,50.17563,3.19272
|
||||
07030,MASNIERES,50.06855,3.17244
|
||||
07005,SENLIS BONSECOURS,49.20703,2.60921
|
||||
07006,SENLIS,49.2154,2.62813
|
||||
07007,PONT STE MAXENCE,49.32069,2.69478
|
||||
07008,COMPIEGNE OUEST,49.39898,2.69922
|
||||
07009,RESSONS,49.52169,2.71574
|
||||
07414,MERU,49.21073,2.15071
|
||||
07415,BEAUVAIS CENTRE,49.39922,2.12564
|
||||
07416,BEAUVAIS NORD,49.43377,2.12615
|
||||
07417,HARDIVILLERS,49.60999,2.20284
|
||||
05142,ALENCON NORD,48.45298,0.12411
|
||||
17111,Entrée SEES,48.63813,0.18495
|
||||
12210,ARGENTAN,48.63284,0.1909
|
||||
12020,GACE SENS 2,48.77175,0.30896
|
||||
12021,GACE SENS 1,48.77694,0.30347
|
||||
07012,ALBERT,49.96568,2.86338
|
||||
07013,BAPAUME,50.1042,2.8679
|
||||
07014,ARRAS,50.2694,2.86387
|
||||
07434,BERCK,50.41161,1.6899
|
||||
07436,ETAPLES-LE TOUQUET,50.50672,1.68025
|
||||
07437,NEUFCHATEL HARDELOT,50.61112,1.64983
|
||||
07438,BOULOGNE SUD,50.67396,1.65981
|
||||
07021,VALLEE DE LA HEM,50.82097,2.06379
|
||||
07022,ST-OMER B,50.7229,2.16749
|
||||
07020,CALAIS,50.71905,2.17249
|
||||
07024,AIRE SUR LA LYS,50.66821,2.25608
|
||||
07023,ST-OMER,50.71935,2.1729
|
||||
07025,LILLERS,50.55304,2.46281
|
||||
07026,BETHUNE,50.514,2.61776
|
||||
07060,NOEUX LES MINES,50.4869,2.68389
|
||||
07027,LIEVIN,50.43782,2.70728
|
||||
07015,DOURGES,50.32504,2.9107
|
||||
07028,ARRAS,50.34517,2.7875
|
||||
09076,COMBRONDE,45.99599,3.10472
|
||||
09077,RIOM,45.89546,3.14816
|
||||
09078,GERZAT-VILLE,45.84223,3.15962
|
||||
04128,ST JULIEN SANCY,45.66634,2.68254
|
||||
04129,VULCANIA BROMONT,45.8339,2.82261
|
||||
04130,MANZAT,45.95393,2.98285
|
||||
04171,LEZOUX,45.84864,3.38363
|
||||
04172,THIERS-OUEST,45.86003,3.50381
|
||||
04173,THIERS-EST,45.87882,3.62491
|
||||
04905,BAYONNE SUD,43.4623,-1.49849
|
||||
04903,BIARRITZ,43.45046,-1.55445
|
||||
04982,ST JEAN DE LUZ NORD,43.37193,-1.67494
|
||||
04902,ST JEAN DE LUZ SUD,43.37196,-1.67775
|
||||
04620,GUICHE,43.51222,-1.22266
|
||||
04689,ORTHEZ,43.46716,-0.74861
|
||||
04691,LESCAR,43.34524,-0.4188
|
||||
04690,ARTIX,43.39508,-0.55123
|
||||
04692,PAU CENTRE,43.33051,-0.35045
|
||||
04695,TARBES OUEST,43.22081,0.02358
|
||||
04638,TARBES EST,43.21372,0.10822
|
||||
04640,TOURNAY,43.17743,0.23992
|
||||
04642,CAPVERN,43.10327,0.34273
|
||||
04342,PERPIGNAN-NORD,42.7818,2.89739
|
||||
04343,PERPIGNAN-SUD,42.66674,2.85891
|
||||
04344,LE BOULOU,42.52366,2.81767
|
||||
07703,SARRE UNION,48.91392,7.12449
|
||||
07708,HOCHFELDEN OUEST,48.76926,7.60208
|
||||
09221,VILLEFRANCHE-NORD,46.02229,4.72208
|
||||
09120,BELLEVILLE S/SAONE,46.1038,4.75047
|
||||
09121,VILLEFRANCHE-VILLE,45.97728,4.73366
|
||||
04268,CONDRIEU ENTREE,45.50581,4.84228
|
||||
04269,CONDRIEU SORTIE,45.50521,4.83904
|
||||
09421,GENAY,45.90029,4.81942
|
||||
04181,TARARE OUEST,45.89156,4.4031
|
||||
04183,TARARE EST F,45.87151,4.50903
|
||||
04184,TARARE EST ENTREE O,45.87624,4.52202
|
||||
09115,CHALON CENTRE,46.80244,4.82981
|
||||
09116,CHALON SUD,46.75343,4.83299
|
||||
09117,TOURNUS,46.57911,4.90124
|
||||
09140,MACON CENTRE,46.3382,4.84688
|
||||
09118,MACON NORD,46.36589,4.83918
|
||||
09119,MACON SUD,46.28308,4.79296
|
||||
09241,LE MIROIR,46.54741,5.32655
|
||||
05611,LA FERTE BERNARD,48.14989,0.68724
|
||||
05612,CONNERRE,48.07532,0.47932
|
||||
05617,LE MANS-OUEST,48.0217,0.12745
|
||||
05615,LE MANS NORD,48.05045,0.17391
|
||||
04780,LE MANS SUD,47.97373,0.05757
|
||||
04781,SABLE LA FLECHE,47.77553,-0.20865
|
||||
05169,MONTABON,47.68608,0.3714
|
||||
05168,ECOMMOY,47.82037,0.30169
|
||||
05153,PARIGNE L'EVEQUE,47.95569,0.32017
|
||||
05151,AUVOURS,48.0047,0.31254
|
||||
05131,MARESCHE,48.1967,0.16871
|
||||
05133,ROUESSE FONTAINE,48.31156,0.13349
|
||||
05143,ALENCON SUD,48.39788,0.1047
|
||||
05819,JOUE EN CHARNIE,48.00434,-0.21729
|
||||
03008,CHAMBERY NORD,45.60265,5.88708
|
||||
03009,AIX SUD,45.65367,5.92169
|
||||
03007,AIGUEBELETTE,45.57691,5.79935
|
||||
03027,CHIGNIN BRETELLE,45.5092,6.00634
|
||||
03025,CHIGNIN LES MARCHES,45.50898,6.00623
|
||||
03031,MONTMELIAN,45.49492,6.05763
|
||||
03032,SAINT PIERRE D'ALBIGNY,45.54852,6.1603
|
||||
03033,AITON,45.55604,6.24815
|
||||
03034,STE HELENE BARRIERE,45.61976,6.31206
|
||||
02050,ST PIERRE DE BELLEVILLE,45.46979,6.28709
|
||||
02051,STE MARIE DE CUINES,45.34596,6.30541
|
||||
02052,HERMILLON,45.29886,6.35492
|
||||
02053,ST JULIEN MONTDENIS,45.25162,6.39687
|
||||
02054,ST MICHEL ECHANGEUR,45.21844,6.45893
|
||||
02057,ST MICHEL-MODANE,45.21651,6.46586
|
||||
10002,CLUSES AMONT,46.04652,6.59638
|
||||
10003,CLUSES AVAL,46.04893,6.58956
|
||||
10004,SCIONZIER,46.06839,6.55374
|
||||
10012,BONNEVILLE OUEST,46.07345,6.38294
|
||||
10158,ELOISE,46.06521,5.8639
|
||||
03011,RUMILLY,45.81591,6.00686
|
||||
03020,SEYNOD SUD,45.84596,6.05773
|
||||
03012,ANNECY CENTRE,45.89783,6.09258
|
||||
03013,ANNECY NORD,45.93882,6.11694
|
||||
03014,ALLONZIER,45.98984,6.12869
|
||||
03016,CRUSEILLES A 410,45.99337,6.12816
|
||||
08311,ST ROMAIN SO,49.55165,0.33583
|
||||
08322,ST ROMAIN SF,49.55101,0.33873
|
||||
08341,BOLBEC,49.58133,0.44307
|
||||
08351,FECAMP,49.63236,0.64
|
||||
08361,YVETOT,49.62746,0.80626
|
||||
08371,YERVILLE,49.64775,0.84262
|
||||
08381,BEAUTOT,49.6353,1.05065
|
||||
08391,COTTEVRARD,49.64675,1.24268
|
||||
07443,AUMALE OUEST,49.75625,1.69842
|
||||
07444,AUMALE EST,49.75939,1.7031
|
||||
07172,ST-JEAN LES 2 JUMEAU,48.9469,3.03972
|
||||
07174,MONTREUIL AUX LIONS (19),49.01316,3.1554
|
||||
09178,CHATILLON-LABORDE,48.54222,2.79737
|
||||
09179,ST-GERMAIN-LAXIS,48.58811,2.72483
|
||||
09176,MAROLLES-SUR-SEINE,48.38015,3.02023
|
||||
09177,FORGES,48.42142,2.94341
|
||||
09102,URY,48.33869,2.59548
|
||||
09104,NEMOURS,48.26951,2.7133
|
||||
09103,FONTAINEBLEAU,48.28882,2.68315
|
||||
09201,VAL DE LOING-SOUPPES,48.17632,2.76729
|
||||
09403,DORDIVES,48.17139,2.76706
|
||||
05198,DOURDAN,48.56902,1.99025
|
||||
05302,ALLAINVILLE,48.45643,1.90827
|
||||
05603,ABLIS,48.52877,1.83225
|
||||
05601,LA FOLIE-B/PARIS,48.55324,1.93062
|
||||
08511,CHAMBOURCY FL,48.9118,2.04672
|
||||
04533,SOUDAN,46.4255,-0.08208
|
||||
04534,NIORT EST,46.35524,-0.33118
|
||||
04547,VOUILLE,46.30743,-0.36642
|
||||
04535,NIORT-S,46.244,-0.46061
|
||||
04548,NIORT NORD,46.41932,-0.39707
|
||||
07010,ROYE,49.70606,2.76919
|
||||
07053,GARE TGV,49.85555,2.83067
|
||||
07011,PERONNE,49.87697,2.83976
|
||||
07418,ESSERTAUX,49.73973,2.22877
|
||||
07422,SALOUEL,49.85977,2.21381
|
||||
07420,AMIENS SUD,49.85416,2.25022
|
||||
07425,AMIENS OUEST,49.89176,2.23839
|
||||
07426,AMIENS NORD,49.93383,2.24381
|
||||
07428,FLIXECOURT,50.02951,2.06974
|
||||
07431,ABBEVILLE NORD,50.13538,1.81102
|
||||
07430,ABBEVILLE EST,50.09981,1.86941
|
||||
07432,COTE PICARDE,50.25441,1.74658
|
||||
07446,POIX-DE-PICARDIE,49.80846,1.96876
|
||||
07052,VILLERS BRETONNEUX,49.85496,2.52207
|
||||
07054,ATHIES,49.83871,2.98978
|
||||
04402,CAUSSADE,44.14993,1.51564
|
||||
04461,VALENCE D'AGEN,44.06418,0.86653
|
||||
04460,CASTELSARRASIN,44.0558,1.09731
|
||||
04459,MONTAUBAN,43.92898,1.31427
|
||||
06003,POURRIERES,43.47756,5.75573
|
||||
06004,ST.MAXIMIN,43.44938,5.87706
|
||||
06007,LE MUY,43.46068,6.55084
|
||||
06008,PUGET ECHANGEUR,43.45693,6.68943
|
||||
06049,FREJUS OUEST,43.46935,6.72917
|
||||
06010,FREJUS,43.47221,6.74342
|
||||
06032,BANDOL ECH.,43.14438,5.76866
|
||||
06042,PUGET VILLE,43.25791,6.12263
|
||||
06046,CARNOULES,43.29345,6.20046
|
||||
06006,CANNET DES MAURES,43.39342,6.35218
|
||||
04209,BOLLENE,44.29026,4.75111
|
||||
04217,ORANGE-N,44.16422,4.76458
|
||||
04210,ORANGE,44.13527,4.79569
|
||||
04218,ORANGE-S,44.11089,4.84525
|
||||
04211,AVIGNON-N,43.9819,4.88828
|
||||
04212,AVIGNON-S,43.89289,4.91565
|
||||
04555,MONTAIGU,46.9596,-1.35294
|
||||
04554,LES ESSARTS,46.79043,-1.19453
|
||||
04553,CHANTONNAY,46.62728,-1.15449
|
||||
04552,STE HERMINE,46.5336,-1.07897
|
||||
04550,FONTENAY CENTRE,46.43595,-0.82151
|
||||
04570,FONTENAY OUEST,46.46462,-0.87667
|
||||
04549,NIORT OUEST,46.38784,-0.64788
|
||||
04566,LA VERRIE,46.94473,-0.9765
|
||||
04567,LES HERBIERS,46.90208,-1.04676
|
||||
05528,CHATELLERAULT-SUD,46.77866,0.50452
|
||||
05526,CHATELLERAULT-NORD,46.83697,0.53082
|
||||
05530,POITIERS-NORD,46.62136,0.34394
|
||||
05529,FUTUROSCOPE,46.67011,0.35987
|
||||
05532,POITIERS-SUD,46.54907,0.28938
|
||||
09265,ROBECOURT,48.14478,5.68904
|
||||
09166,BULGNEVILLE,48.21578,5.8325
|
||||
09167,CHATENOIS,48.29947,5.85293
|
||||
09175,VULAINES,48.23784,3.60136
|
||||
09184,ST-DENIS-LES-SENS,48.23696,3.26209
|
||||
09105,COURTENAY,48.0598,3.09537
|
||||
09106,JOIGNY,47.9397,3.24406
|
||||
09107,AUXERRE NORD,47.85162,3.54878
|
||||
09108,AUXERRE SUD,47.79676,3.65284
|
||||
09109,NITRY,47.65906,3.87958
|
||||
09181,VILLENEUVE-DONDAGRE,48.14968,3.17133
|
||||
07002,CHANTILLY,49.08425,2.55161
|
||||
07018,THUN L'EVEQUE,50.22928,3.27218
|
||||
07171,COUTEVROULT,48.85356,2.83874
|
||||
07140,MONTREUIL AUX LIONS,49.00953,3.14581
|
||||
07718,ST AVOLD,49.1362,6.70162
|
||||
09180,LES EPRUNES,48.58892,2.65679
|
||||
09101,FLEURY-EN-BIERE,48.42582,2.53979
|
||||
09112,POUILLY-EN-AUXOIS,47.25241,4.56116
|
||||
04288,VIENNE SUD,45.47464,4.83439
|
||||
04201,VIENNE,45.47693,4.83243
|
||||
04220,LANCON,43.59398,5.17255
|
||||
06002,LA BARQUE,43.48327,5.53839
|
||||
04345,LE PERTHUS,42.52788,2.82113
|
||||
04390,LE BOULOU (O),42.52367,2.81752
|
||||
04541,VIRSAC,45.02368,-0.43507
|
||||
08521,BUCHELAY FL,48.99097,1.64331
|
||||
08611,DOZULE FL,49.22841,-0.08116
|
||||
08501,MONTESSON FL,48.91429,2.15109
|
||||
07413,AMBLAINVILLE,49.20513,2.1689
|
||||
07439,HERQUELINGUE,50.68885,1.64206
|
||||
04401,MONTAUBAN NORD,44.05229,1.41021
|
||||
05177,ST CHRISTOPHE,47.63838,0.49703
|
||||
12010,SEES,48.63297,0.19109
|
||||
12060,ROUMOIS,49.35396,0.83522
|
||||
08601,QUETTEVILLE FL,49.32057,0.31114
|
||||
07451,JULES VERNE,49.85868,2.39867
|
||||
09169,GYE,48.62975,5.88358
|
||||
09431,FONTAINE-LARIVIERE,47.6758,6.98183
|
||||
09132,SAINT MAURICE,47.4255,6.67126
|
||||
10011,NANGY,46.15316,6.29518
|
||||
10159,VIRY,46.12023,6.00951
|
||||
06070,LA SAULCE,44.44035,6.0286
|
||||
03041,LE CROZET,45.04556,5.67876
|
||||
06037,AURIOL,43.36689,5.64395
|
||||
04262,ARLES,43.69045,4.5449
|
||||
04265,SAINT MARTIN DE CRAU,43.63771,4.85783
|
||||
04355,TOULOUSE-SUD/EST,43.5449,1.50046
|
||||
04468,SAINT-SELVE,44.65901,-0.45426
|
||||
04457,TOULOUSE-NORD/OUEST,43.65838,1.42803
|
||||
04456,TOULOUSE-NORD/EST,43.65805,1.42699
|
||||
19001,BPV SAUGNAC,44.34693,-0.85998
|
||||
19003,BPV CASTETS,43.83527,-1.18061
|
||||
04901,BIRIATOU,43.34098,-1.74938
|
||||
04622,SAMES,43.52955,-1.18678
|
||||
04476,MURET,43.50526,1.35223
|
||||
18001,BAZAS,44.44527,-0.24235
|
||||
18002,CAPTIEUX,44.28603,-0.22806
|
||||
18003,ROQUEFORT,44.04489,-0.34321
|
||||
18004,MONT DE MARSAN,43.94698,-0.39392
|
||||
18006,AIRE SUR L'ADOUR N,43.72216,-0.27088
|
||||
18007,AIRE SUR L'ADOUR S,43.66454,-0.27668
|
||||
18008,GARLIN,43.56528,-0.29261
|
||||
18009,THEZE,43.47137,-0.32105
|
||||
04472,TOULOUSE EST,43.64715,1.50782
|
||||
09063,MONTLUCON,46.39634,2.71132
|
||||
04179,VEAUCHETTE,45.56095,4.24203
|
||||
13001,VIADUC DE MILLAU,44.13397,3.02535
|
||||
09405,MYENNES,47.43731,2.94081
|
||||
05827,LA GRAVELLE VITRE,48.08263,-1.02758
|
||||
05968,RESTIGNE,47.27017,0.25391
|
||||
05016,VEIGNE,47.31191,0.7353
|
||||
05015,ESVRES,47.30717,0.79607
|
||||
05713,VELIZY,48.7828,2.15728
|
||||
05712,VAUCRESSON,48.83241,2.14747
|
||||
05711,RUEIL,48.86991,2.15805
|
||||
04562,BEAULIEU SUR LAYON,47.32641,-0.60428
|
||||
04568,LA ROCHE SUR YON,46.67234,-1.34583
|
||||
17112,Sortie SEES,48.63603,0.1844
|
||||
17114,RONAI vers SEES,48.81632,-0.12945
|
||||
17113,RONAI vers FALAISE,48.8164,-0.12919
|
||||
17116,Sortie NECY,48.82346,-0.13664
|
||||
04122,ST GERMAIN LES VERGN,45.28384,1.61737
|
||||
04170,LES MARTRES ARTIERE,45.8278,3.24112
|
||||
08561,BOURNEVILLE FL,49.38496,0.60381
|
||||
20001,BOUVILLE,49.54825,0.92179
|
||||
08541,INCARVILLE FL,49.24809,1.17938
|
||||
09125,DIJON SUD,47.26023,5.03208
|
||||
07152,REIMS OUEST(THILLOIS,49.24947,3.95591
|
||||
09239,BERSAILLIN,46.84932,5.57458
|
||||
09165,GROISSIAT,46.22366,5.6142
|
||||
09065,GANNAT,46.0981,3.13554
|
||||
09465,VICHY,46.13841,3.3513
|
||||
04544,CABARIOT,45.94391,-0.84935
|
||||
08211,PONT DE TANCARVILLE,49.46365,0.47404
|
||||
04801,PYRENEES ORIENTALES,42.53958,1.82401
|
||||
08222,PONT DE NORMANDIE,49.45001,0.2717
|
||||
06021,ST.ISIDORE ECH. EST,43.70655,7.19071
|
||||
06028,LAGHET,43.7454,7.37858
|
||||
06055,MEYRARGUES,43.66118,5.50356
|
||||
06056,PERTUIS NORD,43.66162,5.5034
|
||||
06039,BELCODENE,43.41783,5.5752
|
||||
08593,BEUZEVILLE FL ECH CAEN,49.33906,0.36807
|
||||
05247,ANCENIS/ANGERS,47.39936,-1.19277
|
||||
05273,VIEILLEVILLE,47.29114,-1.4804
|
||||
07722,HOCHFELDEN,48.76874,7.60218
|
||||
03010,AIX NORD,45.71614,5.92225
|
||||
10014,BONNEVILLE EST,46.07018,6.42445
|
||||
08321,EPRETOT BPV,49.55172,0.3355
|
||||
06005,BRIGNOLES,43.4191,6.06505
|
||||
06011,LES ADRETS,43.54408,6.81245
|
||||
07001,ROISSY CDG,49.21556,2.62796
|
||||
07706,SCHWINDRATZHEIM,48.76953,7.60203
|
||||
09122,VILLEFRANCHE-LIMAS,45.97344,4.73193
|
||||
06009,CAPITOU,43.46857,6.72924
|
||||
06012,ANTIBES P/V,43.60255,7.0783
|
||||
06027,LA TURBIE P/V,43.74367,7.37827
|
||||
06020,ST.ISIDORE P/V,43.70752,7.19138
|
||||
05270,ANCENIS BARRIERE,47.40069,-1.19587
|
||||
08531,HEUDEBOUVILLE FL,49.19512,1.23011
|
||||
08591,BEUZEVILLE FL,49.33815,0.3678
|
||||
08533,HEUDEBOUVILLE FL ECH CAEN,49.19667,1.22974
|
||||
04407,GIGNAC,44.99076,1.52646
|
||||
07441,HAUDRICOURT,49.75948,1.70294
|
||||
10001,CLUSES,46.04695,6.5966
|
||||
03026,CHIGNIN BARRIERE,45.51256,6.00209
|
||||
02056,ST MICHEL BARRIERE,45.21869,6.45903
|
||||
03001,ST QUENTIN FAL. BARRIERE,45.64866,5.12021
|
||||
06033,LA CIOTAT P/V,43.20485,5.59054
|
||||
06031,BANDOL P/V,43.14628,5.77188
|
||||
04354,TOULOUSE-SUD/OUEST,43.54469,1.50019
|
||||
04904,LA NEGRESSE,43.44839,-1.55338
|
||||
09079,CLERMONT-BARRIERE,45.84133,3.16053
|
||||
17115,Entrée NECY,48.8235,-0.13463
|
||||
04182,ST ROMAIN POPEY,45.87177,4.50891
|
||||
07150,REIMS NORD,49.24633,3.96251
|
||||
03015,ST MARTIN BELLEVUE A410,45.98981,6.12837
|
||||
|
@@ -1,24 +1,24 @@
|
||||
/**
|
||||
* Utilitaires d'authentification et d'autorisation
|
||||
*/
|
||||
const admin = require('firebase-admin');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("firebase-functions/logger");
|
||||
|
||||
/**
|
||||
* Vérifie le token Firebase et retourne l'utilisateur
|
||||
*/
|
||||
async function authenticateUser(req) {
|
||||
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) {
|
||||
throw new Error('Unauthorized: No token provided');
|
||||
if (!req.headers.authorization || !req.headers.authorization.startsWith("Bearer ")) {
|
||||
throw new Error("Unauthorized: No token provided");
|
||||
}
|
||||
|
||||
const idToken = req.headers.authorization.split('Bearer ')[1];
|
||||
const idToken = req.headers.authorization.split("Bearer ")[1];
|
||||
try {
|
||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||
return decodedToken;
|
||||
} catch (e) {
|
||||
logger.error("Error verifying Firebase ID token:", e);
|
||||
throw new Error('Unauthorized: Invalid token');
|
||||
throw new Error("Unauthorized: Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ async function authenticateUser(req) {
|
||||
* Récupère les données utilisateur depuis Firestore
|
||||
*/
|
||||
async function getUserData(uid) {
|
||||
const userDoc = await admin.firestore().collection('users').doc(uid).get();
|
||||
const userDoc = await admin.firestore().collection("users").doc(uid).get();
|
||||
if (!userDoc.exists) {
|
||||
return null;
|
||||
}
|
||||
return { uid, ...userDoc.data() };
|
||||
return {uid, ...userDoc.data()};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +40,7 @@ async function getRolePermissions(roleRef) {
|
||||
if (!roleRef) return [];
|
||||
|
||||
let roleId;
|
||||
if (typeof roleRef === 'string') {
|
||||
if (typeof roleRef === "string") {
|
||||
roleId = roleRef;
|
||||
} else if (roleRef.id) {
|
||||
roleId = roleRef.id;
|
||||
@@ -48,7 +48,7 @@ async function getRolePermissions(roleRef) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const roleDoc = await admin.firestore().collection('roles').doc(roleId).get();
|
||||
const roleDoc = await admin.firestore().collection("roles").doc(roleId).get();
|
||||
if (!roleDoc.exists) return [];
|
||||
|
||||
return roleDoc.data().permissions || [];
|
||||
@@ -74,7 +74,7 @@ async function isAdmin(uid) {
|
||||
|
||||
let roleId;
|
||||
const roleField = userData.role;
|
||||
if (typeof roleField === 'string') {
|
||||
if (typeof roleField === "string") {
|
||||
roleId = roleField;
|
||||
} else if (roleField && roleField.id) {
|
||||
roleId = roleField.id;
|
||||
@@ -82,22 +82,22 @@ async function isAdmin(uid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return roleId === 'ADMIN';
|
||||
return roleId === "ADMIN";
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur est assigné à un événement
|
||||
*/
|
||||
async function isAssignedToEvent(uid, eventId) {
|
||||
const eventDoc = await admin.firestore().collection('events').doc(eventId).get();
|
||||
const eventDoc = await admin.firestore().collection("events").doc(eventId).get();
|
||||
if (!eventDoc.exists) return false;
|
||||
|
||||
const eventData = eventDoc.data();
|
||||
const workforce = eventData.workforce || [];
|
||||
|
||||
// workforce contient des références DocumentReference
|
||||
return workforce.some(ref => {
|
||||
if (typeof ref === 'string') return ref === uid;
|
||||
return workforce.some((ref) => {
|
||||
if (typeof ref === "string") return ref === uid;
|
||||
if (ref && ref.id) return ref.id === uid;
|
||||
return false;
|
||||
});
|
||||
@@ -113,7 +113,7 @@ async function authMiddleware(req, res, next) {
|
||||
req.uid = decodedToken.uid;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: error.message });
|
||||
res.status(401).json({error: error.message});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,12 +125,12 @@ function requirePermission(permission) {
|
||||
try {
|
||||
const hasAccess = await hasPermission(req.uid, permission);
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({ error: `Forbidden: Requires permission '${permission}'` });
|
||||
res.status(403).json({error: `Forbidden: Requires permission '${permission}'`});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(403).json({ error: error.message });
|
||||
res.status(403).json({error: error.message});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -142,12 +142,12 @@ async function requireAdmin(req, res, next) {
|
||||
try {
|
||||
const adminAccess = await isAdmin(req.uid);
|
||||
if (!adminAccess) {
|
||||
res.status(403).json({ error: 'Forbidden: Admin access required' });
|
||||
res.status(403).json({error: "Forbidden: Admin access required"});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(403).json({ error: error.message });
|
||||
res.status(403).json({error: error.message});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
|
||||
const getSmtpConfig = () => {
|
||||
return {
|
||||
host: process.env.SMTP_HOST || 'mail.em2events.fr',
|
||||
port: parseInt(process.env.SMTP_PORT || '465'),
|
||||
host: process.env.SMTP_HOST || "mail.em2events.fr",
|
||||
port: parseInt(process.env.SMTP_PORT || "465"),
|
||||
secure: true, // true pour port 465, false pour autres ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'notify@em2events.fr',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
user: process.env.SMTP_USER || "notify@em2events.fr",
|
||||
pass: process.env.SMTP_PASS || "",
|
||||
},
|
||||
tls: {
|
||||
// Ne pas échouer sur certificats invalides
|
||||
@@ -24,12 +24,12 @@ const getSmtpConfig = () => {
|
||||
// Configuration email par défaut
|
||||
const EMAIL_CONFIG = {
|
||||
from: {
|
||||
name: 'EM2 Events',
|
||||
address: 'notify@em2events.fr',
|
||||
name: "EM2 Events",
|
||||
address: "notify@em2events.fr",
|
||||
},
|
||||
replyTo: 'contact@em2events.fr',
|
||||
replyTo: "contact@em2events.fr",
|
||||
// URL de l'application pour les liens
|
||||
appUrl: process.env.APP_URL || 'https://app.em2events.fr',
|
||||
appUrl: process.env.APP_URL || "https://app.em2events.fr",
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
const admin = require('firebase-admin');
|
||||
const handlebars = require('handlebars');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const {EMAIL_CONFIG} = require('./emailConfig');
|
||||
const admin = require("firebase-admin");
|
||||
const handlebars = require("handlebars");
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const {EMAIL_CONFIG} = require("./emailConfig");
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
|
||||
*/
|
||||
function checkAlertPreference(alertType, preferences) {
|
||||
const typeMapping = {
|
||||
'EVENT_CREATED': 'eventsNotifications',
|
||||
'EVENT_MODIFIED': 'eventsNotifications',
|
||||
'EVENT_CANCELLED': 'eventsNotifications',
|
||||
'LOST': 'equipmentNotifications',
|
||||
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
||||
'DAMAGED': 'equipmentNotifications',
|
||||
'QUANTITY_MISMATCH': 'equipmentNotifications',
|
||||
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
||||
'STOCK_LOW': 'stockNotifications',
|
||||
"EVENT_CREATED": "eventsNotifications",
|
||||
"EVENT_MODIFIED": "eventsNotifications",
|
||||
"EVENT_CANCELLED": "eventsNotifications",
|
||||
"LOST": "equipmentNotifications",
|
||||
"EQUIPMENT_MISSING": "equipmentNotifications",
|
||||
"DAMAGED": "equipmentNotifications",
|
||||
"QUANTITY_MISMATCH": "equipmentNotifications",
|
||||
"MAINTENANCE_REMINDER": "maintenanceNotifications",
|
||||
"STOCK_LOW": "stockNotifications",
|
||||
};
|
||||
|
||||
const prefKey = typeMapping[alertType];
|
||||
@@ -29,12 +29,12 @@ function checkAlertPreference(alertType, preferences) {
|
||||
*/
|
||||
async function prepareTemplateData(alert, user) {
|
||||
const data = {
|
||||
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||
'Utilisateur',
|
||||
userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() ||
|
||||
"Utilisateur",
|
||||
alertTitle: getAlertTitle(alert.type),
|
||||
alertMessage: alert.message,
|
||||
isCritical: alert.severity === 'CRITICAL',
|
||||
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
||||
isCritical: alert.severity === "CRITICAL",
|
||||
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`,
|
||||
appUrl: EMAIL_CONFIG.appUrl,
|
||||
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||
year: new Date().getFullYear(),
|
||||
@@ -45,20 +45,20 @@ async function prepareTemplateData(alert, user) {
|
||||
if (alert.eventId) {
|
||||
try {
|
||||
const eventDoc = await admin.firestore()
|
||||
.collection('events')
|
||||
.collection("events")
|
||||
.doc(alert.eventId)
|
||||
.get();
|
||||
|
||||
if (eventDoc.exists) {
|
||||
const event = eventDoc.data();
|
||||
data.eventName = event.Name || event.name || 'Événement';
|
||||
data.eventName = event.Name || event.name || "Événement";
|
||||
if (event.StartDateTime || event.startDate) {
|
||||
const dateField = event.StartDateTime || event.startDate;
|
||||
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
|
||||
data.eventDate = date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
data.eventDate = date.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ async function prepareTemplateData(alert, user) {
|
||||
if (alert.equipmentId) {
|
||||
try {
|
||||
const eqDoc = await admin.firestore()
|
||||
.collection('equipments')
|
||||
.collection("equipments")
|
||||
.doc(alert.equipmentId)
|
||||
.get();
|
||||
|
||||
@@ -90,18 +90,18 @@ async function prepareTemplateData(alert, user) {
|
||||
*/
|
||||
function getEmailSubject(alert) {
|
||||
const subjects = {
|
||||
'EVENT_CREATED': '📅 Nouvel événement créé',
|
||||
'EVENT_MODIFIED': '📝 Événement modifié',
|
||||
'EVENT_CANCELLED': '❌ Événement annulé',
|
||||
'LOST': '🔴 Alerte critique : Équipement perdu',
|
||||
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
||||
'DAMAGED': '⚠️ Équipement endommagé',
|
||||
'QUANTITY_MISMATCH': 'ℹ️ Quantité incorrecte',
|
||||
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
||||
'STOCK_LOW': '📦 Stock faible',
|
||||
"EVENT_CREATED": "📅 Nouvel événement créé",
|
||||
"EVENT_MODIFIED": "📝 Événement modifié",
|
||||
"EVENT_CANCELLED": "❌ Événement annulé",
|
||||
"LOST": "🔴 Alerte critique : Équipement perdu",
|
||||
"EQUIPMENT_MISSING": "⚠️ Équipement manquant",
|
||||
"DAMAGED": "⚠️ Équipement endommagé",
|
||||
"QUANTITY_MISMATCH": "ℹ️ Quantité incorrecte",
|
||||
"MAINTENANCE_REMINDER": "🔧 Rappel de maintenance",
|
||||
"STOCK_LOW": "📦 Stock faible",
|
||||
};
|
||||
|
||||
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
||||
return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,18 +109,18 @@ function getEmailSubject(alert) {
|
||||
*/
|
||||
function getAlertTitle(type) {
|
||||
const titles = {
|
||||
'EVENT_CREATED': 'Nouvel événement créé',
|
||||
'EVENT_MODIFIED': 'Événement modifié',
|
||||
'EVENT_CANCELLED': 'Événement annulé',
|
||||
'LOST': 'Équipement perdu',
|
||||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||
'DAMAGED': 'Équipement endommagé',
|
||||
'QUANTITY_MISMATCH': 'Quantité incorrecte',
|
||||
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
||||
'STOCK_LOW': 'Stock faible',
|
||||
"EVENT_CREATED": "Nouvel événement créé",
|
||||
"EVENT_MODIFIED": "Événement modifié",
|
||||
"EVENT_CANCELLED": "Événement annulé",
|
||||
"LOST": "Équipement perdu",
|
||||
"EQUIPMENT_MISSING": "Équipement manquant",
|
||||
"DAMAGED": "Équipement endommagé",
|
||||
"QUANTITY_MISMATCH": "Quantité incorrecte",
|
||||
"MAINTENANCE_REMINDER": "Maintenance requise",
|
||||
"STOCK_LOW": "Stock faible",
|
||||
};
|
||||
|
||||
return titles[type] || 'Nouvelle alerte';
|
||||
return titles[type] || "Nouvelle alerte";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,17 +129,17 @@ function getAlertTitle(type) {
|
||||
async function renderTemplate(templateName, data) {
|
||||
try {
|
||||
// Lire le template de base
|
||||
const basePath = path.join(__dirname, '..', 'templates', 'base-template.html');
|
||||
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
||||
const basePath = path.join(__dirname, "..", "templates", "base-template.html");
|
||||
const baseTemplate = await fs.readFile(basePath, "utf8");
|
||||
|
||||
// Lire le template de contenu
|
||||
const contentPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'templates',
|
||||
"..",
|
||||
"templates",
|
||||
`${templateName}.html`,
|
||||
);
|
||||
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
||||
const contentTemplate = await fs.readFile(contentPath, "utf8");
|
||||
|
||||
// Compiler les templates
|
||||
const compileContent = handlebars.compile(contentTemplate);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Helpers pour la manipulation de données Firestore
|
||||
*/
|
||||
const admin = require('firebase-admin');
|
||||
const admin = require("firebase-admin");
|
||||
|
||||
/**
|
||||
* Convertit les Timestamps Firestore en ISO strings pour JSON
|
||||
@@ -19,7 +19,7 @@ function serializeTimestamps(data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = { ...data };
|
||||
const result = {...data};
|
||||
|
||||
for (const key in result) {
|
||||
const value = result[key];
|
||||
@@ -29,31 +29,31 @@ function serializeTimestamps(data) {
|
||||
}
|
||||
|
||||
// Gérer les Timestamps Firestore
|
||||
if (value.toDate && typeof value.toDate === 'function') {
|
||||
if (value.toDate && typeof value.toDate === "function") {
|
||||
result[key] = value.toDate().toISOString();
|
||||
}
|
||||
// Gérer les DocumentReference
|
||||
else if (value.path && value.id && typeof value.path === 'string') {
|
||||
else if (value.path && value.id && typeof value.path === "string") {
|
||||
result[key] = value.path;
|
||||
}
|
||||
// Gérer les GeoPoint
|
||||
else if (value.latitude !== undefined && value.longitude !== undefined) {
|
||||
result[key] = {
|
||||
latitude: value.latitude,
|
||||
longitude: value.longitude
|
||||
longitude: value.longitude,
|
||||
};
|
||||
}
|
||||
// Gérer les tableaux
|
||||
else if (Array.isArray(value)) {
|
||||
result[key] = value.map(item => {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
result[key] = value.map((item) => {
|
||||
if (!item || typeof item !== "object") return item;
|
||||
|
||||
// DocumentReference dans un tableau
|
||||
if (item.path && item.id) {
|
||||
return item.path;
|
||||
}
|
||||
// Timestamp dans un tableau
|
||||
if (item.toDate && typeof item.toDate === 'function') {
|
||||
if (item.toDate && typeof item.toDate === "function") {
|
||||
return item.toDate().toISOString();
|
||||
}
|
||||
// Objet normal
|
||||
@@ -61,7 +61,7 @@ function serializeTimestamps(data) {
|
||||
});
|
||||
}
|
||||
// Gérer les objets imbriqués (mais pas les objets Firestore)
|
||||
else if (typeof value === 'object' && !value._firestore && !value._path) {
|
||||
else if (typeof value === "object" && !value._firestore && !value._path) {
|
||||
result[key] = serializeTimestamps(value);
|
||||
}
|
||||
}
|
||||
@@ -75,10 +75,10 @@ function serializeTimestamps(data) {
|
||||
function deserializeTimestamps(data, timestampFields = []) {
|
||||
if (!data) return data;
|
||||
|
||||
const result = { ...data };
|
||||
const result = {...data};
|
||||
|
||||
for (const field of timestampFields) {
|
||||
if (result[field] && typeof result[field] === 'string') {
|
||||
if (result[field] && typeof result[field] === "string") {
|
||||
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
|
||||
}
|
||||
}
|
||||
@@ -92,15 +92,15 @@ function deserializeTimestamps(data, timestampFields = []) {
|
||||
function serializeReferences(data) {
|
||||
if (!data) return data;
|
||||
|
||||
const result = { ...data };
|
||||
const result = {...data};
|
||||
|
||||
for (const key in result) {
|
||||
if (result[key] && result[key].path && typeof result[key].path === 'string') {
|
||||
if (result[key] && result[key].path && typeof result[key].path === "string") {
|
||||
// C'est une DocumentReference
|
||||
result[key] = result[key].id;
|
||||
} else if (Array.isArray(result[key])) {
|
||||
result[key] = result[key].map(item => {
|
||||
if (item && item.path && typeof item.path === 'string') {
|
||||
result[key] = result[key].map((item) => {
|
||||
if (item && item.path && typeof item.path === "string") {
|
||||
return item.id;
|
||||
}
|
||||
return item;
|
||||
@@ -117,7 +117,7 @@ function serializeReferences(data) {
|
||||
function maskSensitiveFields(data, canViewSensitive) {
|
||||
if (canViewSensitive) return data;
|
||||
|
||||
const masked = { ...data };
|
||||
const masked = {...data};
|
||||
|
||||
// Masquer les prix si pas de permission manage_equipment
|
||||
delete masked.purchasePrice;
|
||||
@@ -143,34 +143,34 @@ function paginate(query, limit = 50, startAfter = null) {
|
||||
* Filtre les événements annulés
|
||||
*/
|
||||
function filterCancelledEvents(events) {
|
||||
return events.filter(event => event.status !== 'CANCELLED');
|
||||
return events.filter((event) => event.status !== "CANCELLED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
|
||||
* @param {Object} data - Données de l'événement
|
||||
* @returns {Object} - Données avec DocumentReference
|
||||
* @return {Object} - Données avec DocumentReference
|
||||
*/
|
||||
function convertIdsToReferences(data) {
|
||||
if (!data) return data;
|
||||
|
||||
const result = { ...data };
|
||||
const result = {...data};
|
||||
|
||||
// Convertir EventType (ID → DocumentReference)
|
||||
if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) {
|
||||
result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType);
|
||||
if (result.EventType && typeof result.EventType === "string" && !result.EventType.includes("/")) {
|
||||
result.EventType = admin.firestore().collection("eventTypes").doc(result.EventType);
|
||||
}
|
||||
|
||||
// Convertir customer (ID → DocumentReference)
|
||||
if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) {
|
||||
result.customer = admin.firestore().collection('customers').doc(result.customer);
|
||||
if (result.customer && typeof result.customer === "string" && !result.customer.includes("/")) {
|
||||
result.customer = admin.firestore().collection("customers").doc(result.customer);
|
||||
}
|
||||
|
||||
// Convertir workforce (IDs → DocumentReference)
|
||||
if (Array.isArray(result.workforce)) {
|
||||
result.workforce = result.workforce.map(item => {
|
||||
if (typeof item === 'string' && !item.includes('/')) {
|
||||
return admin.firestore().collection('users').doc(item);
|
||||
result.workforce = result.workforce.map((item) => {
|
||||
if (typeof item === "string" && !item.includes("/")) {
|
||||
return admin.firestore().collection("users").doc(item);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// Configuration de la version de l'application
|
||||
class AppVersion {
|
||||
static const String version = '1.1.20';
|
||||
static const String version = '1.2.4';
|
||||
|
||||
/// Retourne la version complète de l'application
|
||||
static String get fullVersion => 'v$version';
|
||||
|
||||
@@ -256,6 +256,20 @@ class EventFormController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Ajoute ou met à jour l'option FRAIS_KM avec le prix calculé.
|
||||
/// L'option est au format attendu par Firestore : { id: "FRAIS_KM", price: <valeur> }
|
||||
void addTravelCostOption(double price) {
|
||||
// Retirer l'éventuelle option FRAIS_KM existante
|
||||
_selectedOptions.removeWhere((opt) => opt['id'] == 'FRAIS_KM');
|
||||
// Ajouter la nouvelle
|
||||
_selectedOptions.add({
|
||||
'id': 'FRAIS_KM',
|
||||
'price': double.parse(price.toStringAsFixed(2)),
|
||||
});
|
||||
_onAnyFieldChanged();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
||||
_assignedEquipment = equipment;
|
||||
_assignedContainers = containers;
|
||||
@@ -433,6 +447,11 @@ class EventFormController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> submitAsConfirmed(BuildContext context) async {
|
||||
_selectedStatus = EventStatus.confirmed;
|
||||
return await submitForm(context);
|
||||
}
|
||||
|
||||
Future<bool> deleteEvent(BuildContext context, String eventId) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
|
||||
+89
-172
@@ -1,3 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:em2rp/providers/users_provider.dart';
|
||||
import 'package:em2rp/providers/event_provider.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
@@ -5,7 +9,6 @@ import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||
import 'package:em2rp/providers/alert_provider.dart';
|
||||
import 'package:em2rp/utils/auth_guard_widget.dart';
|
||||
import 'package:em2rp/utils/performance_monitor.dart';
|
||||
import 'package:em2rp/views/alerts_page.dart';
|
||||
import 'package:em2rp/views/calendar_page.dart';
|
||||
import 'package:em2rp/views/login_page.dart';
|
||||
@@ -19,10 +22,8 @@ import 'package:em2rp/views/event_statistics_page.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'package:em2rp/services/app_initializer.dart';
|
||||
import 'utils/colors.dart';
|
||||
import 'views/my_account_page.dart';
|
||||
import 'views/user_management_page.dart';
|
||||
@@ -30,35 +31,27 @@ import 'package:provider/provider.dart';
|
||||
import 'providers/local_user_provider.dart';
|
||||
import 'views/reset_password_page.dart';
|
||||
import 'config/env.dart';
|
||||
import 'services/update_service.dart';
|
||||
import 'views/widgets/common/update_dialog.dart';
|
||||
import 'config/api_config.dart';
|
||||
import 'utils/app_start_gate.dart';
|
||||
import 'views/widgets/common/startup_splash_screen.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
void main() async {
|
||||
void main() {
|
||||
// Ne pas effectuer d'initialisations asynchrones lourdes ici.
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
);
|
||||
|
||||
// Configuration des émulateurs en mode développement
|
||||
if (ApiConfig.isDevelopment) {
|
||||
print('🔧 Mode développement activé - Utilisation des émulateurs');
|
||||
|
||||
// Configurer l'émulateur Auth
|
||||
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
|
||||
print('✓ Auth émulateur configuré: localhost:9199');
|
||||
|
||||
// Configurer l'émulateur Firestore
|
||||
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
|
||||
print('✓ Firestore émulateur configuré: localhost:8088');
|
||||
if (kReleaseMode) {
|
||||
debugPrint = (String? message, {int? wrapWidth}) {};
|
||||
}
|
||||
|
||||
await FirebaseAuth.instance.setPersistence(Persistence.LOCAL);
|
||||
|
||||
runZonedGuarded(
|
||||
() {
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
// Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond)
|
||||
ChangeNotifierProvider<AppInitializer>(
|
||||
create: (_) => AppInitializer(),
|
||||
),
|
||||
// LocalUserProvider pour la gestion de l'authentification
|
||||
ChangeNotifierProvider<LocalUserProvider>(
|
||||
create: (context) => LocalUserProvider()),
|
||||
@@ -94,13 +87,71 @@ void main() async {
|
||||
child: const MyApp(),
|
||||
),
|
||||
);
|
||||
},
|
||||
(error, stackTrace) {
|
||||
if (kDebugMode) {
|
||||
print('Uncaught error: $error\n$stackTrace');
|
||||
}
|
||||
},
|
||||
zoneSpecification: ZoneSpecification(
|
||||
print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
|
||||
if (!kReleaseMode) {
|
||||
parent.print(zone, line);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
late final Future<void> _startupFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startupFuture = _bootstrapApp();
|
||||
}
|
||||
|
||||
Future<void> _bootstrapApp() async {
|
||||
final initializer = context.read<AppInitializer>();
|
||||
final localAuthProvider = context.read<LocalUserProvider>();
|
||||
|
||||
await initializer.initialize();
|
||||
|
||||
// Lancer la connexion automatique en dev sans bloquer le démarrage initial
|
||||
if (Env.isDevelopment && FirebaseAuth.instance.currentUser == null) {
|
||||
unawaited(
|
||||
localAuthProvider.signInWithEmailAndPassword(
|
||||
Env.devAdminEmail,
|
||||
Env.devAdminPassword,
|
||||
).then((_) {
|
||||
return localAuthProvider.loadUserData();
|
||||
}).catchError((e) {
|
||||
if (kDebugMode) debugPrint('Dev auto-login failed: $e');
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<void>(
|
||||
future: _startupFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: StartupSplashScreen(),
|
||||
);
|
||||
}
|
||||
|
||||
return MaterialApp(
|
||||
title: 'EM2 Hub',
|
||||
theme: ThemeData(
|
||||
@@ -137,15 +188,15 @@ class MyApp extends StatelessWidget {
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
initialRoute: '/',
|
||||
routes: {
|
||||
'/': (context) => const AutoLoginWrapper(),
|
||||
'/login': (context) => const LoginPage(),
|
||||
'/alerts': (context) => const AuthGuard(child: AlertsPage()),
|
||||
'/calendar': (context) => const AuthGuard(child: CalendarPage()),
|
||||
'/calendar': (context) => const AuthGuard(
|
||||
allowWhileLoading: true, child: CalendarPage()),
|
||||
'/my_account': (context) => const AuthGuard(child: MyAccountPage()),
|
||||
'/user_management': (context) => const AuthGuard(
|
||||
requiredPermission: "view_all_users", child: UserManagementPage()),
|
||||
requiredPermission: "view_all_users",
|
||||
child: UserManagementPage()),
|
||||
'/reset_password': (context) {
|
||||
final args = ModalRoute.of(context)!.settings.arguments
|
||||
as Map<String, dynamic>;
|
||||
@@ -173,14 +224,16 @@ class MyApp extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
'/container_detail': (context) {
|
||||
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel;
|
||||
final container = ModalRoute.of(context)!.settings.arguments
|
||||
as ContainerModel;
|
||||
return AuthGuard(
|
||||
requiredPermission: "view_equipment",
|
||||
child: ContainerDetailPage(container: container),
|
||||
);
|
||||
},
|
||||
'/event_preparation': (context) {
|
||||
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
|
||||
final args = ModalRoute.of(context)!.settings.arguments
|
||||
as Map<String, dynamic>;
|
||||
final event = args['event'] as EventModel;
|
||||
return AuthGuard(
|
||||
child: EventPreparationPage(
|
||||
@@ -189,148 +242,12 @@ class MyApp extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
'/event_statistics': (context) => const AuthGuard(
|
||||
requiredPermission: 'generate_reports', child: EventStatisticsPage()),
|
||||
requiredPermission: 'generate_reports',
|
||||
child: EventStatisticsPage()),
|
||||
},
|
||||
home: const AppStartGate(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AutoLoginWrapper extends StatefulWidget {
|
||||
const AutoLoginWrapper({super.key});
|
||||
|
||||
@override
|
||||
State<AutoLoginWrapper> createState() => _AutoLoginWrapperState();
|
||||
}
|
||||
|
||||
class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Attendre la fin du premier build avant de naviguer
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_autoLogin();
|
||||
// Vérifier les mises à jour après un délai pour ne pas interférer avec l'autologin
|
||||
_checkForUpdateDelayed();
|
||||
});
|
||||
}
|
||||
|
||||
/// Vérifie les mises à jour après un délai
|
||||
Future<void> _checkForUpdateDelayed() async {
|
||||
try {
|
||||
// Attendre que l'app soit complètement chargée (navigation effectuée, etc.)
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final updateInfo = await UpdateService.checkForUpdate();
|
||||
|
||||
if (updateInfo != null && mounted) {
|
||||
// Attendre encore un peu pour être sûr que le bon contexte est disponible
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: !updateInfo.forceUpdate,
|
||||
builder: (context) => UpdateDialog(updateInfo: updateInfo),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[AutoLoginWrapper] Error checking for update: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _autoLogin() async {
|
||||
PerformanceMonitor.start('App.autoLogin');
|
||||
try {
|
||||
final localAuthProvider =
|
||||
Provider.of<LocalUserProvider>(context, listen: false);
|
||||
|
||||
// Vérifier si l'utilisateur est déjà connecté
|
||||
if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) {
|
||||
PerformanceMonitor.start('App.signIn');
|
||||
// Connexion automatique en mode développement
|
||||
await localAuthProvider.signInWithEmailAndPassword(
|
||||
Env.devAdminEmail,
|
||||
Env.devAdminPassword,
|
||||
);
|
||||
PerformanceMonitor.end('App.signIn');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL
|
||||
// En Flutter Web, on peut vérifier window.location.hash
|
||||
final currentUri = Uri.base;
|
||||
final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts
|
||||
|
||||
print('[AutoLoginWrapper] Fragment URL: $fragment');
|
||||
|
||||
// Navigation immédiate sans attendre le chargement des données
|
||||
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
||||
print('[AutoLoginWrapper] Redirection vers: $fragment');
|
||||
Navigator.of(context).pushReplacementNamed(fragment);
|
||||
} else {
|
||||
// Route par défaut : calendrier
|
||||
print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)');
|
||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
||||
}
|
||||
|
||||
PerformanceMonitor.end('App.autoLogin');
|
||||
PerformanceMonitor.printSummary();
|
||||
|
||||
// Charger les données utilisateur en arrière-plan
|
||||
localAuthProvider.loadUserData().catchError((e) {
|
||||
print('Error loading user data: $e');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('Auto login failed: $e');
|
||||
PerformanceMonitor.end('App.autoLogin');
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacementNamed('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo de l'application
|
||||
Image.asset(
|
||||
'assets/logos/RectangleLogoBlack.png',
|
||||
width: 200,
|
||||
height: 200,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.event_available,
|
||||
size: 80,
|
||||
color: AppColors.rouge,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Chargement...',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,34 +242,55 @@ class ContainerModel {
|
||||
|
||||
/// Factory depuis Firestore
|
||||
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||
// Fonction helper pour convertir de manière sécurisée en double
|
||||
double? parseDouble(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime
|
||||
DateTime? parseDate(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||
// Gestion sécurisée de la liste d'IDs d'équipements
|
||||
final List<String> equipmentIds = [];
|
||||
if (map['equipmentIds'] is List) {
|
||||
for (final e in map['equipmentIds'] as List) {
|
||||
if (e != null) {
|
||||
equipmentIds.add(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final List<dynamic> historyRaw = map['history'] ?? [];
|
||||
final List<ContainerHistoryEntry> history = historyRaw
|
||||
.map((e) => ContainerHistoryEntry.fromMap(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
// Gestion sécurisée de l'historique
|
||||
final List<ContainerHistoryEntry> history = [];
|
||||
if (map['history'] is List) {
|
||||
for (final e in map['history'] as List) {
|
||||
if (e is Map<String, dynamic>) {
|
||||
history.add(ContainerHistoryEntry.fromMap(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ContainerModel(
|
||||
id: id,
|
||||
name: map['name'] ?? '',
|
||||
type: containerTypeFromString(map['type']),
|
||||
status: equipmentStatusFromString(map['status']),
|
||||
weight: map['weight']?.toDouble(),
|
||||
length: map['length']?.toDouble(),
|
||||
width: map['width']?.toDouble(),
|
||||
height: map['height']?.toDouble(),
|
||||
name: (map['name'] ?? '').toString(),
|
||||
type: containerTypeFromString(map['type']?.toString()),
|
||||
status: equipmentStatusFromString(map['status']?.toString()),
|
||||
weight: parseDouble(map['weight']),
|
||||
length: parseDouble(map['length']),
|
||||
width: parseDouble(map['width']),
|
||||
height: parseDouble(map['height']),
|
||||
equipmentIds: equipmentIds,
|
||||
eventId: map['eventId'],
|
||||
notes: map['notes'],
|
||||
eventId: map['eventId']?.toString(),
|
||||
notes: map['notes']?.toString(),
|
||||
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||
history: history,
|
||||
@@ -355,16 +376,17 @@ class ContainerHistoryEntry {
|
||||
if (value == null) return DateTime.now();
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
return ContainerHistoryEntry(
|
||||
timestamp: parseDate(map['timestamp']),
|
||||
action: map['action'] ?? '',
|
||||
equipmentId: map['equipmentId'],
|
||||
previousValue: map['previousValue'],
|
||||
newValue: map['newValue'],
|
||||
userId: map['userId'],
|
||||
action: (map['action'] ?? '').toString(),
|
||||
equipmentId: map['equipmentId']?.toString(),
|
||||
previousValue: map['previousValue']?.toString(),
|
||||
newValue: map['newValue']?.toString(),
|
||||
userId: map['userId']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class DepotModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final String address;
|
||||
final DateTime? createdAt;
|
||||
|
||||
const DepotModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory DepotModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
return DepotModel(
|
||||
id: id,
|
||||
name: (map['name'] ?? '').toString(),
|
||||
address: (map['address'] ?? '').toString(),
|
||||
createdAt: map['createdAt'] is Timestamp
|
||||
? (map['createdAt'] as Timestamp).toDate()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
factory DepotModel.fromFirestore(DocumentSnapshot doc) {
|
||||
return DepotModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'address': address,
|
||||
'createdAt': createdAt != null
|
||||
? Timestamp.fromDate(createdAt!)
|
||||
: FieldValue.serverTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
DepotModel copyWith({String? id, String? name, String? address}) {
|
||||
return DepotModel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
address: address ?? this.address,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -387,40 +387,64 @@ class EquipmentModel {
|
||||
});
|
||||
|
||||
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||
// Fonction helper pour convertir de manière sécurisée en double
|
||||
double? parseDouble(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fonction helper pour convertir de manière sécurisée en int
|
||||
int? parseInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime
|
||||
DateTime? parseDate(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gestion des listes
|
||||
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
|
||||
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
|
||||
// Gestion sécurisée des listes d'IDs de maintenance
|
||||
final List<String> maintenanceIds = [];
|
||||
if (map['maintenanceIds'] is List) {
|
||||
for (final e in map['maintenanceIds'] as List) {
|
||||
if (e != null) {
|
||||
maintenanceIds.add(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return EquipmentModel(
|
||||
id: id,
|
||||
name: map['name'] ?? '',
|
||||
brand: map['brand'],
|
||||
model: map['model'],
|
||||
category: equipmentCategoryFromString(map['category']),
|
||||
subCategory: map['subCategory'],
|
||||
status: equipmentStatusFromString(map['status']),
|
||||
purchasePrice: map['purchasePrice']?.toDouble(),
|
||||
rentalPrice: map['rentalPrice']?.toDouble(),
|
||||
totalQuantity: map['totalQuantity']?.toInt(),
|
||||
availableQuantity: map['availableQuantity']?.toInt(),
|
||||
criticalThreshold: map['criticalThreshold']?.toInt(),
|
||||
weight: map['weight']?.toDouble(),
|
||||
length: map['length']?.toDouble(),
|
||||
width: map['width']?.toDouble(),
|
||||
height: map['height']?.toDouble(),
|
||||
name: (map['name'] ?? '').toString(),
|
||||
brand: map['brand']?.toString(),
|
||||
model: map['model']?.toString(),
|
||||
category: equipmentCategoryFromString(map['category']?.toString()),
|
||||
subCategory: map['subCategory']?.toString(),
|
||||
status: equipmentStatusFromString(map['status']?.toString()),
|
||||
purchasePrice: parseDouble(map['purchasePrice']),
|
||||
rentalPrice: parseDouble(map['rentalPrice']),
|
||||
totalQuantity: parseInt(map['totalQuantity']),
|
||||
availableQuantity: parseInt(map['availableQuantity']),
|
||||
criticalThreshold: parseInt(map['criticalThreshold']),
|
||||
weight: parseDouble(map['weight']),
|
||||
length: parseDouble(map['length']),
|
||||
width: parseDouble(map['width']),
|
||||
height: parseDouble(map['height']),
|
||||
purchaseDate: parseDate(map['purchaseDate']),
|
||||
lastMaintenanceDate: parseDate(map['lastMaintenanceDate']),
|
||||
nextMaintenanceDate: parseDate(map['nextMaintenanceDate']),
|
||||
maintenanceIds: maintenanceIds,
|
||||
imageUrl: map['imageUrl'],
|
||||
notes: map['notes'],
|
||||
imageUrl: map['imageUrl']?.toString(),
|
||||
notes: map['notes']?.toString(),
|
||||
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||
);
|
||||
|
||||
@@ -174,6 +174,7 @@ ReturnStatus returnStatusFromString(String? status) {
|
||||
class EventEquipment {
|
||||
final String equipmentId; // ID de l'équipement
|
||||
final int quantity; // Quantité initiale assignée
|
||||
final String? rationale; // Explication/Justification (ex: IA alternative)
|
||||
final bool isPrepared; // Validé en préparation
|
||||
final bool isLoaded; // Validé au chargement
|
||||
final bool isUnloaded; // Validé au déchargement
|
||||
@@ -194,6 +195,7 @@ class EventEquipment {
|
||||
EventEquipment({
|
||||
required this.equipmentId,
|
||||
this.quantity = 1,
|
||||
this.rationale,
|
||||
this.isPrepared = false,
|
||||
this.isLoaded = false,
|
||||
this.isUnloaded = false,
|
||||
@@ -212,6 +214,7 @@ class EventEquipment {
|
||||
return EventEquipment(
|
||||
equipmentId: map['equipmentId'] ?? '',
|
||||
quantity: map['quantity'] ?? 1,
|
||||
rationale: map['rationale'],
|
||||
isPrepared: map['isPrepared'] ?? false,
|
||||
isLoaded: map['isLoaded'] ?? false,
|
||||
isUnloaded: map['isUnloaded'] ?? false,
|
||||
@@ -231,6 +234,7 @@ class EventEquipment {
|
||||
return {
|
||||
'equipmentId': equipmentId,
|
||||
'quantity': quantity,
|
||||
'rationale': rationale,
|
||||
'isPrepared': isPrepared,
|
||||
'isLoaded': isLoaded,
|
||||
'isUnloaded': isUnloaded,
|
||||
@@ -249,6 +253,7 @@ class EventEquipment {
|
||||
EventEquipment copyWith({
|
||||
String? equipmentId,
|
||||
int? quantity,
|
||||
String? rationale,
|
||||
bool? isPrepared,
|
||||
bool? isLoaded,
|
||||
bool? isUnloaded,
|
||||
@@ -265,6 +270,7 @@ class EventEquipment {
|
||||
return EventEquipment(
|
||||
equipmentId: equipmentId ?? this.equipmentId,
|
||||
quantity: quantity ?? this.quantity,
|
||||
rationale: rationale ?? this.rationale,
|
||||
isPrepared: isPrepared ?? this.isPrepared,
|
||||
isLoaded: isLoaded ?? this.isLoaded,
|
||||
isUnloaded: isUnloaded ?? this.isUnloaded,
|
||||
|
||||
@@ -60,29 +60,44 @@ class MaintenanceModel {
|
||||
});
|
||||
|
||||
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
||||
// Fonction helper pour convertir de manière sécurisée en double
|
||||
double? parseDouble(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime
|
||||
DateTime? parseDate(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gestion de la liste des équipements
|
||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||
final List<String> equipmentIds = [];
|
||||
if (map['equipmentIds'] is List) {
|
||||
for (final e in map['equipmentIds'] as List) {
|
||||
if (e != null) {
|
||||
equipmentIds.add(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MaintenanceModel(
|
||||
id: id,
|
||||
equipmentIds: equipmentIds,
|
||||
type: maintenanceTypeFromString(map['type']),
|
||||
type: maintenanceTypeFromString(map['type']?.toString()),
|
||||
scheduledDate: parseDate(map['scheduledDate']) ?? DateTime.now(),
|
||||
completedDate: parseDate(map['completedDate']),
|
||||
name: map['name'] ?? '',
|
||||
description: map['description'] ?? '',
|
||||
performedBy: map['performedBy'],
|
||||
cost: map['cost']?.toDouble(),
|
||||
notes: map['notes'],
|
||||
name: (map['name'] ?? '').toString(),
|
||||
description: (map['description'] ?? '').toString(),
|
||||
performedBy: map['performedBy']?.toString(),
|
||||
cost: parseDouble(map['cost']),
|
||||
notes: map['notes']?.toString(),
|
||||
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/// Résultat d'un itinéraire calculé par Google Maps + Ulys.
|
||||
class RouteResult {
|
||||
/// 'TOLL' ou 'TOLL_FREE'
|
||||
final String routeType;
|
||||
final int distanceMeters;
|
||||
final int durationSeconds;
|
||||
final String encodedPolyline;
|
||||
final double tollCost;
|
||||
|
||||
const RouteResult({
|
||||
required this.routeType,
|
||||
required this.distanceMeters,
|
||||
required this.durationSeconds,
|
||||
required this.encodedPolyline,
|
||||
required this.tollCost,
|
||||
});
|
||||
|
||||
factory RouteResult.fromMap(Map<String, dynamic> map) {
|
||||
return RouteResult(
|
||||
routeType: (map['routeType'] ?? 'TOLL').toString(),
|
||||
distanceMeters: _parseInt(map['distanceMeters'] ?? 0),
|
||||
durationSeconds: _parseInt(map['durationSeconds'] ?? 0),
|
||||
encodedPolyline: (map['encodedPolyline'] ?? '').toString(),
|
||||
tollCost: _parseDouble(map['tollCost'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
bool get isTollFree => routeType == 'TOLL_FREE';
|
||||
|
||||
double get distanceKm => distanceMeters / 1000.0;
|
||||
double get durationMinutes => durationSeconds / 60.0;
|
||||
double get durationHours => durationSeconds / 3600.0;
|
||||
|
||||
/// Calcule le coût carburant.
|
||||
/// [consumptionPer100km] : L/100km (ou kWh/100km si électrique)
|
||||
/// [fuelPricePerLiter] : €/L ou €/kWh
|
||||
/// [freeZoneKm] : km gratuits à déduire (zone de gratuité)
|
||||
double fuelCost({
|
||||
required double consumptionPer100km,
|
||||
required double fuelPricePerLiter,
|
||||
double freeZoneKm = 0,
|
||||
}) {
|
||||
final effectiveKm = (distanceKm - freeZoneKm).clamp(0, double.infinity);
|
||||
return (effectiveKm / 100.0) * consumptionPer100km * fuelPricePerLiter;
|
||||
}
|
||||
|
||||
/// Calcule le coût de maintenance.
|
||||
double maintenanceCost({
|
||||
required double costPerKm,
|
||||
double freeZoneKm = 0,
|
||||
}) {
|
||||
final effectiveKm = (distanceKm - freeZoneKm).clamp(0, double.infinity);
|
||||
return effectiveKm * costPerKm;
|
||||
}
|
||||
|
||||
/// Calcule le coût de main-d'œuvre (techniciens).
|
||||
/// [freeZoneMinutes] : minutes gratuites à déduire (zone de gratuité)
|
||||
double laborCost({
|
||||
required int nbTechnicians,
|
||||
required double hourlyRate,
|
||||
double freeZoneMinutes = 0,
|
||||
}) {
|
||||
final effectiveMinutes =
|
||||
(durationMinutes - freeZoneMinutes).clamp(0, double.infinity);
|
||||
return (effectiveMinutes / 60.0) * nbTechnicians * hourlyRate;
|
||||
}
|
||||
|
||||
/// Calcule le coût total pour un aller simple.
|
||||
double totalCost({
|
||||
required double consumptionPer100km,
|
||||
required double fuelPricePerLiter,
|
||||
required double maintenanceCostPerKm,
|
||||
required int nbTechnicians,
|
||||
required double hourlyRate,
|
||||
bool applyFreeZone = false,
|
||||
}) {
|
||||
const freeKm = 20.0;
|
||||
const freeMinutes = 20.0;
|
||||
|
||||
return fuelCost(
|
||||
consumptionPer100km: consumptionPer100km,
|
||||
fuelPricePerLiter: fuelPricePerLiter,
|
||||
freeZoneKm: applyFreeZone ? freeKm : 0,
|
||||
) +
|
||||
maintenanceCost(
|
||||
costPerKm: maintenanceCostPerKm,
|
||||
freeZoneKm: applyFreeZone ? freeKm : 0,
|
||||
) +
|
||||
laborCost(
|
||||
nbTechnicians: nbTechnicians,
|
||||
hourlyRate: hourlyRate,
|
||||
freeZoneMinutes: applyFreeZone ? freeMinutes : 0,
|
||||
) +
|
||||
tollCost;
|
||||
}
|
||||
|
||||
static double _parseDouble(dynamic v) {
|
||||
if (v is double) return v;
|
||||
if (v is int) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
static int _parseInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Prix des carburants (stocké dans Firestore app_config/fuel_prices)
|
||||
class FuelPrices {
|
||||
final double diesel; // €/L
|
||||
final double essence; // €/L
|
||||
final double electricite; // €/kWh
|
||||
|
||||
const FuelPrices({
|
||||
this.diesel = 1.60,
|
||||
this.essence = 1.75,
|
||||
this.electricite = 0.22,
|
||||
});
|
||||
|
||||
factory FuelPrices.fromMap(Map<String, dynamic> map) {
|
||||
return FuelPrices(
|
||||
diesel: _parseDouble(map['diesel'] ?? 1.60),
|
||||
essence: _parseDouble(map['essence'] ?? 1.75),
|
||||
electricite: _parseDouble(map['electricite'] ?? 0.22),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'diesel': diesel,
|
||||
'essence': essence,
|
||||
'electricite': electricite,
|
||||
};
|
||||
|
||||
double priceForFuelType(String fuelType) {
|
||||
switch (fuelType.toLowerCase()) {
|
||||
case 'diesel':
|
||||
return diesel;
|
||||
case 'essence':
|
||||
return essence;
|
||||
case 'electrique':
|
||||
case 'électrique':
|
||||
return electricite;
|
||||
default:
|
||||
return diesel;
|
||||
}
|
||||
}
|
||||
|
||||
static double _parseDouble(dynamic v) {
|
||||
if (v is double) return v;
|
||||
if (v is int) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class VehicleModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final double consumptionPer100km; // L/100km (ou kWh/100km si électrique)
|
||||
final String fuelType; // 'Diesel', 'Essence', 'Electrique'
|
||||
final double maintenanceCostPerKm; // €/km
|
||||
final int tollCategoryId; // 1 à 5 (catégorie Ulys)
|
||||
final DateTime? createdAt;
|
||||
|
||||
const VehicleModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.consumptionPer100km,
|
||||
required this.fuelType,
|
||||
required this.maintenanceCostPerKm,
|
||||
required this.tollCategoryId,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory VehicleModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
return VehicleModel(
|
||||
id: id,
|
||||
name: (map['name'] ?? '').toString(),
|
||||
consumptionPer100km: _parseDouble(map['consumptionPer100km'] ?? 0),
|
||||
fuelType: (map['fuelType'] ?? 'Diesel').toString(),
|
||||
maintenanceCostPerKm: _parseDouble(map['maintenanceCostPerKm'] ?? 0),
|
||||
tollCategoryId: _parseInt(map['tollCategoryId'] ?? 2),
|
||||
createdAt: map['createdAt'] is Timestamp
|
||||
? (map['createdAt'] as Timestamp).toDate()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
factory VehicleModel.fromFirestore(DocumentSnapshot doc) {
|
||||
return VehicleModel.fromMap(
|
||||
doc.data() as Map<String, dynamic>,
|
||||
doc.id,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'consumptionPer100km': consumptionPer100km,
|
||||
'fuelType': fuelType,
|
||||
'maintenanceCostPerKm': maintenanceCostPerKm,
|
||||
'tollCategoryId': tollCategoryId,
|
||||
'createdAt': createdAt != null
|
||||
? Timestamp.fromDate(createdAt!)
|
||||
: FieldValue.serverTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
VehicleModel copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
double? consumptionPer100km,
|
||||
String? fuelType,
|
||||
double? maintenanceCostPerKm,
|
||||
int? tollCategoryId,
|
||||
}) {
|
||||
return VehicleModel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
consumptionPer100km: consumptionPer100km ?? this.consumptionPer100km,
|
||||
fuelType: fuelType ?? this.fuelType,
|
||||
maintenanceCostPerKm: maintenanceCostPerKm ?? this.maintenanceCostPerKm,
|
||||
tollCategoryId: tollCategoryId ?? this.tollCategoryId,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Label lisible pour l'unité de consommation
|
||||
String get consumptionUnit {
|
||||
if (fuelType == 'Electrique') return 'kWh/100km';
|
||||
return 'L/100km';
|
||||
}
|
||||
|
||||
static double _parseDouble(dynamic v) {
|
||||
if (v is double) return v;
|
||||
if (v is int) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
static int _parseInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 2;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class AlertProvider extends ChangeNotifier {
|
||||
return AlertModel.fromMap(data as Map<String, dynamic>, data['id'] as String);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
print('Error loading alerts: $e');
|
||||
if (kDebugMode) debugPrint('Error loading alerts: $e');
|
||||
_alerts = [];
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
@@ -67,7 +67,7 @@ class AlertProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error marking alert as read: $e');
|
||||
if (kDebugMode) debugPrint('Error marking alert as read: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class AlertProvider extends ChangeNotifier {
|
||||
_alerts.removeWhere((a) => a.id == alertId);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error deleting alert: $e');
|
||||
if (kDebugMode) debugPrint('Error deleting alert: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ class AlertProvider extends ChangeNotifier {
|
||||
await markAsRead(alertId);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error marking all alerts as read: $e');
|
||||
if (kDebugMode) debugPrint('Error marking all alerts as read: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ class AlertProvider extends ChangeNotifier {
|
||||
await deleteAlert(alertId);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error deleting read alerts: $e');
|
||||
if (kDebugMode) debugPrint('Error deleting read alerts: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:em2rp/models/alert_model.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
class AlertProvider extends ChangeNotifier {
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
|
||||
List<AlertModel> _alerts = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
List<AlertModel> get alerts => _alerts;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// Nombre d'alertes non lues
|
||||
int get unreadCount => _alerts.where((a) => !a.isRead).length;
|
||||
|
||||
/// Charger toutes les alertes via l'API
|
||||
Future<void> loadAlerts() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final alertsData = await _dataService.getAlerts();
|
||||
|
||||
_alerts = alertsData.map((data) {
|
||||
return AlertModel.fromMap(data, data['id'] as String);
|
||||
}).toList();
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error loading alerts: $e');
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recharger les alertes
|
||||
Future<void> refresh() async {
|
||||
await loadAlerts();
|
||||
}
|
||||
|
||||
/// Obtenir les alertes non lues
|
||||
List<AlertModel> get unreadAlerts {
|
||||
return _alerts.where((a) => !a.isRead).toList();
|
||||
}
|
||||
|
||||
/// Obtenir les alertes par type
|
||||
List<AlertModel> getByType(AlertType type) {
|
||||
return _alerts.where((a) => a.type == type).toList();
|
||||
}
|
||||
|
||||
/// Obtenir les alertes critiques (stock bas, équipement perdu)
|
||||
List<AlertModel> get criticalAlerts {
|
||||
return _alerts.where((a) =>
|
||||
a.type == AlertType.lowStock || a.type == AlertType.lost
|
||||
).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class ContainerProvider with ChangeNotifier {
|
||||
// Charger toutes les pages en boucle
|
||||
while (hasMore) {
|
||||
pageCount++;
|
||||
print('[ContainerProvider] Loading page $pageCount...');
|
||||
DebugLog.info('[ContainerProvider] Loading page $pageCount...');
|
||||
|
||||
final result = await _dataService.getContainersPaginated(
|
||||
limit: 100, // Charger 100 par page pour aller plus vite
|
||||
@@ -86,14 +86,14 @@ class ContainerProvider with ChangeNotifier {
|
||||
hasMore = result['hasMore'] as bool? ?? false;
|
||||
lastVisible = result['lastVisible'] as String?;
|
||||
|
||||
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
|
||||
DebugLog.info('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error loading containers: $e');
|
||||
DebugLog.error('[ContainerProvider] Error loading containers', e);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -292,7 +292,7 @@ class ContainerProvider with ChangeNotifier {
|
||||
Future<List<ContainerModel>> getContainersByIds(List<String> containerIds) async {
|
||||
if (containerIds.isEmpty) return [];
|
||||
|
||||
print('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
|
||||
DebugLog.info('[ContainerProvider] Loading ${containerIds.length} containers by IDs...');
|
||||
|
||||
try {
|
||||
// Vérifier d'abord le cache local
|
||||
@@ -320,7 +320,7 @@ class ContainerProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
print('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
|
||||
DebugLog.info('[ContainerProvider] Found ${cachedContainers.length} in cache, ${missingIds.length} missing');
|
||||
|
||||
// Si tous sont en cache, retourner directement
|
||||
if (missingIds.isEmpty) {
|
||||
@@ -341,12 +341,12 @@ class ContainerProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
print('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
|
||||
DebugLog.info('[ContainerProvider] Loaded ${loadedContainers.length} containers from API');
|
||||
|
||||
// Retourner tous les conteneurs (cache + chargés)
|
||||
return [...cachedContainers, ...loadedContainers];
|
||||
} catch (e) {
|
||||
print('[ContainerProvider] Error loading containers by IDs: $e');
|
||||
DebugLog.error('[ContainerProvider] Error loading containers by IDs', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class EquipmentProvider extends ChangeNotifier {
|
||||
String _searchQuery = '';
|
||||
bool _isLoading = false;
|
||||
bool _isInitialized = false;
|
||||
bool _isFullListLoaded = false;
|
||||
|
||||
// Mode de chargement (pagination vs full)
|
||||
bool _usePagination = false;
|
||||
@@ -48,6 +49,7 @@ class EquipmentProvider extends ChangeNotifier {
|
||||
bool get isLoadingMore => _isLoadingMore;
|
||||
bool get hasMore => _hasMore;
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isFullListLoaded => _isFullListLoaded;
|
||||
bool get usePagination => _usePagination;
|
||||
|
||||
/// S'assure que les équipements sont chargés (charge si nécessaire)
|
||||
@@ -58,16 +60,8 @@ class EquipmentProvider extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si initialisé MAIS _equipment est vide, forcer le rechargement
|
||||
if (_isInitialized && _equipment.isEmpty) {
|
||||
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
|
||||
_isInitialized = false; // Réinitialiser le flag
|
||||
await loadEquipments();
|
||||
return;
|
||||
}
|
||||
|
||||
// Si déjà initialisé avec des données, ne rien faire
|
||||
if (_isInitialized) {
|
||||
// Si déjà initialisé avec le cache complet des données, ne rien faire
|
||||
if (_isFullListLoaded) {
|
||||
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
|
||||
return;
|
||||
}
|
||||
@@ -80,7 +74,7 @@ class EquipmentProvider extends ChangeNotifier {
|
||||
Future<void> loadEquipments() async {
|
||||
print('[EquipmentProvider] Starting to load ALL equipments...');
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
scheduleMicrotask(notifyListeners);
|
||||
|
||||
try {
|
||||
_equipment.clear();
|
||||
@@ -120,7 +114,7 @@ class EquipmentProvider extends ChangeNotifier {
|
||||
|
||||
// Extraire les modèles et marques uniques
|
||||
_extractUniqueValues();
|
||||
|
||||
_isFullListLoaded = true;
|
||||
_isInitialized = true;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -272,7 +266,7 @@ class EquipmentProvider extends ChangeNotifier {
|
||||
_lastVisible = null;
|
||||
_hasMore = true;
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
scheduleMicrotask(notifyListeners);
|
||||
|
||||
try {
|
||||
await loadNextPage();
|
||||
@@ -296,7 +290,7 @@ class EquipmentProvider extends ChangeNotifier {
|
||||
|
||||
_isLoadingMore = true;
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
scheduleMicrotask(notifyListeners);
|
||||
|
||||
try {
|
||||
final result = await _dataService.getEquipmentsPaginated(
|
||||
@@ -433,9 +427,11 @@ class EquipmentProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Supprimer un équipement
|
||||
Future<void> deleteEquipment(String equipmentId) async {
|
||||
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) async {
|
||||
try {
|
||||
await _dataService.deleteEquipment(equipmentId);
|
||||
await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete);
|
||||
_isFullListLoaded = false;
|
||||
_equipment.clear();
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
@@ -451,6 +447,8 @@ class EquipmentProvider extends ChangeNotifier {
|
||||
Future<void> addEquipment(EquipmentModel equipment) async {
|
||||
try {
|
||||
await _dataService.createEquipment(equipment.id, equipment.toMap());
|
||||
_isFullListLoaded = false;
|
||||
_equipment.clear();
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
@@ -466,6 +464,8 @@ class EquipmentProvider extends ChangeNotifier {
|
||||
Future<void> updateEquipment(EquipmentModel equipment) async {
|
||||
try {
|
||||
await _dataService.updateEquipment(equipment.id, equipment.toMap());
|
||||
_isFullListLoaded = false;
|
||||
_equipment.clear();
|
||||
if (_usePagination) {
|
||||
await reload();
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,8 @@ class EventProvider with ChangeNotifier {
|
||||
bool _lastCanViewAll = false;
|
||||
|
||||
// Nouveau: Cache par mois pour le lazy loading
|
||||
final Map<String, List<EventModel>> _eventsByMonth = {}; // "2026-02" => [events]
|
||||
final Map<String, List<EventModel>> _eventsByMonth =
|
||||
{}; // "2026-02" => [events]
|
||||
String? _currentMonth; // Mois actuellement affiché
|
||||
|
||||
List<EventModel> get events => _events;
|
||||
@@ -28,7 +29,8 @@ class EventProvider with ChangeNotifier {
|
||||
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
|
||||
bool _shouldReload(String userId, bool canViewAllEvents) {
|
||||
if (_lastLoadTime == null) return true;
|
||||
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true;
|
||||
if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents)
|
||||
return true;
|
||||
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(_lastLoadTime!);
|
||||
@@ -36,12 +38,14 @@ class EventProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Charger les événements d'un utilisateur via l'API
|
||||
Future<void> loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async {
|
||||
Future<void> loadUserEvents(String userId,
|
||||
{bool canViewAllEvents = false, bool forceReload = false}) async {
|
||||
PerformanceMonitor.start('EventProvider.loadUserEvents');
|
||||
|
||||
// Éviter les rechargements inutiles
|
||||
if (!forceReload && !_shouldReload(userId, canViewAllEvents)) {
|
||||
print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
|
||||
print(
|
||||
'Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)');
|
||||
PerformanceMonitor.end('EventProvider.loadUserEvents');
|
||||
return;
|
||||
}
|
||||
@@ -50,7 +54,8 @@ class EventProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||
print(
|
||||
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||
|
||||
PerformanceMonitor.start('EventProvider.getEvents_API');
|
||||
// Charger via l'API - les permissions sont vérifiées côté serveur
|
||||
@@ -61,9 +66,8 @@ class EventProvider with ChangeNotifier {
|
||||
final usersData = result['users'] as Map<String, dynamic>;
|
||||
|
||||
// Stocker les utilisateurs dans le cache
|
||||
_usersCache = usersData.map((key, value) =>
|
||||
MapEntry(key, value as Map<String, dynamic>)
|
||||
);
|
||||
_usersCache = usersData
|
||||
.map((key, value) => MapEntry(key, value as Map<String, dynamic>));
|
||||
|
||||
print('Found ${eventsData.length} events from API');
|
||||
|
||||
@@ -74,7 +78,8 @@ class EventProvider with ChangeNotifier {
|
||||
// Parser chaque événement
|
||||
for (var eventData in eventsData) {
|
||||
try {
|
||||
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||
final event =
|
||||
EventModel.fromMap(eventData, eventData['id'] as String);
|
||||
allEvents.add(event);
|
||||
} catch (e) {
|
||||
print('Failed to parse event ${eventData['id']}: $e');
|
||||
@@ -88,7 +93,8 @@ class EventProvider with ChangeNotifier {
|
||||
_lastUserId = userId;
|
||||
_lastCanViewAll = canViewAllEvents;
|
||||
|
||||
print('Successfully loaded ${_events.length} events ($failedCount failed)');
|
||||
print(
|
||||
'Successfully loaded ${_events.length} events ($failedCount failed)');
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -104,8 +110,9 @@ class EventProvider with ChangeNotifier {
|
||||
|
||||
/// Charger les événements d'un mois spécifique (lazy loading optimisé)
|
||||
Future<void> loadMonthEvents(String userId, int year, int month,
|
||||
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async {
|
||||
|
||||
{bool canViewAllEvents = false,
|
||||
bool forceReload = false,
|
||||
bool silent = false}) async {
|
||||
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
|
||||
|
||||
// Vérifier le cache
|
||||
@@ -130,19 +137,15 @@ class EventProvider with ChangeNotifier {
|
||||
|
||||
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
|
||||
final result = await _dataService.getEventsByMonth(
|
||||
userId: userId,
|
||||
year: year,
|
||||
month: month
|
||||
);
|
||||
userId: userId, year: year, month: month);
|
||||
PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
|
||||
|
||||
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||
final usersData = result['users'] as Map<String, dynamic>;
|
||||
|
||||
// Mettre à jour le cache utilisateurs (addAll pour cumuler)
|
||||
_usersCache.addAll(
|
||||
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>))
|
||||
);
|
||||
_usersCache.addAll(usersData
|
||||
.map((key, value) => MapEntry(key, value as Map<String, dynamic>)));
|
||||
|
||||
print('[EventProvider] Found ${eventsData.length} events for $monthKey');
|
||||
|
||||
@@ -153,7 +156,8 @@ class EventProvider with ChangeNotifier {
|
||||
// Parser les événements
|
||||
for (var eventData in eventsData) {
|
||||
try {
|
||||
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||
final event =
|
||||
EventModel.fromMap(eventData, eventData['id'] as String);
|
||||
monthEvents.add(event);
|
||||
} catch (e) {
|
||||
print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
|
||||
@@ -176,7 +180,8 @@ class EventProvider with ChangeNotifier {
|
||||
_lastUserId = userId;
|
||||
_lastCanViewAll = canViewAllEvents;
|
||||
|
||||
print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
|
||||
print(
|
||||
'[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)');
|
||||
|
||||
if (!silent) {
|
||||
_isLoading = false;
|
||||
@@ -195,7 +200,6 @@ class EventProvider with ChangeNotifier {
|
||||
/// Précharger les mois adjacents en arrière-plan
|
||||
void preloadAdjacentMonths(String userId, int year, int month,
|
||||
{bool canViewAllEvents = false}) {
|
||||
|
||||
// Mois précédent
|
||||
final prevMonth = month == 1 ? 12 : month - 1;
|
||||
final prevYear = month == 1 ? year - 1 : year;
|
||||
@@ -230,8 +234,10 @@ class EventProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Recharger les événements (utilise le dernier userId)
|
||||
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
|
||||
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
|
||||
Future<void> refreshEvents(String userId,
|
||||
{bool canViewAllEvents = false}) async {
|
||||
await loadUserEvents(userId,
|
||||
canViewAllEvents: canViewAllEvents, forceReload: true);
|
||||
}
|
||||
|
||||
/// Récupérer un événement spécifique par ID
|
||||
@@ -243,6 +249,41 @@ class EventProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche des événements accessibles à l'utilisateur.
|
||||
Future<List<EventModel>> searchEvents({
|
||||
required String userId,
|
||||
required String query,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final trimmedQuery = query.trim();
|
||||
if (trimmedQuery.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final result = await _dataService.searchEvents(
|
||||
userId: userId,
|
||||
query: trimmedQuery,
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
final events = <EventModel>[];
|
||||
for (final eventData in result) {
|
||||
try {
|
||||
final eventId = eventData['id'] as String?;
|
||||
if (eventId == null || eventId.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
events.add(EventModel.fromMap(eventData, eventId));
|
||||
} catch (e) {
|
||||
print('Failed to parse searched event ${eventData['id']}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
return events;
|
||||
}
|
||||
|
||||
/// Ajouter un nouvel événement
|
||||
Future<void> addEvent(EventModel event) async {
|
||||
try {
|
||||
@@ -250,7 +291,8 @@ class EventProvider with ChangeNotifier {
|
||||
_events.add(event);
|
||||
|
||||
// Ajouter dans le cache par mois
|
||||
final monthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
||||
final monthKey =
|
||||
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
||||
if (_eventsByMonth.containsKey(monthKey)) {
|
||||
_eventsByMonth[monthKey]!.add(event);
|
||||
}
|
||||
@@ -272,8 +314,10 @@ class EventProvider with ChangeNotifier {
|
||||
_events[index] = event;
|
||||
|
||||
// Mettre à jour dans le cache par mois
|
||||
final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
|
||||
final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
||||
final oldMonthKey =
|
||||
'${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}';
|
||||
final newMonthKey =
|
||||
'${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}';
|
||||
|
||||
// Si le mois a changé, supprimer de l'ancien et ajouter au nouveau
|
||||
if (oldMonthKey != newMonthKey) {
|
||||
@@ -286,7 +330,8 @@ class EventProvider with ChangeNotifier {
|
||||
} else {
|
||||
// Même mois, juste mettre à jour
|
||||
if (_eventsByMonth.containsKey(newMonthKey)) {
|
||||
final monthIndex = _eventsByMonth[newMonthKey]!.indexWhere((e) => e.id == event.id);
|
||||
final monthIndex = _eventsByMonth[newMonthKey]!
|
||||
.indexWhere((e) => e.id == event.id);
|
||||
if (monthIndex != -1) {
|
||||
_eventsByMonth[newMonthKey]![monthIndex] = event;
|
||||
}
|
||||
@@ -308,7 +353,8 @@ class EventProvider with ChangeNotifier {
|
||||
|
||||
// Trouver l'événement pour obtenir sa date avant de le supprimer
|
||||
final eventToDelete = _events.firstWhere((e) => e.id == eventId);
|
||||
final monthKey = '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
|
||||
final monthKey =
|
||||
'${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}';
|
||||
|
||||
// Supprimer de _events
|
||||
_events.removeWhere((event) => event.id == eventId);
|
||||
|
||||
@@ -12,7 +12,7 @@ import '../utils/performance_monitor.dart';
|
||||
class LocalUserProvider with ChangeNotifier {
|
||||
UserModel? _currentUser;
|
||||
RoleModel? _currentRole;
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
FirebaseAuth? _auth;
|
||||
final FirebaseStorageManager _storageManager = FirebaseStorageManager();
|
||||
final DataService _dataService = DataService(apiService);
|
||||
|
||||
@@ -43,11 +43,41 @@ class LocalUserProvider with ChangeNotifier {
|
||||
|
||||
/// Charge les données de l'utilisateur actuel via Cloud Function
|
||||
Future<void> loadUserData({bool forceReload = false}) async {
|
||||
if (_auth.currentUser == null) {
|
||||
// Si FirebaseAuth n'est pas encore disponible
|
||||
final FirebaseAuth auth;
|
||||
try {
|
||||
auth = _getAuthInstance();
|
||||
} catch (e) {
|
||||
print('Auth instance not ready in loadUserData: $e');
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.currentUser == null) {
|
||||
print('No current user in Auth');
|
||||
return;
|
||||
}
|
||||
|
||||
// Bootstrap léger : rendre l'UID disponible tout de suite pour les écrans
|
||||
// qui en ont besoin, même si le profil complet n'est pas encore chargé.
|
||||
if (_currentUser == null) {
|
||||
final firebaseUser = auth.currentUser!;
|
||||
_currentUser = UserModel(
|
||||
uid: firebaseUser.uid,
|
||||
email: firebaseUser.email ?? '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: 'USER',
|
||||
phoneNumber: '',
|
||||
profilePhotoUrl: firebaseUser.photoURL ?? '',
|
||||
);
|
||||
_currentRole = RoleModel(
|
||||
id: 'USER',
|
||||
name: '',
|
||||
permissions: const [],
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Éviter les rechargements inutiles
|
||||
if (!forceReload && !_shouldReloadUserData()) {
|
||||
print('Using cached user data');
|
||||
@@ -62,7 +92,7 @@ class LocalUserProvider with ChangeNotifier {
|
||||
|
||||
_isLoadingUserData = true;
|
||||
PerformanceMonitor.start('LocalUserProvider.loadUserData');
|
||||
print('Loading user data for: ${_auth.currentUser!.uid}');
|
||||
print('Loading user data for: ${_auth!.currentUser!.uid}');
|
||||
try {
|
||||
// Utiliser la Cloud Function getCurrentUser
|
||||
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
|
||||
@@ -194,7 +224,8 @@ class LocalUserProvider with ChangeNotifier {
|
||||
Future<UserCredential> signInWithEmailAndPassword(
|
||||
String email, String password) async {
|
||||
try {
|
||||
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
|
||||
final auth = _getAuthInstance();
|
||||
UserCredential userCredential = await auth.signInWithEmailAndPassword(
|
||||
email: email, password: password);
|
||||
// Note: loadUserData() sera appelé en arrière-plan dans main.dart
|
||||
// pour ne pas bloquer la navigation
|
||||
@@ -206,10 +237,25 @@ class LocalUserProvider with ChangeNotifier {
|
||||
|
||||
/// Déconnexion
|
||||
Future<void> signOut() async {
|
||||
await _auth.signOut();
|
||||
try {
|
||||
final auth = _getAuthInstance();
|
||||
await auth.signOut();
|
||||
} catch (e) {
|
||||
debugPrint('Error during signOut: $e');
|
||||
}
|
||||
clearUser();
|
||||
}
|
||||
|
||||
FirebaseAuth _getAuthInstance() {
|
||||
try {
|
||||
_auth ??= FirebaseAuth.instance;
|
||||
return _auth!;
|
||||
} catch (e, st) {
|
||||
debugPrint('[LocalUserProvider] FirebaseAuth.instance access error: $e\n$st');
|
||||
throw Exception('FirebaseAuth not available');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a une permission spécifique
|
||||
bool hasPermission(String permission) {
|
||||
return _currentRole?.permissions.contains(permission) ?? false;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:em2rp/models/maintenance_model.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
class MaintenanceProvider extends ChangeNotifier {
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
|
||||
List<MaintenanceModel> _maintenances = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
List<MaintenanceModel> get maintenances => _maintenances;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// Charger toutes les maintenances via l'API
|
||||
Future<void> loadMaintenances({String? equipmentId}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final maintenancesData = await _dataService.getMaintenances(
|
||||
equipmentId: equipmentId,
|
||||
);
|
||||
|
||||
_maintenances = maintenancesData.map((data) {
|
||||
return MaintenanceModel.fromMap(data, data['id'] as String);
|
||||
}).toList();
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error loading maintenances: $e');
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recharger les maintenances
|
||||
Future<void> refresh({String? equipmentId}) async {
|
||||
await loadMaintenances(equipmentId: equipmentId);
|
||||
}
|
||||
|
||||
/// Obtenir les maintenances pour un équipement spécifique
|
||||
List<MaintenanceModel> getForEquipment(String equipmentId) {
|
||||
return _maintenances.where((m) =>
|
||||
m.equipmentIds.contains(equipmentId)
|
||||
).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
/// Repository pour gérer toutes les opérations sur les alertes.
|
||||
class AlertRepository {
|
||||
final ApiService _apiService;
|
||||
|
||||
AlertRepository(this._apiService);
|
||||
|
||||
/// Récupère toutes les alertes
|
||||
Future<List<Map<String, dynamic>>> getAlerts() async {
|
||||
try {
|
||||
final result = await _apiService.call('getAlerts', {});
|
||||
final alerts = result['alerts'] as List<dynamic>?;
|
||||
if (alerts == null) return [];
|
||||
return alerts.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des alertes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque une alerte comme lue
|
||||
Future<void> markAlertAsRead(String alertId) async {
|
||||
try {
|
||||
await _apiService.call('markAlertAsRead', {'alertId': alertId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime une alerte
|
||||
Future<void> deleteAlert(String alertId) async {
|
||||
try {
|
||||
await _apiService.call('deleteAlert', {'alertId': alertId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de l\'alerte: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
|
||||
/// Repository pour gérer toutes les opérations sur les conteneurs.
|
||||
class ContainerRepository {
|
||||
final ApiService _apiService;
|
||||
|
||||
ContainerRepository(this._apiService);
|
||||
|
||||
/// Récupère tous les conteneurs
|
||||
Future<List<Map<String, dynamic>>> getContainers() async {
|
||||
try {
|
||||
final result = await _apiService.call('getContainers', {});
|
||||
final containers = result['containers'] as List<dynamic>?;
|
||||
if (containers == null) return [];
|
||||
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des conteneurs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère plusieurs containers par leurs IDs
|
||||
Future<List<Map<String, dynamic>>> getContainersByIds(
|
||||
List<String> containerIds) async {
|
||||
try {
|
||||
if (containerIds.isEmpty) return [];
|
||||
|
||||
print(
|
||||
'[ContainerRepository] Getting containers by IDs: ${containerIds.length} items');
|
||||
final result = await _apiService.call('getContainersByIds', {
|
||||
'containerIds': containerIds,
|
||||
});
|
||||
final containers = result['containers'] as List<dynamic>?;
|
||||
if (containers == null) {
|
||||
print('[ContainerRepository] No containers in result');
|
||||
return [];
|
||||
}
|
||||
print('[ContainerRepository] Found ${containers.length} containers by IDs');
|
||||
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
print('[ContainerRepository] Error getting containers by IDs: $e');
|
||||
throw Exception('Erreur lors de la récupération des containers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les containers avec pagination et filtrage
|
||||
Future<Map<String, dynamic>> getContainersPaginated({
|
||||
int limit = 20,
|
||||
String? startAfter,
|
||||
String? type,
|
||||
String? status,
|
||||
String? searchQuery,
|
||||
String? category,
|
||||
String sortBy = 'id',
|
||||
String sortOrder = 'asc',
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'limit': limit,
|
||||
'sortBy': sortBy,
|
||||
'sortOrder': sortOrder,
|
||||
};
|
||||
|
||||
if (startAfter != null) params['startAfter'] = startAfter;
|
||||
if (type != null) params['type'] = type;
|
||||
if (status != null) params['status'] = status;
|
||||
if (category != null) params['category'] = category;
|
||||
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||
params['searchQuery'] = searchQuery;
|
||||
}
|
||||
|
||||
final result =
|
||||
await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||
'getContainersPaginated',
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
'containers': (result['containers'] as List<dynamic>?)
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList() ??
|
||||
[],
|
||||
'hasMore': result['hasMore'] as bool? ?? false,
|
||||
'lastVisible': result['lastVisible'] as String?,
|
||||
'total': result['total'] as int? ?? 0,
|
||||
};
|
||||
} catch (e) {
|
||||
DebugLog.error('[ContainerRepository] Error in getContainersPaginated', e);
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération paginée des containers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les containers contenant un équipement
|
||||
Future<List<Map<String, dynamic>>> getContainersByEquipment(
|
||||
String equipmentId) async {
|
||||
try {
|
||||
final result = await _apiService.call('getContainersByEquipment', {
|
||||
'equipmentId': equipmentId,
|
||||
});
|
||||
final containers = result['containers'] as List<dynamic>?;
|
||||
if (containers == null) return [];
|
||||
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des containers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie la disponibilité d'un container
|
||||
Future<Map<String, dynamic>> checkContainerAvailability({
|
||||
required String containerId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _apiService.call('checkContainerAvailability', {
|
||||
'containerId': containerId,
|
||||
'startDate': startDate.toIso8601String(),
|
||||
'endDate': endDate.toIso8601String(),
|
||||
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la vérification de disponibilité du container: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
|
||||
/// Repository pour gérer toutes les opérations sur les équipements.
|
||||
class EquipmentRepository {
|
||||
final ApiService _apiService;
|
||||
|
||||
EquipmentRepository(this._apiService);
|
||||
|
||||
/// Récupère tous les équipements (avec masquage des prix selon permissions)
|
||||
Future<List<Map<String, dynamic>>> getEquipments() async {
|
||||
try {
|
||||
print('[EquipmentRepository] Calling getEquipments API...');
|
||||
final result = await _apiService.call('getEquipments', {});
|
||||
print('[EquipmentRepository] API call successful, parsing result...');
|
||||
final equipments = result['equipments'] as List<dynamic>?;
|
||||
if (equipments == null) {
|
||||
print('[EquipmentRepository] No equipments in result');
|
||||
return [];
|
||||
}
|
||||
print('[EquipmentRepository] Found ${equipments.length} equipments');
|
||||
return equipments.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
print('[EquipmentRepository] Error getting equipments: $e');
|
||||
throw Exception('Erreur lors de la récupération des équipements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère plusieurs équipements par leurs IDs
|
||||
Future<List<Map<String, dynamic>>> getEquipmentsByIds(
|
||||
List<String> equipmentIds) async {
|
||||
try {
|
||||
if (equipmentIds.isEmpty) return [];
|
||||
|
||||
print(
|
||||
'[EquipmentRepository] Getting equipments by IDs: ${equipmentIds.length} items');
|
||||
final result = await _apiService.call('getEquipmentsByIds', {
|
||||
'equipmentIds': equipmentIds,
|
||||
});
|
||||
final equipments = result['equipments'] as List<dynamic>?;
|
||||
if (equipments == null) {
|
||||
print('[EquipmentRepository] No equipments in result');
|
||||
return [];
|
||||
}
|
||||
print('[EquipmentRepository] Found ${equipments.length} equipments by IDs');
|
||||
return equipments.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
print('[EquipmentRepository] Error getting equipments by IDs: $e');
|
||||
throw Exception('Erreur lors de la récupération des équipements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les équipements avec pagination et filtrage
|
||||
Future<Map<String, dynamic>> getEquipmentsPaginated({
|
||||
int limit = 20,
|
||||
String? startAfter,
|
||||
String? category,
|
||||
String? status,
|
||||
String? searchQuery,
|
||||
String sortBy = 'id',
|
||||
String sortOrder = 'asc',
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'limit': limit,
|
||||
'sortBy': sortBy,
|
||||
'sortOrder': sortOrder,
|
||||
};
|
||||
|
||||
if (startAfter != null) params['startAfter'] = startAfter;
|
||||
if (category != null) params['category'] = category;
|
||||
if (status != null) params['status'] = status;
|
||||
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||
params['searchQuery'] = searchQuery;
|
||||
}
|
||||
|
||||
final result =
|
||||
await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||
'getEquipmentsPaginated',
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
'equipments': (result['equipments'] as List<dynamic>?)
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList() ??
|
||||
[],
|
||||
'hasMore': result['hasMore'] as bool? ?? false,
|
||||
'lastVisible': result['lastVisible'] as String?,
|
||||
'total': result['total'] as int? ?? 0,
|
||||
};
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentRepository] Error in getEquipmentsPaginated', e);
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération paginée des équipements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un équipement
|
||||
Future<void> createEquipment(
|
||||
String equipmentId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final equipmentData = Map<String, dynamic>.from(data);
|
||||
equipmentData['id'] = equipmentId;
|
||||
|
||||
await _apiService.call('createEquipment', equipmentData);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création de l\'équipement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour un équipement
|
||||
Future<void> updateEquipment(
|
||||
String equipmentId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _apiService.call('updateEquipment', {
|
||||
'equipmentId': equipmentId,
|
||||
'data': data,
|
||||
});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime un équipement
|
||||
Future<void> deleteEquipment(String equipmentId,
|
||||
{bool forceDelete = false}) async {
|
||||
try {
|
||||
await _apiService.call('deleteEquipment', {
|
||||
'equipmentId': equipmentId,
|
||||
'forceDelete': forceDelete,
|
||||
});
|
||||
} on ApiException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour uniquement le statut d'un équipement
|
||||
Future<void> updateEquipmentStatusOnly({
|
||||
required String equipmentId,
|
||||
String? status,
|
||||
int? availableQuantity,
|
||||
}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{'equipmentId': equipmentId};
|
||||
|
||||
if (status != null) data['status'] = status;
|
||||
if (availableQuantity != null) {
|
||||
data['availableQuantity'] = availableQuantity;
|
||||
}
|
||||
|
||||
await _apiService.call('updateEquipmentStatusOnly', data);
|
||||
} catch (e) {
|
||||
throw Exception(
|
||||
'Erreur lors de la mise à jour du statut de l\'équipement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche rapide (autocomplétion)
|
||||
Future<List<Map<String, dynamic>>> quickSearch(
|
||||
String query, {
|
||||
int limit = 10,
|
||||
bool includeEquipments = true,
|
||||
bool includeContainers = true,
|
||||
}) async {
|
||||
try {
|
||||
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
|
||||
query,
|
||||
limit: limit,
|
||||
includeEquipments: includeEquipments,
|
||||
includeContainers: includeContainers,
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentRepository] Error in quickSearch', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche des équipements pour l'assistant IA avec fallback paginé.
|
||||
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({
|
||||
required String query,
|
||||
int limit = 12,
|
||||
}) async {
|
||||
final normalizedQuery = query.trim();
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
final quickResults = await quickSearch(
|
||||
normalizedQuery,
|
||||
limit: limit,
|
||||
includeEquipments: true,
|
||||
includeContainers: false,
|
||||
);
|
||||
|
||||
final equipmentResults = quickResults
|
||||
.where((item) =>
|
||||
(item['type']?.toString().toLowerCase() ?? '') == 'equipment')
|
||||
.map(_normalizeAssistantEquipment)
|
||||
.toList();
|
||||
|
||||
if (equipmentResults.isNotEmpty) {
|
||||
return equipmentResults;
|
||||
}
|
||||
|
||||
final paginated = await getEquipmentsPaginated(
|
||||
limit: limit,
|
||||
searchQuery: normalizedQuery,
|
||||
sortBy: 'id',
|
||||
sortOrder: 'asc',
|
||||
);
|
||||
|
||||
final equipments =
|
||||
paginated['equipments'] as List<Map<String, dynamic>>? ?? [];
|
||||
return equipments.map(_normalizeAssistantEquipment).toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentRepository] Error in searchEquipmentsForAssistant', e);
|
||||
throw Exception('Erreur lors de la recherche de matériel: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA.
|
||||
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
|
||||
required String equipmentId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
try {
|
||||
final result = await checkEquipmentAvailability(
|
||||
equipmentId: equipmentId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
|
||||
final available = result['available'] as bool? ?? true;
|
||||
final conflicts = (result['conflicts'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((conflict) {
|
||||
final eventData =
|
||||
conflict['eventData'] as Map<String, dynamic>? ?? const {};
|
||||
final eventName =
|
||||
(eventData['Name'] ?? conflict['eventName'] ?? '').toString();
|
||||
return {
|
||||
'eventId': conflict['eventId']?.toString() ?? '',
|
||||
'eventName': eventName,
|
||||
'overlapDays': conflict['overlapDays'] as int? ?? 0,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return {
|
||||
'equipmentId': equipmentId,
|
||||
'available': available,
|
||||
'conflictCount': conflicts.length,
|
||||
'conflicts': conflicts,
|
||||
};
|
||||
} catch (e) {
|
||||
DebugLog.error(
|
||||
'[EquipmentRepository] Error in checkEquipmentAvailabilityForAssistant', e);
|
||||
throw Exception('Erreur lors de la vérification de disponibilité: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie la disponibilité d'un équipement
|
||||
Future<Map<String, dynamic>> checkEquipmentAvailability({
|
||||
required String equipmentId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _apiService.call('checkEquipmentAvailability', {
|
||||
'equipmentId': equipmentId,
|
||||
'startDate': startDate.toIso8601String(),
|
||||
'endDate': endDate.toIso8601String(),
|
||||
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la vérification de disponibilité: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période
|
||||
/// Optimisé : une seule requête au lieu d'une par équipement
|
||||
Future<Map<String, dynamic>> getConflictingEquipmentIds({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
int installationTime = 0,
|
||||
int disassemblyTime = 0,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _apiService.call('getConflictingEquipmentIds', {
|
||||
'startDate': startDate.toIso8601String(),
|
||||
'endDate': endDate.toIso8601String(),
|
||||
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||
'installationTime': installationTime,
|
||||
'disassemblyTime': disassemblyTime,
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération des équipements en conflit: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _normalizeAssistantEquipment(Map<String, dynamic> item) {
|
||||
return {
|
||||
'id': (item['id'] ?? '').toString(),
|
||||
'name': (item['name'] ?? item['id'] ?? '').toString(),
|
||||
'category': (item['category'] ?? '').toString(),
|
||||
'status': (item['status'] ?? '').toString(),
|
||||
'brand': item['brand']?.toString(),
|
||||
'model': item['model']?.toString(),
|
||||
'availableQuantity': item['availableQuantity'],
|
||||
'totalQuantity': item['totalQuantity'],
|
||||
};
|
||||
}
|
||||
|
||||
/// Récupère toutes les maintenances
|
||||
Future<List<Map<String, dynamic>>> getMaintenances(
|
||||
{String? equipmentId}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{};
|
||||
if (equipmentId != null) data['equipmentId'] = equipmentId;
|
||||
|
||||
final result = await _apiService.call('getMaintenances', data);
|
||||
final maintenances = result['maintenances'] as List<dynamic>?;
|
||||
if (maintenances == null) return [];
|
||||
return maintenances.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des maintenances: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime une maintenance
|
||||
Future<void> deleteMaintenance(String maintenanceId) async {
|
||||
try {
|
||||
await _apiService
|
||||
.call('deleteMaintenance', {'maintenanceId': maintenanceId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de la maintenance: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
/// Repository pour gérer toutes les opérations sur les événements.
|
||||
class EventRepository {
|
||||
final ApiService _apiService;
|
||||
|
||||
EventRepository(this._apiService);
|
||||
|
||||
/// Met à jour les équipements d'un événement
|
||||
Future<void> updateEventEquipment({
|
||||
required String eventId,
|
||||
List<Map<String, dynamic>>? assignedEquipment,
|
||||
String? preparationStatus,
|
||||
String? loadingStatus,
|
||||
String? unloadingStatus,
|
||||
String? returnStatus,
|
||||
}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{'eventId': eventId};
|
||||
|
||||
if (assignedEquipment != null) {
|
||||
data['assignedEquipment'] = assignedEquipment;
|
||||
}
|
||||
if (preparationStatus != null) {
|
||||
data['preparationStatus'] = preparationStatus;
|
||||
}
|
||||
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
|
||||
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
|
||||
if (returnStatus != null) data['returnStatus'] = returnStatus;
|
||||
|
||||
await _apiService.call('updateEventEquipment', data);
|
||||
} catch (e) {
|
||||
throw Exception(
|
||||
'Erreur lors de la mise à jour des équipements de l\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour un événement
|
||||
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final requestData = {'eventId': eventId, ...data};
|
||||
await _apiService.call('updateEvent', requestData);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime un événement
|
||||
Future<void> deleteEvent(String eventId) async {
|
||||
try {
|
||||
await _apiService.call('deleteEvent', {'eventId': eventId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de l\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les événements utilisant un type d'événement donné
|
||||
Future<List<Map<String, dynamic>>> getEventsByEventType(
|
||||
String eventTypeId) async {
|
||||
try {
|
||||
final result = await _apiService
|
||||
.call('getEventsByEventType', {'eventTypeId': eventTypeId});
|
||||
final events = result['events'] as List<dynamic>?;
|
||||
if (events == null) return [];
|
||||
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des événements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les événements (filtrés selon permissions)
|
||||
/// Retourne { events: List<Map>, users: Map<String, Map> }
|
||||
Future<Map<String, dynamic>> getEvents({String? userId}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{};
|
||||
if (userId != null) data['userId'] = userId;
|
||||
|
||||
final result = await _apiService.call('getEvents', data);
|
||||
|
||||
// Extraire events et users
|
||||
final events = result['events'] as List<dynamic>? ?? [];
|
||||
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return {
|
||||
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||
'users': users,
|
||||
};
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des événements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les événements d'un mois spécifique (lazy loading optimisé)
|
||||
Future<Map<String, dynamic>> getEventsByMonth({
|
||||
required String userId,
|
||||
required int year,
|
||||
required int month,
|
||||
}) async {
|
||||
try {
|
||||
print('[EventRepository] Calling getEventsByMonth for $year-$month');
|
||||
final result = await _apiService.call('getEventsByMonth', {
|
||||
'userId': userId,
|
||||
'year': year,
|
||||
'month': month,
|
||||
});
|
||||
|
||||
// Extraire events et users
|
||||
final events = result['events'] as List<dynamic>? ?? [];
|
||||
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
print(
|
||||
'[EventRepository] Events loaded for $year-$month: ${events.length} events');
|
||||
|
||||
return {
|
||||
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||
'users': users,
|
||||
};
|
||||
} catch (e) {
|
||||
print('[EventRepository] Error getting events by month: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération des événements du mois: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche des événements accessibles à l'utilisateur.
|
||||
Future<List<Map<String, dynamic>>> searchEvents({
|
||||
required String userId,
|
||||
required String query,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _apiService.call('searchEvents', {
|
||||
'userId': userId,
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
});
|
||||
|
||||
final events = result['events'] as List<dynamic>?;
|
||||
if (events == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la recherche d\'événements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
|
||||
try {
|
||||
print('[EventRepository] Getting event with details: $eventId');
|
||||
final result = await _apiService.call('getEventWithDetails', {
|
||||
'eventId': eventId,
|
||||
});
|
||||
|
||||
final event = result['event'] as Map<String, dynamic>?;
|
||||
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
|
||||
final containers = result['containers'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
if (event == null) {
|
||||
throw Exception('Event not found');
|
||||
}
|
||||
|
||||
print(
|
||||
'[EventRepository] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
|
||||
|
||||
return {
|
||||
'event': event,
|
||||
'equipments': equipments,
|
||||
'containers': containers,
|
||||
};
|
||||
} catch (e) {
|
||||
print('[EventRepository] Error getting event with details: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération de l\'événement avec détails: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
/// Repository pour gérer toutes les opérations sur les options et types d'événements.
|
||||
class OptionRepository {
|
||||
final ApiService _apiService;
|
||||
|
||||
OptionRepository(this._apiService);
|
||||
|
||||
/// Récupère toutes les options
|
||||
Future<List<Map<String, dynamic>>> getOptions() async {
|
||||
try {
|
||||
final result = await _apiService.call('getOptions', {});
|
||||
final options = result['options'] as List<dynamic>?;
|
||||
if (options == null) return [];
|
||||
return options.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des options: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les types d'événements
|
||||
Future<List<Map<String, dynamic>>> getEventTypes() async {
|
||||
try {
|
||||
final result = await _apiService.call('getEventTypes', {});
|
||||
final eventTypes = result['eventTypes'] as List<dynamic>?;
|
||||
if (eventTypes == null) return [];
|
||||
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération des types d\'événements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un type d'événement
|
||||
Future<String> createEventType({
|
||||
required String name,
|
||||
required double defaultPrice,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _apiService.call('createEventType', {
|
||||
'name': name,
|
||||
'defaultPrice': defaultPrice,
|
||||
});
|
||||
return result['id'] as String;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création du type d\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour un type d'événement
|
||||
Future<void> updateEventType({
|
||||
required String eventTypeId,
|
||||
String? name,
|
||||
double? defaultPrice,
|
||||
}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{'eventTypeId': eventTypeId};
|
||||
if (name != null) data['name'] = name;
|
||||
if (defaultPrice != null) data['defaultPrice'] = defaultPrice;
|
||||
|
||||
await _apiService.call('updateEventType', data);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour du type d\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime un type d'événement
|
||||
Future<void> deleteEventType(String eventTypeId) async {
|
||||
try {
|
||||
await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression du type d\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une option
|
||||
Future<String> createOption(String code, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final requestData = {
|
||||
'id': code,
|
||||
'code': code,
|
||||
...data
|
||||
};
|
||||
final result = await _apiService.call('createOption', requestData);
|
||||
return result['id'] as String? ?? code;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création de l\'option: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour une option
|
||||
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final requestData = {'optionId': optionId, 'data': data};
|
||||
await _apiService.call('updateOption', requestData);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime une option
|
||||
Future<void> deleteOption(String optionId) async {
|
||||
try {
|
||||
await _apiService.call('deleteOption', {'optionId': optionId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de l\'option: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
/// Repository pour gérer toutes les opérations sur les utilisateurs et les rôles.
|
||||
class UserRepository {
|
||||
final ApiService _apiService;
|
||||
|
||||
UserRepository(this._apiService);
|
||||
|
||||
/// Récupère l'utilisateur actuellement authentifié avec son rôle
|
||||
Future<Map<String, dynamic>> getCurrentUser() async {
|
||||
try {
|
||||
print('[UserRepository] Calling getCurrentUser API...');
|
||||
final result = await _apiService.call('getCurrentUser', {});
|
||||
print('[UserRepository] Current user loaded successfully');
|
||||
return result['user'] as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
print('[UserRepository] Error getting current user: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération de l\'utilisateur actuel: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les utilisateurs (selon permissions)
|
||||
Future<List<Map<String, dynamic>>> getUsers() async {
|
||||
try {
|
||||
final result = await _apiService.call('getUsers', {});
|
||||
final users = result['users'] as List<dynamic>?;
|
||||
if (users == null) return [];
|
||||
return users.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des utilisateurs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère un utilisateur spécifique
|
||||
Future<Map<String, dynamic>> getUser(String userId) async {
|
||||
try {
|
||||
final result = await _apiService.call('getUser', {'userId': userId});
|
||||
return result['user'] as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération de l\'utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime un utilisateur (Auth + Firestore)
|
||||
Future<void> deleteUser(String userId) async {
|
||||
try {
|
||||
await _apiService.call('deleteUser', {'userId': userId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de l\'utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour un utilisateur
|
||||
Future<void> updateUser(String userId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _apiService.call('updateUser', {
|
||||
'userId': userId,
|
||||
'data': data,
|
||||
});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un utilisateur avec invitation par email
|
||||
Future<Map<String, dynamic>> createUserWithInvite({
|
||||
required String email,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
String? phoneNumber,
|
||||
required String roleId,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _apiService.call('createUserWithInvite', {
|
||||
'email': email,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'phoneNumber': phoneNumber ?? '',
|
||||
'roleId': roleId,
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création de l\'utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les rôles
|
||||
Future<List<Map<String, dynamic>>> getRoles() async {
|
||||
try {
|
||||
final result = await _apiService.call('getRoles', {});
|
||||
final roles = result['roles'] as List<dynamic>?;
|
||||
if (roles == null) return [];
|
||||
return roles.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des rôles: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
|
||||
/// Représente un tour de conversation dans le chat.
|
||||
class AiAssistantChatTurn {
|
||||
final bool isUser;
|
||||
final String text;
|
||||
|
||||
const AiAssistantChatTurn({required this.isUser, required this.text});
|
||||
}
|
||||
|
||||
/// Document à attacher pour demander à l'IA d'analyser un devis, etc.
|
||||
class AiEquipmentDocument {
|
||||
final String base64Data;
|
||||
final String mimeType;
|
||||
final String? fileName;
|
||||
|
||||
const AiEquipmentDocument({
|
||||
required this.base64Data,
|
||||
required this.mimeType,
|
||||
this.fileName,
|
||||
});
|
||||
}
|
||||
|
||||
/// Un item proposé par l'IA dans la liste de matériel.
|
||||
class AiEquipmentProposalItem {
|
||||
final String equipmentId;
|
||||
final int quantity;
|
||||
final String rationale;
|
||||
|
||||
const AiEquipmentProposalItem({
|
||||
required this.equipmentId,
|
||||
required this.quantity,
|
||||
required this.rationale,
|
||||
});
|
||||
}
|
||||
|
||||
/// Métadonnées pour un container proposé par l'IA.
|
||||
class AiEquipmentProposalContainer {
|
||||
final String containerId;
|
||||
final String rationale;
|
||||
final List<String> equipmentIds;
|
||||
final List<String> matchingEquipmentIds;
|
||||
final List<String> missingEquipmentIds;
|
||||
final bool partial;
|
||||
final bool? available;
|
||||
final dynamic availabilityDetail;
|
||||
|
||||
const AiEquipmentProposalContainer({
|
||||
required this.containerId,
|
||||
required this.rationale,
|
||||
this.equipmentIds = const [],
|
||||
this.matchingEquipmentIds = const [],
|
||||
this.missingEquipmentIds = const [],
|
||||
this.partial = false,
|
||||
this.available,
|
||||
this.availabilityDetail,
|
||||
});
|
||||
}
|
||||
|
||||
/// Proposition complète retournée par l'IA.
|
||||
class AiEquipmentProposal {
|
||||
final String summary;
|
||||
final List<AiEquipmentProposalItem> items;
|
||||
|
||||
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
|
||||
final List<EventEquipment> asEventEquipment;
|
||||
|
||||
/// Containers (métadonnées) proposés par l'IA.
|
||||
final List<AiEquipmentProposalContainer> containers;
|
||||
|
||||
List<String> get containerIds => containers.map((c) => c.containerId).toList();
|
||||
|
||||
const AiEquipmentProposal({
|
||||
required this.summary,
|
||||
required this.items,
|
||||
required this.asEventEquipment,
|
||||
required this.containers,
|
||||
});
|
||||
}
|
||||
|
||||
/// Réponse complète de l'assistant IA (message + proposition optionnelle).
|
||||
class AiEquipmentAssistantResponse {
|
||||
final String assistantMessage;
|
||||
final AiEquipmentProposal? proposal;
|
||||
final List<String> debugLogs;
|
||||
|
||||
const AiEquipmentAssistantResponse({
|
||||
required this.assistantMessage,
|
||||
this.proposal,
|
||||
this.debugLogs = const [],
|
||||
});
|
||||
}
|
||||
|
||||
/// Service assistant IA logisticien.
|
||||
/// Délègue tous les appels Gemini à la Cloud Function [aiEquipmentProposal].
|
||||
/// L'authentification Firebase (token Bearer) suffit — aucune clé API côté client.
|
||||
class AiEquipmentAssistantService {
|
||||
final ApiService _apiService;
|
||||
|
||||
AiEquipmentAssistantService({ApiService? apiService})
|
||||
: _apiService = apiService ?? FirebaseFunctionsApiService();
|
||||
|
||||
/// Envoie un message et retourne la réponse de l'assistant IA.
|
||||
Future<AiEquipmentAssistantResponse> generateProposal({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
required List<AiAssistantChatTurn> history,
|
||||
required String userMessage,
|
||||
String? eventTypeId,
|
||||
String? excludeEventId,
|
||||
List<EventEquipment> currentAssignedEquipment = const [],
|
||||
List<EventEquipment> workingProposalEquipment = const [],
|
||||
AiEquipmentDocument? document,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{
|
||||
'startDate': startDate.toIso8601String(),
|
||||
'endDate': endDate.toIso8601String(),
|
||||
'userMessage': userMessage.trim(),
|
||||
'history': history
|
||||
.where((turn) => turn.text.trim().isNotEmpty)
|
||||
.map((turn) => {'isUser': turn.isUser, 'text': turn.text.trim()})
|
||||
.toList(),
|
||||
'currentEquipment': currentAssignedEquipment
|
||||
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
|
||||
.toList(),
|
||||
'workingProposal': workingProposalEquipment
|
||||
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
|
||||
.toList(),
|
||||
};
|
||||
|
||||
if (eventTypeId != null) payload['eventTypeId'] = eventTypeId;
|
||||
if (excludeEventId != null) payload['excludeEventId'] = excludeEventId;
|
||||
|
||||
if (document != null) {
|
||||
payload['document'] = {
|
||||
'mimeType': document.mimeType,
|
||||
'data': document.base64Data,
|
||||
if (document.fileName != null) 'fileName': document.fileName,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function');
|
||||
|
||||
final result = await _apiService.call('aiEquipmentProposal', payload);
|
||||
final assistantMessage = result['assistantMessage']?.toString().trim() ?? '';
|
||||
final proposal = _parseProposal(result['proposal']);
|
||||
|
||||
final rawLogs = result['debugLogs'];
|
||||
final debugLogs = (rawLogs is List) ? rawLogs.map((e) => e.toString()).toList() : <String>[];
|
||||
|
||||
DebugLog.info(
|
||||
'[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}',
|
||||
);
|
||||
|
||||
return AiEquipmentAssistantResponse(
|
||||
assistantMessage: assistantMessage.isNotEmpty
|
||||
? assistantMessage
|
||||
: 'Je n\'ai pas pu générer de réponse.',
|
||||
proposal: proposal,
|
||||
debugLogs: debugLogs,
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
DebugLog.error('[AiEquipmentAssistantService] API error', e);
|
||||
if (e.isUnauthorized) {
|
||||
throw Exception('Vous n\'êtes pas authentifié. Reconnectez-vous et réessayez.');
|
||||
}
|
||||
throw Exception('Erreur du service IA (${e.statusCode}): ${e.message}');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AiEquipmentAssistantService] Error', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
AiEquipmentProposal? _parseProposal(dynamic rawProposal) {
|
||||
if (rawProposal == null || rawProposal is! Map<String, dynamic>) return null;
|
||||
|
||||
final proposalItems = <AiEquipmentProposalItem>[];
|
||||
final eventEquipmentList = <EventEquipment>[];
|
||||
// legacy containerIds variable removed (we now use containersMeta)
|
||||
|
||||
final rawItems = rawProposal['items'];
|
||||
if (rawItems is List) {
|
||||
for (final rawItem in rawItems) {
|
||||
if (rawItem is! Map) continue;
|
||||
final item = Map<String, dynamic>.from(rawItem);
|
||||
|
||||
final equipmentId = item['equipmentId']?.toString().trim() ?? '';
|
||||
final quantity = int.tryParse(item['quantity']?.toString() ?? '1') ?? 1;
|
||||
|
||||
if (equipmentId.isEmpty || quantity <= 0) continue;
|
||||
|
||||
final rationale = item['rationale']?.toString().trim() ?? 'Proposition IA';
|
||||
|
||||
proposalItems.add(AiEquipmentProposalItem(
|
||||
equipmentId: equipmentId,
|
||||
quantity: quantity,
|
||||
rationale: rationale,
|
||||
));
|
||||
eventEquipmentList.add(EventEquipment(
|
||||
equipmentId: equipmentId,
|
||||
quantity: quantity,
|
||||
rationale: rationale,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final containersMeta = <AiEquipmentProposalContainer>[];
|
||||
final rawContainers = rawProposal['containers'];
|
||||
if (rawContainers is List) {
|
||||
for (final rawContainer in rawContainers) {
|
||||
if (rawContainer is String) {
|
||||
final cid = rawContainer.toString().trim();
|
||||
if (cid.isNotEmpty) {
|
||||
containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (rawContainer is! Map) continue;
|
||||
final container = Map<String, dynamic>.from(rawContainer);
|
||||
final containerId = container['containerId']?.toString().trim() ?? '';
|
||||
if (containerId.isEmpty) continue;
|
||||
|
||||
final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA';
|
||||
final equipmentIds = <String>[];
|
||||
final matching = <String>[];
|
||||
final missing = <String>[];
|
||||
|
||||
if (container['equipmentIds'] is List) {
|
||||
for (final v in container['equipmentIds']) {
|
||||
final s = v == null ? null : v.toString().trim();
|
||||
if (s != null && s.isNotEmpty) equipmentIds.add(s);
|
||||
}
|
||||
}
|
||||
if (container['matchingEquipmentIds'] is List) {
|
||||
for (final v in container['matchingEquipmentIds']) {
|
||||
final s = v == null ? null : v.toString().trim();
|
||||
if (s != null && s.isNotEmpty) matching.add(s);
|
||||
}
|
||||
}
|
||||
if (container['missingEquipmentIds'] is List) {
|
||||
for (final v in container['missingEquipmentIds']) {
|
||||
final s = v == null ? null : v.toString().trim();
|
||||
if (s != null && s.isNotEmpty) missing.add(s);
|
||||
}
|
||||
}
|
||||
|
||||
final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty);
|
||||
final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null;
|
||||
final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null;
|
||||
|
||||
containersMeta.add(AiEquipmentProposalContainer(
|
||||
containerId: containerId,
|
||||
rationale: rationale,
|
||||
equipmentIds: equipmentIds,
|
||||
matchingEquipmentIds: matching,
|
||||
missingEquipmentIds: missing,
|
||||
partial: partial,
|
||||
available: available,
|
||||
availabilityDetail: availabilityDetail,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (proposalItems.isEmpty && containersMeta.isEmpty) return null;
|
||||
|
||||
return AiEquipmentProposal(
|
||||
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
|
||||
? rawProposal['summary'].toString().trim()
|
||||
: 'Proposition matériel générée automatiquement.',
|
||||
items: proposalItems,
|
||||
asEventEquipment: eventEquipmentList,
|
||||
containers: containersMeta,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import 'api_service.dart' show FirebaseFunctionsApiService;
|
||||
/// Architecture simplifiée : le client appelle uniquement les Cloud Functions
|
||||
/// Toute la logique métier est gérée côté backend
|
||||
class AlertService {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
FirebaseFirestore get _firestore => FirebaseFirestore.instance;
|
||||
FirebaseAuth get _auth => FirebaseAuth.instance;
|
||||
|
||||
/// Stream des alertes pour l'utilisateur connecté
|
||||
Stream<List<AlertModel>> getAlertsStream() {
|
||||
|
||||
@@ -173,6 +173,8 @@ class FirebaseFunctionsApiService implements ApiService {
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on ApiException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
DebugLog.error('[API] Error during request: $functionName', e);
|
||||
throw ApiException(
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../firebase_options.dart';
|
||||
import '../config/api_config.dart';
|
||||
import 'cache_service.dart';
|
||||
|
||||
/// Service responsable des initialisations lourdes en tâche de fond.
|
||||
///
|
||||
/// Objectif : réduire au maximum le travail synchrone dans main(),
|
||||
/// afficher immédiatement une UI minimale, puis effectuer l'init asynchrone.
|
||||
class AppInitializer with ChangeNotifier {
|
||||
bool _isInitialized = false;
|
||||
bool _isInitializing = false;
|
||||
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isInitializing => _isInitializing;
|
||||
|
||||
final CacheService cacheService = CacheService();
|
||||
|
||||
/// Démarre l'initialisation asynchrone. Idempotent.
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized || _isInitializing) return;
|
||||
_isInitializing = true;
|
||||
scheduleMicrotask(() => notifyListeners());
|
||||
|
||||
try {
|
||||
// Initialiser Firebase
|
||||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
);
|
||||
|
||||
// Configurer les émulateurs en dev si demandé
|
||||
if (ApiConfig.isDevelopment) {
|
||||
try {
|
||||
await FirebaseAuth.instance.useAuthEmulator('localhost', 9199);
|
||||
FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088);
|
||||
} catch (e) {
|
||||
// Ignorer si non supporté
|
||||
if (kDebugMode) print('Emulator setup failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialiser le cache local sans bloquer l'écran de démarrage.
|
||||
unawaited(cacheService.init());
|
||||
|
||||
// Précharger des assets critiques de façon asynchrone
|
||||
unawaited(_preloadAssets());
|
||||
|
||||
// TODO: lancer ici d'autres initialisations non bloquantes
|
||||
|
||||
_isInitialized = true;
|
||||
_isInitializing = false;
|
||||
notifyListeners();
|
||||
} catch (e, st) {
|
||||
if (kDebugMode) print('AppInitializer failed: $e\n$st');
|
||||
_isInitializing = false;
|
||||
// Ne rethrow pas pour éviter de planter l'app; laisser l'UI gérer les erreurs.
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preloadAssets() async {
|
||||
try {
|
||||
// Charger quelques assets en mémoire pour rendre l'affichage initial fluide
|
||||
await rootBundle.load('assets/logos/RectangleLogoBlack.png');
|
||||
await rootBundle.load('assets/logos/SquareLogoWhite.png');
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('Preload assets failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Service simple de cache local basé sur SharedPreferences.
|
||||
///
|
||||
/// Fonctionne sur mobile et sur Flutter Web pour conserver des données
|
||||
/// locales légères quand cela apporte une vraie valeur.
|
||||
class CacheService {
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
bool ready() => _prefs != null;
|
||||
|
||||
Future<void> setJson(String key, Map<String, dynamic> value) async {
|
||||
if (_prefs == null) return;
|
||||
await _prefs!.setString(key, jsonEncode(value));
|
||||
}
|
||||
|
||||
Map<String, dynamic>? getJson(String key) {
|
||||
if (_prefs == null) return null;
|
||||
final s = _prefs!.getString(key);
|
||||
if (s == null) return null;
|
||||
try {
|
||||
return jsonDecode(s) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('CacheService getJson error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setString(String key, String value) async {
|
||||
if (_prefs == null) return;
|
||||
await _prefs!.setString(key, value);
|
||||
}
|
||||
|
||||
String? getString(String key) => _prefs?.getString(key);
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,6 @@ class ContainerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifier la disponibilité d'un container et de son contenu pour un événement
|
||||
Future<Map<String, dynamic>> checkContainerAvailability({
|
||||
required String containerId,
|
||||
required DateTime startDate,
|
||||
@@ -149,43 +148,21 @@ class ContainerService {
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
try {
|
||||
final container = await getContainerById(containerId);
|
||||
if (container == null) {
|
||||
return {'available': false, 'message': 'Container non trouvé'};
|
||||
}
|
||||
|
||||
// Vérifier le statut du container
|
||||
if (container.status != EquipmentStatus.available) {
|
||||
final result = await _dataService.checkContainerAvailability(
|
||||
containerId: containerId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
return {
|
||||
'available': false,
|
||||
'message': 'Container ${container.name} n\'est pas disponible (statut: ${container.status})',
|
||||
'available': result['isAvailable'] ?? false,
|
||||
'message': result['isAvailable'] == true
|
||||
? 'Container et tout son contenu disponibles'
|
||||
: 'Container non disponible ou en conflit',
|
||||
'conflictType': result['conflictType'],
|
||||
'containerConflicts': result['containerConflicts'],
|
||||
'equipmentConflicts': result['equipmentConflicts'],
|
||||
};
|
||||
}
|
||||
|
||||
// Vérifier la disponibilité de chaque équipement dans le container
|
||||
List<String> unavailableEquipment = [];
|
||||
|
||||
if (container.equipmentIds.isNotEmpty) {
|
||||
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
|
||||
|
||||
for (var data in equipmentsData) {
|
||||
final id = data['id'] as String;
|
||||
final equipment = EquipmentModel.fromMap(data, id);
|
||||
if (equipment.status != EquipmentStatus.available) {
|
||||
unavailableEquipment.add('${equipment.name} (${equipment.status})');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unavailableEquipment.isNotEmpty) {
|
||||
return {
|
||||
'available': false,
|
||||
'message': 'Certains équipements ne sont pas disponibles',
|
||||
'unavailableItems': unavailableEquipment,
|
||||
};
|
||||
}
|
||||
|
||||
return {'available': true, 'message': 'Container et tout son contenu disponibles'};
|
||||
} catch (e) {
|
||||
print('Error checking container availability: $e');
|
||||
return {'available': false, 'message': 'Erreur: $e'};
|
||||
|
||||
@@ -1,49 +1,55 @@
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:em2rp/repositories/event_repository.dart';
|
||||
import 'package:em2rp/repositories/equipment_repository.dart';
|
||||
import 'package:em2rp/repositories/container_repository.dart';
|
||||
import 'package:em2rp/repositories/alert_repository.dart';
|
||||
import 'package:em2rp/repositories/user_repository.dart';
|
||||
import 'package:em2rp/repositories/option_repository.dart';
|
||||
|
||||
/// Service générique pour les opérations de lecture de données via Cloud Functions
|
||||
/// Service façade pour rétrocompatibilité.
|
||||
/// Délègue les opérations aux Repositories de domaine respectifs.
|
||||
class DataService {
|
||||
final ApiService _apiService;
|
||||
final EventRepository eventRepository;
|
||||
final EquipmentRepository equipmentRepository;
|
||||
final ContainerRepository containerRepository;
|
||||
final AlertRepository alertRepository;
|
||||
final UserRepository userRepository;
|
||||
final OptionRepository optionRepository;
|
||||
|
||||
DataService(this._apiService);
|
||||
DataService(ApiService apiService)
|
||||
: eventRepository = EventRepository(apiService),
|
||||
equipmentRepository = EquipmentRepository(apiService),
|
||||
containerRepository = ContainerRepository(apiService),
|
||||
alertRepository = AlertRepository(apiService),
|
||||
userRepository = UserRepository(apiService),
|
||||
optionRepository = OptionRepository(apiService);
|
||||
|
||||
/// Récupère toutes les options
|
||||
Future<List<Map<String, dynamic>>> getOptions() async {
|
||||
try {
|
||||
final result = await _apiService.call('getOptions', {});
|
||||
final options = result['options'] as List<dynamic>?;
|
||||
if (options == null) return [];
|
||||
return options.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des options: $e');
|
||||
}
|
||||
}
|
||||
// ============================================================================
|
||||
// OPTIONS & METADATA (delegated to OptionRepository)
|
||||
// ============================================================================
|
||||
Future<List<Map<String, dynamic>>> getOptions() => optionRepository.getOptions();
|
||||
|
||||
/// Récupère tous les types d'événements
|
||||
Future<List<Map<String, dynamic>>> getEventTypes() async {
|
||||
try {
|
||||
final result = await _apiService.call('getEventTypes', {});
|
||||
final eventTypes = result['eventTypes'] as List<dynamic>?;
|
||||
if (eventTypes == null) return [];
|
||||
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des types d\'événements: $e');
|
||||
}
|
||||
}
|
||||
Future<List<Map<String, dynamic>>> getEventTypes() => optionRepository.getEventTypes();
|
||||
|
||||
/// Récupère tous les rôles
|
||||
Future<List<Map<String, dynamic>>> getRoles() async {
|
||||
try {
|
||||
final result = await _apiService.call('getRoles', {});
|
||||
final roles = result['roles'] as List<dynamic>?;
|
||||
if (roles == null) return [];
|
||||
return roles.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des rôles: $e');
|
||||
}
|
||||
}
|
||||
Future<String> createEventType({required String name, required double defaultPrice}) =>
|
||||
optionRepository.createEventType(name: name, defaultPrice: defaultPrice);
|
||||
|
||||
/// Met à jour les équipements d'un événement
|
||||
Future<void> updateEventType({required String eventTypeId, String? name, double? defaultPrice}) =>
|
||||
optionRepository.updateEventType(eventTypeId: eventTypeId, name: name, defaultPrice: defaultPrice);
|
||||
|
||||
Future<void> deleteEventType(String eventTypeId) => optionRepository.deleteEventType(eventTypeId);
|
||||
|
||||
Future<String> createOption(String code, Map<String, dynamic> data) =>
|
||||
optionRepository.createOption(code, data);
|
||||
|
||||
Future<void> updateOption(String optionId, Map<String, dynamic> data) =>
|
||||
optionRepository.updateOption(optionId, data);
|
||||
|
||||
Future<void> deleteOption(String optionId) => optionRepository.deleteOption(optionId);
|
||||
|
||||
// ============================================================================
|
||||
// EVENTS (delegated to EventRepository)
|
||||
// ============================================================================
|
||||
Future<void> updateEventEquipment({
|
||||
required String eventId,
|
||||
List<Map<String, dynamic>>? assignedEquipment,
|
||||
@@ -51,347 +57,45 @@ class DataService {
|
||||
String? loadingStatus,
|
||||
String? unloadingStatus,
|
||||
String? returnStatus,
|
||||
}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{'eventId': eventId};
|
||||
}) =>
|
||||
eventRepository.updateEventEquipment(
|
||||
eventId: eventId,
|
||||
assignedEquipment: assignedEquipment,
|
||||
preparationStatus: preparationStatus,
|
||||
loadingStatus: loadingStatus,
|
||||
unloadingStatus: unloadingStatus,
|
||||
returnStatus: returnStatus,
|
||||
);
|
||||
|
||||
if (assignedEquipment != null) data['assignedEquipment'] = assignedEquipment;
|
||||
if (preparationStatus != null) data['preparationStatus'] = preparationStatus;
|
||||
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
|
||||
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
|
||||
if (returnStatus != null) data['returnStatus'] = returnStatus;
|
||||
Future<void> updateEvent(String eventId, Map<String, dynamic> data) =>
|
||||
eventRepository.updateEvent(eventId, data);
|
||||
|
||||
await _apiService.call('updateEventEquipment', data);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour des équipements de l\'événement: $e');
|
||||
}
|
||||
}
|
||||
Future<void> deleteEvent(String eventId) => eventRepository.deleteEvent(eventId);
|
||||
|
||||
/// Met à jour uniquement le statut d'un équipement
|
||||
Future<void> updateEquipmentStatusOnly({
|
||||
required String equipmentId,
|
||||
String? status,
|
||||
int? availableQuantity,
|
||||
}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{'equipmentId': equipmentId};
|
||||
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) =>
|
||||
eventRepository.getEventsByEventType(eventTypeId);
|
||||
|
||||
if (status != null) data['status'] = status;
|
||||
if (availableQuantity != null) data['availableQuantity'] = availableQuantity;
|
||||
Future<Map<String, dynamic>> getEvents({String? userId}) =>
|
||||
eventRepository.getEvents(userId: userId);
|
||||
|
||||
await _apiService.call('updateEquipmentStatusOnly', data);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour du statut de l\'équipement: $e');
|
||||
}
|
||||
}
|
||||
Future<Map<String, dynamic>> getEventsByMonth({required String userId, required int year, required int month}) =>
|
||||
eventRepository.getEventsByMonth(userId: userId, year: year, month: month);
|
||||
|
||||
/// Met à jour un événement
|
||||
Future<void> updateEvent(String eventId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
// Correction : fusionner eventId et les champs de data à la racine
|
||||
final requestData = {'eventId': eventId, ...data};
|
||||
await _apiService.call('updateEvent', requestData);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour de l\'événement: $e');
|
||||
}
|
||||
}
|
||||
Future<List<Map<String, dynamic>>> searchEvents({required String userId, required String query, int limit = 20}) =>
|
||||
eventRepository.searchEvents(userId: userId, query: query, limit: limit);
|
||||
|
||||
/// Supprime un événement
|
||||
Future<void> deleteEvent(String eventId) async {
|
||||
try {
|
||||
await _apiService.call('deleteEvent', {'eventId': eventId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de l\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un équipement
|
||||
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
// S'assurer que l'ID est dans les données
|
||||
final equipmentData = Map<String, dynamic>.from(data);
|
||||
equipmentData['id'] = equipmentId;
|
||||
|
||||
await _apiService.call('createEquipment', equipmentData);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création de l\'équipement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour un équipement
|
||||
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _apiService.call('updateEquipment', {
|
||||
'equipmentId': equipmentId,
|
||||
'data': data,
|
||||
});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime un équipement
|
||||
Future<void> deleteEquipment(String equipmentId) async {
|
||||
try {
|
||||
await _apiService.call('deleteEquipment', {'equipmentId': equipmentId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de l\'équipement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les événements utilisant un type d'événement donné
|
||||
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) async {
|
||||
try {
|
||||
final result = await _apiService.call('getEventsByEventType', {'eventTypeId': eventTypeId});
|
||||
final events = result['events'] as List<dynamic>?;
|
||||
if (events == null) return [];
|
||||
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des événements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un type d'événement
|
||||
Future<String> createEventType({
|
||||
required String name,
|
||||
required double defaultPrice,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _apiService.call('createEventType', {
|
||||
'name': name,
|
||||
'defaultPrice': defaultPrice,
|
||||
});
|
||||
return result['id'] as String;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création du type d\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour un type d'événement
|
||||
Future<void> updateEventType({
|
||||
required String eventTypeId,
|
||||
String? name,
|
||||
double? defaultPrice,
|
||||
}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{'eventTypeId': eventTypeId};
|
||||
if (name != null) data['name'] = name;
|
||||
if (defaultPrice != null) data['defaultPrice'] = defaultPrice;
|
||||
|
||||
await _apiService.call('updateEventType', data);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour du type d\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime un type d'événement
|
||||
Future<void> deleteEventType(String eventTypeId) async {
|
||||
try {
|
||||
await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression du type d\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une option
|
||||
Future<String> createOption(String code, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final requestData = {
|
||||
'id': code, // Ajouter l'ID en utilisant le code comme identifiant
|
||||
'code': code,
|
||||
...data
|
||||
};
|
||||
final result = await _apiService.call('createOption', requestData);
|
||||
return result['id'] as String? ?? code;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création de l\'option: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour une option
|
||||
Future<void> updateOption(String optionId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final requestData = {'optionId': optionId, 'data': data};
|
||||
await _apiService.call('updateOption', requestData);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour de l\'option: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime une option
|
||||
Future<void> deleteOption(String optionId) async {
|
||||
try {
|
||||
await _apiService.call('deleteOption', {'optionId': optionId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de l\'option: $e');
|
||||
}
|
||||
}
|
||||
Future<Map<String, dynamic>> getEventWithDetails(String eventId) =>
|
||||
eventRepository.getEventWithDetails(eventId);
|
||||
|
||||
// ============================================================================
|
||||
// LECTURE DES DONNÉES (avec permissions côté serveur)
|
||||
// EQUIPMENTS & AVAILABILITY (delegated to EquipmentRepository)
|
||||
// ============================================================================
|
||||
Future<List<Map<String, dynamic>>> getEquipments() =>
|
||||
equipmentRepository.getEquipments();
|
||||
|
||||
/// Récupère tous les événements (filtrés selon permissions)
|
||||
/// Retourne { events: List<Map>, users: Map<String, Map> }
|
||||
Future<Map<String, dynamic>> getEvents({String? userId}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{};
|
||||
if (userId != null) data['userId'] = userId;
|
||||
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) =>
|
||||
equipmentRepository.getEquipmentsByIds(equipmentIds);
|
||||
|
||||
final result = await _apiService.call('getEvents', data);
|
||||
|
||||
// Extraire events et users
|
||||
final events = result['events'] as List<dynamic>? ?? [];
|
||||
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return {
|
||||
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||
'users': users,
|
||||
};
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des événements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les événements d'un mois spécifique (lazy loading optimisé)
|
||||
Future<Map<String, dynamic>> getEventsByMonth({
|
||||
required String userId,
|
||||
required int year,
|
||||
required int month,
|
||||
}) async {
|
||||
try {
|
||||
print('[DataService] Calling getEventsByMonth for $year-$month');
|
||||
final result = await _apiService.call('getEventsByMonth', {
|
||||
'userId': userId,
|
||||
'year': year,
|
||||
'month': month,
|
||||
});
|
||||
|
||||
// Extraire events et users
|
||||
final events = result['events'] as List<dynamic>? ?? [];
|
||||
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
print('[DataService] Events loaded for $year-$month: ${events.length} events');
|
||||
|
||||
return {
|
||||
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||
'users': users,
|
||||
};
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting events by month: $e');
|
||||
throw Exception('Erreur lors de la récupération des événements du mois: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
|
||||
try {
|
||||
print('[DataService] Getting event with details: $eventId');
|
||||
final result = await _apiService.call('getEventWithDetails', {
|
||||
'eventId': eventId,
|
||||
});
|
||||
|
||||
final event = result['event'] as Map<String, dynamic>?;
|
||||
final equipments = result['equipments'] as Map<String, dynamic>? ?? {};
|
||||
final containers = result['containers'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
if (event == null) {
|
||||
throw Exception('Event not found');
|
||||
}
|
||||
|
||||
print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
|
||||
|
||||
return {
|
||||
'event': event,
|
||||
'equipments': equipments,
|
||||
'containers': containers,
|
||||
};
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting event with details: $e');
|
||||
throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les équipements (avec masquage des prix selon permissions)
|
||||
Future<List<Map<String, dynamic>>> getEquipments() async {
|
||||
try {
|
||||
print('[DataService] Calling getEquipments API...');
|
||||
final result = await _apiService.call('getEquipments', {});
|
||||
print('[DataService] API call successful, parsing result...');
|
||||
final equipments = result['equipments'] as List<dynamic>?;
|
||||
if (equipments == null) {
|
||||
print('[DataService] No equipments in result');
|
||||
return [];
|
||||
}
|
||||
print('[DataService] Found ${equipments.length} equipments');
|
||||
return equipments.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting equipments: $e');
|
||||
throw Exception('Erreur lors de la récupération des équipements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère plusieurs équipements par leurs IDs
|
||||
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) async {
|
||||
try {
|
||||
if (equipmentIds.isEmpty) return [];
|
||||
|
||||
print('[DataService] Getting equipments by IDs: ${equipmentIds.length} items');
|
||||
final result = await _apiService.call('getEquipmentsByIds', {
|
||||
'equipmentIds': equipmentIds,
|
||||
});
|
||||
final equipments = result['equipments'] as List<dynamic>?;
|
||||
if (equipments == null) {
|
||||
print('[DataService] No equipments in result');
|
||||
return [];
|
||||
}
|
||||
print('[DataService] Found ${equipments.length} equipments by IDs');
|
||||
return equipments.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting equipments by IDs: $e');
|
||||
throw Exception('Erreur lors de la récupération des équipements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les conteneurs
|
||||
Future<List<Map<String, dynamic>>> getContainers() async {
|
||||
try {
|
||||
final result = await _apiService.call('getContainers', {});
|
||||
final containers = result['containers'] as List<dynamic>?;
|
||||
if (containers == null) return [];
|
||||
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des conteneurs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère plusieurs containers par leurs IDs
|
||||
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async {
|
||||
try {
|
||||
if (containerIds.isEmpty) return [];
|
||||
|
||||
print('[DataService] Getting containers by IDs: ${containerIds.length} items');
|
||||
final result = await _apiService.call('getContainersByIds', {
|
||||
'containerIds': containerIds,
|
||||
});
|
||||
final containers = result['containers'] as List<dynamic>?;
|
||||
if (containers == null) {
|
||||
print('[DataService] No containers in result');
|
||||
return [];
|
||||
}
|
||||
print('[DataService] Found ${containers.length} containers by IDs');
|
||||
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting containers by IDs: $e');
|
||||
throw Exception('Erreur lors de la récupération des containers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EQUIPMENTS & CONTAINERS - Pagination
|
||||
// ============================================================================
|
||||
|
||||
/// Récupère les équipements avec pagination et filtrage
|
||||
Future<Map<String, dynamic>> getEquipmentsPaginated({
|
||||
int limit = 20,
|
||||
String? startAfter,
|
||||
@@ -400,41 +104,92 @@ class DataService {
|
||||
String? searchQuery,
|
||||
String sortBy = 'id',
|
||||
String sortOrder = 'asc',
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'limit': limit,
|
||||
'sortBy': sortBy,
|
||||
'sortOrder': sortOrder,
|
||||
};
|
||||
|
||||
if (startAfter != null) params['startAfter'] = startAfter;
|
||||
if (category != null) params['category'] = category;
|
||||
if (status != null) params['status'] = status;
|
||||
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||
params['searchQuery'] = searchQuery;
|
||||
}
|
||||
|
||||
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||
'getEquipmentsPaginated',
|
||||
params,
|
||||
}) =>
|
||||
equipmentRepository.getEquipmentsPaginated(
|
||||
limit: limit,
|
||||
startAfter: startAfter,
|
||||
category: category,
|
||||
status: status,
|
||||
searchQuery: searchQuery,
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
);
|
||||
|
||||
return {
|
||||
'equipments': (result['equipments'] as List<dynamic>?)
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList() ?? [],
|
||||
'hasMore': result['hasMore'] as bool? ?? false,
|
||||
'lastVisible': result['lastVisible'] as String?,
|
||||
'total': result['total'] as int? ?? 0,
|
||||
};
|
||||
} catch (e) {
|
||||
DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
|
||||
throw Exception('Erreur lors de la récupération paginée des équipements: $e');
|
||||
}
|
||||
}
|
||||
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) =>
|
||||
equipmentRepository.createEquipment(equipmentId, data);
|
||||
|
||||
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) =>
|
||||
equipmentRepository.updateEquipment(equipmentId, data);
|
||||
|
||||
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) =>
|
||||
equipmentRepository.deleteEquipment(equipmentId, forceDelete: forceDelete);
|
||||
|
||||
Future<void> updateEquipmentStatusOnly({required String equipmentId, String? status, int? availableQuantity}) =>
|
||||
equipmentRepository.updateEquipmentStatusOnly(
|
||||
equipmentId: equipmentId,
|
||||
status: status,
|
||||
availableQuantity: availableQuantity,
|
||||
);
|
||||
|
||||
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({required String query, int limit = 12}) =>
|
||||
equipmentRepository.searchEquipmentsForAssistant(query: query, limit: limit);
|
||||
|
||||
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
|
||||
required String equipmentId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) =>
|
||||
equipmentRepository.checkEquipmentAvailabilityForAssistant(
|
||||
equipmentId: equipmentId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
|
||||
Future<Map<String, dynamic>> checkEquipmentAvailability({
|
||||
required String equipmentId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) =>
|
||||
equipmentRepository.checkEquipmentAvailability(
|
||||
equipmentId: equipmentId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
|
||||
Future<Map<String, dynamic>> getConflictingEquipmentIds({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
int installationTime = 0,
|
||||
int disassemblyTime = 0,
|
||||
}) =>
|
||||
equipmentRepository.getConflictingEquipmentIds(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
installationTime: installationTime,
|
||||
disassemblyTime: disassemblyTime,
|
||||
);
|
||||
|
||||
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) =>
|
||||
equipmentRepository.getMaintenances(equipmentId: equipmentId);
|
||||
|
||||
Future<void> deleteMaintenance(String maintenanceId) =>
|
||||
equipmentRepository.deleteMaintenance(maintenanceId);
|
||||
|
||||
// ============================================================================
|
||||
// CONTAINERS (delegated to ContainerRepository)
|
||||
// ============================================================================
|
||||
Future<List<Map<String, dynamic>>> getContainers() =>
|
||||
containerRepository.getContainers();
|
||||
|
||||
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) =>
|
||||
containerRepository.getContainersByIds(containerIds);
|
||||
|
||||
/// Récupère les containers avec pagination et filtrage
|
||||
Future<Map<String, dynamic>> getContainersPaginated({
|
||||
int limit = 20,
|
||||
String? startAfter,
|
||||
@@ -444,267 +199,71 @@ class DataService {
|
||||
String? category,
|
||||
String sortBy = 'id',
|
||||
String sortOrder = 'asc',
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'limit': limit,
|
||||
'sortBy': sortBy,
|
||||
'sortOrder': sortOrder,
|
||||
};
|
||||
|
||||
if (startAfter != null) params['startAfter'] = startAfter;
|
||||
if (type != null) params['type'] = type;
|
||||
if (status != null) params['status'] = status;
|
||||
if (category != null) params['category'] = category;
|
||||
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||
params['searchQuery'] = searchQuery;
|
||||
}
|
||||
|
||||
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||
'getContainersPaginated',
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
'containers': (result['containers'] as List<dynamic>?)
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList() ?? [],
|
||||
'hasMore': result['hasMore'] as bool? ?? false,
|
||||
'lastVisible': result['lastVisible'] as String?,
|
||||
'total': result['total'] as int? ?? 0,
|
||||
};
|
||||
} catch (e) {
|
||||
DebugLog.error('[DataService] Error in getContainersPaginated', e);
|
||||
throw Exception('Erreur lors de la récupération paginée des containers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche rapide (autocomplétion)
|
||||
Future<List<Map<String, dynamic>>> quickSearch(
|
||||
String query, {
|
||||
int limit = 10,
|
||||
bool includeEquipments = true,
|
||||
bool includeContainers = true,
|
||||
}) async {
|
||||
try {
|
||||
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
|
||||
query,
|
||||
}) =>
|
||||
containerRepository.getContainersPaginated(
|
||||
limit: limit,
|
||||
includeEquipments: includeEquipments,
|
||||
includeContainers: includeContainers,
|
||||
startAfter: startAfter,
|
||||
type: type,
|
||||
status: status,
|
||||
searchQuery: searchQuery,
|
||||
category: category,
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLog.error('[DataService] Error in quickSearch', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER - Current User
|
||||
// ============================================================================
|
||||
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) =>
|
||||
containerRepository.getContainersByEquipment(equipmentId);
|
||||
|
||||
/// Récupère l'utilisateur actuellement authentifié avec son rôle
|
||||
Future<Map<String, dynamic>> getCurrentUser() async {
|
||||
try {
|
||||
print('[DataService] Calling getCurrentUser API...');
|
||||
final result = await _apiService.call('getCurrentUser', {});
|
||||
print('[DataService] Current user loaded successfully');
|
||||
return result['user'] as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting current user: $e');
|
||||
throw Exception('Erreur lors de la récupération de l\'utilisateur actuel: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ALERTS
|
||||
// ============================================================================
|
||||
|
||||
/// Récupère toutes les alertes
|
||||
Future<List<Map<String, dynamic>>> getAlerts() async {
|
||||
try {
|
||||
final result = await _apiService.call('getAlerts', {});
|
||||
final alerts = result['alerts'] as List<dynamic>?;
|
||||
if (alerts == null) return [];
|
||||
return alerts.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des alertes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque une alerte comme lue
|
||||
Future<void> markAlertAsRead(String alertId) async {
|
||||
try {
|
||||
await _apiService.call('markAlertAsRead', {'alertId': alertId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime une alerte
|
||||
Future<void> deleteAlert(String alertId) async {
|
||||
try {
|
||||
await _apiService.call('deleteAlert', {'alertId': alertId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de l\'alerte: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EQUIPMENT AVAILABILITY
|
||||
// ============================================================================
|
||||
|
||||
/// Vérifie la disponibilité d'un équipement
|
||||
Future<Map<String, dynamic>> checkEquipmentAvailability({
|
||||
required String equipmentId,
|
||||
Future<Map<String, dynamic>> checkContainerAvailability({
|
||||
required String containerId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _apiService.call('checkEquipmentAvailability', {
|
||||
'equipmentId': equipmentId,
|
||||
'startDate': startDate.toIso8601String(),
|
||||
'endDate': endDate.toIso8601String(),
|
||||
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la vérification de disponibilité: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période
|
||||
/// Optimisé : une seule requête au lieu d'une par équipement
|
||||
Future<Map<String, dynamic>> getConflictingEquipmentIds({
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
int installationTime = 0,
|
||||
int disassemblyTime = 0,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _apiService.call('getConflictingEquipmentIds', {
|
||||
'startDate': startDate.toIso8601String(),
|
||||
'endDate': endDate.toIso8601String(),
|
||||
if (excludeEventId != null) 'excludeEventId': excludeEventId,
|
||||
'installationTime': installationTime,
|
||||
'disassemblyTime': disassemblyTime,
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des équipements en conflit: $e');
|
||||
}
|
||||
}
|
||||
}) =>
|
||||
containerRepository.checkContainerAvailability(
|
||||
containerId: containerId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// MAINTENANCES
|
||||
// USERS (delegated to UserRepository)
|
||||
// ============================================================================
|
||||
Future<Map<String, dynamic>> getCurrentUser() => userRepository.getCurrentUser();
|
||||
|
||||
/// Récupère toutes les maintenances
|
||||
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{};
|
||||
if (equipmentId != null) data['equipmentId'] = equipmentId;
|
||||
Future<List<Map<String, dynamic>>> getUsers() => userRepository.getUsers();
|
||||
|
||||
final result = await _apiService.call('getMaintenances', data);
|
||||
final maintenances = result['maintenances'] as List<dynamic>?;
|
||||
if (maintenances == null) return [];
|
||||
return maintenances.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des maintenances: $e');
|
||||
}
|
||||
}
|
||||
Future<Map<String, dynamic>> getUser(String userId) => userRepository.getUser(userId);
|
||||
|
||||
/// Supprime une maintenance
|
||||
Future<void> deleteMaintenance(String maintenanceId) async {
|
||||
try {
|
||||
await _apiService.call('deleteMaintenance', {'maintenanceId': maintenanceId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de la maintenance: $e');
|
||||
}
|
||||
}
|
||||
Future<void> deleteUser(String userId) => userRepository.deleteUser(userId);
|
||||
|
||||
/// Récupère les containers contenant un équipement
|
||||
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async {
|
||||
try {
|
||||
final result = await _apiService.call('getContainersByEquipment', {
|
||||
'equipmentId': equipmentId,
|
||||
});
|
||||
final containers = result['containers'] as List<dynamic>?;
|
||||
if (containers == null) return [];
|
||||
return containers.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des containers: $e');
|
||||
}
|
||||
}
|
||||
Future<void> updateUser(String userId, Map<String, dynamic> data) =>
|
||||
userRepository.updateUser(userId, data);
|
||||
|
||||
// ============================================================================
|
||||
// USERS
|
||||
// ============================================================================
|
||||
|
||||
/// Récupère tous les utilisateurs (selon permissions)
|
||||
Future<List<Map<String, dynamic>>> getUsers() async {
|
||||
try {
|
||||
final result = await _apiService.call('getUsers', {});
|
||||
final users = result['users'] as List<dynamic>?;
|
||||
if (users == null) return [];
|
||||
return users.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des utilisateurs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère un utilisateur spécifique
|
||||
Future<Map<String, dynamic>> getUser(String userId) async {
|
||||
try {
|
||||
final result = await _apiService.call('getUser', {'userId': userId});
|
||||
return result['user'] as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération de l\'utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime un utilisateur (Auth + Firestore)
|
||||
Future<void> deleteUser(String userId) async {
|
||||
try {
|
||||
await _apiService.call('deleteUser', {'userId': userId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de l\'utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour un utilisateur
|
||||
Future<void> updateUser(String userId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _apiService.call('updateUser', {
|
||||
'userId': userId,
|
||||
'data': data,
|
||||
});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un utilisateur avec invitation par email
|
||||
Future<Map<String, dynamic>> createUserWithInvite({
|
||||
required String email,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
String? phoneNumber,
|
||||
required String roleId,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _apiService.call('createUserWithInvite', {
|
||||
'email': email,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'phoneNumber': phoneNumber ?? '',
|
||||
'roleId': roleId,
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la création de l\'utilisateur: $e');
|
||||
}
|
||||
}
|
||||
}) =>
|
||||
userRepository.createUserWithInvite(
|
||||
email: email,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
phoneNumber: phoneNumber,
|
||||
roleId: roleId,
|
||||
);
|
||||
|
||||
Future<List<Map<String, dynamic>>> getRoles() => userRepository.getRoles();
|
||||
|
||||
// ============================================================================
|
||||
// ALERTS (delegated to AlertRepository)
|
||||
// ============================================================================
|
||||
Future<List<Map<String, dynamic>>> getAlerts() => alertRepository.getAlerts();
|
||||
|
||||
Future<void> markAlertAsRead(String alertId) => alertRepository.markAlertAsRead(alertId);
|
||||
|
||||
Future<void> deleteAlert(String alertId) => alertRepository.deleteAlert(alertId);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
|
||||
|
||||
/// Service d'envoi d'emails via Cloud Functions
|
||||
class EmailService {
|
||||
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9');
|
||||
FirebaseFunctions get _functions => FirebaseFunctions.instanceFor(region: 'europe-west9');
|
||||
|
||||
/// Envoie un email d'alerte à un utilisateur
|
||||
///
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
@@ -66,27 +67,19 @@ class AvailabilityConflict {
|
||||
class EventAvailabilityService {
|
||||
final DataService _dataService = DataService(apiService);
|
||||
|
||||
/// Helper pour récupérer uniquement la liste d'événements
|
||||
Future<List<Map<String, dynamic>>> _getEventsList() async {
|
||||
final result = await _dataService.getEvents();
|
||||
final events = result['events'] as List<dynamic>? ?? [];
|
||||
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Vérifie si un équipement est disponible pour une plage de dates via Cloud Function
|
||||
Future<List<AvailabilityConflict>> checkEquipmentAvailability({
|
||||
required String equipmentId,
|
||||
required String equipmentName,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId, // Pour exclure l'événement en cours d'édition
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
final conflicts = <AvailabilityConflict>[];
|
||||
|
||||
try {
|
||||
print('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
|
||||
if (kDebugMode) debugPrint('[EventAvailabilityService] Checking availability for equipment $equipmentId ($equipmentName)');
|
||||
|
||||
// Utiliser la Cloud Function pour vérifier la disponibilité
|
||||
final result = await _dataService.checkEquipmentAvailability(
|
||||
equipmentId: equipmentId,
|
||||
startDate: startDate,
|
||||
@@ -94,20 +87,12 @@ class EventAvailabilityService {
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
|
||||
print('[EventAvailabilityService] Result for $equipmentId: $result');
|
||||
|
||||
final available = result['available'] as bool? ?? true;
|
||||
print('[EventAvailabilityService] Equipment $equipmentId available: $available');
|
||||
|
||||
if (!available) {
|
||||
final conflictsData = result['conflicts'] as List<dynamic>? ?? [];
|
||||
print('[EventAvailabilityService] Found ${conflictsData.length} conflicts for equipment $equipmentId');
|
||||
|
||||
for (final conflictData in conflictsData) {
|
||||
final conflict = conflictData as Map<String, dynamic>;
|
||||
final eventId = conflict['eventId'] as String;
|
||||
|
||||
// Le backend retourne déjà eventData
|
||||
final eventData = conflict['eventData'] as Map<String, dynamic>?;
|
||||
|
||||
if (eventData != null && eventData.isNotEmpty) {
|
||||
@@ -119,19 +104,16 @@ class EventAvailabilityService {
|
||||
conflictingEvent: event,
|
||||
overlapDays: conflict['overlapDays'] as int? ?? 0,
|
||||
));
|
||||
print('[EventAvailabilityService] Added conflict with event ${event.name}');
|
||||
} catch (e) {
|
||||
print('[EventAvailabilityService] Error creating EventModel: $e');
|
||||
print('[EventAvailabilityService] EventData: $eventData');
|
||||
if (kDebugMode) debugPrint('[EventAvailabilityService] Error creating EventModel: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EventAvailabilityService] Error checking availability: $e');
|
||||
if (kDebugMode) debugPrint('[EventAvailabilityService] Error checking availability: $e');
|
||||
}
|
||||
|
||||
print('[EventAvailabilityService] Returning ${conflicts.length} conflicts for equipment $equipmentId');
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
@@ -159,164 +141,10 @@ class EventAvailabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return allConflicts;
|
||||
}
|
||||
|
||||
/// Vérifie si deux plages de dates se chevauchent
|
||||
bool _datesOverlap(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
|
||||
// Deux plages se chevauchent si elles ne sont PAS complètement séparées
|
||||
// Elles sont séparées si : end1 < start2 OU end2 < start1
|
||||
// Donc elles se chevauchent si : NOT (end1 < start2 OU end2 < start1)
|
||||
// Équivalent à : end1 >= start2 ET end2 >= start1
|
||||
return !end1.isBefore(start2) && !end2.isBefore(start1);
|
||||
}
|
||||
|
||||
/// Calcule le nombre de jours de chevauchement
|
||||
int _calculateOverlapDays(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
|
||||
final overlapStart = start1.isAfter(start2) ? start1 : start2;
|
||||
final overlapEnd = end1.isBefore(end2) ? end1 : end2;
|
||||
|
||||
return overlapEnd.difference(overlapStart).inDays + 1;
|
||||
}
|
||||
|
||||
/// Récupère la quantité disponible pour un consommable/câble
|
||||
Future<int> getAvailableQuantity({
|
||||
required EquipmentModel equipment,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
if (!equipment.hasQuantity) {
|
||||
return 1; // Équipement non consommable
|
||||
}
|
||||
|
||||
final totalQuantity = equipment.totalQuantity ?? 0;
|
||||
int reservedQuantity = 0;
|
||||
|
||||
try {
|
||||
// Récupérer tous les événements via Cloud Function
|
||||
final eventsData = await _getEventsList();
|
||||
|
||||
for (var eventData in eventsData) {
|
||||
final eventId = eventData['id'] as String;
|
||||
if (excludeEventId != null && eventId == excludeEventId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final event = EventModel.fromMap(eventData, eventId);
|
||||
|
||||
// Ignorer les événements annulés
|
||||
if (event.status == EventStatus.canceled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculer les dates réelles avec temps d'installation et démontage
|
||||
final eventRealStartDate = event.startDateTime.subtract(
|
||||
Duration(hours: event.installationTime),
|
||||
);
|
||||
final eventRealEndDate = event.endDateTime.add(
|
||||
Duration(hours: event.disassemblyTime),
|
||||
);
|
||||
|
||||
// Vérifier le chevauchement des dates
|
||||
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
|
||||
final assignedEquipment = event.assignedEquipment.firstWhere(
|
||||
(eq) => eq.equipmentId == equipment.id,
|
||||
orElse: () => EventEquipment(equipmentId: ''),
|
||||
);
|
||||
|
||||
// Si l'équipement est assigné, réserver la quantité
|
||||
// (peu importe le statut de préparation/retour)
|
||||
if (assignedEquipment.equipmentId.isNotEmpty) {
|
||||
reservedQuantity += assignedEquipment.quantity;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EventAvailabilityService] Error processing event $eventId for quantity: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EventAvailabilityService] Error getting available quantity: $e');
|
||||
}
|
||||
|
||||
return totalQuantity - reservedQuantity;
|
||||
}
|
||||
|
||||
/// Vérifie la disponibilité d'un équipement avec gestion des quantités
|
||||
Future<List<AvailabilityConflict>> checkEquipmentAvailabilityWithQuantity({
|
||||
required EquipmentModel equipment,
|
||||
required int requestedQuantity,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
final conflicts = <AvailabilityConflict>[];
|
||||
|
||||
// Si équipement quantifiable (consommable/câble)
|
||||
if (equipment.hasQuantity) {
|
||||
final totalQuantity = equipment.totalQuantity ?? 0;
|
||||
final availableQty = await getAvailableQuantity(
|
||||
equipment: equipment,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
final reservedQty = totalQuantity - availableQty;
|
||||
|
||||
// ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante
|
||||
if (availableQty < requestedQuantity) {
|
||||
// Trouver les événements qui réservent cette quantité
|
||||
final eventsData = await _getEventsList();
|
||||
|
||||
for (var eventData in eventsData) {
|
||||
final eventId = eventData['id'] as String;
|
||||
if (excludeEventId != null && eventId == excludeEventId) continue;
|
||||
|
||||
try {
|
||||
final event = EventModel.fromMap(eventData, eventId);
|
||||
|
||||
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
|
||||
final assignedEquipment = event.assignedEquipment.firstWhere(
|
||||
(eq) => eq.equipmentId == equipment.id,
|
||||
orElse: () => EventEquipment(equipmentId: ''),
|
||||
);
|
||||
|
||||
if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) {
|
||||
conflicts.add(AvailabilityConflict(
|
||||
equipmentId: equipment.id,
|
||||
equipmentName: equipment.name,
|
||||
conflictingEvent: event,
|
||||
overlapDays: _calculateOverlapDays(startDate, endDate, event.startDateTime, event.endDateTime),
|
||||
type: ConflictType.insufficientQuantity,
|
||||
totalQuantity: totalQuantity,
|
||||
availableQuantity: availableQty,
|
||||
requestedQuantity: requestedQuantity,
|
||||
reservedQuantity: reservedQty,
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EventAvailabilityService] Error processing event $eventId: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Équipement non quantifiable : vérification classique
|
||||
return await checkEquipmentAvailability(
|
||||
equipmentId: equipment.id,
|
||||
equipmentName: equipment.name,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/// Vérifie la disponibilité d'une boîte et de son contenu
|
||||
/// Vérifie la disponibilité d'une boîte et de son contenu via le backend
|
||||
Future<List<AvailabilityConflict>> checkContainerAvailability({
|
||||
required ContainerModel container,
|
||||
required List<EquipmentModel> containerEquipment,
|
||||
@@ -325,99 +153,62 @@ class EventAvailabilityService {
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
final conflicts = <AvailabilityConflict>[];
|
||||
final conflictingChildrenIds = <String>[];
|
||||
|
||||
// Vérifier d'abord si la boîte complète est utilisée
|
||||
final eventsData = await _getEventsList();
|
||||
bool isContainerFullyUsed = false;
|
||||
EventModel? containerConflictingEvent;
|
||||
|
||||
for (var eventData in eventsData) {
|
||||
final eventId = eventData['id'] as String;
|
||||
if (excludeEventId != null && eventId == excludeEventId) continue;
|
||||
|
||||
try {
|
||||
final event = EventModel.fromMap(eventData, eventId);
|
||||
|
||||
// Ignorer les événements annulés
|
||||
if (event.status == EventStatus.canceled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculer les dates réelles avec temps d'installation et démontage
|
||||
final eventRealStartDate = event.startDateTime.subtract(
|
||||
Duration(hours: event.installationTime),
|
||||
);
|
||||
final eventRealEndDate = event.endDateTime.add(
|
||||
Duration(hours: event.disassemblyTime),
|
||||
);
|
||||
|
||||
// Vérifier si cette boîte est assignée
|
||||
if (event.assignedContainers.contains(container.id)) {
|
||||
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
|
||||
isContainerFullyUsed = true;
|
||||
containerConflictingEvent = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EventAvailabilityService] Error processing event $eventId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (isContainerFullyUsed && containerConflictingEvent != null) {
|
||||
// Boîte complète utilisée
|
||||
conflicts.add(AvailabilityConflict(
|
||||
equipmentId: container.id,
|
||||
equipmentName: container.name,
|
||||
conflictingEvent: containerConflictingEvent,
|
||||
overlapDays: _calculateOverlapDays(
|
||||
startDate,
|
||||
endDate,
|
||||
containerConflictingEvent.startDateTime,
|
||||
containerConflictingEvent.endDateTime,
|
||||
),
|
||||
type: ConflictType.containerFullyUsed,
|
||||
final result = await _dataService.checkContainerAvailability(
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
));
|
||||
} else {
|
||||
// Vérifier chaque équipement enfant individuellement
|
||||
for (var equipment in containerEquipment) {
|
||||
final equipmentConflicts = await checkEquipmentAvailability(
|
||||
equipmentId: equipment.id,
|
||||
equipmentName: equipment.name,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
|
||||
if (equipmentConflicts.isNotEmpty) {
|
||||
conflictingChildrenIds.add(equipment.id);
|
||||
conflicts.addAll(equipmentConflicts);
|
||||
}
|
||||
}
|
||||
final isAvailable = result['isAvailable'] as bool? ?? true;
|
||||
if (!isAvailable) {
|
||||
final conflictType = result['conflictType'] as String?;
|
||||
|
||||
// Si au moins un enfant en conflit, ajouter un conflit pour la boîte
|
||||
if (conflictingChildrenIds.isNotEmpty && conflicts.isNotEmpty) {
|
||||
conflicts.insert(
|
||||
0,
|
||||
AvailabilityConflict(
|
||||
if (conflictType == 'complete') {
|
||||
final containerConflicts = result['containerConflicts'] as List<dynamic>? ?? [];
|
||||
for (var conflictData in containerConflicts) {
|
||||
final conflict = conflictData as Map<String, dynamic>;
|
||||
final eventId = conflict['eventId'] as String;
|
||||
final eventName = conflict['eventName'] as String? ?? '';
|
||||
final startDateStr = conflict['startDate'] as String?;
|
||||
final endDateStr = conflict['endDate'] as String?;
|
||||
|
||||
final event = EventModel(
|
||||
id: eventId,
|
||||
name: eventName,
|
||||
description: '',
|
||||
startDateTime: startDateStr != null ? DateTime.tryParse(startDateStr) ?? DateTime.now() : DateTime.now(),
|
||||
endDateTime: endDateStr != null ? DateTime.tryParse(endDateStr) ?? DateTime.now() : DateTime.now(),
|
||||
basePrice: 0.0,
|
||||
installationTime: 0,
|
||||
disassemblyTime: 0,
|
||||
eventTypeId: '',
|
||||
customerId: '',
|
||||
address: '',
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
workforce: const [],
|
||||
documents: const [],
|
||||
);
|
||||
|
||||
conflicts.add(AvailabilityConflict(
|
||||
equipmentId: container.id,
|
||||
equipmentName: container.name,
|
||||
conflictingEvent: conflicts.first.conflictingEvent,
|
||||
overlapDays: conflicts.first.overlapDays,
|
||||
type: ConflictType.containerPartiallyUsed,
|
||||
conflictingEvent: event,
|
||||
overlapDays: conflict['overlapDays'] as int? ?? 0,
|
||||
type: ConflictType.containerFullyUsed,
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
conflictingChildrenIds: conflictingChildrenIds,
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[EventAvailabilityService] Error checking container availability: $e');
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import 'package:em2rp/services/equipment_status_calculator.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
class EventPreparationService {
|
||||
final ApiService _apiService = apiService;
|
||||
|
||||
/// 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
|
||||
// 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')
|
||||
Future<void> completeReturnWithMissing(
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'dart:convert';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:em2rp/config/api_config.dart';
|
||||
import 'package:em2rp/models/depot_model.dart';
|
||||
import 'package:em2rp/models/route_result_model.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
|
||||
class TravelService {
|
||||
final FirebaseFirestore _db = FirebaseFirestore.instance;
|
||||
|
||||
// ─── Auth token ───────────────────────────────────────────
|
||||
Future<String?> _getToken() async {
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
return await user?.getIdToken();
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _headers() async {
|
||||
final token = await _getToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Autocomplétion d'adresses ────────────────────────────
|
||||
Future<List<String>> autocompleteAddress(String query) async {
|
||||
if (query.trim().length < 3) return [];
|
||||
try {
|
||||
final headers = await _headers();
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/googleMapsAutocomplete');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({'data': {'query': query}}),
|
||||
);
|
||||
if (response.statusCode != 200) return [];
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final predictions = data['predictions'] as List<dynamic>? ?? [];
|
||||
return predictions
|
||||
.map((p) => (p['description'] ?? '').toString())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[Travel] autocompleteAddress error', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Calcul des itinéraires ───────────────────────────────
|
||||
Future<List<RouteResult>> computeRoutes({
|
||||
required String origin,
|
||||
required String destination,
|
||||
int vehicleTollCategory = 2,
|
||||
}) async {
|
||||
try {
|
||||
final headers = await _headers();
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/googleMapsComputeRoute');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'data': {
|
||||
'origin': origin,
|
||||
'destination': destination,
|
||||
'vehicleTollCategory': vehicleTollCategory,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final err = jsonDecode(response.body);
|
||||
throw Exception('googleMapsComputeRoute: ${err['error']}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final routes = data['routes'] as List<dynamic>? ?? [];
|
||||
return routes
|
||||
.map((r) => RouteResult.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[Travel] computeRoutes error', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Prix des carburants ───────────────────────────────────
|
||||
Future<FuelPrices> getFuelPrices() async {
|
||||
try {
|
||||
final doc = await _db.collection('app_config').doc('fuel_prices').get();
|
||||
if (!doc.exists) return const FuelPrices();
|
||||
return FuelPrices.fromMap(doc.data()!);
|
||||
} catch (e) {
|
||||
return const FuelPrices();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveFuelPrices(FuelPrices prices) async {
|
||||
await _db.collection('app_config').doc('fuel_prices').set(prices.toMap());
|
||||
}
|
||||
|
||||
// ─── Dépôts ───────────────────────────────────────────────
|
||||
Future<List<DepotModel>> getDepots() async {
|
||||
final snap = await _db.collection('depots').orderBy('name').get();
|
||||
return snap.docs.map((d) => DepotModel.fromFirestore(d)).toList();
|
||||
}
|
||||
|
||||
Stream<List<DepotModel>> watchDepots() {
|
||||
return _db
|
||||
.collection('depots')
|
||||
.orderBy('name')
|
||||
.snapshots()
|
||||
.map((s) => s.docs.map((d) => DepotModel.fromFirestore(d)).toList());
|
||||
}
|
||||
|
||||
Future<String> addDepot(DepotModel depot) async {
|
||||
final ref = await _db.collection('depots').add(depot.toMap());
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
Future<void> updateDepot(DepotModel depot) async {
|
||||
final map = depot.toMap();
|
||||
map.remove('createdAt');
|
||||
await _db.collection('depots').doc(depot.id).update(map);
|
||||
}
|
||||
|
||||
Future<void> deleteDepot(String depotId) async {
|
||||
await _db.collection('depots').doc(depotId).delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance singleton
|
||||
final travelService = TravelService();
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/vehicle_model.dart';
|
||||
|
||||
class VehicleService {
|
||||
final FirebaseFirestore _db = FirebaseFirestore.instance;
|
||||
static const String _collection = 'vehicles';
|
||||
|
||||
/// Récupère tous les véhicules, triés par nom.
|
||||
Future<List<VehicleModel>> getVehicles() async {
|
||||
final snapshot = await _db
|
||||
.collection(_collection)
|
||||
.orderBy('name')
|
||||
.get();
|
||||
return snapshot.docs
|
||||
.map((doc) => VehicleModel.fromFirestore(doc))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Stream en temps réel
|
||||
Stream<List<VehicleModel>> watchVehicles() {
|
||||
return _db
|
||||
.collection(_collection)
|
||||
.orderBy('name')
|
||||
.snapshots()
|
||||
.map((snap) =>
|
||||
snap.docs.map((d) => VehicleModel.fromFirestore(d)).toList());
|
||||
}
|
||||
|
||||
/// Ajoute un véhicule
|
||||
Future<String> addVehicle(VehicleModel vehicle) async {
|
||||
final ref = await _db.collection(_collection).add(vehicle.toMap());
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
/// Modifie un véhicule existant
|
||||
Future<void> updateVehicle(VehicleModel vehicle) async {
|
||||
final map = vehicle.toMap();
|
||||
map.remove('createdAt'); // Ne pas écraser la date de création
|
||||
await _db.collection(_collection).doc(vehicle.id).update(map);
|
||||
}
|
||||
|
||||
/// Supprime un véhicule
|
||||
Future<void> deleteVehicle(String vehicleId) async {
|
||||
await _db.collection(_collection).doc(vehicleId).delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../views/login_page.dart';
|
||||
|
||||
import '../providers/local_user_provider.dart';
|
||||
import '../views/widgets/common/startup_splash_screen.dart';
|
||||
|
||||
/// Gate de démarrage qui attend la restauration Firebase Auth avant
|
||||
/// d'afficher soit le contenu connecté, soit la page de connexion.
|
||||
class AppStartGate extends StatelessWidget {
|
||||
const AppStartGate({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Sur le web, certaines erreurs natives (ex: cookies tiers bloqués)
|
||||
// peuvent faire remonter une FirebaseException sur le stream d'auth.
|
||||
// Pour éviter que StreamBuilder reçoive une erreur qui casse le build
|
||||
// (TypeError JS interop), on "handleError" et on transforme l'erreur
|
||||
// en une valeur nulle (pas d'utilisateur) afin de garder l'app stable.
|
||||
// Accès protégé à `FirebaseAuth.instance` — sur le web certaines erreurs
|
||||
// d'interop JS peuvent produire des TypeError non compatibles. Nous
|
||||
// attrapons toute exception lors de l'accès et fournissons un stream
|
||||
// neutre (pas d'utilisateur) afin de garder l'UI stable.
|
||||
late final Stream<User?> safeAuthStream;
|
||||
try {
|
||||
safeAuthStream = FirebaseAuth.instance
|
||||
.authStateChanges()
|
||||
.handleError((error, stack) {
|
||||
// Log pour debug ; ne rethrow pas
|
||||
debugPrint('[AppStartGate] authStateChanges error: $error');
|
||||
});
|
||||
} catch (e, st) {
|
||||
// Sur certaines configurations web l'accès à FirebaseAuth.instance
|
||||
// peut échouer au niveau JS interop. On log puis on fournit un stream
|
||||
// qui émet une seule valeur nulle pour indiquer "pas d'utilisateur".
|
||||
debugPrint('[AppStartGate] FirebaseAuth.instance access error: $e\n$st');
|
||||
safeAuthStream = Stream<User?>.value(null);
|
||||
}
|
||||
|
||||
return StreamBuilder<User?>(
|
||||
stream: safeAuthStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const StartupSplashScreen();
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
// En théorie handleError évite d'arriver ici, mais on garde
|
||||
// une protection supplémentaire.
|
||||
debugPrint('[AppStartGate] snapshot error: ${snapshot.error}');
|
||||
return const StartupSplashScreen(message: 'Erreur de connexion');
|
||||
}
|
||||
|
||||
if (snapshot.data != null) {
|
||||
return const _AuthenticatedBootstrap();
|
||||
}
|
||||
|
||||
return const LoginPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthenticatedBootstrap extends StatefulWidget {
|
||||
const _AuthenticatedBootstrap();
|
||||
|
||||
@override
|
||||
State<_AuthenticatedBootstrap> createState() =>
|
||||
_AuthenticatedBootstrapState();
|
||||
}
|
||||
|
||||
class _AuthenticatedBootstrapState extends State<_AuthenticatedBootstrap> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_redirectAfterAuth();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _redirectAfterAuth() async {
|
||||
final fragment = Uri.base.fragment;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Charger les données utilisateur de façon non bloquante
|
||||
unawaited(
|
||||
context.read<LocalUserProvider>().loadUserData().catchError((e) {
|
||||
debugPrint('[AppStartGate] User data bootstrap failed: $e');
|
||||
}),
|
||||
);
|
||||
|
||||
if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') {
|
||||
Navigator.of(context).pushReplacementNamed(fragment);
|
||||
} else {
|
||||
Navigator.of(context).pushReplacementNamed('/calendar');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const StartupSplashScreen();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/views/login_page.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/views/login_page.dart';
|
||||
import 'package:em2rp/views/widgets/common/startup_splash_screen.dart';
|
||||
|
||||
class AuthGuard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final String? requiredPermission;
|
||||
final bool allowWhileLoading;
|
||||
|
||||
const AuthGuard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.requiredPermission,
|
||||
this.allowWhileLoading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localAuthProvider = Provider.of<LocalUserProvider>(context);
|
||||
final firebaseUser = FirebaseAuth.instance.currentUser;
|
||||
|
||||
// Log pour débug
|
||||
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission');
|
||||
|
||||
// Si Firebase n'a pas encore restauré la session ou si le profil charge,
|
||||
// afficher un écran neutre plutôt que la page de connexion.
|
||||
if (firebaseUser != null &&
|
||||
(localAuthProvider.currentUser == null ||
|
||||
localAuthProvider.isLoadingUserData)) {
|
||||
if (allowWhileLoading) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return const StartupSplashScreen(message: 'Chargement du profil...');
|
||||
}
|
||||
|
||||
// Si l'utilisateur n'est pas connecté
|
||||
if (localAuthProvider.currentUser == null) {
|
||||
if (firebaseUser == null || localAuthProvider.currentUser == null) {
|
||||
print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
|
||||
return const LoginPage();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Utilitaire pour différer l'exécution d'une action après un délai.
|
||||
/// Utilisé principalement pour les champs de recherche afin d'éviter
|
||||
/// des requêtes à chaque frappe clavier.
|
||||
class Debouncer {
|
||||
final Duration delay;
|
||||
Timer? _timer;
|
||||
|
||||
Debouncer({this.delay = const Duration(milliseconds: 400)});
|
||||
|
||||
void call(VoidCallback action) {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(delay, action);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Utilitaires partages pour la suppression d'equipement avec forcage.
|
||||
class EquipmentDeleteUtils {
|
||||
static const String _legacyConflictToken = 'future_event_assignment';
|
||||
static const List<String> _conflictMessageTokens = [
|
||||
'cannot delete equipment because it is assigned to upcoming events',
|
||||
'cannot delete equipment because it is assigned to future events',
|
||||
'assigned to upcoming events',
|
||||
'assigned to future events',
|
||||
];
|
||||
|
||||
static const String deleteDialogTitle = 'Confirmer la suppression';
|
||||
static const String deleteDialogCancelLabel = 'Annuler';
|
||||
static const String deleteDialogConfirmLabel = 'Supprimer';
|
||||
static const String deleteSuccessMessage = 'Équipement supprimé avec succès';
|
||||
|
||||
/// Retourne [name] si renseigne, sinon [id].
|
||||
static String resolveEquipmentLabel({required String id, String? name}) {
|
||||
final trimmedName = name?.trim();
|
||||
if (trimmedName == null || trimmedName.isEmpty) {
|
||||
return id;
|
||||
}
|
||||
return trimmedName;
|
||||
}
|
||||
|
||||
/// Construit le message de confirmation de suppression d'un equipement.
|
||||
static String buildSingleDeleteConfirmationMessage(String equipmentLabel) {
|
||||
return 'Voulez-vous vraiment supprimer "$equipmentLabel" ?\n\n'
|
||||
'Cette action est irréversible.';
|
||||
}
|
||||
|
||||
/// Construit le message de confirmation de suppression multiple.
|
||||
static String buildBulkDeleteConfirmationMessage(int selectedCount) {
|
||||
return 'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?\n\n'
|
||||
'Cette action est irréversible.';
|
||||
}
|
||||
|
||||
/// Construit le message de succes de suppression multiple.
|
||||
static String buildBulkDeleteSuccessMessage(int deletedCount) {
|
||||
return '$deletedCount équipement(s) supprimé(s) avec succès';
|
||||
}
|
||||
|
||||
/// Construit un message d'erreur de suppression homogene.
|
||||
static String buildDeleteErrorMessage(Object error) {
|
||||
return 'Erreur lors de la suppression : $error';
|
||||
}
|
||||
|
||||
/// Indique si l'erreur correspond a un conflit de suppression 409.
|
||||
static bool isFutureAssignmentDeleteConflict(Object error) {
|
||||
if (error is ApiException && !error.isConflict) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final normalizedMessage = _normalizeErrorMessage(error);
|
||||
if (normalizedMessage.contains(_legacyConflictToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return _conflictMessageTokens.any(normalizedMessage.contains);
|
||||
}
|
||||
|
||||
/// Affiche la confirmation de suppression forcee.
|
||||
static Future<bool> showForceDeleteDialog(
|
||||
BuildContext context, {
|
||||
required String equipmentLabel,
|
||||
}) async {
|
||||
final shouldForceDelete = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Équipement utilisé dans un événement à venir'),
|
||||
content: Text(
|
||||
'"$equipmentLabel" est assigné à au moins un événement à venir.\n\n'
|
||||
'Voulez-vous forcer la suppression ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Forcer la suppression'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return shouldForceDelete == true;
|
||||
}
|
||||
|
||||
/// Execute une suppression, puis propose un forcage en cas de conflit 409.
|
||||
static Future<bool> deleteWithFutureAssignmentCheck({
|
||||
required BuildContext context,
|
||||
required String equipmentLabel,
|
||||
required Future<void> Function({bool forceDelete}) deleteEquipment,
|
||||
}) async {
|
||||
try {
|
||||
await deleteEquipment(forceDelete: false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!isFutureAssignmentDeleteConflict(error)) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final shouldForceDelete = await showForceDeleteDialog(
|
||||
context,
|
||||
equipmentLabel: equipmentLabel,
|
||||
);
|
||||
if (!shouldForceDelete) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await deleteEquipment(forceDelete: true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static String _normalizeErrorMessage(Object error) {
|
||||
if (error is ApiException) {
|
||||
return error.message.toLowerCase();
|
||||
}
|
||||
return error.toString().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
class FirebaseStorageManager {
|
||||
final FirebaseStorage _storage = FirebaseStorage.instance;
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
FirebaseStorage get _storage => FirebaseStorage.instance;
|
||||
final DataService _dataService = DataService(apiService);
|
||||
|
||||
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
|
||||
/// Pour le Web, on fixe l'extension .jpg.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
List<LatLng> safeDecodePolyline(String encoded) {
|
||||
if (encoded.isEmpty) return [];
|
||||
try {
|
||||
List<LatLng> poly = [];
|
||||
int index = 0, len = encoded.length;
|
||||
int lat = 0, lng = 0;
|
||||
|
||||
while (index < len) {
|
||||
int b, shift = 0, result = 0;
|
||||
do {
|
||||
if (index >= len) break;
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
|
||||
// Dart Web bitwise operations (~ and >>) can cause 32-bit unsigned wrap-around
|
||||
// Using arithmetic avoids the issue where lat becomes 42995.xxxx (offset by 2^32)
|
||||
int dlat = (result & 1) != 0 ? -((result >> 1) + 1) : (result >> 1);
|
||||
lat += dlat;
|
||||
// Correction manuelle au cas où un wrap unsigned 32-bit s'est produit
|
||||
if (lat > 2147483647) lat -= 4294967296;
|
||||
|
||||
shift = 0;
|
||||
result = 0;
|
||||
do {
|
||||
if (index >= len) break;
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
|
||||
int dlng = (result & 1) != 0 ? -((result >> 1) + 1) : (result >> 1);
|
||||
lng += dlng;
|
||||
if (lng > 2147483647) lng -= 4294967296;
|
||||
|
||||
double finalLat = lat / 1e5;
|
||||
double finalLng = lng / 1e5;
|
||||
|
||||
poly.add(LatLng(finalLat, finalLng));
|
||||
}
|
||||
|
||||
return poly;
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[POLYLINE] Erreur décodage: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import '../providers/local_user_provider.dart';
|
||||
@@ -33,22 +35,17 @@ class LoginViewModel extends ChangeNotifier {
|
||||
passwordController.text,
|
||||
);
|
||||
|
||||
// --- Étape 2: Charger les données utilisateur depuis Firestore ---
|
||||
await localAuthProvider.loadUserData();
|
||||
// --- Étape 2: Charger les données utilisateur en arrière-plan ---
|
||||
unawaited(
|
||||
localAuthProvider.loadUserData().catchError((e) {
|
||||
debugPrint('Erreur chargement profil après connexion : $e');
|
||||
}),
|
||||
);
|
||||
|
||||
// Vérifier si le contexte est toujours valide
|
||||
if (context.mounted) {
|
||||
// Vérifier si l'utilisateur a bien été chargé dans le provider
|
||||
if (localAuthProvider.currentUser != null) {
|
||||
// Utiliser pushReplacementNamed pour une transition propre
|
||||
Navigator.of(context, rootNavigator: true)
|
||||
.pushReplacementNamed('/calendar');
|
||||
} else {
|
||||
errorMessage =
|
||||
'Erreur inattendue après connexion: Données utilisateur non chargées.';
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
} on FirebaseAuthException catch (e) {
|
||||
// Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
@@ -6,10 +7,12 @@ import 'package:em2rp/utils/performance_monitor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||
import 'package:em2rp/views/widgets/common/startup_splash_screen.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/views/widgets/calendar_widgets/event_details.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
|
||||
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
|
||||
@@ -40,8 +43,18 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
int _selectedEventIndex = 0;
|
||||
String?
|
||||
_selectedUserId; // Filtre par utilisateur (null = tous les événements)
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _searchDebounce;
|
||||
List<EventModel> _searchResults = [];
|
||||
String _searchQuery = '';
|
||||
String? _searchError;
|
||||
bool _isSearching = false;
|
||||
int _searchRequestId = 0;
|
||||
bool _isMobileSearchVisible = false;
|
||||
bool _isRefreshing = false;
|
||||
double _detailsPaneFraction = 0.35;
|
||||
final ValueNotifier<double> _detailsPaneFraction = ValueNotifier<double>(0.35);
|
||||
String? _lastLoadedUserId;
|
||||
bool _initialLoadScheduled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -105,19 +118,22 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
|
||||
/// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place
|
||||
Future<void> _loadEventsAsync() async {
|
||||
PerformanceMonitor.start('CalendarPage.loadEventsAsync');
|
||||
await _loadEvents();
|
||||
|
||||
// Sélectionner l'événement approprié après le chargement
|
||||
if (mounted) {
|
||||
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
|
||||
_selectDefaultEvent();
|
||||
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
|
||||
void _scheduleInitialEventsLoad(String? userId) {
|
||||
if (userId == null || userId == _lastLoadedUserId || _initialLoadScheduled) {
|
||||
return;
|
||||
}
|
||||
PerformanceMonitor.end('CalendarPage.loadEventsAsync');
|
||||
|
||||
_initialLoadScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
try {
|
||||
if (!mounted) return;
|
||||
if (_lastLoadedUserId == userId) return;
|
||||
await _loadCurrentMonthEvents();
|
||||
_lastLoadedUserId = userId;
|
||||
} finally {
|
||||
_initialLoadScheduled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Sélectionne automatiquement l'événement le plus proche de maintenant
|
||||
@@ -188,9 +204,16 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif)
|
||||
/// TEMPORAIREMENT DÉSACTIVÉ - À réactiver quand permission ajoutée dans Firestore
|
||||
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
_detailsPaneFraction.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif).
|
||||
List<EventModel> _filterEventsByUser(List<EventModel> allEvents) {
|
||||
if (_selectedUserId == null) {
|
||||
return allEvents; // Pas de filtre, retourner tous les événements
|
||||
}
|
||||
@@ -208,6 +231,536 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
bool _isSameDay(DateTime left, DateTime right) {
|
||||
return left.year == right.year &&
|
||||
left.month == right.month &&
|
||||
left.day == right.day;
|
||||
}
|
||||
|
||||
List<EventModel> _getEventsForDay(
|
||||
List<EventModel> events,
|
||||
DateTime? day, {
|
||||
EventModel? selectedEvent,
|
||||
}) {
|
||||
if (day == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final dayEvents = events
|
||||
.where((event) => _isSameDay(event.startDateTime, day))
|
||||
.toList()
|
||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
|
||||
if (selectedEvent != null &&
|
||||
_isSameDay(selectedEvent.startDateTime, day) &&
|
||||
!dayEvents.any((event) => event.id == selectedEvent.id)) {
|
||||
dayEvents.add(selectedEvent);
|
||||
dayEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
}
|
||||
|
||||
return dayEvents;
|
||||
}
|
||||
|
||||
List<EventModel> _getDetailsEvents(List<EventModel> events) {
|
||||
final mergedEvents = [...events];
|
||||
|
||||
if (_selectedEvent != null &&
|
||||
!mergedEvents.any((event) => event.id == _selectedEvent!.id)) {
|
||||
mergedEvents.add(_selectedEvent!);
|
||||
}
|
||||
|
||||
mergedEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
return mergedEvents;
|
||||
}
|
||||
|
||||
String _formatSearchResultDate(DateTime dateTime) {
|
||||
return DateFormat('EEE d MMM yyyy • HH:mm', 'fr_FR').format(dateTime);
|
||||
}
|
||||
|
||||
Color _getStatusColor(EventStatus status) {
|
||||
switch (status) {
|
||||
case EventStatus.confirmed:
|
||||
return Colors.green;
|
||||
case EventStatus.canceled:
|
||||
return Colors.red;
|
||||
case EventStatus.waitingForApproval:
|
||||
default:
|
||||
return Colors.amber;
|
||||
}
|
||||
}
|
||||
|
||||
/// Combine uniquement le filtre utilisateur avec la vue calendrier.
|
||||
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
|
||||
return _filterEventsByUser(allEvents);
|
||||
}
|
||||
|
||||
void _cancelPendingSearch() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = null;
|
||||
}
|
||||
|
||||
void _scheduleSearch(String value) {
|
||||
_cancelPendingSearch();
|
||||
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||
_runSearch(value);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
|
||||
if (isMobile && value.isNotEmpty && !_isMobileSearchVisible) {
|
||||
setState(() {
|
||||
_isMobileSearchVisible = true;
|
||||
});
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
|
||||
if (value.trim().isEmpty) {
|
||||
_cancelPendingSearch();
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_searchError = null;
|
||||
_isSearching = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_scheduleSearch(value);
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
_cancelPendingSearch();
|
||||
|
||||
if (_searchController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
_searchResults = [];
|
||||
_searchError = null;
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runSearch(String value) async {
|
||||
final query = value.trim();
|
||||
if (query.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final localUserProvider = context.read<LocalUserProvider>();
|
||||
final userId = localUserProvider.uid;
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final searchId = ++_searchRequestId;
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
_searchError = null;
|
||||
_searchResults = [];
|
||||
});
|
||||
|
||||
try {
|
||||
final eventProvider = context.read<EventProvider>();
|
||||
final results = await eventProvider.searchEvents(
|
||||
userId: userId,
|
||||
query: query,
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_searchQuery.trim() != query) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchId != _searchRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_searchError = null;
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted || _searchQuery.trim() != query) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_searchError = 'Erreur lors de la recherche : $e';
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDesktopFiltersBar({required bool canViewAllUserEvents}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
color: Colors.grey[100],
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher (titre, description, lieu)',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
tooltip: 'Effacer la recherche',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _clearSearch,
|
||||
)
|
||||
: null,
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (canViewAllUserEvents) ...[
|
||||
const SizedBox(width: 12),
|
||||
_buildCompactUserFilter(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactUserFilter() {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: UserFilterDropdown(
|
||||
selectedUserId: _selectedUserId,
|
||||
onUserSelected: (userId) {
|
||||
setState(() {
|
||||
_selectedUserId = userId;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileSearchBar() {
|
||||
return Container(
|
||||
color: Colors.grey[100],
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isMobileSearchVisible ? Icons.search_off : Icons.search,
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
tooltip: _isMobileSearchVisible
|
||||
? 'Masquer la recherche'
|
||||
: 'Afficher la recherche',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isMobileSearchVisible = !_isMobileSearchVisible;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'Rechercher un événement'
|
||||
: 'Recherche active',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_searchQuery.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Effacer la recherche',
|
||||
onPressed: _clearSearch,
|
||||
),
|
||||
],
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: _isMobileSearchVisible
|
||||
? Padding(
|
||||
key: const ValueKey('mobile-search-visible'),
|
||||
padding: const EdgeInsets.only(top: 4, left: 8, right: 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Titre, description ou lieu',
|
||||
prefixIcon:
|
||||
const Icon(Icons.search, color: AppColors.rouge),
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('mobile-search-hidden'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResultsPanel({required bool isMobile}) {
|
||||
final hasQuery = _searchQuery.trim().isNotEmpty;
|
||||
|
||||
if (!hasQuery && !_isSearching && _searchError == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final panelPadding = EdgeInsets.symmetric(
|
||||
horizontal: isMobile ? 8 : 16,
|
||||
vertical: 8,
|
||||
);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: panelPadding,
|
||||
color: Colors.grey[50],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.manage_search, color: AppColors.rouge, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasQuery
|
||||
? 'Résultats pour "$_searchQuery"'
|
||||
: 'Recherche d’événements',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isSearching)
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_searchError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_searchError!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
] else if (!hasQuery) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Saisissez un titre, une description ou un lieu pour lancer la recherche.',
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
),
|
||||
] else if (!_isSearching) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (_searchResults.isEmpty)
|
||||
Text(
|
||||
'Aucun résultat trouvé.',
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: isMobile ? 240 : 280,
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _searchResults.length,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
// ✅ prototypeItem : les résultats ont une hauteur variable
|
||||
// selon la présence du champ adresse (~56px sans, ~70px avec).
|
||||
// prototypeItem à 72px (cas avec adresse + padding) pour
|
||||
// que Flutter estime correctement la hauteur scrollable.
|
||||
// ListView.separated ne supporte pas itemExtent/prototypeItem,
|
||||
// d'où la conversion en ListView.builder avec séparateur intégré.
|
||||
prototypeItem: const SizedBox(height: 72),
|
||||
itemBuilder: (context, index) {
|
||||
final event = _searchResults[index];
|
||||
final isSelected = _selectedEvent?.id == event.id;
|
||||
final isLast = index == _searchResults.length - 1;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Material(
|
||||
color: isSelected
|
||||
? AppColors.rouge.withOpacity(0.08)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _onSearchResultSelected(event),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(event.status),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
event.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatSearchResultDate(
|
||||
event.startDateTime),
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (event.address.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
event.address,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isLast) const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSearchResultSelected(EventModel event) async {
|
||||
final localUserProvider = context.read<LocalUserProvider>();
|
||||
final eventProvider = context.read<EventProvider>();
|
||||
final userId = localUserProvider.uid;
|
||||
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
||||
final selectedDay = DateTime(
|
||||
event.startDateTime.year,
|
||||
event.startDateTime.month,
|
||||
event.startDateTime.day,
|
||||
);
|
||||
final shouldLoadMonth = _focusedDay.year != event.startDateTime.year ||
|
||||
_focusedDay.month != event.startDateTime.month ||
|
||||
eventProvider.events.isEmpty;
|
||||
|
||||
if (shouldLoadMonth) {
|
||||
await eventProvider.loadMonthEvents(
|
||||
userId,
|
||||
event.startDateTime.year,
|
||||
event.startDateTime.month,
|
||||
canViewAllEvents: canViewAllEvents,
|
||||
);
|
||||
|
||||
eventProvider.preloadAdjacentMonths(
|
||||
userId,
|
||||
event.startDateTime.year,
|
||||
event.startDateTime.month,
|
||||
canViewAllEvents: canViewAllEvents,
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final eventsForSelectedDay = _getEventsForDay(
|
||||
eventProvider.events,
|
||||
selectedDay,
|
||||
selectedEvent: event,
|
||||
);
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
|
||||
setState(() {
|
||||
_focusedDay = selectedDay;
|
||||
_selectedDay = selectedDay;
|
||||
_selectedEvent = event;
|
||||
_selectedEventIndex =
|
||||
eventsForSelectedDay.indexWhere((e) => e.id == event.id);
|
||||
if (_selectedEventIndex < 0) {
|
||||
_selectedEventIndex = 0;
|
||||
}
|
||||
_calendarCollapsed = false;
|
||||
if (isMobile) {
|
||||
_isMobileSearchVisible = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _changeWeek(int delta) {
|
||||
setState(() {
|
||||
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
|
||||
@@ -238,10 +791,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
|
||||
Widget _buildDesktopDetailsPane(List<EventModel> filteredEvents) {
|
||||
if (_selectedEvent != null) {
|
||||
final detailsEvents = _getDetailsEvents(filteredEvents);
|
||||
|
||||
return EventDetails(
|
||||
event: _selectedEvent!,
|
||||
selectedDate: _selectedDay,
|
||||
events: filteredEvents,
|
||||
events: detailsEvents,
|
||||
onSelectEvent: (event, date) {
|
||||
setState(() {
|
||||
_selectedEvent = event;
|
||||
@@ -264,12 +819,10 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragUpdate: (details) {
|
||||
setState(() {
|
||||
_detailsPaneFraction = _clampDetailsPaneFraction(
|
||||
_detailsPaneFraction - (details.delta.dx / totalWidth),
|
||||
_detailsPaneFraction.value = _clampDetailsPaneFraction(
|
||||
_detailsPaneFraction.value - (details.delta.dx / totalWidth),
|
||||
totalWidth,
|
||||
);
|
||||
});
|
||||
},
|
||||
child: SizedBox(
|
||||
width: _desktopResizeHandleWidth,
|
||||
@@ -292,10 +845,13 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final eventProvider = Provider.of<EventProvider>(context);
|
||||
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
||||
_scheduleInitialEventsLoad(localUserProvider.uid);
|
||||
final canCreateEvents = localUserProvider.hasPermission('create_events');
|
||||
final canViewAllUserEvents =
|
||||
localUserProvider.hasPermission('view_all_user_events');
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
final showSearchResults =
|
||||
_searchQuery.trim().isNotEmpty || _isSearching || _searchError != null;
|
||||
|
||||
// Appliquer le filtre utilisateur si actif
|
||||
final filteredEvents = _getFilteredEvents(eventProvider.events);
|
||||
@@ -309,11 +865,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
}
|
||||
|
||||
if (eventProvider.isLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
return const StartupSplashScreen(message: 'Chargement des événements...');
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
@@ -343,33 +895,11 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
drawer: const MainDrawer(currentPage: '/calendar'),
|
||||
body: Column(
|
||||
children: [
|
||||
// Filtre utilisateur dans le corps de la page
|
||||
if (canViewAllUserEvents && !isMobile)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.grey[100],
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.filter_list, color: AppColors.rouge),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Filtrer par utilisateur :',
|
||||
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: UserFilterDropdown(
|
||||
selectedUserId: _selectedUserId,
|
||||
onUserSelected: (userId) {
|
||||
setState(() {
|
||||
_selectedUserId = userId;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isMobile)
|
||||
_buildMobileSearchBar()
|
||||
else
|
||||
_buildDesktopFiltersBar(canViewAllUserEvents: canViewAllUserEvents),
|
||||
if (showSearchResults) _buildSearchResultsPanel(isMobile: isMobile),
|
||||
// Corps du calendrier
|
||||
Expanded(
|
||||
child: isMobile
|
||||
@@ -401,8 +931,11 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final totalWidth = constraints.maxWidth;
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: _detailsPaneFraction,
|
||||
builder: (context, fraction, child) {
|
||||
final detailsPaneFraction =
|
||||
_clampDetailsPaneFraction(_detailsPaneFraction, totalWidth);
|
||||
_clampDetailsPaneFraction(fraction, totalWidth);
|
||||
final detailsWidth = totalWidth * detailsPaneFraction;
|
||||
final calendarWidth =
|
||||
totalWidth - _desktopResizeHandleWidth - detailsWidth;
|
||||
@@ -423,24 +956,31 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
|
||||
final eventsForSelectedDay = _selectedDay == null
|
||||
? []
|
||||
: filteredEvents
|
||||
.where((e) =>
|
||||
e.startDateTime.year == _selectedDay!.year &&
|
||||
e.startDateTime.month == _selectedDay!.month &&
|
||||
e.startDateTime.day == _selectedDay!.day)
|
||||
.toList()
|
||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
final eventsForSelectedDay = _getEventsForDay(
|
||||
filteredEvents,
|
||||
_selectedDay,
|
||||
selectedEvent: _selectedEvent,
|
||||
);
|
||||
final hasEvents = eventsForSelectedDay.isNotEmpty;
|
||||
final currentEvent =
|
||||
hasEvents && _selectedEventIndex < eventsForSelectedDay.length
|
||||
final selectedEventIndex = _selectedEvent == null
|
||||
? -1
|
||||
: eventsForSelectedDay
|
||||
.indexWhere((event) => event.id == _selectedEvent!.id);
|
||||
final currentEvent = hasEvents && selectedEventIndex >= 0
|
||||
? eventsForSelectedDay[selectedEventIndex]
|
||||
: hasEvents && _selectedEventIndex < eventsForSelectedDay.length
|
||||
? eventsForSelectedDay[_selectedEventIndex]
|
||||
: null;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = constraints.maxHeight;
|
||||
|
||||
// GESTURE DETECTOR pour swipe vertical (plier/déplier) et horizontal (mois)
|
||||
return GestureDetector(
|
||||
onVerticalDragEnd: (details) {
|
||||
@@ -489,12 +1029,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeInOut,
|
||||
top: _calendarCollapsed ? -600 : 0, // cache le calendrier en haut
|
||||
top: _calendarCollapsed ? -maxHeight : 0, // cache le calendrier en haut
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: _calendarCollapsed ? 0 : null,
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height,
|
||||
height: maxHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMonthHeader(context),
|
||||
@@ -581,7 +1121,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
child: EventDetails(
|
||||
event: eventsForSelectedDay[_selectedEventIndex],
|
||||
selectedDate: _selectedDay,
|
||||
events: eventsForSelectedDay.cast<EventModel>(),
|
||||
events: eventsForSelectedDay,
|
||||
onSelectEvent: (event, date) {
|
||||
final idx = eventsForSelectedDay
|
||||
.indexWhere((e) => e.id == event.id);
|
||||
@@ -600,17 +1140,17 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Vue détail (prend tout l'espace quand calendrier caché)
|
||||
// Vue détail (prend tout l'espace quand calendrier cache)
|
||||
if (_calendarCollapsed && _selectedDay != null)
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeInOut,
|
||||
top: _calendarCollapsed ? 0 : 600,
|
||||
top: _calendarCollapsed ? 0 : maxHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height,
|
||||
height: maxHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMonthHeader(context),
|
||||
@@ -647,7 +1187,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
child: EventDetails(
|
||||
event: currentEvent,
|
||||
selectedDate: _selectedDay,
|
||||
events: eventsForSelectedDay.cast<EventModel>(),
|
||||
events: eventsForSelectedDay,
|
||||
onSelectEvent: (event, date) {
|
||||
final idx = eventsForSelectedDay
|
||||
.indexWhere((e) => e.id == event.id);
|
||||
@@ -659,7 +1199,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
),
|
||||
),
|
||||
if (!hasEvents)
|
||||
Center(
|
||||
const Center(
|
||||
child: Text(
|
||||
'Aucun événement ne démarre à cette date'),
|
||||
),
|
||||
@@ -673,6 +1213,8 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthHeader(BuildContext context) {
|
||||
|
||||
@@ -7,6 +7,10 @@ import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:em2rp/utils/id_generator.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/utils/debouncer.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
|
||||
class ContainerFormPage extends StatefulWidget {
|
||||
final ContainerModel? container;
|
||||
@@ -32,7 +36,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
// Form fields
|
||||
ContainerType _selectedType = ContainerType.flightCase;
|
||||
EquipmentStatus _selectedStatus = EquipmentStatus.available;
|
||||
bool _autoGenerateId = true;
|
||||
final ValueNotifier<bool> _autoGenerateIdNotifier = ValueNotifier<bool>(true);
|
||||
final Set<String> _selectedEquipmentIds = {};
|
||||
|
||||
bool _isEditing = false;
|
||||
@@ -58,11 +62,11 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
_heightController.text = container.height?.toString() ?? '';
|
||||
_notesController.text = container.notes ?? '';
|
||||
_selectedEquipmentIds.addAll(container.equipmentIds);
|
||||
_autoGenerateId = false;
|
||||
_autoGenerateIdNotifier.value = false;
|
||||
}
|
||||
|
||||
void _updateIdFromName() {
|
||||
if (_autoGenerateId && !_isEditing) {
|
||||
if (_autoGenerateIdNotifier.value && !_isEditing) {
|
||||
final name = _nameController.text;
|
||||
if (name.isNotEmpty) {
|
||||
final baseId = IdGenerator.generateContainerId(
|
||||
@@ -75,7 +79,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
}
|
||||
|
||||
void _updateIdFromType() {
|
||||
if (_autoGenerateId && !_isEditing) {
|
||||
if (_autoGenerateIdNotifier.value && !_isEditing) {
|
||||
final name = _nameController.text;
|
||||
if (name.isNotEmpty) {
|
||||
final baseId = IdGenerator.generateContainerId(
|
||||
@@ -87,18 +91,65 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCard({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required List<Widget> children,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: AppColors.rouge, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.noir,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24, thickness: 1),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? 'Modifier boite' : 'Nouvelle boite'),
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
appBar: CustomAppBar(
|
||||
title: _isEditing ? 'Modifier boîte' : 'Nouvelle boîte',
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 800),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Card 1: Informations Générales
|
||||
_buildCard(
|
||||
title: 'Informations générales',
|
||||
icon: Icons.info_outline,
|
||||
children: [
|
||||
// Nom
|
||||
TextFormField(
|
||||
@@ -120,7 +171,10 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ID
|
||||
Row(
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _autoGenerateIdNotifier,
|
||||
builder: (context, autoGenerateId, child) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -132,7 +186,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.qr_code),
|
||||
),
|
||||
enabled: !_autoGenerateId || _isEditing,
|
||||
enabled: !autoGenerateId || _isEditing,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer un identifiant';
|
||||
@@ -146,23 +200,23 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_autoGenerateId ? Icons.lock : Icons.lock_open,
|
||||
color: _autoGenerateId ? AppColors.rouge : Colors.grey,
|
||||
autoGenerateId ? Icons.lock : Icons.lock_open,
|
||||
color: autoGenerateId ? AppColors.rouge : Colors.grey,
|
||||
),
|
||||
tooltip: _autoGenerateId
|
||||
tooltip: autoGenerateId
|
||||
? 'Génération automatique'
|
||||
: 'Saisie manuelle',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_autoGenerateId = !_autoGenerateId;
|
||||
if (_autoGenerateId) {
|
||||
_autoGenerateIdNotifier.value = !autoGenerateId;
|
||||
if (_autoGenerateIdNotifier.value) {
|
||||
_updateIdFromName();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -177,7 +231,13 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
items: ContainerType.values.map((type) {
|
||||
return DropdownMenuItem(
|
||||
value: type,
|
||||
child: Text(type.label),
|
||||
child: Row(
|
||||
children: [
|
||||
type.getIcon(size: 20, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(type.label),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
@@ -197,7 +257,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Statut *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.info),
|
||||
prefixIcon: Icon(Icons.info_outline),
|
||||
),
|
||||
items: [
|
||||
EquipmentStatus.available,
|
||||
@@ -235,18 +295,15 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Caractéristiques physiques
|
||||
Text(
|
||||
'Caractéristiques physiques',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Card 2: Caractéristiques Physiques
|
||||
_buildCard(
|
||||
title: 'Caractéristiques physiques',
|
||||
icon: Icons.scale_outlined,
|
||||
children: [
|
||||
// Poids
|
||||
TextFormField(
|
||||
controller: _weightController,
|
||||
@@ -256,8 +313,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.scale),
|
||||
),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (double.tryParse(value) == null) {
|
||||
@@ -279,8 +335,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
labelText: 'Longueur (cm)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (double.tryParse(value) == null) {
|
||||
@@ -299,8 +354,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
labelText: 'Largeur (cm)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (double.tryParse(value) == null) {
|
||||
@@ -319,8 +373,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
labelText: 'Hauteur (cm)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (double.tryParse(value) == null) {
|
||||
@@ -333,32 +386,33 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Équipements
|
||||
Text(
|
||||
'Équipements dans ce container',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Liste des équipements sélectionnés
|
||||
// Card 3: Équipements dans ce container
|
||||
_buildCard(
|
||||
title: 'Équipements dans ce container',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
children: [
|
||||
if (_selectedEquipmentIds.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade50,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${_selectedEquipmentIds.length} équipement(s) sélectionné(s)',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
'${_selectedEquipmentIds.length} équipement(s) sélectionné(s) :',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
@@ -366,13 +420,19 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
runSpacing: 8,
|
||||
children: _selectedEquipmentIds.map((id) {
|
||||
return Chip(
|
||||
label: Text(id),
|
||||
deleteIcon: const Icon(Icons.close, size: 18),
|
||||
label: Text(
|
||||
id,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
deleteIcon: const Icon(Icons.close, size: 16),
|
||||
onDeleted: () {
|
||||
setState(() {
|
||||
_selectedEquipmentIds.remove(id);
|
||||
});
|
||||
},
|
||||
backgroundColor: AppColors.rouge.withValues(alpha: 0.08),
|
||||
side: BorderSide(color: AppColors.rouge.withValues(alpha: 0.2)),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@@ -381,9 +441,9 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade50,
|
||||
),
|
||||
@@ -394,7 +454,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bouton pour ajouter des équipements
|
||||
OutlinedButton.icon(
|
||||
@@ -403,52 +463,66 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
label: const Text('Ajouter des équipements'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
side: BorderSide(color: AppColors.rouge),
|
||||
foregroundColor: AppColors.rouge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Notes
|
||||
// Card 4: Notes
|
||||
_buildCard(
|
||||
title: 'Notes & Remarques',
|
||||
icon: Icons.notes_outlined,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes',
|
||||
labelText: 'Notes complémentaires',
|
||||
hintText: 'Informations additionnelles...',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.notes),
|
||||
prefixIcon: Icon(Icons.edit_note),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Boutons
|
||||
// Actions
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
child: const Text('Annuler', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _saveContainer,
|
||||
icon: const Icon(Icons.save, color: Colors.white),
|
||||
label: Text(
|
||||
_isEditing ? 'Mettre à jour' : 'Créer',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
_isEditing ? 'Enregistrer' : 'Créer',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -629,6 +703,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
_widthController.dispose();
|
||||
_heightController.dispose();
|
||||
_notesController.dispose();
|
||||
_autoGenerateIdNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -650,25 +725,88 @@ class _EquipmentSelectorDialog extends StatefulWidget {
|
||||
|
||||
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
|
||||
EquipmentCategory? _filterCategory;
|
||||
String _searchQuery = '';
|
||||
late Set<String> _tempSelectedIds;
|
||||
late final Future<void> _loadingFuture;
|
||||
final _searchDebouncer = Debouncer();
|
||||
|
||||
final List<EquipmentModel> _paginatedEquipments = [];
|
||||
bool _isLoadingMore = false;
|
||||
bool _hasMoreEquipments = true;
|
||||
String? _lastEquipmentId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Créer une copie temporaire des IDs sélectionnés
|
||||
_tempSelectedIds = Set<String>.from(widget.selectedIds);
|
||||
_loadingFuture = widget.equipmentProvider.loadEquipments();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadNextPage();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
_searchDebouncer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_isLoadingMore) return;
|
||||
if (_scrollController.hasClients &&
|
||||
_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) {
|
||||
if (_hasMoreEquipments) {
|
||||
_loadNextPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNextPage() async {
|
||||
if (_isLoadingMore || !_hasMoreEquipments) return;
|
||||
setState(() => _isLoadingMore = true);
|
||||
|
||||
try {
|
||||
final result = await _dataService.getEquipmentsPaginated(
|
||||
limit: 50,
|
||||
startAfter: _lastEquipmentId,
|
||||
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||
category: _filterCategory != null ? equipmentCategoryToString(_filterCategory!) : null,
|
||||
sortBy: 'id',
|
||||
sortOrder: 'asc',
|
||||
);
|
||||
|
||||
final newEquipments = (result['equipments'] as List<dynamic>)
|
||||
.map((data) => EquipmentModel.fromMap(data as Map<String, dynamic>, data['id'] as String))
|
||||
.toList();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_paginatedEquipments.addAll(newEquipments);
|
||||
_hasMoreEquipments = result['hasMore'] as bool? ?? false;
|
||||
_lastEquipmentId = result['lastVisible'] as String?;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingMore = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reloadData() async {
|
||||
setState(() {
|
||||
_paginatedEquipments.clear();
|
||||
_lastEquipmentId = null;
|
||||
_hasMoreEquipments = true;
|
||||
});
|
||||
await _loadNextPage();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
@@ -718,6 +856,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
_reloadData();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
@@ -726,6 +865,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
_searchDebouncer(_reloadData);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -743,6 +883,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
setState(() {
|
||||
_filterCategory = null;
|
||||
});
|
||||
_reloadData();
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
@@ -761,6 +902,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
setState(() {
|
||||
_filterCategory = selected ? category : null;
|
||||
});
|
||||
_reloadData();
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
@@ -780,7 +922,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rouge.withOpacity(0.1),
|
||||
color: AppColors.rouge.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -798,48 +940,22 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
|
||||
// Liste des équipements
|
||||
Expanded(
|
||||
child: FutureBuilder<void>(
|
||||
future: _loadingFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Erreur: ${snapshot.error}'));
|
||||
}
|
||||
|
||||
var equipment = List<EquipmentModel>.from(
|
||||
widget.equipmentProvider.allEquipment,
|
||||
);
|
||||
|
||||
// Filtrer par catégorie
|
||||
if (_filterCategory != null) {
|
||||
equipment = equipment
|
||||
.where((e) => e.category == _filterCategory)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer par recherche
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final query = _searchQuery.toLowerCase();
|
||||
equipment = equipment.where((e) {
|
||||
return e.id.toLowerCase().contains(query) ||
|
||||
(e.brand?.toLowerCase().contains(query) ?? false) ||
|
||||
(e.model?.toLowerCase().contains(query) ?? false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (equipment.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucun équipement trouvé'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: equipment.length,
|
||||
child: _paginatedEquipments.isEmpty && !_isLoadingMore
|
||||
? const Center(child: Text('Aucun équipement trouvé'))
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: _paginatedEquipments.length + (_isLoadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
final item = equipment[index];
|
||||
if (index == _paginatedEquipments.length) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final item = _paginatedEquipments[index];
|
||||
final isSelected = _tempSelectedIds.contains(item.id);
|
||||
|
||||
return CheckboxListTile(
|
||||
@@ -879,8 +995,6 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
activeColor: AppColors.rouge,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'package:em2rp/views/widgets/data_management/event_types_management.dart'
|
||||
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/depot_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/vehicles_management.dart';
|
||||
import 'package:em2rp/views/widgets/data_management/fuel_prices_management.dart';
|
||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/utils/permission_gate.dart';
|
||||
@@ -50,6 +53,21 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
||||
child: EventStatisticsTab(),
|
||||
),
|
||||
),
|
||||
DataCategory(
|
||||
title: 'Dépôts',
|
||||
icon: Icons.warehouse_outlined,
|
||||
widget: const DepotManagement(),
|
||||
),
|
||||
DataCategory(
|
||||
title: 'Véhicules',
|
||||
icon: Icons.directions_car_outlined,
|
||||
widget: const VehiclesManagement(),
|
||||
),
|
||||
DataCategory(
|
||||
title: 'Prix carburants',
|
||||
icon: Icons.local_gas_station,
|
||||
widget: const FuelPricesManagement(),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/services/equipment_service.dart';
|
||||
import 'package:em2rp/services/qr_code_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/utils/equipment_delete_utils.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/views/equipment_form_page.dart';
|
||||
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
|
||||
@@ -45,7 +46,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
|
||||
Future<void> _loadMaintenances() async {
|
||||
try {
|
||||
final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id);
|
||||
final maintenances = await _equipmentService
|
||||
.getMaintenancesForEquipment(widget.equipment.id);
|
||||
setState(() {
|
||||
_maintenances = maintenances;
|
||||
_isLoadingMaintenances = false;
|
||||
@@ -57,8 +59,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
@@ -103,7 +103,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 3. Notes
|
||||
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[
|
||||
if (widget.equipment.notes != null &&
|
||||
widget.equipment.notes!.isNotEmpty) ...[
|
||||
EquipmentNotesSection(notes: widget.equipment.notes!),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
@@ -185,7 +186,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _showQRCode() {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -249,10 +249,12 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
|
||||
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'
|
||||
.trim(),
|
||||
style: TextStyle(color: Colors.grey[700]),
|
||||
),
|
||||
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[
|
||||
if (widget.equipment.subCategory != null &&
|
||||
widget.equipment.subCategory!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'📁 ${widget.equipment.subCategory}',
|
||||
@@ -389,7 +391,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
if (!hasPermission) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vous n\'avez pas la permission de gérer les maintenances'),
|
||||
content:
|
||||
Text('Vous n\'avez pas la permission de gérer les maintenances'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
@@ -423,31 +426,50 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
}
|
||||
|
||||
void _deleteEquipment() {
|
||||
final pageContext = context;
|
||||
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
|
||||
id: widget.equipment.id,
|
||||
name: widget.equipment.name,
|
||||
);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
context: pageContext,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
||||
content: Text(
|
||||
'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.',
|
||||
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
|
||||
equipmentLabel,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
// Fermer le dialog
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(dialogContext);
|
||||
|
||||
// Capturer le ScaffoldMessenger avant la suppression
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
||||
final navigator = Navigator.of(pageContext);
|
||||
final provider = pageContext.read<EquipmentProvider>();
|
||||
|
||||
try {
|
||||
await context
|
||||
.read<EquipmentProvider>()
|
||||
.deleteEquipment(widget.equipment.id);
|
||||
final deleted =
|
||||
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
|
||||
context: pageContext,
|
||||
equipmentLabel: equipmentLabel,
|
||||
deleteEquipment: ({bool forceDelete = false}) {
|
||||
return provider.deleteEquipment(
|
||||
widget.equipment.id,
|
||||
forceDelete: forceDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Revenir à la page précédente
|
||||
navigator.pop();
|
||||
@@ -455,19 +477,23 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
// Afficher le snackbar (même si le widget est démonté)
|
||||
scaffoldMessenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Équipement supprimé avec succès'),
|
||||
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Afficher l'erreur
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
|
||||
import 'package:em2rp/views/equipment_form/subcategory_selector.dart';
|
||||
import 'package:em2rp/utils/id_generator.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:em2rp/utils/debouncer.dart';
|
||||
|
||||
class EquipmentFormPage extends StatefulWidget {
|
||||
final EquipmentModel? equipment;
|
||||
@@ -38,28 +39,69 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
final TextEditingController _notesController = TextEditingController();
|
||||
final TextEditingController _quantityToAddController = TextEditingController(text: '1');
|
||||
|
||||
// Physical characteristics controllers
|
||||
final TextEditingController _weightController = TextEditingController();
|
||||
final TextEditingController _lengthController = TextEditingController();
|
||||
final TextEditingController _widthController = TextEditingController();
|
||||
final TextEditingController _heightController = TextEditingController();
|
||||
|
||||
// State variables
|
||||
EquipmentCategory _selectedCategory = EquipmentCategory.other;
|
||||
EquipmentCategory? _selectedCategory; // Nullable by default to force selection
|
||||
EquipmentStatus _selectedStatus = EquipmentStatus.available;
|
||||
DateTime? _purchaseDate;
|
||||
DateTime? _lastMaintenanceDate;
|
||||
DateTime? _nextMaintenanceDate;
|
||||
bool _isLoading = false;
|
||||
bool _addMultiple = false;
|
||||
String? _selectedBrand;
|
||||
List<String> _filteredModels = [];
|
||||
List<String> _filteredSubCategories = [];
|
||||
|
||||
// ID auto-generation and check
|
||||
final ValueNotifier<bool> _autoGenerateIdNotifier = ValueNotifier<bool>(true);
|
||||
final _idCheckDebouncer = Debouncer(delay: const Duration(milliseconds: 500));
|
||||
String? _idConflictMessage;
|
||||
List<String> _candidateIds = [];
|
||||
bool _isCalculatingIds = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_candidateIds = [];
|
||||
_isCalculatingIds = false;
|
||||
super.initState();
|
||||
|
||||
// Set default dates to today for new equipment
|
||||
if (widget.equipment == null) {
|
||||
_purchaseDate = DateTime.now();
|
||||
_lastMaintenanceDate = DateTime.now();
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
||||
provider.loadBrands();
|
||||
provider.loadModels();
|
||||
if (widget.equipment != null) {
|
||||
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
|
||||
_loadFilteredModels(_selectedBrand!);
|
||||
}
|
||||
if (_selectedCategory != null) {
|
||||
_loadFilteredSubCategories(_selectedCategory!);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (widget.equipment != null) {
|
||||
_populateFields();
|
||||
} else {
|
||||
// Set up listeners for auto-generation of ID
|
||||
_brandController.addListener(_triggerCandidateIdsUpdate);
|
||||
_modelController.addListener(_triggerCandidateIdsUpdate);
|
||||
_quantityToAddController.addListener(_triggerCandidateIdsUpdate);
|
||||
_identifierController.addListener(_onIdentifierManualChanged);
|
||||
|
||||
// Run initial check once the page is fully mounted/built
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_triggerCandidateIdsUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,19 +123,172 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
_lastMaintenanceDate = equipment.lastMaintenanceDate;
|
||||
_nextMaintenanceDate = equipment.nextMaintenanceDate;
|
||||
_notesController.text = equipment.notes ?? '';
|
||||
|
||||
_weightController.text = equipment.weight?.toString() ?? '';
|
||||
_lengthController.text = equipment.length?.toString() ?? '';
|
||||
_widthController.text = equipment.width?.toString() ?? '';
|
||||
_heightController.text = equipment.height?.toString() ?? '';
|
||||
});
|
||||
|
||||
// Disable auto-generation for editing
|
||||
_autoGenerateIdNotifier.value = false;
|
||||
|
||||
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
|
||||
|
||||
|
||||
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
|
||||
_loadFilteredModels(_selectedBrand!);
|
||||
}
|
||||
|
||||
// Charger les sous-catégories pour la catégorie sélectionnée
|
||||
_loadFilteredSubCategories(_selectedCategory);
|
||||
void _onIdentifierManualChanged() {
|
||||
if (!_autoGenerateIdNotifier.value && widget.equipment == null) {
|
||||
_triggerCandidateIdsUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerCandidateIdsUpdate() {
|
||||
_idCheckDebouncer(() async {
|
||||
if (!mounted || widget.equipment != null) return;
|
||||
|
||||
setState(() {
|
||||
_isCalculatingIds = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final ids = await _calculateCandidateIds();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_candidateIds = ids;
|
||||
|
||||
// If auto-generating, update the text field with the first generated ID
|
||||
if (_autoGenerateIdNotifier.value && ids.isNotEmpty) {
|
||||
_identifierController.removeListener(_onIdentifierManualChanged);
|
||||
_identifierController.text = ids.first;
|
||||
_identifierController.addListener(_onIdentifierManualChanged);
|
||||
}
|
||||
|
||||
// Determine if there was an ID replacement/conflict
|
||||
_idConflictMessage = null;
|
||||
if (ids.isNotEmpty) {
|
||||
final brand = _brandController.text.trim();
|
||||
final model = _modelController.text.trim();
|
||||
final quantityText = _quantityToAddController.text.trim();
|
||||
final numbers = _parseQuantityOrRange(quantityText);
|
||||
|
||||
if (_autoGenerateIdNotifier.value) {
|
||||
if (numbers != null && numbers.isNotEmpty) {
|
||||
final firstExpectedNum = numbers.first;
|
||||
final baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null);
|
||||
final expectedFirstId = '${baseId}_#$firstExpectedNum';
|
||||
if (ids.first != expectedFirstId) {
|
||||
_idConflictMessage = "L'ID $expectedFirstId était déjà pris et a été remplacé par ${ids.first}";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final manualId = _identifierController.text.trim().toUpperCase();
|
||||
if (manualId.isNotEmpty && ids.first != manualId) {
|
||||
_idConflictMessage = "L'ID $manualId était déjà pris et a été remplacé par ${ids.first}";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
DebugLog.error("Error calculating candidate IDs: $e");
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isCalculatingIds = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<int>? _parseQuantityOrRange(String text) {
|
||||
text = text.trim();
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
// Try single number
|
||||
final singleNum = int.tryParse(text);
|
||||
if (singleNum != null) {
|
||||
if (singleNum < 1 || singleNum > 100) return null;
|
||||
return List<int>.generate(singleNum, (i) => i + 1);
|
||||
}
|
||||
|
||||
// Try range pattern like "3-6" or "3 - 6"
|
||||
final rangeRegex = RegExp(r'^(\d+)\s*-\s*(\d+)$');
|
||||
final match = rangeRegex.firstMatch(text);
|
||||
if (match != null) {
|
||||
final start = int.tryParse(match.group(1)!);
|
||||
final end = int.tryParse(match.group(2)!);
|
||||
if (start != null && end != null && start > 0 && end >= start) {
|
||||
if (end - start + 1 > 100) return null;
|
||||
return List<int>.generate(end - start + 1, (i) => start + i);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
IdParseResult _parseBaseAndNumber(String id) {
|
||||
final match = RegExp(r'^(.*)_#(\d+)$').firstMatch(id);
|
||||
if (match != null) {
|
||||
return IdParseResult(match.group(1)!, int.parse(match.group(2)!));
|
||||
}
|
||||
return IdParseResult(id, null);
|
||||
}
|
||||
|
||||
Future<List<String>> _calculateCandidateIds() async {
|
||||
final brand = _brandController.text.trim();
|
||||
final model = _modelController.text.trim();
|
||||
final quantityText = _quantityToAddController.text.trim();
|
||||
|
||||
if (_autoGenerateIdNotifier.value && brand.isEmpty && model.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get base ID
|
||||
String baseId;
|
||||
int? initialNumber;
|
||||
|
||||
if (_autoGenerateIdNotifier.value) {
|
||||
baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null);
|
||||
} else {
|
||||
final manualId = _identifierController.text.trim().toUpperCase();
|
||||
if (manualId.isEmpty) return [];
|
||||
|
||||
final parsed = _parseBaseAndNumber(manualId);
|
||||
baseId = parsed.baseId;
|
||||
initialNumber = parsed.number;
|
||||
}
|
||||
|
||||
// Parse numbers
|
||||
final numbers = _parseQuantityOrRange(quantityText);
|
||||
if (numbers == null || numbers.isEmpty) return [];
|
||||
|
||||
// If the quantityText is just a single number (e.g. "5") and we have a manual ID with an initial number (e.g. "CUSTOM_#3"),
|
||||
// we adjust the numbers to start from that initial number.
|
||||
List<int> targetNumbers = numbers;
|
||||
final isSingleNumberInput = int.tryParse(quantityText) != null;
|
||||
if (isSingleNumberInput && initialNumber != null) {
|
||||
targetNumbers = List<int>.generate(numbers.length, (i) => initialNumber! + i);
|
||||
}
|
||||
|
||||
List<String> resultIds = [];
|
||||
final Set<String> allocatedInBatch = {};
|
||||
|
||||
for (final num in targetNumbers) {
|
||||
int currentNum = num;
|
||||
String candidateId = '${baseId}_#$currentNum';
|
||||
|
||||
while (allocatedInBatch.contains(candidateId) || !(await _equipmentService.isIdUnique(candidateId))) {
|
||||
currentNum++;
|
||||
candidateId = '${baseId}_#$currentNum';
|
||||
}
|
||||
|
||||
resultIds.add(candidateId);
|
||||
allocatedInBatch.add(candidateId);
|
||||
}
|
||||
|
||||
return resultIds;
|
||||
}
|
||||
|
||||
Future<void> _loadFilteredModels(String brand) async {
|
||||
try {
|
||||
@@ -125,6 +320,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_brandController.removeListener(_triggerCandidateIdsUpdate);
|
||||
_modelController.removeListener(_triggerCandidateIdsUpdate);
|
||||
_quantityToAddController.removeListener(_triggerCandidateIdsUpdate);
|
||||
_identifierController.removeListener(_onIdentifierManualChanged);
|
||||
|
||||
_identifierController.dispose();
|
||||
_brandController.dispose();
|
||||
_modelController.dispose();
|
||||
@@ -135,10 +335,57 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
_criticalThresholdController.dispose();
|
||||
_notesController.dispose();
|
||||
_quantityToAddController.dispose();
|
||||
|
||||
_weightController.dispose();
|
||||
_lengthController.dispose();
|
||||
_widthController.dispose();
|
||||
_heightController.dispose();
|
||||
|
||||
_autoGenerateIdNotifier.dispose();
|
||||
_idCheckDebouncer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isConsumable => _selectedCategory == EquipmentCategory.consumable || _selectedCategory == EquipmentCategory.cable;
|
||||
bool get _isConsumable => _selectedCategory != null && (_selectedCategory == EquipmentCategory.consumable || _selectedCategory == EquipmentCategory.cable);
|
||||
|
||||
Widget _buildCard({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required List<Widget> children,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: AppColors.rouge, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.noir,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24, thickness: 1),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -152,95 +399,99 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 800),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Identifiant (généré ou saisi)
|
||||
TextFormField(
|
||||
// Card 1: Informations Générales
|
||||
_buildCard(
|
||||
title: 'Informations générales',
|
||||
icon: Icons.info_outline,
|
||||
children: [
|
||||
// ID row with padlock and warning
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _autoGenerateIdNotifier,
|
||||
builder: (context, autoGenerateId, child) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _identifierController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Identifiant *',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.tag),
|
||||
hintText: isEditing ? null : 'Laissez vide pour générer automatiquement',
|
||||
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}',
|
||||
hintText: isEditing ? null : 'Généré automatiquement',
|
||||
helperText: isEditing ? 'Non modifiable' : 'Identifiant unique du matériel',
|
||||
),
|
||||
enabled: !isEditing,
|
||||
enabled: !autoGenerateId && !isEditing,
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
// Empêcher les ID commençant par BOX_ (réservé aux containers)
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer un identifiant';
|
||||
}
|
||||
if (value.toUpperCase().startsWith('BOX_')) {
|
||||
return 'Les ID commençant par BOX_ sont réservés aux boites';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Case à cocher "Ajouter plusieurs" (uniquement en mode création)
|
||||
),
|
||||
if (!isEditing) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
autoGenerateId ? Icons.lock : Icons.lock_open,
|
||||
color: autoGenerateId ? AppColors.rouge : Colors.grey,
|
||||
),
|
||||
tooltip: autoGenerateId
|
||||
? 'Génération automatique'
|
||||
: 'Saisie manuelle',
|
||||
onPressed: () {
|
||||
_autoGenerateIdNotifier.value = !autoGenerateId;
|
||||
if (_autoGenerateIdNotifier.value) {
|
||||
_triggerCandidateIdsUpdate();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (_idConflictMessage != null && !isEditing) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.orange.shade800, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: CheckboxListTile(
|
||||
title: const Text('Ajouter plusieurs équipements'),
|
||||
subtitle: const Text('Créer plusieurs équipements numérotés'),
|
||||
value: _addMultiple,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
_addMultiple = value ?? false;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
_idConflictMessage!,
|
||||
style: TextStyle(
|
||||
color: Colors.orange.shade800,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (_addMultiple) ...[
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _quantityToAddController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Quantité ou range',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.plus_one),
|
||||
hintText: '5 ou 6-18',
|
||||
helperText: 'Ex: 5 ou 6-18',
|
||||
),
|
||||
keyboardType: TextInputType.text,
|
||||
validator: (value) {
|
||||
if (_addMultiple) {
|
||||
if (value == null || value.isEmpty) return 'Requis';
|
||||
// Vérifier si c'est un nombre simple ou une range
|
||||
if (value.contains('-')) {
|
||||
final parts = value.split('-');
|
||||
if (parts.length != 2) return 'Format invalide';
|
||||
final start = int.tryParse(parts[0].trim());
|
||||
final end = int.tryParse(parts[1].trim());
|
||||
if (start == null || end == null) return 'Nombres invalides';
|
||||
if (start >= end) return 'Le début doit être < fin';
|
||||
if (end - start > 100) return 'Max 100 équipements';
|
||||
} else {
|
||||
final num = int.tryParse(value);
|
||||
if (num == null || num < 1 || num > 100) return '1-100';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Sélecteur Marque/Modèle
|
||||
// Marque & Modèle
|
||||
BrandModelSelector(
|
||||
brandController: _brandController,
|
||||
modelController: _modelController,
|
||||
@@ -268,6 +519,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
|
||||
// Catégorie et Statut
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<EquipmentCategory>(
|
||||
@@ -283,6 +535,12 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
child: Text(category.label),
|
||||
);
|
||||
}).toList(),
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return 'Catégorie obligatoire';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
@@ -294,7 +552,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
},
|
||||
),
|
||||
),
|
||||
// Afficher le statut uniquement si ce n'est pas un consommable ou câble
|
||||
if (!_isConsumable) ...[
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
@@ -303,7 +560,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Statut *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.info),
|
||||
prefixIcon: Icon(Icons.info_outline),
|
||||
),
|
||||
items: EquipmentStatus.values.map((status) {
|
||||
return DropdownMenuItem(
|
||||
@@ -331,52 +588,102 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
selectedCategory: _selectedCategory,
|
||||
filteredSubCategories: _filteredSubCategories,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// La valeur est déjà dans le controller
|
||||
});
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Prix
|
||||
if (hasManagePermission) ...[
|
||||
Row(
|
||||
// Card 2: Quantité & Stock
|
||||
if (!isEditing || _isConsumable) ...[
|
||||
_buildCard(
|
||||
title: 'Quantité & Stock',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _purchasePriceController,
|
||||
if (!isEditing) ...[
|
||||
TextFormField(
|
||||
controller: _quantityToAddController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Prix d\'achat (€)',
|
||||
labelText: 'Nombre d\'exemplaires à créer *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.euro),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _rentalPriceController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Prix de location (€)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.attach_money),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
|
||||
),
|
||||
prefixIcon: Icon(Icons.copy),
|
||||
helperText: 'Exemples de valeurs acceptées :\n- "5" : crée 5 exemplaires (de #1 à #5)\n- "3-6" : crée 4 exemplaires (de #3 à #6)',
|
||||
helperMaxLines: 3,
|
||||
),
|
||||
keyboardType: TextInputType.text,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9\s-]')),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Veuillez entrer une quantité ou une plage';
|
||||
}
|
||||
final parsed = _parseQuantityOrRange(value);
|
||||
if (parsed == null || parsed.isEmpty) {
|
||||
return 'Format invalide (ex: "5" ou "3-6")';
|
||||
}
|
||||
if (parsed.length > 100) {
|
||||
return 'La quantité maximale autorisée est de 100 exemplaires';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
if (_candidateIds.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Identifiants qui seront créés (${_candidateIds.length}) :',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
if (_isCalculatingIds)
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _candidateIds.map((id) {
|
||||
return Chip(
|
||||
label: Text(
|
||||
id,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
backgroundColor: AppColors.rouge.withValues(alpha: 0.08),
|
||||
side: BorderSide(color: AppColors.rouge.withValues(alpha: 0.2)),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_isConsumable) const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Quantités pour consommables
|
||||
if (_isConsumable) ...[
|
||||
const Divider(),
|
||||
const Text('Gestion des quantités', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -385,7 +692,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Quantité totale',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.inventory),
|
||||
prefixIcon: Icon(Icons.format_list_numbered),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
@@ -398,7 +705,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Seuil critique',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.warning),
|
||||
prefixIcon: Icon(Icons.warning_amber),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
@@ -406,40 +713,146 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Dates
|
||||
const Divider(),
|
||||
const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
_buildDateField(label: 'Date d\'achat', icon: Icons.shopping_cart, value: _purchaseDate, onTap: () => _selectDate(context, 'purchase')),
|
||||
const SizedBox(height: 16),
|
||||
_buildDateField(label: 'Dernière maintenance', icon: Icons.build, value: _lastMaintenanceDate, onTap: () => _selectDate(context, 'lastMaintenance')),
|
||||
const SizedBox(height: 16),
|
||||
_buildDateField(label: 'Prochaine maintenance', icon: Icons.event, value: _nextMaintenanceDate, onTap: () => _selectDate(context, 'nextMaintenance')),
|
||||
const SizedBox(height: 16),
|
||||
// Card 3: Informations Financières
|
||||
if (hasManagePermission) ...[
|
||||
_buildCard(
|
||||
title: 'Informations financières',
|
||||
icon: Icons.euro_outlined,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _purchasePriceController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Prix d\'achat (€)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.shopping_bag_outlined),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _rentalPriceController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Prix de location (€)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.sell_outlined),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Notes
|
||||
const Divider(),
|
||||
// Card 4: Caractéristiques physiques (Amélioration)
|
||||
_buildCard(
|
||||
title: 'Caractéristiques physiques',
|
||||
icon: Icons.scale_outlined,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _weightController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Poids à vide (kg)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.scale),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _lengthController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Longueur (cm)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _widthController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Largeur (cm)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _heightController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Hauteur (cm)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Card 5: Dates
|
||||
_buildCard(
|
||||
title: 'Dates & Maintenance',
|
||||
icon: Icons.calendar_today_outlined,
|
||||
children: [
|
||||
_buildDateField(label: 'Date d\'achat', icon: Icons.shopping_cart_outlined, value: _purchaseDate, onTap: () => _selectDate(context, 'purchase')),
|
||||
const SizedBox(height: 16),
|
||||
_buildDateField(label: 'Dernière maintenance', icon: Icons.build_outlined, value: _lastMaintenanceDate, onTap: () => _selectDate(context, 'lastMaintenance')),
|
||||
const SizedBox(height: 16),
|
||||
_buildDateField(label: 'Prochaine maintenance', icon: Icons.event_outlined, value: _nextMaintenanceDate, onTap: () => _selectDate(context, 'nextMaintenance')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Card 6: Notes
|
||||
_buildCard(
|
||||
title: 'Notes & Remarques',
|
||||
icon: Icons.notes_outlined,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes',
|
||||
labelText: 'Notes complémentaires',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.notes),
|
||||
prefixIcon: Icon(Icons.edit_note),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Boutons
|
||||
// Actions
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
child: const Text('Annuler', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton(
|
||||
@@ -447,19 +860,27 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isEditing ? 'Enregistrer' : 'Créer',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)
|
||||
),
|
||||
child: Text(isEditing ? 'Enregistrer' : 'Créer', style: const TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
@@ -521,6 +942,8 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
Future<void> _saveEquipment() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
@@ -538,56 +961,32 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
}
|
||||
|
||||
// Validation marque/modèle obligatoires
|
||||
String brand = _brandController.text.trim();
|
||||
String model = _modelController.text.trim();
|
||||
final brand = _brandController.text.trim();
|
||||
final model = _modelController.text.trim();
|
||||
|
||||
if (brand.isEmpty || model.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
scaffoldMessenger.showSnackBar(
|
||||
const SnackBar(content: Text('La marque et le modèle sont obligatoires')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Génération d'identifiant si vide
|
||||
// Génération d'identifiant
|
||||
List<String> ids = [];
|
||||
List<int> numbers = [];
|
||||
|
||||
if (!isEditing && _identifierController.text.isEmpty) {
|
||||
// Gérer la range ou nombre simple
|
||||
final quantityText = _quantityToAddController.text.trim();
|
||||
if (_addMultiple && quantityText.contains('-')) {
|
||||
// Range: ex "6-18"
|
||||
final parts = quantityText.split('-');
|
||||
final start = int.parse(parts[0].trim());
|
||||
final end = int.parse(parts[1].trim());
|
||||
for (int i = start; i <= end; i++) {
|
||||
numbers.add(i);
|
||||
}
|
||||
} else if (_addMultiple) {
|
||||
// Nombre simple
|
||||
final nbToAdd = int.tryParse(quantityText) ?? 1;
|
||||
for (int i = 1; i <= nbToAdd; i++) {
|
||||
numbers.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Générer les IDs
|
||||
if (numbers.isEmpty) {
|
||||
String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null);
|
||||
String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService);
|
||||
ids.add(uniqueId);
|
||||
} else {
|
||||
for (final num in numbers) {
|
||||
String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: num);
|
||||
String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService);
|
||||
ids.add(uniqueId);
|
||||
}
|
||||
if (!isEditing) {
|
||||
ids = await _calculateCandidateIds();
|
||||
if (ids.isEmpty) {
|
||||
scaffoldMessenger.showSnackBar(
|
||||
const SnackBar(content: Text('Impossible de générer des identifiants valides')),
|
||||
);
|
||||
setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
ids.add(_identifierController.text.trim());
|
||||
ids.add(_identifierController.text.trim().toUpperCase());
|
||||
}
|
||||
|
||||
// Création des équipements
|
||||
// Création/Mise à jour des équipements
|
||||
for (final id in ids) {
|
||||
final now = DateTime.now();
|
||||
final equipment = EquipmentModel(
|
||||
@@ -595,7 +994,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
name: id, // Utilisation de l'identifiant comme nom
|
||||
brand: brand,
|
||||
model: model,
|
||||
category: _selectedCategory,
|
||||
category: _selectedCategory!,
|
||||
subCategory: _subCategoryController.text.trim().isNotEmpty ? _subCategoryController.text.trim() : null,
|
||||
status: _selectedStatus,
|
||||
purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null,
|
||||
@@ -609,6 +1008,10 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
|
||||
updatedAt: now,
|
||||
availableQuantity: availableQuantity,
|
||||
weight: _weightController.text.isNotEmpty ? double.tryParse(_weightController.text) : null,
|
||||
length: _lengthController.text.isNotEmpty ? double.tryParse(_lengthController.text) : null,
|
||||
width: _widthController.text.isNotEmpty ? double.tryParse(_widthController.text) : null,
|
||||
height: _heightController.text.isNotEmpty ? double.tryParse(_heightController.text) : null,
|
||||
);
|
||||
if (isEditing) {
|
||||
await equipmentProvider.updateEquipment(equipment);
|
||||
@@ -618,11 +1021,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context, true);
|
||||
navigator.pop(true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(content: Text('Erreur lors de l\'enregistrement : $e')),
|
||||
);
|
||||
}
|
||||
@@ -631,3 +1034,9 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IdParseResult {
|
||||
final String baseId;
|
||||
final int? number;
|
||||
IdParseResult(this.baseId, this.number);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/views/equipment_form_page.dart';
|
||||
@@ -16,9 +17,11 @@ import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
||||
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
||||
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:em2rp/utils/equipment_delete_utils.dart';
|
||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||
import 'package:em2rp/utils/debouncer.dart';
|
||||
|
||||
class EquipmentManagementPage extends StatefulWidget {
|
||||
const EquipmentManagementPage({super.key});
|
||||
@@ -28,11 +31,11 @@ class EquipmentManagementPage extends StatefulWidget {
|
||||
_EquipmentManagementPageState();
|
||||
}
|
||||
|
||||
|
||||
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
with SelectionModeMixin<EquipmentManagementPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final _searchDebouncer = Debouncer();
|
||||
EquipmentCategory? _selectedCategory;
|
||||
List<EquipmentModel>? _cachedEquipment;
|
||||
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
|
||||
@@ -66,7 +69,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
if (_scrollController.hasClients &&
|
||||
_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 300) {
|
||||
|
||||
// Vérifier qu'on peut charger plus
|
||||
if (provider.hasMore && !provider.isLoadingMore) {
|
||||
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
|
||||
@@ -76,7 +78,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
_isLoadingMore = false;
|
||||
}).catchError((error) {
|
||||
_isLoadingMore = false;
|
||||
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
|
||||
DebugLog.error(
|
||||
'[EquipmentManagementPage] Error loading next page', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -87,6 +90,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
_searchDebouncer.dispose();
|
||||
// Désactiver le mode pagination en quittant
|
||||
context.read<EquipmentProvider>().disablePagination();
|
||||
super.dispose();
|
||||
@@ -140,7 +144,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
],
|
||||
],
|
||||
)
|
||||
: CustomAppBar(
|
||||
: const CustomAppBar(
|
||||
title: 'Gestion du matériel',
|
||||
),
|
||||
drawer: const MainDrawer(currentPage: '/equipment_management'),
|
||||
@@ -169,9 +173,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
SearchActionsBar(
|
||||
controller: _searchController,
|
||||
hintText: 'Rechercher par nom, modèle ou ID...',
|
||||
onChanged: (value) {
|
||||
context.read<EquipmentProvider>().setSearchQuery(value);
|
||||
},
|
||||
onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
|
||||
onClear: () {
|
||||
_searchController.clear();
|
||||
context.read<EquipmentProvider>().setSearchQuery('');
|
||||
@@ -342,9 +344,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
SearchActionsBar(
|
||||
controller: _searchController,
|
||||
hintText: 'Rechercher par nom, modèle ou ID...',
|
||||
onChanged: (value) {
|
||||
context.read<EquipmentProvider>().setSearchQuery(value);
|
||||
},
|
||||
onChanged: (value) => _searchDebouncer(() => context.read<EquipmentProvider>().setSearchQuery(value)),
|
||||
onClear: () {
|
||||
_searchController.clear();
|
||||
context.read<EquipmentProvider>().setSearchQuery('');
|
||||
@@ -456,11 +456,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
Widget _buildEquipmentList() {
|
||||
return Consumer<EquipmentProvider>(
|
||||
builder: (context, provider, child) {
|
||||
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
||||
DebugLog.info(
|
||||
'[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
||||
|
||||
// Afficher l'indicateur de chargement initial uniquement
|
||||
if (provider.isLoading && provider.equipment.isEmpty) {
|
||||
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
|
||||
DebugLog.info(
|
||||
'[EquipmentManagementPage] Showing initial loading indicator');
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
@@ -490,7 +492,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
);
|
||||
}
|
||||
|
||||
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
|
||||
DebugLog.info(
|
||||
'[EquipmentManagementPage] Building list with ${equipments.length} items');
|
||||
|
||||
// Calculer le nombre total d'items (équipements + indicateur de chargement)
|
||||
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
|
||||
@@ -498,10 +501,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: itemCount,
|
||||
// ✅ Ajouter une estimation de la hauteur pour améliorer le scroll
|
||||
// Note : À ajuster selon la hauteur réelle de vos cartes
|
||||
// itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur
|
||||
// ✅ Augmenter le cache pour un scroll plus fluide
|
||||
// ✅ Augmenter le cache pour un scroll plus fluide (prototypeItem retiré car les hauteurs dynamiques varient selon le type d'équipement)
|
||||
cacheExtent: 500, // Précharger 500px en plus
|
||||
itemBuilder: (context, index) {
|
||||
// Dernier élément = indicateur de chargement
|
||||
@@ -528,41 +528,87 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
return RepaintBoundary(
|
||||
key: ValueKey(equipment.id),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
margin: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: isSelectionMode && isSelected
|
||||
? AppColors.rouge.withValues(alpha: 0.1)
|
||||
: null,
|
||||
child: ListTile(
|
||||
leading: isSelectionMode
|
||||
? Checkbox(
|
||||
? AppColors.rouge
|
||||
: Colors.grey.shade200,
|
||||
width: isSelectionMode && isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
color: isSelectionMode && isSelected
|
||||
? AppColors.rouge.withValues(alpha: 0.05)
|
||||
: Colors.white,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: isSelectionMode
|
||||
? () => toggleItemSelection(equipment.id)
|
||||
: () => _viewEquipmentDetails(equipment),
|
||||
onLongPress: isSelectionMode
|
||||
? null
|
||||
: () {
|
||||
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||
if (localUserProvider.hasPermission('manage_equipment')) {
|
||||
_editEquipment(equipment);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Vous n'avez pas la permission de modifier cet équipement"),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 1. leading selection or icon
|
||||
if (isSelectionMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) => toggleItemSelection(equipment.id),
|
||||
activeColor: AppColors.rouge,
|
||||
),
|
||||
)
|
||||
: CircleAvatar(
|
||||
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: equipment.category.color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: equipment.category.getIcon(
|
||||
size: 20,
|
||||
size: 22,
|
||||
color: equipment.category.color,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Info details (ID, Brand/Model, Subcategory)
|
||||
Expanded(
|
||||
child: Text(
|
||||
equipment.id,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
// Afficher le badge de statut calculé dynamiquement
|
||||
if (equipment.category != EquipmentCategory.consumable &&
|
||||
equipment.category != EquipmentCategory.cable)
|
||||
EquipmentStatusBadge(equipment: equipment),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
equipment.id,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
color: AppColors.noir,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
||||
@@ -570,31 +616,42 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
.isNotEmpty
|
||||
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
|
||||
: 'Marque/Modèle non défini',
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[700],
|
||||
fontSize: 13,
|
||||
),
|
||||
// Afficher la sous-catégorie si elle existe
|
||||
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
|
||||
),
|
||||
// Sous-catégorie
|
||||
if (equipment.subCategory != null &&
|
||||
equipment.subCategory!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'📁 ${equipment.subCategory}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[500],
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Afficher la quantité disponible pour les consommables/câbles
|
||||
if (equipment.category == EquipmentCategory.consumable ||
|
||||
equipment.category == EquipmentCategory.cable) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildQuantityDisplay(equipment),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: isSelectionMode
|
||||
? null
|
||||
: Row(
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 3. Status Badge OR Quantity Display (centered vertically in the row!)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: (equipment.category == EquipmentCategory.consumable ||
|
||||
equipment.category == EquipmentCategory.cable)
|
||||
? _buildQuantityDisplay(equipment)
|
||||
: EquipmentStatusBadge(equipment: equipment),
|
||||
),
|
||||
|
||||
// 4. Trailing Action Buttons
|
||||
if (!isSelectionMode)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
||||
@@ -604,45 +661,59 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
requiredPermissions: const ['manage_equipment'],
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.add_shopping_cart,
|
||||
color: AppColors.rouge),
|
||||
color: AppColors.rouge, size: 20),
|
||||
tooltip: 'Restock',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => _showRestockDialog(equipment),
|
||||
),
|
||||
),
|
||||
if (equipment.category == EquipmentCategory.consumable ||
|
||||
equipment.category == EquipmentCategory.cable)
|
||||
const SizedBox(width: 8),
|
||||
// Bouton QR Code
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
|
||||
icon: const Icon(Icons.qr_code, color: AppColors.rouge, size: 20),
|
||||
tooltip: 'QR Code',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => QRCodeDialog.forEquipment(equipment),
|
||||
builder: (context) =>
|
||||
QRCodeDialog.forEquipment(equipment),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Bouton Modifier (permission required)
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['manage_equipment'],
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit, color: AppColors.rouge),
|
||||
icon: const Icon(Icons.edit, color: AppColors.rouge, size: 20),
|
||||
tooltip: 'Modifier',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => _editEquipment(equipment),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Bouton Supprimer (permission required)
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['manage_equipment'],
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
icon: const Icon(Icons.delete, color: Colors.red, size: 20),
|
||||
tooltip: 'Supprimer',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => _deleteEquipment(equipment),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: isSelectionMode
|
||||
? () => toggleItemSelection(equipment.id)
|
||||
: () => _viewEquipmentDetails(equipment),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -652,60 +723,26 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
final criticalThreshold = equipment.criticalThreshold ?? 0;
|
||||
final isCritical =
|
||||
criticalThreshold > 0 && availableQty <= criticalThreshold;
|
||||
final color = isCritical ? Colors.red : Colors.grey.shade600;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isCritical
|
||||
? Colors.red.withOpacity(0.15)
|
||||
: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isCritical ? Colors.red : Colors.grey.shade400,
|
||||
width: isCritical ? 2 : 1,
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isCritical ? Icons.warning : Icons.inventory,
|
||||
size: 16,
|
||||
color: isCritical ? Colors.red : Colors.grey[700],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Disponible: $availableQty / $totalQty',
|
||||
child: Text(
|
||||
'$availableQty / $totalQty',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: isCritical ? FontWeight.bold : FontWeight.normal,
|
||||
color: isCritical ? Colors.red : Colors.grey[700],
|
||||
),
|
||||
),
|
||||
if (isCritical) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Text(
|
||||
'CRITIQUE',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Actions
|
||||
void _createNewEquipment() {
|
||||
Navigator.push(
|
||||
@@ -726,39 +763,64 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
}
|
||||
|
||||
void _deleteEquipment(EquipmentModel equipment) {
|
||||
final pageContext = context;
|
||||
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
|
||||
id: equipment.id,
|
||||
name: equipment.name,
|
||||
);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'),
|
||||
context: pageContext,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
||||
content: Text(
|
||||
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
|
||||
equipmentLabel,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(dialogContext);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
||||
final provider = pageContext.read<EquipmentProvider>();
|
||||
|
||||
try {
|
||||
await context
|
||||
.read<EquipmentProvider>()
|
||||
.deleteEquipment(equipment.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
final deleted =
|
||||
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
|
||||
context: pageContext,
|
||||
equipmentLabel: equipmentLabel,
|
||||
deleteEquipment: ({bool forceDelete = false}) {
|
||||
return provider.deleteEquipment(
|
||||
equipment.id,
|
||||
forceDelete: forceDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
scaffoldMessenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Équipement supprimé avec succès')),
|
||||
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -768,46 +830,78 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
void _deleteSelectedEquipment() async {
|
||||
if (!hasSelection) return;
|
||||
|
||||
final pageContext = context;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
context: pageContext,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
||||
content: Text(
|
||||
'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?',
|
||||
EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage(
|
||||
selectedCount,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(dialogContext);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
||||
final provider = pageContext.read<EquipmentProvider>();
|
||||
|
||||
try {
|
||||
final provider = context.read<EquipmentProvider>();
|
||||
final equipmentById = {
|
||||
for (final equipment
|
||||
in provider.equipment)
|
||||
equipment.id: equipment,
|
||||
};
|
||||
|
||||
var deletedCount = 0;
|
||||
for (final id in selectedIds) {
|
||||
await provider.deleteEquipment(id);
|
||||
final label = EquipmentDeleteUtils.resolveEquipmentLabel(
|
||||
id: id,
|
||||
name: equipmentById[id]?.name,
|
||||
);
|
||||
final deleted = await EquipmentDeleteUtils
|
||||
.deleteWithFutureAssignmentCheck(
|
||||
context: pageContext,
|
||||
equipmentLabel: label,
|
||||
deleteEquipment: ({bool forceDelete = false}) {
|
||||
return provider.deleteEquipment(
|
||||
id,
|
||||
forceDelete: forceDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (deleted) {
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
disableSelectionMode();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$selectedCount équipement(s) supprimé(s) avec succès'),
|
||||
EquipmentDeleteUtils.buildBulkDeleteSuccessMessage(
|
||||
deletedCount,
|
||||
),
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -829,17 +923,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
try {
|
||||
// Récupérer les équipements sélectionnés
|
||||
final provider = context.read<EquipmentProvider>();
|
||||
final List<EquipmentModel> selectedEquipment = [];
|
||||
|
||||
// On doit récupérer les équipements depuis le stream
|
||||
await for (final equipmentList in provider.equipmentStream.take(1)) {
|
||||
for (final equipment in equipmentList) {
|
||||
if (isItemSelected(equipment.id)) {
|
||||
selectedEquipment.add(equipment);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
final List<EquipmentModel> selectedEquipment =
|
||||
await provider.getEquipmentsByIds(selectedIds.toList());
|
||||
|
||||
// Fermer l'indicateur de chargement
|
||||
if (mounted) {
|
||||
@@ -853,7 +938,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
|
||||
builder: (context) =>
|
||||
QRCodeDialog.forEquipment(selectedEquipment.first),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -1046,7 +1132,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
|
||||
await context
|
||||
.read<EquipmentProvider>()
|
||||
.updateEquipment(updatedEquipment);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -1184,7 +1272,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
|
||||
content: Text(
|
||||
'Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -77,7 +77,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await _controller.submitForm(context, existingEvent: widget.event);
|
||||
final success =
|
||||
await _controller.submitForm(context, existingEvent: widget.event);
|
||||
if (success && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
@@ -141,6 +142,54 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCard({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required List<Widget> children,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD32F2F).withOpacity(0.1), // AppColors.rouge
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, color: const Color(0xFFD32F2F), size: 22),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
@@ -157,25 +206,23 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.grey.shade50,
|
||||
appBar: AppBar(
|
||||
title: Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'),
|
||||
title: Text(
|
||||
isEditMode ? 'Modifier un événement' : 'Créer un événement'),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: (isMobile
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: _buildFormContent(isMobile),
|
||||
)
|
||||
: Card(
|
||||
elevation: 6,
|
||||
margin: const EdgeInsets.all(24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||||
body: SingleChildScrollView(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isMobile ? 16 : 32,
|
||||
vertical: 32),
|
||||
child: _buildFormContent(isMobile),
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -186,20 +233,15 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
Widget _buildFormContent(bool isMobile) {
|
||||
return Consumer<EventFormController>(
|
||||
builder: (context, controller, child) {
|
||||
// Trouver le nom du type d'événement pour le passer au sélecteur d'options
|
||||
final selectedEventTypeIndex = controller.selectedEventTypeId != null
|
||||
? controller.eventTypes.indexWhere((et) => et.id == controller.selectedEventTypeId)
|
||||
: -1;
|
||||
final selectedEventType = selectedEventTypeIndex != -1
|
||||
? controller.eventTypes[selectedEventTypeIndex]
|
||||
: null;
|
||||
final selectedEventTypeName = selectedEventType?.name;
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildCard(
|
||||
title: 'Informations générales',
|
||||
icon: Icons.event_note,
|
||||
children: [
|
||||
EventBasicInfoSection(
|
||||
nameController: controller.nameController,
|
||||
@@ -209,26 +251,31 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
selectedEventTypeId: controller.selectedEventTypeId,
|
||||
startDateTime: controller.startDateTime,
|
||||
endDateTime: controller.endDateTime,
|
||||
onEventTypeChanged: (typeId) => controller.onEventTypeChanged(typeId, context),
|
||||
selectedOptions: controller.selectedOptions,
|
||||
onEventTypeChanged: (typeId) =>
|
||||
controller.onEventTypeChanged(typeId, context),
|
||||
onStartDateTimeChanged: controller.setStartDateTime,
|
||||
onEndDateTimeChanged: controller.setEndDateTime,
|
||||
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
||||
onAnyFieldChanged: () {},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OptionSelectorWidget(
|
||||
eventType: controller.selectedEventTypeId, // Utilise l'ID au lieu du nom
|
||||
eventType: controller.selectedEventTypeId,
|
||||
selectedOptions: controller.selectedOptions,
|
||||
onChanged: controller.setSelectedOptions,
|
||||
onRemove: (optionId) {
|
||||
final newOptions = List<Map<String, dynamic>>.from(controller.selectedOptions);
|
||||
final newOptions = List<Map<String, dynamic>>.from(
|
||||
controller.selectedOptions);
|
||||
newOptions.removeWhere((o) => o['id'] == optionId);
|
||||
controller.setSelectedOptions(newOptions);
|
||||
},
|
||||
eventTypeRequired: controller.selectedEventTypeId == null,
|
||||
isMobile: isMobile,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Section Matériel Assigné
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Section Matériel Assigné (gère sa propre carte pour inclure les boutons d'action dans le header)
|
||||
EventAssignedEquipmentSection(
|
||||
assignedEquipment: controller.assignedEquipment,
|
||||
assignedContainers: controller.assignedContainers,
|
||||
@@ -236,8 +283,13 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
endDate: controller.endDateTime,
|
||||
onChanged: controller.setAssignedEquipment,
|
||||
eventId: widget.event?.id,
|
||||
eventTypeId: controller.selectedEventTypeId,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 24),
|
||||
_buildCard(
|
||||
title: 'Détails & Logistique',
|
||||
icon: Icons.location_on_outlined,
|
||||
children: [
|
||||
EventDetailsSection(
|
||||
descriptionController: controller.descriptionController,
|
||||
installationController: controller.installationController,
|
||||
@@ -248,7 +300,25 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
contactPhoneController: controller.contactPhoneController,
|
||||
isMobile: isMobile,
|
||||
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
||||
onTravelCostSelected: (price) {
|
||||
controller.addTravelCostOption(price);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Frais de déplacement ajoutés : ${price.toStringAsFixed(2)} €'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildCard(
|
||||
title: 'Personnel & Documents',
|
||||
icon: Icons.group_outlined,
|
||||
children: [
|
||||
EventStaffAndDocumentsSection(
|
||||
allUsers: controller.allUsers,
|
||||
selectedUserIds: controller.selectedUserIds,
|
||||
@@ -262,24 +332,57 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
isMobile: isMobile,
|
||||
onPickAndUploadFiles: controller.pickAndUploadFiles,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (controller.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
padding: const EdgeInsets.only(top: 24.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200)
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Colors.red.shade700),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.error!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.red.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.success != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
padding: const EdgeInsets.only(top: 24.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.shade200)
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline, color: Colors.green.shade700),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.success!,
|
||||
style: const TextStyle(color: Colors.green),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.green.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
EventFormActions(
|
||||
isLoading: controller.isLoading,
|
||||
isEditMode: isEditMode,
|
||||
@@ -290,10 +393,15 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
}
|
||||
},
|
||||
onSubmit: _submit,
|
||||
onSetConfirmed: !isEditMode ? () {
|
||||
onSetConfirmed: !isEditMode ? () async {
|
||||
final success = await controller.submitAsConfirmed(context);
|
||||
if (success && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} : null,
|
||||
onDelete: isEditMode ? _deleteEvent : null, // Ajout du callback de suppression
|
||||
onDelete: isEditMode ? _deleteEvent : null,
|
||||
),
|
||||
const SizedBox(height: 48), // Padding bottom for scrolling
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -88,22 +88,22 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
|
||||
// Logique stricte : on avance étape par étape
|
||||
// 1. Préparation dépôt
|
||||
if (prep != PreparationStatus.completed) {
|
||||
if (prep != PreparationStatus.completed && prep != PreparationStatus.completedWithMissing) {
|
||||
return PreparationStep.preparation;
|
||||
}
|
||||
|
||||
// 2. Chargement aller (après préparation complète)
|
||||
if (loading != LoadingStatus.completed) {
|
||||
if (loading != LoadingStatus.completed && loading != LoadingStatus.completedWithMissing) {
|
||||
return PreparationStep.loadingOutbound;
|
||||
}
|
||||
|
||||
// 3. Chargement retour (après chargement aller complet)
|
||||
if (unloading != UnloadingStatus.completed) {
|
||||
if (unloading != UnloadingStatus.completed && unloading != UnloadingStatus.completedWithMissing) {
|
||||
return PreparationStep.unloadingReturn;
|
||||
}
|
||||
|
||||
// 4. Retour dépôt (après déchargement complet)
|
||||
if (returnStatus != ReturnStatus.completed) {
|
||||
if (returnStatus != ReturnStatus.completed && returnStatus != ReturnStatus.completedWithMissing) {
|
||||
return PreparationStep.return_;
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_isCurrentStepCompleted()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cette étape est déjà terminée'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
@@ -141,6 +141,17 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
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
|
||||
_loadEquipmentAndContainers();
|
||||
});
|
||||
@@ -150,13 +161,34 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
bool _isCurrentStepCompleted() {
|
||||
switch (_currentStep) {
|
||||
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:
|
||||
return (_currentEvent.loadingStatus ?? LoadingStatus.notStarted) == LoadingStatus.completed;
|
||||
final status = _currentEvent.loadingStatus ?? LoadingStatus.notStarted;
|
||||
return status == LoadingStatus.completed || status == LoadingStatus.completedWithMissing;
|
||||
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_:
|
||||
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;
|
||||
}
|
||||
|
||||
_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_ ||
|
||||
_currentStep == PreparationStep.unloadingReturn) &&
|
||||
(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'],
|
||||
);
|
||||
|
||||
// Mettre à jour les statuts des équipements si nécessaire
|
||||
if (_currentStep == PreparationStep.preparation ||
|
||||
(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) {
|
||||
// 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) {
|
||||
await _updateEquipmentStatuses(updatedEquipment);
|
||||
}
|
||||
|
||||
@@ -505,6 +541,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
}
|
||||
|
||||
Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async {
|
||||
final List<String> failedUpdates = [];
|
||||
|
||||
for (var eq in equipment) {
|
||||
try {
|
||||
final equipmentData = _equipmentCache[eq.equipmentId];
|
||||
@@ -513,7 +551,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
// Déterminer le nouveau statut
|
||||
EquipmentStatus newStatus;
|
||||
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) {
|
||||
newStatus = EquipmentStatus.inUse;
|
||||
} else {
|
||||
@@ -527,19 +567,22 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
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) {
|
||||
// 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() {
|
||||
@@ -895,26 +938,28 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
||||
_quantitiesAtReturn.addAll(quantities);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtenir la quantité requise selon l'étape (nouvelle logique)
|
||||
int _getTargetQuantity(EventEquipment eventEquipment) {
|
||||
// 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 eventEquipment.quantity; // Quantité initiale
|
||||
return eq.copyWith(quantityAtPreparation: qty);
|
||||
case PreparationStep.loadingOutbound:
|
||||
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
|
||||
return eq.copyWith(quantityAtLoading: qty);
|
||||
case PreparationStep.unloadingReturn:
|
||||
return eventEquipment.quantityAtLoading ??
|
||||
eventEquipment.quantityAtPreparation ??
|
||||
eventEquipment.quantity;
|
||||
return eq.copyWith(quantityAtUnloading: qty);
|
||||
case PreparationStep.return_:
|
||||
return eventEquipment.quantityAtUnloading ??
|
||||
eventEquipment.quantityAtLoading ??
|
||||
eventEquipment.quantityAtPreparation ??
|
||||
eventEquipment.quantity;
|
||||
return eq.copyWith(quantityAtReturn: qty);
|
||||
}
|
||||
}
|
||||
return eq;
|
||||
}).toList();
|
||||
|
||||
_currentEvent = _currentEvent.copyWith(assignedEquipment: updatedList);
|
||||
}
|
||||
|
||||
|
||||
/// Afficher un message de succès
|
||||
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
|
||||
void _updateEquipmentQuantity(String equipmentId, int newQuantity) {
|
||||
setState(() {
|
||||
switch (_currentStep) {
|
||||
case PreparationStep.preparation:
|
||||
_quantitiesAtPreparation[equipmentId] = newQuantity;
|
||||
break;
|
||||
case PreparationStep.loadingOutbound:
|
||||
_quantitiesAtLoading[equipmentId] = newQuantity;
|
||||
break;
|
||||
case PreparationStep.unloadingReturn:
|
||||
_quantitiesAtUnloading[equipmentId] = newQuantity;
|
||||
break;
|
||||
case PreparationStep.return_:
|
||||
_quantitiesAtReturn[equipmentId] = newQuantity;
|
||||
break;
|
||||
}
|
||||
_updateQuantitiesMap({equipmentId: newQuantity});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -75,14 +75,15 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
|
||||
title: 'Gestion des maintenances',
|
||||
),
|
||||
drawer: const MainDrawer(currentPage: '/maintenance_management'),
|
||||
body: Consumer<MaintenanceProvider>(
|
||||
builder: (context, maintenanceProvider, _) {
|
||||
if (maintenanceProvider.isLoading) {
|
||||
body: Selector<MaintenanceProvider, ({bool isLoading, List<MaintenanceModel> maintenances})>(
|
||||
selector: (context, provider) => (isLoading: provider.isLoading, maintenances: provider.maintenances),
|
||||
builder: (context, data, _) {
|
||||
if (data.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final filteredMaintenances = _getFilteredMaintenances(
|
||||
maintenanceProvider.maintenances,
|
||||
data.maintenances,
|
||||
);
|
||||
|
||||
return Column(
|
||||
@@ -91,7 +92,7 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
|
||||
_buildFilterChips(),
|
||||
|
||||
// Statistiques
|
||||
_buildStatsCards(maintenanceProvider),
|
||||
_buildStatsCards(data.maintenances),
|
||||
|
||||
// Liste des maintenances
|
||||
Expanded(
|
||||
@@ -148,10 +149,10 @@ class _MaintenanceManagementPageState extends State<MaintenanceManagementPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsCards(MaintenanceProvider provider) {
|
||||
final upcoming = provider.maintenances.where((m) => !m.isCompleted && !m.isOverdue).length;
|
||||
final overdue = provider.maintenances.where((m) => m.isOverdue).length;
|
||||
final completed = provider.maintenances.where((m) => m.isCompleted).length;
|
||||
Widget _buildStatsCards(List<MaintenanceModel> maintenances) {
|
||||
final upcoming = maintenances.where((m) => !m.isCompleted && !m.isOverdue).length;
|
||||
final overdue = maintenances.where((m) => m.isOverdue).length;
|
||||
final completed = maintenances.where((m) => m.isCompleted).length;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/models/user_model.dart';
|
||||
import 'package:em2rp/views/widgets/inputs/styled_text_field.dart';
|
||||
import 'package:em2rp/views/widgets/image/profile_picture_selector.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
@@ -17,10 +18,9 @@ class MyAccountPage extends StatelessWidget {
|
||||
title: 'Mon compte',
|
||||
),
|
||||
drawer: const MainDrawer(currentPage: '/my_account'),
|
||||
body: Consumer<LocalUserProvider>(
|
||||
builder: (context, userProvider, child) {
|
||||
final user = userProvider.currentUser;
|
||||
|
||||
body: Selector<LocalUserProvider, UserModel?>(
|
||||
selector: (context, provider) => provider.currentUser,
|
||||
builder: (context, user, child) {
|
||||
if (user == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -73,7 +73,7 @@ class MyAccountPage extends StatelessWidget {
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
userProvider.updateUserData(
|
||||
context.read<LocalUserProvider>().updateUserData(
|
||||
firstName: firstNameController.text,
|
||||
lastName: lastNameController.text,
|
||||
phoneNumber: phoneController.text,
|
||||
|
||||
@@ -51,12 +51,13 @@ class _UserManagementPageState extends State<UserManagementPage> {
|
||||
title: 'Gestion des utilisateurs',
|
||||
),
|
||||
drawer: const MainDrawer(currentPage: '/account_management'),
|
||||
body: Consumer<UsersProvider>(
|
||||
builder: (context, usersProvider, child) {
|
||||
if (usersProvider.isLoading) {
|
||||
body: Selector<UsersProvider, ({bool isLoading, List<UserModel> users})>(
|
||||
selector: (context, provider) => (isLoading: provider.isLoading, users: provider.users),
|
||||
builder: (context, data, child) {
|
||||
if (data.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final users = usersProvider.users;
|
||||
final users = data.users;
|
||||
if (users.isEmpty) {
|
||||
return const Center(child: Text("Aucun utilisateur trouvé"));
|
||||
}
|
||||
@@ -92,7 +93,7 @@ class _UserManagementPageState extends State<UserManagementPage> {
|
||||
context: context,
|
||||
builder: (_) => EditUserDialog(user: user)),
|
||||
onResetPassword: () => _resetPassword(context, user),
|
||||
onDelete: () => _confirmDeleteUser(context, usersProvider, user),
|
||||
onDelete: () => _confirmDeleteUser(context, context.read<UsersProvider>(), user),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+89
-12
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class EventDetailsDescription extends StatelessWidget {
|
||||
final EventModel event;
|
||||
@@ -45,6 +47,13 @@ class EventDetailsDescription extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
event.address,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
@@ -57,6 +66,30 @@ class EventDetailsDescription extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.map, color: AppColors.rouge),
|
||||
tooltip: 'Ouvrir dans Maps',
|
||||
onPressed: () async {
|
||||
final query = event.address;
|
||||
if (query.isEmpty) return;
|
||||
|
||||
final url = Uri.parse('https://www.google.com/maps/search/?api=1&query=${Uri.encodeComponent(query)}');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Impossible d\'ouvrir l\'application de carte')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,12 +101,22 @@ class EventDetailsDescription extends StatelessWidget {
|
||||
_buildInfoRow(context, Icons.people, 'Jauge', '${event.jauge} personnes'),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (event.contactEmail != null) ...[
|
||||
_buildInfoRow(context, Icons.email, 'Email', event.contactEmail!),
|
||||
if (event.contactEmail != null && event.contactEmail!.isNotEmpty) ...[
|
||||
_buildInfoRow(context, Icons.email, 'Email', event.contactEmail!, onTap: () async {
|
||||
final url = Uri.parse('mailto:${event.contactEmail!}');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url);
|
||||
}
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (event.contactPhone != null) ...[
|
||||
_buildInfoRow(context, Icons.phone, 'Téléphone', event.contactPhone!),
|
||||
if (event.contactPhone != null && event.contactPhone!.isNotEmpty) ...[
|
||||
_buildInfoRow(context, Icons.phone, 'Téléphone', event.contactPhone!, onTap: () async {
|
||||
final url = Uri.parse('tel:${event.contactPhone!}');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url);
|
||||
}
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -86,15 +129,25 @@ class EventDetailsDescription extends StatelessWidget {
|
||||
children: [
|
||||
if (event.jauge != null)
|
||||
_buildInfoChip(context, Icons.people, 'Jauge', '${event.jauge} personnes'),
|
||||
if (event.contactEmail != null)
|
||||
_buildInfoChip(context, Icons.email, 'Email', event.contactEmail!),
|
||||
if (event.contactPhone != null)
|
||||
_buildInfoChip(context, Icons.phone, 'Téléphone', event.contactPhone!),
|
||||
if (event.contactEmail != null && event.contactEmail!.isNotEmpty)
|
||||
_buildInfoChip(context, Icons.email, 'Email', event.contactEmail!, onTap: () async {
|
||||
final url = Uri.parse('mailto:${event.contactEmail!}');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url);
|
||||
}
|
||||
}),
|
||||
if (event.contactPhone != null && event.contactPhone!.isNotEmpty)
|
||||
_buildInfoChip(context, Icons.phone, 'Téléphone', event.contactPhone!, onTap: () async {
|
||||
final url = Uri.parse('tel:${event.contactPhone!}');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url);
|
||||
}
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(BuildContext context, IconData icon, String label, String value) {
|
||||
Widget _buildInfoRow(BuildContext context, IconData icon, String label, String value, {VoidCallback? onTap}) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Theme.of(context).primaryColor),
|
||||
@@ -109,11 +162,23 @@ class EventDetailsDescription extends StatelessWidget {
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
SelectableText(
|
||||
onTap == null
|
||||
? SelectableText(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
)
|
||||
: SelectableText.rich(
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.rouge,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()..onTap = onTap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -122,7 +187,7 @@ class EventDetailsDescription extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(BuildContext context, IconData icon, String label, String value) {
|
||||
Widget _buildInfoChip(BuildContext context, IconData icon, String label, String value, {VoidCallback? onTap}) {
|
||||
final primaryColor = Theme.of(context).primaryColor;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -146,11 +211,23 @@ class EventDetailsDescription extends StatelessWidget {
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
SelectableText(
|
||||
onTap == null
|
||||
? SelectableText(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
)
|
||||
: SelectableText.rich(
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.rouge,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()..onTap = onTap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+176
-6
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/models/event_model.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/services/api_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
|
||||
/// Boutons de préparation et retour d'événement
|
||||
@@ -52,16 +54,16 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
|
||||
IconData buttonIcon;
|
||||
bool isCompleted = false;
|
||||
|
||||
if (prep != PreparationStatus.completed) {
|
||||
if (prep != PreparationStatus.completed && prep != PreparationStatus.completedWithMissing) {
|
||||
buttonText = 'Préparation dépôt';
|
||||
buttonIcon = Icons.inventory_2;
|
||||
} else if (loading != LoadingStatus.completed) {
|
||||
} else if (loading != LoadingStatus.completed && loading != LoadingStatus.completedWithMissing) {
|
||||
buttonText = 'Chargement aller';
|
||||
buttonIcon = Icons.local_shipping;
|
||||
} else if (unloading != UnloadingStatus.completed) {
|
||||
} else if (unloading != UnloadingStatus.completed && unloading != UnloadingStatus.completedWithMissing) {
|
||||
buttonText = 'Chargement retour';
|
||||
buttonIcon = Icons.unarchive;
|
||||
} else if (returnStatus != ReturnStatus.completed) {
|
||||
} else if (returnStatus != ReturnStatus.completed && returnStatus != ReturnStatus.completedWithMissing) {
|
||||
buttonText = 'Retour dépôt';
|
||||
buttonIcon = Icons.assignment_return;
|
||||
} else {
|
||||
@@ -131,9 +133,9 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
@@ -147,9 +149,177 @@ 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) {
|
||||
// Utiliser le rootNavigator pour s'assurer qu'on gère la bonne pile de navigation
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
useRootNavigator: true,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return const PopScope(
|
||||
canPop: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
final apiService = FirebaseFunctionsApiService();
|
||||
await apiService.call('rollbackEventStep', {
|
||||
'eventId': event.id,
|
||||
'targetStep': selectedStep,
|
||||
});
|
||||
|
||||
// Attendre un tout petit peu pour s'assurer que le showDialog a eu le temps
|
||||
// de faire son animation d'entrée si l'API a répondu trop vite.
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
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.pop(); // Fermer le loader
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Retour en arrière effectué avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
navigator.pop(); // Fermer le loader
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur : $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,12 +36,15 @@ class MonthView extends StatelessWidget {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final rowCount = _computeRowCount(focusedDay);
|
||||
// TableCalendar has internal vertical padding and margins (approx 16px) that cause overflow
|
||||
// if not accounted for. We subtract an extra 16.0 pixels to be safe.
|
||||
final availableHeight = constraints.maxHeight -
|
||||
(_calendarPadding * 2) -
|
||||
_headerHeight -
|
||||
_headerVerticalPadding -
|
||||
_daysOfWeekHeight;
|
||||
final rowHeight = availableHeight / rowCount;
|
||||
_daysOfWeekHeight -
|
||||
16.0;
|
||||
final rowHeight = (availableHeight > 0 ? availableHeight : 200.0) / rowCount;
|
||||
|
||||
return Container(
|
||||
height: constraints.maxHeight,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:em2rp/models/route_result_model.dart';
|
||||
import '../../../utils/polyline_utils.dart';
|
||||
|
||||
/// Affiche 1 ou 2 itinéraires sur une carte OpenStreetMap.
|
||||
/// Route TOLL = bleu, Route TOLL_FREE = vert.
|
||||
class RouteMapWidget extends StatelessWidget {
|
||||
final List<RouteResult> routes;
|
||||
final RouteResult? selectedRoute;
|
||||
|
||||
const RouteMapWidget({
|
||||
super.key,
|
||||
required this.routes,
|
||||
this.selectedRoute,
|
||||
});
|
||||
|
||||
List<LatLng> _decode(String encoded) {
|
||||
final pts = safeDecodePolyline(encoded);
|
||||
// DEBUG: afficher dans la console du navigateur
|
||||
// ignore: avoid_print
|
||||
print('[MAP DEBUG] encoded length=${encoded.length}, decoded ${pts.length} points');
|
||||
if (pts.isNotEmpty) {
|
||||
// ignore: avoid_print
|
||||
print('[MAP DEBUG] first=${pts.first.latitude},${pts.first.longitude} last=${pts.last.latitude},${pts.last.longitude}');
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
LatLngBounds? _computeBounds(List<List<LatLng>> allPoints) {
|
||||
final flat = allPoints.expand((e) => e).cast<LatLng>().toList();
|
||||
if (flat.isEmpty) return null;
|
||||
return LatLngBounds.fromPoints(flat);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allPolylines = <Polyline>[];
|
||||
final allPointGroups = <List<LatLng>>[];
|
||||
|
||||
for (final route in routes) {
|
||||
final pts = _decode(route.encodedPolyline);
|
||||
if (pts.isEmpty) continue;
|
||||
allPointGroups.add(pts);
|
||||
|
||||
final isSelected =
|
||||
selectedRoute == null || selectedRoute!.routeType == route.routeType;
|
||||
final isToll = route.routeType == 'TOLL';
|
||||
|
||||
allPolylines.add(Polyline(
|
||||
points: pts,
|
||||
strokeWidth: isSelected ? 5.0 : 3.0,
|
||||
color: isToll
|
||||
? (isSelected
|
||||
? const Color(0xFF1565C0)
|
||||
: const Color(0xFF1565C0).withValues(alpha: 0.4))
|
||||
: (isSelected
|
||||
? const Color(0xFF2E7D32)
|
||||
: const Color(0xFF2E7D32).withValues(alpha: 0.4)),
|
||||
));
|
||||
}
|
||||
|
||||
final bounds = _computeBounds(allPointGroups);
|
||||
final mapController = MapController();
|
||||
|
||||
// Marqueurs de départ / arrivée
|
||||
final markers = <Marker>[];
|
||||
for (final group in allPointGroups) {
|
||||
if (group.isEmpty) continue;
|
||||
// Départ
|
||||
markers.add(Marker(
|
||||
point: group.first,
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: const Icon(Icons.circle, color: Colors.green, size: 20),
|
||||
));
|
||||
// Arrivée
|
||||
markers.add(Marker(
|
||||
point: group.last,
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: const Icon(Icons.location_pin, color: Colors.red, size: 32),
|
||||
));
|
||||
}
|
||||
|
||||
return FlutterMap(
|
||||
mapController: mapController,
|
||||
options: MapOptions(
|
||||
initialCameraFit: bounds != null
|
||||
? CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(32),
|
||||
)
|
||||
: CameraFit.bounds(
|
||||
bounds: LatLngBounds.fromPoints([LatLng(46.2276, 2.2137)]),
|
||||
padding: const EdgeInsets.all(32),
|
||||
),
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.all,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.em2events.em2rp',
|
||||
),
|
||||
PolylineLayer(polylines: allPolylines),
|
||||
MarkerLayer(markers: markers),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
class StartupSplashScreen extends StatelessWidget {
|
||||
final String message;
|
||||
const StartupSplashScreen({super.key, this.message = 'Démarrage...'});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/logos/RectangleLogoBlack.png',
|
||||
width: 160,
|
||||
height: 160,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.event_available,
|
||||
size: 72,
|
||||
color: AppColors.rouge,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: AppColors.noir,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/depot_model.dart';
|
||||
import 'package:em2rp/services/travel_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/views/widgets/inputs/address_autocomplete_field.dart';
|
||||
|
||||
class DepotManagement extends StatefulWidget {
|
||||
const DepotManagement({super.key});
|
||||
|
||||
@override
|
||||
State<DepotManagement> createState() => _DepotManagementState();
|
||||
}
|
||||
|
||||
class _DepotManagementState extends State<DepotManagement> {
|
||||
final _service = TravelService();
|
||||
bool _isLoading = false;
|
||||
|
||||
void _showDepotDialog({DepotModel? depot}) {
|
||||
final nameCtrl = TextEditingController(text: depot?.name ?? '');
|
||||
final addressCtrl = TextEditingController(text: depot?.address ?? '');
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(depot == null ? 'Ajouter un dépôt' : 'Modifier le dépôt'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nom du dépôt *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.warehouse_outlined),
|
||||
hintText: 'ex: Dépôt principal',
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Requis' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AddressAutocompleteField(
|
||||
controller: addressCtrl,
|
||||
label: 'Adresse du dépôt *',
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Requis' : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
Navigator.pop(ctx);
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
if (depot == null) {
|
||||
await _service.addDepot(DepotModel(
|
||||
id: '',
|
||||
name: nameCtrl.text.trim(),
|
||||
address: addressCtrl.text.trim(),
|
||||
));
|
||||
} else {
|
||||
await _service.updateDepot(depot.copyWith(
|
||||
name: nameCtrl.text.trim(),
|
||||
address: addressCtrl.text.trim(),
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
child: Text(depot == null ? 'Ajouter' : 'Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _delete(DepotModel depot) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Supprimer le dépôt'),
|
||||
content: Text('Supprimer "${depot.name}" ?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await _service.deleteDepot(depot.id);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warehouse_outlined, color: AppColors.rouge, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Dépôts',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter un dépôt'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => _showDepotDialog(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Définissez les adresses de départ pour le calcul des frais de déplacement.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<DepotModel>>(
|
||||
stream: _service.watchDepots(),
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final depots = snap.data ?? [];
|
||||
if (depots.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.warehouse_outlined,
|
||||
size: 64, color: Colors.grey[300]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun dépôt configuré',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: Colors.grey[500]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Ajoutez un dépôt pour commencer.'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: depots.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final d = depots[i];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.rouge.withValues(alpha: 0.1),
|
||||
child: Icon(Icons.warehouse_outlined, color: AppColors.rouge),
|
||||
),
|
||||
title: Text(d.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(d.address),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: () => _showDepotDialog(depot: d),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () => _delete(d),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:em2rp/models/route_result_model.dart';
|
||||
import 'package:em2rp/services/travel_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
|
||||
class FuelPricesManagement extends StatefulWidget {
|
||||
const FuelPricesManagement({super.key});
|
||||
|
||||
@override
|
||||
State<FuelPricesManagement> createState() => _FuelPricesManagementState();
|
||||
}
|
||||
|
||||
class _FuelPricesManagementState extends State<FuelPricesManagement> {
|
||||
final _service = TravelService();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _dieselCtrl = TextEditingController();
|
||||
final _essenceCtrl = TextEditingController();
|
||||
final _electriqueCtrl = TextEditingController();
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _isLoading = true);
|
||||
final prices = await _service.getFuelPrices();
|
||||
_dieselCtrl.text = prices.diesel.toStringAsFixed(3);
|
||||
_essenceCtrl.text = prices.essence.toStringAsFixed(3);
|
||||
_electriqueCtrl.text = prices.electricite.toStringAsFixed(3);
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final prices = FuelPrices(
|
||||
diesel: double.parse(_dieselCtrl.text.trim()),
|
||||
essence: double.parse(_essenceCtrl.text.trim()),
|
||||
electricite: double.parse(_electriqueCtrl.text.trim()),
|
||||
);
|
||||
await _service.saveFuelPrices(prices);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Prix mis à jour ✓'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dieselCtrl.dispose();
|
||||
_essenceCtrl.dispose();
|
||||
_electriqueCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildPriceField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String unit,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: color.withValues(alpha: 0.1),
|
||||
child: Icon(icon, color: color),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: unit,
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,3}'))
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Requis';
|
||||
final parsed = double.tryParse(v);
|
||||
if (parsed == null || parsed <= 0) return 'Valeur invalide';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.local_gas_station, color: AppColors.rouge, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Prix des carburants',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ces prix sont utilisés pour calculer automatiquement le coût en carburant des déplacements.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildPriceField(
|
||||
controller: _dieselCtrl,
|
||||
label: 'Prix du Diesel',
|
||||
unit: '€/L',
|
||||
icon: Icons.local_gas_station,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPriceField(
|
||||
controller: _essenceCtrl,
|
||||
label: 'Prix de l\'Essence',
|
||||
unit: '€/L',
|
||||
icon: Icons.local_gas_station,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPriceField(
|
||||
controller: _electriqueCtrl,
|
||||
label: 'Prix de l\'Électricité',
|
||||
unit: '€/kWh',
|
||||
icon: Icons.electric_bolt,
|
||||
color: Colors.amber,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.save_outlined),
|
||||
label: Text(_isSaving ? 'Enregistrement...' : 'Enregistrer les prix'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onPressed: _isSaving ? null : _save,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:em2rp/models/vehicle_model.dart';
|
||||
import 'package:em2rp/services/vehicle_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
|
||||
class VehiclesManagement extends StatefulWidget {
|
||||
const VehiclesManagement({super.key});
|
||||
|
||||
@override
|
||||
State<VehiclesManagement> createState() => _VehiclesManagementState();
|
||||
}
|
||||
|
||||
class _VehiclesManagementState extends State<VehiclesManagement> {
|
||||
final _service = VehicleService();
|
||||
bool _isLoading = false;
|
||||
|
||||
static const _fuelTypes = ['Diesel', 'Essence', 'Electrique'];
|
||||
|
||||
void _showVehicleDialog({VehicleModel? vehicle}) {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final nameCtrl =
|
||||
TextEditingController(text: vehicle?.name ?? '');
|
||||
final consoCtrl = TextEditingController(
|
||||
text: vehicle?.consumptionPer100km.toString() ?? '');
|
||||
final maintCtrl = TextEditingController(
|
||||
text: vehicle?.maintenanceCostPerKm.toString() ?? '');
|
||||
String fuelType = vehicle?.fuelType ?? 'Diesel';
|
||||
int tollCategory = vehicle?.tollCategoryId ?? 2;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(builder: (ctx, setDlg) {
|
||||
return AlertDialog(
|
||||
title: Text(vehicle == null ? 'Ajouter un véhicule' : 'Modifier le véhicule'),
|
||||
content: SizedBox(
|
||||
width: 480,
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Nom
|
||||
TextFormField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nom du véhicule *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.directions_car_outlined),
|
||||
hintText: 'ex: Renault Master',
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? 'Requis' : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Type de carburant
|
||||
DropdownButtonFormField<String>(
|
||||
value: fuelType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Type de carburant *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.local_gas_station_outlined),
|
||||
),
|
||||
items: _fuelTypes
|
||||
.map((f) => DropdownMenuItem(value: f, child: Text(f)))
|
||||
.toList(),
|
||||
onChanged: (v) => setDlg(() => fuelType = v!),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Consommation
|
||||
TextFormField(
|
||||
controller: consoCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: fuelType == 'Electrique'
|
||||
? 'Consommation (kWh/100km) *'
|
||||
: 'Consommation (L/100km) *',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.speed_outlined),
|
||||
),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,2}'))
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Requis';
|
||||
if (double.tryParse(v) == null) return 'Nombre invalide';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Coût maintenance
|
||||
TextFormField(
|
||||
controller: maintCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Coût maintenance (€/km) *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.build_outlined),
|
||||
hintText: 'ex: 0.08',
|
||||
),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,3}'))
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Requis';
|
||||
if (double.tryParse(v) == null) return 'Nombre invalide';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Catégorie péage
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Catégorie de péage : $tollCategory',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: List.generate(5, (i) {
|
||||
final cat = i + 1;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setDlg(() => tollCategory = cat),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: tollCategory == cat
|
||||
? AppColors.rouge
|
||||
: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$cat',
|
||||
style: TextStyle(
|
||||
color: tollCategory == cat
|
||||
? Colors.white
|
||||
: Colors.black87,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Classe 1 – Véhicules légers\n'
|
||||
'Classe 2 – Véhicules intermédiaires\n'
|
||||
'Classe 3 – Poids lourds, autocars et autres véhicules à 2 essieux\n'
|
||||
'Classe 4 - Poids lourds et autres véhicules à 3 essieux et plus\n'
|
||||
'Classe 5 – Motos, side-cars et trikes',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
Navigator.pop(ctx);
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final v = VehicleModel(
|
||||
id: vehicle?.id ?? '',
|
||||
name: nameCtrl.text.trim(),
|
||||
fuelType: fuelType,
|
||||
consumptionPer100km:
|
||||
double.parse(consoCtrl.text.trim()),
|
||||
maintenanceCostPerKm:
|
||||
double.parse(maintCtrl.text.trim()),
|
||||
tollCategoryId: tollCategory,
|
||||
);
|
||||
if (vehicle == null) {
|
||||
await _service.addVehicle(v);
|
||||
} else {
|
||||
await _service.updateVehicle(v);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
child: Text(vehicle == null ? 'Ajouter' : 'Enregistrer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _delete(VehicleModel v) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Supprimer le véhicule'),
|
||||
content: Text('Supprimer "${v.name}" ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annuler')),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red, foregroundColor: Colors.white),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await _service.deleteVehicle(v.id);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Icon _fuelIcon(String fuelType) {
|
||||
switch (fuelType) {
|
||||
case 'Electrique':
|
||||
return const Icon(Icons.electric_bolt, color: Colors.amber);
|
||||
case 'Essence':
|
||||
return const Icon(Icons.local_gas_station, color: Colors.green);
|
||||
default:
|
||||
return const Icon(Icons.local_gas_station, color: Colors.orange);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.directions_car_outlined,
|
||||
color: AppColors.rouge, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Véhicules',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter un véhicule'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => _showVehicleDialog(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Paramétrez la flotte de véhicules pour le calcul automatique des frais de déplacement.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_isLoading) const LinearProgressIndicator(),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<VehicleModel>>(
|
||||
stream: _service.watchVehicles(),
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final vehicles = snap.data ?? [];
|
||||
if (vehicles.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.directions_car_outlined,
|
||||
size: 64, color: Colors.grey[300]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun véhicule',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: Colors.grey[500]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Ajoutez des véhicules pour commencer.'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: vehicles.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, i) {
|
||||
final v = vehicles[i];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.grey[100],
|
||||
child: _fuelIcon(v.fuelType),
|
||||
),
|
||||
title: Text(v.name,
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(
|
||||
'${v.consumptionPer100km} ${v.consumptionUnit} • Maint. ${v.maintenanceCostPerKm} €/km • Classe péage ${v.tollCategoryId}',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Chip(
|
||||
label: Text(v.fuelType),
|
||||
backgroundColor: v.fuelType == 'Electrique'
|
||||
? Colors.amber[50]
|
||||
: v.fuelType == 'Essence'
|
||||
? Colors.green[50]
|
||||
: Colors.orange[50],
|
||||
side: BorderSide.none,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: () => _showVehicleDialog(vehicle: v),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
color: Colors.red),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () => _delete(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,9 @@ class _EquipmentAssociatedEventsSectionState
|
||||
}
|
||||
|
||||
Future<void> _loadEvents() async {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = true);
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer TOUS les événements via l'API
|
||||
@@ -128,12 +130,16 @@ class _EquipmentAssociatedEventsSectionState
|
||||
// Trier par date
|
||||
filteredEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_events = filteredEvents;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -207,7 +213,9 @@ class _EquipmentAssociatedEventsSectionState
|
||||
],
|
||||
),
|
||||
onSelected: (filter) {
|
||||
if (mounted) {
|
||||
setState(() => _selectedFilter = filter);
|
||||
}
|
||||
_loadEvents();
|
||||
},
|
||||
itemBuilder: (context) => EventFilter.values.map((filter) {
|
||||
|
||||
@@ -12,7 +12,7 @@ enum ChecklistStep {
|
||||
}
|
||||
|
||||
/// Widget pour afficher un équipement dans une checklist de préparation/retour
|
||||
class EquipmentChecklistItem extends StatelessWidget {
|
||||
class EquipmentChecklistItem extends StatefulWidget {
|
||||
final EquipmentModel equipment;
|
||||
final EventEquipment eventEquipment;
|
||||
final ChecklistStep step;
|
||||
@@ -34,92 +34,120 @@ class EquipmentChecklistItem extends StatelessWidget {
|
||||
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() {
|
||||
switch (step) {
|
||||
switch (widget.step) {
|
||||
case ChecklistStep.preparation:
|
||||
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
|
||||
return widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
|
||||
case ChecklistStep.loading:
|
||||
return eventEquipment.quantityAtLoading ?? eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
|
||||
return widget.eventEquipment.quantityAtLoading ?? widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
|
||||
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_:
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final hasQuantity = equipment.hasQuantity;
|
||||
final hasQuantity = widget.equipment.hasQuantity;
|
||||
|
||||
// Déterminer la quantité actuelle selon l'étape
|
||||
final int currentQuantity = _getCurrentQuantity();
|
||||
|
||||
return Padding(
|
||||
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,
|
||||
bottom: 4.0,
|
||||
),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: isChild ? 0 : 1, // Pas d'élévation pour les enfants
|
||||
color: wasMissingBefore
|
||||
elevation: widget.isChild ? 0 : 1, // Pas d'élévation pour les enfants
|
||||
color: widget.wasMissingBefore
|
||||
? Colors.orange.shade50
|
||||
: (isChild ? Colors.grey.shade50 : Colors.white),
|
||||
: (widget.isChild ? Colors.grey.shade50 : Colors.white),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: wasMissingBefore
|
||||
color: widget.wasMissingBefore
|
||||
? Colors.orange
|
||||
: (isValidated ? Colors.green : Colors.grey.shade300),
|
||||
width: (isValidated || wasMissingBefore) ? 2 : 1,
|
||||
: (widget.isValidated ? Colors.green : Colors.grey.shade300),
|
||||
width: (widget.isValidated || widget.wasMissingBefore) ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
dense: isChild, // Plus compact pour les enfants
|
||||
dense: widget.isChild, // Plus compact pour les enfants
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: isChild ? 8.0 : 16.0,
|
||||
vertical: isChild ? 4.0 : 8.0,
|
||||
horizontal: widget.isChild ? 8.0 : 16.0,
|
||||
vertical: widget.isChild ? 4.0 : 8.0,
|
||||
),
|
||||
leading: Container(
|
||||
width: isChild ? 32 : 40,
|
||||
height: isChild ? 32 : 40,
|
||||
width: widget.isChild ? 32 : 40,
|
||||
height: widget.isChild ? 32 : 40,
|
||||
decoration: BoxDecoration(
|
||||
color: wasMissingBefore
|
||||
color: widget.wasMissingBefore
|
||||
? Colors.orange.shade100
|
||||
: (isValidated ? Colors.green.shade100 : Colors.grey.shade100),
|
||||
: (widget.isValidated ? Colors.green.shade100 : Colors.grey.shade100),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
wasMissingBefore
|
||||
widget.wasMissingBefore
|
||||
? Icons.warning
|
||||
: (isValidated ? Icons.check_circle : Icons.radio_button_unchecked),
|
||||
color: wasMissingBefore
|
||||
: (widget.isValidated ? Icons.check_circle : Icons.radio_button_unchecked),
|
||||
color: widget.wasMissingBefore
|
||||
? Colors.orange
|
||||
: (isValidated ? Colors.green : Colors.grey),
|
||||
size: isChild ? 18 : 24,
|
||||
: (widget.isValidated ? Colors.green : Colors.grey),
|
||||
size: widget.isChild ? 18 : 24,
|
||||
),
|
||||
onPressed: onToggle,
|
||||
onPressed: widget.onToggle,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
equipment.name,
|
||||
widget.equipment.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isChild ? FontWeight.w500 : FontWeight.w600,
|
||||
fontSize: isChild ? 13 : 15,
|
||||
decoration: isValidated ? TextDecoration.lineThrough : null,
|
||||
color: isValidated ? Colors.grey : null,
|
||||
fontWeight: widget.isChild ? FontWeight.w500 : FontWeight.w600,
|
||||
fontSize: widget.isChild ? 13 : 15,
|
||||
decoration: widget.isValidated ? TextDecoration.lineThrough : null,
|
||||
color: widget.isValidated ? Colors.grey : null,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (equipment.model != null)
|
||||
if (widget.equipment.model != null)
|
||||
Text(
|
||||
equipment.model!,
|
||||
widget.equipment.model!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
@@ -127,12 +155,12 @@ class EquipmentChecklistItem extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Indicateur si manquant à l'étape précédente
|
||||
if (wasMissingBefore)
|
||||
if (widget.wasMissingBefore)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, size: 14, color: Colors.orange),
|
||||
const Icon(Icons.warning_amber, size: 14, color: Colors.orange),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Était manquant à l\'étape précédente',
|
||||
@@ -151,7 +179,7 @@ class EquipmentChecklistItem extends StatelessWidget {
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
const Text(
|
||||
'Quantité : ',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
@@ -159,11 +187,11 @@ class EquipmentChecklistItem extends StatelessWidget {
|
||||
color: AppColors.bleuFonce,
|
||||
),
|
||||
),
|
||||
if (onQuantityChanged != null)
|
||||
if (widget.onQuantityChanged != null)
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: TextFormField(
|
||||
initialValue: currentQuantity.toString(),
|
||||
controller: _quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
@@ -175,14 +203,14 @@ class EquipmentChecklistItem extends StatelessWidget {
|
||||
),
|
||||
onChanged: (value) {
|
||||
final qty = int.tryParse(value) ?? currentQuantity;
|
||||
onQuantityChanged!(qty);
|
||||
widget.onQuantityChanged!(qty);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
currentQuantity.toString(),
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.bleuFonce,
|
||||
@@ -193,16 +221,16 @@ class EquipmentChecklistItem extends StatelessWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: isValidated
|
||||
trailing: widget.isValidated
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
children: [
|
||||
Icon(Icons.check, size: 16, color: Colors.green),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user