diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 8ab61ff..3c5f147 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -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 }); + } +})); diff --git a/em2rp/functions/migrate_equipment_ids.js b/em2rp/functions/migrate_equipment_ids.js new file mode 100644 index 0000000..77210be --- /dev/null +++ b/em2rp/functions/migrate_equipment_ids.js @@ -0,0 +1,93 @@ +/** + * Script de migration pour ajouter le champ 'id' aux équipements qui n'en ont pas + * + * Ce script parcourt tous les documents de la collection 'equipments' et ajoute + * le champ 'id' avec la valeur du document ID si ce champ est manquant. + */ + +const admin = require('firebase-admin'); +const serviceAccount = require('./serviceAccountKey.json'); + +// Initialiser Firebase Admin +admin.initializeApp({ + credential: admin.credential.cert(serviceAccount) +}); + +const db = admin.firestore(); + +async function migrateEquipmentIds() { + 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(); + console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`); + + let missingIdCount = 0; + let updatedCount = 0; + let errorCount = 0; + const batch = db.batch(); + let batchCount = 0; + + for (const doc of equipmentsSnapshot.docs) { + const data = doc.data(); + + // Vérifier si le champ 'id' est manquant ou vide + if (!data.id || data.id === '') { + missingIdCount++; + console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`); + + // Ajouter au batch + batch.update(doc.ref, { id: doc.id }); + batchCount++; + updatedCount++; + + // Exécuter le batch tous les 500 documents (limite Firestore) + if (batchCount === 500) { + await batch.commit(); + console.log(`✅ Batch de ${batchCount} documents mis à jour`); + batchCount = 0; + } + } + } + + // Exécuter le dernier batch s'il reste des documents + if (batchCount > 0) { + await batch.commit(); + console.log(`✅ Batch final de ${batchCount} documents mis à jour`); + } + + 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'); + + if (missingIdCount === 0) { + console.log('✅ Tous les équipements ont déjà un champ id !'); + } else if (updatedCount === missingIdCount) { + console.log('✅ Migration terminée avec succès !'); + } else { + console.log('⚠️ Migration terminée avec des erreurs'); + } + + } catch (error) { + console.error('❌ Erreur lors de la migration:', error); + throw error; + } +} + +// Exécuter la migration +migrateEquipmentIds() + .then(() => { + console.log('\n✅ Script terminé'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script échoué:', error); + process.exit(1); + }); diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index 98f0ebd..c296020 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -99,7 +99,7 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return UpdateChecker( child: MaterialApp( - title: 'EM2 ERP', + title: 'EM2 Hub', theme: ThemeData( primarySwatch: Colors.red, primaryColor: AppColors.noir, diff --git a/em2rp/lib/providers/container_provider.dart b/em2rp/lib/providers/container_provider.dart index bb37747..cd0b403 100644 --- a/em2rp/lib/providers/container_provider.dart +++ b/em2rp/lib/providers/container_provider.dart @@ -1,27 +1,48 @@ import 'package:flutter/foundation.dart'; +import 'dart:async'; import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/services/container_service.dart'; import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/api_service.dart'; +import 'package:em2rp/utils/debug_log.dart'; class ContainerProvider with ChangeNotifier { final ContainerService _containerService = ContainerService(); final DataService _dataService = DataService(FirebaseFunctionsApiService()); + // Timer pour le debouncing de la recherche + Timer? _searchDebounceTimer; + + // Liste paginée pour la page de gestion + List _paginatedContainers = []; + bool _hasMore = true; + bool _isLoadingMore = false; + String? _lastVisible; + + // Cache complet pour compatibilité List _containers = []; + + // Filtres et recherche ContainerType? _selectedType; EquipmentStatus? _selectedStatus; String _searchQuery = ''; bool _isLoading = false; bool _isInitialized = false; - List get containers => _containers; + // Mode de chargement (pagination vs full) + bool _usePagination = false; + + // Getters + List get containers => _usePagination ? _paginatedContainers : _containers; ContainerType? get selectedType => _selectedType; EquipmentStatus? get selectedStatus => _selectedStatus; String get searchQuery => _searchQuery; bool get isLoading => _isLoading; + bool get isLoadingMore => _isLoadingMore; + bool get hasMore => _hasMore; bool get isInitialized => _isInitialized; + bool get usePagination => _usePagination; /// S'assure que les conteneurs sont chargés (charge si nécessaire) Future ensureLoaded() async { @@ -31,19 +52,43 @@ class ContainerProvider with ChangeNotifier { await loadContainers(); } - /// Charger tous les containers via l'API + /// Charger tous les containers via l'API (avec pagination automatique) Future loadContainers() async { _isLoading = true; notifyListeners(); try { - final containers = await _containerService.getContainers( - type: _selectedType, - status: _selectedStatus, - searchQuery: _searchQuery, - ); + _containers.clear(); + String? lastVisible; + bool hasMore = true; + int pageCount = 0; + + // Charger toutes les pages en boucle + while (hasMore) { + pageCount++; + print('[ContainerProvider] Loading page $pageCount...'); + + final result = await _dataService.getContainersPaginated( + limit: 100, // Charger 100 par page pour aller plus vite + startAfter: lastVisible, + sortBy: 'id', + sortOrder: 'asc', + type: _selectedType?.name, + status: _selectedStatus?.name, + searchQuery: _searchQuery, + ); + + final containers = (result['containers'] as List) + .map((data) => ContainerModel.fromMap(data, data['id'] as String)) + .toList(); + + _containers.addAll(containers); + hasMore = result['hasMore'] as bool? ?? false; + lastVisible = result['lastVisible'] as String?; + + print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore'); + } - _containers = containers; _isLoading = false; _isInitialized = true; notifyListeners(); @@ -80,22 +125,144 @@ class ContainerProvider with ChangeNotifier { } /// Définir le type sélectionné - /// Définir le type sélectionné - void setSelectedType(ContainerType? type) { + void setSelectedType(ContainerType? type) async { + if (_selectedType == type) return; _selectedType = type; - notifyListeners(); + if (_usePagination) { + await reload(); + } else { + notifyListeners(); + } } /// Définir le statut sélectionné - void setSelectedStatus(EquipmentStatus? status) { + void setSelectedStatus(EquipmentStatus? status) async { + if (_selectedStatus == status) return; _selectedStatus = status; - notifyListeners(); + if (_usePagination) { + await reload(); + } else { + notifyListeners(); + } } - /// Définir la requête de recherche + /// Définir la requête de recherche (avec debouncing) void setSearchQuery(String query) { + if (_searchQuery == query) return; _searchQuery = query; + + // Annuler le timer précédent + _searchDebounceTimer?.cancel(); + + if (_usePagination) { + // Attendre 500ms avant de recharger (debouncing) + _searchDebounceTimer = Timer(const Duration(milliseconds: 500), () { + reload(); + }); + } else { + notifyListeners(); + } + } + + @override + void dispose() { + _searchDebounceTimer?.cancel(); + super.dispose(); + } + + // ============================================================================ + // PAGINATION - Nouvelles méthodes + // ============================================================================ + + /// Active le mode pagination (pour la page de gestion) + void enablePagination() { + if (!_usePagination) { + _usePagination = true; + DebugLog.info('[ContainerProvider] Pagination mode enabled'); + } + } + + /// Désactive le mode pagination (pour les autres pages) + void disablePagination() { + if (_usePagination) { + _usePagination = false; + DebugLog.info('[ContainerProvider] Pagination mode disabled'); + } + } + + /// Charge la première page (réinitialise tout) + Future loadFirstPage() async { + DebugLog.info('[ContainerProvider] Loading first page...'); + + _paginatedContainers.clear(); + _lastVisible = null; + _hasMore = true; + _isLoading = true; notifyListeners(); + + try { + await loadNextPage(); + _isInitialized = true; + } catch (e) { + DebugLog.error('[ContainerProvider] Error loading first page', e); + _isLoading = false; + notifyListeners(); + rethrow; + } + } + + /// Charge la page suivante (scroll infini) + Future loadNextPage() async { + if (_isLoadingMore || !_hasMore) { + DebugLog.info('[ContainerProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore'); + return; + } + + DebugLog.info('[ContainerProvider] Loading next page... (current: ${_paginatedContainers.length})'); + + _isLoadingMore = true; + _isLoading = true; + notifyListeners(); + + try { + final result = await _dataService.getContainersPaginated( + limit: 20, + startAfter: _lastVisible, + type: _selectedType != null ? containerTypeToString(_selectedType!) : null, + searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null, + sortBy: 'id', + sortOrder: 'asc', + ); + + final newContainers = (result['containers'] as List) + .map((data) { + final map = data as Map; + return ContainerModel.fromMap(map, map['id'] as String); + }) + .toList(); + + _paginatedContainers.addAll(newContainers); + _hasMore = result['hasMore'] as bool? ?? false; + _lastVisible = result['lastVisible'] as String?; + + DebugLog.info('[ContainerProvider] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMore'); + + _isLoadingMore = false; + _isLoading = false; + notifyListeners(); + } catch (e) { + DebugLog.error('[ContainerProvider] Error loading next page', e); + _isLoadingMore = false; + _isLoading = false; + notifyListeners(); + rethrow; + } + } + + /// Recharge en changeant de filtre ou recherche + Future reload() async { + DebugLog.info('[ContainerProvider] Reloading with new filters...'); + await loadFirstPage(); } /// Créer un nouveau container diff --git a/em2rp/lib/providers/container_provider_new.dart b/em2rp/lib/providers/container_provider_new.dart deleted file mode 100644 index ed11a55..0000000 --- a/em2rp/lib/providers/container_provider_new.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:em2rp/models/container_model.dart'; -import 'package:em2rp/services/data_service.dart'; -import 'package:em2rp/services/api_service.dart'; - -import '../models/equipment_model.dart'; - -class ContainerProvider extends ChangeNotifier { - final DataService _dataService = DataService(FirebaseFunctionsApiService()); - - List _containers = []; - ContainerType? _selectedType; - EquipmentStatus? _selectedStatus; - String _searchQuery = ''; - bool _isLoading = false; - - // Getters - List get containers => _filteredContainers; - ContainerType? get selectedType => _selectedType; - EquipmentStatus? get selectedStatus => _selectedStatus; - String get searchQuery => _searchQuery; - bool get isLoading => _isLoading; - - /// Charger tous les conteneurs via l'API - Future loadContainers() async { - _isLoading = true; - notifyListeners(); - - try { - final containersData = await _dataService.getContainers(); - - _containers = containersData.map((data) { - return ContainerModel.fromMap(data, data['id'] as String); - }).toList(); - - _isLoading = false; - notifyListeners(); - } catch (e) { - print('Error loading containers: $e'); - _isLoading = false; - notifyListeners(); - rethrow; - } - } - - /// Obtenir les conteneurs filtrés - List get _filteredContainers { - var filtered = _containers; - - if (_selectedType != null) { - filtered = filtered.where((c) => c.type == _selectedType).toList(); - } - - if (_selectedStatus != null) { - filtered = filtered.where((c) => c.status == _selectedStatus).toList(); - } - - if (_searchQuery.isNotEmpty) { - final query = _searchQuery.toLowerCase(); - filtered = filtered.where((c) { - return c.name.toLowerCase().contains(query) || - c.id.toLowerCase().contains(query); - }).toList(); - } - - return filtered; - } - - /// Définir le filtre de type - void setSelectedType(ContainerType? type) { - _selectedType = type; - notifyListeners(); - } - - /// Définir le filtre de statut - void setSelectedStatus(EquipmentStatus? status) { - _selectedStatus = status; - notifyListeners(); - } - - /// Définir la requête de recherche - void setSearchQuery(String query) { - _searchQuery = query; - notifyListeners(); - } - - /// Réinitialiser tous les filtres - void clearFilters() { - _selectedType = null; - _selectedStatus = null; - _searchQuery = ''; - notifyListeners(); - } - - /// Recharger les conteneurs - Future refresh() async { - await loadContainers(); - } - - /// Obtenir un conteneur par ID - ContainerModel? getById(String id) { - try { - return _containers.firstWhere((c) => c.id == id); - } catch (e) { - return null; - } - } -} - diff --git a/em2rp/lib/providers/equipment_provider.dart b/em2rp/lib/providers/equipment_provider.dart index c4cb556..7b945bf 100644 --- a/em2rp/lib/providers/equipment_provider.dart +++ b/em2rp/lib/providers/equipment_provider.dart @@ -1,29 +1,43 @@ import 'package:flutter/foundation.dart'; +import 'dart:async'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/api_service.dart'; +import 'package:em2rp/utils/debug_log.dart'; class EquipmentProvider extends ChangeNotifier { final DataService _dataService = DataService(FirebaseFunctionsApiService()); + // Timer pour le debouncing de la recherche + Timer? _searchDebounceTimer; + + // Liste paginée pour la page de gestion + List _paginatedEquipment = []; + bool _hasMore = true; + bool _isLoadingMore = false; + String? _lastVisible; + + // Cache complet pour getEquipmentsByIds et compatibilité List _equipment = []; List _models = []; List _brands = []; + // Filtres et recherche EquipmentCategory? _selectedCategory; EquipmentStatus? _selectedStatus; String? _selectedModel; String _searchQuery = ''; bool _isLoading = false; - bool _isInitialized = false; // Flag pour savoir si les équipements ont été chargés + bool _isInitialized = false; + + // Mode de chargement (pagination vs full) + bool _usePagination = false; - // Constructeur - Ne charge PAS automatiquement - // Les équipements seront chargés à la demande (page de gestion ou via getEquipmentsByIds) EquipmentProvider(); // Getters - List get equipment => _filteredEquipment; - List get allEquipment => _equipment; // Tous les équipements sans filtre + List get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment; + List get allEquipment => _equipment; List get models => _models; List get brands => _brands; EquipmentCategory? get selectedCategory => _selectedCategory; @@ -31,42 +45,86 @@ class EquipmentProvider extends ChangeNotifier { String? get selectedModel => _selectedModel; String get searchQuery => _searchQuery; bool get isLoading => _isLoading; + bool get isLoadingMore => _isLoadingMore; + bool get hasMore => _hasMore; bool get isInitialized => _isInitialized; + bool get usePagination => _usePagination; /// S'assure que les équipements sont chargés (charge si nécessaire) Future ensureLoaded() async { - if (_isInitialized || _isLoading) { - print('[EquipmentProvider] Equipment already loaded or loading, skipping...'); + // Si déjà en train de charger, attendre + if (_isLoading) { + print('[EquipmentProvider] Equipment loading in progress, waiting...'); 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) { + print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...'); + return; + } + print('[EquipmentProvider] Equipment not loaded, loading now...'); await loadEquipments(); } - /// Charger tous les équipements via l'API (utilisé par la page de gestion) + /// Charger tous les équipements via l'API (utilisé par les dialogs et sélection) Future loadEquipments() async { - print('[EquipmentProvider] Starting to load equipments...'); + print('[EquipmentProvider] Starting to load ALL equipments...'); _isLoading = true; notifyListeners(); try { - print('[EquipmentProvider] Calling getEquipments API...'); - final equipmentsData = await _dataService.getEquipments(); - print('[EquipmentProvider] Received ${equipmentsData.length} equipments from API'); + _equipment.clear(); + String? lastVisible; + bool hasMore = true; + int pageCount = 0; - _equipment = equipmentsData.map((data) { - return EquipmentModel.fromMap(data, data['id'] as String); - }).toList(); - print('[EquipmentProvider] Mapped ${_equipment.length} equipment models'); + // Charger toutes les pages en boucle + while (hasMore) { + pageCount++; + print('[EquipmentProvider] Loading page $pageCount...'); + + final result = await _dataService.getEquipmentsPaginated( + limit: 100, // Charger 100 par page pour aller plus vite + startAfter: lastVisible, + sortBy: 'id', + sortOrder: 'asc', + ); + + final equipmentsData = result['equipments'] as List; + print('[EquipmentProvider] Page $pageCount: ${equipmentsData.length} equipments'); + + final pageEquipments = equipmentsData.map((data) { + final id = data['id'] as String; + return EquipmentModel.fromMap(data as Map, id); + }).toList(); + + _equipment.addAll(pageEquipments); + + hasMore = result['hasMore'] as bool? ?? false; + lastVisible = result['lastVisible'] as String?; + + if (!hasMore) { + print('[EquipmentProvider] All pages loaded. Total: ${_equipment.length} equipments'); + } + } // Extraire les modèles et marques uniques _extractUniqueValues(); _isInitialized = true; - _isLoading = false; notifyListeners(); - print('[EquipmentProvider] Equipment loading complete'); + print('[EquipmentProvider] Equipment loading complete: ${_equipment.length} equipments'); } catch (e) { print('[EquipmentProvider] Error loading equipments: $e'); _isLoading = false; @@ -118,7 +176,8 @@ class EquipmentProvider extends ChangeNotifier { final equipmentsData = await _dataService.getEquipmentsByIds(missingIds); final loadedEquipments = equipmentsData.map((data) { - return EquipmentModel.fromMap(data, data['id'] as String); + final id = data['id'] as String; // L'ID vient du backend + return EquipmentModel.fromMap(data, id); }).toList(); // Ajouter au cache @@ -185,58 +244,205 @@ class EquipmentProvider extends ChangeNotifier { return filtered; } - /// Définir le filtre de catégorie - void setSelectedCategory(EquipmentCategory? category) { - _selectedCategory = category; + // ============================================================================ + // PAGINATION - Nouvelles méthodes + // ============================================================================ + + /// Active le mode pagination (pour la page de gestion) + void enablePagination() { + if (!_usePagination) { + _usePagination = true; + DebugLog.info('[EquipmentProvider] Pagination mode enabled'); + } + } + + /// Désactive le mode pagination (pour les autres pages) + void disablePagination() { + if (_usePagination) { + _usePagination = false; + DebugLog.info('[EquipmentProvider] Pagination mode disabled'); + } + } + + /// Charge la première page (réinitialise tout) + Future loadFirstPage() async { + DebugLog.info('[EquipmentProvider] Loading first page...'); + + _paginatedEquipment.clear(); + _lastVisible = null; + _hasMore = true; + _isLoading = true; notifyListeners(); + + try { + await loadNextPage(); + _isInitialized = true; + } catch (e) { + DebugLog.error('[EquipmentProvider] Error loading first page', e); + _isLoading = false; + notifyListeners(); + rethrow; + } + } + + /// Charge la page suivante (scroll infini) + Future loadNextPage() async { + if (_isLoadingMore || !_hasMore) { + DebugLog.info('[EquipmentProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore'); + return; + } + + DebugLog.info('[EquipmentProvider] Loading next page... (current: ${_paginatedEquipment.length})'); + + _isLoadingMore = true; + _isLoading = true; + notifyListeners(); + + try { + final result = await _dataService.getEquipmentsPaginated( + limit: 20, + startAfter: _lastVisible, + category: _selectedCategory?.name, + status: _selectedStatus?.name, + searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null, + sortBy: 'id', + sortOrder: 'asc', + ); + + final newEquipments = (result['equipments'] as List) + .map((data) { + final map = data as Map; + final id = map['id'] as String; // L'ID vient du backend dans le JSON + return EquipmentModel.fromMap(map, id); + }) + .toList(); + + _paginatedEquipment.addAll(newEquipments); + _hasMore = result['hasMore'] as bool? ?? false; + _lastVisible = result['lastVisible'] as String?; + + DebugLog.info('[EquipmentProvider] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipment.length}, hasMore: $_hasMore'); + + _isLoadingMore = false; + _isLoading = false; + notifyListeners(); + } catch (e) { + DebugLog.error('[EquipmentProvider] Error loading next page', e); + _isLoadingMore = false; + _isLoading = false; + notifyListeners(); + rethrow; + } + } + + /// Recharge en changeant de filtre ou recherche + Future reload() async { + DebugLog.info('[EquipmentProvider] Reloading with new filters...'); + await loadFirstPage(); + } + + /// Définir le filtre de catégorie + void setSelectedCategory(EquipmentCategory? category) async { + if (_selectedCategory == category) return; + _selectedCategory = category; + if (_usePagination) { + await reload(); + } else { + notifyListeners(); + } } /// Définir le filtre de statut - void setSelectedStatus(EquipmentStatus? status) { + void setSelectedStatus(EquipmentStatus? status) async { + if (_selectedStatus == status) return; _selectedStatus = status; - notifyListeners(); + if (_usePagination) { + await reload(); + } else { + notifyListeners(); + } } /// Définir le filtre de modèle - void setSelectedModel(String? model) { + void setSelectedModel(String? model) async { + if (_selectedModel == model) return; _selectedModel = model; - notifyListeners(); + if (_usePagination) { + await reload(); + } else { + notifyListeners(); + } } - /// Définir la requête de recherche + /// Définir la requête de recherche (avec debouncing) void setSearchQuery(String query) { + if (_searchQuery == query) return; _searchQuery = query; - notifyListeners(); + + // Annuler le timer précédent + _searchDebounceTimer?.cancel(); + + if (_usePagination) { + // Attendre 500ms avant de recharger (debouncing) + _searchDebounceTimer = Timer(const Duration(milliseconds: 500), () { + reload(); + }); + } else { + notifyListeners(); + } + } + + @override + void dispose() { + _searchDebounceTimer?.cancel(); + super.dispose(); } /// Réinitialiser tous les filtres - void clearFilters() { + void clearFilters() async { _selectedCategory = null; _selectedStatus = null; _selectedModel = null; _searchQuery = ''; - notifyListeners(); + if (_usePagination) { + await reload(); + } else { + notifyListeners(); + } } - /// Recharger les équipements + // ============================================================================ + // MÉTHODES COMPATIBILITÉ (pour ancien code) + // ============================================================================ + + /// Recharger les équipements (ancien système) Future refresh() async { - await loadEquipments(); + if (_usePagination) { + await reload(); + } else { + await loadEquipments(); + } } - // === MÉTHODES STREAM (COMPATIBILITÉ) === - /// Stream des équipements (pour compatibilité avec ancien code) Stream> get equipmentStream async* { - yield _equipment; + if (!_isInitialized && !_usePagination) { + await loadEquipments(); + } + yield equipment; } /// Supprimer un équipement Future deleteEquipment(String equipmentId) async { try { await _dataService.deleteEquipment(equipmentId); - await loadEquipments(); // Recharger la liste + if (_usePagination) { + await reload(); + } else { + await loadEquipments(); + } } catch (e) { - print('Error deleting equipment: $e'); + DebugLog.error('[EquipmentProvider] Error deleting equipment', e); rethrow; } } @@ -245,9 +451,13 @@ class EquipmentProvider extends ChangeNotifier { Future addEquipment(EquipmentModel equipment) async { try { await _dataService.createEquipment(equipment.id, equipment.toMap()); - await loadEquipments(); // Recharger la liste + if (_usePagination) { + await reload(); + } else { + await loadEquipments(); + } } catch (e) { - print('Error adding equipment: $e'); + DebugLog.error('[EquipmentProvider] Error adding equipment', e); rethrow; } } @@ -256,52 +466,67 @@ class EquipmentProvider extends ChangeNotifier { Future updateEquipment(EquipmentModel equipment) async { try { await _dataService.updateEquipment(equipment.id, equipment.toMap()); - await loadEquipments(); // Recharger la liste + if (_usePagination) { + await reload(); + } else { + await loadEquipments(); + } } catch (e) { - print('Error updating equipment: $e'); + DebugLog.error('[EquipmentProvider] Error updating equipment', e); rethrow; } } /// Charger les marques Future loadBrands() async { - // Les marques sont déjà chargées avec loadEquipments + await ensureLoaded(); _extractUniqueValues(); } /// Charger les modèles Future loadModels() async { - // Les modèles sont déjà chargés avec loadEquipments + await ensureLoaded(); _extractUniqueValues(); } /// Charger les modèles d'une marque spécifique Future> loadModelsByBrand(String brand) async { - // Filtrer les modèles par marque - final modelsByBrand = _equipment - .where((eq) => eq.brand == brand && eq.model != null) - .map((eq) => eq.model!) + await ensureLoaded(); + return _equipment + .where((eq) => eq.brand?.toLowerCase() == brand.toLowerCase()) + .map((eq) => eq.model ?? '') + .where((model) => model.isNotEmpty) .toSet() - .toList(); - return modelsByBrand; + .toList() + ..sort(); } /// Charger les sous-catégories d'une catégorie spécifique Future> loadSubCategoriesByCategory(EquipmentCategory category) async { - // Filtrer les sous-catégories par catégorie - final subCategoriesByCategory = _equipment - .where((eq) => eq.category == category && eq.subCategory != null && eq.subCategory!.isNotEmpty) - .map((eq) => eq.subCategory!) + await ensureLoaded(); + return _equipment + .where((eq) => eq.category == category) + .map((eq) => eq.subCategory ?? '') + .where((sub) => sub.isNotEmpty) .toSet() .toList() ..sort(); - return subCategoriesByCategory; } - /// Calculer le statut réel d'un équipement (compatibilité) - Future calculateRealStatus(EquipmentModel equipment) async { - // Pour l'instant, retourner le statut stocké - // TODO: Implémenter le calcul réel si nécessaire + /// Calculer le statut réel d'un équipement (pour badge) + EquipmentStatus calculateRealStatus(EquipmentModel equipment) { + // Pour les consommables/câbles, vérifier le seuil critique + if (equipment.category == EquipmentCategory.consumable || + equipment.category == EquipmentCategory.cable) { + final availableQty = equipment.availableQuantity ?? 0; + final criticalThreshold = equipment.criticalThreshold ?? 0; + + if (criticalThreshold > 0 && availableQty <= criticalThreshold) { + return EquipmentStatus.maintenance; // Utiliser maintenance pour indiquer un problème + } + } + + // Sinon retourner le statut de base return equipment.status; } } diff --git a/em2rp/lib/services/api_service.dart b/em2rp/lib/services/api_service.dart index 41a3984..3c2c513 100644 --- a/em2rp/lib/services/api_service.dart +++ b/em2rp/lib/services/api_service.dart @@ -277,6 +277,63 @@ class FirebaseFunctionsApiService implements ApiService { ); } } + + /// Appelle une Cloud Function avec pagination + Future> callPaginated( + String functionName, + Map params, + ) async { + try { + final headers = await _getHeaders(); + final url = Uri.parse('$_baseUrl/$functionName'); + + DebugLog.info('[API] Calling paginated function: $functionName with params: $params'); + + final response = await http.post( + url, + headers: headers, + body: jsonEncode({'data': params}), + ); + + DebugLog.info('[API] Response status: ${response.statusCode}'); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + return data; + } else { + DebugLog.error('[API] Error response: ${response.body}'); + throw Exception('API call failed with status ${response.statusCode}: ${response.body}'); + } + } catch (e) { + DebugLog.error('[API] Exception in callPaginated: $e'); + rethrow; + } + } + + /// Recherche rapide avec autocomplétion + Future>> quickSearch( + String query, { + int limit = 10, + bool includeEquipments = true, + bool includeContainers = true, + }) async { + try { + final params = { + 'query': query, + 'limit': limit, + 'includeEquipments': includeEquipments.toString(), + 'includeContainers': includeContainers.toString(), + }; + + final response = await callPaginated('quickSearch', params); + final results = response['results'] as List? ?? []; + + return results.cast>(); + } catch (e) { + DebugLog.error('[API] Error in quickSearch: $e'); + return []; + } + } } /// Exception personnalisée pour les erreurs API diff --git a/em2rp/lib/services/container_service.dart b/em2rp/lib/services/container_service.dart index 0a3e049..462e2ac 100644 --- a/em2rp/lib/services/container_service.dart +++ b/em2rp/lib/services/container_service.dart @@ -169,7 +169,8 @@ class ContainerService { final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds); for (var data in equipmentsData) { - final equipment = EquipmentModel.fromMap(data, data['id'] as String); + final id = data['id'] as String; + final equipment = EquipmentModel.fromMap(data, id); if (equipment.status != EquipmentStatus.available) { unavailableEquipment.add('${equipment.name} (${equipment.status})'); } @@ -202,7 +203,10 @@ class ContainerService { final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds); return equipmentsData - .map((data) => EquipmentModel.fromMap(data, data['id'] as String)) + .map((data) { + final id = data['id'] as String; + return EquipmentModel.fromMap(data, id); + }) .toList(); } catch (e) { print('Error getting container equipment: $e'); diff --git a/em2rp/lib/services/data_service.dart b/em2rp/lib/services/data_service.dart index 55fe46b..964b3da 100644 --- a/em2rp/lib/services/data_service.dart +++ b/em2rp/lib/services/data_service.dart @@ -1,4 +1,5 @@ import 'package:em2rp/services/api_service.dart'; +import 'package:em2rp/utils/debug_log.dart'; /// Service générique pour les opérations de lecture de données via Cloud Functions class DataService { @@ -300,7 +301,7 @@ class DataService { } } - /// Récupère plusieurs conteneurs par leurs IDs + /// Récupère plusieurs containers par leurs IDs Future>> getContainersByIds(List containerIds) async { try { if (containerIds.isEmpty) return []; @@ -318,37 +319,119 @@ class DataService { return containers.map((e) => e as Map).toList(); } catch (e) { print('[DataService] Error getting containers by IDs: $e'); - throw Exception('Erreur lors de la récupération des conteneurs: $e'); + throw Exception('Erreur lors de la récupération des containers: $e'); } } - /// Récupère les maintenances (optionnellement filtrées par équipement) - Future>> getMaintenances({String? equipmentId}) async { - try { - final data = {}; - if (equipmentId != null) data['equipmentId'] = equipmentId; + // ============================================================================ + // EQUIPMENTS & CONTAINERS - Pagination + // ============================================================================ - final result = await _apiService.call('getMaintenances', data); - final maintenances = result['maintenances'] as List?; - if (maintenances == null) return []; - return maintenances.map((e) => e as Map).toList(); + /// Récupère les équipements avec pagination et filtrage + Future> getEquipmentsPaginated({ + int limit = 20, + String? startAfter, + String? category, + String? status, + String? searchQuery, + String sortBy = 'id', + String sortOrder = 'asc', + }) async { + try { + final params = { + '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?) + ?.map((e) => e as Map) + .toList() ?? [], + 'hasMore': result['hasMore'] as bool? ?? false, + 'lastVisible': result['lastVisible'] as String?, + 'total': result['total'] as int? ?? 0, + }; } catch (e) { - throw Exception('Erreur lors de la récupération des maintenances: $e'); + DebugLog.error('[DataService] Error in getEquipmentsPaginated', e); + throw Exception('Erreur lors de la récupération paginée des équipements: $e'); } } - - /// Récupère les containers contenant un équipement spécifique - Future>> getContainersByEquipment(String equipmentId) async { + /// Récupère les containers avec pagination et filtrage + Future> getContainersPaginated({ + int limit = 20, + String? startAfter, + String? type, + String? status, + String? searchQuery, + String? category, + String sortBy = 'id', + String sortOrder = 'asc', + }) async { try { - final result = await _apiService.call('getContainersByEquipment', { - 'equipmentId': equipmentId, - }); - final containers = result['containers'] as List?; - if (containers == null) return []; - return containers.map((e) => e as Map).toList(); + final params = { + '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?) + ?.map((e) => e as Map) + .toList() ?? [], + 'hasMore': result['hasMore'] as bool? ?? false, + 'lastVisible': result['lastVisible'] as String?, + 'total': result['total'] as int? ?? 0, + }; } catch (e) { - throw Exception('Erreur lors de la récupération des containers pour l\'équipement: $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>> 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('[DataService] Error in quickSearch', e); + return []; } } @@ -454,6 +537,21 @@ class DataService { // MAINTENANCES // ============================================================================ + /// Récupère toutes les maintenances + Future>> getMaintenances({String? equipmentId}) async { + try { + final data = {}; + if (equipmentId != null) data['equipmentId'] = equipmentId; + + final result = await _apiService.call('getMaintenances', data); + final maintenances = result['maintenances'] as List?; + if (maintenances == null) return []; + return maintenances.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la récupération des maintenances: $e'); + } + } + /// Supprime une maintenance Future deleteMaintenance(String maintenanceId) async { try { @@ -463,6 +561,20 @@ class DataService { } } + /// Récupère les containers contenant un équipement + Future>> getContainersByEquipment(String equipmentId) async { + try { + final result = await _apiService.call('getContainersByEquipment', { + 'equipmentId': equipmentId, + }); + final containers = result['containers'] as List?; + if (containers == null) return []; + return containers.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la récupération des containers: $e'); + } + } + // ============================================================================ // USERS // ============================================================================ diff --git a/em2rp/lib/services/equipment_service.dart b/em2rp/lib/services/equipment_service.dart index cbc05f2..b3db6bc 100644 --- a/em2rp/lib/services/equipment_service.dart +++ b/em2rp/lib/services/equipment_service.dart @@ -9,6 +9,34 @@ class EquipmentService { final ApiService _apiService = apiService; final DataService _dataService = DataService(apiService); + // ============================================================================ + // Helper privée - Charge TOUS les équipements avec pagination + // ============================================================================ + + /// Charge tous les équipements en utilisant la pagination + Future>> _getAllEquipmentsPaginated() async { + final allEquipments = >[]; + String? lastVisible; + bool hasMore = true; + + while (hasMore) { + final result = await _dataService.getEquipmentsPaginated( + limit: 100, + startAfter: lastVisible, + sortBy: 'id', + sortOrder: 'asc', + ); + + final equipments = result['equipments'] as List; + allEquipments.addAll(equipments.cast>()); + + hasMore = result['hasMore'] as bool? ?? false; + lastVisible = result['lastVisible'] as String?; + } + + return allEquipments; + } + // ============================================================================ // CRUD Operations - Utilise le backend sécurisé // ============================================================================ @@ -82,10 +110,13 @@ class EquipmentService { String? searchQuery, }) async { try { - final equipmentsData = await _dataService.getEquipments(); + final equipmentsData = await _getAllEquipmentsPaginated(); var equipmentList = equipmentsData - .map((data) => EquipmentModel.fromMap(data, data['id'] as String)) + .map((data) { + final id = data['id'] as String; + return EquipmentModel.fromMap(data, id); + }) .toList(); // Filtres côté client @@ -165,7 +196,11 @@ class EquipmentService { }); final alternatives = (response['alternatives'] as List?) - ?.map((a) => EquipmentModel.fromMap(a as Map, a['id'] as String)) + ?.map((a) { + final map = a as Map; + final id = map['id'] as String; + return EquipmentModel.fromMap(map, id); + }) .toList() ?? []; return alternatives; @@ -204,27 +239,6 @@ class EquipmentService { } } - /// Vérifier les stocks critiques et créer des alertes - Future checkCriticalStock() async { - try { - final equipmentsData = await _dataService.getEquipments(); - - for (var data in equipmentsData) { - final equipment = EquipmentModel.fromMap(data, data['id'] as String); - - // Filtrer uniquement les consommables et câbles - if ((equipment.category == EquipmentCategory.consumable || - equipment.category == EquipmentCategory.cable) && - equipment.isCriticalStock) { - await _createLowStockAlert(equipment); - } - } - } catch (e) { - print('Error checking critical stock: $e'); - rethrow; - } - } - /// Créer une alerte de stock faible Future _createLowStockAlert(EquipmentModel equipment) async { try { @@ -251,50 +265,10 @@ class EquipmentService { return equipmentId; } - /// Récupérer tous les modèles uniques (pour l'indexation/autocomplete) - Future> getAllModels() async { - try { - final equipmentsData = await _dataService.getEquipments(); - final models = {}; - - for (var data in equipmentsData) { - final model = data['model'] as String?; - if (model != null && model.isNotEmpty) { - models.add(model); - } - } - - return models.toList()..sort(); - } catch (e) { - print('Error getting all models: $e'); - rethrow; - } - } - - /// Récupérer toutes les marques uniques (pour l'indexation/autocomplete) - Future> getAllBrands() async { - try { - final equipmentsData = await _dataService.getEquipments(); - final brands = {}; - - for (var data in equipmentsData) { - final brand = data['brand'] as String?; - if (brand != null && brand.isNotEmpty) { - brands.add(brand); - } - } - - return brands.toList()..sort(); - } catch (e) { - print('Error getting all brands: $e'); - rethrow; - } - } - /// Récupérer les modèles filtrés par marque Future> getModelsByBrand(String brand) async { try { - final equipmentsData = await _dataService.getEquipments(); + final equipmentsData = await _getAllEquipmentsPaginated(); final models = {}; for (var data in equipmentsData) { @@ -316,7 +290,7 @@ class EquipmentService { /// Récupérer les sous-catégories filtrées par catégorie Future> getSubCategoriesByCategory(EquipmentCategory category) async { try { - final equipmentsData = await _dataService.getEquipments(); + final equipmentsData = await _getAllEquipmentsPaginated(); final subCategories = {}; final categoryString = equipmentCategoryToString(category); @@ -375,7 +349,10 @@ class EquipmentService { final equipmentsData = await _dataService.getEquipmentsByIds(ids); return equipmentsData - .map((data) => EquipmentModel.fromMap(data, data['id'] as String)) + .map((data) { + final id = data['id'] as String; + return EquipmentModel.fromMap(data, id); + }) .toList(); } catch (e) { print('Error getting equipments by IDs: $e'); diff --git a/em2rp/lib/services/ics_export_service.dart b/em2rp/lib/services/ics_export_service.dart index 3d5a18a..b221dee 100644 --- a/em2rp/lib/services/ics_export_service.dart +++ b/em2rp/lib/services/ics_export_service.dart @@ -231,7 +231,7 @@ END:VCALENDAR'''; // Lien vers l'application buffer.writeln(''); buffer.writeln('---'); - buffer.writeln('Généré par EM2 ERP ${AppVersion.fullVersion} http://app.em2events.fr'); + buffer.writeln('Généré par EM2 Hub ${AppVersion.fullVersion} http://app.em2events.fr'); return buffer.toString(); } diff --git a/em2rp/lib/views/container_management_page.dart b/em2rp/lib/views/container_management_page.dart index a2629a6..8c62a6f 100644 --- a/em2rp/lib/views/container_management_page.dart +++ b/em2rp/lib/views/container_management_page.dart @@ -6,7 +6,6 @@ import 'package:em2rp/views/widgets/nav/main_drawer.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart'; -import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/views/equipment_detail_page.dart'; @@ -14,10 +13,11 @@ import 'package:em2rp/views/widgets/common/qr_code_dialog.dart'; 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/mixins/selection_mode_mixin.dart'; -import 'package:em2rp/views/widgets/management/management_search_bar.dart'; import 'package:em2rp/views/widgets/management/management_card.dart'; import 'package:em2rp/views/widgets/management/management_list.dart'; import 'package:em2rp/utils/debug_log.dart'; +import 'package:em2rp/views/widgets/common/search_actions_bar.dart'; +import 'package:em2rp/views/widgets/notification_badge.dart'; class ContainerManagementPage extends StatefulWidget { const ContainerManagementPage({super.key}); @@ -30,13 +30,61 @@ class ContainerManagementPage extends StatefulWidget { class _ContainerManagementPageState extends State with SelectionModeMixin { final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); ContainerType? _selectedType; - EquipmentStatus? _selectedStatus; - List? _cachedContainers; // Cache pour éviter le rebuild + bool _isLoadingMore = false; // Flag pour éviter les appels multiples + + @override + void initState() { + super.initState(); + + // Activer le mode pagination + final provider = context.read(); + provider.enablePagination(); + + // Ajouter le listener de scroll + _scrollController.addListener(_onScroll); + + // Charger la première page + WidgetsBinding.instance.addPostFrameCallback((_) { + provider.loadFirstPage(); + }); + } + + void _onScroll() { + // Éviter les appels multiples + if (_isLoadingMore) return; + + final provider = context.read(); + + // Charger la page suivante quand on arrive à 300px du bas + if (_scrollController.hasClients && + _scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 300) { + + // Vérifier qu'on peut charger plus + if (provider.hasMore && !provider.isLoadingMore) { + setState(() => _isLoadingMore = true); + + provider.loadNextPage().then((_) { + if (mounted) { + setState(() => _isLoadingMore = false); + } + }).catchError((error) { + if (mounted) { + setState(() => _isLoadingMore = false); + } + }); + } + } + } @override void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); _searchController.dispose(); + context.read().disablePagination(); super.dispose(); } @@ -73,6 +121,7 @@ class _ContainerManagementPageState extends State style: const TextStyle(color: Colors.white), ), actions: [ + const NotificationBadge(), if (hasSelection) ...[ IconButton( icon: const Icon(Icons.qr_code, color: Colors.white), @@ -87,44 +136,14 @@ class _ContainerManagementPageState extends State ], ], ) - : AppBar( - title: const Text('Gestion des Containers'), - backgroundColor: AppColors.rouge, + : CustomAppBar( + title: 'Gestion des Containers', leading: IconButton( icon: const Icon(Icons.arrow_back), tooltip: 'Retour à la gestion des équipements', onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'), ), - actions: [ - IconButton( - icon: const Icon(Icons.logout, color: Colors.white), - onPressed: () async { - final shouldLogout = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Déconnexion'), - content: const Text('Voulez-vous vraiment vous déconnecter ?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Annuler'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Déconnexion'), - ), - ], - ), - ); - if (shouldLogout == true && context.mounted) { - await context.read().signOut(); - if (context.mounted) { - Navigator.pushReplacementNamed(context, '/login'); - } - } - }, - ), - ], + showLogoutButton: true, ), drawer: const MainDrawer(currentPage: '/container_management'), floatingActionButton: !isSelectionMode @@ -174,21 +193,36 @@ class _ContainerManagementPageState extends State } Widget _buildSearchBar() { - return ManagementSearchBar( + return SearchActionsBar( controller: _searchController, hintText: 'Rechercher un container...', onChanged: (value) { context.read().setSearchQuery(value); }, - onSelectionModeToggle: isSelectionMode ? null : toggleSelectionMode, - showSelectionModeButton: !isSelectionMode, - additionalActions: [ - const SizedBox(width: 12), - IconButton( - icon: const Icon(Icons.qr_code_scanner, color: AppColors.rouge), - tooltip: 'Scanner un QR Code', + onClear: () { + _searchController.clear(); + context.read().setSearchQuery(''); + }, + actions: [ + IconButton.filled( onPressed: _scanQRCode, + icon: const Icon(Icons.qr_code_scanner), + tooltip: 'Scanner un QR Code', + style: IconButton.styleFrom( + backgroundColor: Colors.grey[700], + foregroundColor: Colors.white, + ), ), + if (!isSelectionMode) + IconButton.filled( + onPressed: toggleSelectionMode, + icon: const Icon(Icons.checklist), + tooltip: 'Mode sélection', + style: IconButton.styleFrom( + backgroundColor: AppColors.rouge, + foregroundColor: Colors.white, + ), + ), ], ); } @@ -274,30 +308,12 @@ class _ContainerManagementPageState extends State ...ContainerType.values.map((type) { return _buildFilterOption(type, type.label); }), - - const Divider(height: 32), - - // Filtre par statut - Text( - 'Statut', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - color: AppColors.noir, - ), - ), - const SizedBox(height: 8), - _buildStatusFilter(null, 'Tous les statuts'), - _buildStatusFilter(EquipmentStatus.available, 'Disponible'), - _buildStatusFilter(EquipmentStatus.inUse, 'En prestation'), - _buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'), - _buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'), ], ), ); } Widget _buildFilterOption(ContainerType? type, String label) { - final isSelected = _selectedType == type; return RadioListTile( title: Text(label), value: type, @@ -314,36 +330,62 @@ class _ContainerManagementPageState extends State ); } - Widget _buildStatusFilter(EquipmentStatus? status, String label) { - final isSelected = _selectedStatus == status; - return RadioListTile( - title: Text(label), - value: status, - groupValue: _selectedStatus, - activeColor: AppColors.rouge, - dense: true, - contentPadding: EdgeInsets.zero, - onChanged: (value) { - setState(() { - _selectedStatus = value; - context.read().setSelectedStatus(_selectedStatus); - }); - }, - ); - } Widget _buildContainerList() { return Consumer( builder: (context, provider, child) { - return ManagementList( - stream: provider.containersStream, - cachedItems: _cachedContainers, - emptyMessage: 'Aucun container trouvé', - emptyIcon: Icons.inventory_2_outlined, - onDataReceived: (items) { - _cachedContainers = items; + // Afficher l'indicateur de chargement initial + if (provider.isLoading && provider.containers.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + final containers = provider.containers; + + // Afficher le message vide + if (containers.isEmpty && !provider.isLoading) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inventory_2_outlined, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Aucun container trouvé', + style: TextStyle( + fontSize: 18, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } + + // Calculer le nombre total d'items + final itemCount = containers.length + (provider.hasMore ? 1 : 0); + + return ListView.builder( + controller: _scrollController, + itemCount: itemCount, + itemBuilder: (context, index) { + // Dernier élément = indicateur de chargement + if (index == containers.length) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: provider.isLoadingMore + ? const CircularProgressIndicator() + : const SizedBox.shrink(), + ), + ); + } + + return _buildContainerCard(containers[index]); }, - itemBuilder: (container) => _buildContainerCard(container), ); }, ); diff --git a/em2rp/lib/views/equipment_management_page.dart b/em2rp/lib/views/equipment_management_page.dart index 22d368d..f6a3c10 100644 --- a/em2rp/lib/views/equipment_management_page.dart +++ b/em2rp/lib/views/equipment_management_page.dart @@ -18,6 +18,8 @@ import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart'; import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart'; import 'package:em2rp/views/widgets/management/management_list.dart'; +import 'package:em2rp/views/widgets/common/search_actions_bar.dart'; +import 'package:em2rp/views/widgets/notification_badge.dart'; class EquipmentManagementPage extends StatefulWidget { const EquipmentManagementPage({super.key}); @@ -31,23 +33,66 @@ class EquipmentManagementPage extends StatefulWidget { class _EquipmentManagementPageState extends State with SelectionModeMixin { final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); EquipmentCategory? _selectedCategory; List? _cachedEquipment; + bool _isLoadingMore = false; // Flag pour éviter les appels multiples @override void initState() { super.initState(); DebugLog.info('[EquipmentManagementPage] initState called'); - // Charger les équipements au démarrage + + // Activer le mode pagination + final provider = context.read(); + provider.enablePagination(); + + // Ajouter le listener de scroll pour le chargement infini + _scrollController.addListener(_onScroll); + + // Charger la première page au démarrage WidgetsBinding.instance.addPostFrameCallback((_) { - DebugLog.info('[EquipmentManagementPage] Loading equipments...'); - context.read().loadEquipments(); + DebugLog.info('[EquipmentManagementPage] Loading first page...'); + provider.loadFirstPage(); }); } + void _onScroll() { + // Éviter les appels multiples + if (_isLoadingMore) return; + + final provider = context.read(); + + // Charger la page suivante quand on arrive à 300px du bas + if (_scrollController.hasClients && + _scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 300) { + + // Vérifier qu'on peut charger plus + if (provider.hasMore && !provider.isLoadingMore) { + setState(() => _isLoadingMore = true); + + provider.loadNextPage().then((_) { + if (mounted) { + setState(() => _isLoadingMore = false); + } + }).catchError((error) { + if (mounted) { + setState(() => _isLoadingMore = false); + } + DebugLog.error('[EquipmentManagementPage] Error loading next page', error); + }); + } + } + } + @override void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); _searchController.dispose(); + // Désactiver le mode pagination en quittant + context.read().disablePagination(); super.dispose(); } @@ -84,6 +129,7 @@ class _EquipmentManagementPageState extends State style: const TextStyle(color: Colors.white), ), actions: [ + const NotificationBadge(), if (hasSelection) ...[ IconButton( icon: const Icon(Icons.qr_code, color: Colors.white), @@ -100,13 +146,6 @@ class _EquipmentManagementPageState extends State ) : CustomAppBar( title: 'Gestion du matériel', - actions: [ - IconButton( - icon: const Icon(Icons.checklist), - tooltip: 'Mode sélection', - onPressed: toggleSelectionMode, - ), - ], ), drawer: const MainDrawer(currentPage: '/equipment_management'), body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), @@ -130,61 +169,39 @@ class _EquipmentManagementPageState extends State Widget _buildMobileLayout() { return Column( children: [ - // Barre de recherche et bouton boîtes - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Rechercher par nom, modèle ou ID...', - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - context.read().setSearchQuery(''); - }, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onChanged: (value) { - context.read().setSearchQuery(value); - }, - ), + // Barre de recherche et boutons d'action + SearchActionsBar( + controller: _searchController, + hintText: 'Rechercher par nom, modèle ou ID...', + onChanged: (value) { + context.read().setSearchQuery(value); + }, + onClear: () { + _searchController.clear(); + context.read().setSearchQuery(''); + }, + actions: [ + IconButton.filled( + onPressed: _scanQRCode, + icon: const Icon(Icons.qr_code_scanner), + tooltip: 'Scanner un QR Code', + style: IconButton.styleFrom( + backgroundColor: Colors.grey[700], + foregroundColor: Colors.white, ), - const SizedBox(width: 8), - // Bouton Scanner QR - IconButton.filled( - onPressed: _scanQRCode, - icon: const Icon(Icons.qr_code_scanner), - tooltip: 'Scanner un QR Code', - style: IconButton.styleFrom( - backgroundColor: Colors.grey[700], - foregroundColor: Colors.white, - ), + ), + IconButton.filled( + onPressed: () { + Navigator.pushNamed(context, '/container_management'); + }, + icon: const Icon(Icons.inventory_2), + tooltip: 'Gérer les boîtes', + style: IconButton.styleFrom( + backgroundColor: AppColors.rouge, + foregroundColor: Colors.white, ), - const SizedBox(width: 8), - // Bouton Gérer les boîtes - IconButton.filled( - onPressed: () { - Navigator.pushNamed(context, '/container_management'); - }, - icon: const Icon(Icons.inventory_2), - tooltip: 'Gérer les boîtes', - style: IconButton.styleFrom( - backgroundColor: AppColors.rouge, - foregroundColor: Colors.white, - ), - ), - ], - ), + ), + ], ), // Menu horizontal de filtres par catégorie SizedBox( @@ -249,49 +266,7 @@ class _EquipmentManagementPageState extends State ), child: Column( children: [ - // Bouton Gérer les boîtes - Padding( - padding: const EdgeInsets.all(16.0), - child: ElevatedButton.icon( - onPressed: () { - Navigator.pushNamed(context, '/container_management'); - }, - icon: const Icon(Icons.inventory_2, color: Colors.white), - label: const Text( - 'Gérer les boîtes', - style: TextStyle(color: Colors.white), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.rouge, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - minimumSize: const Size(double.infinity, 50), - ), - ), - ), - // Bouton Scanner QR - Padding( - padding: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 16.0), - child: ElevatedButton.icon( - onPressed: _scanQRCode, - icon: const Icon(Icons.qr_code_scanner, color: Colors.white), - label: const Text( - 'Scanner QR Code', - style: TextStyle(color: Colors.white), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[700], - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - minimumSize: const Size(double.infinity, 50), - ), - ), - ), - const Divider(), + const SizedBox(height: 16), // En-tête filtres Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), @@ -312,37 +287,6 @@ class _EquipmentManagementPageState extends State ], ), ), - // Barre de recherche - Padding( - padding: const EdgeInsets.all(16.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Rechercher...', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, size: 20), - onPressed: () { - _searchController.clear(); - context - .read() - .setSearchQuery(''); - }, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - onChanged: (value) { - context.read().setSearchQuery(value); - }, - ), - ), // Filtres par catégorie Padding( padding: @@ -396,7 +340,56 @@ class _EquipmentManagementPageState extends State ), ), // Contenu principal - Expanded(child: _buildEquipmentList()), + Expanded( + child: Column( + children: [ + SearchActionsBar( + controller: _searchController, + hintText: 'Rechercher par nom, modèle ou ID...', + onChanged: (value) { + context.read().setSearchQuery(value); + }, + onClear: () { + _searchController.clear(); + context.read().setSearchQuery(''); + }, + actions: [ + IconButton.filled( + onPressed: _scanQRCode, + icon: const Icon(Icons.qr_code_scanner), + tooltip: 'Scanner un QR Code', + style: IconButton.styleFrom( + backgroundColor: Colors.grey[700], + foregroundColor: Colors.white, + ), + ), + IconButton.filled( + onPressed: () { + Navigator.pushNamed(context, '/container_management'); + }, + icon: const Icon(Icons.inventory_2), + tooltip: 'Gérer les boîtes', + style: IconButton.styleFrom( + backgroundColor: AppColors.rouge, + foregroundColor: Colors.white, + ), + ), + if (!isSelectionMode) + IconButton.filled( + onPressed: toggleSelectionMode, + icon: const Icon(Icons.checklist), + tooltip: 'Mode sélection', + style: IconButton.styleFrom( + backgroundColor: AppColors.rouge, + foregroundColor: Colors.white, + ), + ), + ], + ), + Expanded(child: _buildEquipmentList()), + ], + ), + ), ], ); } @@ -469,8 +462,9 @@ class _EquipmentManagementPageState extends State builder: (context, provider, child) { DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}'); - if (provider.isLoading && _cachedEquipment == null) { - DebugLog.info('[EquipmentManagementPage] Showing loading indicator'); + // Afficher l'indicateur de chargement initial uniquement + if (provider.isLoading && provider.equipment.isEmpty) { + DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator'); return const Center(child: CircularProgressIndicator()); } @@ -501,9 +495,26 @@ class _EquipmentManagementPageState extends State } 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); + return ListView.builder( - itemCount: equipments.length, + controller: _scrollController, + itemCount: itemCount, itemBuilder: (context, index) { + // Dernier élément = indicateur de chargement + if (index == equipments.length) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: provider.isLoadingMore + ? const CircularProgressIndicator() + : const SizedBox.shrink(), + ), + ); + } + return _buildEquipmentCard(equipments[index]); }, ); diff --git a/em2rp/lib/views/widgets/common/search_actions_bar.dart b/em2rp/lib/views/widgets/common/search_actions_bar.dart new file mode 100644 index 0000000..6b7dc58 --- /dev/null +++ b/em2rp/lib/views/widgets/common/search_actions_bar.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class SearchActionsBar extends StatelessWidget { + final TextEditingController controller; + final String hintText; + final ValueChanged onChanged; + final VoidCallback onClear; + final List actions; + + const SearchActionsBar({ + super.key, + required this.controller, + required this.hintText, + required this.onChanged, + required this.onClear, + this.actions = const [], + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: hintText, + prefixIcon: const Icon(Icons.search), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: onClear, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onChanged: onChanged, + ), + ), + if (actions.isNotEmpty) ...[ + const SizedBox(width: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < actions.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + actions[i], + ], + ], + ), + ], + ], + ), + ); + } +} diff --git a/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart b/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart index 65e9da6..c1d8a00 100644 --- a/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart +++ b/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart @@ -49,19 +49,28 @@ class _EquipmentAssociatedEventsSectionState final events = []; - // Récupérer toutes les boîtes pour vérifier leur contenu via l'API - final containersData = await _dataService.getContainers(); + // Collecter tous les IDs de containers utilisés dans les événements + final allContainerIds = {}; + for (var eventData in eventsData) { + final assignedContainers = eventData['assignedContainers'] as List? ?? []; + allContainerIds.addAll(assignedContainers.map((id) => id.toString())); + } + // Charger SEULEMENT les containers utilisés (au lieu de TOUS les charger) final containersWithEquipment = []; - for (var containerData in containersData) { - try { - final equipmentIds = List.from(containerData['equipmentIds'] ?? []); + if (allContainerIds.isNotEmpty) { + final containersData = await _dataService.getContainersByIds(allContainerIds.toList()); - if (equipmentIds.contains(widget.equipment.id)) { - containersWithEquipment.add(containerData['id'] as String); + for (var containerData in containersData) { + try { + final equipmentIds = List.from(containerData['equipmentIds'] ?? []); + + if (equipmentIds.contains(widget.equipment.id)) { + containersWithEquipment.add(containerData['id'] as String); + } + } catch (e) { + DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e); } - } catch (e) { - DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e); } } diff --git a/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart b/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart index 23a6b73..5f31ed5 100644 --- a/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart +++ b/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart @@ -43,19 +43,28 @@ class _EquipmentCurrentEventsSectionState final events = []; - // Récupérer toutes les boîtes pour vérifier leur contenu via l'API - final containersData = await _dataService.getContainers(); + // Collecter tous les IDs de containers utilisés dans les événements + final allContainerIds = {}; + for (var eventData in eventsData) { + final assignedContainers = eventData['assignedContainers'] as List? ?? []; + allContainerIds.addAll(assignedContainers.map((id) => id.toString())); + } + // Charger SEULEMENT les containers utilisés (au lieu de TOUS les charger) final containersWithEquipment = []; - for (var containerData in containersData) { - try { - final equipmentIds = List.from(containerData['equipmentIds'] ?? []); + if (allContainerIds.isNotEmpty) { + final containersData = await _dataService.getContainersByIds(allContainerIds.toList()); - if (equipmentIds.contains(widget.equipment.id)) { - containersWithEquipment.add(containerData['id'] as String); + for (var containerData in containersData) { + try { + final equipmentIds = List.from(containerData['equipmentIds'] ?? []); + + if (equipmentIds.contains(widget.equipment.id)) { + containersWithEquipment.add(containerData['id'] as String); + } + } catch (e) { + DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e); } - } catch (e) { - DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e); } } diff --git a/em2rp/lib/views/widgets/equipment/equipment_status_badge.dart b/em2rp/lib/views/widgets/equipment/equipment_status_badge.dart index 3f5f492..a0b8259 100644 --- a/em2rp/lib/views/widgets/equipment/equipment_status_badge.dart +++ b/em2rp/lib/views/widgets/equipment/equipment_status_badge.dart @@ -16,35 +16,25 @@ class EquipmentStatusBadge extends StatelessWidget { @override Widget build(BuildContext context) { final provider = Provider.of(context, listen: false); + // Calculer le statut réel (synchrone maintenant) + final status = provider.calculateRealStatus(equipment); + // Logs désactivés en production - - return FutureBuilder( - // On calcule le statut réel de manière asynchrone - future: provider.calculateRealStatus(equipment), - // En attendant, on affiche le statut stocké - initialData: equipment.status, - builder: (context, snapshot) { - // Utiliser le statut calculé s'il est disponible, sinon le statut stocké - final status = snapshot.data ?? equipment.status; - // Logs désactivés en production - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: status.color.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: status.color), - ), - child: Text( - status.label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: status.color, - ), - ), - ); - }, + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: status.color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: status.color), + ), + child: Text( + status.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: status.color, + ), + ), ); } } diff --git a/em2rp/lib/views/widgets/equipment/parent_boxes_selector.dart b/em2rp/lib/views/widgets/equipment/parent_boxes_selector.dart deleted file mode 100644 index d139e5c..0000000 --- a/em2rp/lib/views/widgets/equipment/parent_boxes_selector.dart +++ /dev/null @@ -1,435 +0,0 @@ -import 'package:em2rp/utils/debug_log.dart'; -import 'package:flutter/material.dart'; -import 'package:em2rp/models/container_model.dart'; -import 'package:em2rp/utils/colors.dart'; - -/// Widget pour sélectionner les boîtes parentes d'un équipement -class ParentBoxesSelector extends StatefulWidget { - final List availableBoxes; - final List selectedBoxIds; - final Function(List) onSelectionChanged; - - const ParentBoxesSelector({ - super.key, - required this.availableBoxes, - required this.selectedBoxIds, - required this.onSelectionChanged, - }); - - @override - State createState() => _ParentBoxesSelectorState(); -} - -class _ParentBoxesSelectorState extends State { - final TextEditingController _searchController = TextEditingController(); - String _searchQuery = ''; - - @override - void initState() { - super.initState(); - } - - @override - void didUpdateWidget(ParentBoxesSelector oldWidget) { - super.didUpdateWidget(oldWidget); - } - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - List get _filteredBoxes { - if (_searchQuery.isEmpty) { - return widget.availableBoxes; - } - - final query = _searchQuery.toLowerCase(); - return widget.availableBoxes.where((box) { - return box.name.toLowerCase().contains(query) || - box.id.toLowerCase().contains(query) || - box.type.label.toLowerCase().contains(query); - }).toList(); - } - - void _toggleSelection(String boxId) { - final newSelection = List.from(widget.selectedBoxIds); - if (newSelection.contains(boxId)) { - newSelection.remove(boxId); - } else { - newSelection.add(boxId); - } - widget.onSelectionChanged(newSelection); - } - - @override - Widget build(BuildContext context) { - if (widget.availableBoxes.isEmpty && widget.selectedBoxIds.isEmpty) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Icon(Icons.info_outline, color: Colors.grey.shade600), - const SizedBox(width: 12), - const Expanded( - child: Text( - 'Aucune boîte disponible', - style: TextStyle(color: Colors.grey), - ), - ), - ], - ), - ), - ); - } - - final filteredBoxes = _filteredBoxes; - final selectedCount = widget.selectedBoxIds.length; - - // Vérifier s'il y a des boîtes sélectionnées qui ne sont pas dans la liste - final missingBoxIds = widget.selectedBoxIds - .where((id) => !widget.availableBoxes.any((box) => box.id == id)) - .toList(); - - return Card( - elevation: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // En-tête avec titre et compteur - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - const Icon(Icons.inventory_2, color: AppColors.rouge, size: 20), - const SizedBox(width: 8), - const Text( - 'Boîtes parentes', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - if (selectedCount > 0) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: AppColors.rouge.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.rouge.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Text( - '$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppColors.rouge, - ), - ), - ), - ], - ), - ), - const Divider(height: 1), - - // Message d'avertissement si des boîtes sélectionnées sont manquantes - if (missingBoxIds.isNotEmpty) - Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.orange.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.orange.shade300), - ), - child: Row( - children: [ - Icon(Icons.warning_amber, color: Colors.orange.shade700), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Boîtes introuvables', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.orange.shade900, - ), - ), - const SizedBox(height: 4), - Text( - 'Les boîtes suivantes sont sélectionnées mais n\'existent plus : ${missingBoxIds.join(", ")}', - style: TextStyle( - fontSize: 13, - color: Colors.orange.shade800, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () { - // Retirer les boîtes manquantes de la sélection - final newSelection = widget.selectedBoxIds - .where((id) => !missingBoxIds.contains(id)) - .toList(); - widget.onSelectionChanged(newSelection); - }, - tooltip: 'Retirer ces boîtes', - ), - ], - ), - ), - - // Barre de recherche - Padding( - padding: const EdgeInsets.all(16.0), - child: TextField( - controller: _searchController, - onChanged: (value) { - setState(() { - _searchQuery = value; - }); - }, - decoration: InputDecoration( - hintText: 'Rechercher par nom, ID ou type...', - prefixIcon: const Icon(Icons.search, color: AppColors.rouge), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - setState(() { - _searchController.clear(); - _searchQuery = ''; - }); - }, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.rouge, width: 2), - ), - filled: true, - fillColor: Colors.grey.shade50, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - ), - ), - - // Message si aucun résultat - if (filteredBoxes.isEmpty) - Padding( - padding: const EdgeInsets.all(32.0), - child: Center( - child: Column( - children: [ - Icon(Icons.search_off, size: 48, color: Colors.grey.shade400), - const SizedBox(height: 12), - Text( - 'Aucune boîte trouvée', - style: TextStyle( - fontSize: 16, - color: Colors.grey.shade600, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - 'Essayez une autre recherche', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade500, - ), - ), - ], - ), - ), - ) - else - // Liste des boîtes - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - itemCount: filteredBoxes.length, - separatorBuilder: (context, index) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final box = filteredBoxes[index]; - final isSelected = widget.selectedBoxIds.contains(box.id); - if (index == 0) { - DebugLog.info('[ParentBoxesSelector] Building item $index'); - DebugLog.info('[ParentBoxesSelector] Box ID: ${box.id}'); - DebugLog.info('[ParentBoxesSelector] Selected IDs: ${widget.selectedBoxIds}'); - DebugLog.info('[ParentBoxesSelector] Is selected: $isSelected'); - } - return _buildBoxCard(box, isSelected); - }, - ), - - const SizedBox(height: 16), - ], - ), - ); - } - - Widget _buildBoxCard(ContainerModel box, bool isSelected) { - return Card( - elevation: isSelected ? 3 : 1, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: isSelected ? AppColors.rouge : Colors.grey.shade300, - width: isSelected ? 2 : 1, - ), - ), - child: InkWell( - onTap: () => _toggleSelection(box.id), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // Checkbox - Checkbox( - value: isSelected, - onChanged: (value) => _toggleSelection(box.id), - activeColor: AppColors.rouge, - ), - const SizedBox(width: 8), - - // Icône du type de container - CircleAvatar( - backgroundColor: isSelected - ? AppColors.rouge.withValues(alpha: 0.15) - : Colors.grey.shade200, - radius: 24, - child: box.type.getIconForAvatar( - size: 24, - color: isSelected ? AppColors.rouge : Colors.grey.shade700, - ), - ), - const SizedBox(width: 12), - - // Informations de la boîte - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - box.name, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: isSelected ? AppColors.rouge : Colors.black87, - ), - ), - const SizedBox(height: 4), - Text( - box.type.label, - style: TextStyle( - fontSize: 12, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 6), - - // Badges - Wrap( - spacing: 6, - runSpacing: 4, - children: [ - _buildInfoChip( - icon: Icons.inventory, - label: '${box.itemCount} équip.', - color: Colors.blue, - ), - if (box.weight != null) - _buildInfoChip( - icon: Icons.scale, - label: '${box.weight!.toStringAsFixed(1)} kg', - color: Colors.orange, - ), - _buildInfoChip( - icon: Icons.tag, - label: box.id, - color: Colors.grey, - isCompact: true, - ), - ], - ), - ], - ), - ), - - // Indicateur de sélection - if (isSelected) - const Icon( - Icons.check_circle, - color: AppColors.rouge, - size: 24, - ), - ], - ), - ), - ), - ); - } - - Widget _buildInfoChip({ - required IconData icon, - required String label, - required Color color, - bool isCompact = false, - }) { - return Container( - padding: EdgeInsets.symmetric( - horizontal: isCompact ? 6 : 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: color.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: isCompact ? 10 : 12, - color: color.withValues(alpha: 0.8), - ), - const SizedBox(width: 3), - Text( - label, - style: TextStyle( - fontSize: isCompact ? 9 : 11, - fontWeight: FontWeight.w600, - color: color.withValues(alpha: 0.9), - ), - ), - ], - ), - ); - } -} - diff --git a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart index ba55421..e6138e1 100644 --- a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart +++ b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart @@ -1,11 +1,8 @@ import 'package:em2rp/utils/debug_log.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/event_model.dart'; -import 'package:em2rp/providers/equipment_provider.dart'; -import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/services/event_availability_service.dart'; import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/api_service.dart'; @@ -109,93 +106,70 @@ class _EquipmentSelectionDialogState extends State { Map _conflictDetails = {}; // Détails des conflits par ID Map _equipmentQuantities = {}; // Infos de quantités pour câbles/consommables - bool _isLoadingQuantities = false; bool _isLoadingConflicts = false; String _searchQuery = ''; // Nouvelles options d'affichage bool _showConflictingItems = false; // Afficher les équipements/boîtes en conflit - bool _containersExpanded = true; // Section "Boîtes" dépliée - bool _equipmentExpanded = true; // Section "Tous les équipements" dépliée + + // NOUVEAU : Lazy loading et pagination + SelectionType _displayType = SelectionType.equipment; // Type affiché (équipements OU containers) + bool _isLoadingMore = false; + bool _hasMoreEquipments = true; + bool _hasMoreContainers = true; + String? _lastEquipmentId; + String? _lastContainerId; + List _paginatedEquipments = []; + List _paginatedContainers = []; // Cache pour éviter les rebuilds inutiles List _cachedContainers = []; List _cachedEquipment = []; - bool _initialDataLoaded = false; @override void initState() { super.initState(); + // Ajouter le listener de scroll pour lazy loading + _scrollController.addListener(_onScroll); + // Charger immédiatement les données de manière asynchrone _initializeData(); } + /// Gestion du scroll pour lazy loading + void _onScroll() { + if (_isLoadingMore) return; + + if (_scrollController.hasClients && + _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) { + // Charger la page suivante selon le type affiché + if (_displayType == SelectionType.equipment && _hasMoreEquipments) { + _loadNextEquipmentPage(); + } else if (_displayType == SelectionType.container && _hasMoreContainers) { + _loadNextContainerPage(); + } + } + } + /// Initialise toutes les données nécessaires Future _initializeData() async { try { - // 1. S'assurer que les équipements et conteneurs sont chargés - await _ensureEquipmentsLoaded(); + // 1. Charger les conflits (batch optimisé) + await _loadEquipmentConflicts(); - // 2. Mettre à jour le cache immédiatement après le chargement - if (mounted) { - final equipmentProvider = context.read(); - final containerProvider = context.read(); - - setState(() { - // Utiliser allEquipment pour avoir TOUS les équipements sans filtres - _cachedEquipment = equipmentProvider.allEquipment; - _cachedContainers = containerProvider.containers; - _initialDataLoaded = true; - }); - - DebugLog.info('[EquipmentSelectionDialog] Cache updated: ${_cachedEquipment.length} equipment(s), ${_cachedContainers.length} container(s)'); - } - - // 3. Initialiser la sélection avec le matériel déjà assigné + // 2. Initialiser la sélection avec le matériel déjà assigné await _initializeAlreadyAssigned(); - // 4. Charger les quantités et conflits en parallèle - await Future.wait([ - _loadAvailableQuantities(), - _loadEquipmentConflicts(), - ]); - } catch (e) { - DebugLog.error('[EquipmentSelectionDialog] Error during initialization', e); - } - } - - /// S'assure que les équipements sont chargés avant d'utiliser le dialog - Future _ensureEquipmentsLoaded() async { - final equipmentProvider = context.read(); - final containerProvider = context.read(); - - DebugLog.info('[EquipmentSelectionDialog] Starting equipment loading...'); - - // Forcer le chargement et attendre qu'il soit terminé - await equipmentProvider.ensureLoaded(); - - // Attendre que le chargement soit vraiment terminé - while (equipmentProvider.isLoading) { - await Future.delayed(const Duration(milliseconds: 100)); - } - - // Vérifier qu'on a bien des équipements chargés - if (equipmentProvider.allEquipment.isEmpty) { - DebugLog.warning('[EquipmentSelectionDialog] No equipment loaded after ensureLoaded!'); - } - - // Charger aussi les conteneurs si nécessaire - if (containerProvider.containers.isEmpty) { - await containerProvider.loadContainers(); - - // Attendre que le chargement des conteneurs soit terminé - while (containerProvider.isLoading) { - await Future.delayed(const Duration(milliseconds: 100)); + // 3. Charger la première page selon le type sélectionné + if (_displayType == SelectionType.equipment) { + await _loadNextEquipmentPage(); + } else { + await _loadNextContainerPage(); } + } catch (e) { + DebugLog.error('[EquipmentSelectionDialog] Error initializing data', e); } - - DebugLog.info('[EquipmentSelectionDialog] Data loaded: ${equipmentProvider.allEquipment.length} equipment(s), ${containerProvider.containers.length} container(s)'); } /// Initialise la sélection avec le matériel déjà assigné @@ -215,15 +189,15 @@ class _EquipmentSelectionDialogState extends State { // Ajouter les conteneurs déjà assignés if (widget.alreadyAssignedContainers.isNotEmpty) { try { - final containerProvider = context.read(); - final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers; - + // Pour les conteneurs déjà assignés, on va les chercher via l'API si nécessaire + // ou créer des conteneurs temporaires for (var containerId in widget.alreadyAssignedContainers) { - final container = containers.firstWhere( + // Chercher dans le cache ou créer un conteneur temporaire + final container = _cachedContainers.firstWhere( (c) => c.id == containerId, orElse: () => ContainerModel( id: containerId, - name: 'Inconnu', + name: 'Conteneur $containerId', type: ContainerType.flightCase, status: EquipmentStatus.available, equipmentIds: [], @@ -267,6 +241,152 @@ class _EquipmentSelectionDialogState extends State { DebugLog.info('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items'); } } + + /// Charge la page suivante d'équipements (lazy loading) + Future _loadNextEquipmentPage() async { + if (_isLoadingMore || !_hasMoreEquipments) return; + + setState(() => _isLoadingMore = true); + + try { + final result = await _dataService.getEquipmentsPaginated( + limit: 25, + startAfter: _lastEquipmentId, + searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null, + category: _selectedCategory != null ? equipmentCategoryToString(_selectedCategory!) : null, + sortBy: 'id', + sortOrder: 'asc', + ); + + final newEquipments = (result['equipments'] as List) + .map((data) { + final map = data as Map; + final id = map['id'] as String; + return EquipmentModel.fromMap(map, id); + }) + .toList(); + + if (mounted) { + setState(() { + _paginatedEquipments.addAll(newEquipments); + _hasMoreEquipments = result['hasMore'] as bool? ?? false; + _lastEquipmentId = result['lastVisible'] as String?; + _isLoadingMore = false; + }); + + DebugLog.info('[EquipmentSelectionDialog] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipments.length}, hasMore: $_hasMoreEquipments'); + + // Charger les quantités pour les consommables/câbles de cette page + await _loadAvailableQuantities(newEquipments); + } + } catch (e) { + DebugLog.error('[EquipmentSelectionDialog] Error loading equipment page', e); + if (mounted) { + setState(() => _isLoadingMore = false); + } + } + } + + /// Charge la page suivante de containers (lazy loading) + Future _loadNextContainerPage() async { + if (_isLoadingMore || !_hasMoreContainers) return; + + setState(() => _isLoadingMore = true); + + try { + final result = await _dataService.getContainersPaginated( + limit: 25, + startAfter: _lastContainerId, + searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null, + category: _selectedCategory?.name, // Filtre par catégorie d'équipements + sortBy: 'id', + sortOrder: 'asc', + ); + + final containersData = result['containers'] as List; + + DebugLog.info('[EquipmentSelectionDialog] Raw containers data received: ${containersData.length} containers'); + + // D'abord, extraire TOUS les équipements + final List allEquipmentsToCache = []; + for (var data in containersData) { + final map = data as Map; + final containerId = map['id'] as String; + + // Debug: vérifier si le champ 'equipment' existe + final hasEquipmentField = map.containsKey('equipment'); + final equipmentData = map['equipment']; + DebugLog.info('[EquipmentSelectionDialog] Container $containerId: hasEquipmentField=$hasEquipmentField, equipmentData type=${equipmentData?.runtimeType}, count=${equipmentData is List ? equipmentData.length : 0}'); + + final equipmentList = (map['equipment'] as List?) + ?.map((eqData) { + final eqMap = eqData as Map; + final eqId = eqMap['id'] as String; + DebugLog.info('[EquipmentSelectionDialog] - Equipment found: $eqId'); + return EquipmentModel.fromMap(eqMap, eqId); + }) + .toList() ?? []; + allEquipmentsToCache.addAll(equipmentList); + } + + DebugLog.info('[EquipmentSelectionDialog] Total equipments extracted from containers: ${allEquipmentsToCache.length}'); + + // Créer les containers + final newContainers = containersData + .map((data) { + final map = data as Map; + final id = map['id'] as String; + return ContainerModel.fromMap(map, id); + }) + .toList(); + + if (mounted) { + setState(() { + // Ajouter tous les équipements au cache DANS le setState + for (var eq in allEquipmentsToCache) { + if (!_cachedEquipment.any((e) => e.id == eq.id)) { + _cachedEquipment.add(eq); + } + } + + _paginatedContainers.addAll(newContainers); + _hasMoreContainers = result['hasMore'] as bool? ?? false; + _lastContainerId = result['lastVisible'] as String?; + _isLoadingMore = false; + }); + + DebugLog.info('[EquipmentSelectionDialog] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMoreContainers'); + DebugLog.info('[EquipmentSelectionDialog] Cached ${allEquipmentsToCache.length} equipment(s) from containers, total cache: ${_cachedEquipment.length}'); + + // Mettre à jour les statuts de conflit pour les nouveaux containers + await _updateContainerConflictStatus(); + } + } catch (e) { + DebugLog.error('[EquipmentSelectionDialog] Error loading container page', e); + if (mounted) { + setState(() => _isLoadingMore = false); + } + } + } + + /// Recharge depuis le début (appelé lors d'un changement de filtre/recherche) + Future _reloadData() async { + setState(() { + _paginatedEquipments.clear(); + _paginatedContainers.clear(); + _lastEquipmentId = null; + _lastContainerId = null; + _hasMoreEquipments = true; + _hasMoreContainers = true; + }); + + if (_displayType == SelectionType.equipment) { + await _loadNextEquipmentPage(); + } else { + await _loadNextContainerPage(); + } + } + @override void dispose() { _searchController.dispose(); @@ -275,34 +395,29 @@ class _EquipmentSelectionDialogState extends State { super.dispose(); } - /// Charge les quantités disponibles pour tous les consommables/câbles - Future _loadAvailableQuantities() async { + /// Charge les quantités disponibles pour les consommables/câbles d'une liste d'équipements + Future _loadAvailableQuantities(List equipments) async { if (!mounted) return; - setState(() => _isLoadingQuantities = true); try { - final equipmentProvider = context.read(); - - // Utiliser directement allEquipment du provider (déjà chargé) - final equipment = equipmentProvider.allEquipment; - - final consumables = equipment.where((eq) => + final consumables = equipments.where((eq) => eq.category == EquipmentCategory.consumable || eq.category == EquipmentCategory.cable); for (var eq in consumables) { - final available = await _availabilityService.getAvailableQuantity( - equipment: eq, - startDate: widget.startDate, - endDate: widget.endDate, - excludeEventId: widget.excludeEventId, - ); - _availableQuantities[eq.id] = available; + // Ne recharger que si on n'a pas déjà la quantité + if (!_availableQuantities.containsKey(eq.id)) { + final available = await _availabilityService.getAvailableQuantity( + equipment: eq, + startDate: widget.startDate, + endDate: widget.endDate, + excludeEventId: widget.excludeEventId, + ); + _availableQuantities[eq.id] = available; + } } } catch (e) { DebugLog.error('Error loading quantities', e); - } finally { - if (mounted) setState(() => _isLoadingQuantities = false); } } @@ -351,6 +466,52 @@ class _EquipmentSelectionDialogState extends State { _conflictingContainerIds = conflictingContainerIds; _conflictDetails = conflictDetails; _equipmentQuantities = equipmentQuantities; + + // Convertir conflictDetails en equipmentConflicts pour l'affichage détaillé + _equipmentConflicts.clear(); + conflictDetails.forEach((itemId, conflicts) { + final conflictList = (conflicts as List).map((conflict) { + final conflictMap = conflict as Map; + + // Créer un EventModel minimal pour le conflit + final conflictEvent = EventModel( + id: conflictMap['eventId'] as String, + name: conflictMap['eventName'] as String, + description: '', + startDateTime: DateTime.parse(conflictMap['startDate'] as String), + endDateTime: DateTime.parse(conflictMap['endDate'] as String), + basePrice: 0.0, + installationTime: 0, + disassemblyTime: 0, + eventTypeId: '', + customerId: '', + address: '', + latitude: 0.0, + longitude: 0.0, + workforce: const [], + documents: const [], + options: const [], + status: EventStatus.confirmed, + assignedEquipment: const [], + assignedContainers: const [], + ); + + // Calculer les jours de chevauchement + final conflictStart = DateTime.parse(conflictMap['startDate'] as String); + final conflictEnd = DateTime.parse(conflictMap['endDate'] as String); + final overlapStart = widget.startDate.isAfter(conflictStart) ? widget.startDate : conflictStart; + final overlapEnd = widget.endDate.isBefore(conflictEnd) ? widget.endDate : conflictEnd; + final overlapDays = overlapEnd.difference(overlapStart).inDays + 1; + + return AvailabilityConflict( + equipmentId: itemId, + equipmentName: '', // Sera résolu lors de l'affichage + conflictingEvent: conflictEvent, + overlapDays: overlapDays.clamp(1, 999), + ); + }).toList(); + _equipmentConflicts[itemId] = conflictList; + }); }); } @@ -366,15 +527,11 @@ class _EquipmentSelectionDialogState extends State { /// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit Future _updateContainerConflictStatus() async { - if (!mounted) return; // Vérifier si le widget est toujours monté + if (!mounted) return; try { - final containerProvider = context.read(); - final containers = await containerProvider.containersStream.first; - - if (!mounted) return; // Vérifier à nouveau après l'async - - for (var container in containers) { + // Utiliser les containers paginés chargés + for (var container in _paginatedContainers) { // Vérifier si le conteneur lui-même est en conflit if (_conflictingContainerIds.contains(container.id)) { _containerConflicts[container.id] = ContainerConflictInfo( @@ -406,6 +563,11 @@ class _EquipmentSelectionDialogState extends State { } DebugLog.info('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}'); + + // Déclencher un rebuild pour afficher les changements visuels + if (mounted) { + setState(() {}); + } } catch (e) { DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e); } @@ -639,26 +801,11 @@ class _EquipmentSelectionDialogState extends State { } /// Recherche les conteneurs recommandés pour un équipement + /// NOTE: Désactivé avec le lazy loading - on ne charge pas tous les containers d'un coup Future _findRecommendedContainers(String equipmentId) async { - try { - final containerProvider = context.read(); - - // Récupérer les conteneurs depuis le stream - final containerStream = containerProvider.containersStream; - final containers = await containerStream.first; - - final recommended = containers - .where((container) => container.equipmentIds.contains(equipmentId)) - .toList(); - - if (recommended.isNotEmpty) { - setState(() { - _recommendedContainers[equipmentId] = recommended; - }); - } - } catch (e) { - DebugLog.error('Error finding recommended containers', e); - } + // Désactivé pour le moment avec le lazy loading + // On pourrait implémenter une API dédiée si nécessaire + return; } /// Obtenir les boîtes parentes d'un équipement de manière synchrone depuis le cache @@ -733,14 +880,8 @@ class _EquipmentSelectionDialogState extends State { /// Sélectionner tous les enfants d'un conteneur Future _selectContainerChildren(String containerId) async { try { - final containerProvider = context.read(); - final equipmentProvider = context.read(); - - // Utiliser le cache si disponible - final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers; - final equipment = _cachedEquipment.isNotEmpty ? _cachedEquipment : equipmentProvider.allEquipment; - - final container = containers.firstWhere( + // Chercher le container dans les données paginées ou le cache + final container = [..._paginatedContainers, ..._cachedContainers].firstWhere( (c) => c.id == containerId, orElse: () => ContainerModel( id: containerId, @@ -759,7 +900,8 @@ class _EquipmentSelectionDialogState extends State { // Sélectionner chaque enfant (sans bloquer, car ils sont "composés") for (var equipmentId in container.equipmentIds) { if (!_selectedItems.containsKey(equipmentId)) { - final eq = equipment.firstWhere( + // Chercher l'équipement dans les données paginées ou le cache + final eq = [..._paginatedEquipments, ..._cachedEquipment].firstWhere( (e) => e.id == equipmentId, orElse: () => EquipmentModel( id: equipmentId, @@ -794,12 +936,8 @@ class _EquipmentSelectionDialogState extends State { /// Désélectionner tous les enfants d'un conteneur Future _deselectContainerChildren(String containerId) async { try { - final containerProvider = context.read(); - - // Utiliser le cache si disponible - final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers; - - final container = containers.firstWhere( + // Chercher le container dans les données paginées ou le cache + final container = [..._paginatedContainers, ..._cachedContainers].firstWhere( (c) => c.id == containerId, orElse: () => ContainerModel( id: containerId, @@ -1027,6 +1165,8 @@ class _EquipmentSelectionDialogState extends State { ), onChanged: (value) { setState(() => _searchQuery = value.toLowerCase()); + // Recharger depuis le début avec le nouveau filtre + _reloadData(); }, ), @@ -1078,6 +1218,52 @@ class _EquipmentSelectionDialogState extends State { ), ], ), + + const SizedBox(height: 12), + + // Chip pour switcher entre Équipements et Containers + Row( + children: [ + const Text( + 'Afficher :', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(width: 8), + ChoiceChip( + label: const Text('Équipements'), + selected: _displayType == SelectionType.equipment, + onSelected: (selected) { + if (selected && _displayType != SelectionType.equipment) { + setState(() { + _displayType = SelectionType.equipment; + }); + _reloadData(); + } + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: _displayType == SelectionType.equipment ? Colors.white : Colors.black87, + ), + ), + const SizedBox(width: 8), + ChoiceChip( + label: const Text('Containers'), + selected: _displayType == SelectionType.container, + onSelected: (selected) { + if (selected && _displayType != SelectionType.container) { + setState(() { + _displayType = SelectionType.container; + }); + _reloadData(); + } + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: _displayType == SelectionType.container ? Colors.white : Colors.black87, + ), + ), + ], + ), ], ), ); @@ -1093,6 +1279,8 @@ class _EquipmentSelectionDialogState extends State { setState(() { _selectedCategory = selected ? category : null; }); + // Recharger depuis le début avec le nouveau filtre + _reloadData(); }, selectedColor: AppColors.rouge, checkmarkColor: Colors.white, @@ -1104,7 +1292,7 @@ class _EquipmentSelectionDialogState extends State { Widget _buildMainList() { // Afficher un indicateur de chargement si les données sont en cours de chargement - if (_isLoadingQuantities || _isLoadingConflicts) { + if (_isLoadingConflicts) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -1112,9 +1300,7 @@ class _EquipmentSelectionDialogState extends State { const CircularProgressIndicator(color: AppColors.rouge), const SizedBox(height: 16), Text( - _isLoadingConflicts - ? 'Vérification de la disponibilité...' - : 'Chargement des quantités disponibles...', + 'Vérification de la disponibilité...', style: TextStyle(color: Colors.grey.shade600), ), ], @@ -1128,150 +1314,105 @@ class _EquipmentSelectionDialogState extends State { /// Vue hiérarchique unique avec cache pour éviter les rebuilds inutiles Widget _buildHierarchicalList() { - return Consumer2( - builder: (context, containerProvider, equipmentProvider, child) { - // Utiliser les données du cache si disponibles, sinon utiliser allEquipment des providers - final allContainers = _initialDataLoaded ? _cachedContainers : containerProvider.containers; - final allEquipment = _initialDataLoaded ? _cachedEquipment : equipmentProvider.allEquipment; + return ValueListenableBuilder( + valueListenable: _selectionChangeNotifier, + builder: (context, _, __) { + // Filtrer les données paginées selon le type affiché + List itemWidgets = []; - // Utiliser ValueListenableBuilder pour rebuild uniquement sur changement de sélection - return ValueListenableBuilder( - valueListenable: _selectionChangeNotifier, - builder: (context, _, __) { - // Filtrage des boîtes - final filteredContainers = allContainers.where((container) { - // Filtre par conflit (masquer si non cochée et en conflit) - if (!_showConflictingItems && _conflictingContainerIds.contains(container.id)) { - return false; - } + if (_displayType == SelectionType.equipment) { + // Filtrer côté client pour "Afficher équipements déjà utilisés" + final filteredEquipments = _paginatedEquipments.where((eq) { + if (!_showConflictingItems && _conflictingEquipmentIds.contains(eq.id)) { + return false; + } + return true; + }).toList(); - // Filtre par catégorie : afficher uniquement les boîtes contenant au moins 1 équipement de la catégorie - if (_selectedCategory != null) { - final hasEquipmentOfCategory = container.equipmentIds.any((eqId) { - final equipment = allEquipment.firstWhere( - (e) => e.id == eqId, - orElse: () => EquipmentModel( - id: '', - name: '', - category: EquipmentCategory.other, - status: EquipmentStatus.available, - maintenanceIds: [], - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), - ); - return equipment.id.isNotEmpty && equipment.category == _selectedCategory; - }); + itemWidgets = filteredEquipments.map((equipment) { + return _buildEquipmentCard(equipment, key: ValueKey('equipment_${equipment.id}')); + }).toList(); + } else { + // Containers + final filteredContainers = _paginatedContainers.where((container) { + if (!_showConflictingItems) { + // Vérifier si le container lui-même est en conflit + if (_conflictingContainerIds.contains(container.id)) { + return false; + } - if (!hasEquipmentOfCategory) { - return false; - } - } + // Vérifier si le container a des équipements enfants en conflit + final hasConflictingChildren = container.equipmentIds.any( + (eqId) => _conflictingEquipmentIds.contains(eqId), + ); - // Filtre par recherche - if (_searchQuery.isNotEmpty) { - final searchLower = _searchQuery.toLowerCase(); - return container.id.toLowerCase().contains(searchLower) || - container.name.toLowerCase().contains(searchLower); - } + if (hasConflictingChildren) { + return false; + } + } + return true; + }).toList(); - return true; - }).toList(); + itemWidgets = filteredContainers.map((container) { + return _buildContainerCard(container, key: ValueKey('container_${container.id}')); + }).toList(); + } - // Filtrage des équipements (TOUS, pas seulement les orphelins) - final filteredEquipment = allEquipment.where((eq) { - // Filtre par conflit (masquer si non cochée et en conflit) - if (!_showConflictingItems && _conflictingEquipmentIds.contains(eq.id)) { - return false; - } + return ListView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + children: [ + // Header + _buildSectionHeader( + _displayType == SelectionType.equipment ? 'Équipements' : 'Containers', + _displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory, + itemWidgets.length, + ), + const SizedBox(height: 12), - // Filtre par catégorie - if (_selectedCategory != null && eq.category != _selectedCategory) { - return false; - } + // Items + ...itemWidgets, - // Filtre par recherche - if (_searchQuery.isNotEmpty) { - final searchLower = _searchQuery.toLowerCase(); - return eq.id.toLowerCase().contains(searchLower) || - (eq.brand?.toLowerCase().contains(searchLower) ?? false) || - (eq.model?.toLowerCase().contains(searchLower) ?? false); - } + // Indicateur de chargement en bas + if (_isLoadingMore) + const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: CircularProgressIndicator(color: AppColors.rouge), + ), + ), - return true; - }).toList(); + // Message si fin de liste + if (!_isLoadingMore && !(_displayType == SelectionType.equipment ? _hasMoreEquipments : _hasMoreContainers)) + Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: Text( + 'Fin de la liste', + style: TextStyle(color: Colors.grey.shade600, fontSize: 14), + ), + ), + ), - return ListView( - controller: _scrollController, // Préserve la position de scroll - padding: const EdgeInsets.all(16), - cacheExtent: 1000, // Cache plus d'items pour éviter les rebuilds lors du scroll - children: [ - // SECTION 1 : BOÎTES - if (filteredContainers.isNotEmpty) ...[ - _buildCollapsibleSectionHeader( - 'Boîtes', - Icons.inventory, - filteredContainers.length, - _containersExpanded, - (expanded) { - setState(() { - _containersExpanded = expanded; - }); - }, + // Message si rien trouvé + if (itemWidgets.isEmpty && !_isLoadingMore) + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon(Icons.search_off, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Aucun résultat trouvé', + style: TextStyle(fontSize: 16, color: Colors.grey.shade600), ), - const SizedBox(height: 12), - if (_containersExpanded) ...[ - ...filteredContainers.map((container) => _buildContainerCard( - container, - key: ValueKey('container_${container.id}'), - )), - const SizedBox(height: 24), - ], ], - - // SECTION 2 : TOUS LES ÉQUIPEMENTS - if (filteredEquipment.isNotEmpty) ...[ - _buildCollapsibleSectionHeader( - 'Tous les équipements', - Icons.inventory_2, - filteredEquipment.length, - _equipmentExpanded, - (expanded) { - setState(() { - _equipmentExpanded = expanded; - }); - }, - ), - const SizedBox(height: 12), - if (_equipmentExpanded) ...[ - ...filteredEquipment.map((equipment) => _buildEquipmentCard( - equipment, - key: ValueKey('equipment_${equipment.id}'), - )), - ], - ], - - // Message si rien n'est trouvé - if (filteredContainers.isEmpty && filteredEquipment.isEmpty) - Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - children: [ - Icon(Icons.search_off, size: 64, color: Colors.grey.shade400), - const SizedBox(height: 16), - Text( - 'Aucun résultat trouvé', - style: TextStyle(fontSize: 16, color: Colors.grey.shade600), - ), - ], - ), - ), - ), - ], - ); - }, - ); // Fin du ValueListenableBuilder + ), + ), + ), + ], + ); }, ); } @@ -1873,10 +2014,10 @@ class _EquipmentSelectionDialogState extends State { const SizedBox(width: 4), Text( '${container.itemCount} équipement(s)', - style: TextStyle( + style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, - color: Colors.blue.shade700, + color: Colors.blue, ), ), if (hasConflict) ...[ @@ -1965,68 +2106,65 @@ class _EquipmentSelectionDialogState extends State { /// Widget pour afficher les équipements enfants d'un conteneur Widget _buildContainerChildren(ContainerModel container, ContainerConflictInfo? conflictInfo) { - return Consumer( - builder: (context, provider, child) { - return StreamBuilder>( - stream: provider.equipmentStream, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ); - } + // Utiliser les équipements paginés et le cache + final allEquipment = [..._paginatedEquipments, ..._cachedEquipment]; + final childEquipments = allEquipment + .where((eq) => container.equipmentIds.contains(eq.id)) + .toList(); - final allEquipment = snapshot.data ?? []; - final childEquipments = allEquipment - .where((eq) => container.equipmentIds.contains(eq.id)) - .toList(); + DebugLog.info('[EquipmentSelectionDialog] Building container children for ${container.id}: ${container.equipmentIds.length} IDs, found ${childEquipments.length} equipment(s) in cache (total cache: ${_cachedEquipment.length})'); - if (childEquipments.isEmpty) { - return Container( - decoration: BoxDecoration( - color: Colors.grey.shade50, - border: Border(top: BorderSide(color: Colors.grey.shade300)), + if (container.equipmentIds.isNotEmpty && childEquipments.isEmpty) { + DebugLog.error('[EquipmentSelectionDialog] Container ${container.id} has ${container.equipmentIds.length} equipment IDs but found 0 equipment in cache!'); + DebugLog.info('[EquipmentSelectionDialog] Looking for IDs: ${container.equipmentIds.take(5).join(", ")}...'); + DebugLog.info('[EquipmentSelectionDialog] Cache contains IDs: ${_cachedEquipment.take(5).map((e) => e.id).join(", ")}...'); + } + + if (childEquipments.isEmpty) { + return Container( + decoration: BoxDecoration( + color: Colors.grey.shade50, + border: Border(top: BorderSide(color: Colors.grey.shade300)), + ), + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.info_outline, size: 16, color: Colors.grey.shade600), + const SizedBox(width: 8), + Text( + 'Aucun équipement dans ce conteneur', + style: TextStyle(color: Colors.grey.shade600, fontSize: 13), + ), + ], + ), + ); + } + + return Container( + decoration: BoxDecoration( + color: Colors.grey.shade50, + border: Border(top: BorderSide(color: Colors.grey.shade300)), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.list, size: 16, color: Colors.grey.shade700), + const SizedBox(width: 6), + Text( + 'Contenu de la boîte :', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + fontWeight: FontWeight.bold, ), - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.info_outline, size: 16, color: Colors.grey.shade600), - const SizedBox(width: 8), - Text( - 'Aucun équipement dans ce conteneur', - style: TextStyle(color: Colors.grey.shade600, fontSize: 13), - ), - ], - ), - ); - } - - return Container( - decoration: BoxDecoration( - color: Colors.grey.shade50, - border: Border(top: BorderSide(color: Colors.grey.shade300)), ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.list, size: 16, color: Colors.grey.shade700), - const SizedBox(width: 6), - Text( - 'Contenu de la boîte :', - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade700, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 12), - ...childEquipments.map((eq) { + ], + ), + const SizedBox(height: 12), + ...childEquipments.map((eq) { final hasConflict = _equipmentConflicts.containsKey(eq.id); final conflicts = _equipmentConflicts[eq.id] ?? []; @@ -2115,10 +2253,6 @@ class _EquipmentSelectionDialogState extends State { ], ), ); - }, - ); - }, - ); } Widget _buildSelectionPanel() { diff --git a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart index 3b1b698..edfa206 100644 --- a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart +++ b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart @@ -156,212 +156,9 @@ class _EventAssignedEquipmentSectionState extends State(); - final equipmentProvider = context.read(); + // ✅ Pas de vérification de conflits : déjà fait dans le pop-up + // On enregistre directement la sélection - final allContainers = await containerProvider.containersStream.first; - final allEquipment = await equipmentProvider.equipmentStream.first; - - DebugLog.info('[EventAssignedEquipmentSection] Starting conflict checks...'); - final allConflicts = >{}; - - // 1. Vérifier les conflits pour les équipements directs - DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newEquipment.length} equipment(s)'); - for (var eq in newEquipment) { - DebugLog.info('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}'); - - final equipment = allEquipment.firstWhere( - (e) => e.id == eq.equipmentId, - orElse: () => EquipmentModel( - id: eq.equipmentId, - name: 'Inconnu', - category: EquipmentCategory.other, - status: EquipmentStatus.available, - maintenanceIds: [], - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), - ); - - DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: hasQuantity=${equipment.hasQuantity}'); - - // Pour les équipements quantifiables (consommables/câbles) - if (equipment.hasQuantity) { - // Vérifier la quantité disponible - final availableQty = await _availabilityService.getAvailableQuantity( - equipment: equipment, - startDate: widget.startDate!, - endDate: widget.endDate!, - excludeEventId: widget.eventId, - ); - - // ⚠️ Ne créer un conflit QUE si la quantité demandée est supérieure à la quantité disponible - if (eq.quantity > availableQty) { - // Il y a vraiment un conflit de quantité - final conflicts = await _availabilityService.checkEquipmentAvailabilityWithQuantity( - equipment: equipment, - requestedQuantity: eq.quantity, - startDate: widget.startDate!, - endDate: widget.endDate!, - excludeEventId: widget.eventId, - ); - - // Ne garder que les conflits réels (quand il n'y a pas assez de stock) - if (conflicts.isNotEmpty) { - allConflicts[eq.equipmentId] = conflicts; - } - } - // ✅ Sinon, pas de conflit : il y a assez de stock disponible - } else { - // Pour les équipements non quantifiables (vérification classique) - final conflicts = await _availabilityService.checkEquipmentAvailability( - equipmentId: equipment.id, - equipmentName: equipment.name, - startDate: widget.startDate!, - endDate: widget.endDate!, - excludeEventId: widget.eventId, - ); - - if (conflicts.isNotEmpty) { - DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: ${conflicts.length} conflict(s) found'); - allConflicts[eq.equipmentId] = conflicts; - } else { - DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: no conflicts'); - } - } - } - - // 2. Vérifier les conflits pour les boîtes et leur contenu - DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newContainers.length} container(s)'); - for (var containerId in newContainers) { - final container = allContainers.firstWhere( - (c) => c.id == containerId, - orElse: () => ContainerModel( - id: containerId, - name: 'Inconnu', - type: ContainerType.flightCase, - status: EquipmentStatus.available, - equipmentIds: [], - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), - ); - - // Récupérer les équipements de la boîte - final containerEquipment = container.equipmentIds - .map((eqId) => allEquipment.firstWhere( - (e) => e.id == eqId, - orElse: () => EquipmentModel( - id: eqId, - name: 'Inconnu', - category: EquipmentCategory.other, - status: EquipmentStatus.available, - maintenanceIds: [], - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), - )) - .toList(); - - // Vérifier chaque équipement de la boîte individuellement - final containerConflicts = []; - - for (var equipment in containerEquipment) { - if (equipment.hasQuantity) { - // Pour les consommables/câbles, vérifier la quantité disponible - final availableQty = await _availabilityService.getAvailableQuantity( - equipment: equipment, - startDate: widget.startDate!, - endDate: widget.endDate!, - excludeEventId: widget.eventId, - ); - - // La boîte contient 1 unité de cet équipement - // Si la quantité disponible est insuffisante, créer un conflit - if (availableQty < 1) { - final conflicts = await _availabilityService.checkEquipmentAvailability( - equipmentId: equipment.id, - equipmentName: equipment.name, - startDate: widget.startDate!, - endDate: widget.endDate!, - excludeEventId: widget.eventId, - ); - containerConflicts.addAll(conflicts); - } - } else { - // Pour les équipements non quantifiables - final conflicts = await _availabilityService.checkEquipmentAvailability( - equipmentId: equipment.id, - equipmentName: equipment.name, - startDate: widget.startDate!, - endDate: widget.endDate!, - excludeEventId: widget.eventId, - ); - containerConflicts.addAll(conflicts); - } - } - - if (containerConflicts.isNotEmpty) { - DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: ${containerConflicts.length} conflict(s) found'); - allConflicts[containerId] = containerConflicts; - } else { - DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: no conflicts'); - } - } - - DebugLog.info('[EventAssignedEquipmentSection] Total conflicts found: ${allConflicts.length}'); - - if (allConflicts.isNotEmpty) { - DebugLog.info('[EventAssignedEquipmentSection] Showing conflict dialog with ${allConflicts.length} items in conflict'); - // Afficher le dialog de conflits - final action = await showDialog( - context: context, - builder: (context) => EquipmentConflictDialog(conflicts: allConflicts), - ); - - DebugLog.info('[EventAssignedEquipmentSection] Conflict dialog result: $action'); - - if (action == 'cancel') { - return; // Annuler l'ajout - } else if (action == 'force_removed') { - // Identifier quels équipements/conteneurs retirer - final removedIds = allConflicts.keys.toSet(); - - // Retirer les équipements directs en conflit - newEquipment.removeWhere((eq) => removedIds.contains(eq.equipmentId)); - - // Retirer les boîtes en conflit - newContainers.removeWhere((containerId) => removedIds.contains(containerId)); - - // Informer l'utilisateur des boîtes retirées - for (var containerId in removedIds.where((id) => newContainers.contains(id))) { - if (mounted) { - final container = allContainers.firstWhere( - (c) => c.id == containerId, - orElse: () => ContainerModel( - id: containerId, - name: 'Inconnu', - type: ContainerType.flightCase, - status: EquipmentStatus.available, - equipmentIds: [], - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('La boîte "${container.name}" a été retirée en raison de conflits.'), - backgroundColor: Colors.orange, - duration: const Duration(seconds: 3), - ), - ); - } - } - } - // Si 'force_all', on garde tout - } - // Fusionner avec l'existant final updatedEquipment = [...widget.assignedEquipment]; final updatedContainers = [...widget.assignedContainers]; @@ -398,7 +195,7 @@ class _EventAssignedEquipmentSectionState extends State? actions; final bool showLogoutButton; + final Widget? leading; const CustomAppBar({ super.key, required this.title, this.actions, this.showLogoutButton = true, + this.leading, }); @override @@ -30,6 +32,7 @@ class _CustomAppBarState extends State { return AppBar( title: Text(widget.title), backgroundColor: AppColors.rouge, + leading: widget.leading, actions: [ NotificationBadge(), if (widget.showLogoutButton)