From 25d395b41a44ce57bfd6db99c4ba0e7c1bf2afa3 Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Tue, 6 Jan 2026 10:53:23 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20ajout=20de=20la=20gestion=20de=20la=20p?= =?UTF-8?q?r=C3=A9paration=20d'un=20=C3=A9v=C3=A9nement=20avec=20page=20pe?= =?UTF-8?q?rmettant=20de=20le=20g=C3=A9rer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- em2rp/firebase.json | 6 + em2rp/lib/config/app_version.dart | 2 +- em2rp/lib/main.dart | 8 +- em2rp/lib/models/event_model.dart | 95 +- .../services/event_availability_service.dart | 281 +++++- .../services/event_preparation_service.dart | 111 ++- .../event_preparation_service_extended.dart | 240 +++++ em2rp/lib/utils/colors.dart | 1 + em2rp/lib/views/event_preparation_page.dart | 908 ++++++++++++------ .../calendar_widgets/event_details.dart | 3 + .../event_details_header.dart | 4 +- .../event_preparation_buttons.dart | 160 +++ .../equipment/equipment_checklist_item.dart | 161 ++++ .../equipment/missing_equipment_dialog.dart | 192 ++++ .../event/equipment_conflict_dialog.dart | 48 +- .../event/equipment_selection_dialog.dart | 134 ++- .../event_assigned_equipment_section.dart | 264 ++--- .../widgets/user_management/user_card.dart | 3 - 18 files changed, 2121 insertions(+), 500 deletions(-) create mode 100644 em2rp/lib/services/event_preparation_service_extended.dart create mode 100644 em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_preparation_buttons.dart create mode 100644 em2rp/lib/views/widgets/equipment/equipment_checklist_item.dart create mode 100644 em2rp/lib/views/widgets/equipment/missing_equipment_dialog.dart diff --git a/em2rp/firebase.json b/em2rp/firebase.json index 856bc09..8f51cea 100644 --- a/em2rp/firebase.json +++ b/em2rp/firebase.json @@ -41,6 +41,12 @@ "firebase.json", "**/.*", "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } ] } } diff --git a/em2rp/lib/config/app_version.dart b/em2rp/lib/config/app_version.dart index 39e5c73..abae7d5 100644 --- a/em2rp/lib/config/app_version.dart +++ b/em2rp/lib/config/app_version.dart @@ -1,6 +1,6 @@ /// Configuration de la version de l'application class AppVersion { - static const String version = '0.3.5'; + static const String version = '0.3.7'; /// Retourne la version complète de l'application static String get fullVersion => 'v$version'; diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index e9c0297..3cbb53b 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -13,6 +13,7 @@ import 'package:em2rp/views/container_form_page.dart'; import 'package:em2rp/views/container_detail_page.dart'; import 'package:em2rp/views/event_preparation_page.dart'; import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/event_model.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -152,9 +153,12 @@ class MyApp extends StatelessWidget { ); }, '/event_preparation': (context) { - final eventId = ModalRoute.of(context)!.settings.arguments as String; + final args = ModalRoute.of(context)!.settings.arguments as Map; + final event = args['event'] as EventModel; return AuthGuard( - child: EventPreparationPage(eventId: eventId), + child: EventPreparationPage( + initialEvent: event, + ), ); }, }, diff --git a/em2rp/lib/models/event_model.dart b/em2rp/lib/models/event_model.dart index 8fec2a7..e92a864 100644 --- a/em2rp/lib/models/event_model.dart +++ b/em2rp/lib/models/event_model.dart @@ -13,8 +13,7 @@ String eventStatusToString(EventStatus status) { case EventStatus.canceled: return 'CANCELED'; case EventStatus.waitingForApproval: - default: - return 'WAITING_FOR_APPROVAL'; + return 'WAITING_FOR_APPROVAL'; } } @@ -65,6 +64,78 @@ PreparationStatus preparationStatusFromString(String? status) { } } +// Statut de chargement (loading) +enum LoadingStatus { + notStarted, + inProgress, + completed, + completedWithMissing +} + +String loadingStatusToString(LoadingStatus status) { + switch (status) { + case LoadingStatus.notStarted: + return 'NOT_STARTED'; + case LoadingStatus.inProgress: + return 'IN_PROGRESS'; + case LoadingStatus.completed: + return 'COMPLETED'; + case LoadingStatus.completedWithMissing: + return 'COMPLETED_WITH_MISSING'; + } +} + +LoadingStatus loadingStatusFromString(String? status) { + switch (status) { + case 'NOT_STARTED': + return LoadingStatus.notStarted; + case 'IN_PROGRESS': + return LoadingStatus.inProgress; + case 'COMPLETED': + return LoadingStatus.completed; + case 'COMPLETED_WITH_MISSING': + return LoadingStatus.completedWithMissing; + default: + return LoadingStatus.notStarted; + } +} + +// Statut de déchargement (unloading) +enum UnloadingStatus { + notStarted, + inProgress, + completed, + completedWithMissing +} + +String unloadingStatusToString(UnloadingStatus status) { + switch (status) { + case UnloadingStatus.notStarted: + return 'NOT_STARTED'; + case UnloadingStatus.inProgress: + return 'IN_PROGRESS'; + case UnloadingStatus.completed: + return 'COMPLETED'; + case UnloadingStatus.completedWithMissing: + return 'COMPLETED_WITH_MISSING'; + } +} + +UnloadingStatus unloadingStatusFromString(String? status) { + switch (status) { + case 'NOT_STARTED': + return UnloadingStatus.notStarted; + case 'IN_PROGRESS': + return UnloadingStatus.inProgress; + case 'COMPLETED': + return UnloadingStatus.completed; + case 'COMPLETED_WITH_MISSING': + return UnloadingStatus.completedWithMissing; + default: + return UnloadingStatus.notStarted; + } +} + enum ReturnStatus { notStarted, inProgress, @@ -104,6 +175,8 @@ class EventEquipment { final String equipmentId; // ID de l'équipement final int quantity; // Quantité (pour consommables) final bool isPrepared; // Validé en préparation + final bool isLoaded; // Validé au chargement + final bool isUnloaded; // Validé au déchargement final bool isReturned; // Validé au retour final int? returnedQuantity; // Quantité retournée (pour consommables) @@ -111,6 +184,8 @@ class EventEquipment { required this.equipmentId, this.quantity = 1, this.isPrepared = false, + this.isLoaded = false, + this.isUnloaded = false, this.isReturned = false, this.returnedQuantity, }); @@ -120,6 +195,8 @@ class EventEquipment { equipmentId: map['equipmentId'] ?? '', quantity: map['quantity'] ?? 1, isPrepared: map['isPrepared'] ?? false, + isLoaded: map['isLoaded'] ?? false, + isUnloaded: map['isUnloaded'] ?? false, isReturned: map['isReturned'] ?? false, returnedQuantity: map['returnedQuantity'], ); @@ -130,6 +207,8 @@ class EventEquipment { 'equipmentId': equipmentId, 'quantity': quantity, 'isPrepared': isPrepared, + 'isLoaded': isLoaded, + 'isUnloaded': isUnloaded, 'isReturned': isReturned, 'returnedQuantity': returnedQuantity, }; @@ -139,6 +218,8 @@ class EventEquipment { String? equipmentId, int? quantity, bool? isPrepared, + bool? isLoaded, + bool? isUnloaded, bool? isReturned, int? returnedQuantity, }) { @@ -146,6 +227,8 @@ class EventEquipment { equipmentId: equipmentId ?? this.equipmentId, quantity: quantity ?? this.quantity, isPrepared: isPrepared ?? this.isPrepared, + isLoaded: isLoaded ?? this.isLoaded, + isUnloaded: isUnloaded ?? this.isUnloaded, isReturned: isReturned ?? this.isReturned, returnedQuantity: returnedQuantity ?? this.returnedQuantity, ); @@ -181,6 +264,8 @@ class EventModel { final List assignedEquipment; final List assignedContainers; // IDs des conteneurs assignés final PreparationStatus? preparationStatus; + final LoadingStatus? loadingStatus; + final UnloadingStatus? unloadingStatus; final ReturnStatus? returnStatus; EventModel({ @@ -208,6 +293,8 @@ class EventModel { this.assignedEquipment = const [], this.assignedContainers = const [], this.preparationStatus, + this.loadingStatus, + this.unloadingStatus, this.returnStatus, }); @@ -342,6 +429,8 @@ class EventModel { contactPhone: map['contactPhone']?.toString(), assignedEquipment: assignedEquipment, preparationStatus: preparationStatusFromString(map['preparationStatus'] as String?), + loadingStatus: loadingStatusFromString(map['loadingStatus'] as String?), + unloadingStatus: unloadingStatusFromString(map['unloadingStatus'] as String?), returnStatus: returnStatusFromString(map['returnStatus'] as String?), ); } catch (e) { @@ -401,6 +490,8 @@ class EventModel { 'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(), 'assignedContainers': assignedContainers, 'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null, + 'loadingStatus': loadingStatus != null ? loadingStatusToString(loadingStatus!) : null, + 'unloadingStatus': unloadingStatus != null ? unloadingStatusToString(unloadingStatus!) : null, 'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null, }; } diff --git a/em2rp/lib/services/event_availability_service.dart b/em2rp/lib/services/event_availability_service.dart index beb30e1..bed172f 100644 --- a/em2rp/lib/services/event_availability_service.dart +++ b/em2rp/lib/services/event_availability_service.dart @@ -1,6 +1,15 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/models/container_model.dart'; + +/// Type de conflit +enum ConflictType { + equipmentUnavailable, // Équipement non quantifiable utilisé + insufficientQuantity, // Quantité insuffisante pour consommable/câble + containerFullyUsed, // Boîte complète utilisée + containerPartiallyUsed, // Certains équipements de la boîte utilisés +} /// Informations sur un conflit de disponibilité class AvailabilityConflict { @@ -8,13 +17,48 @@ class AvailabilityConflict { final String equipmentName; final EventModel conflictingEvent; final int overlapDays; + final ConflictType type; + + // Pour les quantités (consommables/câbles) + final int? totalQuantity; + final int? availableQuantity; + final int? requestedQuantity; + final int? reservedQuantity; + + // Pour les boîtes + final String? containerId; + final String? containerName; + final List? conflictingChildrenIds; AvailabilityConflict({ required this.equipmentId, required this.equipmentName, required this.conflictingEvent, required this.overlapDays, + this.type = ConflictType.equipmentUnavailable, + this.totalQuantity, + this.availableQuantity, + this.requestedQuantity, + this.reservedQuantity, + this.containerId, + this.containerName, + this.conflictingChildrenIds, }); + + /// Message descriptif du conflit + String get conflictMessage { + switch (type) { + case ConflictType.equipmentUnavailable: + return 'Équipement déjà utilisé'; + case ConflictType.insufficientQuantity: + return 'Stock insuffisant : $availableQuantity/$totalQuantity disponible (demandé: $requestedQuantity)'; + case ConflictType.containerFullyUsed: + return 'Boîte complète déjà utilisée'; + case ConflictType.containerPartiallyUsed: + final count = conflictingChildrenIds?.length ?? 0; + return '$count équipement(s) de la boîte déjà utilisé(s)'; + } + } } /// Service pour vérifier la disponibilité du matériel @@ -32,21 +76,22 @@ class EventAvailabilityService { final conflicts = []; try { - print('[EventAvailabilityService] Checking availability for equipment: $equipmentId'); - print('[EventAvailabilityService] Date range: $startDate - $endDate'); - // Récupérer TOUS les événements (on filtre côté client car arrayContains avec objet ne marche pas) final eventsSnapshot = await _firestore.collection('events').get(); - print('[EventAvailabilityService] Found ${eventsSnapshot.docs.length} total events'); - for (var doc in eventsSnapshot.docs) { if (excludeEventId != null && doc.id == excludeEventId) { continue; // Ignorer l'événement en cours d'édition } try { - final event = EventModel.fromMap(doc.data(), doc.id); + final data = doc.data(); + final event = EventModel.fromMap(data, doc.id); + + // Ignorer les événements annulés + if (event.status == EventStatus.canceled) { + continue; + } // Vérifier si cet événement contient l'équipement recherché final assignedEquipment = event.assignedEquipment.firstWhere( @@ -54,21 +99,26 @@ class EventAvailabilityService { orElse: () => EventEquipment(equipmentId: ''), ); - // Si l'équipement est assigné et non retourné - if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) { - print('[EventAvailabilityService] Equipment $equipmentId found in event: ${event.name}'); + // Si l'équipement est assigné à cet événement, il est indisponible + // (peu importe le statut de préparation/chargement/retour) + if (assignedEquipment.equipmentId.isNotEmpty) { + // Calculer les dates réelles avec temps d'installation et démontage + final eventRealStartDate = event.startDateTime.subtract( + Duration(hours: event.installationTime), + ); + final eventRealEndDate = event.endDateTime.add( + Duration(hours: event.disassemblyTime), + ); // Vérifier le chevauchement des dates - if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) { + if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) { final overlapDays = _calculateOverlapDays( startDate, endDate, - event.startDateTime, - event.endDateTime, + eventRealStartDate, + eventRealEndDate, ); - print('[EventAvailabilityService] CONFLICT detected! Overlap: $overlapDays days'); - conflicts.add(AvailabilityConflict( equipmentId: equipmentId, equipmentName: equipmentName, @@ -81,15 +131,18 @@ class EventAvailabilityService { print('[EventAvailabilityService] Error processing event ${doc.id}: $e'); } } - - print('[EventAvailabilityService] Total conflicts found for $equipmentId: ${conflicts.length}'); } catch (e) { - print('[EventAvailabilityService] Error checking equipment availability: $e'); + print('[EventAvailabilityService] Error checking availability: $e'); } return conflicts; } + /// Helper pour formater les dates dans les logs + String _formatDate(DateTime date) { + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } + /// Vérifie la disponibilité pour une liste d'équipements Future>> checkMultipleEquipmentAvailability({ required List equipmentIds, @@ -114,6 +167,7 @@ class EventAvailabilityService { } } + return allConflicts; } @@ -160,14 +214,29 @@ class EventAvailabilityService { try { final event = EventModel.fromMap(doc.data(), doc.id); + // Ignorer les événements annulés + if (event.status == EventStatus.canceled) { + continue; + } + + // Calculer les dates réelles avec temps d'installation et démontage + final eventRealStartDate = event.startDateTime.subtract( + Duration(hours: event.installationTime), + ); + final eventRealEndDate = event.endDateTime.add( + Duration(hours: event.disassemblyTime), + ); + // Vérifier le chevauchement des dates - if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) { + if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) { final assignedEquipment = event.assignedEquipment.firstWhere( (eq) => eq.equipmentId == equipment.id, orElse: () => EventEquipment(equipmentId: ''), ); - if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) { + // Si l'équipement est assigné, réserver la quantité + // (peu importe le statut de préparation/retour) + if (assignedEquipment.equipmentId.isNotEmpty) { reservedQuantity += assignedEquipment.quantity; } } @@ -181,5 +250,179 @@ class EventAvailabilityService { return totalQuantity - reservedQuantity; } + + /// Vérifie la disponibilité d'un équipement avec gestion des quantités + Future> checkEquipmentAvailabilityWithQuantity({ + required EquipmentModel equipment, + required int requestedQuantity, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + final conflicts = []; + + // Si équipement quantifiable (consommable/câble) + if (equipment.hasQuantity) { + final totalQuantity = equipment.totalQuantity ?? 0; + final availableQty = await getAvailableQuantity( + equipment: equipment, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); + final reservedQty = totalQuantity - availableQty; + + // ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante + if (availableQty < requestedQuantity) { + // Trouver les événements qui réservent cette quantité + final eventsSnapshot = await _firestore.collection('events').get(); + + for (var doc in eventsSnapshot.docs) { + if (excludeEventId != null && doc.id == excludeEventId) continue; + + try { + final event = EventModel.fromMap(doc.data(), doc.id); + + if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) { + final assignedEquipment = event.assignedEquipment.firstWhere( + (eq) => eq.equipmentId == equipment.id, + orElse: () => EventEquipment(equipmentId: ''), + ); + + if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) { + conflicts.add(AvailabilityConflict( + equipmentId: equipment.id, + equipmentName: equipment.name, + conflictingEvent: event, + overlapDays: _calculateOverlapDays(startDate, endDate, event.startDateTime, event.endDateTime), + type: ConflictType.insufficientQuantity, + totalQuantity: totalQuantity, + availableQuantity: availableQty, + requestedQuantity: requestedQuantity, + reservedQuantity: reservedQty, + )); + } + } + } catch (e) { + print('[EventAvailabilityService] Error processing event ${doc.id}: $e'); + } + } + } + } else { + // Équipement non quantifiable : vérification classique + return await checkEquipmentAvailability( + equipmentId: equipment.id, + equipmentName: equipment.name, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); + } + + return conflicts; + } + + /// Vérifie la disponibilité d'une boîte et de son contenu + Future> checkContainerAvailability({ + required ContainerModel container, + required List containerEquipment, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + final conflicts = []; + final conflictingChildrenIds = []; + + // Vérifier d'abord si la boîte complète est utilisée + final eventsSnapshot = await _firestore.collection('events').get(); + bool isContainerFullyUsed = false; + EventModel? containerConflictingEvent; + + for (var doc in eventsSnapshot.docs) { + if (excludeEventId != null && doc.id == excludeEventId) continue; + + try { + final event = EventModel.fromMap(doc.data(), doc.id); + + // Ignorer les événements annulés + if (event.status == EventStatus.canceled) { + continue; + } + + // Calculer les dates réelles avec temps d'installation et démontage + final eventRealStartDate = event.startDateTime.subtract( + Duration(hours: event.installationTime), + ); + final eventRealEndDate = event.endDateTime.add( + Duration(hours: event.disassemblyTime), + ); + + // Vérifier si cette boîte est assignée + if (event.assignedContainers.contains(container.id)) { + if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) { + isContainerFullyUsed = true; + containerConflictingEvent = event; + break; + } + } + } catch (e) { + print('[EventAvailabilityService] Error processing event ${doc.id}: $e'); + } + } + + if (isContainerFullyUsed && containerConflictingEvent != null) { + // Boîte complète utilisée + conflicts.add(AvailabilityConflict( + equipmentId: container.id, + equipmentName: container.name, + conflictingEvent: containerConflictingEvent, + overlapDays: _calculateOverlapDays( + startDate, + endDate, + containerConflictingEvent.startDateTime, + containerConflictingEvent.endDateTime, + ), + type: ConflictType.containerFullyUsed, + containerId: container.id, + containerName: container.name, + )); + } else { + // Vérifier chaque équipement enfant individuellement + for (var equipment in containerEquipment) { + final equipmentConflicts = await checkEquipmentAvailability( + equipmentId: equipment.id, + equipmentName: equipment.name, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); + + if (equipmentConflicts.isNotEmpty) { + conflictingChildrenIds.add(equipment.id); + conflicts.addAll(equipmentConflicts); + } + } + + // Si au moins un enfant en conflit, ajouter un conflit pour la boîte + if (conflictingChildrenIds.isNotEmpty && conflicts.isNotEmpty) { + conflicts.insert( + 0, + AvailabilityConflict( + equipmentId: container.id, + equipmentName: container.name, + conflictingEvent: conflicts.first.conflictingEvent, + overlapDays: conflicts.first.overlapDays, + type: ConflictType.containerPartiallyUsed, + containerId: container.id, + containerName: container.name, + conflictingChildrenIds: conflictingChildrenIds, + ), + ); + } + } + + return conflicts; + } } + diff --git a/em2rp/lib/services/event_preparation_service.dart b/em2rp/lib/services/event_preparation_service.dart index 3f173be..9bb141e 100644 --- a/em2rp/lib/services/event_preparation_service.dart +++ b/em2rp/lib/services/event_preparation_service.dart @@ -9,7 +9,7 @@ class EventPreparationService { // Collection references CollectionReference get _eventsCollection => _firestore.collection('events'); - CollectionReference get _equipmentCollection => _firestore.collection('equipment'); + CollectionReference get _equipmentCollection => _firestore.collection('equipments'); // === PRÉPARATION === @@ -29,9 +29,21 @@ class EventPreparationService { return eq; }).toList(); - await _eventsCollection.doc(eventId).update({ + // Vérifier si tous les équipements sont préparés + final allPrepared = updatedEquipment.every((eq) => eq.isPrepared); + + final updateData = { 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - }); + }; + + // Mettre à jour le statut selon la complétion + if (allPrepared) { + updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed); + } else { + updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.inProgress); + } + + await _eventsCollection.doc(eventId).update(updateData); } catch (e) { print('Error validating equipment preparation: $e'); rethrow; @@ -56,9 +68,13 @@ class EventPreparationService { 'preparationStatus': preparationStatusToString(PreparationStatus.completed), }); - // Mettre à jour le statut des équipements à "inUse" + // Mettre à jour le statut des équipements à "inUse" (seulement pour les équipements qui existent) for (var equipment in event.assignedEquipment) { - await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse); + // Vérifier si l'équipement existe avant de mettre à jour son statut + final doc = await _equipmentCollection.doc(equipment.equipmentId).get(); + if (doc.exists) { + await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse); + } } } catch (e) { print('Error validating all preparation: $e'); @@ -85,7 +101,11 @@ class EventPreparationService { // Mettre à jour le statut des équipements préparés à "inUse" for (var equipment in event.assignedEquipment) { if (equipment.isPrepared && !missingEquipmentIds.contains(equipment.equipmentId)) { - await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse); + // Vérifier si l'équipement existe avant de mettre à jour son statut + final doc = await _equipmentCollection.doc(equipment.equipmentId).get(); + if (doc.exists) { + await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse); + } } } } catch (e) { @@ -120,9 +140,21 @@ class EventPreparationService { return eq; }).toList(); - await _eventsCollection.doc(eventId).update({ + // Vérifier si tous les équipements sont retournés + final allReturned = updatedEquipment.every((eq) => eq.isReturned); + + final updateData = { 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), - }); + }; + + // Mettre à jour le statut selon la complétion + if (allReturned) { + updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed); + } else { + updateData['returnStatus'] = returnStatusToString(ReturnStatus.inProgress); + } + + await _eventsCollection.doc(eventId).update(updateData); // Mettre à jour le stock si c'est un consommable if (returnedQuantity != null) { @@ -176,9 +208,7 @@ class EventPreparationService { // Mettre à jour le statut des équipements à "available" et gérer les stocks for (var equipment in updatedEquipment) { - await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available); - - // Restaurer le stock pour les consommables + // Vérifier si le document existe final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get(); if (equipmentDoc.exists) { final equipmentData = EquipmentModel.fromMap( @@ -186,10 +216,17 @@ class EventPreparationService { equipmentDoc.id, ); + // Mettre à jour le statut uniquement pour les équipements non quantifiables + if (!equipmentData.hasQuantity) { + await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available); + } + + // Restaurer le stock pour les consommables if (equipmentData.hasQuantity && equipment.returnedQuantity != null) { final currentAvailable = equipmentData.availableQuantity ?? 0; await _equipmentCollection.doc(equipment.equipmentId).update({ 'availableQuantity': currentAvailable + equipment.returnedQuantity!, + 'updatedAt': Timestamp.fromDate(DateTime.now()), }); } } @@ -218,27 +255,36 @@ class EventPreparationService { // Mettre à jour le statut des équipements retournés à "available" for (var equipment in event.assignedEquipment) { + // Vérifier si le document existe + final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get(); + if (!equipmentDoc.exists) { + continue; // Passer cet équipement s'il n'existe pas + } + + final equipmentData = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + if (equipment.isReturned && !missingEquipmentIds.contains(equipment.equipmentId)) { - await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available); + // Mettre à jour le statut uniquement pour les équipements non quantifiables + if (!equipmentData.hasQuantity) { + await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available); + } // Restaurer le stock pour les consommables - final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get(); - if (equipmentDoc.exists) { - final equipmentData = EquipmentModel.fromMap( - equipmentDoc.data() as Map, - equipmentDoc.id, - ); - - if (equipmentData.hasQuantity && equipment.returnedQuantity != null) { - final currentAvailable = equipmentData.availableQuantity ?? 0; - await _equipmentCollection.doc(equipment.equipmentId).update({ - 'availableQuantity': currentAvailable + equipment.returnedQuantity!, - }); - } + if (equipmentData.hasQuantity && equipment.returnedQuantity != null) { + final currentAvailable = equipmentData.availableQuantity ?? 0; + await _equipmentCollection.doc(equipment.equipmentId).update({ + 'availableQuantity': currentAvailable + equipment.returnedQuantity!, + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); } } else if (missingEquipmentIds.contains(equipment.equipmentId)) { - // Marquer comme perdu - await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.lost); + // Marquer comme perdu uniquement pour les équipements non quantifiables + if (!equipmentData.hasQuantity) { + await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.lost); + } } } } catch (e) { @@ -252,13 +298,20 @@ class EventPreparationService { /// Mettre à jour le statut d'un équipement Future updateEquipmentStatus(String equipmentId, EquipmentStatus status) async { try { + // Vérifier que le document existe avant de le mettre à jour + final doc = await _equipmentCollection.doc(equipmentId).get(); + if (!doc.exists) { + print('Warning: Equipment document $equipmentId does not exist, skipping status update'); + return; + } + await _equipmentCollection.doc(equipmentId).update({ 'status': equipmentStatusToString(status), 'updatedAt': Timestamp.fromDate(DateTime.now()), }); } catch (e) { - print('Error updating equipment status: $e'); - rethrow; + print('Error updating equipment status for $equipmentId: $e'); + // Ne pas rethrow pour ne pas bloquer le processus si un équipement n'existe pas } } diff --git a/em2rp/lib/services/event_preparation_service_extended.dart b/em2rp/lib/services/event_preparation_service_extended.dart new file mode 100644 index 0000000..5913c0e --- /dev/null +++ b/em2rp/lib/services/event_preparation_service_extended.dart @@ -0,0 +1,240 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +/// Service étendu pour gérer les 4 étapes : Préparation, Chargement, Déchargement, Retour +class EventPreparationServiceExtended { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + + CollectionReference get _eventsCollection => _firestore.collection('events'); + CollectionReference get _equipmentCollection => _firestore.collection('equipments'); + + // === CHARGEMENT (LOADING) === + + /// Valider un équipement individuel pour le chargement + Future validateEquipmentLoading(String eventId, String equipmentId) async { + try { + final event = await _getEvent(eventId); + if (event == null) throw Exception('Event not found'); + + final updatedEquipment = event.assignedEquipment.map((eq) { + if (eq.equipmentId == equipmentId) { + return eq.copyWith(isLoaded: true); + } + return eq; + }).toList(); + + // Vérifier si tous les équipements sont chargés + final allLoaded = updatedEquipment.every((eq) => eq.isLoaded); + + final updateData = { + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + }; + + // Si tous sont chargés, mettre à jour le statut + if (allLoaded) { + updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed); + } else { + updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.inProgress); + } + + await _eventsCollection.doc(eventId).update(updateData); + } catch (e) { + print('Error validating equipment loading: $e'); + rethrow; + } + } + + /// Valider tous les équipements pour le chargement + Future validateAllLoading(String eventId) async { + try { + final event = await _getEvent(eventId); + if (event == null) throw Exception('Event not found'); + + final updatedEquipment = event.assignedEquipment.map((eq) { + return eq.copyWith(isLoaded: true); + }).toList(); + + await _eventsCollection.doc(eventId).update({ + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + 'loadingStatus': loadingStatusToString(LoadingStatus.completed), + }); + } catch (e) { + print('Error validating all loading: $e'); + rethrow; + } + } + + // === DÉCHARGEMENT (UNLOADING) === + + /// Valider un équipement individuel pour le déchargement + Future validateEquipmentUnloading(String eventId, String equipmentId) async { + try { + final event = await _getEvent(eventId); + if (event == null) throw Exception('Event not found'); + + final updatedEquipment = event.assignedEquipment.map((eq) { + if (eq.equipmentId == equipmentId) { + return eq.copyWith(isUnloaded: true); + } + return eq; + }).toList(); + + // Vérifier si tous les équipements sont déchargés + final allUnloaded = updatedEquipment.every((eq) => eq.isUnloaded); + + final updateData = { + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + }; + + // Si tous sont déchargés, mettre à jour le statut + if (allUnloaded) { + updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed); + } else { + updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.inProgress); + } + + await _eventsCollection.doc(eventId).update(updateData); + } catch (e) { + print('Error validating equipment unloading: $e'); + rethrow; + } + } + + /// Valider tous les équipements pour le déchargement + Future validateAllUnloading(String eventId) async { + try { + final event = await _getEvent(eventId); + if (event == null) throw Exception('Event not found'); + + final updatedEquipment = event.assignedEquipment.map((eq) { + return eq.copyWith(isUnloaded: true); + }).toList(); + + await _eventsCollection.doc(eventId).update({ + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + 'unloadingStatus': unloadingStatusToString(UnloadingStatus.completed), + }); + } catch (e) { + print('Error validating all unloading: $e'); + rethrow; + } + } + + // === PRÉPARATION + CHARGEMENT === + + /// Valider préparation ET chargement en même temps + Future validateAllPreparationAndLoading(String eventId) async { + try { + final event = await _getEvent(eventId); + if (event == null) throw Exception('Event not found'); + + final updatedEquipment = event.assignedEquipment.map((eq) { + return eq.copyWith(isPrepared: true, isLoaded: true); + }).toList(); + + await _eventsCollection.doc(eventId).update({ + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + 'preparationStatus': preparationStatusToString(PreparationStatus.completed), + 'loadingStatus': loadingStatusToString(LoadingStatus.completed), + }); + + // Mettre à jour le statut des équipements + for (var equipment in event.assignedEquipment) { + final doc = await _equipmentCollection.doc(equipment.equipmentId).get(); + if (doc.exists) { + await _updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse); + } + } + } catch (e) { + print('Error validating all preparation and loading: $e'); + rethrow; + } + } + + // === DÉCHARGEMENT + RETOUR === + + /// Valider déchargement ET retour en même temps + Future validateAllUnloadingAndReturn( + String eventId, + Map? returnedQuantities, + ) async { + try { + final event = await _getEvent(eventId); + if (event == null) throw Exception('Event not found'); + + final updatedEquipment = event.assignedEquipment.map((eq) { + final returnedQty = returnedQuantities?[eq.equipmentId] ?? + eq.returnedQuantity ?? + eq.quantity; + return eq.copyWith( + isUnloaded: true, + isReturned: true, + returnedQuantity: returnedQty, + ); + }).toList(); + + await _eventsCollection.doc(eventId).update({ + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + 'unloadingStatus': unloadingStatusToString(UnloadingStatus.completed), + 'returnStatus': returnStatusToString(ReturnStatus.completed), + }); + + // Mettre à jour les statuts et stocks + for (var equipment in updatedEquipment) { + final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get(); + if (equipmentDoc.exists) { + final equipmentData = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + if (!equipmentData.hasQuantity) { + await _updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available); + } + + if (equipmentData.hasQuantity && equipment.returnedQuantity != null) { + final currentAvailable = equipmentData.availableQuantity ?? 0; + await _equipmentCollection.doc(equipment.equipmentId).update({ + 'availableQuantity': currentAvailable + equipment.returnedQuantity!, + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + } + } + } + } catch (e) { + print('Error validating all unloading and return: $e'); + rethrow; + } + } + + // === HELPERS === + + Future _updateEquipmentStatus(String equipmentId, EquipmentStatus status) async { + try { + final doc = await _equipmentCollection.doc(equipmentId).get(); + if (!doc.exists) return; + + await _equipmentCollection.doc(equipmentId).update({ + 'status': equipmentStatusToString(status), + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + } catch (e) { + print('Error updating equipment status: $e'); + } + } + + Future _getEvent(String eventId) async { + try { + final doc = await _eventsCollection.doc(eventId).get(); + if (doc.exists) { + return EventModel.fromMap(doc.data() as Map, doc.id); + } + return null; + } catch (e) { + print('Error getting event: $e'); + rethrow; + } + } +} + diff --git a/em2rp/lib/utils/colors.dart b/em2rp/lib/utils/colors.dart index 55e662d..abbf21e 100644 --- a/em2rp/lib/utils/colors.dart +++ b/em2rp/lib/utils/colors.dart @@ -5,4 +5,5 @@ class AppColors { static const Color blanc = Color(0xFFFFFFFF); // Blanc static const Color rouge = Color.fromARGB(255, 159, 0, 0); // Rouge static const Color gris = Color(0xFF808080); // Gris (gris moyen) + static const Color bleuFonce = Color(0xFF1565C0); // Bleu foncé } diff --git a/em2rp/lib/views/event_preparation_page.dart b/em2rp/lib/views/event_preparation_page.dart index e936e6e..0d25c05 100644 --- a/em2rp/lib/views/event_preparation_page.dart +++ b/em2rp/lib/views/event_preparation_page.dart @@ -1,383 +1,701 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/equipment_model.dart'; -import 'package:em2rp/providers/event_provider.dart'; +import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/providers/equipment_provider.dart'; -import 'package:em2rp/providers/local_user_provider.dart'; +import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/services/event_preparation_service.dart'; +import 'package:em2rp/services/event_preparation_service_extended.dart'; +import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep; +import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart'; import 'package:em2rp/utils/colors.dart'; -import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; -import 'package:em2rp/views/widgets/event/equipment_checklist_item.dart'; -import 'package:em2rp/views/widgets/event/missing_equipment_dialog.dart'; -import 'package:em2rp/views/widgets/event/preparation_success_dialog.dart'; +/// Type d'étape de préparation +enum PreparationStep { + preparation, // Préparation dépôt + loadingOutbound, // Chargement aller + unloadingReturn, // Chargement retour (déchargement) + return_, // Retour dépôt +} + +/// Page de préparation ou de retour d'un événement class EventPreparationPage extends StatefulWidget { - final String eventId; + final EventModel initialEvent; const EventPreparationPage({ super.key, - required this.eventId, + required this.initialEvent, }); @override State createState() => _EventPreparationPageState(); } -class _EventPreparationPageState extends State { +class _EventPreparationPageState extends State with SingleTickerProviderStateMixin { final EventPreparationService _preparationService = EventPreparationService(); - EventModel? _event; - Map _equipmentMap = {}; - Map _returnedQuantities = {}; // Pour les quantités retournées (consommables) + final EventPreparationServiceExtended _extendedService = EventPreparationServiceExtended(); + late AnimationController _animationController; + + Map _equipmentCache = {}; + Map _containerCache = {}; + Map _returnedQuantities = {}; + + // État local des validations (non sauvegardé jusqu'à la validation finale) + Map _localValidationState = {}; + bool _isLoading = true; - bool _isSaving = false; + bool _isValidating = false; + bool _showSuccessAnimation = false; + bool _loadSimultaneously = false; // Checkbox "charger en même temps" - // Mode déterminé automatiquement - bool get _isReturnMode { - if (_event == null) return false; - // Mode retour si préparation complétée et retour pas encore complété - return _event!.preparationStatus == PreparationStatus.completed || - _event!.preparationStatus == PreparationStatus.completedWithMissing; + // Stockage de l'événement actuel + late EventModel _currentEvent; + + // Détermine l'étape actuelle selon le statut de l'événement + PreparationStep get _currentStep { + final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted; + final loading = _currentEvent.loadingStatus ?? LoadingStatus.notStarted; + final unloading = _currentEvent.unloadingStatus ?? UnloadingStatus.notStarted; + final returnStatus = _currentEvent.returnStatus ?? ReturnStatus.notStarted; + + // Logique stricte : on avance étape par étape + // 1. Préparation dépôt + if (prep != PreparationStatus.completed) { + return PreparationStep.preparation; + } + + // 2. Chargement aller (après préparation complète) + if (loading != LoadingStatus.completed) { + return PreparationStep.loadingOutbound; + } + + // 3. Chargement retour (après chargement aller complet) + if (unloading != UnloadingStatus.completed) { + return PreparationStep.unloadingReturn; + } + + // 4. Retour dépôt (après déchargement complet) + if (returnStatus != ReturnStatus.completed) { + return PreparationStep.return_; + } + + // Tout est terminé, par défaut on retourne à la préparation + return PreparationStep.preparation; } - String get _pageTitle => _isReturnMode ? 'Retour matériel' : 'Préparation matériel'; - @override void initState() { super.initState(); - _loadEventAndEquipment(); + _currentEvent = widget.initialEvent; + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + + // Vérification de sécurité : bloquer l'accès si toutes les étapes sont complétées + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_isCurrentStepCompleted()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Cette étape est déjà terminée'), + backgroundColor: Colors.orange, + ), + ); + Navigator.of(context).pop(); + return; + } + }); + + _loadEquipmentAndContainers(); } - Future _loadEventAndEquipment() async { + /// Vérifie si l'étape actuelle est déjà complétée + bool _isCurrentStepCompleted() { + switch (_currentStep) { + case PreparationStep.preparation: + return (_currentEvent.preparationStatus ?? PreparationStatus.notStarted) == PreparationStatus.completed; + case PreparationStep.loadingOutbound: + return (_currentEvent.loadingStatus ?? LoadingStatus.notStarted) == LoadingStatus.completed; + case PreparationStep.unloadingReturn: + return (_currentEvent.unloadingStatus ?? UnloadingStatus.notStarted) == UnloadingStatus.completed; + case PreparationStep.return_: + return (_currentEvent.returnStatus ?? ReturnStatus.notStarted) == ReturnStatus.completed; + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + /// Recharger l'événement depuis Firestore + Future _reloadEvent() async { try { - // Charger l'événement - final eventProvider = context.read(); - final event = await eventProvider.getEvent(widget.eventId); + final doc = await FirebaseFirestore.instance + .collection('events') + .doc(_currentEvent.id) + .get(); - if (event == null) { - throw Exception('Événement non trouvé'); + if (doc.exists) { + setState(() { + _currentEvent = EventModel.fromMap(doc.data() as Map, doc.id); + }); } + } catch (e) { + print('[EventPreparationPage] Error reloading event: $e'); + } + } - // Charger tous les équipements assignés + Future _loadEquipmentAndContainers() async { + setState(() => _isLoading = true); + + try { final equipmentProvider = context.read(); - final Map equipmentMap = {}; + final containerProvider = context.read(); - for (var assignedEq in event.assignedEquipment) { - final equipment = await equipmentProvider.getEquipmentById(assignedEq.equipmentId); - if (equipment != null) { - equipmentMap[assignedEq.equipmentId] = equipment; + final equipment = await equipmentProvider.equipmentStream.first; + final containers = await containerProvider.containersStream.first; - // Initialiser les quantités retournées pour les consommables - if (_isReturnMode && - (equipment.category == EquipmentCategory.consumable || - equipment.category == EquipmentCategory.cable)) { - _returnedQuantities[assignedEq.equipmentId] = assignedEq.returnedQuantity ?? assignedEq.quantity; - } + for (var eq in _currentEvent.assignedEquipment) { + final equipmentItem = equipment.firstWhere( + (e) => e.id == eq.equipmentId, + orElse: () => EquipmentModel( + id: eq.equipmentId, + name: 'Équipement inconnu', + category: EquipmentCategory.other, + status: EquipmentStatus.available, + parentBoxIds: [], + maintenanceIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + _equipmentCache[eq.equipmentId] = equipmentItem; + + // Initialiser l'état local de validation depuis l'événement + switch (_currentStep) { + case PreparationStep.preparation: + _localValidationState[eq.equipmentId] = eq.isPrepared; + break; + case PreparationStep.loadingOutbound: + _localValidationState[eq.equipmentId] = eq.isLoaded; + break; + case PreparationStep.unloadingReturn: + _localValidationState[eq.equipmentId] = eq.isUnloaded; + break; + case PreparationStep.return_: + _localValidationState[eq.equipmentId] = eq.isReturned; + break; + } + + if ((_currentStep == PreparationStep.return_ || + _currentStep == PreparationStep.unloadingReturn) && + equipmentItem.hasQuantity) { + _returnedQuantities[eq.equipmentId] = eq.returnedQuantity ?? eq.quantity; } } - setState(() { - _event = event; - _equipmentMap = equipmentMap; - _isLoading = false; - }); + for (var containerId in _currentEvent.assignedContainers) { + final container = containers.firstWhere( + (c) => c.id == containerId, + orElse: () => ContainerModel( + id: containerId, + name: 'Conteneur inconnu', + type: ContainerType.flightCase, + status: EquipmentStatus.available, + equipmentIds: [], + updatedAt: DateTime.now(), + createdAt: DateTime.now(), + ), + ); + _containerCache[containerId] = container; + } } catch (e) { + print('[EventPreparationPage] Error: $e'); + } finally { setState(() => _isLoading = false); + } + } + + /// Basculer l'état de validation d'un équipement (état local uniquement) + void _toggleEquipmentValidation(String equipmentId) { + setState(() { + _localValidationState[equipmentId] = !(_localValidationState[equipmentId] ?? false); + }); + } + + Future _validateAll() async { + setState(() => _isValidating = true); + + try { + // Si "tout valider" est cliqué, marquer tout comme validé localement + for (var equipmentId in _localValidationState.keys) { + _localValidationState[equipmentId] = true; + } + + // Préparer la liste des équipements avec leur nouvel état + final updatedEquipment = _currentEvent.assignedEquipment.map((eq) { + final isValidated = _localValidationState[eq.equipmentId] ?? false; + + switch (_currentStep) { + case PreparationStep.preparation: + if (_loadSimultaneously) { + return eq.copyWith(isPrepared: isValidated, isLoaded: isValidated); + } + return eq.copyWith(isPrepared: isValidated); + + case PreparationStep.loadingOutbound: + return eq.copyWith(isLoaded: isValidated); + + case PreparationStep.unloadingReturn: + if (_loadSimultaneously) { + final returnedQty = _returnedQuantities[eq.equipmentId] ?? eq.quantity; + return eq.copyWith(isUnloaded: isValidated, isReturned: isValidated, returnedQuantity: returnedQty); + } + return eq.copyWith(isUnloaded: isValidated); + + case PreparationStep.return_: + final returnedQty = _returnedQuantities[eq.equipmentId] ?? eq.quantity; + return eq.copyWith(isReturned: isValidated, returnedQuantity: returnedQty); + } + }).toList(); + + // Mettre à jour Firestore selon l'étape + final updateData = { + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + }; + + // Ajouter les statuts selon l'étape et la checkbox + switch (_currentStep) { + case PreparationStep.preparation: + updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed); + if (_loadSimultaneously) { + updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed); + } + break; + + case PreparationStep.loadingOutbound: + updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed); + break; + + case PreparationStep.unloadingReturn: + updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed); + if (_loadSimultaneously) { + updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed); + } + break; + + case PreparationStep.return_: + updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed); + break; + } + + // Sauvegarder dans Firestore + await FirebaseFirestore.instance + .collection('events') + .doc(_currentEvent.id) + .update(updateData); + + // Mettre à jour les statuts des équipements si nécessaire + if (_currentStep == PreparationStep.preparation || + (_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) { + await _updateEquipmentStatuses(updatedEquipment); + } + + setState(() => _showSuccessAnimation = true); + _animationController.forward(); + + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Erreur: $e'), + content: Text(_getSuccessMessage()), + backgroundColor: Colors.green, + ), + ); + + await Future.delayed(const Duration(seconds: 2)); + Navigator.of(context).pop(true); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur : $e'), backgroundColor: Colors.red, ), ); } - } - } - - Future _toggleEquipmentValidation(String equipmentId, bool isValidated) async { - try { - if (_isReturnMode) { - if (isValidated) { - final returnedQty = _returnedQuantities[equipmentId]; - await _preparationService.validateEquipmentReturn( - widget.eventId, - equipmentId, - returnedQuantity: returnedQty, - ); - } - } else { - await _preparationService.validateEquipmentPreparation(widget.eventId, equipmentId); - } - - // Recharger l'événement - await _loadEventAndEquipment(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red), - ); - } - } - } - - Future _validateAllQuickly() async { - setState(() => _isSaving = true); - - try { - if (_isReturnMode) { - await _preparationService.validateAllReturn(widget.eventId, _returnedQuantities); - } else { - await _preparationService.validateAllPreparation(widget.eventId); - } - - // Afficher le dialog de succès avec animation - if (mounted) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => PreparationSuccessDialog(isReturnMode: _isReturnMode), - ); - - // Retour à la page précédente - Navigator.of(context).pop(); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red), - ); - } } finally { - setState(() => _isSaving = false); + if (mounted) setState(() => _isValidating = false); } } - Future _validatePreparation() async { - if (_event == null) return; + Future _updateEquipmentStatuses(List equipment) async { + for (var eq in equipment) { + try { + final doc = await FirebaseFirestore.instance + .collection('equipments') + .doc(eq.equipmentId) + .get(); - // Vérifier quels équipements ne sont pas validés - final missingEquipment = []; - final missingIds = []; + if (doc.exists) { + final equipmentData = EquipmentModel.fromMap( + doc.data() as Map, + doc.id, + ); - for (var assignedEq in _event!.assignedEquipment) { - final isValidated = _isReturnMode ? assignedEq.isReturned : assignedEq.isPrepared; + // Déterminer le nouveau statut + EquipmentStatus newStatus; + if (eq.isReturned) { + newStatus = EquipmentStatus.available; + } else if (eq.isPrepared || eq.isLoaded) { + newStatus = EquipmentStatus.inUse; + } else { + continue; // Pas de changement + } - if (!isValidated) { - final equipment = _equipmentMap[assignedEq.equipmentId]; - if (equipment != null) { - missingEquipment.add(equipment); - missingIds.add(assignedEq.equipmentId); + // Ne mettre à jour que les équipements non quantifiables + if (!equipmentData.hasQuantity) { + await FirebaseFirestore.instance + .collection('equipments') + .doc(eq.equipmentId) + .update({ + 'status': equipmentStatusToString(newStatus), + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + } + + // Gérer les stocks pour les consommables + if (equipmentData.hasQuantity && eq.isReturned && eq.returnedQuantity != null) { + final currentAvailable = equipmentData.availableQuantity ?? 0; + await FirebaseFirestore.instance + .collection('equipments') + .doc(eq.equipmentId) + .update({ + 'availableQuantity': currentAvailable + eq.returnedQuantity!, + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + } } + } catch (e) { + print('Error updating equipment status for ${eq.equipmentId}: $e'); } } + } - // Si tout est validé, on finalise directement - if (missingEquipment.isEmpty) { - await _validateAllQuickly(); - return; + String _getSuccessMessage() { + switch (_currentStep) { + case PreparationStep.preparation: + return _loadSimultaneously + ? 'Préparation dépôt et chargement aller validés !' + : 'Préparation dépôt validée !'; + case PreparationStep.loadingOutbound: + return 'Chargement aller validé !'; + case PreparationStep.unloadingReturn: + return _loadSimultaneously + ? 'Chargement retour et retour dépôt validés !' + : 'Chargement retour validé !'; + case PreparationStep.return_: + return 'Retour dépôt validé !'; + } + } + + String _getStepTitle() { + switch (_currentStep) { + case PreparationStep.preparation: + return 'Préparation dépôt'; + case PreparationStep.loadingOutbound: + return 'Chargement aller'; + case PreparationStep.unloadingReturn: + return 'Chargement retour'; + case PreparationStep.return_: + return 'Retour dépôt'; + } + } + + String _getValidateAllButtonText() { + if (_loadSimultaneously) { + return _currentStep == PreparationStep.preparation + ? 'Tout confirmer comme préparé et chargé' + : 'Tout confirmer comme déchargé et retourné'; } - // Sinon, afficher le dialog des manquants - if (mounted) { - final result = await showDialog( + switch (_currentStep) { + case PreparationStep.preparation: + return 'Tout confirmer comme préparé'; + case PreparationStep.loadingOutbound: + return 'Tout confirmer comme chargé'; + case PreparationStep.unloadingReturn: + return 'Tout confirmer comme déchargé'; + case PreparationStep.return_: + return 'Tout confirmer comme retourné'; + } + } + + bool _isStepCompleted() { + return _currentEvent.assignedEquipment.every((eq) { + return _localValidationState[eq.equipmentId] ?? false; + }); + } + + double _getProgress() { + if (_currentEvent.assignedEquipment.isEmpty) return 0; + return _getValidatedCount() / _currentEvent.assignedEquipment.length; + } + + int _getValidatedCount() { + return _currentEvent.assignedEquipment.where((eq) { + return _localValidationState[eq.equipmentId] ?? false; + }).length; + } + + ChecklistStep _getChecklistStep() { + switch (_currentStep) { + case PreparationStep.preparation: + return ChecklistStep.preparation; + case PreparationStep.loadingOutbound: + return ChecklistStep.loading; + case PreparationStep.unloadingReturn: + return ChecklistStep.unloading; + case PreparationStep.return_: + return ChecklistStep.return_; + } + } + + Future _confirm() async { + // Vérifier s'il y a des équipements manquants (non cochés localement) + final missingEquipmentIds = _currentEvent.assignedEquipment + .where((eq) => !(_localValidationState[eq.equipmentId] ?? false)) + .map((eq) => eq.equipmentId) + .toList(); + + if (missingEquipmentIds.isEmpty) { + // Tout est validé, confirmer directement + await _validateAll(); + } else { + // Afficher le dialog des manquants + final missingEquipmentModels = missingEquipmentIds + .map((id) => _equipmentCache[id]) + .whereType() + .toList(); + + final missingEventEquipment = _currentEvent.assignedEquipment + .where((eq) => missingEquipmentIds.contains(eq.equipmentId)) + .toList(); + + final action = await showDialog( context: context, builder: (context) => MissingEquipmentDialog( - missingEquipments: missingEquipment, - eventId: widget.eventId, - isReturnMode: _isReturnMode, + missingEquipment: missingEquipmentModels, + eventEquipment: missingEventEquipment, + isReturnMode: _currentStep == PreparationStep.return_ || + _currentStep == PreparationStep.unloadingReturn, ), ); - if (result == 'confirm_with_missing') { - setState(() => _isSaving = true); - try { - if (_isReturnMode) { - await _preparationService.completeReturnWithMissing(widget.eventId, missingIds); - } else { - await _preparationService.completePreparationWithMissing(widget.eventId, missingIds); - } - - if (mounted) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => PreparationSuccessDialog(isReturnMode: _isReturnMode), - ); - Navigator.of(context).pop(); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red), - ); - } - } finally { - setState(() => _isSaving = false); - } - } else if (result == 'validate_missing') { - // Valider tous les manquants - setState(() => _isSaving = true); - try { - for (var equipmentId in missingIds) { - await _toggleEquipmentValidation(equipmentId, true); - } - await _validateAllQuickly(); - } finally { - setState(() => _isSaving = false); + if (action == 'confirm_anyway') { + // Confirmer malgré les manquants + await _validateAll(); + } else if (action == 'mark_as_validated') { + // Marquer les manquants comme validés localement + for (var equipmentId in missingEquipmentIds) { + _localValidationState[equipmentId] = true; } + setState(() {}); + // Puis confirmer + await _validateAll(); } + // Si 'return_to_list', ne rien faire } } @override Widget build(BuildContext context) { - final userProvider = context.watch(); - final userId = userProvider.uid; - final hasManagePermission = userProvider.hasPermission('manage_events'); - - // Vérifier si l'utilisateur fait partie de l'équipe - final isInWorkforce = _event?.workforce.any((ref) => ref.id == userId) ?? false; - final hasPermission = hasManagePermission || isInWorkforce; - - if (!hasPermission) { - return Scaffold( - appBar: const CustomAppBar(title: 'Accès refusé'), - body: const Center( - child: Text('Vous n\'avez pas les permissions pour accéder à cette page.'), - ), - ); - } + final allValidated = _isStepCompleted(); + final stepTitle = _getStepTitle(); return Scaffold( - appBar: CustomAppBar(title: _pageTitle), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _event == null - ? const Center(child: Text('Événement introuvable')) + appBar: AppBar( + title: Text(stepTitle), + backgroundColor: AppColors.bleuFonce, + ), + body: Stack( + children: [ + _isLoading + ? const Center(child: CircularProgressIndicator()) : Column( children: [ - // En-tête avec info de l'événement - _buildEventHeader(), + Container( + padding: const EdgeInsets.all(16), + color: Colors.grey.shade100, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _currentEvent.name, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: _getProgress(), + backgroundColor: Colors.grey.shade300, + valueColor: AlwaysStoppedAnimation( + allValidated ? Colors.green : AppColors.bleuFonce, + ), + ), + ), + const SizedBox(width: 12), + Text( + '${_getValidatedCount()}/${_currentEvent.assignedEquipment.length}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + const SizedBox(height: 12), - // Bouton "Tout confirmer" - _buildQuickValidateButton(), + // Checkbox "charger en même temps" (uniquement pour préparation ou chargement retour) + if (_currentStep == PreparationStep.preparation || + _currentStep == PreparationStep.unloadingReturn) + CheckboxListTile( + value: _loadSimultaneously, + onChanged: (value) { + setState(() { + _loadSimultaneously = value ?? false; + }); + }, + title: Text( + _currentStep == PreparationStep.preparation + ? 'Charger en même temps (chargement aller)' + : 'Confirmer le retour en même temps (retour dépôt)', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + dense: true, + contentPadding: EdgeInsets.zero, + ), - // Liste des équipements - Expanded(child: _buildEquipmentList()), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: allValidated ? null : _validateAll, + icon: const Icon(Icons.check_circle_outline), + label: Text( + allValidated + ? 'Tout est validé !' + : _getValidateAllButtonText(), + ), + style: ElevatedButton.styleFrom( + backgroundColor: allValidated ? Colors.green : AppColors.bleuFonce, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _currentEvent.assignedEquipment.length, + itemBuilder: (context, index) { + final eventEquipment = _currentEvent.assignedEquipment[index]; + final equipment = _equipmentCache[eventEquipment.equipmentId]; - // Bouton de validation final - _buildValidateButton(), + if (equipment == null) { + return const SizedBox.shrink(); + } + + return EquipmentChecklistItem( + equipment: equipment, + eventEquipment: eventEquipment, + step: _getChecklistStep(), + isValidated: _localValidationState[equipment.id] ?? false, + onToggle: () => _toggleEquipmentValidation(equipment.id), + onReturnedQuantityChanged: _currentStep == PreparationStep.return_ && equipment.hasQuantity + ? (qty) { + setState(() { + _returnedQuantities[equipment.id] = qty; + }); + } + : null, + ); + }, + ), + ), ], ), - ); - } - - Widget _buildEventHeader() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.rouge.withOpacity(0.1), - border: Border( - bottom: BorderSide(color: Colors.grey.shade300), - ), + if (_showSuccessAnimation) + Center( + child: ScaleTransition( + scale: _animationController, + child: Container( + padding: const EdgeInsets.all(32), + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check, + size: 64, + color: Colors.white, + ), + ), + ), + ), + ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _event!.name, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, + bottomNavigationBar: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: ElevatedButton( + onPressed: _isValidating ? null : _confirm, + style: ElevatedButton.styleFrom( + backgroundColor: allValidated ? Colors.green : AppColors.rouge, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), ), - const SizedBox(height: 4), - Text( - '${_event!.assignedEquipment.length} équipement(s) assigné(s)', - style: TextStyle(color: Colors.grey.shade700), - ), - ], - ), - ); - } - - Widget _buildQuickValidateButton() { - return Container( - padding: const EdgeInsets.all(16), - child: ElevatedButton.icon( - onPressed: _isSaving ? null : _validateAllQuickly, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - minimumSize: const Size(double.infinity, 50), + child: _isValidating + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + 'Confirmer ${_getStepTitle().toLowerCase()}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), ), - icon: const Icon(Icons.check_circle, color: Colors.white), - label: Text( - _isReturnMode ? 'Tout confirmer comme retourné' : 'Tout confirmer comme préparé', - style: const TextStyle(color: Colors.white, fontSize: 16), - ), - ), - ); - } - - Widget _buildEquipmentList() { - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _event!.assignedEquipment.length, - itemBuilder: (context, index) { - final assignedEq = _event!.assignedEquipment[index]; - final equipment = _equipmentMap[assignedEq.equipmentId]; - - if (equipment == null) { - return const SizedBox.shrink(); - } - - final isValidated = _isReturnMode ? assignedEq.isReturned : assignedEq.isPrepared; - - return EquipmentChecklistItem( - equipment: equipment, - isValidated: isValidated, - onValidate: (value) => _toggleEquipmentValidation(assignedEq.equipmentId, value), - isReturnMode: _isReturnMode, - quantity: assignedEq.quantity, - returnedQuantity: _returnedQuantities[assignedEq.equipmentId], - onReturnedQuantityChanged: _isReturnMode - ? (value) { - setState(() { - _returnedQuantities[assignedEq.equipmentId] = value; - }); - } - : null, - ); - }, - ); - } - - Widget _buildValidateButton() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.3), - spreadRadius: 1, - blurRadius: 5, - offset: const Offset(0, -2), - ), - ], - ), - child: ElevatedButton( - onPressed: _isSaving ? null : _validatePreparation, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.rouge, - minimumSize: const Size(double.infinity, 50), - ), - child: _isSaving - ? const CircularProgressIndicator(color: Colors.white) - : Text( - _isReturnMode ? 'Finaliser le retour' : 'Finaliser la préparation', - style: const TextStyle(color: Colors.white, fontSize: 16), - ), ), ); } diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart index 48dc2e2..daf9858 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart @@ -7,6 +7,7 @@ import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_navigation.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_header.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_status_button.dart'; +import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_preparation_buttons.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_info.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_description.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_documents.dart'; @@ -60,6 +61,8 @@ class EventDetails extends StatelessWidget { onSelectEvent: onSelectEvent, ), ), + // Boutons de préparation et retour + EventPreparationButtons(event: event), const SizedBox(height: 16), Expanded( child: SingleChildScrollView( diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart index 6ffccf3..5f01224 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_header.dart @@ -98,8 +98,8 @@ class _EventDetailsHeaderState extends State { _buildStatusIcon(widget.event.status), const SizedBox(width: 8), IconButton( - icon: const Icon(Icons.calendar_today, color: AppColors.rouge), - tooltip: 'Exporter vers Google Calendar', + icon: const Icon(Icons.add_to_home_screen, color: AppColors.rouge), + tooltip: 'Ajouter a mon application de calendrier', onPressed: _exportToCalendar, ), if (Provider.of(context, listen: false) diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_preparation_buttons.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_preparation_buttons.dart new file mode 100644 index 0000000..65ee2ec --- /dev/null +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_preparation_buttons.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/views/event_preparation_page.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Boutons de préparation et retour d'événement +class EventPreparationButtons extends StatefulWidget { + final EventModel event; + + const EventPreparationButtons({ + super.key, + required this.event, + }); + + @override + State createState() => _EventPreparationButtonsState(); +} + +class _EventPreparationButtonsState extends State { + @override + Widget build(BuildContext context) { + // Écouter les changements de l'événement en temps réel + return StreamBuilder( + stream: FirebaseFirestore.instance + .collection('events') + .doc(widget.event.id) + .snapshots(), + initialData: null, + builder: (context, snapshot) { + // Utiliser l'événement du stream si disponible, sinon l'événement initial + final EventModel currentEvent; + if (snapshot.hasData && snapshot.data != null && snapshot.data!.exists) { + currentEvent = EventModel.fromMap( + snapshot.data!.data() as Map, + snapshot.data!.id, + ); + } else { + currentEvent = widget.event; + } + + return _buildButtons(context, currentEvent); + }, + ); + } + + Widget _buildButtons(BuildContext context, EventModel event) { + // Vérifier s'il y a du matériel assigné + final hasMaterial = event.assignedEquipment.isNotEmpty || event.assignedContainers.isNotEmpty; + + if (!hasMaterial) { + return const SizedBox.shrink(); + } + + // Déterminer l'étape actuelle + final prep = event.preparationStatus ?? PreparationStatus.notStarted; + final loading = event.loadingStatus ?? LoadingStatus.notStarted; + final unloading = event.unloadingStatus ?? UnloadingStatus.notStarted; + final returnStatus = event.returnStatus ?? ReturnStatus.notStarted; + + String buttonText; + IconData buttonIcon; + bool isCompleted = false; + + if (prep != PreparationStatus.completed) { + buttonText = 'Préparation dépôt'; + buttonIcon = Icons.inventory_2; + } else if (loading != LoadingStatus.completed) { + buttonText = 'Chargement aller'; + buttonIcon = Icons.local_shipping; + } else if (unloading != UnloadingStatus.completed) { + buttonText = 'Chargement retour'; + buttonIcon = Icons.unarchive; + } else if (returnStatus != ReturnStatus.completed) { + buttonText = 'Retour dépôt'; + buttonIcon = Icons.assignment_return; + } else { + buttonText = 'Terminé'; + buttonIcon = Icons.check_circle; + isCompleted = true; + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Bouton de l'étape actuelle + if (!isCompleted) + ElevatedButton.icon( + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EventPreparationPage( + initialEvent: event, + ), + ), + ); + + // Si la validation a réussi, le StreamBuilder se rechargera automatiquement + if (result == true && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Étape validée avec succès'), + backgroundColor: Colors.green, + ), + ); + } + }, + icon: Icon(buttonIcon), + label: Text( + buttonText, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.bleuFonce, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + + // Indicateur de completion + if (isCompleted) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green, width: 1), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.check_circle, color: Colors.green, size: 20), + SizedBox(width: 8), + Text( + 'Toutes les étapes sont terminées', + style: TextStyle( + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/equipment/equipment_checklist_item.dart b/em2rp/lib/views/widgets/equipment/equipment_checklist_item.dart new file mode 100644 index 0000000..fa7da6f --- /dev/null +++ b/em2rp/lib/views/widgets/equipment/equipment_checklist_item.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Type d'étape pour le checklist +enum ChecklistStep { + preparation, + loading, + unloading, + return_, +} + +/// Widget pour afficher un équipement dans une checklist de préparation/retour +class EquipmentChecklistItem extends StatelessWidget { + final EquipmentModel equipment; + final EventEquipment eventEquipment; + final ChecklistStep step; + final bool isValidated; // État de validation (passé depuis le parent) + final VoidCallback onToggle; + final ValueChanged? onReturnedQuantityChanged; + + const EquipmentChecklistItem({ + super.key, + required this.equipment, + required this.eventEquipment, + this.step = ChecklistStep.preparation, + required this.isValidated, + required this.onToggle, + this.onReturnedQuantityChanged, + }); + + + @override + Widget build(BuildContext context) { + final hasQuantity = equipment.hasQuantity; + final showQuantityInput = step == ChecklistStep.return_ && hasQuantity; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0), + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: isValidated ? Colors.green : Colors.grey.shade300, + width: isValidated ? 2 : 1, + ), + ), + child: ListTile( + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isValidated ? Colors.green.shade100 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: IconButton( + icon: Icon( + isValidated ? Icons.check_circle : Icons.radio_button_unchecked, + color: isValidated ? Colors.green : Colors.grey, + ), + onPressed: onToggle, + padding: EdgeInsets.zero, + ), + ), + title: Text( + equipment.name, + style: TextStyle( + fontWeight: FontWeight.w600, + decoration: isValidated ? TextDecoration.lineThrough : null, + color: isValidated ? Colors.grey : null, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (equipment.model != null) + Text( + equipment.model!, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + if (hasQuantity) ...[ + const SizedBox(height: 4), + Row( + children: [ + Text( + 'Quantité : ${eventEquipment.quantity}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.bleuFonce, + ), + ), + if (showQuantityInput && onReturnedQuantityChanged != null) ...[ + const SizedBox(width: 16), + const Icon(Icons.arrow_forward, size: 12, color: Colors.grey), + const SizedBox(width: 8), + Text( + 'Retourné : ', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + SizedBox( + width: 80, + child: TextField( + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + ), + hintText: '${eventEquipment.quantity}', + ), + keyboardType: TextInputType.number, + onChanged: (value) { + final qty = int.tryParse(value) ?? eventEquipment.quantity; + onReturnedQuantityChanged!(qty); + }, + style: const TextStyle(fontSize: 12), + ), + ), + ], + ], + ), + ], + ], + ), + trailing: isValidated + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.check, size: 16, color: Colors.green), + SizedBox(width: 4), + Text( + 'Validé', + style: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ) + : null, + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/equipment/missing_equipment_dialog.dart b/em2rp/lib/views/widgets/equipment/missing_equipment_dialog.dart new file mode 100644 index 0000000..a72e91d --- /dev/null +++ b/em2rp/lib/views/widgets/equipment/missing_equipment_dialog.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Dialog affichant les équipements manquants lors de la préparation/retour +class MissingEquipmentDialog extends StatelessWidget { + final List missingEquipment; + final List eventEquipment; + final bool isReturnMode; + + const MissingEquipmentDialog({ + super.key, + required this.missingEquipment, + required this.eventEquipment, + this.isReturnMode = false, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 600), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // En-tête avec icône warning + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.shade100, + shape: BoxShape.circle, + ), + child: Icon( + Icons.warning_amber_rounded, + size: 32, + color: Colors.orange.shade700, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isReturnMode + ? 'Équipements manquants au retour' + : 'Équipements manquants', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + '${missingEquipment.length} équipement(s) non validé(s)', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + + // Liste des équipements manquants + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: missingEquipment.length, + itemBuilder: (context, index) { + final equipment = missingEquipment[index]; + final eventEq = eventEquipment.firstWhere( + (eq) => eq.equipmentId == equipment.id, + orElse: () => EventEquipment(equipmentId: equipment.id), + ); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: Colors.orange.shade100, + child: equipment.category.getIcon( + size: 20, + color: Colors.orange.shade700, + ), + ), + title: Text( + equipment.name, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: equipment.hasQuantity + ? Text('Quantité : ${eventEq.quantity}') + : Text(equipment.model ?? equipment.category.label), + trailing: Icon( + Icons.error_outline, + color: Colors.orange.shade700, + ), + ), + ); + }, + ), + ), + + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + + // Actions + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Bouton principal : Confirmer malgré les manquants + ElevatedButton( + onPressed: () => Navigator.of(context).pop('confirm_anyway'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + isReturnMode + ? 'Confirmer le retour malgré les manquants' + : 'Confirmer malgré les manquants', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + + const SizedBox(height: 12), + + // Bouton secondaire : Marquer comme validés + OutlinedButton( + onPressed: () => Navigator.of(context).pop('mark_as_validated'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + side: BorderSide(color: AppColors.bleuFonce, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + 'Indiquer les manquants comme validés', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.bleuFonce, + ), + ), + ), + + const SizedBox(height: 12), + + // Bouton tertiaire : Retourner à la liste + TextButton( + onPressed: () => Navigator.of(context).pop('return_to_list'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text( + 'Retourner à la liste', + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/event/equipment_conflict_dialog.dart b/em2rp/lib/views/widgets/event/equipment_conflict_dialog.dart index 78bbeaa..63db541 100644 --- a/em2rp/lib/views/widgets/event/equipment_conflict_dialog.dart +++ b/em2rp/lib/views/widgets/event/equipment_conflict_dialog.dart @@ -24,8 +24,22 @@ class _EquipmentConflictDialogState extends State { .where((entry) => !_removedEquipmentIds.contains(entry.key)) .fold(0, (sum, entry) => sum + entry.value.length); + /// Retourne l'icône appropriée selon le type de conflit + IconData _getIconForConflict(AvailabilityConflict conflict) { + switch (conflict.type) { + case ConflictType.containerFullyUsed: + case ConflictType.containerPartiallyUsed: + return Icons.inventory_2; + case ConflictType.insufficientQuantity: + return Icons.production_quantity_limits; + case ConflictType.equipmentUnavailable: + return Icons.block; + } + } + @override Widget build(BuildContext context) { + final dateFormat = DateFormat('dd/MM/yyyy'); return Dialog( @@ -117,19 +131,37 @@ class _EquipmentConflictDialogState extends State { Row( children: [ Icon( - Icons.inventory_2, + _getIconForConflict(firstConflict), color: isRemoved ? Colors.grey : AppColors.rouge, size: 20, ), const SizedBox(width: 8), Expanded( - child: Text( - firstConflict.equipmentName, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - decoration: isRemoved ? TextDecoration.lineThrough : null, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + firstConflict.equipmentName, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + decoration: isRemoved ? TextDecoration.lineThrough : null, + ), + ), + // Message de conflit spécifique + if (firstConflict.conflictMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + firstConflict.conflictMessage, + style: TextStyle( + fontSize: 13, + color: Colors.orange.shade700, + fontWeight: FontWeight.w500, + ), + ), + ), + ], ), ), if (isRemoved) diff --git a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart index 46cecac..d48c2e5 100644 --- a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart +++ b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart @@ -213,28 +213,54 @@ class _EquipmentSelectionDialogState extends State { setState(() => _isLoadingConflicts = true); try { - print('[EquipmentSelectionDialog] Loading equipment conflicts...'); final equipmentProvider = context.read(); final equipment = await equipmentProvider.equipmentStream.first; - print('[EquipmentSelectionDialog] Checking conflicts for ${equipment.length} equipments'); - for (var eq in equipment) { - final conflicts = await _availabilityService.checkEquipmentAvailability( - equipmentId: eq.id, - equipmentName: eq.id, - startDate: widget.startDate, - endDate: widget.endDate, - excludeEventId: widget.excludeEventId, - ); + // 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, + ); - if (conflicts.isNotEmpty) { - print('[EquipmentSelectionDialog] Found ${conflicts.length} conflict(s) for ${eq.id}'); - _equipmentConflicts[eq.id] = conflicts; + // Vérifier si un item de cet équipement est déjà sélectionné + final selectedItem = _selectedItems[eq.id]; + final requestedQty = selectedItem?.quantity ?? 1; + + // ✅ 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, + ); + + 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, + ); + + if (conflicts.isNotEmpty) { + _equipmentConflicts[eq.id] = conflicts; + } } } - - print('[EquipmentSelectionDialog] Total equipments with conflicts: ${_equipmentConflicts.length}'); } catch (e) { print('[EquipmentSelectionDialog] Error loading conflicts: $e'); } finally { @@ -247,32 +273,76 @@ class _EquipmentSelectionDialogState extends State { 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) { - final conflictingChildren = []; + // Vérifier d'abord si la boîte complète est utilisée ailleurs + final containerEquipment = allEquipment + .where((eq) => container.equipmentIds.contains(eq.id)) + .toList(); - // Vérifier chaque équipement enfant - for (var equipmentId in container.equipmentIds) { - if (_equipmentConflicts.containsKey(equipmentId)) { - conflictingChildren.add(equipmentId); - } - } + 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; - - _containerConflicts[container.id] = ContainerConflictInfo( - status: status, - conflictingEquipmentIds: conflictingChildren, - totalChildren: container.equipmentIds.length, + if (containerConflicts.isNotEmpty) { + // Déterminer le statut en fonction du type de conflit + final hasFullConflict = containerConflicts.any( + (c) => c.type == ConflictType.containerFullyUsed, ); - print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)'); + 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)'); + } } } 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 310e746..af8b438 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 @@ -62,10 +62,6 @@ class _EventAssignedEquipmentSectionState extends State _isLoading = true); try { - print('[EventAssignedEquipmentSection] Loading equipment and containers...'); - print('[EventAssignedEquipmentSection] assignedEquipment: ${widget.assignedEquipment.map((e) => e.equipmentId).toList()}'); - print('[EventAssignedEquipmentSection] assignedContainers: ${widget.assignedContainers}'); - final equipmentProvider = context.read(); final containerProvider = context.read(); @@ -73,62 +69,40 @@ class _EventAssignedEquipmentSectionState extends State e.id == eq.equipmentId, + orElse: () => EquipmentModel( + id: eq.equipmentId, + name: 'Équipement inconnu', + category: EquipmentCategory.other, + status: EquipmentStatus.available, + parentBoxIds: [], + maintenanceIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), ); _equipmentCache[eq.equipmentId] = equipmentItem; - print('[EventAssignedEquipmentSection] Cached equipment: ${equipmentItem.id} (${equipmentItem.name})'); } // Créer le cache des conteneurs for (var containerId in widget.assignedContainers) { - print('[EventAssignedEquipmentSection] Looking for container: $containerId'); final container = containers.firstWhere( - (c) { - print('[EventAssignedEquipmentSection] Comparing "${c.id}" with "$containerId"'); - return c.id == containerId; - }, - orElse: () { - print('[EventAssignedEquipmentSection] Container NOT FOUND: $containerId'); - return ContainerModel( - id: containerId, - name: 'Conteneur inconnu', - type: ContainerType.flightCase, - status: EquipmentStatus.available, - equipmentIds: [], - updatedAt: DateTime.now(), - createdAt: DateTime.now(), - ); - }, + (c) => c.id == containerId, + orElse: () => ContainerModel( + id: containerId, + name: 'Conteneur inconnu', + type: ContainerType.flightCase, + status: EquipmentStatus.available, + equipmentIds: [], + updatedAt: DateTime.now(), + createdAt: DateTime.now(), + ), ); _containerCache[containerId] = container; - print('[EventAssignedEquipmentSection] Cached container: ${container.id} (${container.name})'); } - - print('[EventAssignedEquipmentSection] Equipment cache: ${_equipmentCache.keys.toList()}'); - print('[EventAssignedEquipmentSection] Container cache: ${_containerCache.keys.toList()}'); } catch (e) { print('[EventAssignedEquipmentSection] Error loading equipment/containers: $e'); } finally { @@ -173,18 +147,16 @@ class _EventAssignedEquipmentSectionState extends State(); final equipmentProvider = context.read(); final allContainers = await containerProvider.containersStream.first; final allEquipment = await equipmentProvider.equipmentStream.first; - // Collecter TOUS les équipements à vérifier (directs + enfants des boîtes) - final equipmentIds = newEquipment.map((e) => e.equipmentId).toList(); - final equipmentNames = {}; + final allConflicts = >{}; - // Ajouter les équipements directs + // 1. Vérifier les conflits pour les équipements directs for (var eq in newEquipment) { final equipment = allEquipment.firstWhere( (e) => e.id == eq.equipmentId, @@ -199,10 +171,51 @@ class _EventAssignedEquipmentSectionState extends State 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) { + allConflicts[eq.equipmentId] = conflicts; + } + } } - // Ajouter les équipements des conteneurs (par composition) + // 2. Vérifier les conflits pour les boîtes et leur contenu for (var containerId in newContainers) { final container = allContainers.firstWhere( (c) => c.id == containerId, @@ -217,76 +230,105 @@ class _EventAssignedEquipmentSectionState extends State allEquipment.firstWhere( + (e) => e.id == eqId, + orElse: () => EquipmentModel( + id: eqId, + name: 'Inconnu', + category: EquipmentCategory.other, + status: EquipmentStatus.available, + parentBoxIds: [], + maintenanceIds: [], + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + )) + .toList(); - final equipment = allEquipment.firstWhere( - (e) => e.id == childEquipmentId, - orElse: () => EquipmentModel( - id: childEquipmentId, - name: 'Inconnu', - category: EquipmentCategory.other, - status: EquipmentStatus.available, - parentBoxIds: [], - maintenanceIds: [], - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ), + // 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, ); - equipmentNames[childEquipmentId] = '${equipment.id} (dans ${container.name})'; + + // 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) { + allConflicts[containerId] = containerConflicts; + } } - // Vérifier les conflits pour TOUS les équipements (directs + enfants) - final conflicts = await _availabilityService.checkMultipleEquipmentAvailability( - equipmentIds: equipmentIds, - equipmentNames: equipmentNames, - startDate: widget.startDate!, - endDate: widget.endDate!, - excludeEventId: widget.eventId, - ); - - if (conflicts.isNotEmpty) { + if (allConflicts.isNotEmpty) { // Afficher le dialog de conflits final action = await showDialog( context: context, - builder: (context) => EquipmentConflictDialog(conflicts: conflicts), + builder: (context) => EquipmentConflictDialog(conflicts: allConflicts), ); if (action == 'cancel') { return; // Annuler l'ajout } else if (action == 'force_removed') { - // Identifier quels équipements retirer - final removedIds = conflicts.keys.toSet(); + // 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 dont au moins un équipement enfant est en conflit - final containersToRemove = []; - for (var containerId in newContainers) { - final container = allContainers.firstWhere((c) => c.id == containerId); - final hasConflict = container.equipmentIds.any((eqId) => removedIds.contains(eqId)); + // Retirer les boîtes en conflit + newContainers.removeWhere((containerId) => removedIds.contains(containerId)); - if (hasConflict) { - containersToRemove.add(containerId); - } - } - - for (var containerId in containersToRemove) { - newContainers.remove(containerId); - - // Informer l'utilisateur + // Informer l'utilisateur des boîtes retirées + for (var containerId in removedIds.where((id) => newContainers.contains(id))) { if (mounted) { - final containerName = allContainers.firstWhere((c) => c.id == containerId).name; + 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 "$containerName" a été retirée car elle contient du matériel en conflit.'), + content: Text('La boîte "${container.name}" a été retirée en raison de conflits.'), backgroundColor: Colors.orange, - duration: const Duration(seconds: 4), + duration: const Duration(seconds: 3), ), ); } @@ -299,8 +341,21 @@ class _EventAssignedEquipmentSectionState extends State e.equipmentId == eq.equipmentId)) { + final existingIndex = updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId); + + if (existingIndex != -1) { + // L'équipement existe déjà : mettre à jour la quantité + updatedEquipment[existingIndex] = EventEquipment( + equipmentId: eq.equipmentId, + quantity: eq.quantity, // Utiliser la nouvelle quantité + isPrepared: updatedEquipment[existingIndex].isPrepared, + isReturned: updatedEquipment[existingIndex].isReturned, + returnedQuantity: updatedEquipment[existingIndex].returnedQuantity, + ); + } else { + // L'équipement n'existe pas : l'ajouter updatedEquipment.add(eq); } } @@ -347,11 +402,6 @@ class _EventAssignedEquipmentSectionState extends State { ImageProvider? _profileImage; - String? _lastUrl; bool _isLoadingImage = false; @override @@ -44,7 +43,6 @@ class _UserCardState extends State { if (url.isNotEmpty) { setState(() { _isLoadingImage = true; - _lastUrl = url; }); final image = NetworkImage(url); image.resolve(const ImageConfiguration()).addListener( @@ -71,7 +69,6 @@ class _UserCardState extends State { setState(() { _profileImage = null; _isLoadingImage = false; - _lastUrl = null; }); } }