refactor: Passage à la pagination côté serveur pour les équipements et containers

Cette mise à jour refactorise en profondeur le chargement des données pour les équipements et les containers, en remplaçant la récupération complète de la collection par un système de pagination côté serveur. Ce changement améliore considérablement les performances, réduit la consommation de mémoire et accélère le temps de chargement initial, en particulier pour les larges inventaires.

**Changements Backend (Cloud Functions) :**

-   **Nouveaux Endpoints Paginés :**
    -   `getEquipmentsPaginated` et `getContainersPaginated` ont été créés pour remplacer les anciens `getEquipments` et `getContainers`.
    -   Ces nouvelles fonctions supportent le filtrage (catégorie, statut, type), la recherche textuelle et le tri directement côté serveur, limitant la quantité de données transférées.
    -   La pagination est gérée via les paramètres `limit` et `startAfter`, assurant un chargement par lots efficace.
-   **Optimisation de `getContainersPaginated` :**
    -   Peuple désormais les containers avec leurs équipements enfants via une requête `in` optimisée, réduisant le nombre de lectures Firestore.
-   **Suppression des Anciens Endpoints :** Les fonctions `getEquipments` et `getContainers`, qui chargeaient l'intégralité des collections, ont été supprimées.
-   **Nouveau Script de Migration :** Ajout d'un script (`migrate_equipment_ids.js`) pour s'assurer que chaque équipement dans Firestore possède un champ `id` correspondant à son ID de document, ce qui est crucial pour le tri et la pagination.

**Changements Frontend (Flutter) :**

-   **`EquipmentProvider` et `ContainerProvider` :**
    -   La logique de chargement a été entièrement réécrite pour utiliser les nouveaux endpoints paginés.
    -   Introduction d'un mode `usePagination` pour basculer entre le chargement paginé (pour les pages de gestion) et le chargement complet (pour les dialogues de sélection).
    -   Implémentation de `loadFirstPage` et `loadNextPage` pour gérer le scroll infini.
    -   Ajout d'un "debouncing" sur la recherche pour éviter les appels API excessifs lors de la saisie.
-   **Pages de Gestion (`EquipmentManagementPage`, `ContainerManagementPage`) :**
    -   Utilisent désormais un `ScrollController` pour déclencher `loadNextPage` et implémenter un scroll infini.
    -   Le chargement initial et les rechargements (après filtre) sont beaucoup plus rapides.
    -   Refonte de l'UI avec un nouveau widget `SearchActionsBar` pour uniformiser la barre de recherche et les actions.
-   **Dialogue de Sélection d'Équipement (`EquipmentSelectionDialog`) :**
    -   Passe également à un système de lazy loading basé sur des `ChoiceChip` pour afficher soit les équipements, soit les containers.
    -   Charge les pages de manière asynchrone au fur et à mesure du scroll, améliorant drastiquement la réactivité du dialogue.
    -   La logique de chargement des données a été fiabilisée pour attendre la disponibilité des données avant l'affichage.
-   **Optimisations diverses :**
    -   Les sections qui listent les événements associés à un équipement (`EquipmentCurrentEventsSection`, etc.) chargent désormais uniquement les containers pertinents via `getContainersByIds` au lieu de toute la collection.
    -   Le calcul du statut d'un équipement (`EquipmentStatusBadge`) est maintenant synchrone, simplifiant le code et évitant des `FutureBuilder`.

**Correction mineure :**

-   **Nom de l'application :** Le nom de l'application a été mis à jour de "EM2 ERP" à "EM2 Hub" dans `main.dart` et dans les exports ICS.
This commit is contained in:
ElPoyo
2026-01-18 12:40:23 +01:00
parent b79791ff7a
commit a182f1b922
21 changed files with 2069 additions and 1588 deletions

View File

