From 4545bdba817078d32f793554e327f8f877423251 Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Tue, 13 Jan 2026 18:50:46 +0100 Subject: [PATCH] =?UTF-8?q?Fix=20:=20=C3=A9cran=20d'ajout=20de=20materiel,?= =?UTF-8?q?=20correction=20des=20conflits=20pour=20les=20cables=20et=20con?= =?UTF-8?q?sommables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- em2rp/functions/index.js | 54 +- .../event/equipment_selection_dialog.dart | 487 +++++++++++++----- 2 files changed, 403 insertions(+), 138 deletions(-) diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 0c11caa..02944a5 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -2021,10 +2021,23 @@ exports.getConflictingEquipmentIds = onRequest(httpOptions, withCors(async (req, logger.info(`Found ${eventsSnapshot.docs.length} events to check`); + // Récupérer tous les équipements pour savoir lesquels sont quantifiables + const equipmentsSnapshot = await db.collection('equipments').get(); + const equipmentsInfo = {}; + equipmentsSnapshot.docs.forEach(doc => { + const data = doc.data(); + equipmentsInfo[doc.id] = { + category: data.category, + totalQuantity: data.totalQuantity || 0, + hasQuantity: data.category === 'CABLE' || data.category === 'CONSUMABLE' + }; + }); + // Maps pour stocker les conflits const conflictingEquipmentIds = new Set(); const conflictingContainerIds = new Set(); - const conflictDetails = {}; // { equipmentId/containerId: [{ eventId, eventName, startDate, endDate }] } + const conflictDetails = {}; // { equipmentId/containerId: [{ eventId, eventName, startDate, endDate, quantity }] } + const equipmentQuantities = {}; // { equipmentId: { totalQuantity, reservedQuantity, availableQuantity, reservations: [...] } } for (const eventDoc of eventsSnapshot.docs) { // Exclure l'événement en cours d'édition @@ -2078,12 +2091,46 @@ exports.getConflictingEquipmentIds = onRequest(httpOptions, withCors(async (req, // Ajouter les équipements directement assignés for (const eq of assignedEquipment) { const equipmentId = eq.equipmentId; - conflictingEquipmentIds.add(equipmentId); + const quantity = eq.quantity || 1; + const equipInfo = equipmentsInfo[equipmentId]; + + // Pour les équipements quantifiables, on ne les marque pas forcément comme "en conflit" + // On calcule juste les quantités réservées + if (equipInfo && equipInfo.hasQuantity) { + // Initialiser les infos de quantité si nécessaire + if (!equipmentQuantities[equipmentId]) { + equipmentQuantities[equipmentId] = { + totalQuantity: equipInfo.totalQuantity, + reservedQuantity: 0, + availableQuantity: equipInfo.totalQuantity, + reservations: [] + }; + } + + // Ajouter la réservation + equipmentQuantities[equipmentId].reservedQuantity += quantity; + equipmentQuantities[equipmentId].availableQuantity = equipInfo.totalQuantity - equipmentQuantities[equipmentId].reservedQuantity; + equipmentQuantities[equipmentId].reservations.push({ + ...conflictInfo, + quantity: quantity + }); + + // Ne marquer comme "en conflit" que si la quantité totale est épuisée + if (equipmentQuantities[equipmentId].availableQuantity <= 0) { + conflictingEquipmentIds.add(equipmentId); + } + } else { + // Pour les équipements non quantifiables, comportement classique + conflictingEquipmentIds.add(equipmentId); + } if (!conflictDetails[equipmentId]) { conflictDetails[equipmentId] = []; } - conflictDetails[equipmentId].push(conflictInfo); + conflictDetails[equipmentId].push({ + ...conflictInfo, + quantity: quantity + }); } // Ajouter les conteneurs assignés @@ -2125,6 +2172,7 @@ exports.getConflictingEquipmentIds = onRequest(httpOptions, withCors(async (req, conflictingEquipmentIds: Array.from(conflictingEquipmentIds), conflictingContainerIds: Array.from(conflictingContainerIds), conflictDetails: conflictDetails, + equipmentQuantities: equipmentQuantities, // NOUVEAU : Informations sur les quantités }); } catch (error) { logger.error("Error getting conflicting equipment IDs:", error); diff --git a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart index f3ebec6..78d0dea 100644 --- a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart +++ b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart @@ -6,6 +6,8 @@ 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'; import 'package:em2rp/utils/colors.dart'; /// Type de sélection dans le dialog @@ -87,16 +89,23 @@ class EquipmentSelectionDialog extends StatefulWidget { class _EquipmentSelectionDialogState extends State { final TextEditingController _searchController = TextEditingController(); final EventAvailabilityService _availabilityService = EventAvailabilityService(); + final DataService _dataService = DataService(apiService); EquipmentCategory? _selectedCategory; Map _selectedItems = {}; Map _availableQuantities = {}; // Pour consommables Map> _recommendedContainers = {}; // Recommandations - Map> _equipmentConflicts = {}; // Conflits de disponibilité + Map> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés) Map _containerConflicts = {}; // Conflits des conteneurs Set _expandedContainers = {}; // Conteneurs dépliés dans la liste + // NOUVEAU : IDs en conflit récupérés en batch + Set _conflictingEquipmentIds = {}; + Set _conflictingContainerIds = {}; + 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 = ''; @@ -111,7 +120,6 @@ class _EquipmentSelectionDialogState extends State { _initializeAlreadyAssigned(); _loadAvailableQuantities(); _loadEquipmentConflicts(); - _loadContainerConflicts(); }); } @@ -224,59 +232,57 @@ class _EquipmentSelectionDialogState extends State { } } - /// Charge les conflits de disponibilité pour tous les équipements + /// Charge les conflits de disponibilité pour tous les équipements et conteneurs + /// Version optimisée : un seul appel API au lieu d'un par équipement Future _loadEquipmentConflicts() async { setState(() => _isLoadingConflicts = true); try { - final equipmentProvider = context.read(); - final equipment = await equipmentProvider.equipmentStream.first; + print('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...'); - for (var eq in equipment) { - // Pour les consommables/câbles, vérifier avec gestion de quantité - if (eq.hasQuantity) { - // Récupérer la quantité disponible - final availableQty = await _availabilityService.getAvailableQuantity( - equipment: eq, - startDate: widget.startDate, - endDate: widget.endDate, - excludeEventId: widget.excludeEventId, - ); + final startTime = DateTime.now(); - // Vérifier si un item de cet équipement est déjà sélectionné - final selectedItem = _selectedItems[eq.id]; - final requestedQty = selectedItem?.quantity ?? 1; + // UN SEUL appel API pour récupérer TOUS les équipements en conflit + final result = await _dataService.getConflictingEquipmentIds( + startDate: widget.startDate, + endDate: widget.endDate, + excludeEventId: widget.excludeEventId, + installationTime: 0, // TODO: Récupérer depuis l'événement si nécessaire + disassemblyTime: 0, + ); - // ✅ Ne créer un conflit QUE si la quantité demandée dépasse la quantité disponible - if (requestedQty > availableQty) { - final conflicts = await _availabilityService.checkEquipmentAvailabilityWithQuantity( - equipment: eq, - requestedQuantity: requestedQty, - startDate: widget.startDate, - endDate: widget.endDate, - excludeEventId: widget.excludeEventId, - ); + final endTime = DateTime.now(); + final duration = endTime.difference(startTime); - if (conflicts.isNotEmpty) { - _equipmentConflicts[eq.id] = conflicts; - } - } - // Sinon, pas de conflit à afficher dans la liste - } else { - // Pour les équipements non quantifiables, vérification classique - final conflicts = await _availabilityService.checkEquipmentAvailability( - equipmentId: eq.id, - equipmentName: eq.id, - startDate: widget.startDate, - endDate: widget.endDate, - excludeEventId: widget.excludeEventId, - ); + print('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms'); - if (conflicts.isNotEmpty) { - _equipmentConflicts[eq.id] = conflicts; - } - } + // Extraire les IDs en conflit + final conflictingEquipmentIds = (result['conflictingEquipmentIds'] as List?) + ?.map((e) => e.toString()) + .toSet() ?? {}; + + final conflictingContainerIds = (result['conflictingContainerIds'] as List?) + ?.map((e) => e.toString()) + .toSet() ?? {}; + + final conflictDetails = result['conflictDetails'] as Map? ?? {}; + final equipmentQuantities = result['equipmentQuantities'] as Map? ?? {}; + + print('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict'); + print('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)'); + + if (mounted) { + setState(() { + _conflictingEquipmentIds = conflictingEquipmentIds; + _conflictingContainerIds = conflictingContainerIds; + _conflictDetails = conflictDetails; + _equipmentQuantities = equipmentQuantities; + }); } + + // Mettre à jour les statuts de conteneurs + await _updateContainerConflictStatus(); + } catch (e) { print('[EquipmentSelectionDialog] Error loading conflicts: $e'); } finally { @@ -284,90 +290,277 @@ class _EquipmentSelectionDialogState extends State { } } - /// Charge les conflits de disponibilité pour tous les conteneurs - Future _loadContainerConflicts() async { + /// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit + Future _updateContainerConflictStatus() async { try { - print('[EquipmentSelectionDialog] Loading container conflicts...'); final containerProvider = context.read(); - final equipmentProvider = context.read(); final containers = await containerProvider.containersStream.first; - final allEquipment = await equipmentProvider.equipmentStream.first; - - print('[EquipmentSelectionDialog] Checking conflicts for ${containers.length} containers'); for (var container in containers) { - // Vérifier d'abord si la boîte complète est utilisée ailleurs - final containerEquipment = allEquipment - .where((eq) => container.equipmentIds.contains(eq.id)) + // Vérifier si le conteneur lui-même est en conflit + if (_conflictingContainerIds.contains(container.id)) { + _containerConflicts[container.id] = ContainerConflictInfo( + status: ContainerConflictStatus.complete, + conflictingEquipmentIds: [], + totalChildren: container.equipmentIds.length, + ); + continue; + } + + // Vérifier si des équipements enfants sont en conflit + final conflictingChildren = container.equipmentIds + .where((eqId) => _conflictingEquipmentIds.contains(eqId)) .toList(); - final containerConflicts = await _availabilityService.checkContainerAvailability( - container: container, - containerEquipment: containerEquipment, - startDate: widget.startDate, - endDate: widget.endDate, - excludeEventId: widget.excludeEventId, - ); + if (conflictingChildren.isNotEmpty) { + final status = conflictingChildren.length == container.equipmentIds.length + ? ContainerConflictStatus.complete + : ContainerConflictStatus.partial; - if (containerConflicts.isNotEmpty) { - // Déterminer le statut en fonction du type de conflit - final hasFullConflict = containerConflicts.any( - (c) => c.type == ConflictType.containerFullyUsed, + _containerConflicts[container.id] = ContainerConflictInfo( + status: status, + conflictingEquipmentIds: conflictingChildren, + totalChildren: container.equipmentIds.length, ); - final conflictingChildren = containerConflicts - .where((c) => c.type != ConflictType.containerFullyUsed && - c.type != ConflictType.containerPartiallyUsed) - .map((c) => c.equipmentId) - .toList(); - - final status = hasFullConflict - ? ContainerConflictStatus.complete - : (conflictingChildren.isNotEmpty - ? ContainerConflictStatus.partial - : ContainerConflictStatus.none); - - if (status != ContainerConflictStatus.none) { - _containerConflicts[container.id] = ContainerConflictInfo( - status: status, - conflictingEquipmentIds: conflictingChildren, - totalChildren: container.equipmentIds.length, - ); - - print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict'); - } - } else { - // Vérifier chaque équipement enfant individuellement - final conflictingChildren = []; - - for (var equipmentId in container.equipmentIds) { - if (_equipmentConflicts.containsKey(equipmentId)) { - conflictingChildren.add(equipmentId); - } - } - - if (conflictingChildren.isNotEmpty) { - final status = conflictingChildren.length == container.equipmentIds.length - ? ContainerConflictStatus.complete - : ContainerConflictStatus.partial; - - _containerConflicts[container.id] = ContainerConflictInfo( - status: status, - conflictingEquipmentIds: conflictingChildren, - totalChildren: container.equipmentIds.length, - ); - - print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)'); - } + print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)'); } } print('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}'); } catch (e) { - print('[EquipmentSelectionDialog] Error loading container conflicts: $e'); + print('[EquipmentSelectionDialog] Error updating container conflicts: $e'); } } + /// Récupère les détails des conflits pour un équipement/conteneur donné + List> _getConflictDetailsFor(String id) { + final details = _conflictDetails[id]; + if (details == null) return []; + + if (details is List) { + return details.cast>(); + } + + return []; + } + + /// Construit l'affichage des quantités pour les câbles/consommables + Widget _buildQuantityInfo(EquipmentModel equipment) { + final quantityInfo = _equipmentQuantities[equipment.id] as Map?; + + if (quantityInfo == null) { + // Pas d'info de quantité, utiliser l'ancien système (availableQuantities) + final availableQty = _availableQuantities[equipment.id]; + if (availableQty == null) return const SizedBox.shrink(); + + return Text( + 'Disponible : $availableQty ${equipment.category == EquipmentCategory.cable ? "m" : ""}', + style: TextStyle( + color: availableQty > 0 ? Colors.green : Colors.red, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ); + } + + final totalQuantity = quantityInfo['totalQuantity'] as int? ?? 0; + final availableQuantity = quantityInfo['availableQuantity'] as int? ?? 0; + final reservedQuantity = quantityInfo['reservedQuantity'] as int? ?? 0; + final reservations = quantityInfo['reservations'] as List? ?? []; + final unit = equipment.category == EquipmentCategory.cable ? "m" : ""; + + return Row( + children: [ + Text( + 'Disponible : $availableQuantity/$totalQuantity $unit', + style: TextStyle( + color: availableQuantity > 0 ? Colors.green.shade700 : Colors.red, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + if (reservations.isNotEmpty) ...[ + const SizedBox(width: 6), + GestureDetector( + onTap: () => _showQuantityDetailsDialog(equipment, quantityInfo), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.blue.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.info_outline, + size: 16, + color: Colors.blue.shade700, + ), + ), + ), + ], + ], + ); + } + + /// Affiche un dialog avec les détails des réservations de quantité + Future _showQuantityDetailsDialog(EquipmentModel equipment, Map quantityInfo) async { + final reservations = quantityInfo['reservations'] as List? ?? []; + final totalQuantity = quantityInfo['totalQuantity'] as int? ?? 0; + final availableQuantity = quantityInfo['availableQuantity'] as int? ?? 0; + final unit = equipment.category == EquipmentCategory.cable ? "m" : ""; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.inventory_2, color: Colors.blue.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Quantités - ${equipment.name}', + style: const TextStyle(fontSize: 18), + ), + ), + ], + ), + content: SizedBox( + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Résumé + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Quantité totale :', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + Text( + '$totalQuantity $unit', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue.shade900, + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Disponible :', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + Text( + '$availableQuantity $unit', + style: TextStyle( + fontWeight: FontWeight.bold, + color: availableQuantity > 0 ? Colors.green.shade700 : Colors.red, + fontSize: 16, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + + // Liste des réservations + if (reservations.isNotEmpty) ...[ + Text( + 'Utilisé sur ${reservations.length} événement(s) :', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + Container( + constraints: const BoxConstraints(maxHeight: 300), + child: SingleChildScrollView( + child: Column( + children: reservations.map((reservation) { + final res = reservation as Map; + final eventName = res['eventName'] as String? ?? 'Événement inconnu'; + final quantity = res['quantity'] as int? ?? 0; + final viaContainer = res['viaContainer'] as String?; + final viaContainerName = res['viaContainerName'] as String?; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + dense: true, + leading: CircleAvatar( + backgroundColor: Colors.orange.shade100, + child: Text( + '$quantity', + style: TextStyle( + color: Colors.orange.shade900, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + title: Text( + eventName, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: viaContainer != null + ? Text( + 'Via ${viaContainerName ?? viaContainer}', + style: const TextStyle( + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ) + : null, + trailing: Text( + '$quantity $unit', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ), + ); + } + /// Recherche les conteneurs recommandés pour un équipement Future _findRecommendedContainers(String equipmentId) async { try { @@ -398,7 +591,7 @@ class _EquipmentSelectionDialogState extends State { void _toggleSelection(String id, String name, SelectionType type, {int? maxQuantity, bool force = false}) async { // Vérifier si l'équipement est en conflit - if (!force && type == SelectionType.equipment && _equipmentConflicts.containsKey(id)) { + if (!force && type == SelectionType.equipment && _conflictingEquipmentIds.contains(id)) { // Demander confirmation pour forcer final shouldForce = await _showForceConfirmationDialog(id); if (shouldForce == true) { @@ -822,6 +1015,14 @@ class _EquipmentSelectionDialogState extends State { final allContainers = containerSnapshot.data ?? []; final allEquipment = equipmentSnapshot.data ?? []; + // Charger les conflits une seule fois après le chargement des données + if (!_isLoadingConflicts && _conflictingEquipmentIds.isEmpty && allEquipment.isNotEmpty) { + // Lancer le chargement des conflits en arrière-plan + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadEquipmentConflicts(); + }); + } + // Filtrage des boîtes final filteredContainers = allContainers.where((container) { if (_searchQuery.isNotEmpty) { @@ -948,8 +1149,8 @@ class _EquipmentSelectionDialogState extends State { equipment.category == EquipmentCategory.cable; final availableQty = _availableQuantities[equipment.id]; final selectedItem = _selectedItems[equipment.id]; - final hasConflict = _equipmentConflicts.containsKey(equipment.id); - final conflicts = _equipmentConflicts[equipment.id] ?? []; + final hasConflict = _conflictingEquipmentIds.contains(equipment.id); // CORRECTION ICI ! + final conflictDetails = _getConflictDetailsFor(equipment.id); // Bloquer la sélection si en conflit et non forcé final canSelect = !hasConflict || isSelected; @@ -1094,17 +1295,10 @@ class _EquipmentSelectionDialogState extends State { }).toList(), ), ), - if (isConsumable && availableQty != null) + if (isConsumable) Padding( padding: const EdgeInsets.only(top: 4), - child: Text( - 'Disponible : $availableQty ${equipment.category == EquipmentCategory.cable ? "m" : ""}', - style: TextStyle( - color: availableQty > 0 ? Colors.green : Colors.red, - fontWeight: FontWeight.w500, - fontSize: 13, - ), - ), + child: _buildQuantityInfo(equipment), ), ], ), @@ -1134,7 +1328,7 @@ class _EquipmentSelectionDialogState extends State { Icon(Icons.info_outline, size: 16, color: Colors.orange.shade900), const SizedBox(width: 6), Text( - 'Utilisé sur ${conflicts.length} événement(s) :', + 'Utilisé sur ${conflictDetails.length} événement(s) :', style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -1144,21 +1338,44 @@ class _EquipmentSelectionDialogState extends State { ], ), const SizedBox(height: 6), - ...conflicts.take(2).map((conflict) => Padding( - padding: const EdgeInsets.only(left: 22, top: 4), - child: Text( - '• ${conflict.conflictingEvent.name} (${conflict.overlapDays} jour(s))', - style: TextStyle( - fontSize: 11, - color: Colors.orange.shade800, + ...conflictDetails.take(2).map((detail) { + final eventName = detail['eventName'] as String? ?? 'Événement inconnu'; + final viaContainer = detail['viaContainer'] as String?; + final viaContainerName = detail['viaContainerName'] as String?; + + return Padding( + padding: const EdgeInsets.only(left: 22, top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '• $eventName', + style: TextStyle( + fontSize: 11, + color: Colors.orange.shade800, + ), + ), + if (viaContainer != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + 'via ${viaContainerName ?? viaContainer}', + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade600, + fontStyle: FontStyle.italic, + ), + ), + ), + ], ), - ), - )), - if (conflicts.length > 2) + ); + }), + if (conflictDetails.length > 2) Padding( padding: const EdgeInsets.only(left: 22, top: 4), child: Text( - '... et ${conflicts.length - 2} autre(s)', + '... et ${conflictDetails.length - 2} autre(s)', style: TextStyle( fontSize: 11, color: Colors.orange.shade800,