@@ -1725,76 +1725,6 @@ exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
}
}));
// ============================================================================
// EQUIPMENTS - Read with permissions
// ============================================================================
exports.getEquipments = onRequest(httpOptions, withCors(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;
}
const snapshot = await db.collection('equipments').get();
const equipments = snapshot.docs.map(doc => {
const data = doc.data();
// Masquer les prix si l'utilisateur n'a pas manage_equipment
if (!canManage) {
delete data.purchasePrice;
delete data.rentalPrice;
}
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
};
});
res.status(200).json({ equipments });
} catch (error) {
logger.error("Error fetching equipments:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// CONTAINERS - Read with permissions
// ============================================================================
exports.getContainers = onRequest(httpOptions, withCors(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;
}
const snapshot = await db.collection('containers').get();
const containers = snapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['createdAt', 'updatedAt'])
};
});
res.status(200).json({ containers });
} catch (error) {
logger.error("Error fetching containers:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// MAINTENANCES - Read with permissions
// ============================================================================
@@ -3555,4 +3485,408 @@ exports.onAlertCreated = onDocumentCreated('alerts/{alertId}', async (event) =>
// const {onAlertCreated} = require('./onAlertCreated');
// exports.onAlertCreated = onAlertCreated;
// ============================================================================
// EQUIPMENTS - Pagination et filtrage avancé
// ============================================================================
/**
* Récupère les équipements avec pagination et filtrage côté serveur
*
* Paramètres de requête supportés:
* - limit: nombre d'éléments par page (défaut: 20, max: 100)
* - startAfter: ID du dernier élément de la page précédente (pour pagination)
* - category: filtre par catégorie
* - status: filtre par statut
* - searchQuery: recherche textuelle (nom, ID, modèle, marque)
* - sortBy: champ de tri (défaut: 'id')
* - sortOrder: 'asc' ou 'desc' (défaut: 'asc')
*/
exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(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;
// Convertir en majuscules pour correspondre au format Firestore
const category = params.category ? params.category.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null;
const searchQuery = params.searchQuery?.toLowerCase() || 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}`);
// Construire la requête Firestore
let query = db.collection('equipments');
// Si recherche textuelle, on augmente la limite pour filtrer ensuite
const queryLimit = searchQuery ? Math.min(limit * 10, 200) : limit;
// Appliquer les filtres
if (category) {
query = query.where('category', '==', category);
}
if (status) {
query = query.where('status', '==', status);
}
// Tri : Utiliser FieldPath.documentId() pour trier par l'UID du document
// Cela garantit que TOUS les documents sont inclus, même sans champ 'id'
if (sortBy === 'id') {
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
} else {
query = query.orderBy(sortBy, sortOrder);
}
// Pagination
if (startAfterId) {
const startAfterDoc = await db.collection('equipments').doc(startAfterId).get();
if (startAfterDoc.exists) {
query = query.startAfter(startAfterDoc);
}
}
// Limiter les résultats
query = query.limit(queryLimit + 1);
const snapshot = await query.get();
// Déterminer hasMore basé sur le nombre de documents Firestore
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > queryLimit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
let equipments = docsToProcess.map(doc => {
const data = doc.data();
// Masquer les prix si l'utilisateur n'a pas manage_equipment
if (!canManage) {
delete data.purchasePrice;
delete data.rentalPrice;
}
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
};
});
// Filtrage textuel côté serveur
if (searchQuery) {
equipments = equipments.filter(eq => {
const searchableText = [
eq.name || '',
eq.id || '',
eq.model || '',
eq.brand || '',
eq.subCategory || ''
].join(' ').toLowerCase();
return searchableText.includes(searchQuery);
});
}
// Pour la limite finale après filtrage textuel
const limitedEquipments = equipments.slice(0, limit);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
// hasMore reste basé sur le nombre de docs Firestore, pas sur le filtrage textuel
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments (filtered from ${equipments.length}), hasMore=${hasMoreDocs}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreDocs,
lastVisible,
total: limitedEquipments.length
});
} catch (error) {
logger.error("Error fetching paginated equipments:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// CONTAINERS - Pagination et filtrage avancé
// ============================================================================
/**
* Récupère les containers avec pagination et filtrage côté serveur
*
* Paramètres similaires à getEquipmentsPaginated
*/
exports.getContainersPaginated = onRequest(httpOptions, withCors(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;
// Convertir en majuscules pour correspondre au format Firestore
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; // Filtre par catégorie d'équipements
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}`);
// Construire la requête Firestore
let query = db.collection('containers');
// Si recherche textuelle ou filtre par catégorie, on augmente la limite pour filtrer ensuite
const queryLimit = (searchQuery || category) ? Math.min(limit * 10, 200) : limit;
// Appliquer les filtres sur les containers
if (type) {
query = query.where('type', '==', type);
}
if (status) {
query = query.where('status', '==', status);
}
// Tri : Utiliser FieldPath.documentId() pour trier par l'UID du document
if (sortBy === 'id') {
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
} else {
query = query.orderBy(sortBy, sortOrder);
}
// Pagination
if (startAfterId) {
const startAfterDoc = await db.collection('containers').doc(startAfterId).get();
if (startAfterDoc.exists) {
query = query.startAfter(startAfterDoc);
}
}
// Limiter les résultats
query = query.limit(queryLimit + 1);
const snapshot = await query.get();
// Déterminer hasMore basé sur le nombre de documents Firestore
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'])
};
});
// Récupérer tous les équipements liés aux containers (pour population ET filtrage)
const allEquipmentIds = new Set();
containers.forEach(c => {
if (c.equipmentIds && Array.isArray(c.equipmentIds)) {
c.equipmentIds.forEach(id => allEquipmentIds.add(id));
}
});
// Charger les équipements en batch (max 30 par requête Firestore)
const equipmentMap = new Map();
if (allEquipmentIds.size > 0) {
const equipmentIdArray = Array.from(allEquipmentIds);
const batchSize = 30; // Limite Firestore pour les requêtes 'in'
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)
});
});
}
}
// Peupler les containers avec leurs équipements
containers = containers.map(container => ({
...container,
equipment: (container.equipmentIds || [])
.map(eqId => equipmentMap.get(eqId))
.filter(eq => eq !== undefined) // Retirer les équipements non trouvés
}));
// Filtrage par catégorie d'équipements
if (category) {
containers = containers.filter(c => {
// Garder le container s'il contient au moins un équipement de la catégorie demandée
return c.equipment.some(eq => eq.category === category);
});
}
// Filtrage textuel côté serveur
if (searchQuery) {
containers = containers.filter(c => {
const searchableText = [
c.name || '',
c.id || '',
...(c.equipment || []).map(eq => eq.name || '')
].join(' ').toLowerCase();
return searchableText.includes(searchQuery);
});
}
// Pour la limite finale après filtrage
const limitedContainers = containers.slice(0, limit);
const lastVisible = limitedContainers.length > 0 ? limitedContainers[limitedContainers.length - 1].id : null;
// Log pour debugging
const totalEquipmentCount = limitedContainers.reduce((sum, c) => sum + (c.equipment?.length || 0), 0);
logger.info(`[getContainersPaginated] Returning ${limitedContainers.length} containers with ${totalEquipmentCount} total equipment(s)`);
// Log détaillé pour chaque container
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 });
}
}));
// ============================================================================
// SEARCH - Recherche unifiée avec autocomplétion
// ============================================================================
/**
* Recherche rapide d'équipements et containers pour l'autocomplétion
* Retourne un nombre limité de résultats pour des performances optimales
*/
exports.quickSearch = onRequest(httpOptions, withCors(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) // Récupérer plus pour filtrer ensuite
.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
});
}
});
}
// Limiter et trier les résultats
const limitedResults = results
.sort((a, b) => {
// Prioriser les correspondances exactes au début
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 });
}
}));